☰
用Docker Compose快速部署LNMP环境:Nginx、PHP-FPM与MySQL容器化实践
2026/10/22 15:45:27 网站建设 项目流程

1. 为什么用Docker Compose搞LNMP

1.1 传统手工部署的痛点

先说个现象。以前在一台新服务器上部署LNMP(Linux + Nginx + MySQL + PHP),流程长且容易出错。

第一步装Nginx,要处理EPEL源或官方源的选择问题;第二步装MySQL,要处理版本差异和初始化密码的问题;第三步装PHP,要处理依赖库缺失、扩展没装全的问题。每一步都可能卡住,尤其是CentOS和Ubuntu的包管理器行为还不一样,同一套命令换个系统就报错。哪怕装完了,后面维护也折腾:PHP升级要动一堆配置文件,Nginx和PHP-FPM之间用TCP还是Unix Socket连接,哪次没调对,页面直接502。

还有更麻烦的——环境不一致。本地开发用Homebrew装的PHP 7.4,测试服务器是PHP 8.0,线上是8.2,代码在本地能跑,到线上就报函数废弃或语法不兼容。这种问题排查起来非常消耗时间。初学的时候踩过不少坑,后来换到Docker Compose,这些问题基本一扫而空。把整个LNMP环境固化成一个项目仓库,团队任何人拉下来执行一条命令,就能得到完全一致的运行环境,开发、测试、生产用同一套镜像和配置,从源头上消除环境差异。

1.2 Docker Compose方案的核心价值

Docker Compose解决的不是“能不能跑起来”的问题,而是“怎么稳定、可复现、可维护地跑起来”的问题。

用一段话概括:Compose通过一个YAML文件描述多个容器的编排关系(Nginx、PHP-FPM、MySQL),让它们共享一个自定义网络、挂载同一目录、按依赖顺序启动、用服务名互相访问。相比手动执行多条docker run命令,Compose最大的优势是可声明、可版本化、可复用。整个环境的定义就是一份文本文件,进了Git仓库,以后无论在哪台机器上,拉代码、执行docker compose up -d,环境就是一模一样的。

另一个价值是进程隔离与资源管控。MySQL的配置、Nginx的配置、PHP的运行参数都写在各自的挂载配置文件里,改动后重启对应容器即可生效,不会影响其他组件。这一点在调试时特别省心——只重启PHP容器,Nginx保持不断连接,而不是像以前那样,改一个配置就要重启整个服务栈。

再补充一点:Compose项目的可移植性非常好。笔记本上编排好的环境,拷到服务器上只要重新构建一次镜像就能运行;不同项目还可以用不同版本的PHP或MySQL互不干扰。这在工作场景下就是刚需,一次编排,处处运行。

2. 准备工作:环境与镜像选型

2.1 宿主机环境要求

在开始之前,先把宿主机环境确认好。我的推荐组合是Linux服务器(CentOS 7.9及以上或Ubuntu 20.04+),或者本地Windows/macOS装Docker Desktop也行,但生产环境还是建议Linux。

需要准备的东西:

  • Docker Engine,版本建议20.10以上,因为较老版本对Compose V2的支持不完整。
  • Docker Compose V2插件(docker compose命令),与老版本docker-compose独立二进制的语法基本兼容。
  • 一个干净的目录结构,例如/opt/lnmp,里面建好nginx/conf.d和www等子目录。
  • 如果服务器上有旧版Nginx、MySQL、PHP,一定先停掉并释放80/3306端口,避免端口冲突。

确认Docker版本用docker --version和docker compose version两条命令即可。我在实际项目中遇到过Docker装了但Compose没装的情况,注意检查docker compose命令是否存在——如果系统提示找不到该命令,说明只有Docker引擎,需要额外安装Compose插件。

2.2 镜像选型的考量

镜像的选择是整个部署的基础,直接决定后续环境是否好用。以下是基于大量实践得出的结论:

  • Nginx:建议选择nginx:stable-alpine。Alpine版本体积小(约20MB),内核精简,运行Nginx这种轻量级服务非常合适。如果业务需要特定模块(如更多HTTP头处理、流媒体模块),再考虑nginx官方版或其他定制版。
  • PHP:务必选择php:8.2-fpm-alpine(或你需要的版本,但建议8.1以上)。注意是-fpm版本,不是-apache版本,因为我们要配合Nginx使用,PHP-FPM是独立的FastCGI进程管理器。Alpine版同样体积小,但需要确认项目用到的PHP扩展是否都能装到,后面会讲扩展安装技巧。
  • MySQL:建议选择mysql:8.0(或mariadb:10.11)。MySQL 8.0是主流版本,默认字符集utf8mb4,支持JSON类型。如果你需要更好的兼容性,MariaDB也完全可以替代,两者在Compose编排中的配置方式几乎一样。

选型有一个原则:不要盲目追新。除非你确实需要特定新特性,否则选择已发布一年以上的稳定版本更稳妥。新版MySQL 8.4虽然性能更好,但认证插件和权限管理改动较大,不少旧项目会遇到兼容问题。

3. 核心环节一:目录结构与Compose编排文件

3.1 先画清楚目录结构

在写docker-compose.yml之前,先设计好宿主机目录结构。一个标准的LNMP项目目录大概是这样:

/opt/lnmp/ ├── docker-compose.yml ├── .env ├── mysql/ │ ├── conf/ │ │ └── my.cnf │ └── data/ # MySQL数据文件(自动生成) ├── nginx/ │ ├── conf.d/ │ │ └── default.conf │ ├── nginx.conf │ └── logs/ # Nginx日志(自动生成) ├── php/ │ └── php.ini # PHP自定义配置 └── www/ └── index.php # 项目代码

为什么要这样做?把配置文件和项目代码分开放,后续更新或调试时能精准挂载。比如修改Nginx的default.conf后,只需重启nginx容器,不需要动其他容器;修改php.ini后,只需重启php容器。数据文件单独放在mysql/data目录,方便备份和迁移。

3.2 docker-compose.yml的完整编写

直接给一份生产可用的Compose编排文件,逐行说明关键点:

version: "3.8" services: nginx: image: nginx:stable-alpine container_name: lnmp-nginx ports: - "80:80" - "443:443" volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./www:/usr/share/nginx/html - ./nginx/logs:/var/log/nginx depends_on: - php networks: - lnmp-net restart: always php: image: php:8.2-fpm-alpine container_name: lnmp-php volumes: - ./www:/var/www/html - ./php/php.ini:/usr/local/etc/php/php.ini:ro networks: - lnmp-net restart: always mysql: image: mysql:8.0 container_name: lnmp-mysql ports: - "3306:3306" volumes: - ./mysql/conf/my.cnf:/etc/mysql/conf.d/my.cnf:ro - ./mysql/data:/var/lib/mysql environment: MYSQL_ROOT_PASSWORD: root123456 MYSQL_DATABASE: app_db MYSQL_USER: app_user MYSQL_PASSWORD: app_pass networks: - lnmp-net restart: always networks: lnmp-net: driver: bridge

这里有几个容易被忽略的点,值得详细说:

  • version字段:Compose V2已经自动识别格式,写3.8是一种习惯性做法,实际可以不写或写版本号,不影响功能。
  • nginx和php的目录挂载:nginx挂载www到/usr/share/nginx/html,php挂载www到/var/www/html,这两个路径不同但没有冲突,因为nginx的root指令直接指向/usr/share/nginx/html。如果项目代码的入口需要走PHP解析,Nginx通过fastcgi_pass将请求转发给php-fpm容器,php-fpm再从自己的/var/www/html读取同一份代码文件。
  • MySQL环境变量:这是新建数据库时一次性设置用的。注意MYSQL_ROOT_PASSWORD是root用户的密码,另外几个变量用于创建应用账号和初始数据库。如果mysql/data目录已有数据(比如容器已初始化过),修改环境变量不会生效,因为MySQL只在首次初始化数据目录时读取这些环境变量。这一点经常有人混淆,改完密码发现不生效,就是因为数据目录已经存在。
  • restart: always:这个配置让容器在宿主机重启或容器异常退出时自动拉起,生产环境必备。

3.3 给服务分配依赖关系

上面配置中nginx有depends_on: php,这意味着启动nginx容器前会先启动php容器。但这里要明确一点:depends_on只控制启动顺序,不判断php服务是否真正就绪。也就是说,nginx容器启动后如果发现php容器还在初始化(比如PHP正在加载扩展),连接可能会暂时失败,但由于restart策略和Nginx的重试机制,实际使用中影响不大。

如果项目里php还需要连接mysql,那么依赖关系应该是php依赖mysql,nginx依赖php。可以在php服务上加上depends_on: mysql。这样编排启动顺序就是:先mysql,再php,最后nginx。编排文件里还要注意网络连通性——所有容器都在同一个自定义网络lnmp-net上,所以php容器可以直接用mysql作为主机名连接MySQL,nginx容器可以直接用php作为主机名连接PHP-FPM。

4. 核心环节二:各服务的配置细节

4.1 Nginx配置要点

先写一份最基本的default.conf,这个文件放在宿主机的./nginx/conf.d/下:

server { listen 80; server_name localhost; root /usr/share/nginx/html; 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; } location ~ \.php$ { fastcgi_pass php:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } location ~ /\.ht { deny all; } }

几个关键点的解释:

  • fastcgi_pass写的是php:9000而不是127.0.0.1:9000。因为php-fpm在独立的php容器里,Docker自定义网络中的服务名解析会自动把php解析为该容器的IP地址。这也是Docker Compose相对传统部署体验提升最大的地方——不用手动维护IP,服务名即主机名。
  • SCRIPT_FILENAME必须设置正确。$document_root对应的是Nginx容器内的站点根目录/usr/share/nginx/html,而$fastcgi_script_name是请求的PHP脚本路径。注意这里的$document_root和php容器内的/var/www/html不同,但fastcgi_param只是作为一个参数传给PHP-FPM,实际执行时会从php容器的文件系统里读取——所以php容器里的/www挂载必须和Nginx的root指向同一份物理文件。
  • try_files那一行用于支持伪静态,在ThinkPHP、Laravel这类框架里是必须的,否则访问非真实路径会404。

如果要用HTTPS,只需再增加一个443 server块,配置证书路径。证书文件可以放在宿主机目录并挂载进nginx容器。

4.2 PHP-FPM配置要点

PHP-FPM容器默认使用官方php镜像自带的配置,一般不需要额外改太多。但有两个高频问题和对应的解决办法:

  • PHP上传大小限制。默认upload_max_filesize只有2M。项目里涉及文件上传,这个限制必须改。解决方法:在宿主机./php/php.ini里写上:
upload_max_filesize = 20M post_max_size = 20M max_execution_time = 60 memory_limit = 256M

然后在docker-compose.yml的php服务volumes里挂载该文件(已经在3.2节写好了)。改动后重启php容器。

  • 缺少PHP扩展。比如项目需要pdo_mysql、gd、redis等。官方php镜像默认不带这些扩展,需要手动编译安装。最快的办法是写一个Dockerfile,在官方镜像基础上安装:
FROM php:8.2-fpm-alpine RUN docker-php-ext-install pdo_mysql mysqli opcache RUN apk add --no-cache freetype libpng libjpeg-turbo \ && docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-install gd COPY ./php/php.ini /usr/local/etc/php/php.ini

然后把docker-compose.yml里php的image改为build: ./php(即Dockerfile所在目录)。这样一来,每次docker compose up -d --build都会按Dockerfile构建出带好扩展的PHP镜像。

这里要特别提醒:docker-php-ext-install是官方镜像提供的便捷脚本,会自动完成下载、编译、安装,但依赖的系统库需要先用apk(Alpine的包管理器)装好。如果缺少系统库,编译会报错,这是新手最容易卡住的地方。安装gd扩展时比较明显,必须先装freetype、libpng、libjpeg-turbo这几个库。

4.3 MySQL初始化与权限

MySQL容器的初始化过程很特殊,值得单独说一下。

第一次启动时,容器检测到/var/lib/mysql目录为空,会用MYSQL_ROOT_PASSWORD等环境变量执行初始化:创建root用户、创建MYSQL_DATABASE指定的数据库、创建MYSQL_USER指定的用户并授权。如果数据目录里已经有了数据,容器会忽略这些环境变量,直接使用已有数据。

所以,如果你想修改root密码,正确方式是进入容器内执行:

docker exec -it lnmp-mysql mysql -uroot -p ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY '新密码'; FLUSH PRIVILEGES;

不建议移除mysql/data目录重新初始化,因为那会导致数据丢失,是一个很危险的操作。

为了保证远程连接和字符集正确,可以在宿主机./mysql/conf/my.cnf中配置:

[mysqld] character-set-server=utf8mb4 collation-server=utf8mb4_unicode_ci [client] default-character-set=utf8mb4

这里有一个细节:为什么MySQL 8.0要用utf8mb4而不是utf8?因为utf8在MySQL里实际是utf8mb3,最多只能存3字节,遇到某些特殊字符(比如部分emoji)会报错。utf8mb4才是完整的UTF-8编码,能存4字节字符,这是现代项目的标配。

另外提醒一点:MySQL 8.0默认使用caching_sha2_password认证插件,部分旧版PHP的mysql模块不支持。如果连接报认证错误,需要在容器里执行:

ALTER USER 'app_user'@'%' IDENTIFIED WITH mysql_native_password BY 'app_pass';

当然,如果你用较新的PHP(8.0+)配合mysqli/pdo_mysql扩展,一般直接支持,但兼容性还是提前确认一下更好。

5. 启动、验证与应用部署

5.1 首次启动与验证流程

环境文件都准备好了,执行命令启动:

cd /opt/lnmp docker compose up -d

就算不用-d参数,前台看日志更直观,但生产环境推荐-d后台运行。启动后依次验证:

  1. 查看容器状态:docker compose ps。三个容器都应该是Up状态。
  2. 确认Nginx转发正常:在./www/目录写一个最简单探针文件:
<?php phpinfo();

浏览器访问http://服务器IP/,如果能看到PHP信息页,说明Nginx到PHP-FPM的链路已经通了。

  1. 验证PHP连接MySQL。在www目录下写一个测试脚本:
<?php $pdo = new PDO('mysql:host=mysql;dbname=app_db', 'app_user', 'app_pass'); echo $pdo->query('SELECT 1')->fetchColumn();

输出1就说明PHP能连通数据库。这里的关键是dsn中的host必须写mysql,因为在Compose网络里,服务名就是主机名,不需要也不能写成127.0.0.1。

  1. 验证Nginx的80端口监听:ss -tlnp | grep :80,或者直接本地curl -I http://127.0.0.1。

这几步都通过,环境就算搭建完成。如果某个环节不通过,往下看排查章节。

5.2 日常操作与项目更新流程

日常开发中,最常见的操作是改代码后刷新页面。因为代码目录www被挂载到容器里,宿主机改文件,容器内立即生效(PHP-FPM会检查文件mtime),不需要重启任何容器。

改动php.ini等配置文件后,需要重启对应服务:

docker compose restart php

改动nginx配置后:

docker compose exec nginx nginx -s reload

注意,一般用reload而不是restart,因为reload是平滑重载,不会中断已有的HTTP连接。

更新镜像到新版本时,执行:

docker compose pull docker compose up -d

pull会从镜像仓库拉取最新版本,否则默认使用本地已有的旧镜像。

5.3 数据备份与恢复方案

MySQL数据在mysql/data目录里,最直接的备份方式是容器内执行mysqldump导出SQL文件:

docker exec lnmp-mysql mysqldump -uroot -p app_db > backup.sql

恢复时:

docker exec -i lnmp-mysql mysql -uroot -p app_db < backup.sql

mysqldump是逻辑备份,优点是文件小、可跨版本恢复(MySQL 8.0导出的数据可以导入MariaDB)。缺点是数据量大的时候恢复速度慢、导出过程会锁表。对于量级在GB以下的项目,mysqldump足够用了。

如果要做物理备份(直接复制整个mysql/data目录),必须先用docker compose stop mysql停掉容器,否则数据文件正在被写入,直接拷贝可能损坏。这一点我吃过大亏,不停止容器就cp数据目录,恢复时MySQL启动报错,最终折腾了半天才修好。

5.4 Nginx与PHP日志处理

日志默认输出到容器的stdout/stderr,但我们在卷挂载里把日志指到了宿主机。Nginx的访问日志和错误日志都落到./nginx/logs/下。PHP的日志默认写到容器的/var/log/php-fpm.log里面,如果找不到文件,直接在php.ini里增加一行:

error_log = /var/log/php/php-error.log

同时挂载php/logs目录到容器的/var/log/php/,这样PHP的错误日志也能在宿主机看到。调试时实时跟踪日志:

tail -f nginx/logs/error.log

日志里看到的PHP错误信息非常直接,比如xxx.inc.php not found、SQLSTATE[HY000] [2002] Connection refused这类,能帮你快速定位到问题源头。

6. 常见问题与排查技巧

6.1 容器启动失败的可能原因

先看错误日志,docker compose logs是最直接的入口。常见的启动失败原因有这几个:

  • 端口冲突:宿主机80或3306端口已被占用,容器启动报Bind for 0.0.0.0:80 failed: port is already allocated。解决方法是停掉占用端口的进程,或修改ports映射为其他端口,比如8080:80。
  • 挂载目录权限不足:MySQL容器启动时,如果宿主机的mysql/data目录没有权限(因为容器内的mysql用户UID是999),启动会崩溃。解决方法是给目录授权:
    chown -R 999:999 /opt/lnmp/mysql/data
  • 配置文件语法错误:Nginx配置文件写错导致容器反复重启。可以先本地验证语法:docker compose exec nginx nginx -t,如果显示syntax is ok表示配置正常。

6.2 PHP无法连接MySQL

很多人第一次操作时都会遇到SQLSTATE[HY000] [2002] Connection refused。原因是PHP和MySQL分布在两个容器,PHP容器内访问127.0.0.1根本不通。正确方式是使用MySQL服务名mysql作为主机名。另外还需要检查:

  1. 三个容器是否在同一个网络(docker inspect lnmp-php | grep NetworkMode,查看是否有lnmp-net)。
  2. MySQL容器内的用户和权限是否正确。可以进入MySQL容器执行SHOW GRANTS FOR 'app_user'@'%';确认该用户有权限访问app_db。
  3. 如果数据库服务长时间没启动,PHP连接超时,观察mysql容器是否正常运行(docker compose ps)。

6.3 改了配置但没生效

改动Nginx配置后没有reload,或者改了php.ini后没重启PHP-FPM容器,导致配置不生效。很多人踩过类似的坑,需要明确两个容器的配置生效方式:

  • Nginx配置:docker compose exec nginx nginx -s reload
  • PHP配置:docker compose restart php

另外需注意挂载文件权限:php.ini挂载为只读(:ro)没有影响,但如果宿主机的php.ini本身有权限问题,容器可能无法读取。最好确认宿主机文件的权限和属主正确。

6.4 容器重启导致数据丢失

重启容器、重建容器(docker compose up -d --build)都不会丢数据,因为数据卷挂载在宿主机上。但如果执行docker compose down会把容器和网络删掉(不删卷),再up -d时会重新创建容器,但挂载的宿主机数据仍在,不会丢失。

真正会丢数据的是docker volume prune这类命令,或者直接删除mysql/data目录。云服务器的磁盘扩容、重装系统等操作也有风险,所以备份策略一定要提前落实。我的建议是:在mysql/data目录之外,每天定时做一次mysqldump导出,老环境备份到对象存储,这样就算整台服务器报废,也能在几小时内恢复到最新状态。

6.5 镜像拉取慢或失败

国内网络环境下,拉取Docker Hub镜像经常超时。比较实用的办法是配置镜像加速器或使用代理镜像。修改/etc/docker/daemon.json:

{ "registry-mirrors": ["https://your-mirror.mirror"] }

然后重启docker服务。如果这个方案也不行,可以考虑基于现有镜像构建自己的镜像并推送到私有仓库,用的时候从私有仓库拉取会快很多。

7. 安全加固与性能调优

7.1 基本安全配置

  • MySQL密码不要写在compose文件里明文存储。生产环境建议使用.env文件,然后在docker-compose.yml里通过${MYSQL_ROOT_PASSWORD}引用,并且把.env加入.gitignore。这里说得再细一点:.env文件就是键值对,Compose启动时会自动读取同目录下的.env文件。

.env文件示例:

MYSQL_ROOT_PASSWORD=你的复杂密码 MYSQL_DATABASE=app_db MYSQL_USER=app_user MYSQL_PASSWORD=应用密码

docker-compose.yml中environment部分改成:

environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: ${MYSQL_DATABASE} MYSQL_USER: ${MYSQL_USER} MYSQL_PASSWORD: ${MYSQL_PASSWORD}

这样一来,密码不会出现在docker-compose.yml文件里,即使仓库被误提交,也只能看到变量名。

  • MySQL端口不要暴露公网。如果MySQL仅供内网容器使用,可以把ports部分删掉,或者把端口映射改成127.0.0.1:3306:3306。因为云服务器的公网IP直接暴露3306端口,等于把数据库大门敞开,暴力破解是全自动进行的。
  • Nginx容器不要以root运行。官方Nginx镜像默认以root启动master进程,worker进程以nginx用户运行。如果要进一步收紧,可以在compose文件里加user: nginx,但要注意挂载目录需要有对应权限。

7.2 性能调优方向

这套LNMP环境默认性能已经不错,但还有几个值得调整的点:

  • PHP-FPM进程数。默认配置是按CPU核数自动调节,但可以在php镜像的/etc/usr/local/etc/php-fpm.d/www.conf里手动调整pm.max_children、pm.start_servers、pm.min_spare_servers、pm.max_spare_servers。这些参数的设置参考内存大小:每个php-fpm进程大约占用30-50MB内存,如果服务器有4GB内存,max_children设为50左右比较合理。
  • Nginx的keepalive和gzip。Nginx配置中开启gzip压缩可以明显减小传输体积,特别是文本类资源。keepalive超时设到65s,能提高复用效率。
  • MySQL的innodb_buffer_pool_size。这是MySQL性能的核心,同样是内存换性能的思路,一般设为物理内存的50%-70%为宜。改动my.cnf后重启mysql容器即可生效。

这几个点做下来,配合4核8GB的入门级云服务器,支撑一个日活几千的小型站点是完全够用的。

8. 写在最后的实操心得

这一路用Docker Compose部署LNMP,最大的体会是:环境搭建本身不难,难的是理解容器之间的配合关系。只要理清了网络(服务名互访)、挂载(代码和配置放宿主机)、持久化(数据目录在宿主机)这三件事,LNMP容器化部署的基本功就算掌握了。

再分享一个个人习惯:每个项目的Compose编排文件和配置我都放Git仓库,并在README里写清楚启动命令和默认账号。这样不管是自己隔几个月回来看,还是接手同事的服务器,都不会花太多时间在环境重建上。

还有一个小技巧:碰到.htaccess伪静态配置让Nginx返回404时,先检查try_files配置是否正确,这是Nginx支持各类框架伪静态的基础。曾经有个项目因为漏了try_files,导致所有非根路径都404,排查了很久才找到原因。这种问题属于配置细节,不仔细看很容易忽略。

从零开始迁移到Docker Compose以后,同一个LNMP环境可以在任何机器上五分钟重建完成——这比手工部署节省的时间不止一点点。如果你还在被环境问题反复折腾,不妨把这套方案拿过去跑一跑,体验一下容器化部署的稳定感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询