PHP应用Docker镜像优化实战
发布日期: 2026/07/21 阅读总量: 1

问题:一个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秒,开发体验提升巨大。