nginx ちょっと不思議だったリクエストリトライのお話

こんにちは、Sustain チームの山口です。

サイボウズリモートサービスというVPN・中継サービスで使用する L7LB を nginx に移行しようといろいろ調査をしています。

nginx をリバースプロキシとして使用する際に少々障害となる動作があり、それに関するアップデートが最近あったので、今回はそのお話です。

3行で分かる本記事の内容

  • nginx をリバースプロキシとして使う場合、リクエストがバックエンドサーバに二重で飛ぶ可能性がある
    • 対策として proxy_request_buffering off が使えそうだが、使えない
  • nginx 1.9.13 で仕様変更が入り、POST, LOCK, PATCH メソッドのリクエストは二重で飛ばないことが保証された

nginx の受動的な health check とリクエストのリトライ動作

nginx はリバースプロキシとして使う場合に、バックエンドサーバの生存確認手段として基本的に受動的な health check を採用しています。

nginx はバックエンドサーバの生存確認を自分からは行いません。クライアントから来たリクエストを設定されたバックエンドサーバに送ってレスポンスを待ちます。
レスポンスが返ってこなかった場合、送った先のバックエンドサーバが利用できない状態であると見なして、一定期間バランシング対象から外します。
その場合、リクエスト自体は別のバックエンドサーバに送りなおすことでレスポンスが得られるようにしています。この送りなおす動作をリトライと呼んでいます。

例えば upstream に以下のような設定をした場合、

upstream backend_server {
    server backend_a;
    server backend_b;
}
  1. リクエストを nginx が受け付ける
  2. nginx が backend_a(もしくは backend_b)にリクエストを送信する
  3. error/timeout などで backend_a からレスポンスが得られない場合は backend_b へリクエストをリトライする
    • backend_a はバランシング対象から外れている
  4. backend_b から返ってきたレスポンスをクライアントに返す
    • もしすべてのバックエンドサーバがだめだった場合は、502/504 を返す

という動作をします。

リトライの問題点 : リクエストが二重に送られてしまう可能性

ただしこの場合、

  • バックエンドサーバでリクエストの処理に時間がかかった
    • proxy_read_timeout など
  • 処理は終わっていたのに nginx にレスポンスが返せなかった
    • レスポンス受信中のエラーなど

などでも nginx がリトライしてしまい、同じリクエストが複数回バックエンドサーバに届く可能性があります。

proxy_next_upstream

error
an error occurred while establishing a connection with the server, passing a request to it, or reading the response header;

timeout
a timeout has occurred while establishing a connection with the server, passing a request to it, or reading the response header;

*1

nginx では connection 時の error/timeout、リクエスト送信時の error/timeout、レスポンス読み込み時の error/timeout を区別する方法がないため、上記のような問題が起こります。

ずいぶん前から指摘されている問題なのですが、なかなか改善されませんでした。

二重リクエストは proxy_request_buffering off では防げない

1.9.12 以前の場合、これを防ぐために何か良い設定はないかとドキュメントを探すと目につくのは以下の設定ではないかと思います。

proxy_request_buffering

When buffering is disabled, the request body is sent to the proxied server immediately as it is received. In this case, the request cannot be passed to the next server if nginx already started sending the request body.

リクエストボディの送信が始まった後ならリクエストはリトライできない!?
GET はムリでもリクエストボディがあるリクエストはリトライしないように出来ると!?
冪等でない POST リクエストが二重リクエストにならないのを保証できるのなら、とてもありがたい!

…と一見よさそうに見えますが、結論から言うと残念ながらこの設定では二重リクエストを完全に防ぐことはできません。 確かにリクエストがバッファリングされない場合はリトライしないというのはドキュメント通りなのですが、強制的にバッファリングしてしまう場合が存在するためです。

例えばリクエストボディを preread で読み切れるような小さなリクエストボディのリクエストの場合は、request buffering が強制的に on になります。 (=proxy_request_buffering offが無効になる)

該当箇所はここです。

    if (rb->rest == 0) {
        /* the whole request body was pre-read */
      ...
        r->request_body_no_buffering = 0;

        post_handler(r);

        return NGX_OK;
    }

他にも proxy_request_buffering が強制的に on になる条件がいくつかあります。

  • chunked-transfer-encoding なリクエストに対して、バックエンドサーバへの通信で HTTP/1.1 ではない場合
  • HTTP/2 が有効な場合
    • nginx 1.9.14 で HTTP/2 でも proxy_request_buffering off が有効化するようになりました
  • Content-Length ヘッダがなく、かつ chunked-transfer-encoding でもない場合
  • proxy_set_body もしくは proxy_pass_request_body が off の場合
    • そもそもこれらを有効にした場合に request_buffering を off にする必要あるのかという話ですが。

などです。(多分他にもまだあります)

proxy_request_buffering が on になった場合は従来通りの動作となり、結果としてリクエスト送信前/後のどちらでもリトライによる二重リクエストが発生してしまいます。

proxy_request_buffering を二重リクエスト阻止のために設定するのには例外が多く、やめておいた方がよさそうです。

他にも upstream_connect_time を Lua で比較する、proxy_connect_timeout を調整するなども検討しましたが、結局のところ nginx 1.9.12 以下でリトライ動作を完全に制限することはできませんという結論に至りました。

1.9.13 でのリトライの仕様変更

1.9.13 (もう mainline-1.9.15 まで出てますし、stable-1.10.0 になりましたが) では UDP のサポートが大きな話題として挙げられますが、同時にこのバージョンで上で書いたリクエストリトライの動作に仕様変更が入りました。

nginx change log

*) Change: non-idempotent requests (POST, LOCK, PATCH) are no longer passed to the next server by default if a request has been sent to a backend; the "non_idempotent" parameter of the "proxy_next_upstream" directive explicitly allows retrying such requests.

冪等ではないメソッド(POST, LOCK, PATCH)のリクエストは一度バックエンドサーバに送ったら、request buffering の有無にかかわらずリトライしないようになりました。

http://hg.nginx.org/nginx/rev/91c8d990fb45

+    if (u->request_sent
+        && (r->method & (NGX_HTTP_POST|NGX_HTTP_LOCK|NGX_HTTP_PATCH)))

リクエスト送信後のフラグと POST, LOCK, PATCH での条件文が組まれていて、リトライしないようになっています。
ここの時点で request_sent のフラグが立っていれば、POST, LOCK, PATCH のリクエストはリトライしないため、requet 送信後とそれ以外で動作を分けることが可能になります。(正確にはリクエストボディの送信前後)

  1. リクエストを nginx が受け付ける
  2. nginx が backend_a (もしくは backend_b)にリクエストを送信する
  3. error/timeout などで backend_a からレスポンスが完了しない場合
    • request 送信済なら
      • POST, LOCK, PATCH は 502/504 を返す
      • それ以外は backend_b にリトライする
    • request 送信前なら
      • backend_b にリトライし、返ってきたレスポンスをクライアントに返す
    • もしすべてのバックエンドサーバがだめだった場合は 502/504 を返す

デフォルトの動作が変わっているため、逆に1.9.12以前のようなどんなメソッドでもリトライする動作にしたい場合は proxy_next_upstream non_idempotent を設定する必要があります。

おわりに

今回の件は不思議な仕様(というか実装)でかなり悩まされましたが、運よくアップデートが入ったので一件落着しました。細かい上にあまり起きない事例ではありますが、しっかり調査・検証した上で運用していきたいと思います。

今回のこの記事が少しでも皆様の参考になれば幸いです。

参考

注記) nginx で能動的に health check する

バックエンドサーバが生存しているかどうかの能動的なヘルスチェックが出来るなら、接続できるバックエンドサーバのみにリクエストを分配できるので、 proxy_next_upstream error を切ってしまい、エラー時のリトライを off にしてしまえば、それで大丈夫なはずです。(有料版の NGINX Plus や 3rdPartyModule を使用すればできます)
今回は別の都合上 3rdPartyModule を追加できない状況で、かつバージョンアップで対応できそうなので上記の検証は行っていません。

*1:2016/05/09追記
proxy_next_upstream を off にすることでリトライそのものをしないように設定することもできます。 ただしこの設定では connection error/timeout 時もリトライしなくなってしまいます。
例えばバックエンドサーバの一部がダウンした時に、他に稼働しているバックエンドサーバがあるのに、500 を返す場合があるという別の問題が発生します。 そのため今回は使用を見送りました。