一、指令的分工
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
| 指令 | 作用 | 注意 |
|---|---|---|
FROM | 基础镜像 | alpine 最小,slim 折中 |
WORKDIR | 工作目录 | 后续指令的相对基准 |
COPY | 复制文件 | 比 ADD 更可预测(ADD 会自动解压) |
RUN | 构建时执行 | 每条产生一层 |
CMD | 默认启动命令 | 可被 docker run 的参数覆盖 |
ENTRYPOINT | 固定入口 | 参数追加在其后 |
CMD 与 ENTRYPOINT 的经典组合:ENTRYPOINT ["node"] + CMD ["server.js"],运行时 docker run img other.js 会执行 node other.js。
二、缓存是按层命中的
Docker 逐层比对,某一层发生变化,它之后的所有层缓存全部失效。所以顺序很关键:
# 好:依赖清单先拷,代码后拷
COPY package*.json ./
RUN npm ci
COPY . .
# 差:先拷全部代码,改一行就重装依赖
COPY . .
RUN npm ci
package.json 不常变,npm ci 这一层就能长期复用;源码改动只让最后的 COPY . . 失效。这是构建加速最有效的一招,Python(requirements.txt)、Go(go.mod)同理。
三、多阶段构建:把编译工具留在构建阶段
# 阶段一:构建
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app
# 阶段二:运行
FROM alpine:3.20
RUN adduser -D -u 10001 app
USER app
COPY --from=build /out/app /usr/local/bin/app
ENTRYPOINT ["app"]
最终镜像只含二进制和最小运行环境,编译器、源码、缓存全部丢弃。Node 项目同理:构建阶段装 devDependencies 打包,运行阶段只 npm ci --omit=dev。
四、瘦身的六个动作
- 选
alpine/slim/distroless,别用完整版ubuntu。 - 多阶段构建,运行镜像只留产物。
RUN里合并命令并清理缓存:
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
.dockerignore排除node_modules、.git、日志、测试数据。- 避免在镜像里装编译工具(gcc、make)——运行时不需要。
- 不要
COPY .到最终镜像,只拷必要文件。
五、.dockerignore 一定要写
node_modules
.git
*.log
dist
.env
否则 COPY . . 会把宿主机的 node_modules(可能几百 MB、还带平台相关的二进制)一起塞进镜像,覆盖容器里正确安装的依赖,出现「本地能跑、容器报错」的怪问题。
六、安全实践
- 不要用 root 运行:
USER app(UID 建议 10001)。 - 不要把密钥写进镜像:
ENV和ARG都会留在镜像历史里,用运行时挂载或密钥管理。 - 固定基础镜像版本:
node:20.11-alpine比node:latest可复现。 - HEALTHCHECK 让编排系统知道容器是否真的可用。
七、构建与验证
docker build -t myapp:1.0 .
docker run --rm -it myapp:1.0 sh # 进去看目录结构
docker history myapp:1.0 # 看每层大小,找出膨胀点
docker images myapp # 对比体积
docker history 是找「哪一层把镜像撑大了」的最快方式。
小结
先拷依赖清单再拷代码以命中缓存;多阶段构建分离构建与运行;.dockerignore 排除 node_modules 和 .git;用非 root 用户;固定基础镜像版本。