第一次把 Moodle 从本地虚拟机搬到公网,绝大多数人都会经历同一个心理落差:安装向导一路点完,页面正常跳出来了,觉得大功告成,结果换个设备、换个网络打开就白屏,或者卡在登录页反复跳转。问题不在 Moodle 本身,而在于“本地跑通”和“生产环境可对外访问”之间隔着好几层东西——运行时参数、数据目录权限、反向代理的协议识别、证书链、端口收敛,每一项单独看都不复杂,串起来就会互相打架。
Moodle 生产环境部署这件事,本质上是把一个 PHP 单体应用完整地交付到公网:让它能扛住并发、能安全地暴露 443、能在后台持续跑定时任务、能在出问题的时候快速回滚。这篇文章我按自己实际走过一遍的顺序来写,从机器规格怎么定,到 Nginx 配置里哪几行是命门,再到上线后最常撞上的五个报错怎么定位。适合已经会用 Linux 命令、但没独立交付过完整 Web 应用的朋友,也适合已经在运维岗位、需要接手一套现成 Moodle 站点的人对照排查。
1. 一套 Moodle 生产环境部署到底包含多少工作项
1.1 安装向导跑通只完成了不到一半
很多人对“部署”的理解停在浏览器里那个绿色对勾上。但生产环境的标准不一样,它要满足四个额外条件,我习惯把它们叫做四道坎。
第一道坎是运行时匹配。Moodle 对 PHP 版本和扩展有硬性要求,缺一个intl或者sodium,安装向导会直接中断,而且报错信息往往只告诉你“某扩展缺失”,不会告诉你为什么需要它。intl管的是多语言和本地化格式,成绩单、日期、货币都依赖它;sodium管的是密码学相关的加密操作,登录票据和部分令牌会用到。这两个扩展在很多精简版系统镜像里默认是没装的。
第二道坎是目录与权限。Moodle 的代码目录和数据目录必须分开,数据目录还不能放在 Web 根目录下面。这不是洁癖,是硬性安全要求——moodledata里存着所有用户上传的作业、课件、头像、缓存,如果它能被 URL 直接访问,等于把所有学员资料挂在公网上任人下载。
第三道坎是站点地址一致性。Moodle 在数据库里把wwwroot写死了,一旦你安装时用的是http://IP,后面换成域名和 HTTPS,站点会疯狂重定向,因为它认为当前访问的地址不是“自己”。这个坑后面第 4 章会详细拆。
第四道坎是证书与端口。公网访问必须走 HTTPS,而 HTTPS 需要域名、需要证书、需要 Nginx 正确传递协议头给 PHP。这三者任何一环断了,表现都是登录页反复跳或者样式全丢。
1.2 机器规格与磁盘规划的实际数字
规格这块我不太喜欢给“标准答案”,因为使用强度差异太大。但可以给一个按活跃用户量估算的参考区间,这个区间是基于“普通课程站点、以文字和 PDF 为主、少量短视频”的假设:
| 规模 | 活跃用户 | CPU | 内存 | 系统盘 | 数据盘 |
|---|---|---|---|---|---|
| 小型 | 500 以内 | 2 核 | 4 GB | 40 GB | 100 GB 起 |
| 中型 | 500 至 3000 | 4 至 8 核 | 8 至 16 GB | 60 GB | 按每人 1 至 2 GB 估 |
| 大型 | 3000 以上 | 8 核以上 | 16 GB 以上 | 独立 | 对象存储或独立存储节点 |
真正容易被低估的是磁盘。moodledata的增长曲线是阶梯式的,平时几乎不动,一到期末集中交作业、上传录屏,几天就能涨几十 G。我的做法是把moodledata单独挂一块盘,或者直接挂网络存储,这样扩容的时候不用动系统盘,也不会因为根分区满了导致 MySQL 写不进去、整站 500。
还有一条经验:如果预算允许,把数据库和 Web 拆到两台机器上。倒不是为了性能,主要是为了备份和升级的时候能独立操作——升级 Moodle 代码的时候数据库可以继续在线,回滚也只需要回滚代码目录。
2. 环境选型:为什么我最终放弃了“一键集成包”
2.1 PHP 版本、扩展清单与 php.ini 的定档
一开始图省事,我用过那种把 Nginx、PHP、MySQL 打包在一起的集成面板。小规模试用确实快,但它的问题在生产环境会被放大:组件版本被锁死、配置文件被面板覆盖、日志位置不透明。最要命的是升级,Moodle 大版本升级经常要求 PHP 跟着升一档,集成包要么不给你升,要么升完把面板自己的东西搞崩。
所以后来我一律手工装。版本选择上,先看 Moodle 分支的官方兼容表,主流对应关系大致是这样:
| Moodle 分支 | 推荐 PHP | 数据库 |
|---|---|---|
| 4.1 至 4.2 | 8.0 至 8.2 | MariaDB 10.6+ / MySQL 8.0 |
| 4.3 至 4.4 | 8.1 至 8.3 | MariaDB 10.6+ / MySQL 8.0 |
| 4.5 及以后 | 8.2 至 8.3 | MariaDB 10.11+ / MySQL 8.0 |
提示:具体版本对照一定要以你下载的那个 Moodle 分支自带的版本说明为准,别照抄表格。选一个比最低要求高一档的 PHP 小版本,既留了余量,又不会撞上太新的兼容问题。
扩展清单我记在一张便签上,装机时逐项核对:intl、mbstring、curl、gd、xml、zip、mysqli、soap、sodium、openssl、bcmath、redis。其中gd必须带 JPEG 和 PNG 支持,否则头像和缩略图生成会失败;zip影响课件包的导入导出。
php.ini里几个参数必须改,默认值几乎都不够用:
memory_limit = 512M max_execution_time = 300 post_max_size = 512M upload_max_filesize = 512M max_input_vars = 5000 max_input_time = 300post_max_size和upload_max_filesize一定要设成一致的值,不一致会出现“上传 50M 的文件,转圈转到 100% 然后报错”这种极其迷惑的现象。max_input_vars从默认的 1000 提到 5000,是因为课程设置表单字段特别多,超过限制会导致部分设置项静默丢失——你以为保存成功了,其实只存了一半。
2.2 数据库与缓存的搭配逻辑
数据库我在 MariaDB 和 MySQL 之间犹豫过,最后选 MariaDB,理由很朴素:它的默认配置更偏向小内存机器,且mysqldump行为更稳。两者对 Moodle 都完全够用,不存在谁快谁慢的明显差异,别在这上面纠结太久。
字符集必须是utf8mb4,排序规则用utf8mb4_unicode_ci。老教程里常见的utf8在 MariaDB 里其实是三字节的,遇到学员姓名里的特殊字符或者表情符号会直接截断,而且这种错误在写入时不报,读出来才发现是乱码。
innodb_buffer_pool_size是最值钱的一个参数,按物理内存的 40% 到 50% 给。8 GB 内存的机器给 4 GB,剩下的留给 PHP-FPM 和系统。这个参数的逻辑是让热数据尽量待在内存里,少走磁盘 IO。压测时把缓冲池从 1 GB 调到 4 GB,课程列表的首屏加载时间能降一半以上,效果比调 PHP 明显得多。
max_allowed_packet从默认的 16M 提到 128M,否则导入大课件包或者批量备份恢复时会断连。
缓存我坚定地用 Redis,让它同时承担两件事:PHP 会话存储和 Moodle 内部的应用缓存。会话放到 Redis 之后,多台 Web 节点可以共享登录状态,后面想横向扩容不用改代码;应用缓存放 Redis 比默认的文件缓存快一个数量级,因为文件缓存每次都要走 stat 系统调用,Redis 是纯内存操作。安装方式就是系统包管理器装一个 Redis,把bind改成127.0.0.1,maxmemory设成 1 GB 左右,淘汰策略用allkeys-lru。
2.3 Web 服务器与 PHP-FPM 的进程模型
Nginx 和 Apache 都能跑 Moodle,我选 Nginx,原因不是性能,是配置可读性好、反向代理场景下调试方便。Moodle 根目录下有一堆真实存在的文件和目录,需要“先找文件、找不到再交给 PHP”这种路由规则,Nginx 的try_files表达起来很直观。
PHP 和 Nginx 之间的连接方式,我选 Unix socket 而不是 TCP 127.0.0.1:9000。差距不算巨大,但 socket 少走一层网络栈,高并发下延迟更稳定,而且不会因为端口占用引发莫名其妙的连接失败。socket 路径一般是/run/php/php8.2-fpm.sock,具体看你装的版本号。
pm.max_children这个值必须算,不能拍脑袋。算法很简单:可用内存除以单个 PHP 进程的常驻内存。
假设机器 8 GB,MySQL 拿走 4 GB 缓冲池加自身开销约 1 GB,系统和其他进程留 1 GB,剩 2 GB 给 PHP。Moodle 的 PHP 进程单个大概占 60 到 100 MB,按 80 MB 算:
2048 MB / 80 MB ≈ 25那就设pm.max_children = 25。设太大的后果是内存被吃满触发 OOM Killer,MySQL 可能被随机杀掉,表现是整站突然连不上数据库,重启又好了,然后过几天再重演。这种间歇性故障最难查,根因往往就是进程数给多了。
3. 系统底座落地:从裸机到能跑 install.php
3.1 系统初始化与安全基线
拿到新机器,第一件事不是装软件,是把基础项固定下来。时区设成 UTC,这一点很多人有异议,说站点在国内为什么不用东八区。因为 Moodle 内部所有时间戳按 UTC 存,显示时按用户时区转换,服务器本地时区设成什么其实不影响显示。统一用 UTC 的好处是日志、数据库时间、定时任务时间三者对得上,排查问题时不用心算。
时间同步必须开,chronyd或systemd-timesyncd二选一,确保服务在跑。时间漂移会导致 HTTPS 证书校验失败、OAuth 登录令牌失效、日程提醒错乱,这些症状看起来毫无关联,根因却是同一个。
防火墙只开三个端口:22、80、443。80 保留是因为证书签发和 HTTP 跳转要用,其余一律拒绝。数据库端口 3306、Redis 端口 6379 绝对不能对公网开放,这两个是自动化扫描的头号目标。数据库配置里bind-address = 127.0.0.1,Redis 配置里bind 127.0.0.1 ::1并且protected-mode yes,双保险。
还要提醒一句:如果系统开了 SELinux 或 AppArmor,Nginx 访问moodledata目录会被拦。表现是安装到最后一步报“数据目录不可写”,但用ls -l看权限完全正常。这种时候要看审计日志,ausearch -m avc或dmesg | grep -i denied,然后用chcon或者给目录打上正确的上下文标签,别急着把 SELinux 关掉。
3.2 代码与数据目录的分离布局
目录结构我固定成两个:
/var/www/moodle 代码目录 /var/moodledata 数据目录代码目录的所有者是www-data(Debian 系)或nginx(RHEL 系),权限 755,文件 644。数据目录权限 750,所有者同样是 Web 用户,但需要粘滞位,因为 Moodle 会在里面创建大量子目录:
sudo mkdir -p /var/moodledata sudo chown -R www-data:www-data /var/moodledata sudo chmod -R 750 /var/moodledata sudo chmod 2777 /var/moodledata上面最后一条2777看起来和750矛盾,其实是两步:先按常规权限收紧,再对数据目录根设置组粘滞位,保证 Web 用户在里面创建的子目录可以被同一组的进程正常读写。Moodle 的config.php里有一个$CFG->directorypermissions参数,默认02777,和这里的设置是对应的。
代码目录我是 Git 拉取而不是下压缩包,好处是升级和回滚都是git checkout一个命令,而且能随时git diff确认有没有人偷偷改过核心文件。
3.3 数据库初始化与最小权限账户
建库建账户别用 root。Moodle 只需要一个库的完全权限,不需要全局权限:
CREATE DATABASE moodle DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'moodleuser'@'127.0.0.1' IDENTIFIED BY '替换成强密码'; GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER, CREATE TEMPORARY TABLES, LOCK TABLES ON moodle.* TO 'moodleuser'@'127.0.0.1'; FLUSH PRIVILEGES;注意主机名写127.0.0.1而不是localhost,因为localhost在部分配置下会走 socket 连接,而 Moodle 的dbhost配的是 IP,两者对不上会认证失败。这种错配的报错信息通常只说“访问被拒绝”,不会告诉你是主机名匹配的问题。
表前缀默认mdl_,保持默认就行。多站点共用一个库的时候才需要改,单站点改了反而增加心智负担。
4. config.php 里的几个字段决定了你能不能上线
4.1 wwwroot、dataroot、sslproxy 的联动关系
这是我们第一次部署时卡了整整一个下午的地方。现象是:浏览器打开域名,页面开始无限跳转,最后报ERR_TOO_MANY_REDIRECTS。
根因是这样:请求到了 Nginx,Nginx 用 HTTPS 接的,但把它转给 PHP-FPM 的时候,PHP 侧看到的$_SERVER['HTTPS']是空的、SERVER_PORT是 80。Moodle 判断“我现在被 HTTP 访问了”,于是把用户重定向到 HTTPS 地址;浏览器发出 HTTPS 请求,Nginx 又传给 PHP 一个“HTTP”的视图,Moodle 又重定向一次。死循环。
解决分两半。Nginx 侧要显式传递协议头:
location ~ [^/]\.php(/|$) { fastcgi_pass unix:/run/php/php8.2-fpm.sock; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param HTTPS on; fastcgi_param HTTP_X_FORWARDED_PROTO https; }PHP 侧在config.php里加一行:
$CFG->sslproxy = true;这一行告诉 Moodle:“我前面有个反向代理,它在帮我做 TLS 终结,你别自己判断协议,听代理的。”
核心字段的完整样子大致如下:
<?php unset($CFG); global $CFG; $CFG = new stdClass(); $CFG->dbtype = 'mariadb'; $CFG->dblibrary = 'native'; $CFG->dbhost = '127.0.0.1'; $CFG->dbname = 'moodle'; $CFG->dbuser = 'moodleuser'; $CFG->dbpass = '替换成强密码'; $CFG->prefix = 'mdl_'; $CFG->dboptions = ['dbcollation' => 'utf8mb4_unicode_ci']; $CFG->wwwroot = 'https://lms.example.com'; $CFG->dataroot = '/var/moodledata'; $CFG->admin = 'admin'; $CFG->directorypermissions = 02777; $CFG->sslproxy = true; require_once(__DIR__ . '/lib/setup.php');wwwroot必须和浏览器地址栏里看到的完全一致,包括协议、域名、有没有末尾斜杠。末尾斜杠是最隐蔽的一个,https://lms.example.com/和https://lms.example.com在 Moodle 眼里是两个不同的地址,会导致部分 AJAX 请求跨域被拦。
如果站点已经装好了才发现wwwroot写错,不用重装,直接改config.php里的值,然后再进数据库改一下mdl_config表里wwwroot那条记录,最后清一次缓存即可。
4.2 定时任务的配置方式
Moodle 有一个核心机制:几乎所有异步的事情都靠定时任务驱动——邮件发送、论坛通知、成绩计算、备份清理、注册同步。这个任务如果不跑,站点看起来一切正常,但学员收不到任何通知,备份也不会自动生成。
配置方式是在 Web 用户的 crontab 里加一行,注意必须用 Web 用户身份跑,用 root 跑会生成一堆 root 所有者的文件,后续 Web 进程写不进去:
sudo crontab -u www-data -e* * * * * /usr/bin/php /var/www/moodle/admin/cli/cron.php > /var/log/moodle-cron.log 2>&1每分钟一次,这是官方建议的频率。日志重定向到单独文件很关键,出问题时能第一时间看到是哪个任务在报错。我曾经遇到过一个第三方插件在定时任务里抛异常,导致后面的任务全部被跳过,而站点管理界面只显示“定时任务运行正常”,因为 cron.php 本身退出码是 0。去翻日志才看到某一行的堆栈。
邮件通道建议直接配 SMTP 插件,填外部邮件服务的账号,别依赖本机的sendmail。本机发信在现代邮件环境下几乎必然进垃圾箱,因为缺少 SPF、DKIM 记录,而且很多服务商直接拒收家宽和机房的 25 端口。
4.3 安装引导的正确打开方式
代码放好、权限配好、数据库建好、config.php写好后,浏览器访问域名会自动跳到安装向导。这一步反而最省心,一路下一步就行。
但有两点要注意。第一,安装过程中会填写管理员账号,密码策略默认要求挺高,如果填了弱密码它会拒绝,而提示信息在页面顶部容易被忽略,让人以为是卡住了。第二,安装完成后不要立刻把config.php的权限放开,保持 640 或者 440,所有者为 Web 用户,确保里面的数据库密码不会被其他系统用户读到。
安装完成后第一件事是进“站点管理 → 开发 → 调试”把调试信息打开一档,让错误显示到日志里。生产环境不要开到“开发者”级别,那会把所有 SQL 和函数调用栈打到页面上,既泄露信息又拖慢速度。开到“正常”就行,PHP 的错误日志路径在php.ini里指到/var/log/php-fpm/moodle-error.log,方便单独看。
5. Nginx 反代与证书:把站点真正推到公网
5.1 站点配置里必须逐行确认的几段
配置文件我按功能分段来写,每一段都对应一个具体的坑。
server { listen 80; server_name lms.example.com; return 301 https://$host$request_uri; } server { listen 443 ssl; http2 on; server_name lms.example.com; root /var/www/moodle; index index.php; ssl_certificate /etc/letsencrypt/live/lms.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/lms.example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; client_max_body_size 512M; client_body_timeout 300s; add_header X-Content-Type-Options nosniff; add_header X-Frame-Options SAMEORIGIN; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ [^/]\.php(/|$) { fastcgi_pass unix:/run/php/php8.2-fpm.sock; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param HTTPS on; fastcgi_param HTTP_X_FORWARDED_PROTO https; fastcgi_read_timeout 300s; fastcgi_buffers 16 32k; fastcgi_buffer_size 64k; } location ~* \.(?:css|js|svg|gif|png|jpg|jpeg|woff2?|ttf)$ { expires 30d; access_log off; try_files $uri =404; } location ~ /\.(?!well-known) { deny all; } location ~ ^/(cache|localcache|temp|trashdir|sessions)/ { deny all; } }client_max_body_size 512M必须和php.ini里的post_max_size、upload_max_filesize对齐。这三处只要有一处小,大文件上传就在那一层被拦,而且 Nginx 拦下来返回的是 413,页面上可能只显示“上传失败”,不看日志根本不知道是哪一层拒的。
fastcgi_read_timeout 300s是给长任务留的。生成课程备份、导出成绩、批量导入用户这些操作会跑几十秒甚至几分钟,默认 60 秒会被 Nginx 掐断,页面上显示 504,但后台任务其实还在跑,造成状态不一致。
最后两个location是安全兜底。禁止访问所有点开头的隐藏文件(保留证书验证用的.well-known),禁止直接访问 Moodle 的缓存和临时目录。这些目录里可能有会话文件或者序列化的数据,直接暴露风险很大。
5.2 域名解析与证书自动续期
域名解析这块没什么技术含量,A 记录指向服务器公网 IP,TTL 先设 300 秒方便调试,稳定运行后再改成 3600。如果套了 CDN,要把回源协议设成 HTTPS,并且在 Nginx 里信任 CDN 传来的X-Forwarded-For,否则日志里全是 CDN 节点的 IP,查问题时完全对不上人。
证书我用免费签发工具,命令就一条:
sudo certbot --nginx -d lms.example.com --redirect --agree-tos -m admin@example.com它会自动改 Nginx 配置、加跳转规则、装续期定时器。签发完成后可以模拟一次续期验证:
sudo certbot renew --dry-run这一步千万别省。我遇到过一次续期失败,原因是 80 端口的 HTTP 跳转规则把验证请求也 301 到 HTTPS 了,而续期验证必须走 80 明文。免费证书有效期只有 90 天,续期挂了不会立刻有感知,等到第 90 天整站证书过期,浏览器直接拦截页面,那时候就是紧急事故了。所以后续我加了一个监控项,每天检查证书剩余天数,低于 20 天就报警。
5.3 上线前的链路验证清单
配置写完不要直接宣布上线,按这个顺序验一遍,能省掉大量“到底哪一层出问题”的猜谜:
| 验证项 | 命令或方式 | 期望结果 |
|---|---|---|
| HTTP 跳 HTTPS | curl -I http://lms.example.com | 返回 301,Location 指向 https |
| HTTPS 可达 | curl -I https://lms.example.com | 返回 200 或 303 |
| 证书链完整 | openssl s_client -connect lms.example.com:443 -servername lms.example.com | 显示 verify return code: 0 |
| 静态资源 | 浏览器打开页面,看 Network 里 css/js | 无 404,无 403 |
| 大文件上传 | 后台上传一个 200M 文件 | 进度条走到 100% 且提示成功 |
| 数据目录不可访问 | curl -I https://lms.example.com/moodledata/ | 返回 403 或 404 |
| 定时任务 | tail -f /var/log/moodle-cron.log | 每分钟有新输出,无异常堆栈 |
最后一项特别值得盯。定时任务是唯一一个“不跑也不会立刻出问题”的组件,所以最容易被忽略。
6. 上线后的调优、备份与排错
6.1 OPcache 与缓存的联合调优
Moodle 的代码量很大,一个请求会加载几百个 PHP 文件,不走 OPcache 的话每次都要重新编译,CPU 会被白白吃掉。
opcache.enable=1 opcache.memory_consumption=256 opcache.interned_strings_buffer=32 opcache.max_accelerated_files=20000 opcache.validate_timestamps=1 opcache.revalidate_freq=60max_accelerated_files给到 20000 是有讲究的,默认 10000 装不下 Moodle 加插件之后的文件数,超出的部分不会被缓存,表现为“只有部分页面快,部分页面慢”。validate_timestamps=1加revalidate_freq=60是保守选择,好处是改代码后一分钟内自动生效,坏处是每分钟检查文件时间戳有一点开销。如果站点完全稳定、只按计划发布版本,可以关掉验证,手动 reload PHP-FPM 来生效,性能会再好一点。
Moodle 内部的应用缓存(MUC)要切到 Redis。入口在“站点管理 → 插件 → 缓存 → 缓存实例”,新增一个 Redis 实例,填上127.0.0.1:6379,然后把“应用”类型的缓存映射过去。也可以在config.php里声明:
$CFG->cachestores = [ 'redis' => [ 'name' => 'redis', 'plugin' => 'redis', 'configuration' => [ 'server' => '127.0.0.1', 'port' => 6379, 'prefix' => 'muc_', 'serializer' => 1, ], ], ];注意:这个配置数组的字段名会随版本小幅变化,写之前对照一下当前版本的 Redis 缓存插件说明,别直接复制粘贴就上生产。
会话同样切到 Redis:
$CFG->session_handler_class = '\core\session\redis'; $CFG->session_redis_host = '127.0.0.1'; $CFG->session_redis_port = 6379; $CFG->session_redis_prefix = 'mdlsess_'; $CFG->session_redis_acquire_lock_timeout = 120;配完之后不同浏览器窗口的登录状态会立刻变得一致,而且重启 PHP-FPM 不会把所有人踢下线。
6.2 备份、升级与回滚的操作顺序
备份这件事,我吃过一次亏。当时只备份了数据库,觉得代码和文件都在服务器上、随时能重新拉。结果某次升级把插件目录搞乱了,代码仓库里没有插件的第三方代码,重新拉主程序根本起不来。从那以后我固定成三件套一起备:
# 数据库,用单事务保证一致性 mysqldump --single-transaction --quick --routines \ -u moodleuser -p moodle > /backup/moodle-$(date +%F).sql # 代码目录 tar czf /backup/moodle-code-$(date +%F).tar.gz -C /var/www moodle # 数据目录,用 rsync 增量,别用 tar,文件太多太慢 rsync -a --delete /var/moodledata/ /backup/moodledata/备份文件一定要做一次恢复演练。没恢复过的备份只能叫“以为有的备份”。演练很简单:另起一台机器,把三件套还原,改config.php里的域名和数据库连接,看能不能正常登录、能不能打开一门课的附件。
升级的标准顺序是先备份、再进维护模式、再更新代码、再跑升级向导、最后退出维护模式:
sudo -u www-data /usr/bin/php /var/www/moodle/admin/cli/maintenance.php --enable cd /var/www/moodle && git fetch && git checkout 目标版本标签 sudo -u www-data /usr/bin/php /var/www/moodle/admin/cli/upgrade.php --non-interactive sudo -u www-data /usr/bin/php /var/www/moodle/admin/cli/purge_caches.php sudo -u www-data /usr/bin/php /var/www/moodle/admin/cli/maintenance.php --disable升级向导那一步如果用浏览器跑,遇到超时会留下“升级到一半”的状态,非常麻烦。所以我固定用命令行版,跑完会明确告诉你成功还是失败。
6.3 五个高频报错的定位路径
排错的核心思路是“先确定是哪一层出的问题”。我的习惯顺序是:Nginx 错误日志 → PHP-FPM 慢日志和错误日志 → Moodle 自己的错误日志 → 数据库慢查询。大部分问题在前两层就能定位。
| 现象 | 最可能的根因 | 定位方式 |
|---|---|---|
| 页面白屏无任何输出 | PHP 致命错误被隐藏 | 看php-fpm日志,临时提高display_errors |
| 重定向次数过多 | wwwroot与实际地址不符或sslproxy未开 | 检查config.php两个字段 |
| 上传大文件失败 | 三层限制中有一层值偏小 | 对照 Nginx、php.ini、Moodle 站点设置三处 |
| 定时任务不执行 | crontab 用户身份不对或 PHP 路径错 | 手动sudo -u www-data php cron.php试跑 |
| 安装插件提示目录不可写 | 权限或安全模块拦截 | ls -l看属主,再看 SELinux 审计日志 |
白屏这个最典型。默认的display_errors = Off,任何语法错误或者内存溢出都表现为一个空白页,什么提示都没有。定位方法是先看 PHP 错误日志,如果日志也是空的,那就临时在config.php顶部加两行把错误打开:
ini_set('display_errors', 1); error_reporting(E_ALL);这样页面会直接打印出文件和行号。看到之后立刻删掉这两行,别留在生产配置里。
另外提一个很隐蔽的问题:磁盘满。moodledata撑爆分区之后,MySQL 写不进去,Moodle 会报数据库错误;同时日志也写不进去,导致错误日志是空的。所以“日志是空的但确实出问题了”,先df -h看一眼,往往就是磁盘。
再分享一个我个人在实际操作中的体会:每次动完配置,不要靠记忆判断有没有生效,用nginx -t校验语法、用systemctl reload而不是restart、用curl -I看返回头。这三步花不了一分钟,但能挡掉大部分“改完没生效、以为改错了方向”的无效排查。我先在地的一台机器上就因为在 Nginx 配置里写错了一个分号,reload直接失败但命令没报错,导致后面半个小时都在怀疑 PHP 那边的问题。