ブログ一覧

デプロイのたびに502が出る

· 約9分で読めます

「すぐ収まるから」と放っておかれがちなデプロイ中のエラーは、たいてい原因が1つではない。1台のPodが入れ替わる順に、原因を1つずつつぶしていく。

症状:デプロイのたびに、数十件の502

20台のPodで動くAPIを、ローリングデプロイで1台ずつ入れ替えている。デプロイのたびに502が数十件出て、デプロイ自体にも10分以上かかる。

原因は1つではない。Podが止まり、新しいPodが入り、その間にスキーマが変わり、最後に全員へ公開される。その各段階に、エラーの種がある。

1台のPodが入れ替わる流れ。この記事はこの順に進む

古いPodが止まる → 新しいPodが入る → スキーマが変わる → 全員に公開する

止まる:振り分けから外れる前に終わらない

KubernetesでPodを消すと、2つのことが同時に始まる。Podの停止(preStopを実行してからSIGTERM)と、振り分けの更新(宛先からPodを外し、各ノードやIngressが反映する)だ。2つは互いを待たない。

振り分けの反映には数秒かかる。SIGTERMを受けてすぐ終了したPodには、外れるまでの数秒間、リクエストが届き続けて接続エラーになる。

Podを消したときの、正しい終了の順番
  1. Kubernetes: Podの削除を始める
  2. ロードバランサー: 宛先から外す(反映に数秒)
  3. Pod: preStopで5秒待つ。届いた要求は処理する
  4. Kubernetes: SIGTERMを送る
  5. Pod: 受付を止め、処理中の要求を終える
  6. Pod: DBの接続を閉じて終了する

待ち時間の合計は、強制終了までの猶予(terminationGracePeriodSeconds、既定30秒)に収める。猶予にはpreStopの時間も含まれるので、preStopで5秒、処理中の要求に最大20秒なら収まる。

YAML:preStopで待ってからSIGTERMを受ける
spec:
  terminationGracePeriodSeconds: 30   # preStopの時間も含む
  containers:
    - name: api
      lifecycle:
        preStop:
          exec:
            # 宛先から外れたことが反映されるまで待つ
            command: ["sleep", "5"]
Go:SIGTERMで受付を止め、処理中の要求を待つ
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM)
defer stop()
go srv.ListenAndServe()

<-ctx.Done() // preStopが終わってからSIGTERMが届く
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx) // 受付を止め、処理中の要求を最大20秒待つ
db.Close()

止まらない:SIGTERMがアプリに届いていない

終了処理を書いたのに、1台がちょうど30秒ほどで止まる。この「ちょうど猶予の時間」が手がかりだ。アプリはSIGTERMを受け取らず、猶予が切れたところでSIGKILLされている。

原因は、ENTRYPOINTをシェル形式で書いたことだ。PID 1が /bin/sh になり、シェルはSIGTERMをアプリに中継しない。書いた終了処理は、一度も動いていなかった。

Dockerfile:シェル形式とexec形式
# シェル形式:PID 1は /bin/sh。SIGTERMがアプリに届かない
ENTRYPOINT node server.js

# exec形式:アプリがPID 1になり、SIGTERMを直接受け取る
ENTRYPOINT ["node", "server.js"]
20台を1台ずつ入れ替えるデプロイにかかる時間
  • シェル形式(毎回30秒待ってSIGKILL): 10分以上
  • exec形式(約1秒で停止): 約2分

PID 1は、ハンドラーのないシグナルを無視する。アプリにSIGTERMのハンドラーを書くことも忘れない。子プロセスを生むアプリなら、tiniのような小さなinitをPID 1にする。

入る:ヘルスチェックの深さを間違えない

新しいPodは、準備が終わるまで振り分けに入れない。それがレディネスプローブの役目だ。maxUnavailable: 0、maxSurge: 1 にすれば、新しいPodの準備ができてから古いPodを止めるので、入れ替え中も台数は減らない。

危ないのは、ヘルスチェックが深すぎる場合だ。20台のライブネスプローブが毎回DBに問い合わせ、1秒でタイムアウトする設定だったとする。夜間バッチでDBが2秒かかるようになると、全Podが同時に再起動し、一斉にDBへ接続して、サービス全体が止まる。

プローブ失敗したら見てよいもの見てはいけないもの
ライブネス再起動プロセスの中(デッドロックなど)DBなど外の依存先
レディネス振り分けから外すそのPod固有の準備全台が共有する依存先

全台に共通する依存先の状態は、外すか再起動するかの判断ではなく、監視とアラートで見る。

まとめて止まる:ノードの入れ替え

Podはデプロイ以外でも止まる。クラスターの更新でノードをdrainするときだ。3つのPodのうち2つが同じノードにあると、2つが同時に退避され、残り1つに全リクエストが集まる。

PodDisruptionBudget(PDB)で、同時に止めてよい数を決める。ただし0にすると退避が進まず、待ちきれなくなったマネージドのサービスが強制的に退避することがある。レプリカが1つのサービスは、PDBで止めるのではなくレプリカを増やす。

YAML:同時に止めてよいのは1台まで
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api
spec:
  maxUnavailable: 1
  selector:
    matchLabels:
      app: api

PDBは「何台まで止めるか」、終了処理は「1台をどう止めるか」を決める。両方そろって、ノードの入れ替えでもエラーが出なくなる。

新旧が同時に動く:スキーマは段階に分ける

ローリングデプロイの数分間は、古いアプリと新しいアプリが同じDBを使う。ここで name 列を full_name に改名すると、まだ残っている古いPodが name を読めずに500を返す。止め方をどれだけ直しても、このエラーは消えない。

変更を、新旧どちらのアプリでも壊れない段階に分ける(expand-contract)。

リリースDBアプリ
1full_name 列を追加変更なし
2—name と full_name の両方に書く
3既存の行を小分けにバックフィル両方に書く
4—full_name を読む
5—full_name だけを読み書き
6name 列を削除—

どのリリースの途中でも、1つ前のアプリが動いて困らないかを確かめる。列を足すALTERにも lock_timeout を付ける。

公開する:全員の前に、一部で確かめる

ここまでで、正しいコードならエラーなく入れ替えられる。残るのは、コードそのものが間違っている場合だ。決済画面の改修を全台に出した直後にエラー率が5%に上がり、戻すまでの15分間、全員が影響を受けた。

障害の影響は、だいたい影響を受ける割合 × 戻すまでの時間で決まる。

障害の影響(影響を受ける割合 × 時間、対数目盛)
  • 全台に出す: 100% × 15分
  • 1%のカナリア: 1% × 15分
  • 1%のカナリア+自動停止: 1% × 5分

見直すときのチェックリスト

  1. preStopで数秒待ってから、受付の停止→処理中の要求の完了→接続の切断の順に終わるか。
  2. ENTRYPOINTはexec形式か。SIGTERMから終了までの時間を測ったか。
  3. ライブネスプローブは、プロセスの中だけを見ているか。
  4. 入れ替え中も台数が減らない設定か。
  5. 複数のレプリカを持つサービスにPDBがあり、Podが分散しているか。
  6. スキーマ変更は、新旧どちらのアプリでも動く段階に分けているか。
  7. 新しいバージョンを一部から出し、指標で自動的に止められるか。

面接で「無停止でデプロイするには?」と聞かれたら

「ローリングデプロイにします」で終わらせず、1台が入れ替わる順に話す。

  1. 止まる側:振り分けから外れるのを待ってから、受付を止めて処理中の要求を終える
  2. 入る側:準備ができてから振り分けに入れ、ヘルスチェックで共有の依存先を見ない
  3. 新旧が同時に動く間:スキーマを追加・移行・削除に分ける
  4. コードの誤りに備えて:カナリアで一部に出し、指標で自動停止する

影響を「割合 × 時間」で見積もり、100分の1にできると数字で言えると、なぜその手間をかけるのかが伝わる。

この記事で深掘りしたTips