前言
docker run 只是开始,把应用做成自己可控的镜像才算入门容器化。同样的应用,Dockerfile 写法不同,镜像可以从 1GB 到 20MB——这篇讲怎么写出又小又快又安全的镜像。
一、基础指令速览
FROM python:3.11-slim # 基础镜像(一切的地基)
WORKDIR /app # 工作目录(不存在会自动创建)
COPY app.py requirements.txt ./ # 拷贝文件(比 ADD 更纯粹, 推荐)
RUN pip install -r requirements.txt # 构建期执行命令
ENV TZ=Asia/Shanghai # 环境变量
EXPOSE 8000 # 声明端口(文档性质)
CMD ["python", "app.py"] # 容器启动命令(可被 run 参数覆盖)
ENTRYPOINT ["./server"] # 入口(不易被覆盖, 与 CMD 配合)
CMD vs ENTRYPOINT:
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8000"] # CMD 变成传给 ENTRYPOINT 的默认参数
# docker run myimg -> python app.py --port 8000
# docker run myimg --port 9000 -> python app.py --port 9000
COPY vs ADD:日常一律 COPY;只有需要自动解压 tar(ADD app.tar.gz /opt/)或加远程 URL(不推荐)时才用 ADD。
shell 格式 vs exec 格式:
CMD python app.py # shell 格式: 实际执行 /bin/sh -c "python app.py", PID1 是 sh
CMD ["python", "app.py"] # exec 格式: python 直接是 PID1 ✅(信号能正确传递)
二、分层缓存:指令顺序的艺术
每条指令生成一层,层有缓存;某层失效,其后所有层全部重建。
# ❌ 错误示范:改一行代码, 依赖重装一遍
COPY . /app
RUN pip install -r /app/requirements.txt
# ✅ 正确:先拷依赖清单装依赖(很少变, 缓存命中), 再拷代码
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
从上到下按变动频率从低到高排列:基础镜像 → 系统依赖 → 语言依赖 → 源代码。
三、多阶段构建:瘦身的王牌
编译期需要的工具链(JDK、Go 工具链、node_modules)运行期完全不需要。多阶段构建只把产物带走:
Go 应用(1GB+ → 20MB 以内)
# ---------- 阶段1: 构建 ----------
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download # 依赖层缓存
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/server .
# ---------- 阶段2: 运行 ----------
FROM alpine:3.19
RUN adduser -D -u 10001 app && apk add --no-cache tzdata ca-certificates
COPY --from=builder /out/server /usr/local/bin/server
USER app
ENTRYPOINT ["server"]
甚至可以 FROM scratch(空镜像),Go 静态编译的程序直接跑,最终镜像 ≈ 二进制本身大小。
Python 应用
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
FROM base AS builder
WORKDIR /wheels
COPY requirements.txt .
RUN pip wheel --no-cache-dir -w . -r requirements.txt
FROM base
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/*.whl && rm -rf /wheels
COPY . .
RUN useradd -m appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "app:app", "-b", "0.0.0.0:8000"]
四、最佳实践清单
镜像体积
- 选 slim/alpine 基础镜像(
python:3.11-slim而非python:3.11) -
apt install后清理:rm -rf /var/lib/apt/lists/* - 多阶段构建,不带构建工具链
-
.dockerignore排除无关文件(.git、node_modules、.env、测试数据)
# .dockerignore
.git
.venv
__pycache__
*.pyc
.env
tests/
docs/
*.md
可复现性
- 基础镜像锁 digest:
FROM python@sha256:abc123...(安全级别要求时) - 不用
latest标签 - 依赖清单锁版本(requirements.txt 带 ==、go.sum)
安全
- 非 root 运行(上面示例的 adduser/useradd)
- 不在镜像里放密钥:
COPY .env .是重大事故源头 - 最小化安装:不需要的包不装(减少攻击面)
- 镜像扫描:
docker scout/ Trivy
# Trivy 扫描漏洞
trivy image myapp:v1.2.0
运行健康
- 日志输出到 stdout/stderr(交给 docker logs / 收集器)
- 优雅退出:程序处理 SIGTERM,配合
docker stop -t 30 - 定义 HEALTHCHECK(编排系统会更早发现问题)
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -qO- http://127.0.0.1:8000/health || exit 1
五、构建与推送
# 构建(注意最后的点 = 构建上下文路径)
docker build -t myapp:v1.2.0 .
# 看构建过程与缓存命中
docker build --progress=plain -t myapp:v1.2.0 .
# 对比优化前后体积
docker images | grep myapp
# 推送私有仓库(如 Harbor)
docker tag myapp:v1.2.0 reg.local/team/myapp:v1.2.0
docker push reg.local/team/myapp:v1.2.0
六、常见坑
| 现象 | 原因 |
|---|---|
| 改代码构建依然用缓存 | COPY 上下文没变化 / .dockerignore 排除了文件 |
| 容器时区不对 | 加 ENV TZ=Asia/Shanghai + 装 tzdata |
| docker stop 要等 10s | PID1 是 sh 没转发信号,用 exec 格式 CMD |
| 镜像越滚越大 | 历史层里有垃圾,用多阶段构建根除 |
| 容器里连不上外网 | 检查 DNS:docker run --rm busybox nslookup baidu.com |
小结
| 原则 | 手段 |
|---|---|
| 快 | 分层缓存 + 指令按变动频率排序 |
| 小 | 多阶段构建 + slim 基础镜像 + .dockerignore |
| 安全 | 非 root + 不放密钥 + 锁版本 + 扫描 |
| 可靠 | stdout 日志 + 信号处理 + HEALTHCHECK |
本文是「云原生」系列第 3 篇。