Docker の CMD と ENTRYPOINT の使い方

## はじめに

Docker の ENTRYPOINT と CMD の違いについて、サンプルとともに解説しています。

適切な書き方をすることで、利用者に開発者の意図通りに使ってもらえて、信頼性が高い Docker イメージを構築することができます。

## なぜこの文章を書いたか

~~アドベントカレンダーと社内勉強会のコンテンツを一緒に作って一石二鳥~~
仕事がある程度こなせるようになってきた新卒エンジニアに以下の点を伝えることを目的にしています。

- どうやって動いてるのか？ 一段階深堀ってみる楽しさ
- ちょっとしたサンプルコードを作り動かしてみることで、理解が深まる過程の楽しさ
- ドキュメントを**ちゃんと**読みこむ大切さ
- ソフトウェアを正しく使うことの大切さ

## CMD

コンテナが Docker イメージから起動されたときに実行する既定のコマンドを指定するものです。
コンテナの実行時にコマンドラインから簡単に上書きできます。 

例として一定時間（30秒） sleep して終了するコンテナを作ってみます[^1]

```Dockerfile
FROM ubuntu
CMD sleep 30
```

ビルドして実行してみます。
```shell-session
$ docker build -f Dockerfile -t test .
$ docker run -it --rm --name tmp test
``` 

別のターミナルで確認。
```shell-session
$ docker ps
CONTAINER ID   IMAGE     COMMAND                   CREATED         STATUS         PORTS     NAMES
71247cb98985   test      "/bin/sh -c 'sleep 3…"    3 seconds ago   Up 2 seconds             tmp
```

コマンドラインで bash を指定すれば、インタラクティブなシェルが起動できます。

```shell-session
$ docker run -it --rm --name tmp test bash
root@907005770eb0:/#
```

## ENTORYPOINT 

コンテナを実行可能ファイルとして使用する場合に設定するものです。
コンテナの実行時に上書きするには、`--entrypoint` オプションを使います。

```Dockerfile
FROM ubuntu
ENTRYPOINT ["sleep", "30"]
```

ビルドして実行してみます。
```shell-session
$ docker build -f Dockerfile -t test .
$ docker run -it --rm --name tmp test
``` 

ビルドして実行し、別のターミナルから確認してみます。

```shell-session
$ docker ps
CONTAINER ID   IMAGE     COMMAND       CREATED         STATUS         PORTS     NAMES
1ac0254790ce   test      "sleep 30"    3 seconds ago   Up 2 seconds             tmp
```

先ほどと同じように、シェルを起動しようとすると失敗します。

```shell-session
$ docker run -it --rm --name tmp test bash
sleep: invalid time interval 'bash'
Try 'sleep --help' for more information.
```

実行ファイルとして sleep を指定してるので、sleep 以外は動かせない状態になっています。

```shell-session
$ docker run -it --rm --entrypoint bash --name tmp test
root@751032d79551:/#
```

`--entrypoint` を利用することで、インタラクティブシェルが起動できます。

実際に実行してみると、いくつか疑問が浮かんできませんか？

- 外からみた動作は一緒だが、別ターミナルで確認したときの COMMAND が異なるのはなんでだろ？
- 同じようなことをするのに、2つのやり方がなぜあるのか？

## shell form と exec form

ドキュメントを調べてみると、shell form（Shell形式） と exec form（Exec形式） というのがでてくるかと思います。

> Shell形式とは、指定のコマンドを「/bin/sh -c」に渡して実行するものです。

プロセスの状態を分かりやすく表示するため pstree が欲しいので、Dockerfile を少しだけ書き換えます。

```Dockerfile
FROM ubuntu
RUN set -ex; apt update; apt install psmisc; apt autoremove; rm -rf /var/lib/apt/lists/*
CMD sleep 30
```

今までと同じようにBuildして実行、別ターミナルから確認してみます。
```shell-session
$ docker exec -it tmp pstree -ap
sh,1 -c sleep 30
  `-sleep,8 30
```

ENTRYPOINT も同様に書き換えてみます。

```Dockerfile
FROM ubuntu
RUN set -ex; apt update; apt install psmisc; apt autoremove; rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["sleep", "300"]
```
今までと同じようにBuildして実行、別ターミナルから確認してみます。

```shell-session
$ docker exec -it tmp pstree -ap
sleep,1 300
```

プロセスツリーが異なってますね！
PID=1 はコンテナで実行するメインのプロセスと言われます。

あ、失敗しました。Typo で Sleep の時間が長すぎました。
Ctrl-C を使って止めてみましょう。

・・・あれ、止まらない？　`docker kill tmp` で 強制終了できます。

CMD バージョンのもの再度Buildして実行して、Ctrl-C を試してみると・・・
ちゃんと止まりますね！

もうひとつ疑問が増えました。 
docker stop は何をしてるのだろうか? ということでドキュメントを読んでみます。

https://docs.docker.com/reference/cli/docker/container/stop/

> コンテナ内のメインプロセスはSIGTERMを受信し、猶予期間後にSIGKILLを受信します。

なるほど、sleep は確かにSIGTERM を無視しますね。
CMD は PID=1 が sh になっているので、シグナルを処理してくれてたんですね！

ということは、全て Shell形式 で書いてた方が良いということでしょうか？

https://docs.docker.com/reference/dockerfile/#shell-and-exec-form

> シェル形式はより緩やかで、使いやすさ、柔軟性、および読みやすさを重視しています。

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

> これらのシグナルは適切に処理されず、コンテナーの不正なシャットダウンにつながる可能性があります。

むむ、たまたま今回の例では動いていただけ？

> 特に外部ソースからの引数やコマンドを指定する場合に、より安全で予測可能です。

なるほど、自分で適切に処理をした方が確実のようです。

ちなみに記事の後半にある、決定木が一番分かりやすいです。（まさにベストプラクティス）

## CMD と ENTRYPOINT の使い分け

https://docs.docker.com/reference/dockerfile/#cmd

> コンテナで毎回同じ実行可能ファイルを実行する場合は、ENTRYPOINT を CMD と組み合わせて使用​​することを検討してください。ENTRYPOINT を参照してください。ユーザーが docker run に引数を指定すると、CMD で指定されたデフォルトが上書きされますが、デフォルトの ENTRYPOINT は引き続き使用されます。

使い分けというより、両方使うパターンもあるのか！ ということで、早速試してみましょう。

CMD を ENTRYPOINT コマンドのデフォルト引数を定義する方法というのが、一番最初のユースケースとして紹介されてます。

```Dockerfile
FROM ubuntu
RUN set -ex; apt update; apt install psmisc; apt autoremove; rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["sleep"]
CMD ["30"]
```

今までと同じく実行してみると、たしかにデフォルト値として引数が採用されています。

```shell-session
$ docker exec -it tmp pstree -ap
sleep,1 30
```

引数を指定してみましょう。

```shell-session
$ docker run -it --rm --name tmp test 3
```

すぐにスリープが終わりました。オプションをちゃんと渡せてますね。

https://docs.docker.com/reference/dockerfile/#understand-how-cmd-and-entrypoint-interact

ちなみに、Shell形式 と Exec形式の組み合わせについても記載されてます。（とはいえ、こんな挙動になるのが想像しにくいものも・・・） 

実際にはどのように設定されているかは、inspect を使うことで確認をすることができます。
Config セクションに Cmd と Entrypoint が記載されます。

```shell-session
$ docker inspect test
 :
         "Config": {
            "Cmd": [
                "30"
            ],
            "Entrypoint": [
                "sleep"
            ], 
```

CMD を Shell形式で指定した場合は以下のようになります。
```
            "Cmd": [
                "/bin/sh",
                "-c",
                "sleep 10"
            ],
```


ENTRYPOINT は一度設定すると、継承先コンテナで未定義には戻すことができません。

```Dockerfile
FROM ubuntu as build
ENTRYPOINT ["sleep"]
CMD ["30"]

FROM build
ENTRYPOINT
```

上記イメージの inspect を見ることで、ドキュメントの意味が腹に落ちるかと思います。

```
            "Cmd": null,
            "Entrypoint": [
                "/bin/sh",
                "-c",
                ""
            ],
```

継承だけした場合には

```Dockerfile
FROM ubuntu as build
ENTRYPOINT ["sleep"]
CMD ["30"]

FROM build
```
全てが継承されます。
```
            "Cmd": [
                "30"
            ],
            "Entrypoint": [
                "sleep",
            ],
```

継承しCMDだけ変更すると

```Dockerfile
FROM ubuntu as build
ENTRYPOINT ["sleep"]
CMD ["30"]

FROM build
CMD ["10"]
```
ちゃんと反映されます。
```
            "Cmd": [
                "10"
            ],

            "Entrypoint": [
                "sleep"
            ],
```

## まとめ
CMD も ENTRYPOINT どちらとも、デフォルトで起動されるコマンドを指定するものです。

ENTRYPOINT はコンテナを実行可能ファイルとして使用すること明示するもので、継承されるコンテナにも強制をします。（可能ではあるが）上書きをしないことを前提としています。

ENTRYPOINT を CMD と組み合わせ Exec形式 を利用することで、シグナルを適切に処理し予測可能な操作を維持できます。 

- コンテナで必ず実行したいコマンドや引数を ENTRYPOINT で指定
- デフォルト引数や推奨のパラメータを CMD で指定

と使い分けすることにより、継承先で変更して欲しい部分を明示したり、実行されるコマンドを固定したりすることができます。

### 利用例

弊社で利用している OpenResty のベースイメージでは、読み込む設定ファイルを明示するようにしています。

```Dockerfile
ENTRYPOINT ["openresty", "-g", "daemon off;"]
CMD ["-c", "/usr/local/openresty/nginx/conf/nginx.conf"]
```

ちょっと前に自分が書いた Python のバッチでは [Invoke](https://www.pyinvoke.org/) を使って

```Dockerfile
ENTRYPOINT ["invoke"]
CMD ["--list"]
```

デフォルトではタスクリストを表示、タスクを指定することで実行 とコンテナを実行ファイルとして利用しています。

## おわりに

なんかコピペしてるだけな気がする[^2]・・・といったときのコーチングや、コンテナの終了がなんか遅いんだけど・・・ といったときの助けになれば幸いです。

今年は全日埋めることを目標にしています。明日の投稿もお楽しみに！

https://qiita.com/advent-calendar/2024/intimatemerger

[^1]: busybox など軽いイメージを使いたいところですが、僕の環境（ [Multipassで構築したDocker](https://qiita.com/fecker/items/2c602a9913e23bf67dac) ）だと stable-glibc を使わないとうまくいかなかった（process tree が異なった）です。そのあたりの話をすると広がりすぎるので、大人しく ubuntu を利用してます。
[^2]: ENV と ARG の話もちゃんと説明できるか？など質問をしてみたりしようと思っています。