从一次线上事故说起
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 GB | 12 min | 低(包含编译工具链) | 不推荐 |
| 构建机手动瘦身 | 412 MB | 9 min | 中(依赖基础镜像状态) | 临时方案 |
| 多阶段构建 | 89 MB | 3 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 GB | 89 MB | ↓ 92.6% |
| CI构建时间(冷缓存) | 12 min 10 s | 3 min 48 s | ↓ 68.8% |
| CI构建时间(热缓存) | 8 min 45 s | 2 min 12 s | ↓ 74.9% |
| 推送时间(10Mbps内网) | 19 min | 1 min 30 s | ↓ 92.1% |
| K8s拉取启动时间 | 4 min 25 s | 28 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=1、opcache.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)。如果你的版本不同,数据会有偏差,但思路不变。