Claude Media
Claude CodeでDockerのentrypointシェルを書く — execでSIGTERMを渡す

Claude CodeでDockerのentrypointシェルを書く — execでSIGTERMを渡す

entrypointシェルの最終行をexec "$@"にすると、docker stopのSIGTERMがアプリに届きます。execなしで停止が10秒遅れる理由と、Claude Codeに書かせて検証する手順をまとめます。

Dockerのentrypointシェルは、最後の1行を exec "$@" にするだけでシグナルの届き方が変わります。exec を付け忘れると、docker stop のSIGTERMはアプリに届きません。コンテナは猶予時間いっぱい待たされたあと、SIGKILLで強制終了されます。

この記事は、Claude Codeにentrypointシェルを書かせるときの依頼文、exec の有無による挙動の差、docker stop の所要時間で確かめる手順をまとめたものです。

execを付けないと、docker stopはなぜ遅くなるのか

Dockerfileリファレンスは、ENTRYPOINTの書き方を2つに分けています。JSON配列で書くexec形式と、文字列で書くシェル形式です。exec形式が推奨で、シェル形式には次の制約があります。

  • 実行ファイルを /bin/sh -c のサブコマンドとして起動する
  • /bin/sh -c はシグナルを渡さない
  • 実行ファイルがコンテナのPID 1にならず、Unixシグナルを受けない
  • docker stop のSIGTERMが実行ファイルに届かない

一方 docker stop は、コンテナのメインプロセスにSIGTERMを送り、猶予時間が過ぎるとSIGKILLを送ります。猶予時間の既定値は、Linuxコンテナで10秒です。Dockerfileの STOPSIGNAL や docker run --stop-signal で最初のシグナルを変えることはできますが、既定はSIGTERMです。

つまり、シグナルを受け取る側が正しくPID 1に座っていないと、docker stop は毎回10秒待ってからSIGKILLに切り替わります。

entrypointをシェルスクリプトにした場合も同じ構図になります。スクリプトがPID 1で動き、最後にアプリを普通のコマンドとして呼ぶと、アプリはスクリプトの子プロセスです。SIGTERMを受けるのはスクリプトのほうで、アプリには届きません。

exec "$@"で、シェルをアプリに置き換える

exec は、シェルのプロセスを指定したコマンドで置き換えます。新しい子プロセスは作られません。"$@" は、スクリプトに渡された引数をそのまま並べた形です。

entrypointスクリプトの典型は、準備処理をして、最後に exec "$@" でCMDを実行する形です。Dockerfileリファレンスにも、exec とgosuを使う例があります。

#!/usr/bin/env bash
set -e
 
if [ "$1" = 'postgres' ]; then
    chown -R postgres "$PGDATA"
 
    if [ -z "$(ls -A "$PGDATA")" ]; then
        gosu postgres initdb
    fi
 
    exec gosu postgres "$@"
fi
 
exec "$@"

これはDockerfileリファレンスに載っている例を、そのまま写したものです。準備処理は set -e の下で走り、最後の exec でスクリプト自身がアプリに置き換わります。exec より後ろの行は実行されません。

Dockerfile側は、ENTRYPOINTをexec形式にします。CMDもexec形式で揃えます。

COPY entrypoint.sh /usr/local/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
CMD ["node", "server.js"]

CMDをシェル形式にすると /bin/sh -c で包まれます。entrypointにはコマンドそのものではなく、シェル呼び出しが引数として渡ります。ENTRYPOINTとCMDは、同じexec形式で揃えるのが安全です。

シェル形式のENTRYPOINTなら、execを頭に付ける

スクリプトを使わず、ENTRYPOINTをシェル形式で書いている場合の対処も、Dockerfileリファレンスにあります。コマンドの先頭に exec を付けます。

FROM ubuntu
ENTRYPOINT exec top -b

この形なら、top がPID 1の単独プロセスとして動きます。exec を忘れて ENTRYPOINT top -b と書いた場合、docker exec で ps waux を見ると、PID 1は /bin/sh -c top -b ... で、top は別のPIDの子プロセスです。この状態の docker stop は、タイムアウトまで待ってからSIGKILLを送ることになります。

シェルがexecしないと何が起きるか(手元のmacOSでの観察)

Dockerを使わず、手元のmacOS(/bin/sh)で、exec の有無による違いを確かめました。コンテナのPID 1とは条件が違うので、docker stopの所要時間の再現ではありません。確かめたのは、プロセスの親子関係とSIGTERMの行き先です。

使った2つのスクリプトは、最終行だけが違います。

# ep-noexec.sh
#!/bin/sh
echo "setup done"
"$@"
# ep-exec.sh
#!/bin/sh
echo "setup done"
exec "$@"

どちらも ./ep-xxx.sh sleep 30 で起動し、1秒後にスクリプトのPIDへSIGTERMを送りました。

スクリプト起動直後のプロセスSIGTERM後の sleep 30
ep-noexec.sh起動直後のプロセス/bin/sh ./ep-noexec.sh sleep 30 と、その子の sleep 30SIGTERM後の sleep 30親が 1 になって残る
ep-exec.sh起動直後のプロセスsleep 30 が1つだけ(スクリプトのPIDのまま)SIGTERM後の sleep 30残らない

exec なしでは、シェルだけが終了し、sleep 30 は孤児として残りました。exec ありでは、スクリプトのPIDがそのまま sleep になり、SIGTERMで終わります。

コンテナで起きる事故は、この裏返しです。コンテナのPID 1がシェルのまま居座り、アプリがその子プロセスになると、アプリへのシグナルがシェルで止まります。

execしても止まらない場合: PID 1にはシグナルの既定動作が働かない

exec でアプリをPID 1にしても、それだけで速く止まるとは限りません。LinuxはPID 1のプロセスを特別に扱い、既定動作のシグナルを無視させます。docker run リファレンスの注記にあるとおり、PID 1のプロセスはSIGINTやSIGTERMで終了しません。終了するのは、アプリ自身がそのシグナルを処理するコードを持っているときだけです。

CMD ["node", "server.js"] のようにSIGTERMのハンドラを登録していないアプリは、exec を付けても docker stop の猶予時間を使い切り、SIGKILLで終わります。アプリ側にハンドラを書くのが本筋です。書けない場合は、次の --init が代わりになります。

Claude Codeにentrypointシェルを書かせる

依頼文には、exec で終えることと、確かめ方まで書きます。Claude Codeの一般的なワークフローでは、バグ調査のとき、再現に使うコマンドをClaudeに伝えるのがヒントとして挙げられています。entrypointの場合は、docker stop の所要時間がそれに当たります。

docker/entrypoint.sh を新規に作ってください。
- 準備処理(マイグレーション、設定ファイルの生成)をしたあと、
  最後の行は exec "$@" にする
- Dockerfile の ENTRYPOINT と CMD は exec 形式(JSON 配列)で書く
- 書いたら docker build、docker run -d、time docker stop を実行して、
  停止が数秒以内に終わることを確認し、結果を報告する

最後の1行が肝心です。exec の有無はコードを読んでも分かりますが、実際に止まる時間は動かさないと分かりません。ベストプラクティスのページには、Claudeが完了と言った後に /verify でアプリを実際に動かして確認させる方法が書かれています。テストや型チェックでは見つからない問題を拾うための手段です。

CLAUDE.mdに書く規約

プロジェクトで繰り返し頼むなら、規約をCLAUDE.mdに置きます。例えば次のような内容です。

## Dockerのentrypoint
- entrypointスクリプトの最終行は `exec "$@"`。
- ENTRYPOINTとCMDはexec形式(JSON配列)。シェル形式は使わない。
- 変更したら `time docker stop <container>` で停止時間を確認する。
  猶予時間(既定10秒)に近ければ、`exec` 漏れか、アプリがSIGTERMを処理していないかを疑う。

規約は短いほうが守られやすいので、確認コマンドまで1か所にまとめています。

権限の範囲

Claudeに docker build や docker run を実行させるなら、Bashの許可ルールで範囲を絞ります。権限のドキュメントでは、Bash(npm run build) のように完全一致のルールと、Bash(npm run *) のようにワイルドカードを使ったルールが紹介されています。Dockerなら Bash(docker build *) や Bash(docker stop *) のように、使うサブコマンドの後ろに * を置く形です。bypassPermissions は、使い捨てのコンテナの中でだけ意味がある設定です。

docker stopの所要時間で、execの効きを確かめる

確認に使うのは、イメージを作って起動し、停止時間を測る手順だけです。

docker build -t entry-test .
docker run -d --name entry-test entry-test
docker exec entry-test ps aux
time docker stop entry-test
docker rm entry-test

見る場所は2つです。

  • ps aux の出力でPID 1の行がアプリ本体になっているか。entrypoint.sh や /bin/sh -c のままなら、exec が効いていません
  • time docker stop が数秒以内で終わるか。10秒前後なら、SIGTERMがアプリに届いていないか、届いてもアプリが処理しないまま、SIGKILLで終わった可能性が高いです。PID 1がアプリ本体なのに遅いときは、後者です

コンテナにシェルが無い場合や、docker exec を使いたくない場合は、docker top entry-test でもプロセス一覧を見られます。Dockerfileリファレンスは ps aux と docker top の両方を例に使っています。

exec ありの例では docker stop が0.2秒台で返り、exec なしの例ではタイムアウトのあとにSIGKILLが送られます。

猶予時間を超えた強制終了は、終了コード137として観測されます。137の意味と、OOMとの切り分けはexit code 137のOOMを切り分ける手順にまとめています。

結果をClaudeに返して直させる

確認が失敗したら、出力をそのままClaudeに貼ります。Claude Codeのドキュメントでも、エラーを共有してから修正案を聞き、直させる流れが基本の型として示されています。

docker stop が10秒かかりました。ps aux の出力を貼ります。
PID 1 が /bin/sh -c になっています。
原因を entrypoint.sh と Dockerfile から特定して直し、
もう一度 time docker stop を測って結果を教えてください。

貼るのは ps aux と time docker stop の2つで足ります。Dockerfileのどの行がシェル形式かは、Claudeがファイルを読めば分かります。人が見るのは、直したあとのPID 1の行と、停止時間の数字です。

execを使わない選択が必要なとき

exec は常に正解ではありません。シェルがアプリに置き換わるので、シェル側に残したい処理があると使えません。

目的exec "$@"別の書き方
準備のあとアプリを動かすだけexec "$@"向く別の書き方—
停止時にクリーンアップをしたいexec "$@"向かない別の書き方trap でシグナルを受ける
複数のプロセスを1コンテナで動かすexec "$@"向かない別の書き方スクリプトが残り、trap で各プロセスへ渡す
アプリにシグナル処理が無い / ゾンビプロセスの回収が要るexec "$@"exec だけでは足りない別の書き方docker run --init(tini)で、initがPID 1になる

停止時の後始末や複数実行ファイルの調整が必要なら、ENTRYPOINTスクリプトがシグナルを受け、それを渡したうえで追加の処理をする書き方になります。ここで使うのが trap です。

#!/bin/sh
trap "echo TRAPed signal" HUP INT QUIT TERM
 
/usr/sbin/apachectl start
 
echo "[hit enter key to exit] or run 'docker stop <container>'"
read
 
echo "stopping apache"
/usr/sbin/apachectl stop
 
echo "exited $0"

この形ではPID 1はシェルのままで、ps にも /bin/sh /run.sh が出ます。それでも docker stop が約0.27秒で返るのは、スクリプト自身がシグナルを受けて停止処理を実行しているからです。exec ありの形と見分けるときは、PID 1の行だけでなく、停止にかかる時間も見てください。

--init を付けると、docker-init がPID 1として動き、シグナルを転送してゾンビプロセスを回収します。docker-init は標準のインストールに含まれ、中身はtiniです。アプリのコードに手を入れられないときの選択肢になります。

docker run --init -d --name entry-test entry-test

Claudeに頼むときも、「停止時に後始末が要るか」を先に伝えてください。後始末が要るのに exec で終えると、trap を書いたシェル自体がアプリに置き換わって消えてしまいます。

Ctrl+Cとdocker stopは別のシグナルを送る

手元で docker run -it を動かしてCtrl+Cを押すと止まるのに、docker stop だと遅い、という症状があります。原因は送られるシグナルの違いです。

STOPSIGNAL は docker stop が送るシグナルを決めます。Ctrl+CのSIGINTは、STOPSIGNAL の設定に関係なく、キーボードから直接プロセスに送られます。Ctrl+Cが効くのにSIGTERMで止まらないなら、アプリがSIGINTだけを処理している可能性があります。その場合は、アプリ側のシグナル処理を確かめます。

アプリがSIGTERMでなく別のシグナルで終了するなら、Dockerfileに STOPSIGNAL を書くか、docker run --stop-signal で指定します。

つまずきやすい点

  • ENTRYPOINT ["entrypoint.sh"] のスクリプトに実行権限が無いと、コンテナが起動しません。chmod +x で実行権限を付けます
  • docker run --entrypoint で上書きできるのは実行ファイルだけで、sh -c は使われません
  • CMDをシェル形式にすると、exec "$@" が受け取る引数が /bin/sh -c ... になります
  • exec の後ろに処理を書いても動きません。後始末は trap に移します

Claude Codeがentrypointを書き換えたら、ps aux と time docker stop をセットで見ると確実です。コードの見た目が正しくても、PID 1とシグナルの経路は動かさないと分かりません。Compose環境で起動順を整えたい場合はdocker composeの起動競合をhealthcheckで直すも参照してください。スクリプト自体のテストはbatsのシェルスクリプトテストで書けます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. docs.docker.com/reference/dockerfile
  2. docs.docker.com/reference/cli/docker/container/stop
  3. docs.docker.com/reference/cli/docker/container/run
  4. code.claude.com/docs/en/common-workflows
  5. code.claude.com/docs/en/best-practices
この記事を共有:XはてブLinkedIn