Dockerfile 指令详解


RUN 执行命令

基本语法

RUN <command>
RUN ["executable", "param1", "param2"]

RUN 指令是 Dockerfile 中最常用的指令之一。它在当前镜像层之上创建一个新层,执行指定的命令,并提交结果。

两种格式对比

Shell 格式

RUN apt-get update

  • 特点:默认通过 /bin/sh -c 执行。
  • 优势:可以使用环境变量、管道、重定向等 Shell 特性。
  • 示例:RUN echo "Hello" > /test.txt
Exec 格式

RUN ["apt-get", "update"]

  • 特点:直接调用可执行文件,不经过 Shell。
  • 优势:避免 Shell 字符串解析问题,适用于参数中包含特殊字符的情况。
  • 注意:无法使用 $VAR 环境变量替换(除非显式调用 shell)。

常见最佳实践

组合命令(减少层数)

每一个 RUN 指令都会新建一层镜像。为了减少镜像体积和层数,应使用 && 连接命令

糟糕的写法(创建 3 层):

RUN apt-get update
RUN apt-get install -y nginx
RUN rm -rf /var/lib/apt/lists/*

推荐写法(创建 1 层):

RUN apt-get update && \
    apt-get install -y nginx && \
    rm -rf /var/lib/apt/lists/*
清理缓存

在安装完软件后,立即清除缓存,可以显著减小镜像体积。

  • Debian/Ubuntu:
    RUN apt-get update && apt-get install -y package-bar \
        && rm -rf /var/lib/apt/lists/*
  • Alpine:
    RUN apk add --no-cache package-bar
使用 set -e 和 pipefail

默认情况下,管道命令 cmd1 | cmd2 只要 cmd2 成功,整个 RUN 就视为成功。

隐蔽的错误:

# 如果下载失败,gzip 可能会报错,但如果不影响后续,构建可能继续
RUN wget http://error-url | gzip -d > file

推荐写法:

SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN wget http://url | gzip -d > file
RUN set -eux; \
wget http://url | gzip -d > file
  • -e(Exit on error)‌
    当脚本中的任意一条命令返回非零退出状态(即执行失败)时,脚本将立即终止。这有助于防止错误状态继续传播,使脚本更可靠。
  • -u(Unset variable error)‌
    如果脚本尝试使用未定义的变量,则会报错并退出。这样可以避免因变量拼写错误或未初始化而导致的难以发现的错误
  • -x(Debug trace)
    在执行每条命令前,打印该命令及其参数。这使得脚本的执行过程更加透明,便于调试。
常见问题
  • 为什么 RUN cd /app 不生效?
    RUN cd /app
    RUN touch hello.txt

结果:hello.txt 会出现在根目录 /,而不是 /app

原因:每个 RUN 都在一个新的 Shell/容器 环境中执行。cd 只影响当前 RUN 的环境。

解决:使用 WORKDIR 指令。

WORKDIR /app
RUN touch hello.txt
  • 环境变量不生效?
RUN export MY_VAR=hello
RUN echo $MY_VAR

结果:输出为空。

原因:同上,环境变量只在当前 RUN 有效。

解决:使用 ENV 指令,或在同一行 RUN 中导出。

ENV MY_VAR=hello
RUN echo $MY_VAR
高级技巧
  • 使用 BuildKit 的挂载缓存

BuildKit 支持在 RUN 指令中使用 --mount 挂载缓存,加速构建。

# 缓存 apt 包
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update && apt-get install -y gcc
# 缓存 Go 模块
RUN --mount=type=cache,target=/go/pkg/mod \
    go build -o app
  • 挂载密钥

安全地使用 SSH 密钥或 Token,而不将其记录在镜像中。

RUN --mount=type=secret,id=mysecret \
    cat /run/secrets/mysecret

COPY 复制文件

基本语法

COPY [选项] <源路径>... <目标路径>
COPY [选项] ["<源路径1>", "<源路径2>", ... "<目标路径>"]

COPY 指令将构建上下文中的文件或目录复制到镜像内。

基本用法

复制单个文件
# 复制文件到指定目录
COPY package.json /app/

# 复制文件并重命名
COPY config.json /app/settings.json
复制多个文件
# 复制多个指定文件
COPY package.json package-lock.json /app/

# 使用通配符
COPY *.json /app/
COPY src/*.js /app/src/
复制目录
# 复制整个目录的内容(不是目录本身)
COPY src/ /app/src/

注意:复制目录时,复制的是目录的内容,不包含目录本身。

构建上下文:              镜像内:
src/                     /app/src/
├── index.js      →      ├── index.js
└── utils.js             └── utils.js

通配符规则

COPY 支持 Go 的 filepath.Match 通配符规则:

通配符说明示例
*匹配任意字符序列*.json
?匹配单个字符config?.json
[abc]匹配括号内任一字符[abc].txt
[a-z]匹配范围内字符file[0-9].txt
COPY hom* /mydir/       # home.txt, homework.md 等
COPY hom?.txt /mydir/   # home.txt, homy.txt 等
COPY app[0-9].js /app/  # app0.js ~ app9.js

目标路径

绝对路径
COPY app.js /usr/src/app/
相对路径(基于 WORKDIR)
WORKDIR /app
COPY package.json ./        # 复制到 /app/package.json
COPY src/ ./src/            # 复制到 /app/src/
自动创建目录

如果目标目录不存在,Docker 会自动创建:

# /app/config/ 不存在也会自动创建
COPY settings.json /app/config/

修改文件所有者

使用 --chown 选项设置文件的用户和组:

# 使用用户名和组名
COPY --chown=node:node package.json /app/

# 使用 UID 和 GID
COPY --chown=1000:1000 . /app/

# 只指定用户
COPY --chown=node . /app/

结合 USER 指令使用,确保应用以非 root 用户运行。

保留文件元数据

COPY 会保留源文件的元数据:

  • 读、写、执行权限
  • 修改时间

这对于脚本文件特别重要:

# start.sh 的可执行权限会被保留
COPY start.sh /app/

COPY vs ADD

特性COPYADD
复制本地文件
自动解压 tar
支持 URL✅ 不推荐
推荐程度✅ 推荐⚠️ 特殊场景使用
# 推荐:使用 COPY
COPY app.tar.gz /app/
RUN tar -xzf /app/app.tar.gz

# ADD 会自动解压(行为不明显,不推荐)
ADD app.tar.gz /app/

建议:除非需要自动解压 tar 文件,否则始终使用 COPY。明确的行为比隐式的魔法更好。

多阶段构建中的 COPY

从其他构建阶段复制
# 构建阶段
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# 生产阶段
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
# 使用 --link 后,文件以独立层添加,不依赖前序指令
COPY --link --from=builder /app/dist /usr/share/nginx/html

--link 的优势:

  • 更高效利用构建缓存
  • 并行化构建过程
  • 加速多阶段构建
.dockerignore

使用 .dockerignore 排除不需要复制的文件:

# .dockerignore
node_modules
.git
.env
*.log
Dockerfile
.dockerignore

这可以:

  • 减小构建上下文大小
  • 加速构建
  • 避免复制敏感文件

最佳实践

利用缓存,先复制依赖文件
# ✅ 好:先复制依赖定义,再安装,最后复制代码
COPY package.json package-lock.json ./
RUN npm install
COPY . .

# ❌ 差:一次性复制所有文件,代码变更会导致重新 npm install
COPY . .
RUN npm install
使用 .dockerignore
# 确保 node_modules 不被复制
COPY . .
# .dockerignore 中应包含 node_modules
明确复制路径
# ✅ 好:明确的路径
COPY src/ /app/src/
COPY package.json /app/

# ❌ 差:过于宽泛
COPY . .

ADD 更高级的复制文件

基本语法

ADD [选项] <源路径>... <目标路径>
ADD [选项] ["<源路径>", ... "<目标路径>"]

ADDCOPY 基础上增加了两个功能:

  • 自动解压 tar 压缩包
  • 支持从 URL 下载文件(不推荐)

自动解压功能

基本用法
# 自动解压 tar.gz 到目标目录
ADD app.tar.gz /app/

ADD 会识别并解压以下格式:

  • .tar
  • .tar.gz / .tgz
  • .tar.bz2 / .tbz2
  • .tar.xz / .txz
实际应用

官方基础镜像通常使用 ADD 解压根文件系统:

FROM scratch
ADD ubuntu-noble-core-cloudimg-amd64-root.tar.gz /
解压过程
ADD app.tar.gz /app/
        │
        ├─ 识别 .tar.gz 格式
        ├─ 自动解压
        └─ 内容放入 /app/

app.tar.gz 包含:        /app/ 目录结果:
├── src/                 ├── src/
│   └── main.py          │   └── main.py
└── config.json          └── config.json

URL 下载功能(不推荐)

基本用法
# 从 URL 下载文件
ADD https://example.com/app.zip /app/app.zip
为什么不推荐
问题说明
权限固定下载的文件权限为 600,通常需要额外 RUN 修改
不会解压URL 下载的压缩包不会自动解压
缓存问题URL 内容变化时不会重新下载
层数增加需要额外 RUN 清理
推荐替代方案
# ❌ 不推荐:使用 ADD 下载
ADD https://example.com/app.tar.gz /tmp/
RUN tar -xzf /tmp/app.tar.gz -C /app && rm /tmp/app.tar.gz

# ✅ 推荐:使用 RUN + curl
RUN curl -fsSL https://example.com/app.tar.gz | tar -xz -C /app

修改文件所有者

ADD --chown=node:node app.tar.gz /app/
ADD --chown=1000:1000 files/ /app/

何时使用 ADD

适合使用 ADD
# 解压本地 tar 文件
FROM scratch
ADD rootfs.tar.gz /

# 解压应用包
ADD dist.tar.gz /app/
不适合使用 ADD
# 复制普通文件(用 COPY)
ADD package.json /app/          # ❌
COPY package.json /app/         # ✅

# 下载文件(用 RUN + curl)
ADD https://example.com/file /  # ❌
RUN curl -fsSL ... -o /file     # ✅

# 需要保留 tar 不解压(用 COPY)
ADD archive.tar.gz /archives/   # ❌ 会解压
COPY archive.tar.gz /archives/  # ✅ 保持原样

缓存行为

ADD 可能导致构建缓存失效:

# 如果 app.tar.gz 内容变化,此层及后续层都需重建
ADD app.tar.gz /app/
RUN npm install

优化建议:

# 先复制依赖文件
COPY package*.json /app/
RUN npm install

# 再添加应用代码
ADD app.tar.gz /app/

最佳实践

  • 默认使用 COPY
    # ✅ 大多数场景使用 COPY
    COPY . /app/
  • 仅在需要解压时使用 ADD
    # ✅ 自动解压场景
    ADD app.tar.gz /app/
  • 不要用 ADD 下载文件
    # ❌ 避免
    ADD https://example.com/file.tar.gz /tmp/
    
    # ✅ 推荐
    RUN curl -fsSL https://example.com/file.tar.gz | tar -xz -C /app
  • 解压后清理
    # 如果需要控制解压过程
    COPY app.tar.gz /tmp/
    RUN tar -xzf /tmp/app.tar.gz -C /app && \
        rm /tmp/app.tar.gz

CMD 容器启动命令

CMD 指令用于指定容器启动时默认执行的命令。它定义了容器的”主进程”。

核心概念:容器的生命周期 = 主进程的生命周期。CMD 指定的命令就是这个主进程。

语法格式

CMD 有三种格式:

格式语法推荐程度
exec 格式CMD ["可执行文件", "参数1", "参数2"]✅ 推荐
shell 格式CMD 命令 参数1 参数2⚠️ 简单场景
参数格式CMD ["参数1", "参数2"]配合 ENTRYPOINT
exec 格式(推荐)
CMD ["nginx", "-g", "daemon off;"]
CMD ["python", "app.py"]
CMD ["node", "server.js"]

优点:

  • 直接执行指定程序,是容器的 PID 1
  • 正确接收信号(如 SIGTERM)
  • 无需 shell 解析
shell 格式
CMD echo "Hello World"
CMD nginx -g "daemon off;"

实际执行:会被包装为 sh -c

# 你写的
CMD echo $HOME

# 实际执行的
CMD ["sh", "-c", "echo $HOME"]

优点:可以使用环境变量、管道等 shell 特性

缺点:主进程是 sh,信号无法正确传递给应用

信号传递问题示例
# ❌ shell 格式:docker stop 会超时
CMD node server.js
# 实际是 sh -c "node server.js"
# SIGTERM 发给 sh,不会传递给 node

# ✅ exec 格式:docker stop 正常工作
CMD ["node", "server.js"]
# SIGTERM 直接发给 node

运行时覆盖 CMD

docker run 后的命令会覆盖 Dockerfile 中的 CMD:

# ubuntu 默认 CMD 是 /bin/bash
$ docker run -it ubuntu        # 进入 bash
$ docker run ubuntu cat /etc/os-release  # 覆盖为 cat 命令
Dockerfile:              docker run 命令:
CMD ["/bin/bash"]   +    cat /etc/os-release
        │                        │
        └───────► 被覆盖 ◄───────┘
                    ↓
           执行: cat /etc/os-release

经典错误:容器立即退出

错误示例
# ❌ 容器启动后立即退出
CMD service nginx start
原因分析
1. CMD service nginx start
   ↓ 被转换为
2. CMD ["sh", "-c", "service nginx start"]
   ↓
3. sh 启动,执行 service 命令
   ↓
4. service 命令将 nginx 放到后台
   ↓
5. service 命令结束,sh 退出
   ↓
6. 容器主进程(sh)退出 → 容器停止
正确做法
# ✅ 让 nginx 在前台运行
CMD ["nginx", "-g", "daemon off;"]

CMD vs ENTRYPOINT

指令用途运行时行为
CMD默认命令docker run 参数会覆盖它
ENTRYPOINT入口点docker run 参数会追加到它后面
单独使用 CMD
# Dockerfile
CMD ["curl", "-s", "http://example.com"]
$ docker run myimage              # 执行默认命令
$ docker run myimage curl -v ...  # 完全覆盖
搭配 ENTRYPOINT
# Dockerfile
ENTRYPOINT ["curl", "-s"]
CMD ["http://example.com"]
$ docker run myimage              # curl -s http://example.com
$ docker run myimage http://other.com  # curl -s http://other.com(参数覆盖)

最佳实践

优先使用 exec 格式
# ✅ 推荐
CMD ["python", "app.py"]

# ⚠️ 仅在需要 shell 特性时使用
CMD ["sh", "-c", "echo $PATH && python app.py"]
确保应用在前台运行
# ✅ 前台运行
CMD ["nginx", "-g", "daemon off;"]
CMD ["apache2ctl", "-D", "FOREGROUND"]
CMD ["java", "-jar", "app.jar"]

# ❌ 不要使用后台服务命令
CMD service nginx start
CMD systemctl start nginx
使用双引号
# ✅ 正确:双引号
CMD ["node", "server.js"]

# ❌ 错误:单引号(JSON 不支持)
CMD ['node', 'server.js']
配合 ENTRYPOINT 使用
# 用于可配置参数的场景
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8080"]

# 运行时可以覆盖端口
$ docker run myapp --port 9000

ENTRYPOINT 入口点

ENTRYPOINT 指定容器启动时运行的入口程序。与 CMD 不同,ENTRYPOINT 定义的命令不会被 docker run 的参数覆盖,而是接收这些参数。

语法格式

exec 格式:ENTRYPOINT ["可执行文件", "参数1"]
shell 格式:ENTRYPOINT 命令 参数

# exec 格式(推荐)
ENTRYPOINT ["nginx", "-g", "daemon off;"]

# shell 格式(不推荐)
ENTRYPOINT nginx -g "daemon off;"

场景一:让镜像像命令一样使用

需求

创建一个查询公网 IP 的”命令”镜像。

使用 CMD 的问题
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
CMD ["curl", "-s", "http://myip.ipip.net"]
$ docker run myip           # ✓ 正常工作
当前 IP:61.148.226.66

$ docker run myip -i        # ✗ 错误!
exec: "-i": executable file not found
# -i 替换了整个 CMD,被当作可执行文件
使用 ENTRYPOINT 解决
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["curl", "-s", "http://myip.ipip.net"]
$ docker run myip           # ✓ 正常工作
当前 IP:61.148.226.66

$ docker run myip -i        # ✓ 添加 -i 参数
HTTP/1.1 200 OK
...
当前 IP:61.148.226.66
交互图示
ENTRYPOINT ["curl", "-s", "http://myip.ipip.net"]
            │
docker run myip -i
            │
            ▼
curl -s http://myip.ipip.net -i
└─────────────────────────────┘
     ENTRYPOINT + docker run 参数

场景二:启动前的准备工作

需求

在启动主服务前执行初始化脚本(如数据库迁移、权限设置)。

实现方式
FROM redis:7-alpine
COPY docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["redis-server"]

docker-entrypoint.sh:

#!/bin/sh
set -e

# 准备工作
echo "Initializing..."

# 如果第一个参数是 redis-server,以 redis 用户运行
if [ "$1" = 'redis-server' ]; then
    chown -R redis:redis /data
    exec gosu redis "$@"
fi

# 其他命令直接执行
exec "$@"
工作流程
docker run redis                    docker run redis bash
        │                                    │
        ▼                                    ▼
docker-entrypoint.sh redis-server   docker-entrypoint.sh bash
        │                                    │
        ├─ 初始化                             ├─ 初始化
        ├─ chown -R redis:redis /data        │
        └─ exec gosu redis redis-server      └─ exec bash
           (以 redis 用户运行)                  (以 root 用户运行)
关键点
  • exec "$@":用传入的参数替换当前进程,确保信号正确传递
  • 条件判断:根据 CMD 不同执行不同逻辑
  • 用户切换:使用 gosu 切换用户(比 su 更适合容器)

场景三:带参数的应用

FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt

ENTRYPOINT ["python", "app.py"]
CMD ["--host", "0.0.0.0", "--port", "8080"]
# 使用默认参数
$ docker run myapp
# 执行: python app.py --host 0.0.0.0 --port 8080

# 覆盖参数
$ docker run myapp --host 0.0.0.0 --port 9000
# 执行: python app.py --host 0.0.0.0 --port 9000

# 完全不同的参数
$ docker run myapp --help
# 执行: python app.py --help

最佳实践

使用 exec 格式
# ✅ 推荐
ENTRYPOINT ["python", "app.py"]

# ❌ 避免 shell 格式
ENTRYPOINT python app.py
提供有意义的默认参数
ENTRYPOINT ["nginx"]
CMD ["-g", "daemon off;"]
入口脚本使用 exec
#!/bin/sh
# 准备工作...

# 使用 exec 替换当前进程
exec "$@"
处理信号

确保 ENTRYPOINT 脚本能正确传递信号:

#!/bin/bash
trap 'kill -TERM $PID' TERM INT

# 启动应用
app "$@" &
PID=$!

# 等待应用退出
wait $PID

ENV 设置环境变量

基本语法

## 格式一:单个变量

ENV <key> <value>

## 格式二:多个变量(推荐)

ENV <key1>=<value1> <key2>=<value2> ...

基本用法

设置单个变量
ENV NODE_VERSION 20.10.0
ENV APP_ENV production
设置多个变量
ENV NODE_VERSION=20.10.0 \
    APP_ENV=production \
    APP_NAME="My Application"

包含空格的值用双引号括起来

环境变量的作用

后续指令中使用
ENV NODE_VERSION=20.10.0

## 在 RUN 中使用

RUN curl -fsSL https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz \
    | tar -xJ -C /usr/local --strip-components=1

## 在 WORKDIR 中使用

ENV APP_HOME=/app
WORKDIR $APP_HOME

## 在 COPY 中使用

COPY . $APP_HOME
容器运行时使用
ENV DATABASE_URL=postgres://localhost/mydb

应用代码中可以读取:

import os
db_url = os.environ.get('DATABASE_URL')
const dbUrl = process.env.DATABASE_URL;

支持环境变量的指令

以下指令可以使用 $变量名${变量名} 格式:

指令示例
RUNRUN echo $VERSION
CMDCMD ["sh", "-c", "echo $HOME"]
ENTRYPOINTENTRYPOINT ["sh", "-c", "echo $HOME"]
COPYCOPY . $APP_HOME
ADDADD app.tar.gz $APP_HOME
WORKDIRWORKDIR $APP_HOME
EXPOSEEXPOSE $PORT
VOLUMEVOLUME $DATA_DIR
USERUSER $USERNAME
LABELLABEL version=$VERSION
FROMFROM node:$NODE_VERSION

运行时覆盖

使用 -e--env 覆盖 Dockerfile 中定义的环境变量:

## 覆盖单个变量

$ docker run -e APP_ENV=development myimage

## 覆盖多个变量

$ docker run -e APP_ENV=development -e DEBUG=true myimage

## 从环境变量文件读取

$ docker run --env-file .env myimage
.env 文件格式
## .env

APP_ENV=development
DEBUG=true
DATABASE_URL=postgres://localhost/mydb

ENV vs ARG

特性ENVARG
生效时间构建时 + 运行时仅构建时
持久性写入镜像,运行时可用构建后消失
覆盖方式docker run -edocker build --build-arg
适用场景应用配置构建参数(如版本号)
组合使用
## ARG 接收构建时参数

ARG NODE_VERSION=20

## ENV 保存到运行时

ENV NODE_VERSION=$NODE_VERSION

## 后续指令使用

RUN curl -fsSL https://nodejs.org/dist/v${NODE_VERSION}/...
## 构建时指定版本

$ docker build --build-arg NODE_VERSION=18 -t myapp .

最佳实践

统一管理版本号
## ✅ 好:版本集中管理

ENV NGINX_VERSION=1.25.0 \
    NODE_VERSION=20.10.0 \
    PYTHON_VERSION=3.12.0

RUN apt-get install nginx=${NGINX_VERSION}

## ❌ 差:版本分散在各处

RUN apt-get install nginx=1.25.0
不要存储敏感信息
## ❌ 错误:密码写入镜像

ENV DB_PASSWORD=secret123

## ✅ 正确:运行时传入

## docker run -e DB_PASSWORD=xxx myimage
为应用提供合理默认值
ENV APP_ENV=production \
    APP_PORT=8080 \
    LOG_LEVEL=info
使用有意义的变量名
## ✅ 好:清晰的命名

ENV REDIS_HOST=localhost \
    REDIS_PORT=6379

## ❌ 差:模糊的命名

ENV HOST=localhost \
    PORT=6379

常见问题

环境变量在 CMD 中不展开

exec 格式不会自动展开环境变量:

## ❌ 不会展开 $PORT

CMD ["python", "app.py", "--port", "$PORT"]

## ✅ 使用 shell 格式或显式调用 sh

CMD ["sh", "-c", "python app.py --port $PORT"]
如何查看容器的环境变量
$ docker inspect mycontainer --format '{{json .Config.Env}}'
$ docker exec mycontainer env
多行 ENV 还是多个 ENV
## ✅ 推荐:减少层数

ENV VAR1=value1 \
    VAR2=value2 \
    VAR3=value3

## ⚠️ 多个 ENV 会创建多层

ENV VAR1=value1
ENV VAR2=value2
ENV VAR3=value3

ARG 构建参数

基本语法

ARG <参数名>[=<默认值>]

ARG 指令定义构建时的变量,可以在 docker build 时通过 --build-arg 传入。

⚠️ 安全提示:不要用 ARG 传递密码等敏感信息,docker history 可以查看所有 ARG 值。

基本用法

定义和使用
## 定义有默认值的 ARG

ARG NODE_VERSION=20

## 使用 ARG

FROM node:${NODE_VERSION}-alpine
RUN echo "Using Node.js $NODE_VERSION"
构建时覆盖
## 使用默认值

$ docker build -t myapp .

## 覆盖默认值

$ docker build --build-arg NODE_VERSION=18 -t myapp .

ARG 的作用域

FROM 之前的 ARG
## FROM 之前的 ARG 只能用于 FROM 指令

ARG REGISTRY=docker.io
ARG IMAGE_NAME=node

FROM ${REGISTRY}/${IMAGE_NAME}:20

## ❌ 这里无法使用上面的 ARG

RUN echo $REGISTRY  # 输出空
FROM 之后重新声明
ARG NODE_VERSION=20

FROM node:${NODE_VERSION}-alpine

## 需要再次声明才能使用

ARG NODE_VERSION
RUN echo "Node version: $NODE_VERSION"
多阶段构建中的 ARG
ARG BASE_VERSION=alpine

FROM node:20-${BASE_VERSION} AS builder
## 需要重新声明

ARG NODE_VERSION=20
RUN echo "Building with Node $NODE_VERSION"

FROM node:20-${BASE_VERSION}
## 每个阶段都需要重新声明

ARG NODE_VERSION=20
RUN echo "Running with Node $NODE_VERSION"

常见使用场景

控制基础镜像版本
ARG ALPINE_VERSION=3.19
FROM alpine:${ALPINE_VERSION}
$ docker build --build-arg ALPINE_VERSION=3.18 .
设置软件版本
ARG NGINX_VERSION=1.25.0

RUN curl -fsSL https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz | tar -xz
配置构建环境
ARG BUILD_ENV=production
ARG ENABLE_DEBUG=false

RUN if [ "$ENABLE_DEBUG" = "true" ]; then \
        npm install --include=dev; \
    else \
        npm install --production; \
    fi
配置私有仓库
ARG NPM_TOKEN

RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc && \
    npm install && \
    rm ~/.npmrc
## 构建时传入 token

$ docker build --build-arg NPM_TOKEN=xxx .

将 ARG 传递给 ENV

如果需要在运行时使用 ARG 的值:

ARG VERSION=1.0.0

## 将 ARG 传递给 ENV

ENV APP_VERSION=$VERSION

## 运行时可用

CMD echo "App version: $APP_VERSION"

预定义 ARG

Docker 提供了一些预定义的 ARG,无需声明即可使用:

ARG说明
HTTP_PROXYHTTP 代理
HTTPS_PROXYHTTPS 代理
NO_PROXY不使用代理的地址
FTP_PROXYFTP 代理
## 构建时使用代理

$ docker build --build-arg HTTP_PROXY=http://proxy:8080 .

最佳实践

为 ARG 提供合理默认值
## ✅ 好:有默认值

ARG NODE_VERSION=20

## ⚠️ 需要每次传入

ARG NODE_VERSION
不要用 ARG 存储敏感信息
## ❌ 错误:密码会被记录在镜像历史中

ARG DB_PASSWORD
RUN echo "password=$DB_PASSWORD" > /app/.env

## ✅ 正确:使用 secrets 或运行时环境变量
使用 ARG 提高构建灵活性
ARG BASE_IMAGE=python:3.12-slim
FROM ${BASE_IMAGE}

## 可以构建不同基础镜像的版本

## docker build --build-arg BASE_IMAGE=python:3.11-alpine .

VOLUME 定义匿名卷

基本语法

VOLUME ["/路径1", "/路径2"]
VOLUME /路径

VOLUME 指令创建挂载点,并标记为外部挂载的卷。

为什么使用 VOLUME

核心原则:容器存储层应该保持无状态,任何运行时数据都应该存储在卷中。

没有 VOLUME:                    使用 VOLUME:
┌─────────────────────┐         ┌─────────────────────┐
│     容器存储层       │         │     容器存储层       │
│   ┌─────────────┐   │         │   (只读/无状态)    │
│   │  数据库文件  │←─问题      │                     │
│   │  日志文件    │   │         └──────────┬──────────┘
│   │  上传文件    │   │                    │
│   └─────────────┘   │         ┌──────────▼──────────┐
└─────────────────────┘         │      数据卷         │
容器删除 = 数据丢失              │   ┌─────────────┐   │
                                │   │  持久化数据  │←─安全
                                │   └─────────────┘   │
                                └─────────────────────┘
                                容器删除,数据保留

基本用法

定义单个卷
FROM mysql:8.0
VOLUME /var/lib/mysql
定义多个卷
FROM myapp
VOLUME ["/data", "/logs", "/config"]

VOLUME 的行为

自动创建匿名卷

如果运行时未指定挂载,Docker 会自动创建匿名卷:

$ docker run mysql:8.0
$ docker volume ls
DRIVER    VOLUME NAME
local     a1b2c3d4e5f6...  # 自动创建的匿名卷
可被命名卷覆盖
## 使用命名卷替代匿名卷

$ docker run -v mysql_data:/var/lib/mysql mysql:8.0
可被 Bind Mount 覆盖
## 使用宿主机目录替代

$ docker run -v /my/data:/var/lib/mysql mysql:8.0

VOLUME 在构建时的特殊行为

⚠️ 重要:VOLUME 之后对该目录的修改会被丢弃!

FROM ubuntu
VOLUME /data

## ❌ 这个文件不会出现在镜像中!

RUN echo "hello" > /data/test.txt

原因:VOLUME 指令之后,Docker 将该目录视为外部挂载点,不再记录对它的修改。

正确做法:

FROM ubuntu

## ✅ 先写入文件

RUN mkdir -p /data && echo "hello" > /data/test.txt

## 再声明 VOLUME

VOLUME /data

常见使用场景

数据库持久化
FROM postgres:15
VOLUME /var/lib/postgresql/data
日志目录
FROM nginx
VOLUME /var/log/nginx
上传文件目录
FROM myapp
VOLUME /app/uploads

查看 VOLUME 定义

## 查看镜像定义的 VOLUME

$ docker inspect mysql:8.0 --format '{{json .Config.Volumes}}' | jq
{
  "/var/lib/mysql": {}
}

## 查看容器挂载的卷

$ docker inspect mycontainer --format '{{json .Mounts}}' | jq

VOLUME vs docker run -v

特性Dockerfile VOLUMEdocker run -v
定义时机镜像构建时容器运行时
默认行为创建匿名卷可指定命名卷或路径
灵活性低(固定路径)高(可任意指定)
适用场景定义必须持久化的路径灵活的数据管理

在 Compose 中

services:
  db:
    image: postgres:15
    volumes:
      # 命名卷(推荐)
      - postgres_data:/var/lib/postgresql/data
      # Bind Mount
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql

volumes:
  postgres_data:  # 声明命名卷

安全注意事项

匿名卷可能导致数据丢失

## 使用 --rm 运行的容器,匿名卷会在容器删除时一起删除

$ docker run --rm mysql:8.0
## 容器停止后,数据丢失!

解决:始终使用命名卷

$ docker run -v mysql_data:/var/lib/mysql mysql:8.0

最佳实践

定义必须持久化的路径
## 数据库必须使用卷

FROM postgres:15
VOLUME /var/lib/postgresql/data
不要在 VOLUME 后修改目录
## ❌ 避免

VOLUME /app/data
RUN cp init-data.json /app/data/

## ✅ 正确

RUN mkdir -p /app/data && cp init-data.json /app/data/
VOLUME /app/data
文档中说明 VOLUME 用途
## 持久化用户上传的文件

VOLUME /app/uploads

## 持久化数据库数据

VOLUME /var/lib/mysql

EXPOSE 暴露端口

基本语法

EXPOSE <端口> [<端口>/<协议>...]

EXPOSE 声明容器运行时提供服务的端口。这是一个文档性质的声明,告诉使用者容器会监听哪些端口。

基本用法

## 声明单个端口

EXPOSE 80

## 声明多个端口

EXPOSE 80 443

## 声明 TCP 和 UDP 端口

EXPOSE 80/tcp
EXPOSE 53/udp

EXPOSE 的作用

文档说明

告诉镜像使用者,容器将在哪些端口提供服务:

## 使用者一看就知道这是 web 应用

EXPOSE 80 443
## 查看镜像暴露的端口

$ docker inspect nginx --format '{{.Config.ExposedPorts}}'
map[80/tcp:{}]
配合 -P 使用

使用 docker run -P 时,Docker 会自动映射 EXPOSE 的端口到宿主机随机端口:

## Dockerfile

EXPOSE 80
$ docker run -P nginx
$ docker port $(docker ps -q)
80/tcp -> 0.0.0.0:32768

EXPOSE vs -p

特性EXPOSE-p
位置Dockerfiledocker run 命令
作用声明/文档实际端口映射
是否必需是(外部访问时)
映射发生时不发生运行时发生

没有 EXPOSE 也能 -p

## 即使没有 EXPOSE,也可以使用 -p

FROM nginx
## 没有 EXPOSE
## 仍然可以映射端口

$ docker run -p 8080:80 mynginx

常见误解

误解:EXPOSE 会打开端口
## ❌ 错误理解:这不会让容器可从外部访问

EXPOSE 80

EXPOSE 不会:

  • 自动进行端口映射
  • 让服务可从外部访问
  • 在容器启动时开启端口监听

EXPOSE 只是元数据声明。容器是否实际监听该端口,取决于容器内的应用。

正确理解
## Dockerfile

FROM nginx
EXPOSE 80    # 1. 声明:这个容器会在 80 端口提供服务
## 运行:需要 -p 才能从外部访问

$ docker run -p 8080:80 nginx    # 2. 映射:宿主机 8080 → 容器 80

最佳实践

总是声明应用使用的端口
## Web 服务

FROM nginx
EXPOSE 80 443

## 数据库

FROM postgres
EXPOSE 5432

## Redis

FROM redis
EXPOSE 6379
使用明确的协议
## 默认是 TCP

EXPOSE 80

## 明确指定 UDP

EXPOSE 53/udp

## 同时支持 TCP 和 UDP

EXPOSE 53/tcp 53/udp
与应用实际端口保持一致
## ✅ 好:EXPOSE 与应用端口一致

ENV PORT=3000
EXPOSE 3000
CMD ["node", "server.js"]

## ❌ 差:EXPOSE 与应用端口不一致(误导)

EXPOSE 80
CMD ["node", "server.js"]  # 实际监听 3000

使用环境变量

ARG PORT=80
EXPOSE $PORT

在 Compose 中

services:
  web:
    build: .
    ports:
      - "8080:80"    # 映射端口(类似 -p)
    expose:
      - "80"         # 仅声明(类似 EXPOSE)

expose 在 Compose 中仅用于容器间通信的文档说明,不进行端口映射。

WORKDIR 指定工作目录

基本语法

WORKDIR <工作目录路径>

WORKDIR 指定后续指令的工作目录。如果目录不存在,Docker 会自动创建。

基本用法

WORKDIR /app

RUN pwd          # 输出 /app
RUN echo "hello" > world.txt    # 创建 /app/world.txt
COPY . .         # 复制到 /app/

为什么需要 WORKDIR

常见错误
## ❌ 错误:cd 在下一个 RUN 中无效

RUN cd /app
RUN echo "hello" > world.txt    # 文件在根目录!
原因分析
RUN cd /app
    ↓
启动容器 → cd /app(仅内存变化)→ 提交镜像层 → 容器销毁
                                   │
                                   ↓ 工作目录未改变!
RUN echo "hello" > world.txt
    ↓
启动新容器(工作目录在 /)→ 创建 /world.txt

每个 RUN 都在新容器中执行,前一个 RUN 的内存状态(包括工作目录)不会保留。

正确做法
## ✅ 正确:使用 WORKDIR

WORKDIR /app
RUN echo "hello" > world.txt    # 创建 /app/world.txt

相对路径

WORKDIR 支持相对路径,基于上一个 WORKDIR

WORKDIR /a
WORKDIR b
WORKDIR c

RUN pwd    # 输出 /a/b/c

使用环境变量

ENV APP_HOME=/app
WORKDIR $APP_HOME

RUN pwd    # 输出 /app

多阶段构建中的 WORKDIR

## 构建阶段

FROM node:20 AS builder
WORKDIR /build
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

## 生产阶段

FROM nginx:alpine
WORKDIR /usr/share/nginx/html
COPY --from=builder /build/dist .

最佳实践

尽早设置 WORKDIR
FROM node:20
WORKDIR /app    # 尽早设置

COPY package*.json ./
RUN npm install
COPY . .
CMD ["node", "server.js"]
使用绝对路径
## ✅ 推荐:绝对路径,意图明确

WORKDIR /app

## ⚠️ 避免:相对路径可能造成混淆

WORKDIR app
不要用 RUN cd
## ❌ 避免

RUN cd /app && echo "hello" > world.txt

## ✅ 推荐

WORKDIR /app
RUN echo "hello" > world.txt
适时重置 WORKDIR
WORKDIR /app
## ... 应用相关操作 ...

WORKDIR /data
## ... 数据相关操作 ...
运行时覆盖

使用 -w 参数覆盖工作目录:

$ docker run -w /tmp myimage pwd
/tmp

USER 指定当前用户

基本语法

USER <用户名>[:<用户组>]
USER <UID>[:<GID>]

USER 指令切换后续指令(RUNCMDENTRYPOINT)的执行用户。

为什么要使用 USER

笔者强调:以非 root 用户运行容器是最重要的安全实践之一。

root 用户运行的风险:
┌────────────────────────────────────────────────────────┐
│  容器内 root  ←─ 可能逃逸 ─→  宿主机 root             │
│      │                            │                    │
│      └── 漏洞利用 ───────────────→ 完全控制宿主机     │
└────────────────────────────────────────────────────────┘

非 root 用户运行:
┌────────────────────────────────────────────────────────┐
│  容器内普通用户  ──逃逸后──→  宿主机普通用户          │
│      │                            │                    │
│      └── 权限受限,危害降低 ─────→ 无法控制系统       │
└────────────────────────────────────────────────────────┘

基本用法

创建并切换用户
FROM node:20-alpine

## 1. 创建用户和组

RUN addgroup -g 1001 appgroup && \
    adduser -u 1001 -G appgroup -D appuser

## 2. 设置目录权限

WORKDIR /app
COPY --chown=appuser:appgroup . .

## 3. 切换用户

USER appuser

## 4. 后续命令以 appuser 身份运行

CMD ["node", "server.js"]
使用 UID/GID
## 也可以使用数字

USER 1001:1001

用户必须已存在

USER 指令只能切换到已存在的用户:

## ❌ 错误:用户不存在

USER nonexistent
## Error: unable to find user nonexistent

## ✅ 正确:先创建用户

RUN useradd -r -s /bin/false appuser
USER appuser
创建用户的方式
  • Debian/Ubuntu:
    RUN groupadd -r appgroup && \
        useradd -r -g appgroup appuser
  • Alpine:
    RUN addgroup -g 1001 -S appgroup && \
        adduser -u 1001 -S -G appgroup appuser
选项说明
-r(useradd)/-S(adduser)创建系统用户
-g指定主组
-G指定附加组
-u指定 UID
-s /bin/false禁用登录 shell

运行时切换用户

使用 gosu(推荐)

在 ENTRYPOINT 脚本中切换用户时,不要使用 su 或 sudo,应使用 gosu

FROM debian:bookworm

## 创建用户

RUN groupadd -r redis && useradd -r -g redis redis

## 安装 gosu

RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*

COPY docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["redis-server"]

docker-entrypoint.sh:

#!/bin/bash
set -e

## 以 root 执行初始化

chown -R redis:redis /data

## 用 gosu 切换到 redis 用户运行服务

exec gosu redis "$@"
为什么不用 su/sudo
问题su/sudogosu
TTY 要求需要不需要
信号传递不正确正确
子进程exec 替换
容器中使用

运行时覆盖用户

使用 -u--user 参数:

## 以指定用户运行

$ docker run -u 1001:1001 myimage

## 以 root 运行(调试时)

$ docker run -u root myimage

文件权限处理

切换用户后,确保应用有权访问文件:

FROM node:20-alpine

## 创建用户

RUN adduser -D -u 1001 appuser

WORKDIR /app

## 方式1:使用 --chown

COPY --chown=appuser:appuser . .

## 方式2:手动 chown(减少层数)

## COPY . .

## RUN chown -R appuser:appuser /app

USER appuser
CMD ["node", "server.js"]

最佳实践

始终使用非 root 用户
## ✅ 推荐

RUN adduser -D appuser
USER appuser
CMD ["myapp"]

## ❌ 避免

CMD ["myapp"]  # 以 root 运行
使用固定 UID/GID

便于在宿主机和容器间共享文件:

## 使用常见的非 root UID

RUN addgroup -g 1000 -S appgroup && \
    adduser -u 1000 -S -G appgroup appuser
USER 1000:1000
多阶段构建中的 USER
## 构建阶段可以用 root

FROM node:20 AS builder
WORKDIR /app
COPY . .
RUN npm install && npm run build

## 生产阶段用非 root

FROM node:20-alpine
RUN adduser -D appuser
WORKDIR /app
COPY --from=builder --chown=appuser:appuser /app/dist .
USER appuser
CMD ["node", "server.js"]

常见问题

权限被拒绝

运行报错:

permission denied: '/app/data.log'

解决:确保目录权限正确

RUN mkdir -p /app/data && chown appuser:appuser /app/data
无法绑定低于 1024 的端口

非 root 用户无法绑定 80、443 等端口。

解决:

  • 使用高端口(如 8080)
  • 在运行时映射端口:docker run -p 80:8080

文章作者: 江湖义气
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 江湖义气 !
  目录