Docker 上級 #6 プロダクション運用 — graceful shutdown、healthcheck、restart

Docker 上級シリーズの最後の記事です。ビルド / マルチアーキ / セキュリティ / リソース制限 — 全て一コンテナの 外形 を扱ったとすれば、この記事はそのコンテナが 運用環境でうまく死んでうまく蘇る 細部を集めました。

Docker 上級 シリーズでこの記事の位置:

docker stop で起きること — もう一段深く #

基礎 #3 で短く触れた話です。運用視点でもう一度。

docker stop の流れ
docker stop myapp
コンテナの PID 1 に SIGTERM 送信
デフォルト 10 秒待機 (--time で調整可能)
   ├─ PID 1 がきれいに終了 → exit code そのまま
   └─ タイムアウト → SIGKILL

この流れの 全ての重みが PID 1 に乗ります。PID 1 が SIGTERM を受けて子に信号を伝播し、自分の片付けを終えなければ — コンテナがグレースフルに死ねません。

PID 1 問題 — コンテナの中でよく壊れるポイント #

Linux で PID 1 は特別です。

  • 親を失った子 (orphan) の養親 になる — ゾンビを reap しなければならない
  • 信号配送ルールが違う — 明示的にハンドラを登録していない信号は無視される

普通のアプリ (Python、Node、Java) は PID 1 で動くように設計されたことがありません。だからコンテナの中で PID 1 になると二つの問題が出ます。

問題 1 — SIGTERM を受けない #

ほとんどのランタイムは SIGTERM ハンドラを明示的に登録しないとその信号を無視します。Docker が送った SIGTERM が効果なく消えて、10 秒後に SIGKILL で強制終了されます。

診断:

アプリが SIGTERM を受けるか確認
docker run --rm -d --name test myapp
docker stop test
# 終了まで 10 秒くらいかかれば SIGTERM を無視している可能性大
# 即時 (1~2 秒) で終われば OK

問題 2 — ゾンビプロセス蓄積 #

アプリの中で子プロセスを起こすと (Node の child_process.spawn、Python の subprocess.Popen + 早い終了)、親が子を wait しないとゾンビになります。普通の環境では init が養親役でゾンビを掃除しますが、コンテナの中の PID 1 がその役割をしないと ゾンビが溜まります。

解決 — 小さな init を PID 1 に #

答えは PID 1 に小さな init を置いて、その下にアプリを置くこと。Docker が親切にオプション一つで提供します。

--init オプション
docker run --init -d myapp
compose
services:
  web:
    image: myapp
    init: true

--inittini という小さな init を PID 1 として立ち上げ、Dockerfile の CMD をその子として実行します。tini が:

  • 受けた SIGTERM を子にそのまま伝播
  • ゾンビプロセスを自動 reap

この一行で二つの問題が両方解けます。運用コンテナにはほぼ常に init: true

dumb-init — Dockerfile の中に刻む形 #

もう一つの道は dumb-init (Yelp) を ENTRYPOINT に置くこと。

Dockerfile
FROM python:3.14-slim
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init && \
    rm -rf /var/lib/apt/lists/*
COPY app.py .
ENTRYPOINT ["dumb-init", "--"]
CMD ["python", "app.py"]

dumb-init -- が PID 1 で立ち上がってその下に Python が子として。tini と似た役割。Docker の --init オプションの方が軽いのでそちらを好みますが、イメージがどこで立ち上がるか分からないとき (運用環境ごとに --init を有効にするか保証できない) は dumb-init をイメージに刻んでおく方が安全です。

アプリ自体の SIGTERM ハンドラ #

init で信号配送は解けても、アプリがその信号を聞いて片付けを始めなければ グレースフルが完成しません。言語別の短いパターン。

Node.js #

server.js
const server = app.listen(3000);

const shutdown = () => {
  console.log('Received SIGTERM, draining connections...');
  server.close(() => {
    console.log('All connections drained, exiting.');
    process.exit(0);
  });

  // 30 秒以内にきれいに終わらなければ強制終了
  setTimeout(() => {
    console.error('Force exit after 30s');
    process.exit(1);
  }, 30000).unref();
};

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

server.close() は新しい接続を拒否し、処理中のリクエストが終わるとコールバックを呼びます。その間 SIGTERM の 10 秒 (または延ばした時間) 以内に終わらせれば SIGKILL が落ちません。

Python (FastAPI / Django) #

uvicorn / gunicorn のようなプロダクションサーバが SIGTERM を自動で処理します。直接実装することは少ないです。ただし — ワーカープロセスが処理中のリクエストを終わらせる時間 を十分に与える必要があります。

gunicorn オプション
gunicorn app:app \
  --workers 4 \
  --graceful-timeout 30 \
  --timeout 60 \
  --bind 0.0.0.0:8000

--graceful-timeout 30 — SIGTERM 後 30 秒間リクエストを処理し続ける。Docker の stop --time もそれに合わせる:

compose
services:
  web:
    stop_grace_period: 35s   # gunicorn の graceful-timeout より少し長く

Go #

main.go
srv := &http.Server{Addr: ":8000", Handler: mux}
go srv.ListenAndServe()

stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
<-stop

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx)

http.Server.Shutdown が標準パターン。context タイムアウト内で進行中のリクエストを終えて終了。

stop_grace_period — 時間を延ばす #

デフォルト 10 秒できれいに終われないアプリ (例: 大容量ファイルアップロード処理中) なら:

compose
services:
  web:
    stop_grace_period: 60s
docker stop
docker stop --time 60 myapp

運用でよく見る設定です。ただし — 長くしすぎると デプロイが遅くなり、ELB のようなロードバランサがより早くバックエンドを unhealthy と認識してしまって意味がなくなる可能性もあります。

Restart 方針 — 深く再び #

中級 #4 で触れた表を運用視点でもう一度。

方針いつ
no一回限りのコンテナ (マイグレーション、シード、ビルド)
always常に — ホスト起動時にも
on-failure[:N]0 でない終了コード + 回数限界
unless-stopped明示的に止めたのでなければ常に

運用の安全なデフォルト — unless-stopped #

alwaysunless-stopped の違いがよく混乱します。違いは docker stop の意味:

  • always なら、docker stop で止めても Docker デーモン再起動時にまた立ち上がる
  • unless-stopped なら、docker stop で止めたコンテナはデーモン再起動時にも立ち上がらない

運用者が意図的に止めたコンテナをそのままにする方が合理的なので、unless-stopped が運用デフォルト です。

Restart 無限ループ — バックオフ #

アプリが起動時点で常に死ぬなら、restart: always は無限ループになります。Docker はこれを防ぐため 再起動間隔を徐々に延ばすバックオフ を持っています。

restart backoff
1 回目失敗 → 即時再試行
2 回目失敗 → 100ms 待機
3 回目失敗 → 200ms 待機
...
N 回目失敗 → 最大 1 分待機

連続失敗が多いコンテナはログを追って原因を見ます — docker logs --tail 200 <c>、OOMKilled 点検 (#5)。

Healthcheck — 運用視点で #

中級 #4 で見た healthcheck を運用視点でもう一度。

Liveness vs Readiness — 二つの違う問い #

K8s の概念ですが Docker にもそのまま適用できる思考道具。

LivenessReadiness
質問生きているか?トラフィックを受け取る準備ができたか?
失敗時コンテナ再起動トラフィック遮断 (再起動は X)
デッドロックに陥った → 再起動が必要DB 接続が一時切れ → 一時的にトラフィックだけ止める

Docker 自体には healthcheck 一種類 しかなく、二つを区別しません。だから Docker 単独運用では二つの概念を一つの healthcheck に混ぜるのが難しいです。

代替 — healthcheck は liveness に近く定義 し、readiness はアプリの中で処理。例: 起動直後 N 秒間 /health が 503 を返してから準備できたら 200。

良い healthcheck の条件 #

良い healthcheck
□ 速く応答 (1 秒以内)
□ 依存サービスを再帰的に点検しない
□ side effect なし
□ 別のエンドポイント (/health) — 一般トラフィックと分離
□ 認証なし (攻撃面を増やさないように内部からのみ)
悪い healthcheck
✗ DB クエリを実行 — DB 負荷
✗ ビジネスロジックを通過 — 依存性 / 負荷
✗ 外部 API 呼び出し — 外部ダウンで自分のコンテナが unhealthy になる
✗ 認証 — トラフィックヘルスチェッカも認証情報必要

healthcheck は アプリが生きていてリクエストを処理できるか だけ確認すれば十分です。依存性の健康は別のモニタリングが捉えます。

起動グレース — start_period #

マイグレーション / ウォームアップがあるアプリは起動直後しばらく unhealthy が正常です。

start_period 使用
healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
  interval: 10s
  timeout: 5s
  retries: 3
  start_period: 60s   # 最初の 60 秒の失敗は retries に数えない

K8s なら startupProbe と同じ役割です。

ロギング — 運用の細部 #

中級 #6 の stdout 原則 + log driver をもう一度、ただし運用の視点で。

ログローテーション #

必須オプション
services:
  web:
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

このオプションがないとディスク暴走 — Docker 事故の常連です。デーモンのグローバルデフォルトで刻んでおく方が安全です。

/etc/docker/daemon.json
{
  "log-driver": "local",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

local driver は json-file の効率的バリアント (圧縮 + ローテーションがデフォルト)。運用環境で次第に標準になりつつある選択肢です。

外部収集器へ #

運用が育つと stdout で終わらず外部収集器に流れます。

fluentd へ流す
services:
  web:
    logging:
      driver: fluentd
      options:
        fluentd-address: localhost:24224
        tag: web.{{.Name}}

これを受けて Loki / Elasticsearch / CloudWatch どこへも送れます。一段落だけ。

モニタリング — 一行の拡張 #

#5 の cAdvisor + Prometheus + Grafana が Docker 単独運用の最初のモニタリングセットアップです。よく見るパネル一束:

  • コンテナごとの CPU / メモリ / ネットワーク
  • restart 回数 (頻繁に再起動されるコンテナのアラーム)
  • OOMKill イベント
  • healthcheck 失敗率
  • ディスク IO

アラームの最初のルールは普通 「同じコンテナが 5 分以内に 3 回以上再起動」 くらい。頻繁な OOMKill / 死を素早く察知する基準です。

運用チェックリスト — 一箇所に #

このシリーズ全体で積み上げてきた内容を一コンテナの点検チェックリストにまとめると:

イメージ / ビルド
□ マルチステージ — ビルド道具分離 ([中級 #1])
□ ベース: slim または distroless ([中級 #1]、[#3])
□ マルチアーキ — linux/amd64 + linux/arm64 ([#2])
□ Dockerfile: hadolint 通過 ([#3])
□ イメージ: Trivy HIGH/CRITICAL クリーン ([#3])
□ SBOM 添付 + cosign 署名 ([#4])
□ ビルドは buildx + 外部キャッシュ ([中級 #2]、[#1])
ランタイム / compose.yaml
□ image: digest またはセマンティックバージョン (latest 禁止)
□ restart: unless-stopped
□ init: true (PID 1 処理)
□ stop_grace_period 明示 (アプリの graceful 時間より長く)
□ healthcheck — 速く軽く認証なし
□ リソース: mem_limit + cpus + pids_limit ([#5])
□ セキュリティ: read_only + tmpfs + cap_drop ALL + no-new-privileges ([#3])
□ 秘密: secrets: または外部マネージャ、絶対 ENV に刻まない ([中級 #5]、[#4])
□ ログ: max-size + max-file
□ DB / 内部サービス: -p は 127.0.0.1 バインドのみ
□ 環境ごとの値: .env / override ファイルで分離 ([中級 #4])
デプロイ / CI
□ ビルド → マルチアーキ → SBOM → 署名 → push 一ワークフロー ([#4])
□ 検証をゲートに — Trivy / cosign verify
□ タグ戦略: セマンティック + Git SHA + latest 同時 ([基礎 #5])
□ 外部キャッシュ: type=gha または type=registry ([中級 #2])

次へ — Docker 実戦シリーズ #

このシリーズは Docker 自体 の深掘りでした。次のシリーズ — Docker 実戦 — ではここまで築いてきた道具を 実際のアプリのデプロイの流れ に乗せます。扱う内容:

  • FastAPI アプリのコンテナ化 — 実運用級 Dockerfile
  • Django + PostgreSQL compose セットアップ — admin / static / migration まで
  • React/Next.js ビルドコンテナ — standalone、multi-stage
  • CI でのイメージビルド — GitHub Actions のフルワークフロー
  • レジストリ push とタグ戦略 — 運用の細部
  • クラウドデプロイ — Fly.io / Railway / ECS のうち一つの流れ

基礎 / 中級 / 上級で築いた全道具が実戦で一箇所に集まるシリーズです。

まとめ #

この記事で掴んだ絵:

  • docker stop = SIGTERM → グレース時間 → SIGKILL。PID 1 の信号処理が核心
  • PID 1 問題 — 普通のアプリは PID 1 として設計されていません。init: true または dumb-init で
  • アプリの中で SIGTERM ハンドラで進行中のリクエストを処理し終える — Node server.close、gunicorn --graceful-timeout、Go srv.Shutdown
  • stop_grace_period で十分な片付け時間を確保
  • restart: unless-stopped が運用デフォルト。バックオフが無限ループを防ぐ
  • Healthcheck は速く軽く、liveness に近く 定義。依存性点検は別途
  • 運用チェックリスト — イメージ / ランタイム / デプロイ三束
X