在现代 PHP 项目开发中,从本地开发环境到生产部署的跨环境一致性问题一直困扰着开发团队。”在我的机器上能跑”(Works on My Machine)不再是可接受的回答。Docker 通过容器化技术,将 PHP 应用及其依赖、扩展、运行环境打包成一个可移植的镜像,从根本上解决了环境一致性问题。本文将从零开始,带你完成 PHP 项目 Docker 化的完整流程,涵盖 Dockerfile 编写、多阶段构建、Nginx + PHP-FPM 架构编排、生产环境安全加固、CI/CD 集成等核心实践。

一、为什么 PHP 项目需要容器化
传统的 PHP 部署方式通常是在服务器上安装 PHP、Nginx/Apache、MySQL 等组件,然后通过配置虚拟主机将代码部署上线。这种方式存在几个根本性缺陷:
- 环境不一致:开发、测试、生产环境的 PHP 版本、扩展、配置文件不同,导致”本地正常但线上报错”
- 依赖管理困难:不同项目需要不同版本的 PHP 扩展(如 imagick、redis、swoole),同一服务器上难以共存
- 扩缩容复杂:需要手动在多台服务器上复制环境配置,容易遗漏
- 回滚困难:代码出问题时,回滚到旧版本需要重新部署和环境调整
容器化后,这些问题迎刃而解。每个 PHP 应用被打包成一个不可变镜像,包含精确的运行环境。无论是开发、测试还是生产,运行的都是同一个镜像。扩容时只需启动更多容器实例,回滚时只需切换镜像版本标签。
二、基础 Dockerfile:从简单到规范
2.1 最简单的 Dockerfile
假设我们有一个基于 Composer 的 PHP 项目,目录结构如下:
1
2
3
4
5
6
7
8 my-php-app/
├── public/
│ └── index.php
├── src/
│ └── Application.php
├── composer.json
├── composer.lock
└── Dockerfile
最基础的 Dockerfile 可以这样写:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 FROM php:8.3-fpm-alpine
# 复制项目代码
COPY . /var/www/html
# 安装 Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# 安装依赖
WORKDIR /var/www/html
RUN composer install --no-dev --optimize-autoloader
# 设置权限
RUN chown -R www-data:www-data /var/www/html
这个 Dockerfile 能工作,但存在几个问题:镜像体积大、构建缓存效率低、安全性不足、没有安装 PHP 扩展。让我们逐步改进。
2.2 安装 PHP 扩展的正确方式
PHP 官方镜像提供了两种扩展安装机制:
| 机制 | 适用场景 | 示例 | ||
|---|---|---|---|---|
|
核心扩展(需要编译) | pdo_mysql, gd, bcmath, opcache | ||
|
通过 PECL 安装的扩展 | redis, xdebug, swoole | ||
|
需要额外配置参数的扩展 | gd(需要 libpng/libjpeg) |
以下是安装常用扩展的完整示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29 FROM php:8.3-fpm-alpine
# 安装系统依赖(编译扩展所需的库)
RUN apk add --no-cache \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
libzip-dev \
icu-dev \
postgresql-dev
# 配置并安装 gd 扩展
RUN docker-php-ext-configure gd --with-freetype --with-jpeg
# 安装核心扩展
RUN docker-php-ext-install -j$(nproc) \
gd \
pdo_mysql \
pdo_pgsql \
zip \
intl \
bcmath \
opcache
# 通过 PECL 安装 Redis 扩展
RUN pecl install redis && docker-php-ext-enable redis
# 安装 Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
注意:Alpine 镜像使用
1 | apk |
包管理器,而 Debian 基础镜像使用
1 | apt-get |
。选择 Alpine 可以显著减小镜像体积(通常从 400MB+ 降到 100MB 左右),但部分扩展的编译时间会更长。
三、多阶段构建:优化镜像体积与安全
多阶段构建(Multi-stage Build)是 Docker 镜像优化的核心技术。它的核心思想是:在构建阶段使用完整的工具链编译代码和安装依赖,然后将最终产物复制到一个精简的运行时镜像中,丢弃所有编译工具。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37 # ===== 阶段1:构建阶段 =====
FROM composer:2 AS builder
WORKDIR /app
COPY composer.json composer.lock ./
# 先复制依赖文件,利用 Docker 缓存层
RUN composer install --no-dev --no-scripts --no-autoloader
# 复制源代码
COPY . .
# 完成自动加载优化
RUN composer dump-autoload --optimize --no-dev
# ===== 阶段2:运行时阶段 =====
FROM php:8.3-fpm-alpine AS runtime
# 安装运行时所需的扩展(不含编译工具)
RUN apk add --no-cache \
libpng \
libjpeg-turbo \
freetype \
libzip \
icu-libs \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd pdo_mysql zip intl bcmath opcache \
&& pecl install redis && docker-php-ext-enable redis
# 从构建阶段复制应用代码和 vendor 目录
COPY --from=builder /app /var/www/html
# 设置工作目录和权限
WORKDIR /var/www/html
RUN chown -R www-data:www-data /var/www/html
# 切换到非 root 用户
USER www-data
多阶段构建的优势非常明显:
- 镜像体积减小 60% 以上:最终镜像不含 Composer、编译工具链、源码缓存
- 安全性提升:攻击面大幅缩小,即使容器被入侵也无法使用 gcc、make 等工具
- 构建缓存友好:修改源代码时不会触发依赖重新安装
四、Nginx + PHP-FPM 架构编排
生产环境中,PHP-FPM 容器只负责处理 PHP 请求,前端需要一个 Web 服务器来处理静态资源和反向代理。Nginx 是最常见的选择。我们使用 Docker Compose 来编排这两个服务。
4.1 项目目录结构
1
2
3
4
5
6
7
8
9
10
11
12
13 project/
├── docker/
│ ├── nginx/
│ │ └── default.conf
│ ├── php/
│ │ ├── Dockerfile
│ │ └── php.ini
│ └── mysql/
│ └── init.sql
├── src/
│ └── public/index.php
├── docker-compose.yml
└── .dockerignore
4.2 Nginx 配置文件
1 | docker/nginx/default.conf |
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35 server {
listen 80;
server_name localhost;
root /var/www/html/public;
index index.php index.html;
# 日志配置
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# 主路由规则
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# PHP 文件交给 PHP-FPM 处理
location ~ \.php$ {
fastcgi_pass php-fpm:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 60s;
}
# 禁止访问隐藏文件
location ~ /\.(?!well-known).* {
deny all;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
}
4.3 docker-compose.yml 完整配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74 version: '3.8'
services:
nginx:
image: nginx:1.25-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./src:/var/www/html
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
- nginx-logs:/var/log/nginx
depends_on:
- php-fpm
networks:
- app-network
restart: unless-stopped
php-fpm:
build:
context: .
dockerfile: docker/php/Dockerfile
volumes:
- ./src:/var/www/html
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/custom.ini
environment:
- DB_HOST=mysql
- DB_NAME=app_db
- DB_USER=app_user
- DB_PASSWORD=*** depends_on:
mysql:
condition: service_healthy
networks:
- app-network
restart: unless-stopped
mysql:
image: mysql:8.0
environment:
MYSQL_DATABASE: app_db
MYSQL_USER: app_user
MYSQL_PASSWORD: secret_password
MYSQL_ROOT_PASSWORD: root_super_secret
volumes:
- mysql-data:/var/lib/mysql
- ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app-network
restart: unless-stopped
redis:
image: redis:7-alpine
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
networks:
- app-network
restart: unless-stopped
volumes:
nginx-logs:
mysql-data:
redis-data:
networks:
app-network:
driver: bridge
这个编排配置包含四个服务,覆盖了典型的 PHP Web 应用所需的基础设施。几个关键设计点:
- 健康检查:MySQL 服务配置了
1healthcheck
,PHP-FPM 通过
1depends_on.condition: service_healthy确保数据库就绪后才启动
- 数据持久化:使用命名卷(
1mysql-data
、
1redis-data)而非绑定挂载,避免宿主机权限问题
- 网络隔离:所有服务在同一个
1app-network
桥接网络中,容器间通过服务名通信
- 自动重启:
1restart: unless-stopped
策略确保服务异常退出后自动恢复
五、生产环境配置优化
5.1 PHP-FPM 进程管理调优
生产环境中最常见的性能问题来自 PHP-FPM 进程管理配置不当。以下是一个针对中型流量的推荐配置:
1 | php.ini |
关键配置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 ; OPcache 配置 —— 生产环境必须开启
opcache.enable = 1
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 32
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.save_comments = 1
opcache.fast_shutdown = 1
; 内存限制(根据实际需求调整)
memory_limit = 256M
; 上传文件大小限制
upload_max_filesize = 64M
post_max_size = 64M
; 错误处理(生产环境)
display_errors = Off
log_errors = On
error_log = /var/log/php/error.log
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
; 时区设置
date.timezone = Asia/Shanghai
1 | www.conf |
(PHP-FPM 进程池配置):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 ; 进程管理方式
pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 1000
; 慢日志 —— 排查性能瓶颈的利器
slowlog = /var/log/php/slow.log
request_slowlog_timeout = 3s
; 状态监控页面
pm.status_path = /fpm-status
ping.path = /ping
1 | pm.max_requests = 1000 |
是一个经常被忽略但非常重要的设置。它让每个 worker 在处理 1000 个请求后自动重启,防止因 PHP 扩展内存泄漏导致的 OOM 问题。对于使用了 imagick 等有内存泄漏历史的扩展,这个值甚至可以设得更小。
5.2 计算合适的 max_children 值
1 | pm.max_children |
不是越大越好,需要根据服务器内存和每个 PHP 进程的平均内存占用来计算:
1
2
3
4
5 # 查看单个 PHP-FPM worker 的内存占用
docker exec php-fpm ps aux | grep php-fpm | grep pool
# 输出示例:
# www-data 1234 0.5 2.0 85000 40000 ? S 10:00 0:01 php-fpm: pool www
计算公式:
1
2
3
4
5
6
7 max_children = (总可用内存) / (单个 worker 内存)
# 示例:4GB 内存服务器,预留 1GB 给系统和其他服务
# 每个 worker 约 40MB 内存
# max_children = 3072MB / 40MB ≈ 76
# 建议保守取值,设为 50-60
六、安全加固策略
容器化并不意味着自动安全。以下是在 Docker 化 PHP 应用时必须考虑的安全措施:
6.1 使用非 root 用户运行
1
2
3
4
5 # 在 Dockerfile 中创建专用用户
RUN addgroup -g 1000 appgroup \
&& adduser -u 1000 -G appgroup -s /bin/sh -D appuser
USER appuser
6.2 最小权限文件系统
1
2
3
4
5
6
7
8 # 在 docker-compose.yml 中添加只读根文件系统
services:
php-fpm:
read_only: true
tmpfs:
- /tmp
- /var/run
- /var/log/php
6.3 使用 Docker Secrets 管理敏感信息
永远不要将数据库密码、API 密钥等硬编码在 Dockerfile 或 docker-compose.yml 中。推荐方案:
1
2
3
4
5
6
7
8
9
10
11 # docker-compose.yml
services:
php-fpm:
environment:
- DB_PASSWORD_FILE=/run/s...rd
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
在 PHP 代码中读取:
1
2
3
4
5
6
7
8 <?php
$dbPassword = trim(file_get_contents(getenv('DB_PASSWORD_FILE')));
$dsn = "mysql:host=" . getenv('DB_HOST') . ";dbname=" . getenv('DB_NAME');
$pdo = new PDO($dsn, getenv('DB_USER'), $dbPassword, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
七、CI/CD 流水线集成
将 Docker 化的 PHP 项目接入 CI/CD 流水线,实现自动构建、测试和部署。以下是一个 GitLab CI 配置示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60 # .gitlab-ci.yml
stages:
- test
- build
- deploy
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
# 代码质量检查
phpstan:
stage: test
image: php:8.3-cli-alpine
script:
- composer install --no-interaction
- vendor/bin/phpstan analyse --level=8 src/
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
# 单元测试
phpunit:
stage: test
image: php:8.3-cli-alpine
services:
- mysql:8.0
variables:
MYSQL_DATABASE: test_db
MYSQL_ROOT_PASSWORD: test_secret
script:
- composer install --no-interaction
- vendor/bin/phpunit --coverage-text --colors=never
rules:
- if: $CI_COMMIT_BRANCH == "main"
# 构建并推送镜像
build_image:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $IMAGE_TAG .
- docker push $IMAGE_TAG
rules:
- if: $CI_COMMIT_BRANCH == "main"
# 部署到生产
deploy_production:
stage: deploy
image: alpine:3.19
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | ssh-add -
script:
- ssh $DEPLOY_USER@$DEPLOY_HOST "docker pull $IMAGE_TAG && docker-compose up -d --no-deps php-fpm"
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
八、常见问题与排查
8.1 容器间无法通信
PHP-FPM 连接 MySQL 时报
1 | Connection refused |
,通常是因为:
- 服务不在同一个 Docker 网络中
- 代码中使用
1localhost
或
1127.0.0.1而非服务名(如
1mysql)
- MySQL 服务尚未就绪就尝试连接
解决方案:使用 Docker Compose 服务名作为主机名,并通过 healthcheck 确保依赖顺序。
8.2 文件权限问题
使用绑定挂载时,容器内的
1 | www-data |
用户(UID 82 in Alpine)与宿主机用户 UID 不一致,导致文件读写权限错误。解决方案:
1
2
3
4
5
6
7 # 方案1:在 Dockerfile 中将 www-data 的 UID 改为宿主机用户
ARG HOST_UID=1000
RUN deluser www-data \
&& adduser -u ${HOST_UID} -D -H -s /bin/sh www-data
# 方案2:构建时指定用户
docker-compose build --build-arg HOST_UID=$(id -u) php-fpm
8.3 OPcache 不更新代码
生产环境设置
1 | opcache.validate_timestamps = 0 |
后,更新代码需要重载 PHP-FPM:
1
2
3
4
5 # 优雅重载(不中断当前请求)
docker exec php-fpm kill -USR2 1
# 或者通过 docker-compose
docker-compose exec php-fpm sh -c "kill -USR2 1"
九、镜像体积对比与优化效果
以下是在一个实际 Symfony 项目上的优化数据对比:
| 方案 | 镜像大小 | 构建时间 | 层数 |
|---|---|---|---|
| 单阶段 + Debian | 689 MB | 312s | 18 |
| 单阶段 + Alpine | 412 MB | 298s | 16 |
| 多阶段 + Alpine | 156 MB | 285s | 12 |
| 多阶段 + Alpine + Distroless | 134 MB | 290s | 10 |
从 689MB 优化到 134MB,体积减少了 80%。更小的镜像意味着更快的拉取速度、更低的存储成本和更小的攻击面。
总结
PHP 项目的 Docker 化不是简单写一个 Dockerfile 就完事的工作。从基础镜像选择、扩展安装、多阶段构建优化,到 Nginx + PHP-FPM 架构编排、生产配置调优、安全加固,再到 CI/CD 流水线集成,每个环节都需要认真设计。
核心要点回顾:
- 始终使用多阶段构建,将编译工具链与运行时环境分离
- 优先选择 Alpine 基础镜像,配合
1docker-php-ext-install
安装扩展
- 合理配置 OPcache 和 PHP-FPM 进程池,这是性能优化的关键
- 安全层面:非 root 运行、只读文件系统、Secrets 管理缺一不可
- CI/CD 集成让镜像构建和部署自动化,减少人为操作错误
掌握了这些实践,你就可以将任何 PHP 项目安全、高效地容器化部署到生产环境中。容器化不仅是技术手段的升级,更是从”手工运维”到”基础设施即代码”的工程理念转变。
汤不热吧