TT Lab
はじめる
学ぶ 学習パス コース

Tomcat & nginxの運用

サーバを二台置くことと障害に耐えることは違う

TT Labで続きを見る

一言でいうと

負荷分散の第一の目的は、リクエストを半分ずつ分けることではなく、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つのことが導かれます。

何を失敗として数えるかは、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台のサーバーに偏ります。

優先順位は、次の順序です。

  1. セッションをサーバーの外に出す: RedisやDBに置けば、どのサーバーが受けてもかまいません。
  2. 状態をトークンに入れる: 署名されたJWTなら、サーバーは何も記憶していなくてもかまいません。
  3. それでもだめなら固定する: このときも、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が実際に何回の失敗のあとに動作するかを確認します。最後に、アルゴリズムごとに、いつ使い、いつ避けるべきかを、運用基準として整理します。