サーバを二台置くことと障害に耐えることは違う
一言でいうと
負荷分散の第一の目的は、リクエストを半分ずつ分けることではなく、1台のサーバーの失敗が、サービス全体の失敗に広がらないようにすることです。そのため、選ぶべきものはアルゴリズムではなく、失敗にいつ気づき、いつ再び戻すかです。
なぜ必要なのか
バックエンドを2台立ち上げてnginxのupstreamに入れたからといって、高可用性が完成するわけではありません。死んだサーバーをいつ除外するか、接続を再利用するか、セッションをどこに保管するかを決めなければ、ユーザーはリクエストのたびに、成功と失敗を交互に見ることになります。
最もよくある事故は、次のように始まります。バックエンド1台が、デプロイ中の30秒間、500を返します。nginxはその事実を知らないので、半分をそちらに送り続けます。ユーザーの目には、「リロードすると、動いたり動かなかったりする」状態になります。モニタリングは平均値を見るので、エラー率50%が25%に薄まり、アラートも遅れます。
アルゴリズムは何を基準に選ぶのか
| 方式 | 分配の基準 | 向いている場面 | 注意点 |
|---|---|---|---|
round_robin(デフォルト) |
リクエストの順序 | 処理時間が均一なAPI | 遅いリクエストが混ざると、偏りが大きくなります |
least_conn |
処理中の接続数 | 応答時間がばらつく場面 | 接続が長く維持されるSSE・WebSocketでは、歪みます |
ip_hash |
クライアントIPのハッシュ | セッションをサーバーのメモリに置くレガシー | サーバーを追加・削除すると、割り当てが丸ごと変わります |
hash $key consistent |
任意のキーのコンシステントハッシュ | キャッシュのヒット率が重要な場面 | キー設計を誤ると、一方に偏ります |
weightは、サーバーの性能が違うときにだけ使います。同じスペックなのに重みを変えると、容量の計算がずれて、障害のときに一方が先に崩れます。
失敗に気づく方式が受動的であること
max_failsとfail_timeoutは、アクティブヘルスチェックではありません。実際のリクエストが失敗するのを観察して、サーバーを一時的に除外する方式です。ここから3つのことが導かれます。
- トラフィックがないと、障害も発見できません。明け方に死んだサーバーは、朝の最初のリクエストが犠牲になってから、除外されます。
- 判定は、ワーカープロセスごとに別々です。
worker_processes 4なら、各ワーカーが自分のカウンターを持ちます。max_fails=3なら、最悪の場合12回の失敗リクエストが出ていきます。 fail_timeoutが過ぎると、何の確認もなく再び戻します。サーバーがまだ復旧していなければ、また失敗し、この循環が続きます。
何を失敗として数えるかは、proxy_next_upstreamが決めます。デフォルト値はerror timeoutなので、バックエンドが返した500は失敗として数えられません。アプリケーションが死んで500を返しているのに、そちらに送り続ける理由がこれです。http_500 http_502 http_503を明示すると数えられます。ただし、ここにnon_idempotentを一緒に置かないとPOSTが2回実行されるおそれがあるので、リトライの対象は、必ず冪等なリクエストに限定します。
アップストリームのKeep-Aliveは、3つの条件が揃う必要がある
接続を再利用すると、TCPハンドシェイクとTLSネゴシエーションのコストがなくなります。ところが、3つすべてを行わないと有効になりません。1つでも欠けると、静かに毎回新しい接続を張ります。
upstream app {
server 127.0.0.1:8080;
server 127.0.0.1:8082;
keepalive 32; # 1) 워커당 유지할 유휴 연결 수
}
location / {
proxy_pass http://app;
proxy_http_version 1.1; # 2) 기본값 1.0 은 keep-alive 를 못 쓴다
proxy_set_header Connection "";# 3) 클라이언트가 보낸 Connection 헤더를 지운다
}
このコードブロックの韓国語コメントは、順に、ワーカーごとに保持するアイドル接続の数、デフォルトの1.0ではkeep-aliveを使えない、クライアントが送ったConnectionヘッダーを消す、という意味です。
keepaliveの値は「同時処理量」ではなく、「アイドル状態で残しておく接続の数」です。ワーカー数を掛けた値が、バックエンドの最大同時接続の設定を超えてはいけません。TomcatのmaxThreadsが200なのに、nginxのワーカー4つがそれぞれ100を維持すると、400個の接続がスレッドを待って積み上がります。
セッション固定は最後の手段
ip_hashで固定すると、サーバーを追加・削除した瞬間にハッシュ空間が変わり、かなりの数のユーザーが別のサーバーに再割り当てされ、セッションがそちらのメモリにないため、一斉にログアウトされます。デプロイのたびに、これが起きます。さらに、会社や学校のようにNATの内側にいるユーザーたちは、IPが同じなので、1台のサーバーに偏ります。
優先順位は、次の順序です。
- セッションをサーバーの外に出す: RedisやDBに置けば、どのサーバーが受けてもかまいません。
- 状態をトークンに入れる: 署名されたJWTなら、サーバーは何も記憶していなくてもかまいません。
- それでもだめなら固定する: このときも、
ip_hashよりCookieベースのstickyのほうがよいです。
現場での姿
障害がときどきしか見えないなら、まずアクセスログに$upstream_addrと$upstream_statusを残して、バックエンドごとに結果を分けます。
log_format lb '$remote_addr [$time_local] "$request" $status '
'$upstream_addr $upstream_status '
'$upstream_response_time $request_time';
読み方が重要です。$upstream_addrにアドレスがカンマで2つ出力されていたら、リトライが起きたということです(127.0.0.1:8080, 127.0.0.1:8082)。特定のアドレスでだけ5xxが出るなら、分配アルゴリズムより、デプロイや設定の違いを先に見る必要があります。逆に、すべてのアドレスが同時に遅ければ、共通のDBや下流のサービスを調査します。
$upstream_response_timeと$request_timeの差も、診断に使います。両者が近ければ、バックエンドが遅いのであり、$request_timeだけが大きければ、クライアント側のネットワークやレスポンスの転送が遅いのです。
次のラボですること
2つのバックエンドを自分で立ち上げて、ラウンドロビン、重み付け、セッション固定、失敗の除外、接続の再利用を比較します。$upstream_addrをログに残して、どちらに行ったかを数え、1台をわざと止めて、max_failsが実際に何回の失敗のあとに動作するかを確認します。最後に、アルゴリズムごとに、いつ使い、いつ避けるべきかを、運用基準として整理します。