curl で相手が突然死すると無限に待ってシグナルでも死なない件

なんで今さら ZF1 やねんという話ですが、Zend_Http_Client_Adapter_Curltimeout は接続のタイムアウトなので、Read/Write も含めたタイムアウトである request_timeout が未指定だと接続後に相手が突然死すると無限に待ちます。

ネットワーク経路のステートフルなファイアウォールとかが RST なりなんなり返すならそうでもないと思いますけど、そういうのが中間に入っていなくてかつサーバが RST も返すことなく突然死したりあるいは経路上のネットワークがぶつ切りになったりしたときなどです。

https://github.com/zendframework/zf1/blob/release-1.12.20/library/Zend/Http/Client/Adapter/Curl.php#L224-L242

curl のオプションとは次のように対応しています。

  • timeout -> CURLOPT_CONNECTTIMEOUT
  • request_timeout -> CURLOPT_TIMEOUT

とあるシステムでこれが原因で PHP のプロセスがいつまでも終わらずにいつまでも実行し続けてしまう問題が発生しました。しかたないので TERM シグナルで殺そうとしたところ・・死なない。

実はそのプロセスは簡易なデーモンになっていて TERM シグナルをハンドリングしていたんですね。 PHP の pcntl_signal で指定するハンドラは本当のシグナルハンドラとして実行されるわけではないので curl の中で止まっているとシグナルを撃っても PHP のシグナルハンドラは呼ばれません。

PHP のシグナル周りは これ とか これ とか が詳しいです。

ただ、I/O 待ちでシグナルを受ければ curl も EINTR とかで中断されてエラーになって PHP まで戻ってきそうな気がしますが・・・

うーん?

https://github.com/curl/curl/blob/curl-7_64_0/lib/select.c#L211-L230

  if(error && ERROR_NOT_EINTR(error))
    break;

https://github.com/curl/curl/blob/curl-7_64_0/lib/select.c#L57

#define ERROR_NOT_EINTR(error) (Curl_ack_eintr || error != EINTR)

https://github.com/curl/curl/blob/curl-7_64_0/lib/easy.c#L266-L267

  if(flags & CURL_GLOBAL_ACK_EINTR)
    Curl_ack_eintr = 1;

PHP からこのフラグを指定する術は無さそうです。ので、PHP で curl がネットワーク I/O 待ちになったとき、シグナルハンドラを仕込んでいるとそのシグナルでは死ななくなります。

下記のようなコードで確認できます。シグナルが撃たれてもリクエストが完了するまで待たされます。

<?php
pcntl_async_signals(true);

$curl = curl_init();
curl_setopt_array($curl, [
    CURLOPT_URL => 'http://httpbin.org/delay/10',
]);

pcntl_signal(SIGTERM, function ($signo) {
    var_dump($signo);
});

$mypid = getmypid();
putenv("mypid=$mypid");
system('(sleep 1 && kill -- "$mypid") </dev/null >/dev/null 2>&1 &');
curl_exec($curl);

なお、シグナルハンドラをコメントアウトすればシグナルで死ぬようになります。curl が EINTR をどう扱っているかにせよ PHP からシグナルハンドラを設定していないのならシグナルでプロセスが死ぬからです。

さいごに

curl あるいは curl を使用する HTTP クライアントライブラリを使うときは CURLOPT_CONNECTTIMEOUT だけでなく CURLOPT_TIMEOUT も指定しないと意図せず無限に待ち続けてしまうことがあるかもしれないので注意。

ただ、curl を使う PHP の HTTP クライアントライブラリは大抵は普通にタイムアウトを設定すれば CURLOPT_TIMEOUT が設定されるようです。

CURLOPT_CONNECTTIMEOUT を指定するのは connect_timeout とか(Guzzle)、connecttimeout とか(ZF2)、いかにもそれっぽい名前になっていることが多いようです。