Dockerコンテナの終了が遅い問題とゾンビプロセスの原因:Tiniによる根本的な解決策

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

問題:なぜDockerコンテナの終了にいつも10秒かかるのか?

docker stopを実行した際、停止までちょうど10秒待たされる場合、それはアプリケーションが重いからではありません。実際には、シグナルハンドリング(Signal Handling)の問題が発生しています。DockerはSIGTERMシグナルを送信して、アプリケーションに接続を閉じ、正常に終了(クリーンシャットダウン)するように要求します。アプリケーションが10秒以上この要求を無視し続けると、Dockerはしびれを切らし、SIGKILLを使用してプロセスを強制終了させます。

多くの人は、データベースの切断が遅いことが原因だと誤解しがちです。しかし、根本的な原因は、多くの場合コンテナ内のLinuxにおけるPID 1の管理方法にあります。Node.js、Python、Javaなどの主要なランタイムの多くは、本格的なInitプロセスとしての役割を果たすようには設計されていません。

以前のプロジェクトで、RAMは空いているのに1週間ごとにサーバーのPIDリソースが枯渇するという問題に頭を悩ませたことがあります。ps auxコマンドで確認したところ、数百ものプロセスが<defunct>状態になっていました。これこそが、適切にクリーンアップされずに蓄積されたゾンビプロセス(Zombie Process)の正体でした。

なぜPID 1が重要なのか?

「祖先プロセス」の責任

Linuxにおいて、プロセスIDが1(PID 1)のプロセスは、他のすべてのプロセスの根源です。これには、無視できない2つの大きな役割があります:

  • シグナル転送(Signal Forwarding): Ctrl+Cを押したりstopコマンドを呼んだりすると、システムはSIGINT/SIGTERMシグナルを送信します。PID 1はこれを受け取り、配下の子プロセスに「伝言」を伝える必要があります。
  • 後片付け(Reaping): 子プロセスが終了しても、すぐには消滅せずゾンビ(Zombie)になります。PID 1には、Linuxカーネルに対してそのゾンビのリソースを解放するよう確認(Reap)する任務があります。

ENTRYPOINT ["node", "index.js"]と記述すると、Node.jsがPID 1を占有します。困ったことに、Node.jsは通常のOSでsystemdinitが行うような、子プロセスの自動的なクリーンアップやシグナルの転送を行いません。

ゾンビプロセスによる弊害

アプリケーションが画像処理やメール送信のためにシェルスクリプトを呼び出す場面を想像してください。もしそのスクリプトの実行が終わっても、PID 1が「Reap(回収)」操作を行わなければ、そのプロセスはLinuxカーネルの管理テーブルに永久に残り続けます。1時間に数千もの短時間のタスクを実行するシステムでは、これらのゾンビがPIDテーブルを埋め尽くし、サーバー全体をハングさせる可能性があります。

Tini — 「小さくても頼れる」解決策

TiniはC言語で書かれた、わずか20〜30KBの超軽量なInitプロセスです。これはコンテナのためのプロフェッショナルなPID 1になるという、ただ一つの目的のために作られました。Tiniはアプリケーションに代わってシグナルを受け取り、発生したすべてのゾンビをクリーンアップします。

# Tiniの動作メカニズム:
[tini] (PID 1) --> [app] (PID 2) --> [child] (ゾンビ状態!)
# Tiniが子の終了を検知 --> リーピングを実行 --> PIDテーブルを解放。

Tiniをワークフローに統合する3つの方法

方法1:–initフラグを使用する(最も素早く簡単)

Docker 1.13以降、Tiniは標準機能として組み込まれています。コードやDockerfileを修正する必要はなく、コンテナ起動時に–initフラグを追加するだけです。

docker run --init -d my-app:latest

これは、イメージを再ビルドする手間をかけずに、アプリケーションがシグナルハンドリングの問題を抱えているかどうかを素早く確認するのに最適な方法です。

方法2:Dockerfileに直接組み込む(本番環境の推奨)

KubernetesやAWS ECSなど、あらゆる環境でコンテナを安定して動作させるには、イメージにTiniを直接インストールするのが最善です。Alpine Linuxの例を以下に示します:

FROM python:3.9-alpine
RUN apk add --no-cache tini
WORKDIR /app
COPY . .

# 常にTiniをENTRYPOINTとして使用
ENTRYPOINT ["/sbin/tini", "--"]

# メインアプリケーションをCMDに配置
CMD ["python", "app.py"]

ヒント:必ずexec形式(配列形式["..."])を使用してください。shell形式python app.py)を使用すると、Dockerはコマンドを/bin/sh -cでラップしてしまいます。その結果、シェルがPID 1となり、元の問題が再発します。

方法3:Docker Composeで設定する

Composeを使用しているプロジェクトでは、docker-compose.ymlファイルに1行追加するだけです:

services:
  api:
    image: my-node-app
    init: true
    ports:
      - "8080:8080"

BashスクリプトをEntrypointにする際の注意点

アプリを起動する前にマイグレーションなどを実行するため、entrypoint.shファイルを作成するケースが多いでしょう。もし最終行に/usr/bin/python main.pyと書くと、シェルスクリプトがPID 1を占有します。SIGTERMを受け取ったとき、このスクリプトは即座に終了しますが、内部のPythonプロセスは「孤児(Orphan)」として残ってしまいます。シェルのプロセスをアプリのプロセスに置き換えるため、execコマンド(例:exec python main.py)を使用してください。

まとめ

Tiniを使用する理由は、デプロイのたびに10秒 waitつのを避けるためだけではありません。これは、リソースリークを防ぎ、堅牢なコンテナシステムを構築するための標準的な手法です。本番環境で重要なサービスを管理している場合は、ぜひ5分時間を取ってPID 1を確認してみてください。この小さな変更が、システムのプロフェッショナルさと安定性を大きく向上させます。

Share: