Files

94 lines
4.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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。