Moodle生产环境部署:从LNMP配置到性能调优与备份回滚 1. 部署前的整体规划与选型思路Moodle 生产环境部署这件事我前后完整走过不少遍从最初照着文档一步步抄命令到后来能把整个流程压缩到半天之内、并且知道每个参数为什么这么填中间踩的坑基本都集中在“能打开”和“扛得住”之间那段距离上。很多人第一次部署 Moodle会在本地虚拟机里跑一遍安装向导看到首页出来就以为结束了结果搬到有真实用户的生产环境上第一天课程导入就把 PHP 进程打满第二天上传课件报 413第三天定时任务没跑导致邮件全堵在队列里。这篇内容就是把这些环节串成一条完整的线从一台空服务器开始到域名解析、HTTPS、缓存调优、定时任务、备份回滚最后能支撑一个真实的教学场景对外提供服务。适合两类读者一类是第一次要把 Moodle 从测试环境搬到正式环境的运维或技术负责人另一类是自己搭过但总觉得哪里不对劲、想系统梳理一遍的中级使用者。文中涉及的命令和配置都可以直接抄改遇到需要按自己机器规格计算的地方我会把计算过程写清楚。1.1 先把“跑通”和“扛住”当成两件不同的事我见过太多部署记录把这两件事混在一起写导致读者误以为安装向导走完就等于部署完成。实际上这是两条完全不同的路径。第一条是功能路径Web 服务器能执行 PHP、PHP 能连上数据库、Moodle 的代码能找到数据目录、安装向导能建表并写入初始数据。这条路径的判断标准非常明确——浏览器打开首页看到登录框没有报错。第二条是容量与可靠性路径并发上来之后 PHP 进程池够不够、OPcache 有没有生效、数据库缓冲池是否匹配内存、上传大文件时各层的限制是否对齐、定时任务是否准时、备份能不能在限定时间内完成。这条路径没有“完成”这个状态只有“当前规模下是否够用”。把这两条分开的意义在于排查思路。用户反馈“打不开”的时候你要先判断是功能路径断了还是容量路径断了。功能路径断了的典型表现是所有人同时打不开、报 500 或 502、日志里有明确的 PHP 致命错误容量路径断了的典型表现是高峰期慢、部分请求超时、PHP-FPM 日志里出现pool www seems busy。两种问题的处理方向完全相反前者要去翻错误日志后者要去算资源和调参数。我个人的习惯是部署时先只保证功能路径通把站点跑起来、能登录、能建一门课、能上传一个小文件确认无误之后再进入调优环节一次只改一类参数改完压一下再改下一类。这样出问题时能立刻定位到是哪次改动引入的。1.2 组件版本对照与选型理由Moodle 对运行环境有明确的版本区间超出区间不是不能跑而是官方不再保证行为一致升级时容易撞上没人踩过的坑。下面这张表是我在几个项目里实际用过的组合都是长期支持分支社区里能搜到的答案最多遇到问题不至于孤立无援。组件推荐版本选择理由与注意事项操作系统Ubuntu 22.04 LTS 或 24.04 LTS软件源里的 PHP 和数据库版本较新安全更新周期长包管理命令统一Web 服务器Nginx 1.18 及以上静态资源处理效率高配置语义清晰配合 PHP-FPM 的 socket 模式延迟低PHP8.1配 4.1 LTS或 8.2配 4.3 及以上版本要对齐 Moodle 主版本的支持矩阵不要跨两个大版本硬上数据库MySQL 8.0 或 MariaDB 10.6默认字符集已经是 utf8mb4省掉很多历史包袱MariaDB 在同等配置下内存占用略低缓存可选Redis 6/7多台应用服务器共享会话和缓存时必须要单机部署用文件缓存也够关于 PHP 版本这里有一个常被忽略的细节Moodle 的很多第三方库是通过 Composer 引入的这些库对 PHP 版本有自己的下限要求而 Moodle 官方支持矩阵给出的是一个区间。实践中的做法是取两者的交集并且优先选区间里偏新的偶数小版本因为奇数版本属于开发分支生命周期短。我吃过一次亏为了用某个新语法把 PHP 提到了超出支持矩阵的版本安装过程一切正常直到某天启用一个活动模块插件里用了已被移除的函数整页白屏日志里只有一行Call to undefined function翻遍社区也找不到同类案例最后只能降版本重装。数据库方面单机部署 MySQL 和 MariaDB 差别不大但如果你的场景里已经有其他系统在用其中一种统一技术栈能减少运维负担。字符集一定要在建库阶段就定成utf8mb4不要等到数据写进去再改那个迁移过程的痛苦程度和表的数量成正比。1.3 外网可达这件事到底卡在哪几步“可外网访问”听起来像一句话实际上要穿过四道关卡任何一道没通表现都是“浏览器转圈然后超时”但原因完全不同。我把它列成一条固定的排查顺序每次新部署都按这个顺序走一遍能省掉大量瞎猜的时间。第一道是域名解析。域名的 A 记录要指向服务器的公网地址生效时间取决于 TTL 设置刚改完的时候不同地区解析结果可能不一致。验证方法是换几个不同的公共 DNS 查询工具看结果或者在服务器上用dig查自己的域名确认返回的地址和实际公网地址一致。第二道是云平台或机房侧的入方向规则。这是新手最容易漏掉的一环服务器上防火墙全开、Nginx 监听正常但外部就是连不上因为流量在更外层就被拦了。80 和 443 两个端口必须放行如果你把 SSH 也改了端口记得同步更新规则。第三道是主机自身的防火墙。用ufw或firewalld的话要显式放行端口并且确认规则是持久化的重启后依然生效。可以用ss -tlnp看监听状态确认 Nginx 监听的是0.0.0.0:443而不是127.0.0.1:443——后者只对本机开放外部永远连不上这个错误我在别人的配置里见过不止一次。第四道是应用层配置。Moodle 的wwwroot必须和你实际访问的地址完全一致包括协议、域名、有没有带端口号。写的是http://而实际用https://访问会出现样式和脚本加载失败、登录后跳回登录页这类诡异现象因为浏览器把 HTTPS 页面里的 HTTP 资源当成混合内容拦掉了。这四道关卡我建议做成一张检查清单部署时逐项打勾比事后一个个猜要快得多。2. 系统底座与运行环境的落地细节底座这部分最容易被跳过因为命令都很短看起来没什么技术含量。但我处理过的故障里有相当一部分根因在这里时区没设导致证书校验和定时任务时间错乱、swap 没开导致内存峰值时 PHP 进程被内核直接杀掉、文件描述符限制太低导致高并发下连接被拒。这些问题的共同特点是不报错、只是偶发异常排查成本极高。所以这部分我会写得细一点每条命令后面都说明它在解决什么问题。2.1 服务器初始化与安全基线拿到一台干净的服务器第一件事不是装软件而是把基础环境固定下来。我用的是下面这一组操作顺序有讲究先建普通用户再禁用 root 登录避免把自己锁在外面。# 更新系统与时间同步 apt update apt -y upgrade timedatectl set-timezone Asia/Shanghai apt -y install chrony systemctl enable --now chrony # 创建运维账号并配置密钥登录 adduser deploy usermod -aG sudo deploy mkdir -p /home/deploy/.ssh chmod 700 /home/deploy/.ssh # 把公钥写入 authorized_keys 后修正权限 chown -R deploy:deploy /home/deploy/.ssh chmod 600 /home/deploy/.ssh/authorized_keys # 调整 SSH禁用密码登录与 root 直连 sed -i s/^#\?PermitRootLogin.*/PermitRootLogin no/ /etc/ssh/sshd_config sed -i s/^#\?PasswordAuthentication.*/PasswordAuthentication no/ /etc/ssh/sshd_config systemctl restart sshd # 防火墙只放行必要端口 ufw default deny incoming ufw allow 22/tcp ufw allow 80/tcp ufw allow 443/tcp ufw --force enable时区这一条单独说一下。Moodle 内部有自己的时区处理逻辑但底层依赖系统的时区数据。系统时区不对日志时间戳和实际时间错位排查问题时会被误导更隐蔽的是某些定时任务和证书校验逻辑会因此出现偏差。我在一个项目里遇到过课程开放时间比预期早八小时生效的问题查了两天才定位到时区。swap 的配置取决于内存大小。对于 8G 内存的机器我会开 4G swap并把vm.swappiness调到 10。理由是 Moodle 的内存占用峰值通常出现在课程备份、报表导出、批量导入这类操作平时用不到那么多开了 swap 相当于给峰值留一个缓冲不至于触发内核的 OOM 直接杀进程。swappiness 调低是为了让内核尽量不用 swap只在真正吃紧的时候才用避免日常运行就被换页拖慢。文件描述符限制同样重要。Nginx 和 MySQL 在高并发下会打开大量文件句柄默认的 1024 往往不够典型症状是日志里出现Too many open files。在/etc/security/limits.conf里给对应用户加上nofile 65535同时确认 systemd 服务单元里的LimitNOFILE也调高了因为通过 systemd 启动的服务不读 limits.conf。提示改完 SSH 配置后不要立刻断开当前连接另开一个终端验证新配置能登录成功再关闭旧连接。这一步能避免把自己锁在服务器外面。2.2 PHP 版本与扩展的取舍Moodle 需要的 PHP 扩展清单在官方文档里能查到但文档只是列出“需要”不会告诉你哪些是可选的、哪些缺了会直接装不上、哪些缺了只是少个功能。我按实际影响分成三类装的时候心里有数。扩展缺失后果说明mysqli / pdo_mysql安装向导第一步就失败连数据库的基础必须有mbstring中文内容出现乱码或截断多字节字符串处理中文场景必装xml / dom / simplexml安装时报解析错误大量配置文件和数据交换依赖 XMLcurl无法访问外部服务、插件市场打不开站点间通信、备份到外部存储都靠它gd 或 imagick用户头像上传失败、缩略图不生成图像处理至少装一个gd 依赖更少zip备份和课件包无法打包解包课程备份功能强依赖intl部分语言包和日期格式化异常国际化支持多语言站点必装soap某些外部认证插件不可用按需用不到可以不装bcmath / sodium部分加密与计算功能异常建议装上成本低opcache性能损失一大截严格说不算扩展而是缓存但生产环境必开版本选择上我不建议用系统默认源之外的第三方源除非你有明确的版本需求。系统源的包经过发行版维护者测试和系统库的兼容性最好升级时也不会出现依赖冲突。如果你确实需要特定小版本用apt-cache policy php8.1-fpm先看看有哪些可选版本再决定要不要加源。安装时把 FPM 和 CLI 两个包都装上。FPM 是给 Web 请求用的CLI 是给定时任务和命令行工具用的两者读取的php.ini路径不同很多人只改了 FPM 的配置结果定时任务跑起来还是报内存不足因为 CLI 那边memory_limit还停在默认值。这一点在调优章节还会再提。2.3 数据库初始化与字符集建库这一步有两条命令值得单独讲因为它们的参数一旦选错后面很难改。CREATE DATABASE moodle DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; CREATE USER moodleuser127.0.0.1 IDENTIFIED BY 换成你自己的强密码; GRANT ALL PRIVILEGES ON moodle.* TO moodleuser127.0.0.1; FLUSH PRIVILEGES;字符集必须是utf8mb4不是utf8。MySQL 里的utf8实际只支持最多三个字节的字符存不下四字节字符比如某些符号和生僻字用户提交的内容里一出现这类字符就会被截断或者报错。这个坑的隐蔽之处在于大部分中文内容是三字节的测试阶段完全正常直到某个用户粘贴了一段带特殊符号的文本才暴露。排序规则选utf8mb4_unicode_cici表示大小写不敏感符合大多数搜索和比较场景的直觉。不建议用utf8mb4_general_ci它的比较规则在部分语言下不够准确也不建议用utf8mb4_bin那是按字节比较搜索行为会变得很奇怪。用户授权的主机名写127.0.0.1而不是%这样数据库只接受本机连接。如果应用和数据库分离部署就写应用服务器的内网地址不要图省事写%。数据库端口暴露在公网是极其危险的事我见过有人为了方便调试把 3306 开到公网几天后数据库被清空勒索。分离部署时用内网地址加防火墙白名单是成本最低的防护。MySQL 8.0 的默认认证插件是caching_sha2_passwordPHP 8.1 及以上版本的 mysqli 扩展原生支持不需要额外改配置。如果你的 PHP 版本较老连不上与其改数据库的认证插件不如升级 PHP因为改认证插件等于降级了安全强度。3. Moodle 本体安装与配置文件手写代码放到服务器上有两种方式下载压缩包解压或者用 Git 拉取。生产环境我倾向于用压缩包配合版本号命名目录升级和回滚都直观需要频繁打补丁或者跟随开发分支的场景才用 Git。无论哪种方式目录结构和权限的规划都是一样的这部分做对了后面能少掉一半的“权限不足”报错。3.1 目录规划与权限设计Moodle 有两个必须分开的目录代码目录和数据目录。代码目录放程序文件数据目录放用户上传的文件、缓存、日志、临时文件。分开的理由不只是整洁更关键的是升级时可以直接替换代码目录而不动用户数据以及备份时可以单独对数据目录做增量同步。我用的路径方案是代码放/var/www/moodle数据放/var/moodledata。数据目录必须放在 Web 根目录之外这一点是硬性要求——如果数据目录能被浏览器直接访问任何人只要猜到文件名就能下载其他用户上传的作业和试卷这是真实发生过的安全事故。Nginx 配置里我也会显式禁止访问这个路径做双保险。# 代码目录 mkdir -p /var/www/moodle cd /var/www/moodle # 解压对应版本的压缩包注意解压后文件直接位于该目录下 # 数据目录 mkdir -p /var/moodledata chown -R www-data:www-data /var/moodledata chmod -R 2770 /var/moodledata # 代码目录归属 chown -R deploy:www-data /var/www/moodle find /var/www/moodle -type d -exec chmod 2755 {} \; find /var/www/moodle -type f -exec chmod 0644 {} \;这里有几个细节值得展开。2770里的2是 setgid 位作用是新创建的子目录自动继承父目录的属组这样 Web 进程以www-data身份写入的文件属组始终一致不会出现“某些文件属主是 root、某些是 www-data导致有的能改有的不能改”的混乱局面。代码目录的2755同理目录需要有 setgid 保证新建文件的属组正确。代码目录的属主设为运维账号、属组设为 Web 用户这个组合的好处是日常改配置文件用运维账号就行不用sudoWeb 进程有读权限能正常执行但 Web 进程没有写权限即使程序存在漏洞也无法篡改自身代码。唯一需要写权限的是config.php安装时生成和少数几个缓存目录后者在安装过程中会自动处理。注意不要图省事把整个代码目录chmod -R 777。这不只是安全问题还会导致某些文件被 Web 进程意外修改后后续升级时版本比对失败升级脚本拒绝执行。3.2 config.php 逐项手写与参数含义安装向导会自动生成config.php但我更推荐先手写一份再跑安装。原因有二手写的过程强制你确认每个参数的含义安装失败时能明确区分是配置问题还是环境问题。下面这份是骨架实际使用把域名、密码换成自己的。?php unset($CFG); global $CFG; $CFG new stdClass(); $CFG-dbtype mysqli; $CFG-dblibrary native; $CFG-dbhost 127.0.0.1; $CFG-dbname moodle; $CFG-dbuser moodleuser; $CFG-dbpass 你的数据库密码; $CFG-prefix mdl_; $CFG-dboptions [ dbpersist 0, dbport 3306, dbsocket , dbcollation utf8mb4_unicode_ci, ]; $CFG-wwwroot https://learn.example.com; $CFG-dataroot /var/moodledata; $CFG-admin admin; $CFG-directorypermissions 02777; require_once(__DIR__ . /lib/setup.php);逐项说一下容易出错的地方。dbhost写127.0.0.1而不是localhost前者强制走 TCP 连接后者会尝试用 Unix socket如果 socket 路径和 PHP 里配的不一致就会得到“无法连接数据库”这种含糊的报错。dbpersist设 0 关闭持久连接因为持久连接在 PHP-FPM 模式下容易造成连接泄漏长时间运行后数据库连接数只增不减最后还是得重启进程池。wwwroot必须和实际访问地址严格一致。这里有个隐含的坑如果你先用 HTTP 部署测试后续再上 HTTPSwwwroot要同步改成https://否则会出现登录后跳回登录页、后台保存设置无效这类现象。建议从一开始就规划好最终地址直接用 HTTPS 部署。dataroot用绝对路径不要用相对路径。且这个目录的权限必须是 Web 用户可读写否则上传功能会静默失败——用户看到的是“上传完成”实际上文件根本没落地。directorypermissions设成02777是官方推荐值作用是新建目录时保证组内可写多进程环境下不会因为权限不一致导致写入失败。当 HTTPS 在外层终结、PHP-FPM 通过 socket 接收请求时还要确保 Nginx 把“当前是 HTTPS”这个信息透传给 PHP具体做法在 Nginx 配置里加一行fastcgi_param HTTPS on;。漏了这一行Moodle 会认为自己在 HTTP 环境下运行生成的链接全是http://页面加载一半被浏览器拦截。3.3 命令行安装与外网首访验证图形化安装向导能跑但命令行更可控尤其是在远程服务器上浏览器操作容易因为网络波动中断。命令行安装把所有参数一次给全失败时能直接看到错误输出。cd /var/www/moodle sudo -u www-data /usr/bin/php admin/cli/install.php \ --wwwroothttps://learn.example.com \ --dataroot/var/moodledata \ --dbtypemysqli \ --dbhost127.0.0.1 \ --dbnamemoodle \ --dbusermoodleuser \ --dbpass你的数据库密码 \ --fullname示例教学平台 \ --shortnamedemo \ --adminuseradmin \ --adminpass换成强密码 \ --non-interactive \ --agree-license管理员密码有复杂度要求至少八位包含大写字母、小写字母、数字和至少一个非字母数字字符。不满足的话安装会卡在这一步报错而这个错误提示在非交互模式下不太显眼容易被误判成其他问题。安装完成后的首访验证我会按这个顺序走一遍每一步确认无误再走下一步服务器本地用curl -I https://learn.example.com看返回状态码和响应头确认 200 且没有证书警告浏览器访问确认页面样式完整说明静态资源加载正常、没有混合内容警告用管理员账号登录进后台看“站点管理”是否有环境检查的警告项新建一门测试课程上传一个小文件确认数据目录写入正常手动跑一次定时任务确认无报错。这五步都过了功能路径就算通了。剩下的是把“能用”变成“好用”也就是下一章的内容。4. Web 服务与 HTTPS 的完整配置Nginx 的配置文件看起来长但真正关键的行数不多。我见过很多配置是从网上抄来的堆了一大堆缓存和安全头实际上有几行互相冲突导致某些功能时好时坏。这一章我把配置拆成三段讲基础转发、HTTPS 协商、访问限制每一段都说明为什么这么写。4.1 核心 server 块的逐行解释完整的配置块如下我把它放在/etc/nginx/sites-available/moodle.conf再软链到sites-enabled。server { listen 80; server_name learn.example.com; return 301 https://$host$request_uri; } server { listen 443 ssl http2; server_name learn.example.com; root /var/www/moodle; index index.php index.html; ssl_certificate /etc/letsencrypt/live/learn.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/learn.example.com/privkey.pem; client_max_body_size 512m; add_header X-Content-Type-Options nosniff; add_header X-Frame-Options SAMEORIGIN; add_header Referrer-Policy strict-origin-when-cross-origin; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ [^/]\.php(/|$) { fastcgi_split_path_info ^(.\.php)(/.)$; fastcgi_pass unix:/run/php/php8.1-fpm.sock; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; fastcgi_param HTTPS on; fastcgi_read_timeout 300; } location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?|ttf)$ { expires 30d; access_log off; try_files $uri 404; } location ~ ^/(\.git|\.env|composer\.(json|lock)|node_modules) { deny all; } location ^~ /moodledata/ { deny all; } }逐段说明。第一个 server 块只做一件事把 HTTP 请求永久重定向到 HTTPS$request_uri保留原始路径和查询参数这样用户从旧链接进来不会丢参数。第二个块里client_max_body_size 512m决定了 Nginx 允许的最大请求体。这个值和 PHP 的upload_max_filesize、post_max_size必须协调一致三者取最小值才是实际生效的上传上限。很多人只改了 PHP 的配置结果上传大文件时报 413因为请求在到达 PHP 之前就被 Nginx 拒了。fastcgi_split_path_info和PATH_INFO这两行是给 Moodle 的路由用的。Moodle 有些脚本通过路径附加参数来定位资源没有这两行会出现“页面能打开但部分功能报 404”。fastcgi_read_timeout 300是给耗时操作留的余量。课程备份、报表生成、大文件导入这些操作可能跑几分钟默认的 60 秒会提前断开连接用户看到的是“页面加载失败”但后台任务其实还在跑造成状态不一致。静态资源那个 location 块expires 30d让浏览器缓存静态文件减少重复请求。这里有个前提Moodle 升级后静态文件会带版本参数所以长缓存不会导致用户看到旧样式。try_files $uri 404是为了防止路径穿越找不到文件直接返回 404 而不是继续往下匹配。最后两个 deny 块是安全兜底。.git目录如果被访问到整个代码仓库的提交历史就暴露了数据目录被访问到等于所有用户文件公开。这两个路径必须显式拒绝。4.2 证书申请与自动续期证书我用 Lets Encrypt 配合 certbot免费且自动化程度高。签发之前要确认两件事域名解析已经生效80 端口能从外网访问——因为验证过程需要外部能访问到你的服务器。apt -y install certbot python3-certbot-nginx certbot --nginx -d learn.example.com --redirect --agree-tos -m 你的邮箱--redirect会自动帮你加上 HTTP 到 HTTPS 的跳转规则如果前面手写了 server 块这一步可以省略。签发完成后用certbot renew --dry-run测试续期流程这条命令能提前发现续期会失败的情况比如验证路径被防火墙拦了、Nginx 配置有语法错误。自动续期靠系统自带的定时任务certbot 装包时会自动创建。续期成功后需要重载 Nginx 才能生效新版 certbot 会自动处理老版本需要手动加一个 deploy hook。我习惯显式加一个certbot renew --deploy-hook systemctl reload nginx证书有效期是三个月续期在到期前三十天开始尝试。日志在/var/log/letsencrypt/定期看一眼有没有失败记录比等到证书过期、用户报“网站不安全”要主动得多。注意证书路径里的域名要和server_name完全一致。改过域名之后忘了重新签发Nginx 启动会直接报错因为找不到对应的证书文件。4.3 大文件上传的链路对齐上传失败是部署后最高频的反馈之一而且同一个现象可能由四个不同层的原因导致。我把整条链路和对应的参数列成一张表出问题时从上往下查。层级参数配置位置典型症状浏览器侧无无超大文件在前端就提示超限Nginxclient_max_body_sizeserver 块413 Request Entity Too LargePHPupload_max_filesizephp.iniFPM 与 CLI 都要改文件上传后大小变成 0 或截断PHPpost_max_sizephp.iniPOST 数据被丢弃页面空白PHPmax_execution_timephp.ini大文件上传到一半超时Moodle站点管理里的最大上传限制后台界面界面上限比实际能力小数据目录磁盘权限与剩余空间文件系统上传完成但文件不存在实际配置时我一般把 Nginx 设成 512mPHP 的upload_max_filesize设 512m、post_max_size设 520m比上传上限略大因为 POST 里还有表单字段和其他数据max_execution_time设 300。Moodle 后台的“最大上传文件大小”读的是 PHP 的配置一般不用单独改但如果之前手动设过一个小值会覆盖掉 PHP 的设置这一点要检查。还有一个容易漏的点php.ini有两份FPM 用的在/etc/php/8.1/fpm/php.iniCLI 用的在/etc/php/8.1/cli/php.ini。Web 上传走 FPM但课程备份、批量导入这些通过命令行触发的操作走 CLI。只改一份的话会出现“网页上传正常但命令行导入失败”这种割裂现象。我的做法是两份都改改完分别用php -i | grep upload_max_filesize和建一个phpinfo页面确认生效。5. 性能调优把并发撑起来的几组参数调优这件事最忌讳凭感觉。我见过有人把 PHP-FPM 的pm.max_children直接设成 200理由是“越大越好”结果内存被吃光系统开始疯狂换页响应时间反而从 200ms 涨到 5 秒。正确的做法是先算清楚资源账再按计算结果设置最后用实际压测验证。这一章的三组参数是相互关联的改任何一组都要重新核对另外两组。5.1 PHP-FPM 进程数的计算过程pm.max_children决定了同时能处理多少个 PHP 请求是影响并发能力的核心参数。计算逻辑很朴素可用内存除以单个进程的内存占用。先测单进程内存。启动服务后用ps --no-headers -o rss -C php-fpm8.1 | awk {sum$1} END {print sum/NR/1024 MB}看平均值。但平均值不够Moodle 的进程内存波动很大普通页面请求可能只占 40MB课程备份时能冲到 150MB。我一般取实际观察到的峰值或者用平均值乘以 1.8 作为估算。以一台 8G 内存的机器为例系统和其他服务预留 1.5GMySQL 分配 3G剩下 3.5G 给 PHP。单进程按 80MB 算3500 / 80 ≈ 43取整设 40留一点余量。; /etc/php/8.1/fpm/pool.d/www.conf pm dynamic pm.max_children 40 pm.start_servers 8 pm.min_spare_servers 4 pm.max_spare_servers 12 pm.max_requests 800pm dynamic适合大多数场景进程数按需增减。start_servers设成max_children的 20% 左右启动时就有一定处理能力不至于第一个请求还要等进程创建。max_requests设 800 是为了防止内存泄漏累积——PHP 本身不泄漏但某些扩展和第三方库会定期回收进程能规避这个问题。这个值太小会频繁重建进程增加开销太大则失去意义800 到 1000 是我常用的区间。pm static适合流量平稳且内存充足的场景进程数固定没有创建销毁开销响应更一致。但如果你不确定流量模型先用 dynamic。改完配置要systemctl reload php8.1-fpm然后看日志确认没有报错。压测可以用ab或wrk重点看两个指标请求的成功率和响应时间的 95 分位。如果出现大量 502说明进程数不够或者有其他瓶颈如果响应时间随并发上升急剧恶化说明瓶颈在数据库或磁盘 IO加 PHP 进程没用。5.2 OPcache 与 Moodle 应用层缓存OPcache 把编译后的 PHP 字节码缓存在内存里省掉每次请求的编译开销。Moodle 的代码文件数量很大官方数据是上万个文件不开 OPcache 的话单次请求可能要多花几百毫秒。; /etc/php/8.1/fpm/php.ini opcache.enable1 opcache.memory_consumption256 opcache.interned_strings_buffer32 opcache.max_accelerated_files20000 opcache.validate_timestamps0 opcache.revalidate_freq0 opcache.save_comments1max_accelerated_files这一项必须调大。默认值是 10000Moodle 的文件数往往超过这个数超出部分不会被缓存表现为“部分页面快、部分页面慢”。设成 20000 能覆盖绝大多数安装。这个值是哈希表的容量提示设大了不会真的占用那么多内存所以宁可往大了设。validate_timestamps0表示不检查文件修改时间性能最好。代价是改代码之后必须手动重载 FPM 才生效。生产环境这个取舍是值得的因为代码变更本来就该走发布流程。但要注意如果你在后台通过界面上传插件插件文件不会被立即加载需要重载一次。这一点在装插件时容易被当成“插件装失败了”。save_comments1必须开着。很多 PHP 框架和库依赖注解docblock comment来做运行时配置关掉注释会导致某些功能异常而且报错信息完全看不出关联。Moodle 自己还有一层缓存在“站点管理 → 插件 → 缓存”里配置。默认用文件缓存能用但效率一般。如果装了 Redis可以把默认缓存存储切过去多个缓存定义共用同一个 Redis 实例通过前缀区分。切换之后要清一次缓存并观察一段时间确认没有出现缓存不一致导致的数据错乱。我个人的经验是单机部署、访问量不大时文件缓存完全够用不必为了“看起来专业”而上 Redis多一个组件就多一个故障点。5.3 数据库参数与连接数匹配MySQL 的调优项很多但对 Moodle 这种读多写少的负载真正影响大的就那么几个。# /etc/mysql/mysql.conf.d/mysqld.cnf innodb_buffer_pool_size 4G innodb_log_file_size 1G innodb_flush_log_at_trx_commit 2 innodb_file_per_table 1 max_connections 200 table_open_cache 4000innodb_buffer_pool_size是最重要的一个作用是缓存数据和索引。经验值是物理内存的 50% 到 70%但前提是这台机器只跑数据库。如果和应用混部就要按前面算的分配来。8G 内存的混部机器给 3G 比较合适。innodb_log_file_size设大一点能减少检查点刷盘频率提升写入性能。1G 是常见值但要注意修改这个参数需要先停库MySQL 8.0 之后可以动态调整改完观察错误日志确认没有异常。innodb_flush_log_at_trx_commit 2是一个权衡每次事务提交不强制刷盘由操作系统每秒刷一次。性能提升明显代价是极端情况下操作系统崩溃或断电可能丢失最后一秒的数据。对于教学平台这种场景我认为这个取舍可以接受因为它丢的是“最后一秒”而不是历史数据。如果业务对数据完整性要求极高就设成 1。max_connections要和 PHP-FPM 的max_children匹配。最坏情况是每个 PHP 进程都占一个数据库连接所以max_connections要比max_children大一些留出管理连接和后台任务的余量。前面 PHP 设了 40这里设 200 是绰绰有余的。设太大反而有风险因为每个连接都要占内存连接数打满时数据库可能直接拒绝服务。table_open_cache这一项容易被忽略。Moodle 的表数量多几百张加上前缀和临时表默认的 2000 在高并发下不够用表现为Opened tables频繁增长、查询变慢。设成 4000 能明显改善。改完参数重启 MySQL用SHOW STATUS LIKE Threads_connected观察实际连接数用SHOW ENGINE INNODB STATUS看缓冲池命中率。命中率低于 95% 说明缓冲池偏小可以考虑加大或者优化查询。5.4 定时任务与文件存储策略Moodle 的很多功能依赖定时任务邮件发送、成绩计算、备份清理、外部系统同步。任务不跑表现是“邮件延迟几小时才收到”“备份文件堆满磁盘”。# /etc/cron.d/moodle * * * * * www-data /usr/bin/php /var/www/moodle/admin/cli/cron.php /dev/null 21几个要点。第一用www-data身份跑因为任务要读写数据目录和上传文件用 root 跑会产生属主为 root 的文件后续 Web 进程改不了。第二频率是一分钟一次Moodle 内部会判断哪些任务到期不需要自己拆分。第三输出重定向到/dev/null避免每小时收到几十封 cron 邮件。如果确实需要日志重定向到文件并配置日志轮转。验证任务是否在跑进后台的“计划任务”页面看每个任务的“上次运行时间”是不是在最近几分钟内。如果全是“从未运行”说明 cron 根本没执行检查 cron 服务状态、文件权限、PHP 路径是否写对。文件存储方面单机部署直接放本地磁盘就行。但有几个细节要注意数据目录所在分区要留够空间课程备份、学生作业、视频课件都会吃磁盘。我一般会单独挂一个数据盘给/var/moodledata这样即使日志或者数据库把系统盘写满用户数据也不受影响。另外df -h要加到日常巡检里磁盘使用率超过 80% 就该清理了满了之后 Moodle 会直接报错而且错误信息五花八门不容易联想到磁盘。6. 常见故障排查实录排查故障的能力很大程度上取决于你知道“正常”长什么样。这一章我把实际遇到过的问题按现象分类整理每条都写了判断依据和处理方式。这类经验在官方文档里基本找不到因为文档假设一切按预期运行。6.1 白屏与 500 错误的定位路径白屏是最让人焦虑的现象因为浏览器什么都不告诉你。实际上 PHP 的错误信息被display_errors关掉了你需要主动去日志里找。# 系统日志 tail -f /var/log/nginx/error.log # PHP-FPM 日志 tail -f /var/log/php8.1-fpm.log # Moodle 自己的日志需要先在 config.php 里开启 tail -f /var/moodledata/error.log三个日志各有分工。Nginx 日志里有请求路径和上游错误如果看到upstream timed out说明是 PHP 处理太慢PHP-FPM 日志里有进程池的状态和启动错误如果看到failed to open stream说明是文件权限问题Moodle 的日志最详细但默认不开启需要在config.php里加两行$CFG-debug (E_ALL | E_STRICT); $CFG-debugdisplay 1;排查完记得把debugdisplay关掉或者直接把这两行注释掉。生产环境开着会向所有访问者暴露文件路径和数据库结构是实打实的信息泄露。白屏最常见的三个原因按出现频率排序文件权限不对Web 进程读不了某个文件、PHP 扩展缺失调用了不存在的函数、磁盘满了。前两个看日志能直接定位第三个比较隐蔽因为日志可能因为写不进去而没有任何记录只能靠df -h发现。6.2 502 与上传失败的区分502 的意思是 Nginx 联系不上上游的 PHP 进程。常见原因和处理方式现象可能原因处理方式全部请求 502PHP-FPM 没启动systemctl status php8.1-fpm看启动日志间歇性 502进程池打满调大max_children或排查慢查询特定页面 502单请求超时调大fastcgi_read_timeout和max_execution_time502 伴随权限报错socket 权限不对核对 socket 路径和属组socket 权限这个问题值得展开。PHP-FPM 的 socket 默认属组是www-data属主是root权限 0660。Nginx 的工作进程也是www-data所以能连上。但如果你把 Nginx 的 user 改成了别的或者 socket 路径写错了一个字符就会得到 502而错误日志里只有一句connect() to unix:... failed不够直观。遇到这种情况先ls -l确认 socket 文件存在且权限正确再用ss -xl | grep php看 socket 是否在监听。上传失败和 502 的区别在于上传失败通常有明确的错误码413或者前端提示502 是页面直接打不开。前面 4.3 那张表已经覆盖了上传的完整链路这里补充一个实际案例有人在后台设置里把“最大上传文件大小”设成了 2MB之后不管怎么改 php.ini 都没用因为 Moodle 读取的是数据库里存的那个值会覆盖 PHP 的默认值。解决方法是在后台改回 0 或者一个足够大的值让它跟随 PHP 配置。6.3 外网打不开的固定排查顺序这个问题前面提过一次这里给出更具体的操作顺序因为实际场景中经常是多个问题叠加。第一步服务器本地自测curl -I http://127.0.0.1和curl -I https://127.0.0.1 -k。本地能通说明服务本身没问题问题在网络层或配置层。第二步查监听地址ss -tlnp | grep nginx。必须是0.0.0.0:80和0.0.0.0:443如果是127.0.0.1就改 Nginx 的listen指令。第三步查防火墙ufw status verbose确认 80/443 是 ALLOW。注意有些镜像自带 iptables 规则ufw显示放行但实际被上层规则拦了可以用iptables -L -n交叉验证。第四步查云平台安全组这一层在服务器内部完全看不到只能去控制台确认。第五步从外部测试用另一台机器的curl或者在线工具确认能否建立 TCP 连接。telnet 域名 443能连上说明网络通畅连不上就是前面某层拦了。第六步如果 TCP 通了但 HTTPS 报证书错误检查证书是否过期、域名是否匹配、中间证书有没有配全。用openssl s_client -connect 域名:443 -servername 域名看完整握手过程和证书链。这个顺序的价值在于每一层都有明确的判断依据不会陷入“重启一下试试”的循环。6.4 备份策略与升级回滚备份这件事只有真正恢复过一次才算验证过。我见过备份脚本天天跑、日志显示成功真出事的时候发现备份的是空文件因为数据库密码写错了mysqldump报错但退出码被忽略了。一个可用的备份方案要覆盖三样东西数据库、数据目录、配置文件。前两个好理解第三个容易被漏——config.php里有数据库密码和站点地址没有它光有数据也恢复不起来。#!/bin/bash set -e BACKUP_DIR/backup/moodle/$(date %Y%m%d) mkdir -p $BACKUP_DIR mysqldump --single-transaction --quick --default-character-setutf8mb4 \ -u moodleuser -p密码 moodle | gzip $BACKUP_DIR/db.sql.gz rsync -a --delete /var/moodledata/ $BACKUP_DIR/moodledata/ cp /var/www/moodle/config.php $BACKUP_DIR/config.php # 保留最近 14 天 find /backup/moodle -maxdepth 1 -type d -mtime 14 -exec rm -rf {} \;--single-transaction保证导出过程中不锁表业务不受影响。set -e让脚本在任何一步失败时立即退出不会留下不完整的备份。rsync --delete保证备份和目标一致但要注意它也会删除备份目录里多余的旧文件方向别写反。升级流程我固定成这几步先进入维护模式后台有开关或者用命令行admin/cli/maintenance.php --enable然后完整备份一次再替换代码目录保留config.php和local目录下的自定义插件最后跑升级脚本admin/cli/upgrade.php。升级脚本会改数据库结构所以备份必须在它之前完成。出问题时恢复代码目录和数据库两份就能回到升级前的状态。回滚的关键是数据库也能回滚。很多人只备份了代码数据库升级失败后代码回退了但表结构已经变了站点照样起不来。所以升级前的数据库备份是硬要求而且要确认备份文件大小正常、能成功解压。提示升级前先在测试环境完整走一遍特别注意第三方插件是否兼容新版本。插件不兼容时的表现五花八门有的是功能异常有的是整个站点打不开在测试环境发现总比在生产环境发现好。最后分享一个我在实际操作中的体会部署这类系统最容易出问题的不是那些复杂的配置项而是“你以为已经设好了”的地方——时区、权限、两份 php.ini、监听地址。每次部署我会把这几项单独列出来逐条确认花不了五分钟但能省掉后面几个小时的排查。另外养成把每一步操作和结果记下来的习惯隔几个月回来做升级或者迁移时那份记录比任何文档都管用。