目錄
Dockerfile 指令大全:FROM、RUN、COPY、CMD、ENTRYPOINT 完整說明
上一節用一個小範例寫出第一個 Dockerfile。這一節把每個指令說明清楚,並用小實驗實際比較最容易搞混的幾組指令。
基本規則
- 指令不分大小寫,但慣例用大寫,方便和參數區分。
- 由上往下依序執行,第一個指令(除了
ARG和註解外)必須是FROM。 #開頭是註解。- 一行太長可以用
\換行。
FROM:基礎映像檔
FROM python:3.14-slim
FROM --platform=linux/amd64 node:24-alpine
FROM scratch # 完全空白的映像檔,用來放靜態編譯的程式
FROM golang:1.27 AS builder # 多階段建置時幫階段命名
RUN:建置時執行指令
# shell 格式:透過 /bin/sh -c 執行,可以用 && 和管線
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
# exec 格式:不經過 shell
RUN ["pip", "install", "--no-cache-dir", "flask"]
每個 RUN 都會產生一層。apt-get update 和 apt-get install 要寫在同一個 RUN 裡,否則快取的 update 層可能過期,裝到舊版本的套件。最後清掉套件清單(rm -rf /var/lib/apt/lists/*)可以讓映像檔小一點,實際差多少見 建置快取與最佳化。
COPY 與 ADD:複製檔案
COPY package.json package-lock.json ./
COPY src/ ./src/
COPY --chown=app:app . .
COPY --from=builder /app/dist ./dist # 從其他建置階段複製
ADD 和 COPY 很像,但遇到 tar 壓縮檔時行為不同。實驗:準備一個 files.tar.gz(裡面有 files/a.txt、files/b.txt),分別用兩種指令複製:
FROM alpine:3.23
COPY files.tar.gz /copy/
ADD files.tar.gz /add/
CMD ["sh", "-c", "echo '--- COPY:'; ls -R /copy; echo '--- ADD:'; ls -R /add"]
$ docker build -q -t add-demo .
sha256:2466246e51b76cd547fa913fce223fccc227ae19b9e53006b9ce203e1afc8ec4
$ docker run --rm add-demo
--- COPY:
/copy:
files.tar.gz
--- ADD:
/add:
files
/add/files:
a.txt
b.txt
COPY原封不動地複製了files.tar.gz。ADD自動解壓縮了,/add底下直接是files/a.txt、files/b.txt。
ADD 的來源還可以是網址(自動下載)。這些「自動行為」容易造成意外,官方建議一般情況都用 COPY,只在需要解壓縮時才用 ADD。
WORKDIR:工作目錄
WORKDIR /app
之後的 RUN、COPY、CMD 等都在這個目錄下執行,目錄不存在會自動建立。請用 WORKDIR,不要寫 RUN cd /app:每個 RUN 都是獨立的 shell,cd 不會延續到下一行。
ENV 與 ARG:變數
| ARG | ENV | |
|---|---|---|
| 何時有效 | 只在建置時 | 建置時與容器執行時 |
| 怎麼設定 | docker build --build-arg NAME=值 | Dockerfile 或 docker run -e |
| 容器裡看得到嗎 | 看不到 | 看得到 |
實驗:
FROM alpine:3.23
ARG APP_VERSION=dev
ENV APP_ENV=production
RUN echo "building version $APP_VERSION" > /build-info.txt
CMD ["sh", "-c", "cat /build-info.txt; echo APP_ENV=$APP_ENV; echo APP_VERSION=${APP_VERSION:-<not set>}"]
$ docker build -q --build-arg APP_VERSION=1.2.0 -t args-demo .
sha256:3537c3e70baef148c3481f97bf325963b4809a15b806a2e38fed7e8eb5f50619
$ docker run --rm args-demo
building version 1.2.0
APP_ENV=production
APP_VERSION=<not set>
$ docker history --no-trunc --format "{{.CreatedBy}}" args-demo | head -n 3
CMD ["sh" "-c" "cat /build-info.txt; echo APP_ENV=$APP_ENV; echo APP_VERSION=${APP_VERSION:-<not set>}"]
RUN |1 APP_VERSION=1.2.0 /bin/sh -c echo "building version $APP_VERSION" > /build-info.txt # buildkit
ENV APP_ENV=production
- 建置時傳入的
APP_VERSION=1.2.0有被RUN用到(寫進了/build-info.txt),但容器執行時已經看不到它(<not set>)。 ENV設定的APP_ENV在容器裡看得到。- 注意
docker history的第二行:RUN |1 APP_VERSION=1.2.0 ...,ARG 的值被記錄在映像檔的歷史裡,任何拿到映像檔的人都看得到。
所以絕對不要用
ARG或ENV傳密碼、API 金鑰。建置時需要機密資料,請用RUN --mount=type=secret,見 環境變數與 .env 檔。
FROM 之前宣告的 ARG 只能用在 FROM 裡;FROM 之後要再用,需要重新寫一次 ARG 名稱。
EXPOSE:說明連接埠
EXPOSE 3000
EXPOSE 53/udp
EXPOSE 只是文件用途,告訴使用者這個容器會用哪個連接埠,不會真的開放,實際對應還是要靠 docker run -p。唯一的作用是搭配 -P,把所有 EXPOSE 的連接埠對應到隨機的主機連接埠(nginx 映像檔 EXPOSE 80):
$ docker run --rm -d --name exp -P nginx:1.30
857560427b1218d038314394f03cc0eb8edf950062304efaf2f5f3dc995f6ef6
$ docker port exp
80/tcp -> 0.0.0.0:32768
80/tcp -> [::]:32768
$ docker rm -f exp
exp
USER:以非 root 身分執行
預設容器內是 root。改用一般使用者執行,即使程式被入侵,影響範圍也比較小:
FROM alpine:3.23
RUN addgroup -S app && adduser -S -G app app
USER app
CMD ["sh", "-c", "whoami; id"]
$ docker build -q -t user-demo .
sha256:bdcaab4c6133a5446cdaaa1ca3353e01a690dc9fd0647458543abcec0d6041f1
$ docker run --rm user-demo
app
uid=100(app) gid=101(app) groups=101(app),101(app)
$ docker run --rm alpine:3.23 whoami
root
很多官方映像檔已經內建一般使用者,例如 node 映像檔有 node 使用者,可以直接 USER node。
VOLUME:宣告資料目錄
VOLUME /var/lib/data
宣告這個目錄應該存放持久資料。執行時如果沒有掛載,Docker 會自動建立一個匿名 Volume(Volume 一節有實際的例子)。實務上通常直接在 docker run -v 或 Compose 裡指定,自己的 Dockerfile 不一定需要寫 VOLUME。
HEALTHCHECK:健康檢查
Docker 會定期執行健康檢查指令,結束代碼 0 代表健康,非 0 代表不健康:
FROM nginx:1.30-alpine
HEALTHCHECK --interval=5s --timeout=3s --retries=3 \
CMD wget -q --spider http://localhost/ || exit 1
實驗:啟動後等幾秒,再故意刪掉首頁讓 nginx 回傳錯誤:
$ docker run -d --name health health-demo
52bfa4cce8024630797ba6f33ba99dea44f80399fbadd617b2d04a8ac7f8e3b1
$ docker ps --filter name=health
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
52bfa4cce802 health-demo "/docker-entrypoint.…" 1 second ago Up Less than a second (health: starting) 80/tcp health
$ docker ps --filter name=health
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
52bfa4cce802 health-demo "/docker-entrypoint.…" 9 seconds ago Up 8 seconds (healthy) 80/tcp health
$ docker exec health rm /usr/share/nginx/html/index.html
$ docker ps --filter name=health
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
52bfa4cce802 health-demo "/docker-entrypoint.…" 29 seconds ago Up 28 seconds (unhealthy) 80/tcp health
$ docker inspect --format '{{range .State.Health.Log}}{{.ExitCode}} {{.Output}}{{println}}{{end}}' health
0
1 wget: server returned error: HTTP/1.1 403 Forbidden
1 wget: server returned error: HTTP/1.1 403 Forbidden
1 wget: server returned error: HTTP/1.1 403 Forbidden
1 wget: server returned error: HTTP/1.1 403 Forbidden
$ docker rm -f health
health
狀態的變化:health: starting(剛啟動)→ healthy(檢查通過)→ 刪掉首頁後連續失敗 3 次(--retries=3)→ unhealthy。.State.Health.Log 記錄了每次檢查的結束代碼與輸出。
- 映像檔裡要有
curl或wget等工具才能這樣寫(alpine 內建wget)。 - Compose 可以等服務
healthy之後才啟動依賴它的服務,見 compose.yaml 語法。
CMD 與 ENTRYPOINT:啟動指令
兩者都決定容器啟動時執行什麼,差別在於能不能被覆蓋以及怎麼組合。
CMD:可以被整個取代
FROM alpine:3.23
CMD ["echo", "hello from CMD"]
$ docker build -q -t cmd-demo .
sha256:45b2af259ce828e227961bcc620b973758a1f757d948a7e23e056781afba324d
$ docker run --rm cmd-demo
hello from CMD
$ docker run --rm cmd-demo echo overridden
overridden
docker run 時在映像檔名稱後面寫的指令,會整個取代 CMD。
ENTRYPOINT:固定的主程式
FROM alpine:3.23
ENTRYPOINT ["echo", "ENTRYPOINT says:"]
CMD ["default argument"]
$ docker build -q -t ep-demo .
sha256:32414489000b71c255d808cfd4cda73764b526525229dece3a8f1fd5487fc6d1
$ docker run --rm ep-demo
ENTRYPOINT says: default argument
$ docker run --rm ep-demo my argument
ENTRYPOINT says: my argument
$ docker run --rm --entrypoint date ep-demo
Mon Oct 12 23:59:17 UTC 2026
ENTRYPOINT和CMD同時存在時,實際執行的是ENTRYPOINT + CMD。docker run後面寫的東西只會取代CMD,變成ENTRYPOINT的參數。- 要換掉
ENTRYPOINT本身,用--entrypoint。
| CMD | ENTRYPOINT | |
|---|---|---|
| 作用 | 預設指令或預設參數 | 容器的主要執行檔 |
docker run 映像檔 xxx | xxx 取代 CMD | xxx 附加在 ENTRYPOINT 後面 |
| 怎麼覆蓋 | 直接在映像檔名稱後寫指令 | --entrypoint |
選擇原則:
- 一般應用程式:只寫
CMD,使用者想進 shell 時可以直接docker run -it myapp sh。 - 把容器當成「一個指令」來用(例如 CLI 工具):用
ENTRYPOINT指定程式,CMD放預設參數。
exec 格式與 shell 格式
CMD ["node", "server.js"] # exec 格式(JSON 陣列)
CMD node server.js # shell 格式
shell 格式實際執行的是 /bin/sh -c "node server.js",可以使用環境變數展開和 && 等 shell 語法;exec 格式則直接執行程式,不經過 shell。一般建議使用 exec 格式,行為最明確。
PID 1 與 docker stop 要等 10 秒的問題
容器生命週期 提到,docker stop 有時候要等滿 10 秒。下面用同一個 Node.js 程式(上一節 的 server.js,沒有處理 SIGTERM),比較四種 CMD 寫法,用 docker top 看容器裡的程序,再計時 docker stop:
$ docker top shell-npm
PID USER TIME COMMAND
4155 root 0:00 npm start
4182 root 0:00 {MainThread} node server.js
$ time docker stop shell-npm
shell-npm
real 0m 0.66s
user 0m 0.00s
sys 0m 0.00s
$ docker top exec-npm
PID USER TIME COMMAND
4318 root 0:00 npm start
4347 root 0:00 {MainThread} node server.js
$ time docker stop exec-npm
exec-npm
real 0m 0.67s
user 0m 0.00s
sys 0m 0.00s
$ docker top shell-node
PID USER TIME COMMAND
4488 root 0:00 {MainThread} node server.js
$ time docker stop shell-node
shell-node
real 0m 10.14s
user 0m 0.01s
sys 0m 0.01s
$ docker top exec-node
PID USER TIME COMMAND
4660 root 0:00 {MainThread} node server.js
$ time docker stop exec-node
exec-node
real 0m 10.14s
user 0m 0.01s
sys 0m 0.00s
| CMD 寫法 | 容器裡的 PID 1 | docker stop |
|---|---|---|
CMD npm start | npm | 0.66 秒 |
CMD ["npm", "start"] | npm | 0.67 秒 |
CMD node server.js | node | 10.14 秒 |
CMD ["node", "server.js"] | node | 10.14 秒 |
關鍵不在 shell 格式或 exec 格式,而在誰是 PID 1:
- Linux 對 PID 1 有特別的規則:PID 1 沒有自己處理 SIGTERM 時,訊號會被忽略。node 直接當 PID 1、程式裡又沒有處理 SIGTERM,
docker stop只能等 10 秒後用 SIGKILL 強制結束。 - 由 npm 啟動時,npm 是 PID 1,它會把 SIGTERM 轉給 node;node 不是 PID 1,收到 SIGTERM 就照預設行為結束。
- 第一列的 shell 格式之所以沒有多一層
sh,是因為 Alpine 的sh在只有單一指令時會直接用npm取代自己。
解決方法(擇一):
- 在程式裡處理 SIGTERM,優雅地關閉伺服器(最好的做法,見 Node.js 應用)。
- 執行時加
--init(Compose 寫init: true),由一個小型 init 程序當 PID 1 並轉發訊號。
其他指令
| 指令 | 用途 |
|---|---|
LABEL | 加上中繼資料,例如 LABEL org.opencontainers.image.source="https://github.com/..." |
SHELL | 改變 shell 格式使用的 shell,例如 Windows 容器用 PowerShell |
STOPSIGNAL | 改變 docker stop 送出的訊號(nginx 映像檔設定為 SIGQUIT,見 映像檔 中的 docker history) |
ONBUILD | 當其他映像檔以此為基礎時才執行的指令(少用) |
完整範例:Python 網站
FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd --system app
USER app
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
PYTHONUNBUFFERED=1 讓 Python 的輸出立即寫出,docker logs 才能即時看到。這個 Dockerfile 實際建置與執行的過程見 用 Docker 部署 Python 應用。
重點回顧
RUN、COPY、ARG在建置時生效;CMD、ENTRYPOINT、USER、ENV影響執行時。docker run 映像檔 xxx的xxx會取代 CMD,或附加在 ENTRYPOINT 之後。CMD、ENTRYPOINT用 exec 格式(JSON 陣列)。- 不要用
ARG、ENV傳遞密碼。
動手練習
練習 1:映像檔設定 ENTRYPOINT ["ping"] 和 CMD ["-c", "3", "localhost"]。執行 docker run 映像檔 -c 1 example.com 實際執行的是什麼?
答案
ping -c 1 example.com。執行時的參數取代 CMD,再附加在 ENTRYPOINT 之後。
練習 2:為什麼 ENV NODE_ENV production 會被 docker build --check 警告?
答案
這是舊的寫法,應該寫成 ENV NODE_ENV=production,警告名稱是 LegacyKeyValueFormat。
常見問題
COPY 和 ADD 該用哪個?
一律優先用 COPY。ADD 會自動解壓縮 tar 檔、可以下載網址,行為比較不直覺,只有需要這些功能時才用。
CMD 和 ENTRYPOINT 該用哪個?
大多數應用程式只用 CMD,方便被覆蓋。映像檔像一個指令工具(每次都執行同一個程式、只換參數)時才用 ENTRYPOINT。
怎麼檢查 Dockerfile 寫法有沒有問題?
執行 docker build --check .,它會列出例如 JSONArgsRecommended、LegacyKeyValueFormat 等警告,見 [docker init](/zh-tw/docker/docker-init/)。
更新於 2026年10月14日