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 模式延迟低 |
| PHP | 8.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 | 安装时报解析错误 | 大量配置文件和数据交换依赖 XML |
| curl | 无法访问外部服务、插件市场打不开 | 站点间通信、备份到外部存储都靠它 |
| 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 'moodleuser'@'127.0.0.1' IDENTIFIED BY '换成你自己的强密码'; GRANT ALL PRIVILEGES ON moodle.* TO 'moodleuser'@'127.0.0.1'; FLUSH PRIVILEGES;字符集必须是utf8mb4,不是utf8。MySQL 里的utf8实际只支持最多三个字节的字符,存不下四字节字符(比如某些符号和生僻字),用户提交的内容里一出现这类字符就会被截断或者报错。这个坑的隐蔽之处在于,大部分中文内容是三字节的,测试阶段完全正常,直到某个用户粘贴了一段带特殊符号的文本才暴露。
排序规则选utf8mb4_unicode_ci,ci表示大小写不敏感,符合大多数搜索和比较场景的直觉。不建议用utf8mb4_general_ci,它的比较规则在部分语言下不够准确;也不建议用utf8mb4_bin,那是按字节比较,搜索行为会变得很奇怪。
用户授权的主机名写127.0.0.1而不是%,这样数据库只接受本机连接。如果应用和数据库分离部署,就写应用服务器的内网地址,不要图省事写%。数据库端口暴露在公网是极其危险的事,我见过有人为了方便调试把 3306 开到公网,几天后数据库被清空勒索。分离部署时用内网地址加防火墙白名单,是成本最低的防护。
MySQL 8.0 的默认认证插件是caching_sha2_password,PHP 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 用户,这个组合的好处是:日常改配置文件用运维账号就行,不用sudo;Web 进程有读权限,能正常执行;但 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 部署测试,后续再上 HTTPS,wwwroot要同步改成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 \ --wwwroot=https://learn.example.com \ --dataroot=/var/moodledata \ --dbtype=mysqli \ --dbhost=127.0.0.1 \ --dbname=moodle \ --dbuser=moodleuser \ --dbpass='你的数据库密码' \ --fullname='示例教学平台' \ --shortname='demo' \ --adminuser=admin \ --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 证书申请与自动续期
证书我用 Let's 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 大文件上传的链路对齐
上传失败是部署后最高频的反馈之一,而且同一个现象可能由四个不同层的原因导致。我把整条链路和对应的参数列成一张表,出问题时从上往下查。
| 层级 | 参数 | 配置位置 | 典型症状 |
|---|---|---|---|
| 浏览器侧 | 无 | 无 | 超大文件在前端就提示超限 |
| Nginx | client_max_body_size | server 块 | 413 Request Entity Too Large |
| PHP | upload_max_filesize | php.ini(FPM 与 CLI 都要改) | 文件上传后大小变成 0 或截断 |
| PHP | post_max_size | php.ini | POST 数据被丢弃,页面空白 |
| PHP | max_execution_time | php.ini | 大文件上传到一半超时 |
| Moodle | 站点管理里的最大上传限制 | 后台界面 | 界面上限比实际能力小 |
| 数据目录 | 磁盘权限与剩余空间 | 文件系统 | 上传完成但文件不存在 |
实际配置时,我一般把 Nginx 设成 512m,PHP 的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.ini,CLI 用的在/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.5G,MySQL 分配 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.enable=1 opcache.memory_consumption=256 opcache.interned_strings_buffer=32 opcache.max_accelerated_files=20000 opcache.validate_timestamps=0 opcache.revalidate_freq=0 opcache.save_comments=1max_accelerated_files这一项必须调大。默认值是 10000,Moodle 的文件数往往超过这个数,超出部分不会被缓存,表现为“部分页面快、部分页面慢”。设成 20000 能覆盖绝大多数安装。这个值是哈希表的容量提示,设大了不会真的占用那么多内存,所以宁可往大了设。
validate_timestamps=0表示不检查文件修改时间,性能最好。代价是改代码之后必须手动重载 FPM 才生效。生产环境这个取舍是值得的,因为代码变更本来就该走发布流程。但要注意:如果你在后台通过界面上传插件,插件文件不会被立即加载,需要重载一次。这一点在装插件时容易被当成“插件装失败了”。
save_comments=1必须开着。很多 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 2>&1几个要点。第一,用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 进程。常见原因和处理方式:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 全部请求 502 | PHP-FPM 没启动 | systemctl status php8.1-fpm,看启动日志 |
| 间歇性 502 | 进程池打满 | 调大max_children或排查慢查询 |
| 特定页面 502 | 单请求超时 | 调大fastcgi_read_timeout和max_execution_time |
| 502 伴随权限报错 | 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-set=utf8mb4 \ -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、监听地址。每次部署我会把这几项单独列出来逐条确认,花不了五分钟,但能省掉后面几个小时的排查。另外,养成把每一步操作和结果记下来的习惯,隔几个月回来做升级或者迁移时,那份记录比任何文档都管用。