为什么要优化 Docker 镜像

Docker 镜像并不是一个简单的压缩包,而是由多个只读 Layer 组成的文件系统。

例如:

Dockerfile
    │
    ├── FROM
    ├── RUN
    ├── COPY
    └── RUN
          │
          ▼
    Image Layers
          │
          ▼
      Container

镜像体积会直接影响:

  • 构建时间。

  • 镜像 Push 时间。

  • 镜像 Pull 时间。

  • CI/CD 执行时间。

  • 磁盘占用。

  • 容器启动速度。

  • 镜像攻击面。

因此,镜像优化并不只是为了“让镜像变小”。

更准确地说:

镜像优化
   │
   ├── 减少无用文件
   ├── 减少无用依赖
   ├── 提高构建缓存命中率
   ├── 缩短构建时间
   ├── 减少网络传输
   └── 降低攻击面

Docker 官方也将“小而聚焦”的镜像作为生产镜像构建的重要目标,并强调镜像大小和安全性之间存在直接关系。

因此,一份生产级 Dockerfile 通常应该同时考虑:

体积
性能
缓存
安全
可维护性

而不是只关注其中一个指标。


选择合适的基础镜像

基础镜像是整个 Docker 镜像的起点。

例如:

FROM ubuntu:24.04

或者:

FROM debian:bookworm

也可以使用更精简的基础镜像:

FROM alpine:3.22

对于 PHP 应用,还可能使用:

FROM php:8.3-cli

或者:

FROM php:8.3-fpm

基础镜像并不是越小越好。

例如:

完整发行版
   ↓
Debian
   ↓
Slim
   ↓
Alpine

镜像可能逐渐变小,但同时也可能带来:

  • 软件包管理方式不同。

  • C 库实现差异。

  • 调试工具缺失。

  • 第三方扩展兼容性问题。

  • 构建复杂度增加。

因此不要为了追求几十 MB 的差异,盲目更换基础镜像。

更合理的原则是:

选择能够满足应用运行需求的最小基础镜像。

同时应该固定基础镜像版本:

FROM php:8.3-fpm-bookworm

而不是:

FROM php:latest

这样可以减少基础镜像自动变化带来的不可预测性。


使用多阶段构建减少最终镜像

多阶段构建是 Docker 镜像优化中最重要的方法之一。

传统 Dockerfile 可能把编译环境和运行环境全部放进最终镜像:

最终镜像
├── 编译器
├── Git
├── Composer
├── 开发依赖
├── 源代码
├── 构建工具
└── 运行时

实际上生产环境只需要:

最终镜像
├── 运行时
├── 应用代码
└── 生产依赖

多阶段构建就是将两者拆开:

Builder Stage
├── Composer
├── Git
├── GCC
├── 开发依赖
└── 编译工具
        │
        │ COPY --from
        ▼
Runtime Stage
├── PHP Runtime
├── Application
└── Production Dependencies

Docker 官方推荐使用 Multi-stage Build,因为它可以将构建环境和最终运行环境分离,从而减少最终镜像体积和攻击面。

例如 PHP 应用:

FROM composer:2 AS builder

WORKDIR /app

COPY composer.json composer.lock ./

RUN composer install \
    --no-dev \
    --no-interaction \
    --prefer-dist \
    --optimize-autoloader

COPY . .

FROM php:8.3-fpm-bookworm

WORKDIR /var/www

COPY --from=builder /app /var/www

CMD ["php-fpm"]

最终镜像不会包含 Builder 阶段中不需要的构建工具。

对于 Node.js:

FROM node:22 AS builder

WORKDIR /app

COPY package*.json ./

RUN npm ci

COPY . .

RUN npm run build

FROM nginx:alpine

COPY --from=builder /app/dist /usr/share/nginx/html

最终 Nginx 镜像只需要保存构建后的静态文件。


合理利用 Docker 构建缓存

Dockerfile 的每条指令都可能产生 Layer。

例如:

COPY . .

RUN composer install

如果修改任何一个 PHP 文件:

COPY . .
   ↓
Cache Miss
   ↓
composer install
   ↓
重新执行

这会导致大量重复工作。

更合理的方式是:

COPY composer.json composer.lock ./

RUN composer install

COPY . .

这样:

composer.json
composer.lock
      │
      ▼
composer install
      │
      ▼
缓存

源码变化
      │
      ▼
COPY . .

只有依赖文件发生变化时,Composer 依赖层才需要重新构建。

因此 Dockerfile 通常应该遵循:

变化频率低的内容
        ↓
变化频率高的内容

例如:

COPY composer.json composer.lock ./

RUN composer install --no-dev --prefer-dist

COPY app ./app
COPY config ./config
COPY public ./public

而不是一开始就:

COPY . .

Docker 官方构建最佳实践也建议合理利用 Build Cache,并通过拆分构建阶段和共享 Stage 来提高构建效率。


使用 .dockerignore 减少构建上下文

执行:

docker build .

最后的:

.

表示当前目录是 Build Context。

如果项目目录中存在:

.git/
node_modules/
vendor/
runtime/
logs/
.env
.idea/
.vscode/

而这些内容没有被排除,它们都可能进入构建上下文。

因此项目根目录应该配置:

.dockerignore

例如 PHP 项目:

.git
.github

.idea
.vscode

vendor
node_modules

runtime
logs

.env
.env.*

Dockerfile*
docker-compose*.yml

*.log
*.tmp

其中尤其需要注意:

.env
.env.*

因为环境变量文件通常包含:

数据库密码
Redis 密码
API Key
Access Token
第三方服务凭证

不应该把它们复制到 Docker Build Context。

.dockerignore 的核心作用有两个:

减少 Build Context
        +
避免无关文件进入镜像构建流程

但是要注意:

.dockerignore 不能代替 Secrets 管理。

如果 Secret 已经通过其他方式进入 Dockerfile,仍然可能被写入镜像 Layer。


不要把 Secret 写进镜像

这是 Docker 镜像安全中非常重要的一点。

错误示例:

ARG NPM_TOKEN

RUN npm config set //registry.example.com/:_authToken=$NPM_TOKEN

或者:

ENV API_KEY=xxxxxxxx

甚至:

RUN echo "password=123456" > /app/config.ini

问题在于 Docker Image 的 Layer 是持久化的。

即使后续执行:

RUN rm /app/config.ini

之前 Layer 中的数据仍然可能存在。

因此:

Secret
  ↓
ARG / ENV / RUN
  ↓
Image Layer
  ↓
泄露风险

Docker 官方明确指出,Build Arguments 和环境变量不适合传递构建 Secret,因为敏感信息可能持久存在最终镜像或构建历史中。应该使用 BuildKit 的 Secret Mount 或 SSH Mount。

例如:

# syntax=docker/dockerfile:1

RUN --mount=type=secret,id=composer_auth \
    composer install --no-interaction

构建:

docker build \
  --secret id=composer_auth,src=$HOME/.composer/auth.json \
  -t my-api:latest \
  .

Secret 只在对应的构建步骤中临时提供,不需要写入最终镜像。

如果需要访问私有 Git Repository,也可以使用 SSH Mount:

RUN --mount=type=ssh \
    git clone git@github.com:example/private-repository.git

这比把 SSH Key 写进 Dockerfile 或 Build Args 中安全得多。


以非 root 用户运行容器

很多基础镜像默认使用:

root

运行应用。

例如:

FROM php:8.3-fpm

WORKDIR /var/www

COPY . .

CMD ["php-fpm"]

如果应用进程被攻击,攻击者可能直接获得容器内的 root 权限。

更合理的方式是创建专用用户:

FROM php:8.3-cli

RUN groupadd --system app \
    && useradd --system --gid app app

WORKDIR /app

COPY --chown=app:app . .

USER app

CMD ["php", "bin/hyperf.php", "start"]

这里:

USER app

表示容器默认以 app 用户运行。

这可以降低容器进程被攻击后的权限范围。

Docker Scout 的默认策略中也包含 Default non-root user 相关检查,说明非 root 运行已经成为容器镜像安全评估中的重要实践。

需要注意:

容器非 root

并不等于:

宿主机绝对安全

它只是降低容器内进程的权限和潜在影响范围。

如果基础设施允许,还可以进一步考虑 Docker Rootless Mode。Rootless Mode 可以让 Docker daemon 和容器都在非 root 用户环境下运行,从而降低 daemon 和 runtime 漏洞带来的风险。


减少镜像中的无用组件

一个常见问题是:

RUN apt-get update \
    && apt-get install -y \
       git \
       curl \
       vim \
       wget \
       gcc \
       make

这些工具可能只在构建阶段需要。

如果最终运行环境根本不需要:

git
gcc
make
vim
wget

就没有必要全部放进 Runtime Image。

因此:

Builder
├── gcc
├── make
├── git
├── composer
└── 开发工具

Runtime
├── PHP
├── 应用
└── 必要运行库

这是多阶段构建的核心价值。

另外,使用 Debian/Ubuntu 类基础镜像安装软件时,也应注意减少不必要的包,并在同一个 RUN 中完成安装和清理:

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
       libzip-dev \
    && rm -rf /var/lib/apt/lists/*

这样可以避免软件包索引长期保留在镜像 Layer 中。


镜像漏洞扫描

即使 Dockerfile 写得非常规范,镜像仍然可能存在漏洞。

例如:

基础镜像
   ↓
OS Packages
   ↓
PHP Extensions
   ↓
Composer Dependencies
   ↓
Application

任何一层都可能存在 CVE。

因此:

Build
 ↓
Scan
 ↓
Push

比:

Build
 ↓
Push

更加合理。

Docker Scout 可以分析镜像内容,生成 SBOM,并根据漏洞数据库识别镜像中的已知漏洞。

例如:

docker scout cves my-api:latest

查看镜像漏洞。

也可以:

docker scout cves registry.example.com/backend/api:v1.0.0

针对 Registry 中的镜像进行分析。

还可以查看 SBOM:

docker scout sbom my-api:latest

Docker 官方提供的 docker scout sbom 可以列出镜像中包含的软件包及版本信息。

需要注意:

漏洞扫描不是一次性任务。

今天没有漏洞:

v1.0.0
    ↓
0 CVE

并不意味着下个月仍然没有漏洞。

因为新的 CVE 会不断发布。

因此生产环境需要:

构建时扫描
      +
持续监控
      +
定期重建

SBOM 与 Provenance

随着软件供应链安全要求提高,仅仅知道:

镜像有没有漏洞

已经不够。

还需要知道:

镜像里面有什么?
镜像从哪里来?
镜像是怎么构建的?
谁构建的?
使用了哪些依赖?

这就是 SBOM 和 Provenance 的作用。

SBOM

SBOM,即 Software Bill of Materials,可以理解为:

软件物料清单。

例如一个 PHP 镜像:

Image
├── Debian
│   ├── libc
│   ├── openssl
│   └── curl
│
├── PHP
│
├── Composer
│
└── Composer Packages
    ├── symfony/*
    ├── psr/*
    └── hyperf/*

SBOM 记录这些软件组件及其版本等信息。

Docker 的 BuildKit 支持在构建时生成 SBOM Attestation,并将其附加到镜像元数据中。

Provenance

Provenance 更关注:

这个镜像是怎么构建出来的?

例如:

Source Repository
      ↓
Commit SHA
      ↓
GitHub Actions
      ↓
Docker Build
      ↓
Image

这样可以建立:

代码
 ↓
构建过程
 ↓
镜像

之间的关系。

构建时可以使用:

docker buildx build \
  --provenance=true \
  --sbom=true \
  -t registry.example.com/backend/api:v1.0.0 \
  --push \
  .

Docker 官方目前将 SBOM 和 Provenance 都作为 Build Attestations 的重要组成部分。


将镜像安全检查加入 CI/CD

前面的 Docker CI/CD 流程可以进一步升级:

Git Push
    ↓
Test
    ↓
Build
    ↓
Vulnerability Scan
    ↓
SBOM
    ↓
Provenance
    ↓
Push
    ↓
Deploy

例如:

- name: Build and Push
  uses: docker/build-push-action@v7
  with:
    context: .
    push: true
    tags: ${{ steps.m