问题:一个300MB的PHP镜像,部署要3分钟
去年我们团队接手一个遗留PHP项目,Docker镜像大小稳定在300MB左右。每次CI/CD构建+推送+拉取,耗时3分20秒。开发同学抱怨:改一行代码要等5分钟才能看到效果。更坑的是,镜像里塞满了开发工具、测试代码、甚至.git目录。
我接手后,花了2周时间把镜像从300MB砍到50MB,构建时间从3分20秒降到40秒。这篇文章就是我当时踩过的坑和最终方案。
方案对比:三种镜像构建策略
我测试了三种方案,直接说结论:
| 方案 | 镜像大小 | 构建时间 | 部署时间 | 安全风险 |
|---|---|---|---|---|
| 单阶段构建(原始) | 312MB | 120s | 200s | 高(包含源码、密钥) |
| 多阶段构建(Debian) | 158MB | 85s | 110s | 低 |
| 多阶段构建(Alpine) | 52MB | 65s | 40s | 低 |
最终选择Alpine方案。下面直接给完整代码。
完整实现:生产级Dockerfile
1. 基础镜像选择
PHP官方提供三种基础镜像:Debian、Alpine、Slim。Alpine基于musl libc,体积最小(5MB vs Debian的120MB)。但注意:Alpine的PHP扩展编译方式不同,需要额外处理。
# 第一阶段:构建依赖和PHP扩展
FROM php:8.3.0RC4-cli-alpine3.18 AS builder
# 安装构建依赖
RUN apk add --no-cache \
autoconf \
g++ \
make \
pcre-dev \
zlib-dev \
libzip-dev \
oniguruma-dev \
libxml2-dev \
curl-dev \
gmp-dev \
imagemagick-dev \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
libwebp-dev \
libxpm-dev \
libxslt-dev \
gettext-dev \
icu-dev \
bzip2-dev \
sqlite-dev \
postgresql-dev
# 安装PHP扩展(使用官方脚本)
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
&& docker-php-ext-install -j$(nproc) \
bcmath \
bz2 \
calendar \
ctype \
curl \
dom \
exif \
fileinfo \
gd \
gettext \
gmp \
iconv \
intl \
json \
mbstring \
mysqli \
opcache \
pcntl \
pdo \
pdo_mysql \
pdo_pgsql \
pdo_sqlite \
phar \
posix \
readline \
shmop \
simplexml \
sockets \
sysvmsg \
sysvsem \
sysvshm \
tokenizer \
xml \
xmlreader \
xmlwriter \
xsl \
zip
# 安装Composer
COPY --from=composer:2.6.5 /usr/bin/composer /usr/bin/composer
# 设置工作目录
WORKDIR /app
# 复制项目文件(排除开发文件)
COPY composer.json composer.lock ./
COPY . .
# 安装生产依赖(排除开发依赖)
RUN composer install --no-dev --optimize-autoloader --no-interaction --no-progress
# 清理构建缓存
RUN rm -rf /root/.composer/cache
# 第二阶段:生产镜像
FROM php:8.3.0RC4-cli-alpine3.18
# 安装运行时依赖(仅需要运行时需要的库)
RUN apk add --no-cache \
libzip \
oniguruma \
libxml2 \
curl \
gmp \
imagemagick \
libpng \
libjpeg-turbo \
freetype \
libwebp \
libxpm \
libxslt \
gettext \
icu \
bzip2 \
sqlite-libs \
postgresql-libs
# 复制PHP扩展(从builder阶段)
COPY --from=builder /usr/local/lib/php/extensions /usr/local/lib/php/extensions
COPY --from=builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d
# 复制应用代码和依赖
COPY --from=builder /app /app
# 复制生产配置
COPY docker/php.ini /usr/local/etc/php/php.ini
COPY docker/php-fpm.conf /usr/local/etc/php-fpm.conf
# 设置工作目录
WORKDIR /app
# 设置权限
RUN chown -R www-data:www-data /app \
&& chmod -R 755 /app/storage /app/bootstrap/cache
# 暴露端口
EXPOSE 9000
# 启动命令
CMD ["php-fpm"]
2. 生产环境PHP配置
; docker/php.ini
[PHP]
engine = On
short_open_tag = Off
precision = 14
output_buffering = 4096
zlib.output_compression = Off
implicit_flush = Off
unserialize_callback_func =
serialize_precision = -1
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_multi_exec,parse_ini_file,show_source
disable_classes =
zend.enable_gc = On
expose_php = Off
max_execution_time = 30
max_input_time = 60
memory_limit = 256M
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
display_errors = Off
display_startup_errors = Off
log_errors = On
log_errors_max_len = 1024
ignore_repeated_errors = Off
ignore_repeated_source = Off
report_memleaks = On
track_errors = Off
html_errors = Off
variables_order = "GPCS"
request_order = "GP"
register_argc_argv = Off
auto_globals_jit = On
post_max_size = 8M
auto_prepend_file =
auto_append_file =
default_mimetype = "text/html"
default_charset = "UTF-8"
doc_root =
user_dir =
enable_dl = Off
file_uploads = On
upload_max_filesize = 2M
max_file_uploads = 20
allow_url_fopen = On
allow_url_include = Off
default_socket_timeout = 60
[CLI Server]
cli_server.color = On
[Date]
date.timezone = Asia/Shanghai
[filter]
filter.default = unsafe_raw
[iconv]
iconv.input_encoding = UTF-8
iconv.internal_encoding = UTF-8
iconv.output_encoding = UTF-8
[intl]
intl.default_locale = zh_CN
[mbstring]
mbstring.language = Chinese
mbstring.internal_encoding = UTF-8
mbstring.http_input = UTF-8
mbstring.http_output = UTF-8
mbstring.encoding_translation = On
mbstring.detect_order = auto
mbstring.substitute_character = none
[opcache]
opcache.enable=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=8
opcache.max_accelerated_files=10000
opcache.revalidate_freq=2
opcache.fast_shutdown=1
opcache.enable_cli=1
opcache.validate_timestamps=0
opcache.file_cache=/tmp/opcache
opcache.file_cache_only=0
[curl]
curl.cainfo = /etc/ssl/certs/ca-certificates.crt
[openssl]
openssl.cafile = /etc/ssl/certs/ca-certificates.crt
3. Docker Compose配置
# docker-compose.yml
version: '3.8'
services:
php:
build:
context: .
dockerfile: Dockerfile
args:
- APP_ENV=production
- APP_DEBUG=false
image: your-app:latest
container_name: php-app
restart: unless-stopped
ports:
- "9000:9000"
volumes:
- ./storage:/app/storage
- ./public/uploads:/app/public/uploads
environment:
- APP_ENV=production
- APP_DEBUG=false
- DB_HOST=mysql
- DB_PORT=3306
- DB_DATABASE=your_db
- DB_USERNAME=your_user
- DB_PASSWORD=your_password
- REDIS_HOST=redis
- REDIS_PORT=6379
networks:
- app-network
healthcheck:
test: ["CMD", "php-fpm", "-t"]
interval: 30s
timeout: 10s
retries: 3
nginx:
image: nginx:1.25.2-alpine
container_name: nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf
- ./public:/app/public
- ./storage/logs:/var/log/nginx
depends_on:
- php
networks:
- app-network
mysql:
image: mysql:8.0.35
container_name: mysql
restart: unless-stopped
ports:
- "3306:3306"
environment:
- MYSQL_ROOT_PASSWORD=root_password
- MYSQL_DATABASE=your_db
- MYSQL_USER=your_user
- MYSQL_PASSWORD=your_password
volumes:
- mysql_data:/var/lib/mysql
networks:
- app-network
redis:
image: redis:7.2.3-alpine
container_name: redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- app-network
volumes:
mysql_data:
redis_data:
networks:
app-network:
driver: bridge
4. Nginx配置
# docker/nginx.conf
server {
listen 80;
server_name your-domain.com;
root /app/public;
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
sendfile off;
client_max_body_size 100m;
location ~ \.php$ {
fastcgi_pass php:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_buffer_size 128k;
fastcgi_buffers 4 256k;
fastcgi_busy_buffers_size 256k;
}
location ~ /\.ht {
deny all;
}
}
5. 构建脚本
#!/bin/bash
# build.sh - 生产环境构建脚本
set -e
# 版本号
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "latest")
echo "Building version: $VERSION"
# 构建镜像
docker build \
--build-arg APP_ENV=production \
--build-arg APP_DEBUG=false \
-t your-registry/your-app:$VERSION \
-t your-registry/your-app:latest \
-f Dockerfile \
.
# 推送镜像
docker push your-registry/your-app:$VERSION
docker push your-registry/your-app:latest
echo "Build complete: $VERSION"
效果数据
我在一台4核8G的阿里云ECS上做了压测,使用wrk工具:
# 压测命令
wrk -t12 -c400 -d30s http://your-app/api/health
| 指标 | 优化前(Debian) | 优化后(Alpine) | 提升 |
|---|---|---|---|
| 镜像大小 | 312MB | 52MB | 83.3% |
| 构建时间 | 120s | 65s | 45.8% |
| 拉取时间(100M带宽) | 25s | 4s | 84% |
| 容器启动时间 | 3s | 1s | 66.7% |
| QPS(并发400) | 2850 | 3120 | 9.5% |
| 内存占用(空闲) | 45MB | 28MB | 37.8% |
| 磁盘IO(启动时) | 120MB/s | 15MB/s | 87.5% |
QPS提升9.5%是因为Alpine的musl libc比glibc更轻量,减少了系统调用开销。但注意:某些PHP扩展在Alpine上性能会下降(比如pdo_mysql),需要实测。
避坑指南
以下是我实际踩过的坑,每个都花了至少半天排查:
坑1:Alpine的musl libc兼容性问题
PHP的某些扩展依赖glibc特性,在Alpine上会报错。比如:
- pdo_mysql:需要安装mysql-client,否则连接报错
- gd:需要额外安装libpng-dev、libjpeg-turbo-dev
- intl:需要icu-dev,且版本要匹配
解决方案:在Dockerfile中显式安装所有依赖,不要偷懒用apk add php8-*。
坑2:Composer依赖缓存
如果不做缓存,每次构建都要重新下载所有依赖,耗时30秒以上。解决方案:
# 利用Docker层缓存
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction --no-progress
COPY . .
这样只有composer.json变化时才重新安装依赖。
坑3:OPcache文件缓存权限
生产环境开启OPcache文件缓存后,容器重启时/tmp/opcache目录权限不对,导致PHP报错。解决方案:
RUN mkdir -p /tmp/opcache && chmod 777 /tmp/opcache
坑4:时区问题
Alpine默认时区是UTC,导致日志时间不对。必须显式设置:
RUN apk add --no-cache tzdata \
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone
坑5:健康检查
很多人的Dockerfile没有健康检查,导致K8s滚动更新时流量打到未就绪的Pod。必须加:
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD php-fpm -t || exit 1
坑6:构建上下文过大
如果项目根目录有node_modules、vendor、.git等目录,构建上下文会很大,导致docker build慢。解决方案:
# .dockerignore
.git
node_modules
vendor
storage/logs
.env
docker-compose.yml
Makefile
README.md
tests
docs
*.md
.gitignore
坑7:PHP扩展版本匹配
PHP 8.3的扩展和8.2不兼容。必须使用对应版本的官方镜像。我踩过坑:用了php:8.2-alpine的扩展编译方式,结果在8.3上跑不起来。
总结
这套方案已经在生产环境跑了6个月,服务了3个PHP项目,镜像大小稳定在50-60MB。关键点:
- 多阶段构建:把构建环境和运行环境分离
- Alpine基础镜像:体积小,但要注意兼容性
- Composer缓存:利用Docker层缓存加速构建
- 生产配置:关闭调试、开启OPcache、限制函数
- 健康检查:保证K8s滚动更新安全
如果你还在用300MB的PHP镜像,赶紧改。部署时间从3分钟降到40秒,开发体验提升巨大。