ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

PHP容器化生产环境避坑指南:镜像构建与Compose编排实战

PHP容器化生产环境避坑指南:镜像构建与Compose编排实战 先把话说在前面这篇东西不是给你抄个 Dockerfile 就完事的。PHP 项目容器化网上教程一抓一大把但大多数都是开发环境跑通就算成功真扔到生产线上权限、日志、扩展、Opcache、健康检查、回滚策略随便一个环节都能让你半夜爬起来救火。我做过不少 PHP 项目的容器化改造从裸机迁移到 Docker 再到编排上线踩过的坑比写过的代码还多。这篇就把我实际验证过的方案、参数和排错思路完整写出来照着做不一定让你一步到位但至少能绕开大多数生产环境的暗坑适合正在做 PHP 容器化改造、或者想优化现有 Docker 部署方案的团队参考。1. 生产级镜像设计思路与基础选型1.1 为什么不能直接把代码丢进官方镜像很多第一次做 PHP 容器化的人图省事直接FROM php:8.3-apache然后把源码 COPY 进去就启动。开发环境这么玩没毛病但你注意看镜像结构和官方默认配置就知道里面埋了多少雷。首先是体积。php:8.3-apache完整镜像大概 400MB 起步装在服务器上不光占磁盘镜像拉取、传输、启动全都要陪着一起慢。其次是配置不透明。Apache 的默认 vhost 配置、PHP 的默认 php.ini你很难精确控制而生产环境恰恰需要你对每个参数都有数。第三是安全问题。官方镜像为了方便开发默认以 root 用户跑主进程这在生产环境是风口浪尖的操作一旦应用被攻破攻击者拿到的就是容器内的最高权限。有人会说那用php:8.3-fpm配合宿主机 Nginx 不行吗可以但这只解决了 Web 服务器分离的问题镜像体积、进程用户、扩展管理、依赖管理这些核心矛盾一个没解决。生产级镜像不是“能跑起来就行”它需要考虑构建可复现、运行最小化、权限最小化、依赖清晰化这四件事缺一个都算不上生产级。我的建议是不用 apache 镜像用 FPM 镜像不用默认 root 用户单独建低权限用户不用裸装扩展用显式声明的安装方式不用 latest 标签锁定精确版本。下面逐个展开。1.2 基础镜像选型Alpine 还是 Debianphp:8.3-fpm-alpine和php:8.3-fpm这俩是当前主流选择。我直接说结论没有特殊原因优先选 Debian 版本的 php:8.3-fpm即 bookworm/slim 版本而不是 Alpine。Alpine 的优势是体积小一个 FPM 镜像能压到 80MB 左右Debian 的要 150MB 往上。但 Alpine 用的是 musl libc 而不是 glibc这会导致两个实际问题。一是不少 PHP 扩展的编译依赖 glibc 体系比如swoole、pcntl在某些版本下编译直接报错你得额外打补丁或者换编译参数折腾半天。二是很多预编译的二进制工具比如一些数据库驱动、第三方 SDK只提供 glibc 版本在 Alpine 里一跑就是file not found排查起来非常费劲。Debian 镜像虽然大点但跟生产环境主流发行版的运行时行为一致性更好扩展编译省心出问题搜资料也容易命中。至于体积问题后面多阶段构建可以把最终镜像压到合理范围没必要用 Alpine 的“轻”来牺牲稳定性。提示如果你用 Alpine务必在 Dockerfile 里显式配置时区和 CA 证书这两个是 musl 环境下最常见的隐性问题。2. Dockerfile 构建实操与优化技巧2.1 多阶段构建把编译环境和运行时环境分开多阶段构建是我做 PHP 镜像时最推荐的方式核心思路就是编译扩展、安装依赖、处理源码这些“脏活累活”放一个临时镜像里干最后只把干净的运行产物拷到正式镜像中。为什么非要这样因为 PHP 扩展编译需要一堆系统工具链比如 gcc、make、autoconf这些工具加起来几十上百 MB还要依赖各种-dev包。如果你把它们留在最终镜像里镜像体积膨胀两倍不止而且攻击面也变大——攻击者拿到 shell 之后第一件事就是看有没有编译器有编译器就能搞出更多幺蛾子。下面是我在一个 Laravel 项目里用过的 Dockerfile结构清晰你直接参考改动就行# 第一阶段构建阶段 FROM php:8.3-fpm-bookworm AS builder # 安装编译扩展所需依赖 RUN apt-get update apt-get install -y \ git \ unzip \ libzip-dev \ libpq-dev \ libicu-dev \ libonig-dev \ libxml2-dev \ libcurl4-openssl-dev \ libssl-dev \ docker-php-ext-configure intl \ docker-php-ext-install -j$(nproc) \ pdo_mysql \ pdo_pgsql \ mysqli \ zip \ intl \ mbstring \ opcache # 安装 Redis 扩展这个在 PECL 仓库中 RUN pecl install redis docker-php-ext-enable redis # 安装 Composer 到 /usr/local/bin COPY --fromcomposer:2 /usr/bin/composer /usr/local/bin/composer # 先拷贝 composer 元文件利用缓存 WORKDIR /var/www/html COPY composer.json composer.lock ./ RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction # 再拷贝全部源码并生成完整自动加载 COPY . . RUN composer install --no-dev --no-scripts --optimize-autoloader --prefer-dist --no-interaction \ composer dump-autoload --optimize # 第二阶段运行时阶段 FROM php:8.3-fpm-bookworm # 系统依赖只装运行需要的不装编译工具链 RUN apt-get update apt-get install -y \ libicu72 \ libzip4 \ libonig5 \ libxml2 \ libcurl4 \ libpng-dev \ libjpeg-dev \ libfreetype6-dev \ docker-php-ext-configure gd --with-freetype --with-jpeg \ docker-php-ext-install -j$(nproc) pdo_mysql zip intl mbstring opcache gd \ pecl install redis docker-php-ext-enable redis # 复制 PHP 生产配置 COPY docker/php/php.ini-production-custom.ini /usr/local/etc/php/conf.d/99-custom.ini COPY docker/php/www2.conf /usr/local/etc/php-fpm.d/www2.conf # 创建非 root 用户并赋予 web 目录权限 RUN groupadd -r appuser -g 1000 \ useradd -r -g appuser -u 1000 -d /var/www/html appuser \ mkdir -p /var/www/html /var/www/html/storage/framework/sessions \ /var/www/html/storage/framework/views \ /var/www/html/storage/framework/cache \ /var/www/html/bootstrap/cache \ /var/www/html/public # 从构建阶段复制代码与依赖 COPY --frombuilder --chownappuser:appuser /var/www/html /var/www/html # 清理不需要的文件可能有 .git 等多阶段构建后源码里一般没有 RUN rm -rf /var/www/html/.git /var/www/html/docker /var/www/html/docs 2/dev/null || true USER appuser WORKDIR /var/www/html EXPOSE 9000 CMD [php-fpm]有一说一这个 Dockerfile 拿出去跑绝对能跑但这里头有几个细节值得说透不然你会觉得我“复制粘贴一个高深的文件”而已。先看为什么在 builder 阶段要专门跑一次composer install之后再 COPY 全量代码。这是因为composer.lock文件一般很少变动但实际源码天天在动。如果先拷贝composer.json和composer.lock并安装依赖只要 lock 文件没变这一层就能命中 Docker 缓存整个构建能省下大半时间。等依赖装完再拷贝代码就不会每次代码提交都重新拉一遍依赖包。再看为什么要在两个阶段分别安装扩展。因为编译扩展时会把libpq-dev、libicu-dev这类开发库编译进扩展文件里但运行时其实只需要对应的.so文件和动态库。如果只装运行时版的libicu72、libzip4镜像体积能小不少。命令docker-php-ext-install会用 PHP 官方提供的 Docker 构建脚本自动编译并复制扩展. so 文件。所以我其实可以在运行时阶段只装libicu72这些动态库再docker-php-ext-install重新编一次这一步是为了让扩展在目标基础镜像上正确编译避免宿主机 glibc 版本不匹配。这是细节但直接影响能不能跑起来。多说一句www2.conf这是 PHP-FPM 的进程配置文件我这里是自定义的默认配置里clear_env no没开启导致 FPM 子进程会清空系统环境变量有些框架拿不到环境变量会直接 500。生产环境建议显式设置[www] user appuser group appuser listen 127.0.0.1:9000 pm dynamic pm.max_children 50 pm.start_servers 5 pm.min_spare_servers 5 pm.max_spare_servers 35 pm.max_requests 1000 catch_workers_output yes clear_env no2.2 扩展安装策略与常见坑别一股脑全装PHP 扩展这块很多人的思路是“多多益善”什么 Redis、MongoDB、ImageMagick、Swoole 全往上招呼。我可以负责任地告诉你扩展装多了只会给自己挖坑。首先每个扩展都会增加内存占用和潜在冲突概率。比如xdebug和opcache同时开性能掉一半因为 xdebug 的调试钩子会干扰 opcache 的优化策略。其次装了不用的扩展等于放大攻击面一旦某个扩展被爆出漏洞你根本意识不到自己已经被波及了。所以我的扩展安装原则只有一条按项目实际需要装能用官方包管理器装的就用官方包管理能用 PECL 的就用 PECL不要手动编译 .so。给大家一个常见的 PHP 8.x Laravel/Symfony 项目的扩展清单参考扩展用途安装方式pdo_mysql / pdo_pgsql数据库连接docker-php-ext-installmbstring多字节字符串处理docker-php-ext-installintl国际化Laravel 的 Carbon 需要docker-php-ext-installzip扩展包解压、上传docker-php-ext-installopcache性能优化必装docker-php-ext-installredisRedis 缓存/队列pecl install redisgd / imagick图片处理docker-php-ext-install gdpcntl队列进程、Swooledocker-php-ext-install pcntl这里有一个非常容易踩的坑intl扩展依赖 ICU 库即使你在 Dockerfile 里装了libicu-dev并配置好docker-php-ext-configure intl有时候编译出来的扩展运行时会报 ICU 版本不一致的错误。我当时排查了好久最后发现是基础镜像bookworm自带的 ICU 版本和 PECL 编译时的版本不一致导致的。解决方式就是在运行时阶段也安装对应版本的libicu72或者直接用基础镜像的自带扩展安装脚本重新编译。这又回到上一节为什么两个阶段要重复编译扩展的原因。另一个坑是opcache。这个扩展官方建议默认启用但它有一个经典问题死活坑新人opcache.validate_timestamps。默认值是1意味着每次请求都会检查文件时间戳看代码有没有变化这在高并发下是个不小的开销。生产环境我建议你显式设置opcache.enable1 opcache.memory_consumption256 opcache.interned_strings_buffer16 opcache.max_accelerated_files20000 opcache.revalidate_freq60 opcache.validate_timestamps0这里validate_timestamps0的意思是告诉 OPcache 不要每次请求都去检查源码文件有没有被修改。这样性能能提升一截但要记住一个代价以后部署代码之后必须 reload php-fpm 容器或重启容器代码才会生效。所以如果你的发布流程里没有“reload php-fpm”这一步别急着关时间戳检查。2.3 镜像标签、基础镜像锁定与体积瘦身镜像 tag 是最容易被忽略却又极其关键的一个点。php:8.3-fpm这个标签是滚动的拉镜像时拿到的是当时的最新 8.3 补丁版本今天拉到8.3.0下周可能就变成8.3.7。补丁版本更新通常向后兼容但 PHP 偶尔会改默认配置导致镜像行为变化你根本无法复现当时线上的镜像。所以生产级做法是锁定到具体补丁版本比如php:8.3.7-fpm-bookworm或者用镜像的 digest哈希值来锁定。每次升级版本是主动行为而不是被动跟着 tag 一起漂移。镜像体积这块除了多阶段构建还有几个实用技巧构建时增加--networkhost避免 DNS 问题拖慢构建。安装完依赖包后执行apt-get clean rm -rf /var/lib/apt/lists/*这个也是官方推荐的标准操作。注意/var/www/html/storage这类运行时需要写入的目录在镜像里先创建好并调整权限否则容器启动后会因为目录不存在或没权限疯狂报错。不要往镜像里塞.env文件、SSH 私钥、docker-compose.override.yml这类环境相关文件这些都是运行时通过环境变量或挂载注入的进镜像只会带来安全隐患。构建时如果能用DOCKER_BUILDKIT1配合--squash能把多层压缩成一层但我个人对这种“极限体积优化”持保留态度因为 squash 会丢失构建历史排查问题时不方便体积差个几十 MB 在生产服务器上根本不敏感。docker build --networkhost -t my-registry.example.com/php-app:1.4.2 -f docker/php/Dockerfile .好到这里镜像构建这块就先收个尾下面进入真正考验人的部署编排阶段。3. Docker Compose 编排生产环境3.1 一个生产可用的 Compose 文件镜像造好之后就要考虑编排了。用 Docker Compose 在当前阶段依然是中小团队的最佳选择简单、直观、不依赖额外基础设施。Kubernetes 自然是趋势但如果你团队没有专职运维上 K8s 只会让问题翻倍没必要为了追新而矫枉过正。来看看我实际在服务端用的一个docker-compose.yml跑的是一个典型的 PHP-FPM Nginx MySQL Redis 四件套架构version: 3.8 services: nginx: image: nginx:1.25.5-alpine container_name: php-app-nginx restart: unless-stopped ports: - 80:80 - 443:443 volumes: - /srv/php-app/certbot/conf:/etc/letsencrypt:ro - /srv/php-app/nginx/conf.d:/etc/nginx/conf.d:ro - static_volume:/var/www/html/public depends_on: php: condition: service_healthy networks: - backend - frontend logging: driver: json-file options: max-size: 10m max-file: 3 php: image: my-registry.example.com/php-app:1.4.2 container_name: php-app-php restart: unless-stopped env_file: - .env environment: - APP_ENVproduction - APP_DEBUGfalse - DB_HOSTmysql - REDIS_HOSTredis volumes: - static_volume:/var/www/html/public - /srv/php-app/storage/logs:/var/www/html/storage/logs depends_on: mysql: condition: service_healthy redis: condition: service_healthy networks: - backend healthcheck: test: [CMD-SHELL, php -r \exit(extension_loaded(pdo_mysql) ? 0 : 1);\] interval: 30s timeout: 5s retries: 3 start_period: 10s mysql: image: mysql:8.0.36 container_name: php-app-mysql restart: unless-stopped command: - --character-set-serverutf8mb4 - --collation-serverutf8mb4_unicode_ci - --max_connections300 environment: MYSQL_DATABASE: appdb MYSQL_USER: appuser MYSQL_PASSWORD: ${DB_PASSWORD} MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} volumes: - mysql_data:/var/lib/mysql - /srv/php-app/mysql/init:/docker-entrypoint-initdb.d:ro networks: - backend healthcheck: test: [CMD, mysqladmin, ping, -h, 127.0.0.1, -uappuser, -p${DB_PASSWORD}] interval: 10s timeout: 5s retries: 5 redis: image: redis:7.2.4-alpine container_name: php-app-redis restart: unless-stopped command: [redis-server, --appendonly, yes, --requirepass, ${REDIS_PASSWORD}] volumes: - redis_data:/data networks: - backend healthcheck: test: [CMD, redis-cli, ping] interval: 10s timeout: 5s retries: 5 volumes: static_volume: mysql_data: redis_data: networks: backend: driver: bridge frontend: driver: bridge这套配置有几个点非常重要拆开讲。depends_on里的condition: service_healthy是 Docker Compose 里的“服务就绪后才启动下游”机制。默认depends_on只关心容器是否启动不关心服务能否访问。MySQL 容器启动到真正可以接受连接中间有初始化时间如果你不等待健康检查PHP 容器一旦连不上数据库就会退避重试日志里全是Connection refused。Nginx 同理等 PHP-FPM 健康了再启动前面挂着的请求就不会产生 502。再来看新版本 Compose 对健康检查的写法这里我用的是数组格式test: [CMD-SHELL, php -r ...]。在容器里执行php -r的检测脚本如果能跑通就认为健康。这个检查本身非常简单只验证了 pdo_mysql 扩展是否加载实际上生产环境更完善的检查应该从应用层做比如加一个专门的/healthz路由里面检测数据库、缓存是否正常返回 200 才认为健康。项目小的时候用这个简单的就够了但是如果是核心链路建议还是写个 health 路由然后healthcheck: test: [CMD-SHELL, curl -f http://127.0.0.1:9000/healthz || exit 1]注意 FPM 容器里默认没有 curl你要提前装一下直接用扩展内建的php -r file_get_contents(http://127.0.0.1:9000/healthz);也行。3.2 配置管理环境变量、.env 与敏感信息处理容器化部署最大的变化之一就是配置外置化。PHP 代码里不要出现任何数据库密码、API 密钥这些信息通过环境变量注入。具体到 Compose 项目我在服务器上的/srv/php-app/.env管理所有环境配置内容大致长这样# 应用配置 APP_ENVproduction APP_DEBUGfalse # 数据库配置 DB_HOSTmysql DB_PORT3306 DB_DATABASEappdb DB_USERNAMEappuser DB_PASSWORD这里填强密码 DB_ROOT_PASSWORD这里填另一个强密码 # Redis配置 REDIS_HOSTredis REDIS_PORT6379 REDIS_PASSWORD这里填Redis密码这个文件不要提交到代码仓库生产服务器上的这个文件权限要设置为 600只有管理员能读。.env文件由env_file指令注入到容器PHP-FPM 进程通过getenv()或框架自带的 env 读取到对应值。那你可能会问php-fpm进程怎么读到这些环境变量这里就是之前提到clear_env no起作用的地方。PHP-FPM 默认会清空子进程环境变量除非显式配置clear_env no否则框架即使调用了getenv()也读不到任何东西。这是很多 Laravel 项目容器化后环境变量死活读不出来的根因。另一个和配置相关的细节是不同环境开发/测试/生产不要维护多份 .env 文件在同一个地方很容易改错。我的习惯是开发环境用 Docker Compose Override下一节细说生产环境只维护/srv/php-app/.env这一份所有服务共享。发布新版本时不需要改动这一份配置和代码分离这样回滚也不会牵连配置。3.3 日志别让数据丢在容器里日志是生产环境最容易翻车的一个点。默认情况下 Docker 会把容器里的 stdout/stderr 收集到json-file日志驱动里用docker logs能查看但如果容器挂了日志很可能跟着一起消失取决于你怎么删除容器。另外json-file默认不限制文件大小一个日志写疯了的容器能把宿主机的磁盘直接占满这是真实事故级别的坑不是玩笑。解决方案就是在 Compose 里限制日志文件大小logging: driver: json-file options: max-size: 10m max-file: 3这个配置的意思是每个容器的日志文件最大 10MB保留 3 个文件轮转。如果你的日志量特别大10MB 不够可以适当调大但一定要设上限不然“日志撑爆磁盘”分分钟让你体会到什么叫半夜警报。更规范的做法是接入集中式日志系统比如 Loki、Elasticsearch、Splunk 这类。PHP 应用的日志通常写到storage/logs/文件里这时候我一般把日志目录挂载到宿主机象前面 Compose 里写的- /srv/php-app/storage/logs:/var/www/html/storage/logs这样即使容器重建日志也完整保留在宿主机后续可以用 Filebeat 或 Promtail 把这些日志采集走而不用侵入容器内部去折腾。这里还有一个很多人忽视的细节容器内 PHP 进程默认会把php-fpm的 error log 输出到/proc/self/fd/2stderr这其实是好事能直接被 Docker 收集。但如果你的应用把日志写到文件里而不是 stderr那么docker logs默认什么都看不到排障的时候会一头雾水。所以我的建议是应用框架的日志输出到 stdout/stderr配合 Docker 日志驱动收集业务日志如果需要单独留存再额外写文件并挂载 volume。两边各管各的互不干扰。4. 生产环境避坑与性能调参4.1 文件权限问题容器里那个非 root 用户前面提到最终镜像用非 root 用户运行这个方向是对的但实际部署中会浮现一系列跟权限相关的连锁问题很多人在这里卡几天。最常见的报错就是 Laravel 的storage/logs目录写不进去报错信息类似file_put_contents(...): failed to open stream: Permission denied。原因很简单你以appuserUID 1000运行 PHP-FPM但宿主机上/srv/php-app/storage/logs的所有者可能是 root或者 UID 跟你容器里的用户不一致容器进程没有写权限。我在生产服务器上的做法比较直接宿主机和容器统一用 UID 1000mkdir -p /srv/php-app/storage/logs chown -R 1000:1000 /srv/php-app/storage/logs因为容器里的appuser我已经固定为 UID 1000宿主机上对应的目录也改成 UID 1000两边就对齐了。这条经验说起来简单但新手真的很容易忽略UID 一致性问题——你容器里创建的 appuser 可能是 1000宿主机上看到的结果却不一定跟你想的挂钩因为 uid 是数字跟名字没有关系。排查的时候不要只盯着用户名直接在宿主机上看 UIDls -n /srv/php-app/storage/logs另外提醒一点Nginx 和 PHP-FPM 共同访问的public/静态文件目录必须保证两边都能读。我在 Compose 里用了一个命名卷static_volume同时挂给 nginx 和 php 两个容器这样静态资源在两者之间是共享的但注意 Nginx 容器内的 UID 是 nginx通常 101对文件的读取权限要放开目录至少 755文件至少 644。4.2 OPcache 和 PHP-FPM 性能调参容器本身几乎不影响 PHP 的性能真正的性能瓶颈在 PHP-FPM 和 OPcache 配置。很多人用 Docker 之后觉得“PHP 变慢了”其实不是 Docker 的锅而是容器默认配置下 OPcache 没开或者 FPM 进程数没调好。docker-php-ext-install opcache只负责编译扩展并不会自动开启。你要确保 php.ini 里有一行zend_extensionopcache opcache.enable1php:8.3-fpm镜像默认其实会附带 opcache 配置但开启状态因版本而异。建议你进入容器确认docker exec -it php-app-php php -m | grep opcache如果没输出就要手动配置。上面在 Dockerfile 里把自定义php.ini拷贝到conf.d/99-custom.ini了这样会在容器启动时最后加载能覆盖默认配置。PHP-FPM 的进程管理参数也是老生常谈但很多人的配置是直接把默认值改大完全没有结合容器内存来算。我给你们一个简单的计算方式先看每个 PHP-FPM 进程平均占多少内存ps aux里 RSS 那列或者用docker exec php-app-php php-fpm -tt。一般 30-80MB 不等看你的应用复杂度。pm.max_children 可用内存MB / 每个进程内存MB。如果你给容器限制 2GB 内存--memory2gCompose 里用mem_limit每个 PHP 进程平均占 60MB那pm.max_children最多设 30 个左右留出一部分内存给 MySQL、Redis 和系统本身。别压榨得太狠出现 OOM 直接系统杀进程落到日志里比慢更吓人。这里也说一个跟容器强相关的点给容器加内存限制。mem_limit在 Compose 的 3.8 版本里已经换成deploy.resources.limits在非 swarm 模式下部分版本不生效更通用的还是用docker run --memory或者在 compose 里写mem_limit旧版或者deploy新版集群专用。我的建议是如果不用 Docker Compose 的 swarm 模式直接在docker run或者 compose 文件的mem_limit项里加上例如php: mem_limit: 2g cpus: 1.5限内存的意义不只是防单个服务把宿主机吃空更重要的是当 PHP 出现雪崩式进程膨胀时容器会被系统 kill容器守护进程会自动重启比起整个宿主机 OOM 导致所有项目一起挂这个“单点故障”会好处理得多。4.3 健康检查、优雅退出与重启策略容器是会被重启的如何处理“重启瞬间”的流量和任务状态是生产环境必须考虑的事。健康检查刚才已经提过核心思路是让编排系统知道什么时候一个容器算“可用”。在docker-compose.yml里给 php 服务加了 healthcheck 后通过docker ps你能看到容器的健康状态列只有显示healthy才代表业务就绪。如果依赖方Nginx 或负载均衡在容器不健康时还继续分发请求用户就会看到 502。优雅退出是另一个容易被忽略的问题。PHP-FPM 处理完一个请求可能要处理好几秒如果你直接docker stop不等待等于把正在执行的请求硬生生掐断可能导致数据库事务没提交、缓存数据丢失。Docker 默认在docker stop发 SIGTERM 信号后等待 10 秒超过就直接 SIGKILL。生产环境建议用 stop_grace_period 把宽限期调长一点php: stop_grace_period: 60s这样在发版回滚时容器能从容地处理完正在跑的请求再退出而不是用用户的请求为你的发布买单。重启策略同样重要。restart: unless-stopped意味着容器意外退出时会自动重启但如果是被你手动停止的比如发布新版本时先 stop 再替换Docker 不会自作主张帮你拉起来。unless-stopped和always的区别就在这生产环境我建议用unless-stopped因为它不会在你为了排障而主动 stop 容器之后又给你添乱。这个策略属于运维习惯选哪一种都行但要明确知道区别。5. 从开发到上线的全流程经验5.1 本地开发环境与生产环境的差异化管理Docker 最讨厌的一点就是“开发能跑生产跑不了”其实多数是环境配置没分离干净。我推荐用 Docker Compose 的 override 机制来区分环境。生产环境只维护一份docker-compose.yml开发环境在此基础上叠加一个docker-compose.override.ymlDocker 默认会自动读取这个文件合并配置。开发环境的 override 文件大致长这样version: 3.8 services: php: build: context: . dockerfile: docker/php/Dockerfile.dev volumes: - ./:/var/www/html environment: - APP_ENVlocal - APP_DEBUGtrue - XDEBUG_MODEdebug extra_hosts: - host.docker.internal:host-gateway这样开发时源码是直接挂载进容器的改完代码刷新就能看到不用每次重新构建镜像。Xdebug 也可以在容器里启用方便打断点调试。而生产环境的镜像已经把源码打进去了不挂载任何源码目录业务日志目录除外保证了代码一致性和部署可预测性。Dockerfile.dev和Dockerfile的区别通常只是多安装几个开发扩展xdebug、pcntl以及不编译 opcache。如果你不想维护两个 Dockerfile也可以用环境变量控制 PHP 扩展启用但我试下来还是分开维护更省心因为开发和生产对镜像的要求差异实在太大强行合并反而让镜像急剧膨胀。5.2 版本发布与回滚镜像 tag 是唯一的发布单元生产环境发布要形成一个固定流程我这边目前是这么走的开发代码合并主干后CI 自动跑测试。测试通过后构建新版本镜像镜像 tag 用 git commit hash 或者语义化版本号比如1.4.2。推送镜像到私有 registry我用的是 Harbor也用过 AWS ECR都行。登录服务器进到/srv/php-app目录修改docker-compose.yml里 php 镜像的 tag然后docker compose pull php docker compose up -d php等容器健康检查通过确认日志无异常这个版本就算上去了。如果出问题要回滚操作同样简单docker compose up -d --no-deps --force-recreate php前提是docker-compose.yml里 php 镜像的 tag 改回上一版本的 tag然后重新 pull。这里你可以看到** tag 是整个发布体系里唯一可追踪的单元**。所以镜像 tag 务必要有含义不能全部用latest。用latest意味着你根本不知道线上跑的是哪一天的代码一出问题连回滚的目标都没有。另外基础镜像的版本锁定同样重要Dockerfile 里php:8.3-fpm-bookworm应该固定成php:8.3.7-fpm-bookworm之类保证构建可复现。还有一点需要强调.env文件在生产环境不要跟代码捆绑着走。我在服务器上的习惯是/srv/php-app/.env独立维护不放在 Docker 构建上下文里也不提交到 git。当你要部署新版本时直接 pull 镜像并重启容器即可配置层面完全不用动。如果新代码需要新的环境变量手动编辑.env然后逐个 restart 服务整个过程可审计、可回滚。5.3 监控与告警别等用户来告诉你系统挂了容器化之后进程不再像裸机那样常驻你需要一整套监控手段否则系统出问题了你都不知道。最基础的是 Docker 自带的docker ps docker stats docker logs -f php-app-phpdocker stats能实时看容器 CPU、内存、网络 IO适合临时检查不适合长期盯。稍微进阶一点可以部署一套轻量监控组合cAdvisor 采集容器指标Prometheus 存指标Grafana 做可视化告警。这套东西本身也能用 Docker Compose 跑起来但那是另外一个很长的话题了。如果你团队资源有限哪怕先从“服务器上部署一台 Uptime Kuma”开始也能让 HTTP 层宕机最早通知到你。头三天你可能觉得烦每次报警都被拉起但比起用户先发现再通报你值太多了。和监控配套的是日志系统。前面提过 PHP 应用的 stderr 被 Docker 收集了但如果容器被删除或者节点挂了这些日志就会丢。所以至少做到这一步把关键业务日志挂载到宿主机并通过 logrotate 做按天或按大小轮转。我的服务器上用logrotate管理/srv/php-app/storage/logs/*.log配置很简单/srv/php-app/storage/logs/*.log { daily rotate 30 compress delaycompress missingok notifempty copytruncate }这样 30 天内的日志都留得住挂载卷里的日志文件不会被无脑积到几个 GB 然后把磁盘写满。6. 常见问题与排查技巧实录6.1 PHP 容器连不上 MySQL这是 PHP-Docker 项目里最高频的问题。先排查容器网络docker exec -it php-app-php ping mysql如果 ping 不通检查是否在同一个 network。用 Compose 时只要服务在同一个 network 下用服务名当主机名就可以互通。避免用127.0.0.1连数据库因为那是容器自己的回环地址不是宿主机的。如果网络通但还是连不上进 MySQL 容器看权限docker exec -it php-app-mysql mysql -u root -p然后看 MySQL 用户授权。如果你在docker run时只授权了localhost的访问权限那 PHP 容器当然连不上。正确授权方式CREATE USER appuser% IDENTIFIED BY 密码; GRANT ALL PRIVILEGES ON appdb.* TO appuser%; FLUSH PRIVILEGES;生产环境为了安全和可维护性最好别用 root 连接应用数据库。6.2 Nginx 502 Bad Gateway502 是 Nginx 连不上 PHP-FPM 的信号常见原因有三类。第一类PHP-FPM 监听的地址不匹配。Nginx 配置里写的 fastcgi_pass 是php:9000但 PHP-FPM 配置里listen写的是127.0.0.1:9000那就完蛋了——php这个主机名解析出来的 IP 是一个 Docker 网络地址而 PHP-FPM 只监听回环自然连不上。正确写法是listen 9000监听所有接口或者直接监听 Unix socket 并在 Nginx 里配置一致。第二类PHP-FPM 容器没起来或起完就崩了。docker logs php-app-php看日志如果显示Permission denied就是目录权限问题如果是ERROR: unable to bind listening socket是端口冲突。第三类PHP 进程因为在处理崩溃请求而挂掉OPcache 配置错误也可能导致 FPM 子进程不停崩溃。这时看 FPM 日志里的Segment fault或者exit status根据关键词再搜解决方案会高效很多。6.3 容器时间不对定时任务全崩容器默认时区是 UTC如果你的 PHP 业务用到date()、定时任务、车票订单这种时间敏感逻辑等到的结果全都会偏 8 小时。解决办法要么在 Dockerfile 里用ENV TZAsia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime echo $TZ /etc/timezone要么在运行容器时加-e TZAsia/Shanghai然后在 php.ini 里设置date.timezone Asia/Shanghai。别小看这一行很多定时任务半夜莫名多跑一小时或者少跑一小时排查到最后发现是时区问题。6.4 容器启动后数据库版本不一致导致数据丢失开发环境 MySQL 8.0生产环境 MySQL 5.7这种版本乱象会导致 SQL 语法、字符集排序规则不一致然后线上莫名其妙报错。用 Docker 的一个巨大优势就是开发环境数据库版本能跟生产保持一致。所以 Compose 项目里数据库镜像版本要和生产对齐升级数据库版本时也要谨慎做数据迁移备份先行。6.5 构建时 Composer 慢或超时国内环境拉 Composer 依赖经常超时Docker 构建过程也会受影响。我常用的优化手段有这么几种使用 Composer 中国镜像。官方仓库在构建时被墙的话设置环境变量ENV COMPOSER_ALLOW_SUPERUSER1 RUN composer config -g repo.packagist composer https://packagist.phpcomposer.com但是用镜像源也会带来风险依赖包哈希验证不过或者镜像源同步不及时。生产构建时我倾向于用官方源加上合理的超时时间RUN composer install --no-dev --prefer-dist --no-interaction --timeout600利用 Docker 层缓存。把composer.json、composer.lock单独拷贝并先执行composer install代码变了还能命中缓存。这也是前面 Dockerfile 里强调过的。6.6 应用日志被写入容器层导致磁盘爆炸默认情况下容器内写入的文件在容器删除后丢失并且占的是容器可写层的空间。如果你把应用日志写在/var/www/html/storage/logs不挂载容器一删日志就没了如果日志文件一直写容器可写层会越来越大最终容器所在的分区被写满。两种办法一是挂载宿主机目录来持久化日志上面已经给了方案二是用日志驱动限制文件大小。实在不记得配置就记住一句话运行时会产生数据的地方要么挂载 volume要么想办法输出到 stdout/stderr。7. 一些值得坚持的实战习惯最后再倒一点这些年积累的操作习惯都是踩坑踩出来的按性价比从高到低列一下。镜像构建完了不要直接docker run上生产。先本地把镜像拉下来进容器看一眼扩展列表、php.ini 配置、代码版本确认没问题再推送到服务器。多花一分钟少熬一夜。所有可以外置的敏感信息永远不要写进镜像或代码仓库。包括数据库密码、Redis 密码、第三方 API Key、私钥文件。Compose 的env_file和environment是首选的注入方式。有人会把.env也打进镜像里等于把密码交到了所有能拉取镜像的人手里。不要迷信latest标签。不管是基础镜像还是自己的应用镜像全部用精确版本号。基础镜像的升级要主动做而不是某天重新构建镜像时被动地带上来一些不可控的改动。尽量保持底层镜像少变。一旦确定了php:8.3.7-fpm-bookworm除非有明确的安全漏洞或功能需要不要频繁升级基础镜像。每次基础镜像漂移都可能带来php.ini默认值变化、扩展编译版本变化这些微小的差异在生产环境会被放大。容器编排文件要进版本库。docker-compose.yml、Dockerfile、nginx 配置都提交到 git这既是团队的协作基础也是你后续排查“环境为什么和之前不一样”的凭证。唯一不提交的是.env用.env.example提交占位模板真正内容留在服务器上。我在多次 PHP 容器化改造里最深的体会是Docker 这东西真正难的不是把容器跑起来而是把它作为一个“可持续维护的发布单元”来设计。镜像怎么构建、配置怎么注入、日志怎么流转、回滚怎么做、监控怎么覆盖每一个环节都像八爪鱼的触手牵一发而动全身。上面这套组合拳不敢说覆盖了所有场景但至少能让你上线之后睡觉踏实一点。如果你在实操过程中遇到其他奇怪的坑欢迎按这些思路去拆解——大多数时候答案就藏在日志的第一行报错里。
返回列表