PVE 环境下 1500+ WordPress 站点的性能救赎之路

—— 从 SAS RAID5 到 NVMe 直通与内网万兆互联架构

以下内容根据实际生产环境进行编写,部分内容参考了互联网上的内容,以及经过了AI的润色,请结合自己实际情况进行调整。

1. 现状与瓶颈诊断

环境背景:

  • 物理机:DELL EMC 96C / 256G / 10TB SAS / RAID 5。
  • 业务:单台 Debian 虚拟机(88C/200G)承载 1500 个 WordPress 站点。
  • 现象:尽管接入 Cloudflare 清洗流量,CPU 长期处于 I/O Wait,网站响应缓慢,经常出现 502/504。

根本原因分析:

  1. 存储介质瓶颈:机械硬盘 RAID 5 存在严重的写惩罚 (Write Penalty)。SAS 盘单盘 IOPS 约 150-200,面对 1500 个站点的并发 MySQL 写入(Binlog, Redolog, Undo Log)及 PHP Session/日志写入,磁头寻道时间已耗尽。
  2. 架构缺陷:Web 服务与数据库服务混跑在同一虚拟机,且共享同一组物理磁盘,造成严重的 I/O 争抢。
  3. 云数据库不可行:本地 Ping 云服务器延迟 5.8ms。WordPress 单页面渲染涉及 20-100 次串行 SQL 查询,5.8ms 延迟会被放大至 300ms+,导致 PHP-FPM 进程阻塞积压,瞬间打满内存。

最终决策:放弃云数据库,采用 本地 NVMe 直通 + PVE 内网互联 方案。


2. 硬件改造:引入企业级 NVMe

选型原则

  • 严禁使用消费级 SSD(如 Samsung 980/990 Pro)。无掉电保护 (PLP),数据库断电必丢数据;且缓存写满后性能暴跌。
  • 必须组 RAID 1。单盘无冗余是生产环境大忌。

推荐清单

  • 型号:Intel D7-P5520 / Samsung PM9A3 (U.2 接口)。
  • 容量:3.84TB x 2 (组 RAID 1)。
  • 连接:通过 PCIe x4 转 U.2 转接卡接入主板 PCIe 插槽。

3. 实施步骤 A:存储层 (PCIe Passthrough)

为了榨干 NVMe 的性能,不使用 PVE 的虚拟磁盘(VirtIO Block),直接将 PCIe 控制器直通给虚拟机。

3.1 宿主机开启 IOMMU

在 PVE Shell 中操作:

Bash

nano /etc/default/grub
# 修改如下 (Intel CPU):
GRUB_CMDLINE_LINUX_DEFAULT="quiet intel_iommu=on iommu=pt"
update-grub
reboot

验证dmesg | grep -e DMAR -e IOMMU 有输出即成功。

3.2 虚拟机直通配置

  1. 确认 NVMe ID:lspci -nn | grep Non-Volatile (记下 ID 如 04:00.0)。
  2. PVE 界面 -> DB 虚拟机 -> 硬件 -> 添加 PCI 设备 -> 选择对应 ID -> 勾选 “All Functions” 和 “PCI-Express”
  3. 警告:直通后该虚拟机无法使用 PVE 快照功能,备份需改用文件级或数据库级备份。

3.3 虚拟机内部组建 RAID 1

在 DB 虚拟机(Debian)内部操作:

Bash

# 1. 安装工具
apt install mdadm

# 2. 创建软 RAID 1 (假设盘符为 nvme0n1 和 nvme1n1)
mdadm --create /dev/md0 --level=1 --raid-devices=2 /dev/nvme0n1 /dev/nvme1n1

# 3. 格式化并挂载
mkfs.ext4 /dev/md0
mkdir -p /var/lib/mysql_data
mount /dev/md0 /var/lib/mysql_data

# 4. 写入 fstab (防止重启丢失)
echo "UUID=$(blkid -s UUID -o value /dev/md0) /var/lib/mysql_data ext4 defaults 0 0" >> /etc/fstab

# 5. 保存 RAID 配置
mdadm --detail --scan >> /etc/mdadm/mdadm.conf
update-initramfs -u

4. 实施步骤 B:网络层 (VirtIO Internal Fabric)

利用内存拷贝实现虚拟机间通信,带宽可达 20Gbps+,延迟 < 0.1ms。

4.1 PVE 宿主机网络架构

创建独立网桥,不绑定任何物理网口,实现物理隔离。

  • 设备vmbr1
  • IP/网关:全部留空
  • Bridge Ports:留空
  • MTU:9000 (关键:开启巨型帧以降低 CPU 中断消耗)

4.2 虚拟机网卡配置

为 Web VM 和 DB VM 分别添加第二张网卡:

  • 桥接vmbr1
  • 模型:VirtIO
  • 防火墙关闭 (Firewall=No,减少内核包过滤开销)

4.3 系统内网配置 (Debian /etc/network/interfaces)

DB Server (192.168.10.20):

Plaintext

auto ens19
iface ens19 inet static
    address 192.168.10.20
    netmask 255.255.255.0
    mtu 9000

Web Server (192.168.10.10):

Plaintext

auto ens19
iface ens19 inet static
    address 192.168.10.10
    netmask 255.255.255.0
    mtu 9000

注:配置完成后需 systemctl restart networking 并互 ping 测试。


5. 实施步骤 C:应用层迁移与调优

5.1 MySQL 针对 NVMe 调优 (my.cnf)

默认配置无法发挥 NVMe 性能,必须修改 IO 相关参数。

Ini, TOML

[mysqld]
datadir = /var/lib/mysql_data

# 内存分配 (假设 VM 64G 内存)
innodb_buffer_pool_size = 48G
innodb_log_file_size = 4G

# NVMe 核心优化
innodb_io_capacity = 20000        # 释放 SSD 性能
innodb_io_capacity_max = 40000
innodb_flush_neighbors = 0        # SSD 不需要邻近刷新
innodb_flush_method = O_DIRECT    # 绕过系统缓存
skip-name-resolve                 # 禁用 DNS 反查
bind-address = 0.0.0.0            # 允许内网连接

5.2 权限迁移

在 DB Server 执行:

SQL

-- 创建允许内网段访问的用户
GRANT ALL PRIVILEGES ON *.* TO 'root'@'192.168.10.%' IDENTIFIED BY 'PASSWORD' WITH GRANT OPTION;
FLUSH PRIVILEGES;

注意:如果每个 WP 站点使用独立用户,需批量修改 mysql.user 表中的 Host 字段为 192.168.10.10

5.3 批量修改 wp-config.php

在 Web Server 站点根目录执行,将 localhost 批量替换为内网 DB IP。

Bash

# 备份是个好习惯
# cp -r /var/www/html /var/www/html_backup 

# 批量替换 DB_HOST
find /var/www/html -name "wp-config.php" -exec sed -i "s/define( *'DB_HOST', *'localhost' *);/define( 'DB_HOST', '192.168.10.20' );/g" {} +

6. 最终检查清单

  1. I/O 验证:在 DB VM 执行 iostat -mx 1,确认 svctmawait 处于微秒级。
  2. 网络验证:使用 iperf3 压测 Web 与 DB 间带宽,预期应 > 15Gbps。
  3. WP 验证:随机抽取站点,检查页面加载速度及 PHP 错误日志。
  4. 安全验证:在 DB VM 上使用 iptables 封锁 3306 端口,仅允许 192.168.10.10 访问。

架构收益总结:

此方案通过物理隔离 I/O(NVMe 直通)和逻辑隔离网络(VirtIO 网桥),彻底消除了 SAS 机械硬盘的随机读写瓶颈,是目前单机 PVE 环境下最高性能的 WordPress 托管架构。

800+个WordPress被黑站点批量修复与架构演进 (Bash + WP-CLI)

背景:单台服务器托管 800+ 个 WordPress 站点(站群架构),遭遇批量挂马。特征为 wp-content 目录下充斥恶意 PHP/HPH 文件,部分站点的 wp-config.php 被直接篡改为恶意 HTML 页面。

目标:在不丢失数据库数据、保留上传图片 (uploads) 的前提下,批量重置核心文件、清理后门、重置管理员权限。

难点

  1. 规模大:手动处理不现实,必须自动化。
  2. 环境脏:配置文件被污染,常规脚本读取会报错。
  3. 容错性:不能因为 1 个站点失败导致脚本中断。

本文记录从最初的“简单逻辑”到“生产级容错”的脚本演进过程。

核心原则 (Production Rules)

  1. 数据隔离:永远假设文件系统是不可信的,只信任数据库中的内容。
  2. 最小权限:Web Shell 无法执行的操作,通过 WP-CLI 在服务器端完成。
  3. 白名单机制:与其费力去识别哪些是病毒(黑名单),不如只保留什么是正常的(白名单)。
  4. 原子操作:单个站点的失败不应影响整体流程。

踩坑与演进:

在编写自动化脚本时,我们遇到了几个典型的生产环境陷阱。以下是问题的根源及解决方案。

1. 解析陷阱:当 wp-config.php 变成 HTML

问题现象:

脚本尝试读取数据库密码时,报错 –dbprefix can only contain numbers。日志显示读取到的数据库用户名竟然是 <html lang=en-US>。

根本原因:

黑客将部分站点的 wp-config.php 替换成了纯 HTML 的钓鱼页面。

早期的读取逻辑是直接 include 该文件。PHP 解释器遇到非 PHP 代码(HTML)会直接输出到标准输出 (stdout),导致脚本捕获了 HTML 源码作为变量值。

解决方案:

引入 Output Buffering (输出缓冲)。

在 eval 代码前使用 ob_start(),执行后使用 ob_end_clean()。这样可以吞掉所有非 PHP 的杂音(HTML),只保留内存中定义的常量。

2. 内核陷阱:不要加载 wp-settings.php

问题现象:

即使解决了 HTML 问题,脚本依然报错 PHP输出行数不足。

根本原因:

标准的 wp-config.php 尾部通常包含 require_once ABSPATH . ‘wp-settings.php’;。

这行代码会启动整个 WordPress 应用。在修复模式下,站点通常是损坏的,加载内核会导致 PHP 抛出致命错误或输出页面内容,干扰变量读取。

解决方案:

正则剔除。在 eval 之前,读取文件内容字符串,使用正则暴力移除加载内核的代码。

// 必须使用 s 修饰符 (PCRE_DOTALL),防止 require_once 跨行导致匹配失败
$content = preg_replace('/require_once\s*\(?.*wp-settings\.php.*?;/is', '', $content);

3. 用户管理陷阱:一刀切的风险

问题现象:

使用 SQL 直接 DELETE FROM wp_users 只保留管理员。

风险:

对于商城或会员站,这会误删数万真实用户。

解决方案:

使用 WP-CLI 进行逻辑操作。先查询 administrator 角色的列表,排除掉我们的超级管理员账号,再删除其余管理员。

经验:能用 API/CLI 解决的,绝不直接操作数据库 Raw SQL,因为你不知道有哪些关联表(UserMeta)需要清理。

4. 进程陷阱:单点故障导致全盘崩溃

问题现象:

脚本跑到第 50 个站点时,因为网络波动导致下载核心文件失败,脚本配置了 set -e,直接退出。剩余 750 个站点未处理。

解决方案:

子进程隔离。

将单个站点的所有修复逻辑放入 ( … ) 子 Shell 中执行。子 Shell 的退出状态不会杀死父进程(主循环)。

最终生产版本脚本

这是经过多次迭代后的最终版本,具备容错、强力解析和安全隔离特性。

适用环境:CentOS/Debian/Ubuntu, 已安装 PHP, WP-CLI, MySQL Client。

前置条件:准备好 failed_sites.txt (或其他站点列表文件)。

#!/usr/bin/env bash
set -Eeuo pipefail

# =========================
# 配置区
# =========================
WEBROOT_BASE="/www/"
# 指定包含站点目录名的列表文件
SITE_LIST_FILE="/backup/failed_sites.txt"
# 干净的插件/主题源路径 (用于覆盖)
SOURCE_WPCONTENT="/backup/68wordpress/wp-content"
# 目标管理员信息
TARGET_USER_LOGIN="username"
TARGET_USER_EMAIL="[email protected]"
TARGET_USER_HASH='password' # WordPress Hash string
OWNER="www:www"

# 依赖检查
PHP_BIN="$(command -v php || true)"
WP_BIN="$(command -v wp || true)"

# 辅助函数
log() { printf -- "[%s] %s\n" "$(date '+%F %T')" "$*" >&2; }
doit() { eval "$@"; }

# --- 核心:强力 PHP 解析函数 ---
# 解决 HTML 注入和 WP 内核加载问题
php_eval_from_config() {
  local cfg="$1"
  local php_code
  # 使用 Heredoc 避免 Shell 转义地狱
  php_code=$(cat <<'PHPEOF'
    ini_set('display_errors', 'stderr'); error_reporting(E_ALL);
    $cfg = $argv[1];
    if (!file_exists($cfg)) exit(10);
    
    $content = file_get_contents($cfg);
    
    // 关键:正则移除 wp-settings.php 加载,防止启动 WP
    $content = preg_replace('/require_once\s*\(?.*wp-settings\.php.*?;/is', '', $content);
    
    // 关键:输出缓冲,吞掉可能的 HTML 注入
    ob_start();
    eval('?>' . $content);
    ob_end_clean();
    
    if (!defined('DB_NAME') || !defined('DB_USER')) exit(11);
    
    $prefix = isset($table_prefix) ? $table_prefix : 'wp_';
    echo constant('DB_NAME') . PHP_EOL . constant('DB_USER') . PHP_EOL . 
         constant('DB_PASSWORD') . PHP_EOL . constant('DB_HOST') . PHP_EOL . $prefix . PHP_EOL;
PHPEOF
)
  "$PHP_BIN" -r "$php_code" -- "$cfg"
}

# --- 单站点处理逻辑 ---
process_single_site() {
    local domain=$1
    local site_dir="$WEBROOT_BASE/$domain"
    local cfg="$site_dir/wp-config.php"

    if [[ ! -f "$cfg" ]]; then log "跳过:无配置文件 $domain"; return; fi

    # 1. 解析配置
    local output
    # 使用 || true 捕获 PHP 错误而不退出
    output=$(php_eval_from_config "$cfg" 2>/dev/null | tr -d '\r' || true)
    
    if [[ -z "$output" ]]; then log "错误:解析配置失败 $domain"; return; fi
    
    readarray -t meta <<< "$output"
    if (( ${#meta[@]} < 5 )); then log "错误:配置信息不全 $domain"; return; fi

    local DB_NAME="${meta[0]}" DB_USER="${meta[1]}" DB_PASS="${meta[2]}" \
          DB_HOST="${meta[3]}" TBL_PREFIX="${meta[4]}"

    # 简单的安全检查
    if [[ "$DB_USER" == *"<"* ]]; then log "警告:配置包含 HTML,疑似被篡改 $domain"; return; fi

    # 2. 执行修复 (子 Shell 隔离环境)
    # 任何一步失败只会终止当前站点的处理
    (
        set -e 
        log "处理中: $domain"

        # A. 清理文件 (白名单模式)
        # 仅保留 wp-content 和特定文件,其余全删
        find "$site_dir" -maxdepth 1 ! -name 'wp-content' ! -name '.user.ini' ! -name '.' ! -name '..' -exec rm -rf {} +
        
        # B. 清理 Uploads (删掉所有 PHP/HPH)
        find "$site_dir/wp-content/uploads" -type f -iregex '.*\.\(php\|phtml\|phar\|hph\)$' -delete

        # C. 重装核心 & 生成新配置
        "$WP_BIN" core download --path="$site_dir" --force --allow-root
        "$WP_BIN" config create --path="$site_dir" --dbname="$DB_NAME" --dbuser="$DB_USER" \
            --dbpass="$DB_PASS" --dbhost="$DB_HOST" --dbprefix="$TBL_PREFIX" --force --allow-root
        "$WP_BIN" config shuffle-salts --path="$site_dir" --allow-root

        # D. 同步插件/主题 (覆盖模式)
        rsync -a --delete "$SOURCE_WPCONTENT/plugins/" "$site_dir/wp-content/plugins/"
        rsync -a --delete "$SOURCE_WPCONTENT/themes/" "$site_dir/wp-content/themes/"
        "$WP_BIN" plugin activate --all --path="$site_dir" --allow-root

        # E. 账号收敛 (只创建/重置目标账号,删除多余管理员)
        if ! "$WP_BIN" user get "$TARGET_USER_LOGIN" --path="$site_dir" --field=ID --allow-root >/dev/null 2>&1; then
             "$WP_BIN" user create "$TARGET_USER_LOGIN" "$TARGET_USER_EMAIL" --role=administrator \
                --user_pass="TEMP_PASS" --path="$site_dir" --allow-root
        fi
        "$WP_BIN" user update "$TARGET_USER_LOGIN" --user_pass="$TARGET_USER_HASH" --path="$site_dir" --allow-root
        
        # 找出其他管理员并删除
        local admins
        admins=$("$WP_BIN" user list --role=administrator --field=user_login --exclude="$TARGET_USER_LOGIN" --path="$site_dir" --allow-root || true)
        for admin in $admins; do
            "$WP_BIN" user delete "$admin" --reassign=1 --yes --path="$site_dir" --allow-root
        done

        # F. 权限修正
        chown -R "$OWNER" "$site_dir"
        
        log "成功:$domain"
    ) || log "失败:站点 $domain 处理中断"
}

# --- 主循环 ---
main() {
    [[ ! -f "$SITE_LIST_FILE" ]] && { log "列表文件不存在"; exit 1; }

    while IFS= read -r domain || [[ -n "$domain" ]]; do
        domain=$(echo "$domain" | tr -d '\r' | xargs)
        [[ -z "$domain" || "$domain" == \#* ]] && continue
        
        process_single_site "$domain"
        
    done < "$SITE_LIST_FILE"
}

main "$@"

技术总结

  1. Parse, Don’t Grep: 不要尝试用 Grep 去提取代码文件中的变量,永远使用该语言原本的解释器(这里是 PHP)去解析。
  2. Heredoc for Code Injection: 在 Bash 中通过 php -r 执行复杂 PHP 代码时,使用 Heredoc (<<‘EOF’) 是最干净的方式,避免了无休止的引号转义。
  3. Fail Fast, But Keep Going: 对于批处理脚本,单个任务必须快速失败(Fail Fast),但主进程必须具备韧性,能够跳过错误继续执行下一个任务。

警告:此脚本为“无备份”版本,执行的是破坏性修复。在生产环境使用前,务必确认已通过快照或其他方式对底层存储进行了备份。

以上脚本在多台服务器总计约3000+站点的环境测试通过。

Proxmox VE 单公网 IP 部署实战手册 (NAT + Cloud-Init)

适用场景:Hetzner、OVH 等只提供单公网 IP 的独立服务器,或家庭宽带下的 PVE 环境。

目标:通过 NAT 组网,让宿主机充当路由器,实现虚拟机内网隔离与公网访问。

1. 存储选型:ZFS 还是 LVM-Thin?

别纠结,根据硬件条件二选一。

方案核心特点建议
ZFS数据不死。防静默腐败,快照极强(原子级),支持异地增量备份。生产环境首选。但内存要在 16GB 以上(ZFS 吃内存做 ARC 缓存)。
LVM-Thin简单高效。接近裸盘性能,配置极简,资源占用低。家用/测试机首选。适合内存有限或不做关键数据存储的场景。

⚠️ 警告:千万别把 /boot 分区装在 ZFS 上,GRUB 对 ZFS 的特性支持很差,升级极易挂。引导分区请老老实实由 PVE 安装程序自动处理(通常是 ext4)。

2. 镜像避坑:请认准 Generic

Debian 官方提供了几种 Cloud Image,名字很像,坑很大。

  • Generic (generic)PVE 生产环境唯一推荐
    • 带标准物理硬件驱动,内核通用,兼容性最好。
    • 注意:默认锁 root 密码,必须配合 Cloud-Init 注入公钥才能登录。
  • GenericCloud (genericcloud):为 AWS/OpenStack 优化的精简版,剔除了很多物理驱动。PVE 下直通硬件时可能会缺驱动,不建议作为首选。
  • Nocloud (nocloud)仅限本地测试
    • 允许空密码 root 登录。严禁在公网环境使用,那是给黑客送肉鸡。

下载 debian-12-generic-amd64.qcow2 到 PVE 母机的 /tmp 目录。

3. 网络架构:NAT 模式配置 (核心)

单 IP 环境下,PVE 本身就是一个路由器。我们需要新建一个“内网交换机” (vmbr1)。

3.1 创建内网网桥 (GUI)

PVE 节点 -> 系统 -> 网络

  • 新建:Linux Bridge
  • 名称vmbr1
  • IPv4/CIDR10.0.0.1/24 (这是虚拟机的网关 IP)
  • 网关/桥接端口全部留空 (不要填!)
  • 应用配置

3.2 配置 NAT 转发 (CLI)

PVE 界面无法配置 NAT,必须进 Shell 操作。

第一步:开启路由转发

# 写入配置,重启不失效
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf && sysctl -p

第二步:配置 SNAT (让虚拟机能上网)

# 语法:iptables -t nat -A POSTROUTING -s '内网网段' -o 公网接口 -j MASQUERADE
# 请将 vmbr0 替换为你实际有公网 IP 的网口
iptables -t nat -A POSTROUTING -s '10.0.0.0/24' -o vmbr0 -j MASQUERADE

第三步:配置端口映射 (让外网访问虚拟机)

比如把公网 80 转发给 10.0.0.101 的 80:

iptables -t nat -A PREROUTING -i vmbr0 -p tcp --dport 80 -j DNAT --to-destination 10.0.0.101:80
# 其他端口类似添加

第四步:规则持久化

iptables 重启会丢,必须安装持久化工具:

apt update && apt install iptables-persistent -y
# 安装时选 Yes,或者手动保存:
iptables-save > /etc/iptables/rules.v4

4. 虚拟机部署:Cloud-Init 标准流程

不要用 ISO 安装系统,那是上个世纪的做法。Cloud Image 是秒级部署。

  1. 创建空壳:Web 界面创建 VM,OS 选“不使用介质”,删除默认硬盘。
  2. 导入磁盘 (Shell):# 101 是 VMID,local-lvm 是存储池名称 qm importdisk 101 /tmp/debian-12-generic-amd64.qcow2 local-lvm
  3. 挂载与扩容 (Web 硬件页面):
    • 双击 Unused Disk 0 -> 添加。
    • 关键动作:默认镜像只有 3G。选中磁盘 -> 磁盘操作 -> 调整大小 -> 输入 +37G
  4. 注入 Cloud-Init
    • 硬件 -> 添加 -> CloudInit Drive。
    • 配置 用户SSH 公钥 (必填,否则无法登录)。
    • 网络:IP 10.0.0.101/24,网关 10.0.0.1桥接选 vmbr1
    • 点击 Regenerate Image (重要!)。
  5. 启动:别忘了在“选项”里把新硬盘设为第一启动项。

5. 常见痛点 (FAQ)

Q: 既然我扩容了磁盘,为什么进系统看还是 2G?

A: PVE 只是拉大了物理磁盘 (Block Device),文件系统 (File System) 还是懵的。

登录虚拟机执行:

  1. 扩分区:growpart /dev/sda 1 (注意空格)
  2. 扩文件系统:resize2fs /dev/sda1 (Ext4) 或 xfs_growfs / (XFS)

Q: 开机后主机名是 localhost,也没有 IP?

A: Cloud-Init 炸了。通常是因为用了 Nocloud 镜像或者没点 “Regenerate Image”。

  • 排查:确保虚拟机光驱里加载了 cloudinit 盘。
  • 重置:sudo cloud-init clean --logs && sudo reboot

Q: 我想跑 Docker/K8s,性能很差?

A: 检查 CPU 类型。

默认的 kvm64 兼容性好但性能差,且不支持嵌套虚拟化。

请将 CPU 类别 (Type) 改为 host,直接透传宿主机指令集。

Q: 虚拟机卡死,Stop 按钮无效?

A: 僵尸进程。

  1. Shell 查 PID:ps aux | grep "/usr/bin/kvm -id 101"
  2. 强杀:kill -9 <PID>
  3. 解锁:qm unlock 101