Docker多阶段构建:镜像体积从1.2G到89M
发布日期: 2026/08/10 阅读总量: 1

从一次线上事故说起

2024年3月,我们线上服务突然出现大批504错误。排查后发现:推送一个1.2GB的PHP应用镜像到私有仓库耗时19分钟,K8s滚动更新时拉取镜像超时,POD一直处于ImagePullBackOff状态。

这不是个例。我们的CI流水线每次构建要跑12分钟,其中8分钟花在重新下载vendor依赖和安装扩展上——因为基础镜像用了php:8.3-apache(包含大量我们根本用不到的模块:mysqli、pgsql、gd、zip……)。

这篇文章记录我怎么把镜像从1.2GB砍到89MB,构建时间从12分钟降到3分48秒,以及踩过的所有坑。

方案对比:三种镜像构建策略

方案镜像体积构建时间安全性适用场景
传统单阶段构建1.2 GB12 min低(包含编译工具链)不推荐
构建机手动瘦身412 MB9 min中(依赖基础镜像状态)临时方案
多阶段构建89 MB3 min 48 s高(仅运行所需)生产推荐

数据来自同一台构建机(8C16G,SSD),同一份代码仓库(基于Laravel 11 + PHP 8.3 + Nginx 1.24),构建后未经压缩。多阶段构建把体积缩小了92.6%

手动瘦身的坑在于:你删了一个包,不知道它有没有被别的模块依赖。你改了系统配置,不知道哪天构建机恢复快照就丢了。多阶段构建把「编译」和「运行」彻底隔离——编译阶段可以任意造,运行阶段只保留结果。

目录结构

project/
├── docker/
│   ├── Dockerfile
│   ├── nginx/
│   │   └── default.conf
│   └── php/
│       └── php.ini
├── src/                  # Laravel 应用源码
├── public/
├── .dockerignore
├── docker-compose.yml    # 本地开发环境
└── deploy.sh             # 生产构建脚本

完整代码实现

1. Dockerfile(多阶段构建核心)

# ========== 阶段一:编译PHP扩展 ==========
FROM php:8.3-cli-alpine3.19 AS php-build

# 编译依赖
RUN apk add --no-cache --virtual .build-deps \
        gcc g++ make autoconf \
        libzip-dev freetype-dev libjpeg-turbo-dev \
        libpng-dev oniguruma-dev

# 安装pdo_mysql和opcache(体积最小,性能最好)
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install -j$(nproc) pdo_mysql opcache gd

# ========== 阶段二:安装composer依赖 ==========
FROM composer:2.7 AS vendor

WORKDIR /app
COPY src/composer.json src/composer.lock ./

# 只装生产依赖,不带dev,不跑脚本
RUN composer install --no-dev --no-autoloader --no-scripts \
    --no-interaction --prefer-dist --optimize-autoloader

# ========== 阶段三:运行时镜像 ==========
FROM php:8.3-fpm-alpine3.19 AS runtime

# 运行时依赖(比编译依赖小一个数量级)
RUN apk add --no-cache \
        nginx \
        curl \
        libzip freetype libjpeg libpng \
    && docker-php-ext-install pdo_mysql opcache

# 复制编译好的扩展
COPY --from=php-build /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/
COPY --from=vendor /app/vendor/ /var/www/html/vendor/

# 应用代码
WORKDIR /var/www/html
COPY src/ .
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY docker/php/php.ini /usr/local/etc/php/conf.d/custom.ini

# 权限控制
RUN chown -R www-data:www-data storage bootstrap cache \
    && chmod -R 775 storage bootstrap cache

EXPOSE 80
CMD ["sh", "-c", "php-fpm -D && nginx -g 'daemon off;'"]

2. composer.json(依赖配置示例)

{
    "require": {
        "php": "^8.3",
        "laravel/framework": "^11.0",
        "laravel/tinker": "^2.9",
        "predis/predis": "^2.2"
    },
    "require-dev": {
        "phpunit/phpunit": "^11.0",
        "fakerphp/faker": "^1.23"
    },
    "config": {
        "optimize-autoloader": true,
        "sort-packages": true,
        "platform": {
            "php": "8.3.0"
        }
    },
    "scripts": {
        "post-autoload-dump": [
            "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
            "@php artisan package:discover --ansi"
        ]
    }
}

3. nginx配置(优化静态文件处理)

server {
    listen 80;
    server_name _;
    root /var/www/html/public;
    index index.php;

    # 静态文件直接由nginx响应,不进PHP-FPM
    location /assets/ {
        expires 7d;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/var/run/php-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

4. php.ini(运行时配置)

memory_limit = 256M
max_execution_time = 30
post_max_size = 32M
upload_max_filesize = 32M

opcache.enable = 1
opcache.memory_consumption = 32
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0

session.save_handler = redis
session.save_path = "tcp://redis:6379?database=1"

5. docker-compose.yml(本地开发环境)

version: "3.8"

services:
  app:
    build:
      context: .
      dockerfile: docker/Dockerfile
    ports:
      - "8080:80"
    environment:
      - APP_ENV=local
      - DB_CONNECTION=mysql
      - DB_HOST=db
      - DB_PORT=3306
      - REDIS_HOST=redis
    depends_on:
      - db
      - redis
    volumes:
      - ./src:/var/www/html

  db:
    image: mysql:8.0.35
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: app
    ports:
      - "3306:3306"

  redis:
    image: redis:7.2-alpine
    ports:
      - "6379:6379"

6. 生产部署脚本(真正的构建入口)

#!/bin/bash
set -euo pipefail

IMAGE_NAME="registry.internal/app"
IMAGE_TAG="${1:-$(git rev-parse --short HEAD)}"
BUILD_TIME=$(date +%s)

echo "==> [$(date +%H:%M:%S)] 开始构建 ${IMAGE_NAME}:${IMAGE_TAG}"

# 关键:先拉旧镜像做缓存(如果有),构建能快50%
docker pull "${IMAGE_NAME}:latest" 2>/dev/null || true

docker build \
    --cache-from "${IMAGE_NAME}:latest" \
    --build-arg BUILD_TIME="${BUILD_TIME}" \
    --target runtime \
    -t "${IMAGE_NAME}:${IMAGE_TAG}" \
    -t "${IMAGE_NAME}:latest" \
    -f docker/Dockerfile \
    .

echo "==> 推送镜像"
docker push "${IMAGE_NAME}:${IMAGE_TAG}"
docker push "${IMAGE_NAME}:latest"

echo "==> 完成。镜像大小:"
docker images "${IMAGE_NAME}" --format "{{.Tag}}: {{.Size}}"

效果数据:优化前后对比

指标优化前优化后提升比例
镜像体积1.2 GB89 MB↓ 92.6%
CI构建时间(冷缓存)12 min 10 s3 min 48 s↓ 68.8%
CI构建时间(热缓存)8 min 45 s2 min 12 s↓ 74.9%
推送时间(10Mbps内网)19 min1 min 30 s↓ 92.1%
K8s拉取启动时间4 min 25 s28 s↓ 89.4%
磁盘占用(dev环境)3.7 GB(×3镜像副本)268 MB↓ 92.8%
CVE漏洞(Trivy扫描)27个(4个高危)2个(0个高危)↓ 安全提升明显

最直观的生产反馈:发布一次从「泡杯咖啡等」变成「喝口水就好」。K8s滚动更新时不再因为镜像拉取超时产生错误,线上没有因为发布再出现5xx。

为什么php:8.3-fpm-alpine比php:8.3-apache小这么多?

docker images看基础镜像体积:

php:8.3-apache      476MB
php:8.3-fpm-alpine  84.2MB
# 差距:391.8MB(82%)
# 原因:
# 1. alpine用musl libc替代glibc,省掉了大部分GNU兼容层
# 2. apache镜像自带mod_php等模块,我们根本用不到
# 3. alpine的包管理apk比apt轻量,依赖更少

原理拆解:多阶段构建为什么有效?

1. 传统构建的问题在于「一次构建,全部打包」

你RUN一条命令,就会产生一个新的镜像层。编译工具(gcc、make)一旦安装,就在层里待着。即使你后面RUN rm -rf删除了,Docker的分层机制也不会删除之前的层——因为层是只读的,删除只是在最上层加一条记录。所以镜像永远包含了编译工具。

这就是为什么你的生产镜像里带着gcc和make,白白胖胖的。

2. 多阶段构建解决「打包带工具」

多阶段构建的关键在于COPY --from。它允许你在一个临时镜像里编译,然后复制最终产物到新的干净镜像。编译工具链留在临时镜像里,不会进入最终镜像。

我们分三个阶段:

  • php-build:装gcc/make,编译扩展,产生.so文件
  • vendor:用composer官方镜像生成vendor目录
  • runtime:只包含nginx + php-fpm + 编译产物,裸奔

注意第二个阶段用了composer:2.7官方镜像。为什么不直接复制composer到runtime阶段?因为composer官方镜像自带composer可执行文件,省去安装过程,同时它的基础镜像已经包含php运行时,composer install可以直接跑。

3. --cache-from 让「每次构建」都变快

CI/CD环境每次从零开始构建,没有Docker层缓存。用--cache-from拉取上一次构建的镜像,Docker会复用未变化层的缓存。实测:代码只改了一行PHP时,构建时间从3分48秒缩减到2分12秒

避坑指南(每个都是真实踩过)

坑1:.dockerignore缺失导致构建上下文巨大

忘了写.dockerignore,构建上下文会包含node_modules、.git、storage/logs,导致docker build命令在执行之前就要打包几百MB数据到Docker守护进程。严重时会直接构建失败。

解决:项目根目录必须有.dockerignore:

.git
.gitignore
node_modules
vendor
storage/logs/*
.env
docker-compose.yml
Dockerfile*
*.md
.idea
.vscode

坑2:composer install --no-dev 还是装进去dev依赖

我们用composer:2.7阶段执行composer install,但第一次构建时用了composer.lock里包含phpunit的版本,APPPATH没加载。--no-dev写在composer命令里,但环境变量COMPOSER_ALLOW_SUPERUSER=1没设置,容器内默认是root用户,composer拒绝运行。

解决:构建vendor阶段加环境变量:

ENV COMPOSER_ALLOW_SUPERUSER=1

坑3:alpine的glibc兼容问题

我们的PHP扩展有部分依赖动态库(如libssl.so)。Alpine用musl,某些商业扩展(如ionCube loader、SG)不支持。测试阶段发现Redis扩展装不上,因为pecl安装redisd的编译文档要求glibc环境。

解决:生产环境我们最终用Debian slim版本(php:8.3-fpm-bookworm-slim)替代alpine,体积从89MB涨到115MB,但换来兼容性。如果业务不涉及这类扩展,alpine优先。

坑4:COPY --from 复制了源码而不是编译产物

在多阶段构建里,你很容易把东西复制到错误阶段。比如我想把vendor复制到runtime,结果写成了COPY --from=composer /app/vendor /var/www/html/vendor——composer阶段确实有vendor,但它没有/usr/local/lib/php/extensions目录里的编译产物。我写成了COPY --from=php-build /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/

验证方式:构建完成后进容器确认扩展目录。

docker run --rm your-image php -m | grep -E "pdo_mysql|opcache|gd"

坑5:nginx的worker_processes在容器里是auto,但没启动

我们用CMD ["sh", "-c", "php-fpm -D && nginx -g 'daemon off;'"]来同时跑php-fpm和nginx。nginx的默认配置文件包含daemon off才能前台运行。但docker/nginx/default.conf里没写,导致nginx启动后立即退出,容器重启循环。

解决:在主配置nginx.conf开头加daemon off;。这个文件我一开始没复制进去,后来在Dockerfile里显式COPY nginx.conf。

user  nginx;
worker_processes  auto;
daemon off;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  65;
    include /etc/nginx/conf.d/*.conf;
}

坑6:时区未设置导致日志时间和业务时间戳差8小时

alpine镜像默认UTC时区。我们线上业务记录的用户操作时间比北京时间早8小时,排查了半天才发现是容器时区问题。

# 在runtime阶段安装时区数据
RUN apk add --no-cache tzdata \
    && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
    && echo "Asia/Shanghai" > /etc/timezone \
    && apk del tzdata 2>/dev/null || true

坑7:opcache.validate_timestamps=0在本地环境的坑

生产环境设置opcache.validate_timestamps=0能提升性能(不检查文件时间戳),但docker-compose本地开发环境下,你的代码改了,opcache不刷新,页面还是旧代码。

解决:本地覆盖环境变量,或者docker-compose的volumes挂载时用consistent模式并设置opcache.validate_timestamps=1opcache.revalidate_freq=2。生产环境不要这样干。

坑8:部署时镜像tag用latest导致回滚困难

我们用git commit hash做tag,但构建脚本里也打了latest标签。有一天发布后需要紧急回滚一个版本,发现重新构建的latest镜像包含了后来commit的代码,回滚变得困难。

解决:生产回滚用固定commit hash的tag,latest仅给本地开发用。或者用git tag版本的语义化version。

总结:什么时候该用多阶段构建?

不是所有项目都适合。一个静态HTML站点镜像本来就不到10MB,用不上这套。以下情况建议上多阶段构建:

  • 有编译步骤(PHP扩展、Go程序、Node服务端渲染)
  • 生产需要精简,不想带工具链
  • CI流水线耗时过长、部署拉取慢
  • 容器安全扫描长期报高危漏洞,需要缩减攻击面

我们的教训是:镜像优化是工程问题,不是技巧问题。与其在Docker Hub上翻「怎么减小镜像体积」的帖子,不如把编译和运行分离、把基础镜像换成slim/alpine、给所有COPY路径加白名单。这三件事做完,90%的优化就到位了。

以上数字全部来自2024年3月线上真实数据,环境为PHP 8.3.4 / Laravel 11 / MySQL 8.0.35 / K8s 1.29.1 / Docker 25.0.5(BuildKit enabled)。如果你的版本不同,数据会有偏差,但思路不变。