Docker镜像分层实战:从0到1构建生产级服务

先说结论:Docker镜像分层不是什么高深概念,但大多数人只用对了60%。

太多项目动辄上GB的镜像,每次改一行代码要重新构建5分钟。这些问题的根源都在同一个地方——没搞懂分层机制。

下面这个项目是一个典型的用户API服务,用它来讲分层再合适不过。

目录

Docker镜像分层实战:从0到1构建生产级服务

项目背景

一、Dockerfile怎么写才合理

二、分层的核心原则

原则1:变化频率从低到高

原则2:层大小越小越好

原则3:每层要做一件事

三、实战:看懂你的镜像

3.1 查看分层历史

3.2 缓存命中的验证

四、Overlay2底层怎么看

4.1 查看存储结构

4.2 验证层的不可变性

五、生产环境的坑和解决方案

坑1:改一行代码,重新构建5分钟

坑2:镜像越来越大,怀疑人生

坑3:.dockerignore失效

六、性能对比数据

七、快速启动这个项目

八、工具推荐

分析镜像层大小分布

深度分析镜像内容

清理无用层

总结

后续可以做的优化


项目背景

技术栈:Flask + Gunicorn + Redis

  • QPS:500-1000
  • 容器数:12个实例
  • 镜像大小:从最初的380MB优化到现在的215MB

完整的代码结构:

├── app.py              # Flask应用,用户管理API
├── requirements.txt    # Python依赖
├── Dockerfile         # 关键:11层精心设计
└── docker-compose.yml # 开发环境一键启动

一、Dockerfile怎么写才合理

先上代码,再讲为什么:

# 第1层:基础镜像,几乎不变
FROM python:3.11-slim

# 第2层:工作目录,不变
WORKDIR /app

# 第3层:环境变量,不变
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

# 第4层:系统依赖,很少变
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    gcc \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

# 第5层:依赖清单,偶尔变
COPY requirements.txt .

# 第6层:Python依赖,偶尔变但耗时最长
RUN pip install --no-cache-dir -r requirements.txt

# 第7层:应用代码,经常变
COPY app.py .

# 第8层:创建用户,不变
RUN useradd -m -u 1000 appuser && \
    chown -R appuser:appuser /app
USER appuser

# 第9层:暴露端口,不变
EXPOSE 5000

# 第10层:健康检查,偶尔变
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD python -c "import requests; requests.get('http://localhost:5000/health')"

# 第11层:启动命令,不变
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--threads", "2", "--timeout", "120", "app:app"]

重点来了,为什么这样排层?

二、分层的核心原则

原则1:变化频率从低到高

改了app.py,只有第7层要重新构建,前6层直接用缓存。什么概念?

# 首次构建:2分15秒
time docker build -t user-api:v1 .

# 改一行代码重新构建:6.8秒(前6层命中缓存)
echo "# fix bug" >> app.py
time docker build -t user-api:v2 .

如果你把COPY app.py .放在COPY requirements.txt .前面,每次改代码都要重新装依赖,那滋味...

原则2:层大小越小越好

每层都是独立的增量,layer越多,metadata开销越大。但这里有个权衡:

# ❌ 错误:分层太细
RUN apt-get update
RUN apt-get install -y gcc
RUN apt-get install -y libpq-dev

# ✅ 正确:相关操作合并
RUN apt-get update && \
    apt-get install -y gcc libpq-dev && \
    rm -rf /var/lib/apt/lists/*

但不要为了少层把不相关的操作合并,比如把pip install和apt-get混在一起。

原则3:每层要做一件事

这是我的血泪教训:

# ❌ 当年的坑
COPY . .
RUN pip install -r requirements.txt && python setup.py develop

# 问题:改了无关的配置文件,也会重新安装依赖

# ✅ 现在的做法
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY app.py .

三、实战:看懂你的镜像

3.1 查看分层历史

docker history user-api:latest --no-trunc

你会看到类似这样的输出:

IMAGE          CREATED       CREATED BY                                      SIZE
<none>         0 seconds ago /bin/sh -c #(nop)  CMD ["gunicorn" ...]        0B
<none>         0 seconds ago /bin/sh -c #(nop) HEALTHCHECK ...             0B
<none>         0 seconds ago /bin/sh -c #(nop)  EXPOSE 5000                0B
<none>         0 seconds ago /bin/sh -c useradd -m -u 1000 appuser ...    0B
<none>         0 seconds ago /bin/sh -c #(nop) COPY file:... in ./app.py  5.7KB
<none>         0 seconds ago /bin/sh -c pip install --no-cache-dir ...    35.2MB
<none>         0 seconds ago /bin/sh -c #(nop) COPY file:... in ./        104B
<none>         0 seconds ago /bin/sh -c apt-get update && apt-get ...      48.7MB
<none>         0 seconds ago /bin/sh -c #(nop)  ENV PYTHONDONTWRITE...     0B
<none>         0 seconds ago /bin/sh -c #(nop) WORKDIR /app                0B
<none>         2 weeks ago  /bin/sh -c #(nop)  CMD ["python3"]              0B
<none>         2 weeks ago  /bin/sh -c #(nop) ADD file:... in /            125MB

关键观察点:

  1. 第6层(35.2MB) :最重的层,安装Python依赖,这是缓存最值钱的地方
  2. 第4层(48.7MB) :第二重的层,系统依赖,改一次代价很大
  3. 第7层(5.7KB) :应用代码,最轻的层,变化最频繁

3.2 缓存命中的验证

做个实验:

# 场景1:只改代码
echo "# feature: add rate limit" >> app.py
docker build -t user-api:v3 .
# 输出会有大量 "CACHED",只有 COPY app.py 那层重新构建

# 场景2:改依赖
echo "prometheus-client==0.19.0" >> requirements.txt
docker build -t user-api:v4 .
# COPY requirements.txt 和 pip install 两层重新构建

# 场景3:改基础镜像
# 把 FROM python:3.11-slim 改为 FROM python:3.12-slim
docker build -t user-api:v5 .
# 所有层都重新构建,从头来过

这个验证过程能让你直观理解"按变化频率分层"的意义。

四、Overlay2底层怎么看

Docker默认用Overlay2存储驱动,它怎么把多个层"拼"成一个文件系统?

4.1 查看存储结构

# 先跑个容器
docker run -d --name test user-api:latest

# 看看它的挂载信息
docker inspect test | grep -A 10 "Mounts"

你会看到类似的输出:

"Mounts": [
    {
        "Type": "overlay",
        "Source": "/var/lib/docker/overlay2/abc123.../merged",
        "UpperDir": "/var/lib/docker/overlay2/abc123.../diff",
        "LowerDir": "/var/lib/docker/overlay2/def456.../diff:/var/lib/docker/overlay2/ghi789.../diff:..."
    }
]

关键概念:

  • LowerDir:只读层(镜像层),多个层用冒号分隔
  • UpperDir:可写层(容器层),运行时修改的文件放在这里
  • Merged:联合视图,容器看到的"完整"文件系统

4.2 验证层的不可变性

# 在容器里创建文件
docker exec test touch /tmp/myfile.txt

# 查看UpperDir,文件在这里
ls -la /var/lib/docker/overlay2/$(docker inspect test -f '{{.Id}}')/diff/tmp/

# 停止删除容器
docker stop test && docker rm test

# 新容器看不到刚才的文件
docker run --rm user-api:latest ls /tmp/myfile.txt
# 输出:ls: cannot access '/tmp/myfile.txt': No such file or directory

这就是为什么说镜像是不可变的——容器层的修改不会影响镜像层。

五、生产环境的坑和解决方案

坑1:改一行代码,重新构建5分钟

原因:Dockerfile层顺序不合理

# ❌ 错误顺序
FROM python:3.11-slim
COPY . .
RUN pip install -r requirements.txt

# 每次改任何文件都要重新安装依赖

解决:把不变的层往前放

# ✅ 正确顺序
FROM python:3.11-slim
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY app.py .

坑2:镜像越来越大,怀疑人生

原因:每层的临时文件没有清理

# ❌ 没清理
RUN apt-get update
RUN apt-get install -y gcc

# apt缓存留在层里,每层独立

解决:同一层内清理

# ✅ 同一层清理
RUN apt-get update && \
    apt-get install -y gcc && \
    rm -rf /var/lib/apt/lists/*

坑3:.dockerignore失效

原因:误把Dockerfile本身ignore了,或者语法错误

# .dockerignore
.git
__pycache__
*.pyc

验证一下哪些文件被拷进去了:

docker build --progress=plain -t debug . 2>&1 | grep "COPY"

六、性能对比数据

这是我实测的数据,供参考:

场景构建时间说明
首次构建2m 15s无缓存,从头构建
只改代码6.8s前6层命中缓存
改依赖58s2层重新构建
改基础镜像2m 20s所有层重新构建
无缓存强制构建2m 25s--no-cache

镜像大小对比:

版本大小优化点
v1.0380MB未优化,很多冗余
v2.0275MB清理apt缓存
v3.0215MB使用slim基础镜像
v4.0180MB多阶段构建(未展示)

七、快速启动这个项目

# 1. 构建镜像
docker build -t user-api:latest .

# 2. 启动服务(包含Redis)
docker-compose up -d

# 3. 查看日志
docker-compose logs -f web

# 4. 测试接口
curl http://localhost:8080/health
curl http://localhost:8080/api/users
curl http://localhost:8080/api/stats

# 5. 停止服务
docker-compose down

八、工具推荐

分析镜像层大小分布

docker history user-api:latest --format "table {{.CreatedBy}}\t{{.Size}}"

深度分析镜像内容

docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock \
    wagoodman/dive:latest user-api:latest

这个工具能让你看到每一层里具体有哪些文件,哪一层贡献了最大的体积。

清理无用层

# 清理悬空镜像
docker image prune

# 清理所有未使用的镜像
docker image prune -a

总结

Docker分层的核心就三点:

  1. 按变化频率排序:不变的放下面,常变的放上面
  2. 合并相关操作:减少层数,但要保持逻辑清晰
  3. 清理临时文件:不要让垃圾文件留进层里

后续可以做的优化

如果还想进一步优化:

  1. 多阶段构建:把构建依赖和运行依赖分开,最终镜像能到150MB以下
  2. alpine基础镜像:更小,但要注意glibc兼容性
  3. 依赖分离:把requirements.txt按核心/可选拆分,利用缓存

更多推荐