docker stopを実行してもコンテナがすぐに止まらず、10秒後に強制終了(SIGKILL)される」「長時間稼働させるとdocker top コンテナ名で<defunct>という謎のプロセスが増え続ける」——本番でDockerコンテナを動かしていると、こんなトラブルに出会うことがある。これらはどちらも「PID 1問題」と呼ばれる、Dockerコンテナに固有のプロセス管理の落とし穴だ。アプリをそのままPID 1として起動すると、シグナルの伝達もゾンビプロセスの回収も正常に機能しない。
この記事では、PID 1問題の仕組みを整理した上で、
--initフラグとtiniを使った解決策を実機で確認しながら解説する。実行環境はUbuntu 24.04 LTS(Docker 27.3.1)で動作確認済みだ。
この記事のポイント
・アプリをPID 1で起動するとSIGTERMが届かずdocker stopが10秒後にSIGKILLになる
・子プロセスのゾンビが溜まるのはPID 1がwait()を呼ばないのが原因
・--initフラグでtiniをPID 1に差し込むと両問題を解決できる
・本番イメージに焼き込むにはDockerfileにtiniをインストールしてENTRYPOINTに指定する
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
DockerコンテナがPID 1で起動する仕組みと問題の本質
通常のLinuxサーバーでは、OS起動時に最初に立ち上がるプロセスのPIDは1になる。現代のサーバーではsystemdがPID 1として動き、全プロセスの先祖になる。systemdはシグナルを適切に処理し、孤立した子プロセスの終了を回収する「リーピング」も担う。
Dockerコンテナは、VMとは異なり新しいPID名前空間を作成することでプロセスを分離する。この名前空間の中ではプロセス番号が1から振り直されるため、ENTRYPOINTやCMDに指定したプロセスがそのままPID 1になる。
# sleepコマンドをPID 1として起動した例 $ docker run --rm -d --name pid1-demo ubuntu:24.04 sleep 600 $ docker exec pid1-demo ps aux USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND root 1 0.0 0.0 2788 952 ? Ss 03:21 0:00 sleep 600
sleep 600がPID 1になっている。通常のサーバーではsystemdが担うPID 1の座に、コンテナでは直接アプリが座る。これがすべての問題の出発点だ。
シグナル処理の問題|なぜdocker stopが10秒かかるのか
docker stop コンテナ名を実行すると、DockerはコンテナのPID 1にSIGTERMを送る。アプリがSIGTERMを受け取って正常終了すれば問題ない。しかし多くの場合、これが意図どおりに動かない。
典型的な落とし穴がシェル形式のCMD/ENTRYPOINTだ。
# シェル形式(PID 1がshになってしまう) CMD sh -c "node server.js" # または CMD ["sh", "-c", "node server.js"] # exec形式(PID 1がnodeになる。こちらが正しい) CMD ["node", "server.js"]
シェル形式で起動すると/bin/sh -c "node server.js"という形でshがPID 1になり、nodeはその子プロセスとして動く。docker stopでSIGTERMがshに届いても、shはデフォルトではSIGTERMに対して何もしない(LinuxのシグナルはPID 1のデフォルトハンドラーを登録しない仕様で、shにはSIGTERMのハンドラーがない)。子プロセスのnodeにもシグナルは転送されない。
結果として--stop-timeout(デフォルト10秒)が経過してからSIGKILLで強制終了される。実機で確認してみよう。
# シェル形式でsleepを起動(shがPID 1でSIGTERMを無視する) $ docker run --rm -d --name test-signal ubuntu:24.04 sh -c 'exec sleep 600' # stopにかかる時間を計測 $ time docker stop test-signal test-signal real 0m10.394s ← 10秒以上かかっている(SIGKILLで強制終了)
exec形式に変えるとアプリ自体がPID 1になってSIGTERMを直接受け取れる。ただしexec形式に変えても、アプリがSIGTERMを適切に処理しない場合や、シグナルを子プロセスに転送する必要がある複数プロセス構成では問題が残る。
ゾンビプロセスが発生する仕組み
Linuxでは子プロセスが終了すると、カーネルはその終了情報を保持する「ゾンビ状態」(<defunct>)にする。親プロセスがwait()システムコールを呼んで終了情報を回収して初めて、ゾンビは消える。
通常のサーバーではPID 1(systemd)が孤立した子プロセスを引き取り、終了時にwait()してゾンビを回収する。DockerコンテナでアプリがPID 1になっている場合、そのアプリがwait()を自分で呼ばない限りゾンビが蓄積する。実機で確認してみよう。
# PID 1にシェルを置いて子プロセスを次々に起動・終了させる $ docker run --rm -d --name zombie-test ubuntu:24.04 sh -c 'while true; do sleep 0.1 & done' # 少し待ってからプロセスを確認 $ docker exec zombie-test ps aux | grep defunct | head -5 root 312 0.0 0.0 0 0 ? Z 04:15 0:00 [sleep]
root 313 0.0 0.0 0 0 ? Z 04:15 0:00 [sleep] root 314 0.0 0.0 0 0 ? Z 04:15 0:00 [sleep] root 315 0.0 0.0 0 0 ? Z 04:15 0:00 [sleep] root 316 0.0 0.0 0 0 ? Z 04:15 0:00 [sleep] # ゾンビの数を確認(放置すると増え続ける) $ docker exec zombie-test ps aux | grep -c defunct 47
<defunct>と表示されているのがゾンビプロセスだ。コンテナが長時間稼働すると、このゾンビが蓄積してシステムのプロセステーブルを圧迫し、やがて新しいプロセスを起動できなくなる。
--initフラグで解決する方法
1. docker runでの--initフラグの使い方
--initフラグを付けるだけで、DockerはコンテナのPID 1としてtiniという軽量なinit処理系を自動的に挿入する。アプリはPID 2以降で動き、tiniがSIGTERMの転送とゾンビ回収を引き受ける。
# --initフラグを付けて起動 $ docker run --rm -d --name with-init --init ubuntu:24.04 sh -c 'while true; do sleep 0.1 & done' # PID 1がtiniになっている $ docker exec with-init ps aux USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND root 1 0.0 0.0 876 708 ? Ss 04:30 0:00 /sbin/docker-init -- sh -c while true; do sleep ... root 7 0.0 0.0 2788 952 ? S 04:30 0:00 sh -c while true; do sleep 0.1 & done # ゾンビが溜まらないことを確認 $ docker exec with-init ps aux | grep defunct | wc -l 0
PID 1が/sbin/docker-init(Dockerが組み込んだtiniの別名)になり、アプリのshは子プロセスとして動いている。ゾンビも溜まっていない。docker stopの速度も改善する。
# --initあり:tiniがSIGTERMをアプリに転送して即座に終了 $ time docker stop with-init with-init real 0m0.481s ← ほぼ即時終了
2. Docker Composeでのinit設定(init: true)
Docker Composeでは、サービスの定義にinit: trueを追加するだけで同じ効果が得られる。docker runの--initに相当する設定だ。
# compose.yml services: app: image: myapp:latest init: true # tiniをPID 1として挿入 ports: - "3000:3000" db: image: mysql:8.4 init: true environment: MYSQL_ROOT_PASSWORD: secret
コンテナの本番設計についてはDocker実践講座でも詳しく取り上げている。シグナル処理やリソース制限など、運用で欠かせないポイントを体系的に学べる。
DockerfileにtiniをENTRYPOINTとして組み込む方法
--initフラグやinit: trueはコンテナ実行時に指定する方式だ。イメージ自体にtiniを焼き込んでおけば、フラグを付け忘れてもPID 1問題が発生しない。特にKubernetesやAWS ECSなど、--initオプションを使えない環境では必須になる。
1. Alpineベースイメージでのtini組み込み
Alpineの公式パッケージにtiniが含まれているのでapk addで追加できる。
FROM node:22-alpine # tiniをパッケージマネージャーで追加 RUN apk add --no-cache tini WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . # ENTRYPOINTの先頭にtiniを置く(exec形式必須) ENTRYPOINT ["/sbin/tini", "--"] CMD ["node", "server.js"]
tiniを挟んだ後、アプリはtiniの子プロセス(PID 2以降)として起動する。tini --の--は「以降の引数がtiniへのオプションではなくコマンド」であることを示すセパレータだ。
2. Debian・Ubuntuベースイメージでのtini組み込み
Debian・Ubuntuではapt-getでtiniをインストールする。
FROM python:3.13-slim RUN apt-get update && apt-get install -y tini && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . ENTRYPOINT ["/usr/bin/tini", "--"] CMD ["python", "app.py"]
3. tiniのインストールパスを確認する
tiniのインストールパスはディストリビューションによって異なる。ビルド後に確認しておくとよい。
# Alpine のパス $ docker run --rm node:22-alpine which tini /sbin/tini # Debian/Ubuntu のパス $ docker run --rm python:3.13-slim sh -c 'apt-get install -y tini -qq && which tini' /usr/bin/tini # Dockerfileを正しく書けていればビルド後のコンテナで確認可能 $ docker run --rm myapp:latest ps aux USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND root 1 0.0 0.0 884 708 ? Ss 05:00 0:00 /sbin/tini -- node server.js root 6 0.2 1.2 681420 49152 ? Sl 05:00 0:00 node server.js
--initフラグとDockerfile組み込みの使い分け
どちらの方法も技術的には同等で、tiniがPID 1に入るという点では同じだ。選択基準を整理する。
| 観点 | --initフラグ(init: true) | Dockerfile組み込み |
|---|---|---|
| 設定の場所 | 実行時(docker run / compose.yml) | イメージビルド時(Dockerfile) |
| 付け忘れリスク | あり(実行者に依存する) | なし(イメージに焼き込む) |
| Kubernetes・ECS対応 | 不可(--initはDocker固有) | 可(tini自体はどこでも動く) |
| イメージのポータビリティ | 低い(実行環境依存) | 高い(イメージが自律) |
| 導入コスト | 低い(既存Dockerfileを変えない) | 中程度(Dockerfile修正が必要) |
本番・ステージング・KubernetesなどDockerランタイムに依存しない環境で運用する場合は、Dockerfile組み込みが推奨だ。開発環境やローカルで手軽に試したい場合は--initフラグで十分だ。
なお、exec形式のENTRYPOINTを使っていてアプリ自体がSIGTERMを適切に処理し、かつ子プロセスをフォークしない場合は、tiniなしでも問題が起きないケースもある。ただし確認コストを考えると、tiniを組み込んでおく方が安全だ。
よくあるエラーと対処法
1. tiniのパスが見つからず起動に失敗する
DockerfileにENTRYPOINT ["/sbin/tini", "--"]と書いたのに起動時に次のエラーが出る場合は、tiniのインストールパスが異なることが原因だ。
# 起動時のエラー例 docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: container_linux.go:380: starting container process caused: exec: "/sbin/tini": stat /sbin/tini: no such file or directory: unknown.
Debian・Ubuntuベースのイメージでは/usr/bin/tiniにインストールされる。Alpine系は/sbin/tiniだ。which tiniでパスを確認してからDockerfileに反映する。
# パスを確認してからDockerfileに書く $ docker run --rm python:3.13-slim sh -c 'apt-get install -y tini -qq && which tini' /usr/bin/tini # Dockerfileは確認したパスで記述 ENTRYPOINT ["/usr/bin/tini", "--"]
2. --initを指定してもdocker stopが遅い
【注意】 tiniがPID 1に入っても、アプリ自体がSIGTERMを受け取って自分でシャットダウン処理を行わなければ意味がない。アプリのコードでSIGTERMハンドラーを実装していない場合は、tiniがシグナルを転送しても無視されて10秒後にSIGKILLになる。
Node.jsならprocess.on('SIGTERM', ...)、Pythonならsignal.signal(signal.SIGTERM, ...)でハンドラーを登録する。アプリのSIGTERM対応と--initはセットで考えること。
3. Kubernetesで--initに相当する設定を行う方法
KubernetesのPodSpecには--initフラグに相当するオプションがない。KubernetesでPID 1問題を回避するには、DockerfileにtiniをINSTALLしてENTRYPOINTに指定する方法が唯一の解だ。shareProcessNamespace: trueはPID名前空間をサイドカーと共有する別機能なので混同しないよう注意。
本記事のまとめ
| 問題 | 原因 | 解決策 |
|---|---|---|
| docker stopが10秒後にSIGKILL | PID 1がSIGTERMを無視・転送しない | --initフラグまたはtini組み込み |
| ゾンビプロセスが増え続ける | PID 1がwait()でゾンビを回収しない | --initフラグまたはtini組み込み |
| KubernetesやECSでも安全に動かしたい | --initはDocker固有オプション | DockerfileにtiniをINSTALL・ENTRYPOINT設定 |
| シェル形式CMDでシグナルが届かない | shがPID 1でSIGTERMを子に転送しない | exec形式CMD + --initまたはtini |
PID 1問題は見落とされがちだが、本番コンテナの安定運用には欠かせない知識だ。まずは--initフラグで動作を確認し、本番イメージにはDockerfileへのtini組み込みを採用するとよい。
現場で通用する安全なLinuxサーバー構築の「型」を体系的に身につけたい方へ、
Dockerを使ったコンテナ設計から本番運用まで、実務で通用するスキルを2日間のハンズオンで学べる講座があります。
>> Docker実践講座の詳細はこちら
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら
- 前のページへ:DockerfileのHEREDOC構文でRUN命令をすっきり書く方法|マルチラインとCOPYを使わないファイル生成の実践設計
- この記事の属するカテゴリ:Dockerへ戻る

無料メルマガで学習を続ける
Linuxの実践スキルをメールで毎週お届け。
登録は30秒、解除もいつでも可。
登録無料・いつでも解除できます