凌晨两点半的上线事故
2024年3月,我们公司一个PHP 8.2 + Laravel 11的项目要上线。CI流水线跑完,docker push把镜像推到阿里云ACR,然后K8s滚动更新。一切看起来很正常,直到生产环境Pod一直处于ImagePullBackOff状态。
查了半天,发现问题是镜像太大:1.4GB。K8s节点的磁盘被占满,镜像拉不下来。运维同事在群里说「要不你们把镜像优化一下?」——轻飘飘一句话,我花了三个通宵。
后来我把这个镜像从1.4GB压缩到148MB,构建时间从5分30秒缩短到2分05秒,CI推送时间从4分钟降到40秒。这里面的每一步都是拿生产事故换来的。
问题拆解:镜像为什么会这么大
先看原始Dockerfile(别笑,当时我们就是这么写的):
# 第一版:所有人都在用的祖传Dockerfile
FROM php:8.2-apache
# 安装系统依赖和PHP扩展
RUN apt-get update && apt-get install -y \
git \
unzip \
libzip-dev \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libonig-dev \
libxml2-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install pdo_mysql zip gd mbstring exif pcntl bcmath \
&& apt-get clean
# 安装Composer
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer
# 复制项目代码
WORKDIR /var/www/html
COPY . /var/www/html
# 安装PHP依赖
RUN composer install --no-dev --optimize-autoloader
# 设置权限
RUN chown -R www-data:www-data /var/www/html \
&& a2enmod rewrite
这个镜像的问题很清楚:
- php:8.2-apache 基于 Debian bookworm,基础镜像本身就300MB+
- apt安装的依赖全留在镜像里,编译工具链、临时文件都没清
- composer install 在构建阶段执行,但下载的依赖包缓存、源码全都在
- 整个项目代码直接COPY进去,包含.git目录、node_modules、测试文件、本地配置
- 一个镜像里装了apache服务器、全部运行时环境、项目代码、构建产物
方案对比:三种优化思路
方案A:换Alpine基础镜像(看似简单,坑最多)
很多人第一反应是把php:8.2-apache换成php:8.2-alpine。Alpine基于musl libc,体积确实小(基础镜像只有50MB左右),但坑也很深:
- PHP官方Alpine镜像用了musl libc,某些PHP扩展(比如swoole、redis)需要编译,而Alpine用的是musl,很多C库不兼容,得额外打patch
- Alpine的包管理器是apk,很多apt时代的shell脚本要完全重写
- 最恶心的坑:Alpine默认用 busybox,里面自带的wget不支持HTTPS,curl也没有,调试问题的时候能让你怀疑人生
- 线上环境出问题,生产环境没有bash、没有strace,排查问题的常用命令全没有
我们试过Alpine,踩了swoole扩展编译失败的坑,花了两天才搞定。如果项目只用简单扩展,Alpine是个不错的选择;但如果依赖swoole、redis、grpc这类需要底层编译的扩展,我劝你慎重。
方案B:多阶段构建(推荐,收益最大)
多阶段构建的核心思想:构建阶段用完全体镜像,把编译好的产物复制到干净的运行阶段镜像里。最终镜像只保留运行所需的文件,跟构建过程相关的全部丢弃。
这样做的本质是:构建阶段和运行阶段解耦。
我们最终采用的就是这个方案。具体来说:
- 阶段一(builder):用php:8.2-cli + Debian作为构建环境,装全套编译工具,composer install、npm run build(如果前端需要)都在这里做
- 阶段二(runtime):用php:8.2-fpm-alpine,只保留运行时需要的PHP扩展,拷贝编译好的vendor、public目录(含打包后的前端资源)
方案C:镜像分层优化(辅助手段)
这个不能单独用,但作为补充很有价值。核心是把经常变的层放后面,把不常变的依赖层放前面,最大限度利用Docker的Layer Cache。配合多阶段构建一起用。
三种方案对比下来,多阶段构建收益最大、坑最少、可复制性最强。下面是我的完整落地过程。
完整方案:多阶段构建 + 动静分离
最终版目录结构
project-root/
├── docker/
│ ├── production/
│ │ ├── Dockerfile
│ │ └── php.ini
│ └── development/
│ └── Dockerfile
├── src/
│ └── (Laravel项目代码)
├── .dockerignore
├── docker-compose.yml
└── Makefile
.dockerignore:第一步就把垃圾挡在门外
# .dockerignore
.git
.gitignore
node_modules
npm-debug.log
storage/logs/*.log
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/views/*
.idea
.vscode
docker-compose*.yml
Makefile
.env
.phpunit.result.cache
tests
phpunit.xml
这一步很多人忽略,但它效果特别明显。我们项目里有node_modules大概200MB,.git目录100多MB,之前全被当成构建上下文传到Docker守护进程,又慢又占空间。加了这个文件之后,CPU和内存占用直接降了一个量级,构建时间立省30%。
生产环境Dockerfile(完整可运行版)
# =============================================
# 阶段一:构建阶段(builder)
# 用 php:8.2-cli-debian(完整版),安装所有编译工具
# =============================================
FROM php:8.2-cli-bookworm AS builder
# 安装系统依赖 + PHP扩展
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
unzip \
libzip-dev \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libonig-dev \
libxml2-dev \
libcurl4-openssl-dev \
pkg-config \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j"$(nproc)" \
pdo_mysql \
zip \
gd \
mbstring \
exif \
pcntl \
bcmath \
intl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# 安装Composer
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer
# 设置工作目录
WORKDIR /app
# 先复制composer.json和composer.lock,利用Docker Layer Cache
# 只要composer.lock不变,这一层就不会重新执行
COPY src/composer.json src/composer.lock ./
# 安装PHP依赖
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-progress
# 再复制源码
COPY src/ .
# 生成优化后的自动加载器(因为前面--no-autoloader跳过了)
RUN composer dump-autoload --optimize
# 生成Laravel配置缓存
RUN php artisan config:cache \
&& php artisan route:cache \
&& php artisan view:cache
# =============================================
# 阶段二:运行阶段(runtime)
# 用 php:8.2-fpm-alpine,尽量精简
# =============================================
FROM php:8.2-fpm-alpine
# 安装运行时需要的扩展(不装编译工具)
# 因为Alpine的PHP扩展是从官方源装的,直接apk add就行
RUN apk add --no-cache \
nginx \
supervisor \
curl \
tzdata \
&& docker-php-ext-install pdo_mysql opcache \
&& ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone
# 拷贝PHP配置(优化后的生产配置)
COPY docker/production/php.ini /usr/local/etc/php/conf.d/99-production.ini
# 从builder阶段拷过来:vendors、项目代码、Laravel缓存
COPY --from=builder /app /var/www/html
# 配置nginx + php-fpm + supervisor
COPY docker/production/nginx.conf /etc/nginx/nginx.conf
COPY docker/production/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
WORKDIR /var/www/html
# 目录权限
RUN chown -R www-data:www-data /var/www/html \
&& chmod -R 755 /var/www/html/storage \
&& chmod -R 755 /var/www/html/bootstrap/cache
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost/healthz || exit 1
EXPOSE 80
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
为什么构建阶段用Debian、运行阶段用Alpine?
这是坑出来的经验:
- 构建阶段用Debian系的全量镜像,是因为PHP扩展的编译工具链(gcc、make、autoconf)在Debian上最全、最稳定。你要是用Alpine,编译swoole都要专门打patch,折腾到你想骂娘
- 运行阶段用Alpine,是因为正式运行时不需要编译能力,只需要运行环境。Alpine的包管理器apk装模块非常快,镜像体积只有Debian的一半
- PHP官方镜像的fpm-alpine变体自带docker-php-ext-install脚本,可以通过这个装PHP扩展,不用手动apk add php8-pecl-xxx这种东西
php.ini 生产配置(关键参数实测有效)
; docker/production/php.ini
; 生产环境PHP配置
; OPcache:实测开启后API响应时间从180ms降到90ms
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=128
opcache.interned_strings_buffer=8
opcache.max_accelerated_files=10000
opcache.validate_timestamps=0
opcache.revalidate_freq=0
opcache.fast_shutdown=1
; PHP内存限制
memory_limit=512M
; 上传大小
upload_max_filesize=20M
post_max_size=20M
; 超时设置
max_execution_time=60
; 显示错误(生产环境别开display_errors,我之前因为这个被安全团队点名过)
display_errors=Off
log_errors=On
error_log=/var/log/php-fpm.log
; 时区
date.timezone=Asia/Shanghai
nginx配置:PHP应用直接跑
# docker/production/nginx.conf
user www-data;
worker_processes auto;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
# Laravel的入口文件
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# 静态资源缓存(Laravel的public目录下的前端构建产物)
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2|ttf|eot|svg)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
location ~ /\.(?!well-known).* {
deny all;
return 403;
}
# 健康检查(供K8s使用)
location = /healthz {
access_log off;
return 200 "ok";
}
}
}
supervisord:一个进程管理nginx和php-fpm
; docker/production/supervisord.conf
[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisord.log
pidfile=/var/run/supervisord.pid
[program:php-fpm]
command=/usr/local/sbin/php-fpm -F
stdout_logfile=/var/log/php-fpm.log
redirect_stderr=true
autostart=true
autorestart=true
[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
stdout_logfile=/var/log/nginx.log
redirect_stderr=true
autostart=true
autorestart=true
docker-compose.yml(本地联调用)
# docker-compose.yml
services:
php:
build:
context: .
dockerfile: docker/production/Dockerfile
ports:
- "8080:80"
environment:
APP_ENV: production
APP_DEBUG: "false"
DB_HOST: mysql
REDIS_HOST: redis
depends_on:
- mysql
- redis
networks:
- app-net
mysql:
image: mysql:8.0.35
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: app
volumes:
- mysql-data:/var/lib/mysql
networks:
- app-net
redis:
image: redis:7.2-alpine
networks:
- app-net
volumes:
mysql-data:
networks:
app-net:
driver: bridge
构建命令(直接复制跑)
# 构建镜像(--no-cache:第一次构建确保不依赖旧缓存)
docker build --no-cache -t myapp:1.0.0 -f docker/production/Dockerfile .
# 查看镜像大小
docker images | grep myapp
# 本地跑起来测试
docker-compose up -d
# 测试接口是否正常
curl http://localhost:8080/healthz
多阶段构建原理:同样是FROM,为什么体积差这么多?
核心在于每个FROM都开启一个新的构建阶段,最终镜像只保留最后一个阶段的文件系统。前面阶段产生的所有中间层(apt包缓存、编译临时文件、源码、Composer缓存)都不会出现在最终镜像里。
举个例子:composer install在builder阶段跑完,生成了vendor目录。但vendor目录里有很多源码文件(比如laravel/framework整个框架的源码都有),你不需要在运行时查看这些源码,只要PHP能加载它们就行。多阶段构建让你可以把这一堆依赖打完包,只拷贝vendor目录到运行镜像里。
实际数据:builder阶段镜像1.2GB,但最终运行镜像只有148MB。差了近8倍,这8倍全是构建产物和工具链。
效果数据:改前vs改后(实测)
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| 镜像大小 | 1.4GB | 148MB | 降低89.4% |
| 构建时间(含Composer安装) | 5分30秒 | 2分05秒 | 缩短62.1% |
| CI推送时间(到阿里云ACR) | 4分05秒 | 40秒 | 缩短83.7% |
| K8s拉镜像时间(生产环境) | 3分钟以上 | 15秒 | 缩短91.7% |
| 磁盘占用(开发机镜像总和) | 2.8GB | 300MB | 降低89.3% |
| 首次请求响应时间(已排除启动时间) | 2.4s | 1.1s | 缩短54.2% |
这里特别要说一个细节:镜像小了之后,CI推送快了,但真正的收益在生产环境。K8s的节点磁盘是有限的,之前每个节点的镜像缓存都被撑爆了,新Pod调度过去总是要重新拉镜像。优化后,一个140MB的镜像在K8s节点上是秒级拉取,ImagePullBackOff再也没出现过。
避坑指南:这5个坑都是我踩过的
坑1:Alpine的musl libc坑(差点让整个方案流产)
第一次尝试Alpine的时候,我们的项目用了swoole扩展。swoole依赖一些底层C库,在Debian上apk装完就能用,但在Alpine上编译失败,报错信息是:
checking for swoole... configure: error: Cannot find OpenSSL's libraries
这其实是musl libc和glibc不兼容导致的。解决方案只有两个:换回Debian,或者给swoole打patch。我的建议是:项目里有任何不在PHP官方扩展列表里的C扩展,别用Alpine。如果只是pdo_mysql、gd、mbstring这种官方扩展,用Alpine没问题。
坑2:Composer install在构建阶段执行时,`--no-dev` 扔掉了测试依赖,但要小心Laravel的artisan命令需要扩展
在构建阶段跑 php artisan config:cache 的时候,如果某个配置项在config文件里用了环境变量,而这些环境变量在构建阶段不存在,就会报错。解法:构建阶段设置默认环境变量,或者在Dockerfile里用 ENV 关键字定义一份,至少保证config:cache能跑通。
# 要这样:在构建阶段给个默认值
ENV APP_ENV=production \
APP_KEY=base64:xxxxxxxxxxxxx \
DB_CONNECTION=mysql \
DB_HOST=localhost \
DB_PORT=3306 \
DB_DATABASE=app \
DB_USERNAME=root \
DB_PASSWORD=secret
注意:这个APP_KEY必须是真实有效的base64编码的key,否则config:cache会报错。你可以在本地 php artisan key:generate 然后用生成的key。
坑3:Composer的autoload优化必须在构建阶段做,不能跳过
因为前面用了 --no-autoloader(为了在依赖变更时利用Layer Cache),所以跑完composer install之后必须立即执行 composer dump-autoload --optimize。如果你忘了这一步,运行阶段会报Class not found错误。
为什么会这样?因为composer install --no-autoloader只下载依赖包,不生成autoload文件。依赖包多了之后,每次composer install都重新生成autoloader非常耗时(我们项目实测要多花40秒)。先拷composer.json和composer.lock,装完依赖再拷源码,最后统一生成autoloader,这样只要composer.lock不变,Docker Layer Cache就能跳过整个composer install步骤。
坑4:Laravel的storage目录权限问题(第10086次出现)
优化后的镜像里,storage/logs、storage/framework/cache、storage/framework/views 这些目录必须可写。如果构建阶段拷贝时忘记 chown -R www-data:www-data,运行阶段就会疯狂报Permission denied。
我在Dockerfile里最后加了这一行:
RUN chown -R www-data:www-data /var/www/html \
&& chmod -R 755 /var/www/html/storage \
&& chmod -R 755 /var/www/html/bootstrap/cache
这个必须在RUN里做,因为COPY --from=builder拷贝过来的文件的所有者和权限在构建阶段就已经固定了,你改了builder里的权限也没用,得在runtime阶段重新设置。
坑5:K8s的镜像拉取策略——要设置成Always,也别设置成Always
这个是个悖论。如果你在K8s里部署的时候设置了 imagePullPolicy: IfNotPresent,那么节点上如果没有这个tag的镜像,才会去拉。这在开发环境省时间,但生产环境有可能导致:本地缓存了一个旧镜像,tag没变就永远不会拉新版本。
我们用的tag策略是版本号(比如1.2.3),这样每次发版都是新tag,不存在这个问题。如果你坚持用latest,一定要把imagePullPolicy设为Always。这是运维层面的事,但镜像优化做完之后顺手把K8s的yaml也检查一遍比较稳妥。
更进一步的优化(按需采用)
1. 压缩层(BuildKit + 压缩)
# 用BuildKit构建,开启压缩
DOCKER_BUILDKIT=1 docker build --build-arg BUILDKIT_INLINE_CACHE=1 -t myapp:1.0.0 .
注意:这个取决于你的镜像仓库是否支持压缩层。阿里云ACR是支持的,Harbor也是。压缩之后128MB的镜像会再减到90MB左右。
2. 分阶段缓存
把Composer依赖的缓存放到一个单独的层里,这样每次构建的时候,只要composer.lock不变,composer install不会重新跑。
# 这样写:把composer.json和composer.lock单独复制
COPY src/composer.json src/composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-progress \
&& composer dump-autoload --optimize
实测数据:缓存命中后,从2分05秒降到了40秒。
3. 基础镜像里直接装nginx + php-fpm
官方php:8.2-fpm-alpine自带php-fpm,但nginx需要自己装。在Alpine里apk add nginx大概2秒。装完之后,用supervisord同时管理两个进程即可。为什么我们不推荐用apache?因为nginx + php-fpm的请求处理模型在PHP上性能更好,内存占用也更低。我们的压测数据显示:nginx + php-fpm在500并发下比apache + mod_php快31%。
验证:优化后的镜像确实能用(生产验证)
说再多不如跑一把。我在交付给运维之前,自己先做了一轮验证:
# 1. 镜像构建
docker build -t myapp:1.0.0 -f docker/production/Dockerfile .
# 输出:Successfully built 8f5c1a2b3c4d
# 2. 检查镜像
docker images | grep myapp
# 输出:myapp 1.0.0 8f5c1a2b3c4d About a minute ago 148MB
# 3. 用镜像起容器
docker run -d -p 8080:80 --name myapp-test myapp:1.0.0
# 4. 测接口
curl -v http://localhost:8080/healthz
# HTTP/1.1 200 OK
# 5. 测一个实际的Laravel路由(用GET带一点参数)
time curl -s http://localhost:8080/api/v1/users\?limit\=10 | jq '.'
# real 0m0.121s
# 6. 进入容器检查PHP信息
docker exec -it myapp-test php -v
# PHP 8.2.15 (cli) (built: Feb 1 2024 10:35:23)
再把镜像推到生产环境,K8s部署验证:从ImagePullBackOff到Running,整个流程持续在线,无回滚。
写在最后
Docker镜像优化不是炫技。它直接决定了你的CI流程快不快、K8s节点稳不稳、部署顺不顺。一个1.4GB的镜像和148MB的镜像,差的不是体积,是上线时候的心跳次数。
我的建议:如果你项目还在用单阶段构建,看完这篇就去改成多阶段。改一次,收益终身。