【Docker】正确构建Docker镜像:大小、分层与安全性优化

Docker镜像是现代应用交付中最基础的构建块之一。
不过,许多生产环境镜像仍存在不必要的臃肿、难以复现、默认配置不安全等问题。

本文将详细讲解:

  • 虚拟机与容器的核心区别(简要背景介绍)
  • Docker镜像的分层构建原理
  • 简单镜像为何会变得异常庞大
  • 多阶段构建如何大幅缩减镜像体积
  • 构建更小、更安全Docker镜像的实操步骤

本文聚焦实操技巧,所有方法均可直接落地应用。

虚拟机与容器(快速背景)

虚拟机

虚拟机一般包含:

  • 宿主操作系统
  • 宿主内核
  • 虚拟机监控程序(Hypervisor)
  • 客户操作系统(含独立内核)
  • 运行在客户系统中的应用

每个虚拟机都包含完整的操作系统,虽能提供强隔离性,但也存在明显短板:

  • 资源占用高
  • 启动速度慢
  • 维护开销大

容器

容器采用完全不同的架构:

  • 宿主操作系统
  • 单一宿主内核
  • 容器运行时(如Docker、containerd)
  • 通过内核特性隔离的应用进程

容器不自带完整操作系统,而是依赖Linux内核的核心机制实现隔离:

  • 命名空间(Namespaces):实现进程、网络、文件系统视图的隔离
  • 控制组(cgroups):限制资源使用额度

容器本质上是被放入隔离环境的普通进程,这使其具备轻量、快速的特性——但也意味着镜像的质量和配置至关重大。

Docker镜像的构建原理:分层结构

Docker镜像基于分层文件系统构建。

Dockerfile中的每条指令,要么:

  • 创建新的文件系统层
  • 或更新镜像元数据

会创建分层的指令

这类指令一般会增加镜像体积:

  • RUN
  • ​COPY
  • ​ADD

不会创建分层的指令

这类指令仅修改元数据:

  • ​CMD
  • ​ENTRYPOINT
  • ​ENV
  • ​EXPOSE
  • ​USER

额外的分层会带来这些问题:

  • 镜像体积增大
  • 构建时间延长
  • 潜在攻击面扩大

理解分层机制,是构建高效镜像的关键。

为何简单镜像会变得异常庞大?

假设我们有一个极简的Go应用,功能是每秒打印当前用户。

一个直观的Dockerfile可能是这样:

  1. 基于Ubuntu镜像
  2. 安装Go编译器
  3. 复制源代码
  4. 编译二进制文件
  5. 运行应用

构建完成后执行 docker image ls | grep app,结果可能如下:

app latest ~700MB

对于一个微小的应用来说,这个体积过于庞大。

问题根源

最终镜像中仍包含了所有构建依赖:

  • Ubuntu基础镜像
  • Go编译器
  • 所有构建依赖包
  • 安装和编译过程中创建的所有分层

即便运行时已不再需要编译器,它依然会残留在镜像中。

多阶段构建:正确的解决方案

多阶段构建能干净利落地解决镜像臃肿问题。

核心思路

  1. 用第一个镜像构建应用(构建阶段)
  2. 用第二个精简镜像运行应用(运行阶段)
  3. 仅在阶段间复制编译后的产物
  4. 丢弃所有无用依赖

最终只有最后一个阶段会成为最终镜像。

示例:多阶段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镜像视为生产级软件(而非单纯的打包产物),后续的部署运维工作都会变得更安全、更轻松。

© 版权声明

相关文章

暂无评论

none
暂无评论...