--- id: devops.docker.image-dockerfile title: 镜像和 Dockerfile:分层缓存、多阶段构建、非 root、信号处理 type: lesson domain: devops stack: [docker] tags: [Dockerfile, 多阶段构建, 镜像分层, 构建缓存, distroless] level: basic est_hours: 3 related: [devops.docker.compose-network-volume, devops.docker.interview] week: [3, 6] order: 1 --- # 镜像和 Dockerfile:分层缓存、多阶段构建、非 root、信号处理 ## 解释说明 - **镜像 vs 容器**:镜像是只读的模板(像“类”),容器是镜像运行起来的实例(像“对象”),容器在镜像之上加一个可写层。 - **分层**:Dockerfile 中 `RUN`、`COPY`、`ADD` 每条指令产生一层。构建时如果某一层的输入没变,就直接用缓存;**某一层变了,它后面的层全部重新构建**。所以把变化少的放前面(安装依赖),变化多的放后面(复制源码)。 - **多阶段构建**:第一阶段用带完整工具链的镜像编译,第二阶段只复制编译产物到很小的运行镜像。Go 编译成静态二进制后,运行镜像可以是 `distroless` 甚至 `scratch`,体积从几百 MB 降到十几 MB,攻击面也小。 - **`.dockerignore`**:排除 `.git`、`node_modules`、本地数据,减少构建上下文、避免把密钥打进镜像。 - **非 root 运行**:`USER` 指定普通用户,容器被攻破时影响更小。 - **PID 1 和信号**:`ENTRYPOINT ["/api"]`(exec 形式)让程序直接成为 PID 1,能收到 `docker stop` 发出的 SIGTERM 做优雅退出;`ENTRYPOINT /api`(shell 形式)会让 `/bin/sh` 成为 PID 1,信号传不到程序,最后被强制 kill。 - **`CMD` vs `ENTRYPOINT`**:`ENTRYPOINT` 是固定执行的程序,`CMD` 是默认参数(运行时可以覆盖)。 **常用命令**:`docker build -t api:1.0 .`、`docker run -d -p 8080:8080 --name api api:1.0`、`docker logs -f api`、`docker exec -it api sh`(distroless 没有 shell)、`docker images`、`docker history api:1.0`(看每层大小)。 ## 案例 Go 服务最初用 `FROM golang` 单阶段构建,镜像 800MB+,每次改一行代码都要重新下载依赖。改成:先 `COPY go.mod go.sum` 再 `go mod download`(依赖层可缓存)→ 再复制源码编译 → 运行阶段用 distroless,镜像十几 MB,代码改动时构建只需重新编译。 Python(FastAPI)服务同理:先复制 `requirements.txt` / `pyproject.toml` 安装依赖,再复制源码;用 `python:3.12-slim` 作为基础镜像。 ## 代码示例 Go 服务的多阶段 Dockerfile(完整示例见 [examples/go-api-compose](examples/go-api-compose/)): ```dockerfile # syntax=docker/dockerfile:1 FROM golang:1.26-alpine AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download # 依赖层:go.mod 不变就用缓存 COPY . . RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api . FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /out/api /api USER nonroot:nonroot EXPOSE 8080 ENTRYPOINT ["/api"] # exec 形式:能收到 SIGTERM ``` Python FastAPI 服务(示意,按项目实际的依赖文件调整): ```dockerfile FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app ./app RUN useradd -m appuser USER appuser EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] ``` Go 程序里配合优雅退出: ```go ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() go srv.ListenAndServe() <-ctx.Done() // 收到 docker stop 的 SIGTERM shutdownCtx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() srv.Shutdown(shutdownCtx) // 等正在处理的请求完成 ``` ## 易错点 - **`COPY . .` 放在安装依赖之前**:每次改代码都重新安装依赖,构建很慢。 - **把密钥写进 Dockerfile 或复制进镜像**:镜像的每一层都能被查看,删掉的文件也还在之前的层里。密钥通过运行时环境变量或密钥管理注入。 - **使用 `latest` 标签**:不知道实际跑的是哪个版本,无法回滚。用版本号或 Git 提交哈希。 - **服务监听 `127.0.0.1`**:容器外访问不到,要监听 `0.0.0.0`。 - **shell 形式的 ENTRYPOINT**:收不到 SIGTERM,每次停止都要等 10 秒后被强杀,正在处理的请求被中断。 - **CGO 编译的二进制放进 scratch / distroless static**:缺少 libc 运行不起来,要 `CGO_ENABLED=0` 或换带 libc 的基础镜像。 ## 面试常问 见 [Docker 面试题](interview.md):镜像和容器的区别、分层与缓存、多阶段构建、如何减小镜像体积、CMD 和 ENTRYPOINT。