🏄

CMD python main.pyとCMD、ENTRYPOINT ["python", "main.py"]の違い

に公開

きっかけ

社内でPythonで書かれたアプリケーションをgraceful shutdownの実装をしたいとなったときに、Dockerfileの書き方を変えないといけないと話になった。

その時のDockerfileの書き方

CMD python main.py

直した書き方

ENTRYPOINT ["python", "main.py"]

率直にこの書き方によってgraceful shutdownになる理由がよくわからなかったので調べた。

結論

CMD ["python", "main.py"]ENTRYPOINT ["python", "main.py"] のようなexec形式で書くこと。

調べたこと

以下のドキュメントにDockerfileをこのように書くと良いというチェックリストがあった。

https://docs.docker.com/reference/build-checks/json-args-recommended/

JSON arguments recommended for ENTRYPOINT/CMD to prevent unintended behavior related to OS signals

CMD my-cmd start のような、[] で囲わずに直接書くような書き方をシェル形式といい、CMD ["my-cmd", "start"]というふうに []で囲うやり方はexec形式と言うらしい。

[] で引数を渡すことを ここではJSON引数と言うみたい。

どうやら、shell形式で書くと、/bin/sh -c "my-cmd start" といった形で、間にshellが動いてしまうらしい。

試してみる

SIGINTとSIGTERMのシグナルを受け取れるpythonスクリプトを用意する。
SIGTERMを受け取ったら、sleep 5.5してからexit 0する。

#!/usr/bin/env python3
"""
Docker CMD vs ENTRYPOINT のシグナルハンドリング検証。
Python 稼働プロセスをシミュレートする。
"""

import os
import signal
import sys
import time
import threading


def print_process_info():
    pid = os.getpid()
    ppid = os.getppid()
    print(f"[INFO] PID={pid}, PPID={ppid}", flush=True)
    if pid == 1:
        print("[INFO] PID 1(initプロセス)として起動中 - SIGTERM は直接このプロセスに届く", flush=True)
    else:
        print("[INFO] PID 1 ではない - SIGTERM がこのプロセスに届かない可能性がある", flush=True)


def sigterm_handler(signum, frame):
    print(f"\n[SIGNAL] *** SIGTERM を受信! (PID={os.getpid()}) ***", flush=True)
    print("[SIGNAL] グレースフルシャットダウン開始(クリーンアップをシミュレート)", flush=True)
    # クリーンアップをシミュレート(例: 実行中のLLM呼び出しの完了、状態のフラッシュ)
    time.sleep(5.5)
    print("[SIGNAL] クリーンアップ完了。終了します。", flush=True)
    sys.exit(0)


def sigint_handler(signum, frame):
    print(f"\n[SIGNAL] SIGINT を受信 (PID={os.getpid()})", flush=True)
    sys.exit(0)


def simulate_adk_work():
    """エージェントの作業をシミュレート(LLM呼び出し、ツール使用など)"""
    step = 0
    while True:
        step += 1
        print(f"[INFO] step={step} 処理中... (PID={os.getpid()})", flush=True)
        time.sleep(3)


def main():
    print("=" * 60, flush=True)
    print("[START] Python シグナルハンドリングテスト", flush=True)
    print_process_info()

    # シグナルハンドラを登録
    signal.signal(signal.SIGTERM, sigterm_handler)
    signal.signal(signal.SIGINT, sigint_handler)
    print("[INFO] シグナルハンドラ登録済み(SIGTERM, SIGINT)", flush=True)
    print("=" * 60, flush=True)

    # シミュレーションをメインスレッドで実行
    try:
        simulate_adk_work()
    except SystemExit:
        raise
    except Exception as e:
        print(f"[ERROR] {e}", flush=True)
        sys.exit(1)


if __name__ == "__main__":
    main()

シェル形式で実行されるDockerfileとexec形式で実行されるDockerfileを用意する。

●シェル形式

FROM python:3.12-slim


RUN apt-get update && apt-get install -y --no-install-recommends procps \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY app.py .

CMD python app.py

●exec形式

FROM python:3.12-slim

RUN apt-get update && apt-get install -y --no-install-recommends procps \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY app.py .

CMD ["python", "app.py"]

実行

●シェル形式

  docker exec -it 96be0ce83cae sh

# ps auxw
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.0   2676  1684 ?        Ss   23:40   0:00 /bin/sh -c python app.py
root           7  0.0  0.0  17092 13488 ?        S    23:40   0:00 python app.py
root           8  0.1  0.0   2676  1736 pts/0    Ss   23:41   0:00 sh
root          15  0.0  0.0   6788  3932 pts/0    R+   23:41   0:00 ps auxw

たしかにPID 1が /bin/sh -c python app.py となっている。
python自体のPIDは7。

docker stopすると、何も終了処理のメッセージが出ずにログが終わってしまう。

[INFO]  step=78 処理中... (PID=7)
[INFO]  step=79 処理中... (PID=7)
[INFO]  step=80 処理中... (PID=7)
[INFO]  step=81 処理中... (PID=7)

  

●exec形式

  docker exec -it 95d7ad47b1d1 sh

# ps auxw
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.2  0.0  17092 13564 ?        Ss   23:45   0:00 python app.py
root           7  0.7  0.0   2676  1756 pts/0    Ss   23:46   0:00 sh
root          13  0.0  0.0   6788  3968 pts/0    R+   23:46   0:00 ps auxw

pythonのプロセスがPID 1となっている。

docker stopすると、SIGTERMシグナルを受け取りちゃんと終了処理のメッセージが受け取れている。

[INFO]  step=22 処理中... (PID=1)
[INFO]  step=23 処理中... (PID=1)
[INFO]  step=24 処理中... (PID=1)

[SIGNAL] *** SIGTERM を受信! (PID=1) ***
[SIGNAL] グレースフルシャットダウン開始(クリーンアップをシミュレート)
[SIGNAL] クリーンアップ完了。終了します。



CMDとENTRYPOINTの違い

https://www.docker.com/ja-jp/blog/docker-best-practices-choosing-between-run-cmd-and-entrypoint/

こちらのブログ記事を読んでいると、CMDだと簡単にオーバーライドができる(docker run -it <image> /bin/bashなどで)

ENTRYPOINTの場合は上書きすることが望ましくない、特定ユースケース用のコンテナとして扱わせるときに使うと良いとのこと。
(とはいえ、--entrypoint を使えば上書きできちゃうらしい)

余談

docker build --check

こちらの記事を読んでいたときに、docker build --check というオプションがあって、指定したDockerfileが記事に載っているようなチェックリストに引っかかっていないかどうかをチェックするコマンドがあるらしい。便利。

https://docs.docker.com/reference/build-checks/

docker build --check -f <Dockerfile> .

リポジトリ

検証用のコードつくるのをclaude codeにお願いしたら簡単に作ってもらえた。便利。

https://github.com/jnuank/docker-study

Discussion