寫 Dockerfile 時,經常搞不清楚 CMDENTRYPOINT 的差異,雖然兩者用途相當雷同,但還是特別查閱了一下 Docker 的官方文件,了解兩者的用途以及差別,並且透過實際的範例驗證後,記錄成本文。

本文環境

Exec form vs. Shell form

在理解 CMDENTRYPOINT 的差異之前,必須先了解 2 個重要的格式:

  1. Shell form
    command param1 param2
  2. Exec form
    ["executable","param1","param2"]

上述 2 種都是 CMDENTRYPOINT 可支援的格式。

以執行 ls -alh 指令為例, shell form 可以直接將 ls -alh 接在 CMD 以及 ENTRYPOINT 後方即可,就像執行 shell 一樣,例如:

CMD ls -alh

vs.

ENTRYPOINT ls -alh

而 exec form 則需要將指令以 JSON 陣列(array)方式進行表達,例如 ls -alh 指令就必須轉換為 ["ls", "-alh"] , 例如:

CMD ["ls", "-alh"]

vs.

ENTRYPOINT ["ls", "-alh"]

此外, exec form 也是 Docker 較推薦使用的方式,所以一般會較少看到開發者使用 shell form 。

CMD

以下引用自 Docker 官方文件,說明 CMD 的用途:

The main purpose of a CMD is to provide defaults for an executing container. These defaults can include an executable, or they can omit the executable, in which case you must specify an ENTRYPOINT instruction as well.

簡而言之,CMD 主要用途是為執行容器(container)時提供預設值,預設值可以是一個可執行檔(executable)以及其需要的參數;也可以只是單純提供參數的部分,不過這種用法一定要搭配 ENTRYPOINT 使用,也就是 CMD 只是作為 ENTRYPOINT 所需的預設參數。

CMD 的範例可以參考 Python:3.10.1-alpine3.15 的 Dockerfile:

CMD ["python3"]

該 Dockerfile 只有用 CMD 提供 1 個 python3 作為可執行檔(executable) , 因此執行該 Python 的容器(container)時就會啟動 Python 直譯器(interpreter):

$ docker run -it python:3.10.1-alpine3.15
Python 3.10.1 (main, Dec  8 2021, 04:32:13) [GCC 10.3.1 20211027] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>

CMD 的相關規則

Dockerfile should specify at least one of CMD or ENTRYPOINT commands.

There can only be one CMD instruction in a Dockerfile. If you list more than one CMD then only the last CMD will take effect.

Dockerfile 撰寫規則中規定必須包含至少 1 個 CMDENTRYPOINT 在內。如果有多個 CMD 指令被寫在 Dockerfile 內,只有最後 1 個有效。

但以下的 Dockerfile 範例不包含任何 CMDENTRYPOINT 也能夠通過這個規則:

# Dockerfile
FROM python:3.10.1-alpine3.15

上述 Dockerfile 編譯指令:

$ docker build -t mypy:latest .

這是由於 python:3.10.1-alpine3.15 中已經包含 CMD 的原因,當我們以 python:3.10.1-alpine3.15 為基底(base)時,就會繼承其 CMDENTRYPOINT , 如果想覆蓋原始的 CMDENTRYPOINT 設定,只需要明確指定 CMDENTRYPOINT 即可,例如:

# Dockerfile
FROM python:3.10.1-alpine3.15
CMD ["echo", "$HOME"]

上述 Dockerfile 編譯與執行結果如下:

$ docker build -t mypy:latest .
$ docker run mypy:latest
$HOME

從上述結果可以發現:環境變數 $HOME 並沒有成功被列印!

這是由於 exec form 內的指令並不是透過 shell 環境所執行的原因,因此若要讓環境變數也能夠生效,就必須使用 shell form 設定 CMD , 或者明確以 ["/bin/sh", "-c", "<command> [param1] [param2]..."] 方式設定 CMD , 例如以下 2 個範例,都能達到相同效果:

範例 1:

# Dockerfile
FROM python:3.10.1-alpine3.15
CMD echo $HOME

範例 2:

# Dockerfile
FROM python:3.10.1-alpine3.15
CMD ["/bin/sh", "-c", "echo $HOME"]

上述 2 個範例編譯與執行結果如下:

$ docker build -t mypy:latest .
$ docker run mypy:latest
/root

最後,當我們執行 docker run [OPTIONS] IMAGE [COMMAND] [ARG...] 指令時,其實等同於設定 CMD :

If the user specifies arguments to docker run then they will override the default specified in CMD .

值得注意的是,以 docker run 指令執行 CMD 也同樣會遇到前述環境變數的問題,如下 2 種指令所示,可以發現 1 個無法正確列印環境變數,另 1 個則可以:

$ docker run -it python:3.10.1-alpine3.15 echo \$PYTHON_VERSION
$PYTHON_VERSION
$ docker run -it python:3.10.1-alpine3.15 /bin/sh -c "echo \$PYTHON_VERSION"
3.10.1

ENTRYPOINT

在沒有同時使用 CMD 以及 ENTRYPOINT 的情況下,基本上可以說 CMD 以及 ENTRYPOINT 擁有相同的功用,都能夠讓容器(container)啟動時執行特定程式(當然也同樣支援 shell form 以及 exec form)。

但兩者仍有區別,根據 Docker 官方文件所言,如果要將整個容器當作可執行檔(executable)時,就應該使用 ENTRYPOINT

ENTRYPOINT should be defined when using the container as an executable.

單就敘述而言,其實不好理解 “using the container as an executable” 的意思,但透過範例將會很好理解。

以下 Dockerfile 範例透過 ENTRYPOINT 在容器啟動時,執行 Python 的 HTTP server:

# Dockerfile
FROM python:3.10.1-alpine3.15
ENTRYPOINT ["python", "-m", "http.server"]

以下是編譯以及執行的結果:

$ docker build -t mypy:latest .
$ docker run -it mypy:latest
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

接著,試著透過 docker run [OPTIONS] IMAGE [COMMAND] [ARG...] 指令執行 mypy:latest 內的 ls 試試:

$ docker run -it mypy:latest ls
usage: server.py [-h] [--cgi] [--bind ADDRESS] [--directory DIRECTORY] [port]
server.py: error: argument port: invalid int value: 'ls'

從上述結果可以看到使用 ENTRYPOINT 後,就無法再像 CMD 那樣輕易執行容器內的相關指令, docker run 指令中的 [COMMAND] [ARG...] 部分被附加(append)到 ENTRYPOINT 後並執行,讓整個容器就像被包裝成可執行檔(executable)一樣, docker run 指令中的 [COMMAND] [ARG...] 對該容器而言就是參數,這也是為何上述結果出現 error: argument port: invalid int value: 'ls' 錯誤訊息,這是由於最後容器執行指令變成 python -m http.server ls 的緣故。

那如果使用了 ENTRYPOINT , 同時也想要具備可以任意執行容器內的指令的能力時,該怎麼辦?

可以參考 NGINX 的做法,該 Dockerfile 使用 ENTRYPOINT ["entrypoint.sh"] 處理透過 docker run 指令傳進來的字串,在字串符合特定規則時,觸發相對應的行為,否則就統一交給 exec "$@" 執行:

#!/bin/sh
# vim:sw=4:ts=4:et

set -e

if [ -z "${NGINX_ENTRYPOINT_QUIET_LOGS:-}" ]; then
    exec 3>&1
else
    exec 3>/dev/null
fi

if [ "$1" = "nginx" -o "$1" = "nginx-debug" ]; then
    if /usr/bin/find "/docker-entrypoint.d/" -mindepth 1 -maxdepth 1 -type f -print -quit 2>/dev/null | read v; then
        (...略...)
    fi
fi

exec "$@"

這就是為什麼 NGINX 雖然使用 ENTRYPOINT , 但仍能任意執行容器內的指令的原因,例如以下執行 NGINX 容器內的 ls -alh 指令:

docker run -it --rm nginx ls -alh
total 80K
drwxr-xr-x   1 root root 4.0K Dec 27 16:04 .
drwxr-xr-x   1 root root 4.0K Dec 27 16:04 ..
-rwxr-xr-x   1 root root    0 Dec 27 16:04 .dockerenv
drwxr-xr-x   2 root root 4.0K Dec 20 00:00 bin
drwxr-xr-x   2 root root 4.0K Dec 11 17:25 boot
drwxr-xr-x   5 root root  360 Dec 27 16:04 dev
drwxr-xr-x   1 root root 4.0K Dec 21 03:00 docker-entrypoint.d
-rwxrwxr-x   1 root root 1.2K Dec 21 03:00 docker-entrypoint.sh
drwxr-xr-x   1 root root 4.0K Dec 27 16:04 etc
drwxr-xr-x   2 root root 4.0K Dec 11 17:25 home
drwxr-xr-x   1 root root 4.0K Dec 20 00:00 lib
drwxr-xr-x   2 root root 4.0K Dec 20 00:00 lib64
drwxr-xr-x   2 root root 4.0K Dec 20 00:00 media
drwxr-xr-x   2 root root 4.0K Dec 20 00:00 mnt
drwxr-xr-x   2 root root 4.0K Dec 20 00:00 opt
dr-xr-xr-x 186 root root    0 Dec 27 16:04 proc
drwx------   2 root root 4.0K Dec 20 00:00 root
drwxr-xr-x   3 root root 4.0K Dec 20 00:00 run
drwxr-xr-x   2 root root 4.0K Dec 20 00:00 sbin
drwxr-xr-x   2 root root 4.0K Dec 20 00:00 srv
dr-xr-xr-x  13 root root    0 Dec 27 16:04 sys
drwxrwxrwt   1 root root 4.0K Dec 21 03:00 tmp
drwxr-xr-x   1 root root 4.0K Dec 20 00:00 usr
drwxr-xr-x   1 root root 4.0K Dec 20 00:00 var

如果好奇容器正在執行何種指令,可以透過 docker ps --no-trunc 指令顯示。

以下為執行 NGINX 後,並透過 docker ps --no-trunc 列印容器執行指令的範例:

$ docker run -it -d --rm -p 8888:80 nginx nginx-debug -g 'daemon off;'
$
$ docker ps --no-trunc --format "{{.Image}} {{.Command}}"
nginx "/docker-entrypoint.sh nginx-debug -g 'daemon off;'"

順帶一提,除了 NGINX 的做法之外,其實也可以用 docker run 指令中的 --entrypoint 參數覆蓋改寫 ENTRYPOINT ,如此就能夠任意執行容器內的指令:

$ docker run -it --rm --entrypoint /bin/sh nginx

CMD 與 ENTRYPOINT 的交互作用表

最後, Docker 官方文件其實有整理 CMDENTRYPOINT表格,讓大家了解 CMD 以及 ENTRYPOINT 如何交互作用:

以上!

Happy Coding!

References

https://docs.docker.com/engine/reference/builder/#cmd

https://docs.docker.com/engine/reference/builder/#entrypoint