ARTICLE DETAIL

资讯详情

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

PHP Docker 生产级镜像构建:从基础选型到安全加固全解析

PHP Docker 生产级镜像构建:从基础选型到安全加固全解析 开篇先亮个观点把 PHP 代码扔进 Docker 跑起来和把 PHP 应用做成一个能扛生产流量的 Docker 镜像中间隔着一条河。我见过太多团队本地docker run php:8.2-apache一把梭代码挂进去就完事。结果上了生产第一天晚上就出幺蛾子扩展缺了、时区不对、OPcache 没开、权限文件全乱、镜像光秃秃地涨到 1 个多 G。这些问题单个看都不致命凑一块儿就能把你凌晨三点的觉全废掉。这篇文章我把自己在多个项目里沉淀下来的 PHP Docker 镜像部署经验完整拆一遍。从基础镜像选型、Dockerfile 分层、扩展编译、Composer 多阶段构建到 Nginx 编排、安全加固、健康检查、排障技巧全部用可复现的配置讲清楚。适合正在把 PHP 应用容器化、或者已经容器化但总觉得哪里不对劲的开发者参考。1. 生产级 PHP 镜像到底“级”在哪里1.1 开发镜像和生产镜像的差异不少人的第一个误区是把开发环境用的镜像直接推到生产。开发镜像为了调试方便通常会装 Xdebug、装了各种开发工具链、开了 display_errors、关闭了 OPcache甚至直接用 root 用户跑进程。这些配置在本地开发时是爽但一旦暴露在生产环境就是三座大山性能低下没缓存、重复编译脚本、安全隐患错误信息暴露路径和 SQL 片段、禁用函数没做、部署不稳定体积大、依赖多、行为不可预期。生产级镜像的核心诉求拆开看其实就五点体积要小、依赖要全、性能要调好、权限要收敛、行为要可复现。体积小意味着拉取快、启动快、攻击面小依赖全意味着不会因为缺一个.so扩展就 white screen性能调好主要指 OPcache 和文件缓存权限收敛指不用 root 跑 FPM 进程行为可复现指同一个镜像在开发、测试、生产拉出来的行为一致。1.2 基础镜像选型Alpine 还是 Debian这是每个做 PHP 镜像的人都会纠结的问题。官方镜像主要提供两个分支php:8.x-fpmDebian 系和php:8.x-fpm-alpineAlpine Linux 系。Alpine 的最大优势是体积。一个php:8.2-fpm-alpine镜像大概 40 到 60MB而对应的 Debian 版本通常 300MB 往上。体积小意味着网络传输快、存储占用低、容器启动时间短。但 Alpine 有两个坑一是它用的 C 库是 musl不是常规的 glibc部分预编译二进制比如某些公司内部下发的 SDK 扩展跑不起来二是一部分 PECL 扩展需要现场编译编译过程对依赖包的查找方式也和 Debian 不一样。我的建议是分场景选型场景推荐基础镜像原因业务代码使用标准扩展php:8.2-fpm-alpine体积小扩展齐全项目依赖公司内部 .so 扩展php:8.2-fpm兼容 glibc省去折腾需要快速排查网络问题php:8.2-fpm自带 curl/telnet 等工具链更全追求极致镜像体积php:8.2-fpm-alpine 多阶段可压到 30MB 级别这里我说的“业务代码使用标准扩展”指的是 PDO、Redis、GD、BCMath、Zip、OPcache 这类常见扩展这些在两个分支里都能装无非是安装命令从apt-get换成apk add。1.3 Docker 官方镜像的标签机制官方镜像的标签设计其实是有一套讲究的。php:8.2-fpm-alpine拆开看是三段8.2是 PHP 大版本fpm是运行模式alpine是系统分支。日常使用中我强烈不建议直接用php:latest或者php:8这种模糊标签。生产环境必须锁定具体小版本号比如php:8.2.12-fpm-alpine。这么做不是为了强迫症而是因为 PHP 的小版本之间偶尔会有行为变化安全修复也是在小版本里推进的。锁版本配合镜像 digest 摘要能保证今天构建的镜像和三个月后构建出来的东西依赖完全一致。2. Dockerfile 实战从零写出一个能上生产的 PHP-FPM 镜像2.1 基础结构和依赖安装先给一个我最常用的生产级 Dockerfile 骨架基于php:8.2-fpm-alpine然后逐段拆解说明。# syntaxdocker/dockerfile:1.4 FROM php:8.2.12-fpm-alpine AS build # 安装系统依赖和 PHP 扩展依赖 RUN apk add --no-cache --virtual .build-deps \ $PHPIZE_DEPS \ libzip-dev \ libpng-dev \ libjpeg-turbo-dev \ freetype-dev \ icu-dev \ oniguruma-dev \ apk add --no-cache \ libzip \ libpng \ libjpeg-turbo \ freetype \ icu-libs \ oniguruma \ docker-php-ext-configure gd --with-freetype --with-jpeg \ docker-php-ext-install -j$(nproc) \ pdo_mysql \ bcmath \ gd \ zip \ intl \ opcache \ apk del .build-deps这里有个关键操作apk add --virtual .build-deps加apk del .build-deps。这是 Alpine 里典型的“用完即弃”手法。编译扩展需要一堆头文件但运行时根本不需要。把它们打包成一个虚拟依赖组编译完一把全删可以省下几十 MB 体积。这个技巧在 Debian 系里对应的是apt-get install --no-install-recommends加apt-get purge。2.2 扩展编译顺序和组合的策略PHP 扩展分为几类安装顺序和方式各不相同。第一类是官方自带、常见又必须的扩展pdo_mysql、bcmath、opcache、gd、zip、intl可以直接用docker-php-ext-install一条命令搞定。这个命令是 Docker 官方镜像封装好的脚本会自动调用phpize加./configure加make加make install。第二类是 PECL 扩展redis、xdebug、mongodb 等需要用pecl install安装再用docker-php-ext-enable启用。注意 composer 底层的phpredis和 PHP 官方的 redis 扩展不是一回事生产环境建议直接装 PECL 的 redis 扩展。第三类是公司自研或闭源的特殊扩展通常给你一个编译好的.so文件。这种情况不用走编译流程直接把.so拷到/usr/local/lib/php/extensions/对应目录再写好docker-php-ext-enable要用的.ini文件即可。一个经常被忽略的问题是扩展顺序。如果你同时装了 OPcache 和 APCu或者同时装了 redis 和 igbinary加载顺序会影响实际行为。Docker 镜像里扩展的启用顺序默认是按.ini文件名排序的。需要控制顺序时可以手动编写.ini文件明确用extensionxxx.so的先后位置来固定。2.3 OPcache 配置性能的立竿见影项PHP 是解释型语言每次请求都要重新解析编译 PHP 文件。OPcache 把编译后的字节码缓存到共享内存里能省掉这一大块性能损耗。生产环境不配 OPcache 的 PHP-FPM 镜像相当于买了个功率减半的发动机。opcache.enable1 opcache.enable_cli0 opcache.memory_consumption128 opcache.interned_strings_buffer8 opcache.max_accelerated_files10000 opcache.revalidate_freq60 opcache.fast_shutdown1 opcache.validate_timestamps1说明几个关键参数opcache.memory_consumption是缓存池大小单位 MB。项目代码量大或者用到 Laravel 这种重量级框架建议 128 起步代码量大的项目可以上 256。opcache.max_accelerated_files默认值很小只有 2000 多现代框架动辄一万多个 PHP 文件不调大就会出现“文件没被缓存”的情况。opcache.revalidate_freq表示多少秒检查一次文件变动生产环境可以设到 60 或更大能减少文件 mtime 检查次数。opcache.validate_timestamps1这一项比较有争议。设成 0 的话性能更好但代码更新时不会自动感知必须重启 PHP-FPM 才能生效。我建议保持 1配合revalidate_freq60既保证部署后能较快生效又避免每次都做文件系统检查。配置方式建议单独写一个opcache.ini文件在 Dockerfile 里用COPY拷进来最后通过docker-php-ext-enable opcache关联。不要直接改php.ini-production那样不利于维护和追踪。2.4 多阶段构建Composer 依赖装完就扔生产镜像里有没有必要保留 Composer我的答案是运行时不需要构建时需要。如果你把 Composer 和所有开发依赖都留在最终镜像里镜像体积会大幅增加而且composer install装出来的vendor目录里可能带着 dev 依赖PHPUnit、Mockery 这些平白给生产环境增加了暴露面。正确做法是用多阶段构建在build阶段跑 Composer把vendor目录拷贝到运行镜像里。FROM composer:2 AS composer WORKDIR /app COPY composer.json composer.lock ./ RUN composer install --no-dev --no-scripts --prefer-dist --optimize-autoloader --no-progress FROM php:8.2.12-fpm-alpine AS runtime # 上面 build 阶段的代码在这里省略直接进入 vendor 拷贝 COPY --fromcomposer /app/vendor /var/www/html/vendor COPY --frombuild /usr/local/lib/php/extensions/no-debug-non-zts-20230831/ /usr/local/lib/php/extensions/no-debug-non-zts-20230831/这个COPY --frombuild的路径要特别留意。不同 PHP 细小版本的扩展目录名不一样比如 PHP 8.2 下通常叫no-debug-non-zts-20230831。你可以在本地进容器看一眼实际路径再复制避免硬编码后升级版本时踩坑。composer install参数里那几个 flag 也很重要。--no-dev去掉开发依赖--prefer-dist优先用压缩包而不是 Git 源码--no-scripts暂不执行安装脚本--optimize-autoloader生成优化过的类映射表对生产性能有正面影响代价是构建时间略长。2.5 非 root 用户与文件权限官方php:fpm镜像默认配置里FPM 进程是以www-data用户跑的这一点基本没问题。但如果你在 Dockerfile 里执行了USER root然后又忘了切回来或者某些启动脚本里chmod -R 777那就要小心了。生产容器里FPM 进程是 root意味着应用一旦被利用攻击者直接拿到容器最高权限。更稳妥的做法是保持官方www-data用户在 Dockerfile 里显式声明USER www-data WORKDIR /var/www/html CMD [php-fpm]同时挂载进来的代码目录必须保证www-data有读写权限。这里有个经典问题宿主机上代码的 owner 是ubuntuuid 1000容器里www-data的 uid 是 82两者对不上就会报Permission denied。解决方案有几种一是构建镜像时把www-data改成固定 uid比如 1000二是宿主机目录 chown 成 82三是用 entrypoint 脚本在启动时统一修复权限。我个人偏好第一种在 Dockerfile 里用usermod -u 1000 www-data固定 uid一劳永逸。3. 编排部署PHP-FPM 不是孤岛3.1 docker-compose 服务编排的骨架PHP 应用极少是单容器跑法的至少要有 Nginx 和 PHP-FPM 两个容器。涉及存储和缓存的还得挂 MySQL 和 Redis。这里给出一个经过生产验证的docker-compose.yml骨架。version: 3.8 services: php: build: context: . dockerfile: Dockerfile container_name: app-php restart: unless-stopped working_dir: /var/www/html volumes: - ./app:/var/www/html:cached environment: - APP_ENVproduction - DB_HOSTmysql - REDIS_HOSTredis networks: - app-net depends_on: - mysql - redis - nginx nginx: image: nginx:1.25-alpine container_name: app-nginx restart: unless-stopped ports: - 80:80 - 443:443 volumes: - ./app:/var/www/html:ro - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./nginx/ssl:/etc/nginx/ssl:ro - nginx_logs:/var/log/nginx networks: - app-net mysql: image: mysql:8.0 container_name: app-mysql restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} MYSQL_DATABASE: ${DB_DATABASE} MYSQL_USER: ${DB_USER} MYSQL_PASSWORD: ${DB_PASSWORD} volumes: - mysql_data:/var/lib/mysql networks: - app-net redis: image: redis:7-alpine container_name: app-redis restart: unless-stopped command: [redis-server, --appendonly, yes, --requirepass, ${REDIS_PASSWORD}] volumes: - redis_data:/data networks: - app-net networks: app-net: driver: bridge volumes: mysql_data: redis_data: nginx_logs:这套编排的关键点PHP 容器和 Nginx 共用一份代码但挂载方式不同。Nginx 这边只读挂载:roPHP 这边用cached优化 macOS 场景下的文件 IO 性能。生产环境如果追求极致性能代码不一定要挂载可以构建时直接 COPY 进镜像容器用临时目录跑。两种模式各有取舍挂载方便热更新COPY 性能好、和宿主机解耦。3.2 Nginx 配置fastcgi 参数一个都不能少Nginx 在这里的角色是反向代理把请求转发给 PHP-FPM。这一层配置最容易出问题。给一份我常用的站配置server { listen 80; server_name example.com; root /var/www/html/public; index index.php; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass php:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; fastcgi_read_timeout 60; fastcgi_send_timeout 60; } location ~ /\.(?!well-known).* { deny all; } }这里fastcgi_pass php:9000用的是 Compose 服务名Docker 内置 DNS 会把它解析成 PHP 容器的 IP。fastcgi_param SCRIPT_FILENAME是很多人调不通的元凶如果这个参数不对Nginx 能把请求转给 PHP-FPM但 PHP 找不到脚本文件直接返回空白页面或 502。注意$document_root要和 PHP 容器里的工作目录一致都是/var/www/html。location ~ /\.(?!well-known).*这一段是防扫描器遍历隐藏文件的。默认 Nginx 配置经常忽略这一点.git目录被暴露的事故屡见不鲜。3.3 环境变量与配置分离生产环境下代码里绝不能写死数据库密码。推荐做法环境变量通过 Compose 的environment或env_file注入PHP 侧读取$_ENV或$_SERVER。.env文件只放在宿主机上不进版本库模板可以放.env.example。Compose 会自动读取同目录的.env来做变量替换。这里有一个经常踩的坑PHP-FPM 默认清理环境变量。如果你在docker-compose.yml里配置了环境变量但 PHP 代码里getenv(DB_HOST)拿不到值多半是 FPM 的clear_env没关掉。clear_env no在php-fpm.conf或pool.d/www.conf里加上这一行容器内环境变量才会透传给 PHP-FPM 工作进程。这是容器化 PHP 场景里一个特别隐蔽的问题排查起来非常费时间。4. 性能与安全加固4.1 镜像瘦身dive 工具分析和层缓存策略镜像体积不是玄学是可以量化优化的。我用dive这个工具逐层分析镜像看哪些层吃了大量空间。常见的体积黑洞集中在三类编译依赖没清理、Composer 缓存没清理、图片字体等静态资源被重复拷贝进镜像。在 Dockerfile 里编译依赖务必要“装了就删”。Composer 阶段要加--no-scripts和--prefer-dist并且执行完后清理缓存RUN composer install --no-dev --prefer-dist --optimize-autoloader \ composer clear-cache另外利用 Docker 构建缓存能极大提升迭代速度。把变化频率低的步骤放在 Dockerfile 前面。比如composer.json和composer.lock的 COPY 要放在代码 COPY 之前这样只要依赖没有变化后面所有层都能命中缓存构建时间能从几分钟降到十几秒。4.2 PHP 生产配置的安全项清单除了 OPcache还有几个安全相关的配置必须处理配置项推荐值原因expose_phpOff隐藏 PHP 版本号减少被定向攻击的风险display_errorsOff生产环境绝不能把报错直接输出给用户log_errorsOn同时必须把错误日志打到容器 stdoutmemory_limit128M 或按业务调整防止单个请求吃掉整个容器内存max_execution_time30 或 60防止慢请求无限占用 FPM workerpost_max_size20M 或按业务调整控制上传大小防止恶意大包upload_max_filesize20M与上传场景相关display_errors这个坑我踩过不止一次。开发环境开着能看到红色报错部署到生产忘记关数据库密码、文件路径、SQL 语句全被塞进 HTML 输出里。配合log_errorsOn错误应该只进日志PHP-FPM 容器的日志最好直接打到 stdout/stderrDocker 自带 log driver 会统一收集方便对接 ELK 或 Loki。4.3 安全加固cap_drop、只读根文件系统和资源限制Docker 容器默认继承了很多宿主机的 capabilities很多在跑 PHP 时根本用不上。生产环境建议显式丢弃它们security_opt: - no-new-privileges:true cap_drop: - ALL cap_add: - CHOWN - SETGID - SETUID - NET_BIND_SERVICE这里只保留运行 FPM 所需的最小能力。NET_BIND_SERVICE是给 Nginx 监听 80/443 用的PHP-FPM 容器如果不监听特权端口可以连它都不加。另外还可以把容器的根文件系统设为只读可写目录只有临时目录和日志目录read_only: true tmpfs: - /tmp这个配置能挡住一大类“写入恶意文件”的攻击手段。WebShell 就算上传成功了也没有可写目录可以落盘。资源限制同样不能少deploy: resources: limits: cpus: 1.5 memory: 1G容器防的是“一个请求把整台宿主机拖死”。PHP 偶发的内存泄漏在资源限制的保护下影响面会被限制在单容器范围内。4.4 健康检查让编排系统知道 PHP-FPM 是死是活Kubernetes 或者 Docker Swarm 都依赖健康检查来判断容器是否正常。PHP-FPM 镜像里需要实现一个轻量的健康检查端点。我目前采用的方案在 PHP 容器里放一个healthz.php内容是一个简单的 HTTP 响应同时检查数据库连接和 Redis 连接。?php $dbOk true; $redisOk true; try { $pdo new PDO( mysql:host . getenv(DB_HOST) . ;port3306;dbname . getenv(DB_DATABASE), getenv(DB_USER), getenv(DB_PASSWORD), [PDO::ATTR_TIMEOUT 2] ); $pdo-query(SELECT 1); } catch (Throwable $e) { $dbOk false; } if (class_exists(Redis::class)) { try { $redis new Redis(); $redis-connect(getenv(REDIS_HOST), 6379, 2); $redisOk true; } catch (Throwable $e) { $redisOk false; } } header(Content-Type: application/json); http_response_code(($dbOk $redisOk) ? 200 : 503); echo json_encode([status ($dbOk $redisOk) ? ok : degraded, db $dbOk, redis $redisOk]);Dockerfile 里加 HEALTHCHECK 指令HEALTHCHECK --interval30s --timeout5s --start-period10s --retries3 \ CMD curl -fsS http://localhost:9000/healthz.php || exit 1注意php:8.2-fpm-alpine里默认可能没有curl需要先apk add --no-cache curl。健康检查要让 FPM 处理所以请求打给 9000 端口不需要经过 Nginx避免多一层混淆。数据库和 Redis 挂了不代表 PHP-FPM 进程死了但业务上它们挂了应用确实不可用。这种“依赖感知”的健康检查比单纯的进程存活性检查更贴合实际。5. 常见问题与排查技巧实录5.1 Nginx 返回 502 的排查路径502 Bad Gateway 是 PHP-FPM 容器化后最常见的问题。出现 502 时按顺序排查第一看 PHP-FPM 容器是否还活着。docker ps看状态如果反复重启大概率是 FPM 进程崩溃或者健康检查失败。第二看 Nginx 能不能解析服务名。在 Nginx 容器里getent hosts php解析不到就检查网络是否在同一 network服务名是否写对。第三看端口是否匹配。FPM 默认监听 9000确认 Nginx 的fastcgi_pass指向了 9000 端口。9000 是 FPM 默认端口这件事Nginx 配置里也写 9000但常有人在这中间写错。第四看 SCRIPT_FILENAME。fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;这句话里的$document_root在容器里指向的是 Nginx 容器里的 root而不是 PHP 容器的 root。两个容器挂载同一份代码时root 要一致否则 PHP 容器收到的 SCRIPT_FILENAME 路径不存在直接返回Primary script unknown。5.2 时区问题日志时间、数据时间差 8 小时容器默认时区是 UTC日志时间、数据库时间和你本地差 8 小时排查问题的时候整个人都会错乱。解决方式是在 Dockerfile 里显式设置时区RUN apk add --no-cache tzdata \ cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini \ printf [PHP]\ndate.timezone Asia/Shanghai\n /usr/local/etc/php/php.ini其实更干净的做法是在php.ini里加一行date.timezone Asia/Shanghai在php-fpm.conf里也确认slowlog和access.log的时间戳。如果数据库连接串里指定了timezonePHP 侧的时区还要和数据库保持一致不然NOW()和date()的差异会引发数据 bug。5.3 挂载目录权限和缓存权限宿主机挂载目录的权限问题几乎每个做容器化的人都会碰到一次。症状是 Laravel 的storage/目录写不进去日志报Permission denied或者 Session 文件创建失败。排查思路进入容器确认运行用户。docker exec -it app-php whoami如果是www-data而宿主机目录权限是 755owner 是别的用户那就会出问题。生产环境推荐把宿主机的代码目录chown -R 1000:1000 ./appDockerfile 里把www-data改成 uid 1000RUN usermod -u 1000 www-data groupmod -g 1000 www-data5.4 常见问题速查表问题现象直接原因解决方案容器启动后立即退出Dockerfile CMD 写错或配置文件语法错误docker logs 容器名看启动日志Nginx 502fastcgi_pass 服务名解析失败检查 Compose 网络服务名是否正确Nginx 502PHP-FPM 启动失败检查 FPM 配置、监听端口代码改了不生效OPcache revalidate_freq 过大开发环境关闭 OPcache生产设合理值环境变量取不到FPMclear_env未关闭在 pool 配置里设clear_env no上传图片报错GD 扩展没装或者不支持 jpegdocker-php-ext-configure gd --with-freetype --with-jpeg重装中文文件名乱码文件系统编码或三方库问题加intl扩展确保源码文件 UTF-8时区差 8 小时容器默认 UTC设置date.timezone5.5 踩过的坑动态扩展目录和 Compose 依赖再分享两个小编排上的冷门坑。第一个是扩展目录名。PHP 8.2 的扩展目录带一个构建日期字符串20230831升级 PHP 小版本后目录名会变。如果你在 Dockerfile 里硬编码了COPY --frombuild /usr/local/lib/php/extensions/no-debug-non-zts-20230831/升级版本之后构建会直接失败。更稳的做法是用通配符或者干脆不单独 COPY 扩展目录而是完整拷贝整个/usr/local/lib/php/目录。第二个是depends_on不等于“数据库已就绪”。depends_on只解决启动顺序MySQL 容器进程起来了不代表 MySQL 已经准备好接受连接。从 PHP 代码到数据库连接要先做重试。可以写一个小的连接重试逻辑启动后尝试 3 到 5 次间隔 2 秒全部失败再退出让编排系统自动重启容器。6. 把这个镜像铺到更大规模如果你的集群规模不是单机而是想在 Swarm 或 Kubernetes 上运行这套 PHP 应用这里有一些扩展思路。Kubernetes 上没必要用 Dockerfile 里的HEALTHCHECK它会被 K8s 的 livenessProbe 和 readinessProbe 替代。YAML 里对应的配置是livenessProbe: httpGet: path: /healthz.php port: 9000 initialDelaySeconds: 10 periodSeconds: 30 readinessProbe: httpGet: path: /healthz.php port: 9000 initialDelaySeconds: 5 periodSeconds: 10另外多副本运行时 PHP-FPM 的 Session 默认是文件存储会分散在多个 Pod 里用户登录状态串不起来。生产环境要么把 Session 存到 Redis要么用外部 Session 驱动。这是从单机 Docker 迁移到 Kubernetes 时最常被忽略的隐藏炸弹。CDN 和负载均衡器对 PHP 静态资源的处理也要提前规划。Nginx 容器可以用 ConfigMap 管理站点配置证书用 Secret 挂载。这样扩展副本时配置不会漂移。最后给一个个人经验构建 PHP 镜像这件事最怕的不是技术复杂而是每次部署都“稍微手动改一下”。把 Dockerfile、Compose 文件、Nginx 配置全部纳入版本管理任何改动走 MR 流程镜像版本打上 Git commit 号才能在凌晨出问题时快速回滚。这些容器化的“最佳实践”本质上都是为了让故障不再依赖某个人的记忆而是变成系统里可复现、可追溯、可回滚的确定性操作。
返回列表