KeepAlive On な Apache+mod_php で HTTP/1.0 クライアントに HTTP/1.1 を返すとタイムアウトを待ってしまう

  • PHP 7.1.7
  • Apache 2.4.10
  • ApacheBench 2.3
  • zend-expressive 2.0.3
  • zend-expressive-skeleton 2.0.3
  • zend-diactoros 1.4.0
  • zend-stratigility 2.0.1

Docker で Apache+mod_php を実行して、

docker run --rm -p 8888:80 -v "$PWD:/var/www/html" php:apache

header 関数で HTTP/1.1 を指定するコードを配置して、

<?php
header('HTTP/1.1 200 OK');

次のように ab します。

ab -c 1 -n 1 http://localhost:8888/

すると、なんか異様なスコアになります(1回のリクエストに5秒もかかってる)。

Requests per second:    0.20 [#/sec] (mean)
Time per request:       5011.751 [ms] (mean)
Time per request:       5011.751 [ms] (mean, across all concurrent requests)

header 関数をコメントアウトしたり、

<?php
//header('HTTP/1.1 200 OK');

HTTP/1.0 を指定したり、

<?php
header('HTTP/1.0 200 OK');

Connection: close を指定したり、

<?php
header('HTTP/1.1 200 OK');
header('Connection: close');

Aapche Bench で KeepAlive を有効にすれば、

ab -k -c 1 -n 1 http://localhost:8888/

それっぽい結果になります。

Requests per second:    560.85 [#/sec] (mean)
Time per request:       1.783 [ms] (mean)
Time per request:       1.783 [ms] (mean, across all concurrent requests)

原因

Apache Bench はデフォで HTTP/1.0 なリクエストを送ります。なのでレスポンスの最後はサーバの方からクローズされることを期待します。

一方、php が header 関数で HTTP/1.1 を指定すると、Apache は元のリクエストが 1.0 だったとしても 1.1 を応答し、しかも KeepAlive On なら KeepAlive も有効になります。そのため KeepAliveTimeout で指定された秒数まで接続しっぱなしになります。

Apache Bench はサーバからのアクティブクローズを待つので KeepAliveTimeout の秒数が経過するまでリクエストが終わらなくなります。

Docker Hub の php:apache のイメージは KeepAlive ONKeepAliveTimeout 5 です。

curl だと発生しない

curl で HTTP/1.0 を指定してもこの現象は発生しません。

time curl -i -0 http://localhost:8888/
HTTP/1.1 200 OK
Date: Sun, 30 Jul 2017 08:54:12 GMT
Server: Apache/2.4.10 (Debian)
X-Powered-By: PHP/7.1.7
Content-Length: 0
Content-Type: text/html; charset=UTF-8


real    0m0.012s
user    0m0.003s
sys     0m0.004s

curl はリクエストを 1.0 で送ってもレスポンスが 1.1 なら 1.1 のクライアントとして振る舞うからだと思います。ので、Content-Length を元にレスポンスの終わりを判断できます。

なお、php のコードを次のようにすると、

<?php
header('HTTP/1.1 200 OK');
flush();

curl の応答は次のようになります。

time curl -i -0 http://localhost:8888/
HTTP/1.1 200 OK
Date: Sun, 30 Jul 2017 08:57:05 GMT
Server: Apache/2.4.10 (Debian)
X-Powered-By: PHP/7.1.7
Vary: Accept-Encoding
Transfer-Encoding: chunked
Content-Type: text/html; charset=UTF-8

real    0m0.014s
user    0m0.002s
sys     0m0.005s

この場合は Transfer-Encoding: chunked なので、チャンクサイズ 0 でレスポンスの終端が判断できます。

zend-expressive

この現象、zend-expressive を Docker Hub の php:apache で動かして ab したときのスコアがおかしなことになって気づきました。

zend-expressive で使われている zend-diactoros のレスポンスオブジェクトは普通に作るとリクエストの内容に依らず 1.1 で固定のためです。

パイプラインの最初のほうでリクエストのバージョンを元にレスポンスのバージョンを変更するミドルウェアを登録しておけば解決します。

// pipeline.php

use Psr\Http\Message\ServerRequestInterface;
use Interop\Http\ServerMiddleware\DelegateInterface;

$app->pipe(function (ServerRequestInterface $request, DelegateInterface $delegate) {
    return $delegate->process($request)->withProtocolVersion($request->getProtocolVersion());
});

nginx+php-fpm では発生しない

ちなみに nginx+php-fpm の構成では発生しませんでした。

nginx の場合は php-fpm が 1.1 を返してきたとしても、リクエストのバージョンが 1.0 なら Connection: close を付与したうえでサーバからアクティブクローズするようです。

結局どこが悪いの?

現代の PHP は http_response_code があるのでレスポンスコードを指定するためだけにプロトコルバージョンまで付けて header 関数を使うべきでは無いと思いますけれども、zend-diactoros は header 関数でステータスコードを指定しています。ただ、PSR-7 の ResponseInterfacegetProtocolVersion/withProtocolVersion を持っているので、レスポンスの送信時にプロトコルバージョンも指定するのは至って自然な気がします。

なので、zend-expressive でレスポンスのプロトコルバージョンがリクエストのプロトコルバージョンに基いていないのがダメなのだろうか・・・?

でも、HTTP/1.0 のリクエストに対して HTTP/1.1 のレスポンスを返すこと自体は合法なようです。

ただ、サーバが「おれ HTTP/1.1 でも大丈夫だよ」とクライアントに伝えるためであって、KeepAlive などの HTTP/1.1 の機能は有効にするべきではない、気がする、ので、Apache が悪いような気もする。

あるいは zend-expressive が(あるいは mod_php が) HTTP/1.0 なリクエストに対しては Connection :close でレスポンスを返すべき?