目录
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/docker/docker-init/)。
更新于 2026年10月14日