【Docker】正确构建Docker镜像:大小、分层与安全性优化
Docker镜像是现代应用交付中最基础的构建块之一。
不过,许多生产环境镜像仍存在不必要的臃肿、难以复现、默认配置不安全等问题。
本文将详细讲解:
- 虚拟机与容器的核心区别(简要背景介绍)
- Docker镜像的分层构建原理
- 简单镜像为何会变得异常庞大
- 多阶段构建如何大幅缩减镜像体积
- 构建更小、更安全Docker镜像的实操步骤
本文聚焦实操技巧,所有方法均可直接落地应用。
虚拟机与容器(快速背景)
虚拟机
虚拟机一般包含:
- 宿主操作系统
- 宿主内核
- 虚拟机监控程序(Hypervisor)
- 客户操作系统(含独立内核)
- 运行在客户系统中的应用
每个虚拟机都包含完整的操作系统,虽能提供强隔离性,但也存在明显短板:
- 资源占用高
- 启动速度慢
- 维护开销大
容器
容器采用完全不同的架构:
- 宿主操作系统
- 单一宿主内核
- 容器运行时(如Docker、containerd)
- 通过内核特性隔离的应用进程
容器不自带完整操作系统,而是依赖Linux内核的核心机制实现隔离:
- 命名空间(Namespaces):实现进程、网络、文件系统视图的隔离
- 控制组(cgroups):限制资源使用额度
容器本质上是被放入隔离环境的普通进程,这使其具备轻量、快速的特性——但也意味着镜像的质量和配置至关重大。
Docker镜像的构建原理:分层结构
Docker镜像基于分层文件系统构建。
Dockerfile中的每条指令,要么:
- 创建新的文件系统层
- 或更新镜像元数据
会创建分层的指令
这类指令一般会增加镜像体积:
- RUN
- COPY
- ADD
不会创建分层的指令
这类指令仅修改元数据:
- CMD
- ENTRYPOINT
- ENV
- EXPOSE
- USER
额外的分层会带来这些问题:
- 镜像体积增大
- 构建时间延长
- 潜在攻击面扩大
理解分层机制,是构建高效镜像的关键。
为何简单镜像会变得异常庞大?
假设我们有一个极简的Go应用,功能是每秒打印当前用户。
一个直观的Dockerfile可能是这样:
- 基于Ubuntu镜像
- 安装Go编译器
- 复制源代码
- 编译二进制文件
- 运行应用
构建完成后执行 docker image ls | grep app,结果可能如下:
app latest ~700MB
对于一个微小的应用来说,这个体积过于庞大。
问题根源
最终镜像中仍包含了所有构建依赖:
- Ubuntu基础镜像
- Go编译器
- 所有构建依赖包
- 安装和编译过程中创建的所有分层
即便运行时已不再需要编译器,它依然会残留在镜像中。
多阶段构建:正确的解决方案
多阶段构建能干净利落地解决镜像臃肿问题。
核心思路
- 用第一个镜像构建应用(构建阶段)
- 用第二个精简镜像运行应用(运行阶段)
- 仅在阶段间复制编译后的产物
- 丢弃所有无用依赖
最终只有最后一个阶段会成为最终镜像。
示例:多阶段Dockerfile
#########################
# 阶段0:构建阶段 #
#########################
FROM ubuntu:22.04 AS builder
ARG DEBIAN_FRONTEND=noninteractive
# 安装构建依赖
RUN apt-get update && apt-get install -y golang-go
# 复制源代码
COPY app.go .
# 编译应用(关闭CGO,生成静态链接二进制)
RUN CGO_ENABLED=0 go build -o /app app.go
#############################
# 阶段1:运行阶段 #
#############################
FROM alpine:3.12.1
# 仅从构建阶段复制编译产物
COPY --from=builder /app /app
# 启动应用
CMD ["/app"]
构建结果对比
执行构建命令:docker build -t app-multistage .
查看镜像大小:docker image ls | grep app
典型结果如下:
app-multistage latest ~7MB
app latest ~700MB
一样的应用,镜像体积大幅缩减。
镜像安全加固
镜像体积优化后,下一步是通过设计保障安全性。
仅靠精简镜像远远不够——构建方式和配置直接决定镜像的安全态势。
以下是四个实操步骤,能显著提升Docker镜像的安全性。每个步骤均提供完整Dockerfile示例,清晰展示修改要点。
1. 固定基础镜像版本
生产环境镜像应避免使用latest标签。
#########################
# 阶段0:构建阶段 #
#########################
FROM ubuntu:22.04 AS builder # ← 固定版本号
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y golang-go
COPY app.go .
RUN CGO_ENABLED=0 go build -o /app app.go
#############################
# 阶段1:运行阶段 #
#############################
FROM alpine:3.12.1 # ← 固定版本号
COPY --from=builder /app /app
CMD ["/app"]
固定基础镜像版本的优势:
- 构建结果可复现
- 运行行为可预测
- 漏洞报告清晰可追溯
版本升级将成为明确的决策,而非重构时意外触发的变更。
2. 非root用户运行
默认情况下,容器以root用户运行——但多数场景下这完全不必要。
更安全的做法是在运行时镜像中创建专用用户,并以该用户身份启动应用。
#########################
# 阶段0:构建阶段 #
#########################
FROM ubuntu:22.04 AS builder
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y golang-go
COPY app.go .
RUN CGO_ENABLED=0 go build -o /app app.go
#############################
# 阶段1:运行阶段 #
#############################
FROM alpine:3.12.1
# 创建用户组和用户(新增)
RUN addgroup -S appgroup
&& adduser -S appuser
-G appgroup
-h /home/appuser
# 从构建阶段复制产物到用户目录(修改路径)
COPY --from=builder /app /home/appuser/app
# 切换到非root用户(新增)
USER appuser
CMD ["/home/appuser/app"]
这样即使应用被攻破,进程在容器内也不具备root权限,能大幅降低攻击影响。
3. 限制文件系统写权限
许多文件系统路径在运行时完全无需修改。移除系统目录的写权限,能防止攻击者篡改配置文件。
#########################
# 阶段0:构建阶段 #
#########################
FROM ubuntu:22.04 AS builder
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y golang-go
COPY app.go .
RUN CGO_ENABLED=0 go build -o /app app.go
#############################
# 阶段1:运行阶段 #
#############################
FROM alpine:3.12.1
# 移除/etc目录的写权限(新增)
RUN chmod a-w /etc
# 创建用户组和用户
RUN addgroup -S appgroup
&& adduser -S appuser
-G appgroup
-h /home/appuser
# 复制应用到用户目录
COPY --from=builder /app /home/appuser/app
# 切换非root用户
USER appuser
CMD ["/home/appuser/app"]
这能防止运行时系统配置被篡改,降低攻击成功后的危害范围。
4. 移除Shell访问权限
Shell对攻击者极具价值。如果镜像中存在sh、bash、curl等工具,攻击者攻破应用后几乎必然会利用它们。所有配置步骤完成后,可从运行时镜像中移除Shell二进制文件。
#########################
# 阶段0:构建阶段 #
#########################
FROM ubuntu:22.04 AS builder
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y golang-go
COPY app.go .
RUN CGO_ENABLED=0 go build -o /app app.go
#############################
# 阶段1:运行阶段 #
#############################
FROM alpine:3.12.1
# 移除/etc目录写权限
RUN chmod a-w /etc
# 创建用户组和用户
RUN addgroup -S appgroup
&& adduser -S appuser
-G appgroup
-h /home/appuser
# 移除所有Shell工具(新增)
RUN rm -rf /bin/*
# 复制应用到用户目录
COPY --from=builder /app /home/appuser/app
# 切换非root用户
USER appuser
CMD ["/home/appuser/app"]
此时镜像已完全定型,仅包含应用二进制文件和运行必需的最小运行时环境。因此,执行类似以下的命令会失败:
docker exec -it <container-name> sh
缘由是容器内已不存在Shell。这能显著削弱攻击者攻破后的操作能力,同时强化“容器应运行单一用途进程,而非通用系统”的设计理念。
总结
一个优质的Docker镜像应具备这些特质:
- 体积小巧
- 可复现
- 精简最小化
- 配置明确
- 无多余权限和工具
Docker让构建镜像变得简单——但正确构建镜像需要刻意的工程化设计。
多阶段构建、版本固定、非root运行、文件系统权限限制、精简工具集,这些并非高级技巧,而是现代容器化应用的良好默认实践。
若你将Docker镜像视为生产级软件(而非单纯的打包产物),后续的部署运维工作都会变得更安全、更轻松。





