◆ Node.js から送信した POST のペイロードを PHP で受け取れない
  ◆ 受け取れないときは PHP 側で受け取るヘッダーで Transfer-Encoding: chunked になってる
  ◆ 受け取れてるときは Content-Length になってる
◆ 発生条件が安定しなくて console.log の有無だけで変わったりする

サーバーで POST のペイロードが受け取れない

Docker の almalinux:9 のイメージに httpd と php パッケージをインストールし /var/www/html に↓を配置します

<?php

header("content-type: application/json");

echo json_encode(
[
'post' => $_POST,
'headers' => getallheaders(),
],
JSON_PRETTY_PRINT
);

名前はとりあえず a.php にして /a.php へのアクセスで実行されるようにします
POST したデータとヘッダーをレスポンスとして返します

これをサーバーとします

クライアントは Node.js です
http.request で POST します

const http = require("http")
const stream = require("stream")

const file_size = 1024 * 10
const use_stream = true
const stream_chunk_size = 8000
const log = true

const payload =`
------WebKitFormBoundary7KvCNMsEQGTuSHn7
Content-Disposition: form-data; name="a"

1
------WebKitFormBoundary7KvCNMsEQGTuSHn7
Content-Disposition: form-data; name="b"; filename="name"
Content-Type: application/octet-stream

${"0".repeat(file_size)}
------WebKitFormBoundary7KvCNMsEQGTuSHn7--
`.trim().replaceAll("\n", "\r\n")

const headers = {
"Content-Type": "multipart/form-data; boundary=----WebKitFormBoundary7KvCNMsEQGTuSHn7",
}

const request = http.request({
hostname: "172.17.0.4",
port: 80,
path: "/a.php",
method: "POST",
headers,
}, (response) => {
console.log(response.statusCode)

const buffers = []
response.on("data", (chunk) => {
buffers.push(chunk)
})
response.on("end", () => {
console.log(Buffer.concat(buffers).toString())
})
})

if (use_stream) {
stream.Readable.from(function* () {
const buf = Buffer.from(payload)
for (let index = 0; index < buf.length; index += stream_chunk_size){
const chunk = buf.slice(index, index + stream_chunk_size)
if (log) {
console.log(chunk.length)
}
yield chunk
}
}()).pipe(request)
} else {
request.end(payload)
}

上の方の変数で stream で送信するかどうかや ファイルサイズや console.log の有無を変更できます
このスクリプトでさっきのサーバーにリクエストを送信します

stream を使わない場合は特に問題なく普通に送信できます
送信するデータは a と b で a は文字列なので post に含まれます
b はファイルなので含まれません

200
{
"post": {
"a": "1"
},
"headers": {
"Content-Length": "10516",
"Connection": "close",
"Host": "172.17.0.4",
"Content-Type": "multipart\/form-data; boundary=----WebKitFormBoundary7KvCNMsEQGTuSHn7"
}
}

stream を使うとなぜか a も含まれなくなります
しかし conole.log を入れると含まれます

root@a7d5c251940a:/opt# node a.js
200
{
"post": [],
"headers": {
"Transfer-Encoding": "chunked",
"Connection": "close",
"Host": "172.17.0.4",
"Content-Type": "multipart\/form-data; boundary=----WebKitFormBoundary7KvCNMsEQGTuSHn7"
}
}

root@a7d5c251940a:/opt# node a.js
8000
2516
200
{
"post": {
"a": "1"
},
"headers": {
"Content-Length": "10516",
"Connection": "close",
"Host": "172.17.0.4",
"Content-Type": "multipart\/form-data; boundary=----WebKitFormBoundary7KvCNMsEQGTuSHn7"
}
}

意味がわかりませんね
ヘッダーの方を見ると 届いてるときは Content-Length があって届いてないときは Transfer-Encoding がついてます
この違いで POST データが読み取れるか変わってそうです

安定してない

このときは console.log で出力を切り替えると結果が変わり 何度か実行しても console.log すると POST データが読み取れる結果でした
しかし 翌日になって同じ環境で試すと console.log があってもなくても届いていたり 安定してません
また ループにせず手動で 3 分割にして yield を 3 つ書くようにするなど書き方を変えるだけでも発生したりしなかったりが切り替わりました

発生するしないの傾向としては 処理時間でしょうか?
読み取れているときは余計な処理が多く 時間がかかりそうなもの(と言っても数 ms ~数十 ms 程度) で シンプルにしたときに読み取れていないです
遅くなるとサーバー側で最後まで読み取らず読み取りを完了し 不正なデータなので捨てるというならわからなくはないですが 逆です
遅くなったほうが届くというのは謎です

確実に発生するものでもないですし ネットワーク関係が絡みそうで Node.js/Apache/PHP-FPM と関係するツールも多く あまり深く調べる気も起きないです
いつか気が向いたときに調べるかもしれないのでメモ代わりに残しておきます

サーバー側の問題?

そもそも Content-Length であろうと Transfer-Encoding: chunked であろうと POST データは正しく送信できるはずです

実際この問題は最初に書いた方法で用意したサーバーでしか発生していません
PHP でも Docker の php イメージで apache を使う環境を用意したり Node.js サーバーにすると chunked でも特に受け取れないということはないです
PHP-FPM が chunked に対応してないなんてことはさすがにないでしょうし なにかのバグなんでしょうか?

ウェブサーバーを経由

最初に見つけたときは Node.js のウェブサーバーで PHP サーバーにプロキシしていてその時に発生していました
こんな感じでリクエストを全部別サーバーに転送しています

const http = require("http")

http.createServer((req, res) => {

console.log("--- REQ", req.method, req.url, req.headers)

const headers = req.headers
delete headers["content-length"]

const request = http.request({
hostname: "172.17.0.4",
port: 80,
path: req.url,
method: req.method,
headers,
}, (response) => {
res.writeHead(response.statusCode, response.headers)
response.pipe(res)
})

if (req.method === "POST") {
req.pipe(request)
} else {
request.end()
}

}).listen(3000)

この Node.js サーバーへブラウザから こういう感じで fetch でリクエストします

const fd = new FormData()
fd.append("a", new Blob([new ArrayBuffer(1024 * 10)]), "name")
fd.append("b", "c")
fetch("", { method: "POST", body: fd }).then(res => res.text()).then(console.log)

このときは console.log は関係なく送信するデータ量で読み取れる読み取れないが変わりました
ArrayBuffer のところで 15 くらいが境界で 10 などの小さい数値だと受け取れて 20 などの大きい数値だと受け取れなかったです
15 だと受け取れたり受け取れなかったりでした

Content-Length を消さなければ chunked で送られないのでデータ量問わず受け取れるようです
プロキシするときにペイロードをそのまま転送するならサイズは変わらないので消す必要ないと思うのですが プロキシライブラリやツールなどを見てると消してる事が多いみたいだったので一応消すようにしました
その結果この問題が起きるようになったんですよね

サーバー側でペイロードを全部を受信してから一括して送信するなら 一度消しても送るときに再計算してセットできるものですが stream で送るときはヘッダーを送るタイミングで全体のサイズがわからないので Content-Length は省略するしかないです
一旦全部を受信する場合 何十 MB みたいな大きいペイロードの場合に全部をメモリに保持することになるので あまりしたくないです

その後

少し気になることもあったので tcpdump で実際の通信を見てみることにしました
すると興味深いことになってました

まず読み取れているときです

リクエスト
06:03:11.206138 IP (tos 0x0, ttl 64, id 58695, offset 0, flags [DF], proto TCP (6), length 7292)
a7d5c251940a.60716 > 172.17.0.4.http: Flags [P.], cksum 0x7498 (incorrect -> 0x493f), seq 1:7241, ack 1, win 502, options [nop,nop,TS val 2704784398 ecr 3389519049], length 7240: HTTP, length: 7240
POST /a.php HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7KvCNMsEQGTuSHn7
Host: 172.17.0.4
Connection: close
Transfer-Encoding: chunked

1f40
------WebKitFormBoundary7KvCNMsEQGTuSHn7
Content-Disposition: form-data; name="a"

1
------WebKitFormBoundary7KvCNMsEQGTuSHn7
Content-Disposition: form-data; name="b"; filename="name"
Content-Type: application/octet-stream

000...(略)

レスポンス
06:03:11.214379 IP (tos 0x0, ttl 64, id 2676, offset 0, flags [DF], proto TCP (6), length 516)
172.17.0.4.http > a7d5c251940a.60716: Flags [P.], cksum 0x5a20 (incorrect -> 0xe7b4), seq 1:465, ack 10710, win 501, options [nop,nop,TS val 3389519062 ecr 2704784399], length 464: HTTP, length: 464
HTTP/1.1 200 OK
Date: Sat, 04 Mar 2023 06:03:11 GMT
Server: Apache/2.4.53 (AlmaLinux)
X-Powered-By: PHP/8.0.27
Connection: close
Transfer-Encoding: chunked
Content-Type: application/json

100
{
"post": {
"a": "1"
},
"headers": {
"Content-Length": "10516",
"Connection": "close",
"Host": "172.17.0.4",
"Content-Type": "multipart\/form-data; boundary=----WebKitFormBoundary7KvCNMsEQGTuSHn7"
}
}
0


次は読み取れてないときです

リクエスト
06:04:56.720069 IP (tos 0x0, ttl 64, id 60438, offset 0, flags [DF], proto TCP (6), length 7292)
a7d5c251940a.60720 > 172.17.0.4.http: Flags [P.], cksum 0x7498 (incorrect -> 0xee38), seq 1:7241, ack 1, win 502, options [nop,nop,TS val 2704889912 ecr 3389624564], length 7240: HTTP, length: 7240
POST /a.php HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7KvCNMsEQGTuSHn7
Host: 172.17.0.4
Connection: close
Transfer-Encoding: chunked

13880
------WebKitFormBoundary7KvCNMsEQGTuSHn7
Content-Disposition: form-data; name="a"

1
------WebKitFormBoundary7KvCNMsEQGTuSHn7
Content-Disposition: form-data; name="b"; filename="name"
Content-Type: application/octet-stream

000...(略)

レスポンス
06:04:56.722313 IP (tos 0x0, ttl 64, id 44168, offset 0, flags [DF], proto TCP (6), length 498)
172.17.0.4.http > a7d5c251940a.60720: Flags [P.], cksum 0x5a0e (incorrect -> 0x948d), seq 1:447, ack 102872, win 133, options [nop,nop,TS val 3389624570 ecr 2704889914], length 446: HTTP, length: 446
HTTP/1.1 200 OK
Date: Sat, 04 Mar 2023 06:04:56 GMT
Server: Apache/2.4.53 (AlmaLinux)
X-Powered-By: PHP/8.0.27
Connection: close
Transfer-Encoding: chunked
Content-Type: application/json

ef
{
"post": [],
"headers": {
"Transfer-Encoding": "chunked",
"Connection": "close",
"Host": "172.17.0.4",
"Content-Type": "multipart\/form-data; boundary=----WebKitFormBoundary7KvCNMsEQGTuSHn7"
}
}
0


どっちもリクエストヘッダーは Transfer-Encoding: chunked です
しかし サーバーの PHP で認識されてるリクエストヘッダーは異なっています
受け取れているときに Transfer-Encoding: chunked はなく Content-Length になっています

Node.js は常に chunked で送っていて Apache が勝手に Content-Length に変換する時があるということみたいです
変換されたときはペイロードを読み取れています
ペイロードの読み取りに失敗した結果 変換が行われなかったのかもしれません

Nginx + PHP-FPM

Nginx だとどうなるんだろうと クライアント側の Node.js と PHP-FPM はそのままで Apache を Nginx に置き換えてみました

すると結果はこうなりました

8000
2516
200
{
"post": {
"a": "1"
},
"headers": {
"Transfer-Encoding": "chunked",
"Connection": "close",
"Host": "172.17.0.4:9001",
"Content-Type": "multipart\/form-data; boundary=----WebKitFormBoundary7KvCNMsEQGTuSHn7",
"Content-Length": "10516"
}
}

ファイルサイズや console.log の有無等いろいろ変えましたが 同じでちゃんと受け取れます
リクエストヘッダーを見ると Transfer-Encoding: chunked と Content-Length の両方が入ってます
実際に送ってるのは Transfer-Encoding: chunked の方なので Content-Length が Nginx によって追加されてるみたいです
仕様的には両方指定して送ってはいけないになってるはずなので Content-Length を入れるなら Transfer-Encoding: chunked を消す Apache の動きのほうが正しいのかもしれません

PHP-FPM との通信内容は把握していませんが POST のペイロードを含むリクエストを全部 Apache/Nginx で受け取ってから PHP-FPM を呼び出す感じなのでしょうか
それだと Content-Length の数値は分かる状態なので PHP 側で参照できたほうが便利なので追加してるというのはわからなくないです

ちなみに PHP-FPM を使わず Apache のモジュールの場合は Content-Length は追加されず Transfer-Encoding: chunked のみです
それでちゃんと読み取れています

たまたま使った環境が Apache+PHP-FPM だっただけで 基本 Nginx で Nginx+PHP-FPM だと発生しないようですし もう見なかったことにしようかと思います