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 > fileRUN 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/mysecretCOPY 复制文件
基本语法
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
| 特性 | COPY | ADD |
|---|---|---|
| 复制本地文件 | ✅ | ✅ |
| 自动解压 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 优化缓存(BuildKit)
# 使用 --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 [选项] ["<源路径>", ... "<目标路径>"]ADD 在 COPY 基础上增加了两个功能:
- 自动解压 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.jsonURL 下载功能(不推荐)
基本用法
# 从 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 9000ENTRYPOINT 入口点
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 $PIDENV 设置环境变量
基本语法
## 格式一:单个变量
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;支持环境变量的指令
以下指令可以使用 $变量名 或 ${变量名} 格式:
| 指令 | 示例 |
|---|---|
RUN | RUN echo $VERSION |
CMD | CMD ["sh", "-c", "echo $HOME"] |
ENTRYPOINT | ENTRYPOINT ["sh", "-c", "echo $HOME"] |
COPY | COPY . $APP_HOME |
ADD | ADD app.tar.gz $APP_HOME |
WORKDIR | WORKDIR $APP_HOME |
EXPOSE | EXPOSE $PORT |
VOLUME | VOLUME $DATA_DIR |
USER | USER $USERNAME |
LABEL | LABEL version=$VERSION |
FROM | FROM 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/mydbENV vs ARG
| 特性 | ENV | ARG |
|---|---|---|
| 生效时间 | 构建时 + 运行时 | 仅构建时 |
| 持久性 | 写入镜像,运行时可用 | 构建后消失 |
| 覆盖方式 | docker run -e | docker 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=value3ARG 构建参数
基本语法
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_PROXY | HTTP 代理 |
HTTPS_PROXY | HTTPS 代理 |
NO_PROXY | 不使用代理的地址 |
FTP_PROXY | FTP 代理 |
## 构建时使用代理
$ 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.0VOLUME 在构建时的特殊行为
⚠️ 重要: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}}' | jqVOLUME vs docker run -v
| 特性 | Dockerfile VOLUME | docker 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/mysqlEXPOSE 暴露端口
基本语法
EXPOSE <端口> [<端口>/<协议>...]EXPOSE 声明容器运行时提供服务的端口。这是一个文档性质的声明,告诉使用者容器会监听哪些端口。
基本用法
## 声明单个端口
EXPOSE 80
## 声明多个端口
EXPOSE 80 443
## 声明 TCP 和 UDP 端口
EXPOSE 80/tcp
EXPOSE 53/udpEXPOSE 的作用
文档说明
告诉镜像使用者,容器将在哪些端口提供服务:
## 使用者一看就知道这是 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:32768EXPOSE vs -p
| 特性 | EXPOSE | -p |
|---|---|---|
| 位置 | Dockerfile | docker run 命令 |
| 作用 | 声明/文档 | 实际端口映射 |
| 是否必需 | 否 | 是(外部访问时) |
| 映射发生时 | 不发生 | 运行时发生 |
没有 EXPOSE 也能 -p
## 即使没有 EXPOSE,也可以使用 -p
FROM nginx
## 没有 EXPOSE## 仍然可以映射端口
$ docker run -p 8080:80 mynginx常见误解
误解:EXPOSE 会打开端口
## ❌ 错误理解:这不会让容器可从外部访问
EXPOSE 80EXPOSE 不会:
- 自动进行端口映射
- 让服务可从外部访问
- 在容器启动时开启端口监听
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
/tmpUSER 指定当前用户
基本语法
USER <用户名>[:<用户组>]
USER <UID>[:<GID>]USER 指令切换后续指令(RUN、CMD、ENTRYPOINT)的执行用户。
为什么要使用 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/sudo | gosu |
|---|---|---|
| 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