目录

Dockerfile 指令大全:FROM、RUN、COPY、CMD、ENTRYPOINT 完整说明

上一节用一个小示例写出第一个 Dockerfile。这一节把每个指令说明清楚,并用小实验实际比较最容易搞混的几组指令。

基本规则

  • 指令不分大小写,但惯例用大写,方便和参数区分。
  • 由上往下依序运行,第一个指令(除了 ARG 和注解外)必须是 FROM。
  • # 开头是注解。
  • 一行太长可以用 \ 换行。
Dockerfile 指令分为构建时生效与运行时生效两类,以及 ENTRYPOINT 与 CMD 的组合方式
构建时(docker build)与运行时(docker run)生效的指令,以及 ENTRYPOINT + CMD 的组合

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:变量

ARGENV
何时有效只在构建时构建时与容器运行时
怎么设置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。
CMDENTRYPOINT
作用默认指令或默认参数容器的主要可执行文件
docker run 镜像 xxxxxx 取代 CMDxxx 附加在 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 1docker stop
CMD npm startnpm0.66 秒
CMD ["npm", "start"]npm0.67 秒
CMD node server.jsnode10.14 秒
CMD ["node", "server.js"]node10.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 取代自己。

解决方法(择一):

  1. 在程序里处理 SIGTERM,优雅地关闭服务器(最好的做法,见 Node.js 应用)。
  2. 运行时加 --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日