在 Dockerfile 中,ENTRYPOINT
和 CMD
是兩個核心指令,用於定義容器啟動時執行的命令。然而,它們都有兩種不同的格式:Shell 格式 (字串) 和 Exec 格式 (陣列)。這兩種格式在底層的運作方式有著天壤之別,直接影響到容器的行為、信號處理 (Signal Handling) 和整體效能。
理解這兩者之間的差異,對於撰寫出健壯、可預測且易於維護的 Dockerfile 至關重要。本文將深入探討這兩種格式的區別,並提供最佳實踐建議。
Shell 格式是將整個命令寫成一個簡單的字串。
語法: INSTRUCTION "command param1 param2"
範例: CMD echo "Hello, Docker"
運作方式:
當你使用 Shell 格式時,Docker 會在內部將你的命令包裹在 /bin/sh -c 中執行。也就是說,CMD echo "Hello, Docker" 實際上等同於執行 /bin/sh -c 'echo "Hello, Docker"'。
優點:
&&
)、管道 (|
) 等 Shell 功能。缺點:
PID 1 問題:你的應用程式不會是容器中的主程序 (PID 1)。/bin/sh
會成為 PID 1,而你的應用程式則是它的一個子程序。這會導致一個嚴重問題:當你執行 docker stop
時,Docker 會向 PID 1 (也就是 /bin/sh
) 發送 SIGTERM
信號。這個 Shell 程序可能不會正確地將信號轉發給你的應用程式,導致應用程式無法優雅地關閉 (graceful shutdown),最終被 SIGKILL
強制終止。
解析問題:命令的解析完全交給 Shell,有時可能會產生非預期的行為。
"陣列"
Exec 格式是將命令和參數寫成一個 JSON 陣列。
語法: INSTRUCTION ["executable", "param1", "param2"]
範例: CMD ["echo", "Hello, Docker"]
運作方式:
當你使用 Exec 格式時,Docker 會直接執行你指定的 executable,並傳入後續的參數。這個過程完全沒有 Shell 的參與。
優點:
應用程式即為 PID 1:你指定的 executable
會直接成為容器的主程序 (PID 1)。這意味著 docker stop
發送的 SIGTERM
信號會直接被你的應用程式接收,讓你有機會進行清理工作,實現優雅關閉。
行為明確:命令和參數的解析非常清晰,不會受到 Shell 的影響,行為更可預測。
缺點:
CMD ["/bin/sh", "-c", "echo $HOME"]
。ENTRYPOINT
與 CMD
的協同工作ENTRYPOINT
和 CMD
可以獨立使用,也可以組合使用。當它們組合時,CMD
的值會成為 ENTRYPOINT
的預設參數。
ENTRYPOINT | CMD | 最終執行的命令 | 說明 |
(無) | ["/bin/ls", "-l"] |
/bin/ls -l |
CMD 提供完整的執行命令。 |
["/usr/bin/wc"] |
["-l"] |
/usr/bin/wc -l |
最佳實踐:ENTRYPOINT 定義主命令,CMD 提供預設參數。 |
"/bin/echo" |
"World" |
/bin/sh -c '/bin/echo' World |
CMD 的值會被附加到 ENTRYPOINT 命令的末尾。 |
["/bin/echo"] |
"Hello World" |
/bin/echo /bin/sh -c "Hello World" |
這種組合方式通常不是我們想要的結果,應避免使用。 |
核心規則:
當 ENTRYPOINT
使用 Exec 格式時,CMD
的值(無論是 Shell 還是 Exec 格式)都會被解析為 ENTRYPOINT
的參數。
當 ENTRYPOINT
使用 Shell 格式時,CMD
的值會被完全忽略。
docker exec
與 Dockerfile 繼承ENTRYPOINT
和 CMD
的行為不僅僅影響 docker run
,在容器的生命週期和映像檔的繼承中,它們的表現也值得我們深入探討。
docker exec
的行為docker exec
指令的用途是在一個已經在運行的容器中執行一個新的命令。
一個常見的誤解是 docker exec
會以某種方式與 ENTRYPOINT
互動。事實上,docker exec
完全繞過了 ENTRYPOINT
和 CMD
。這兩個指令只在容器啟動時(docker run
或 docker create
+ docker start
)被評估一次,用於設定容器的主程序(PID 1)。
docker exec
啟動的是一個全新的、獨立的程序,它與容器的主程序是兄弟關係。
簡單來說:
docker run [image]
-> 觸發 ENTRYPOINT
/ CMD
docker exec [container] command
-> 不觸發 ENTRYPOINT
/ CMD
範例:
假設你的 Dockerfile 如下:
FROM ubuntu
ENTRYPOINT ["top", "-b"]
你啟動容器 docker run -d --name my-top-container my-image
,這個容器的主程序是 top -b
。
現在,如果你執行 docker exec my-top-container ls -l
,你並不是在 ENTRYPOINT
的 top
程序中執行 ls -l
。Docker 只是在容器的命名空間內直接啟動了一個新的 ls -l
程序。
FROM
)當你的 Dockerfile 從一個基礎映像檔(Base Image)繼承時,ENTRYPOINT
和 CMD
的指令會被繼承下來,但可以被覆蓋。規則如下:
CMD
的覆蓋:如果在新的 Dockerfile 中定義了 CMD
,它將永遠覆蓋掉基礎映像檔中的 CMD
。
ENTRYPOINT
的覆蓋:如果在新的 Dockerfile 中定義了 ENTRYPOINT
,它將永遠覆蓋掉基礎映像檔中的 ENTRYPOINT
。
這意味著,如果你只想修改基礎映像檔的預設參數,你只需要覆蓋 CMD
即可。
範例:
假設我們有一個基礎映像檔 base-image:
# Dockerfile for base-image
FROM ubuntu
ENTRYPOINT ["ping", "-c", "3"]
CMD ["localhost"]
這個映像檔預設會執行 ping -c 3 localhost
。
情境一:只覆蓋 CMD
# Dockerfile for new-image-1
FROM base-image
CMD ["google.com"]
new-image-1
將會繼承 ENTRYPOINT ["ping", "-c", "3"]
,但會用新的 CMD
作為參數。最終執行命令為:ping -c 3 google.com
。
情境二:覆蓋 ENTRYPOINT
# Dockerfile for new-image-2
FROM base-image
ENTRYPOINT ["curl"]
new-image-2
覆蓋了 ENTRYPOINT
,但繼承了基礎映像檔的 CMD ["localhost"]
。最終執行命令為:curl localhost
。這是一個容易出錯的地方,因為繼承來的 CMD
可能不適用於新的 ENTRYPOINT
。
情境三:重置 ENTRYPOINT
如果你想完全擺脫基礎映像檔的 ENTRYPOINT,只使用 CMD,你可以將 ENTRYPOINT 設為一個空陣列。
# Dockerfile for new-image-3
FROM base-image
ENTRYPOINT []
CMD ["echo", "Hello from new image"]
new-image-3
將會執行 echo "Hello from new image"
,完全忽略了基礎映像檔的 ENTRYPOINT
。
在許多情境下,我們希望容器在啟動主應用程式之前,能先執行一些初始化腳本。同時,我們也希望能保有直接傳入參數,或是啟動一個 Shell 來進行偵錯的彈性。這可以透過撰寫一個 entrypoint.sh
腳本來完美實現。
entrypoint.sh
腳本範例這個腳本的邏輯是檢查傳入的第一個參數 ($1
)。如果這個參數看起來不像是要執行的命令(例如,它以 -
開頭,是一個選項),腳本就會在所有參數前加上預設的應用程式命令。否則,它就直接執行使用者傳入的命令。
#!/bin/sh
# 使用 `set -e` 確保腳本在任何指令失敗時立即退出
set -e
# 這裡可以放置你的初始化程式碼
echo "Running pre-start initializations..."
# 例如:等待資料庫連線、從 secret manager 取得機敏資料、產生設定檔等
# ...
# 判斷傳入的參數
# 邏輯:如果第一個參數以 `-` 開頭,或者第一個參數不是一個系統中存在的命令
# 我們就假設使用者想要執行預設的 CMD,並將傳入的參數作為 CMD 的參數。
# `command -v` 用於檢查命令是否存在
if [ "${1#-}" != "$1" ] || ! command -v "$1" > /dev/null; then
# 在所有參數前加上預設的應用程式命令
# 這裡的 `my-app` 是你容器中主要執行的程式
set -- my-app "$@"
fi
# 使用 `exec` 來執行最終的命令
# `exec` 會用新的程序取代目前的 shell 程序,這確保了主應用程式
# 成為容器的 PID 1,從而能正確地接收來自 `docker stop` 的信號。
exec "$@"
接著,在 Dockerfile 中複製這個腳本並設定為 ENTRYPOINT
。
FROM ubuntu
# 假設你的應用程式叫做 my-app
# COPY ./my-app /usr/local/bin/my-app
# 複製 entrypoint 腳本並給予執行權限
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# 設定 entrypoint
ENTRYPOINT ["entrypoint.sh"]
# 提供預設執行的命令及參數
CMD ["--help"]
透過這樣的組合,你可以實現極大的彈性:
執行預設命令: docker run my-image
entrypoint.sh
接收到 CMD
的內容,也就是 "--help"
。
因為 "--help"
以 -
開頭,腳本會執行 set -- my-app --help
。
最終 exec my-app --help
被執行。
傳遞參數給預設命令: docker run my-image --version
entrypoint.sh
接收到 "--version"
。
同樣地,因為 "--version"
以 -
開頭,腳本會執行 set -- my-app --version
。
最終 exec my-app --version
被執行。
執行完全不同的命令(例如,進入 Shell 偵錯): docker run -it my-image bash
entrypoint.sh
接收到 "bash"
。
腳本檢查發現 bash
是一個存在的命令 (command -v bash
成功)。
if
判斷式不成立,腳本直接跳到最後。
最終 exec bash
被執行,使用者獲得一個互動式的 Shell。
docker exec
排查: docker exec -it <container-id> bash
docker exec
完全繞過 ENTRYPOINT
,直接在容器內啟動 bash
程序,因此這種偵錯方式依然有效。這個模式兼顧了標準化(初始化流程)與靈活性(命令覆蓋),是建構高品質 Docker 映像檔時非常推薦的作法。
根據以上的分析,我們可以得出以下結論和最佳實踐:
優先使用 Exec 格式:為了讓你的應用程式能正確接收系統信號並成為 PID 1,始終優先為 ENTRYPOINT
和 CMD
選擇 Exec 格式 (陣列)。
組合使用 ENTRYPOINT
和 CMD
:將你的容器想像成一個可執行的程式。
使用 ENTRYPOINT
(Exec 格式) 來設定固定的、不會改變的主命令。
使用 CMD
(Exec 格式) 來為 ENTRYPOINT
提供一組預設的、可被覆蓋的參數。
範例 (推薦):
FROM ubuntu
ENTRYPOINT ["ping"]
CMD ["localhost"]
預設執行 ping localhost
。
使用者可以輕鬆覆蓋參數:docker run my-image google.com
,這將會執行 ping google.com
。
透過遵循這些原則,你可以建立出行為一致、穩定且易於使用的 Docker 映像檔。