prodnote.dev本番ノート

Index/07

docker ps が5日間 unhealthy だった。でもサイトは正常に動いていた

コンテナの状態表示がずっと赤いのに、サイトは問題なく見えていました。重すぎてタイムアウトしていると思ったら違って、実際には一度も成功できない設定になっていました。原因はリダイレクトと証明書です。ローカルでは再現しません。赤い表示を放置すると何が起きるかまで書きます。

Published
Tags
DockernginxHTTPS運用個人開発

docker ps を見ると、こうなっていました。

nginx      Up 5 days (unhealthy)
backend    Up 5 days (healthy)
postgres   Up 5 days (healthy)

nginx だけ赤い。 でもサイトは普通に見えていました。記事も表示され、画像も出て、HTTPS も効いています。誰も困っていません。

そのまま 5日間放置しました。

この記事は、放置してよかったのかと、実際の原因は何だったかの話です。 原因は、私が最初に疑ったものとは違いました。


まず疑ったこと ―「重すぎてタイムアウト」は外れました

設定はこうなっていました。

healthcheck:
  test: wget -q -O - http://127.0.0.1/
  interval: 5s
  timeout: 3s

トップページ全体を、5秒ごとに取得していました。 そのページは約100KBあります。そして制限時間は3秒でした。

約100KBを3秒以内に返せていないのだろう」と考えました。もっともらしい話です。

実際に叩いてみるまでは。

$ wget -q -O - http://127.0.0.1/
ssl_client: certificate verify failed
$ echo $?
1

タイムアウトではありませんでした。 証明書の検証に失敗していました。


原因は ssl_client: certificate verify failed — 301 と証明書でした

順に追うとこうなります。

1. wget が http://127.0.0.1/ を叩く        ← http(暗号化なし)
2. サーバーは「https で来てください」と返す ← 301 リダイレクト
3. wget が https://127.0.0.1/ を追いかける
4. 証明書を確かめる
   証明書に書いてある名前 : example.com
   いま接続している相手   : 127.0.0.1
   → 一致しない → 失敗

HTTPS を正しく設定したこと自体が、原因でした。

サイトを https に統一するために、http で来た人を全部 https へ送る設定を入れていました。その設定が、自分自身のヘルスチェックにも適用されていました。

つまり、重かったから失敗していたのではなく、構造的に一度も成功できませんでした。 5日どころか、設定した瞬間から一度も通っていません。


ローカルでは起きません

手元の開発環境では、ヘルスチェックは成功していました

理由は単純で、ローカルには https へのリダイレクトが無いからです。

ローカル : http://127.0.0.1/ → そのまま 200 → 成功
本番     : http://127.0.0.1/ → 301 → https → 証明書で失敗

同じ設定ファイルなのに、結果が逆になります。

「ローカルで動いたから大丈夫」が効かない典型です。 本番にだけある仕組み(この場合はHTTPS化)が、本番にだけある失敗を作ります。


直し方

2つ変えました。

① 軽いものを叩く

まず、叩く先を軽くします。ただし、これだけでは本番は直りません(理由は②で書きます)。

test: wget -q -O - http://127.0.0.1/api/health

約100KBのトップページ → 15バイトの確認用エンドポイントにしました。

そもそも、5秒ごとにトップページ全体を作らせていたのが無駄でした。 1日に換算すると17,000回以上です。自分で自分に仕事をさせていました。

② 本番だけ、証明書の検証を外す(いちばん手軽)

①の http のままでは、本番は直りません。 さっき書いた301リダイレクトは、どのパスにもかかるからです。

http://127.0.0.1/api/health → 301 → https://127.0.0.1/api/health → 証明書不一致 → 失敗

軽くはなりましたが、赤いままです。

# 本番用のファイルだけ
test: wget --no-check-certificate -q -O - https://127.0.0.1/api/health

最初から https で叩けば、リダイレクトが起きません。 そのうえで、127.0.0.1 に対する証明書の不一致を承知のうえで無視します。

ベースには①、本番用のファイルには②、という形になります。 同じ項目を、ファイルを分けて書き分ける話は別の記事に書きました。 書き分けたつもりで書けていないという失敗を、私はそこでもやっています。

docker-compose.prod.yml を書いたのに、本番が開発モードで動いていた

③ そもそもリダイレクトさせない(根本的)

②が必要になるのは、http で叩くとリダイレクトされるからでした。 確認用のパスだけリダイレクトの対象外にすれば、証明書がそもそも登場しません。

/api/health だけは https へ転送しない
  → http のまま 200 が返る
  → 証明書を確かめる場面が無い
  → 「外すかどうか」を判断する必要も無い

無料の証明書を、サーバーで http を受け取る方式で自動更新しているなら、同じ形の例外がもう入っているはずです。 更新のときにサーバーが /.well-known/acme-challenge/ を http で受け取る必要があるので、 その1本だけはリダイレクトから外してあります。 そこに1本足すだけです。

DNS のレコードで証明する方式を使っている場合は、この例外がありません。 探しても見つからなければ、そちらの方式かもしれません。

そして③にすると、ベースと本番で書き分ける必要がなくなります。 どちらも http://127.0.0.1/api/health のままでよくなるからです。書き分けないで済むなら、書き分けを間違えることもありません。

②のほうが手軽で、③のほうが確実です。 私は②で直しました。③に気づいたのは、これを書いていてです。


⚠️ 間隔を変えたら、回数も見直してください

ここで、もう1つ考えることがあります。

interval: 5s
retries: 20      # 20回連続で失敗したら unhealthy

5秒 × 20回 = 約100秒で異常と判断される設定でした。

間隔だけを 30 秒に伸ばすと、こうなります。

30秒 × 20回 = 10分

異常に気づくまで10分かかるようになります。 負荷は減りましたが、反応が鈍くなりました。

回数のほうも減らして、元と同じくらいに戻します。

interval: 30s
retries: 3       # 30秒 × 3回 = 約90秒

ところで、この2つは Docker の既定値と同じです。

interval  既定 30s
retries   既定 3

「5秒ごとに20回」は、私が自分で決めた値でした。 そして調整して戻ってきた先が、何も指定しなかったときの値でした。

既定値には理由があります。 変えるなら、変える理由のほうを説明できる必要があります。

片方だけ変えると、静かに別のものが悪くなります。 「軽くする」つもりの変更が、「気づくのが遅くなる」変更になっていました。

起動直後は starting と出ます

直したあと、起動してすぐ見ると health: starting と出て焦ります。

Docker の最初のチェックは、間隔の時間が経ってから走ります。 30秒間隔にしたので、しばらくは判定前です。異常ではありません。

⚠️ start_period を設定していると、その間だけ短い間隔で走るという仕組みが 新しめの Docker にあります。私は自分の環境でそこまで測っていませんので、 「最初の30秒は必ず判定前」とは書きません。

正確に知りたければ、記録を見てください。 さっきの docker inspect の出力に、 実行された時刻が1回ずつ入っています。


いちばん怖いのは、赤い表示に慣れることです

直したあとの表示です。

nginx      Up 17 seconds (healthy)
frontend   Up 23 seconds (healthy)
backend    Up 47 hours (healthy)

サイトの動きは、直す前と後で何ひとつ変わっていません。 直す前も正常に見えていました。

では何が変わったか。赤が意味を取り戻しました。

5日間 (unhealthy) を見続けた私は、その表示を「そういうもの」として扱うようになっていました。 もし6日目に本当に壊れていたら、同じ赤を見て、同じように無視していたはずです。

警告が常に出ている状態は、警告が無い状態より危険です。 無いなら「無い」と分かりますが、常にあるものは見えなくなります。

これは「アラート疲れ」と呼ばれる、よく知られた現象です。私の感想ではありません。鳴り続ける警報は、鳴らない警報より危険になる——病院の機器でも同じことが問題になっています。

個人開発のサーバーでも、同じことが起きます。 見ているのが自分ひとりなら、なおさらです。


まとめ

表示は赤い。でもサイトは動いている  → コンテナは止まらないので、動き続ける
疑ったこと(重すぎる)             → 外れた
実際                               → 301 → https → 証明書の名前が一致しない
ローカルでは                       → リダイレクトが無いので成功する
直し方                             → 軽いものを叩く + 本番だけ検証を外す
一緒に見直す                       → 間隔を変えたら回数も

このサイトでは「動いているのに壊れている」話を何度か書きました。 これはその逆で、壊れていないのに壊れて見えていた話です。

どちらも、表示と実態がずれています。 そしてずれたまま放置すると、表示が使えなくなります。

次にやること

いま何かをコンテナで動かしているなら、この順に見てください。

1. 赤いものがあるか

docker compose ps

2. その理由は、もう記録されています

docker inspect <コンテナ名> --format '{{json .State.Health}}'

コンテナ名は docker compose ps の出力に出ています。Compose が付ける名前は <プロジェクト名>-<サービス名>-<番号>(例 myapp-nginx-1)で、サービス名だけでは引けません。

3. それでも分からなければ、手で実行してみる

docker compose exec <サービス名> wget -q -O - http://127.0.0.1/

サーバーを借りるところから始める場合は、先に読んでおくと詰まりにくい記事があります。

VPSを契約したら最初にやる4つのこと

設定ファイルを重ねて使っているなら、こちらも同じ種類の話です。

docker-compose.prod.yml を書いたのに、本番が開発モードで動いていた