ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PHP8.5配置Docker多阶段构建怎么优化

PHP8.5配置Docker多阶段构建怎么优化 前言如果你现在打开项目的 Dockerfile看到的是一个FROM php:8.5-fpm开头、里面塞了apt-get install一堆编译工具、pecl install、composer install、最后再COPY . .的单阶段文件那么你大概率已经遇到过下面这几种症状镜像体积 1 GB 起步推送到镜像仓库要等好几分钟回滚一次发布像搬家生产镜像里躺着gcc、make、phpize、*.h头文件和.git目录攻击面白白变大只改了一行 PHP 业务代码composer install那一层缓存就失效构建时间从 20 秒变成 4 分钟构建机上编译出来的扩展和运行时的 PHP 版本、ABI 对不上容器一启动就是Unable to load dynamic library。这些问题的根因是同一个编译期需要的东西和运行期需要的东西被塞进了同一个文件系统层。多阶段构建multi-stage build就是 Docker 为这件事提供的原生解法——把「装依赖、编扩展」放在前面的阶段只把产物COPY到后面干净的阶段。本文按PHP 8.5的官方镜像php:8.5-fpm/php:8.5-cli来写示例是完整的、可以直接docker build跑通的两阶段 缓存挂载的 Dockerfile。如果你的镜像仓库里还没有 8.5 的 tag把文中的8.5换成8.4同样成立改的只是 tag方法论完全一样。一、先把阶段切开谁进最终镜像谁不进多阶段构建不是「多写几个 FROM」而是按生命周期划边界。一个 PHP 应用天然有三段生命周期阶段基础镜像职责是否进最终镜像vendorphp:8.5-cli装 composer、解析依赖、生成 autoload否只留/app/vendorassetsnode:22-alpine前端构建如果有否只留public/buildruntimephp:8.5-fpm只装运行时扩展、放源码、跑 php-fpm是nginx可选nginx:alpine静态文件与 FastCGI 转发是独立镜像关键在于vendor阶段里那些apt-get install libicu-dev、build-essential、composer本体统统留在vendor阶段的层里最终镜像只COPY --from拿一个vendor/目录。最终镜像里没有编译器也就没有对应的-dev包。同理node_modules几百 MB 也只是assets阶段的中间产物产出静态文件之后整层丢掉。二、让缓存层「命中」依赖清单必须在源码之前Docker 的层缓存是逐指令匹配的只要某条指令的输入变了它和它之后的所有层全部失效。所以下面这个写法是灾难COPY . . # 改一行 PHP 代码这一层就变了 RUN composer install # 于是每次都要重新装依赖正确的顺序是先把「很少变的东西」拷进去COPY composer.json composer.lock ./ RUN composer install ... # 只要 lock 不变这一层永远命中缓存 COPY . . # 源码变不变都无所谓了再配合 BuildKit 的缓存挂载cache mount把 composer 的下载缓存跨构建保留下来RUN --mounttypecache,target/tmp/composer-cache \ COMPOSER_CACHE_DIR/tmp/composer-cache \ composer install --no-dev --no-interaction --no-progress --prefer-dist --optimize-autoloader--mounttypecache里的内容不会进入镜像层只在构建器本地留存所以既快又不会让镜像变大。要让它生效Dockerfile 首行要声明 BuildKit 语法版本构建时用docker buildx build或较新的docker build。还有一件常被忽略的事.dockerignore。如果它没排除vendor/、.git/、node_modules/、tests/那么COPY . .会把宿主机的vendor可能是 Windows 或 macOS 上装的、平台相关的包覆盖掉容器里刚装好的那份还会让构建上下文变得巨大到几 GB。三、运行时镜像扩展怎么装、opcache 怎么开运行时阶段的扩展安装有三个原则能不加就不加。扩展是攻击面也是编译时间。装完就把-dev包和 apt 缓存清掉否则镜像白胖 200 MB。绝不把 builder 阶段的二进制文件COPY到另一个发行版的运行时阶段。glibc 和 musl 的.so不通用php:8.5-fpm-alpine和php:8.5-fpm-bookworm之间不能互拷扩展。下面这份是完整的、可运行的Dockerfile# syntaxdocker/dockerfile:1.7 ######################################## # 阶段 1只负责产出 vendor/ ######################################## FROM php:8.5-cli-bookworm AS vendor ENV COMPOSER_ALLOW_SUPERUSER1 COPY --fromcomposer:2 /usr/bin/composer /usr/bin/composer WORKDIR /app # 只拷依赖清单源码变化不会击穿这一层 COPY composer.json composer.lock ./ RUN --mounttypecache,target/tmp/composer-cache \ COMPOSER_CACHE_DIR/tmp/composer-cache \ composer install \ --no-dev \ --no-interaction \ --no-progress \ --prefer-dist \ --optimize-autoloader ######################################## # 阶段 2运行时 ######################################## FROM php:8.5-fpm-bookworm AS runtime # 装扩展装完立刻清 apt 缓存和 -dev 包 RUN set -eux; \ savedAptMark$(apt-mark showmanual); \ apt-get update; \ apt-get install -y --no-install-recommends \ libicu-dev \ libzip-dev \ libpng-dev \ libjpeg62-turbo-dev \ libfreetype6-dev \ ; \ docker-php-ext-configure gd --with-freetype --with-jpeg; \ docker-php-ext-install -j$(nproc) gd intl zip pdo_mysql opcache; \ apt-mark auto .* /dev/null; \ apt-mark manual $savedAptMark; \ apt-get purge -y --auto-remove -o APT::AutoRemove::Remove1; \ rm -rf /var/lib/apt/lists/* # 用自定义 ini 覆盖默认值conf.d 里的文件名决定加载顺序 COPY docker/php/app.ini $PHP_INI_DIR/conf.d/99-app.ini WORKDIR /var/www/html # vendor 来自构建阶段chown 让 www-data 有读权限 COPY --fromvendor --chownwww-data:www-data /app/vendor ./vendor # 业务源码最后拷改动只会让最后几层失效 COPY --chownwww-data:www-data . . USER www-data EXPOSE 9000 # 只判端口存活不依赖 cgi-fcgi 之类的额外二进制 HEALTHCHECK --interval30s --timeout3s --start-period15s --retries3 \ CMD php -r $s fsockopen(127.0.0.1, 9000, $e, $m, 2); exit($s ? 0 : 1); CMD [php-fpm, -F]配套的docker/php/app.ini生产参数注意opcache.validate_timestamps0意味着改了代码必须重启容器这正是不可变镜像该有的语义; 只在生产用代码随镜像一起发布不需要 fpm 去 stat 文件 opcache.validate_timestamps0 opcache.enable1 opcache.memory_consumption192 opcache.max_accelerated_files20000 opcache.interned_strings_buffer16 opcache.jittracing opcache.jit_buffer_size64M ; 错误不要直接吐给用户 display_errorsOff log_errorsOn error_log/dev/stderr ; 让 PHP 的错误和 warning 走 stderrdocker logs 才看得到 expose_phpOff如果php -m | grep -i opcache查不到 Zend OPcache说明该.so没有被加载再补一条docker-php-ext-enable opcache即可。验证一下镜像里到底进了什么docker build --target runtime -t app:local . # 看扩展是不是都装上了 docker run --rm app:local php -m # 看有没有残留的编译工具应该什么都搜不到 docker run --rm app:local sh -c command -v gcc make phpize || echo clean # 看体积 docker image ls app:local在 CI 里让缓存跨机器复用再补一行构建参数docker buildx build \ --cache-from typeregistry,refregistry.example.com/app:buildcache \ --cache-to typeregistry,refregistry.example.com/app:buildcache,modemax \ --target runtime \ -t registry.example.com/app:${GIT_SHA} .modemax会把中间阶段的层也导出这样vendor那层的缓存也能被下一次构建复用。四、编排nginx 与 fpm 分成两个镜像services: php: build: context: . target: runtime environment: APP_ENV: prod volumes: # 生产不要挂载源码目录覆盖镜像内容只挂载真正需要持久化的东西 - ./storage:/var/www/html/storage restart: unless-stopped nginx: image: nginx:1.27-alpine ports: - 8080:80 volumes: - ./public:/var/www/html/public:ro - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - php restart: unless-stoppednginx 那边只需要把.php转发给php:9000并确保fastcgi_param SCRIPT_FILENAME指向容器内的真实路径。最常见的一类「Nginx 502 / File not found」就是 nginx 容器和 php 容器里源码路径不一致nginx 容器里只有public/如果SCRIPT_FILENAME写成/var/www/html/public/index.php而 fpm 容器里也是同一个路径才是对的。常见坑点❌COPY . .写在composer install之前改一行代码就重建依赖✅ 先COPY composer.json composer.lock ./RUN composer install最后再COPY . .❌ 用docker-php-ext-install redis装 Redis 扩展redis 不是 PHP 源码自带的扩展这条命令只会报错✅ 用pecl install redis docker-php-ext-enable redis或者干脆用纯 PHP 实现的客户端❌vendor阶段用php:8.5-cli-alpine运行时用php:8.5-fpm-bookworm然后COPY --from拷.so或二进制✅ 两个阶段用同一个发行版要拷二进制就必须保证 libc 一致❌ 构建时用composer install --ignore-platform-reqs掩盖「缺 gd 扩展」这类问题运行时才崩✅ 在 builder 阶段装齐与运行时同版本同扩展的依赖让 composer 在构建期就把平台要求校验掉❌ 生产开opcache.validate_timestamps1指望git pull后自动生效✅ 设0代码变更走「重建镜像 滚动重启」如果确实要热更新就必须同时重启 php-fpm❌ 用--classmap-authoritative却没意识到它会禁用「运行时按 PSR-4 规则找类」的兜底逻辑✅ 只在确认所有类都能被扫描出来时开启有动态生成类、eval依赖的场景退回--optimize-autoloader❌ 容器里用 root 跑 php-fpm然后抱怨上传目录属主是 root、宿主机删不掉✅COPY --chownwww-data:www-data并让容器内的 uid/gid 与宿主机挂载目录的属主对齐❌ 只挂载了vendor的匿名卷做「加速」结果镜像里的依赖被宿主机的空目录覆盖网站白屏✅ 生产环境不要在源码路径上挂卷需要持久化的只有上传目录、日志、缓存总结优化点做法直接收益编译期与运行期分离多阶段构建COPY --from取产物最终镜像不含编译器与头文件层缓存命中率依赖清单先拷源码后拷改业务代码不再重装依赖依赖下载加速--mounttypecacheCOMPOSER_CACHE_DIR缓存不进镜像层构建器本地复用构建上下文完善.dockerignore上下文从 GB 级降到 MB 级运行时扩展开销装完purge -y --auto-remove、清 apt 列表少几百 MB 无用层生产性能opcache.validate_timestamps0省掉每次请求的 stat 与重编译CI 提速--cache-to typeregistry,modemax跨机器复用全部阶段缓存多阶段构建真正优化的不是「Dockerfile 的写法」而是镜像的内容边界编译期的一切都不该出现在生产文件系统里。把这条边界划清楚缓存、体积、安全、启动速度这几件事会一起变好而它们本来就是同一个问题的不同侧面。
返回列表