告别运营商强制重拨导致的批量断网：RouterOS PPPoE 批量预重拨脚本实战
背景与痛点许多网络运维人员都遇到过这样的场景：使用 RouterOS 管理多条 PPPoE 宽带线路（例如多条 ADSL、光纤宽带聚合），运营商每隔固定周期（常见为 48 小时、72 小时或 4 天）会强制断开 PPPoE 会话并触发重拨。当几十甚至上百条 PPPoE 连接同时被运营商踢下线时，所有线路会一起进入重拨流程，导致整个网络在接下来 1~2 分钟内完全瘫痪——业务中断、游戏掉线、视频卡顿，用户投诉不断。更让人困惑的是：如果手动去重拨某一条线路（先禁用再启用），往往几秒钟就能重新获得 IP，几乎感觉不到断流。为什么运营商强制重拨就会导致长达 2 分钟的“集体罢工”？原因很简单：运营商侧的重拨通常会伴随着后台认证服务器的瞬时压力、或客户端同时发起大量并发请求造成的拥塞。而手动单条重拨则避开了“洪水式”并发，认证速度快，断线时间极短。解决思路既然运营商定期踢线无法改变，我们可以主动抢占先机：在运营商强制重拨之前，我们自己逐个、有序地重拨每一条 PPPoE 连接。只要重拨间隔足够分散（例如每隔 30 秒重拨一条），每条线路只会有几秒钟的短暂中断，且不同线路的中断时间错开，整体网络几乎不受影响。这就像火车站的检票口：如果几千人同时涌向一个入口，必然拥堵；如果安排多个入口并分批放行，队伍就顺畅得多。下面给出的 RouterOS 脚本，可以自动检查所有 PPPoE 客户端的运行时长，一旦超过设定阈值（比如 4 天），就逐个执行“禁用→启用”重拨，每次重拨后等待 30 秒再操作下一条。脚本详解# ========================================================= # PPPoE 批量重拨脚本 # 功能：检查所有 PPPoE 连接，运行时间超过 4 天的自动重拨 # 重拨间隔：每个连接重拨后等待 30 秒 # ========================================================= :local maxDays 4 :local delaySeconds 30 :local logPrefix "【PPPoE批量重拨】" :local thresholdSec ($maxDays * 86400) :local renewedCount 0 :local totalChecked 0 :local ifaceList [/interface pppoe-client find] :log info "$logPrefix 开始检查所有 PPPoE 连接（运行 > $maxDays 天则重拨）" :foreach iface in=$ifaceList do={ :local ifName [/interface pppoe-client get $iface name] :set totalChecked ($totalChecked + 1) :local uptimeStr ([/interface pppoe-client monitor $ifName once as-value]->"uptime") :if ([:len $uptimeStr] = 0) do={ :log info "$logPrefix $ifName 当前未连接，跳过" continue } :local uptimeSec [:totime $uptimeStr] :if ($uptimeSec > $thresholdSec) do={ :log info "$logPrefix $ifName 已运行 $uptimeStr（超过 $maxDays 天），正在重拨..." # 执行重拨 /interface pppoe-client disable [find name=$ifName] /interface pppoe-client enable [find name=$ifName] :set renewedCount ($renewedCount + 1) :if ($renewedCount < [:len $ifaceList]) do={ :delay $delaySeconds } } else={ :log info "$logPrefix $ifName 已运行 $uptimeStr（未超过 $maxDays 天），无需重拨" } } :log info "$logPrefix 执行完成。共检查 $totalChecked 个连接，实际重拨 $renewedCount 个。"关键参数说明参数默认值含义maxDays4连接运行超过此天数即触发重拨（建议小于运营商强制周期，比如运营商 5 天踢线就设为 4）delaySeconds30每重拨完一条连接后等待的秒数，避免多条同时重拨脚本工作流程获取所有 PPPoE Client 接口列表。遍历每个接口，读取其当前 uptime（在线时长）。如果在线时长超过 maxDays（以秒计算），执行：记录日志禁用接口 → 启用接口（触发重拨）重拨计数 +1如果还有未处理的接口，等待 delaySeconds 秒如果在线时长未超阈值或连接已断开，则直接跳过。最终输出总检查和实际重拨数量。为什么这样能解决问题？主动预重拨：在运营商强踢之前（比如运营商周期是 5 天，我们在第 4 天做一次），主动刷新会话。此时运营商侧不会同时踢掉所有线路，重拨过程与手动重拨一样平滑。串行处理 + 间隔：避免大量连接在同一秒发起认证请求，极大降低 PPPoE 认证服务器的瞬时压力，也避免了 RouterOS 自身并发连接过多可能导致的资源问题。精准判断：只处理运行时间超阈值的连接，已重拨过的连接 uptime 会重置，下次检查时不会重复操作。部署步骤1. 添加脚本到 RouterOS通过 WinBox 或 SSH 进入 RouterOS：/system script add name="pppoe-auto-renew" source="上述脚本内容"2. 设置定时任务为了让脚本每天自动运行一次（例如凌晨低峰期），添加 scheduler：/system scheduler add name="daily-pppoe-renew" on-event="/system script run pppoe-auto-renew" start-time=03:00:00 interval=1d建议将执行时间设在业务低峰期，比如凌晨 2:00~5:00。如果某条线路刚好在白天业务高峰时达到了阈值，也不必担心——单条线路重拨只中断几秒钟，用户基本无感。3. 手动测试首次运行前可以先测试：/system script run pppoe-auto-renew通过日志（/log print）观察执行结果。效果对比场景中断时长影响范围运营商集体强制重拨约 1~2 分钟所有线路同时断，全网瘫痪脚本主动预重拨每条线路 < 10 秒同一时间只有一条线路中断，其他线路正常对于链路聚合、PCC 负载均衡、多线备援等场景，单条线路短暂中断不会导致整体断网，业务几乎无感知。注意事项运营商周期摸底：建议先观察日志，确定运营商强制重拨的间隔（通常为 2~5 天），将 maxDays 设置为比该间隔小半天或一天。例如运营商 72 小时（3 天）踢线，则设 maxDays 为 2。间隔时间调整：delaySeconds 可根据线路数量调整。如果只有几条线路，可设为 10~20 秒；如果有上百条，建议 30~60 秒，避免瞬间发起过多认证请求。日志监控：通过 /log find where topics~"pppoe" 观察重拨是否成功，确认每次重拨后 uptime 被重置。拨号依赖：如果 PPPoE 接口绑定了特定网口或 vlan，确保禁用/启用操作不会影响底层接口。进阶优化动态延迟：可以根据当前在线连接数计算延迟，例如 :delay (3600 / $totalLines)，让重拨均匀分布在一小时内。失败重试：重拨后检查接口是否成功获取 IP，若失败则再次重试并延长等待时间。通知推送：结合 Telegram 或 E-mail 脚本，将重拨结果发送给管理员。
📝 从零开始编写 TextFSM 模板：网络配置解析实战
让非结构化文本变成整齐的表格数据，TextFSM 是网络工程师的必备利器。一、什么是 TextFSM？TextFSM 是 Google 开源的一个 Python 库，专门用于将半结构化的文本（如路由器、交换机、防火墙的 CLI 输出）解析成表格形式的数据（列表嵌套列表或字典）。它通过一个模板文件来定义要提取的字段和匹配规则，非常适合网络自动化场景——例如批量采集设备信息、生成巡检报告、对接 Ansible 或 Nornir。二、模板的核心语法一个 TextFSM 模板由三个部分组成：Value 定义 – 声明要捕获的变量及其正则表达式。状态机 – 定义如何逐行扫描文本，通常包含 Start 状态和其他自定义状态。动作 – 匹配到某行后执行的动作：Record（保存当前记录）、Continue（继续匹配）、Clear（清空记录）、Error（报错）等。2.1 Value 语句Value 字段名 (正则表达式)字段名：通常大写，如 NAME、IP_ADDR。正则表达式：用括号括起来，支持 Python 正则语法。可选修饰符：Required（必须存在）、Filldown（下行未捕获时沿用上行值）、List（多个匹配值保存为列表）。示例：Value Required ID (\d+) Value NAME (\S+) Value STATUS (up|down) Value MAC ([0-9a-f]{2}(:[0-9a-f]{2}){5})2.2 状态与匹配行Start ^期望的正则表达式 -> 动作 [下一个状态]状态名：通常大写，Start 是起始状态。行匹配符：^ 行首，$$ 行尾（注意：TextFSM 中用 $$ 表示行尾，因为单个 $ 有特殊含义？实际是单 $ 即可，但历史版本有些差异，建议用 $。为保险可写 $$ 或 $，看引擎版本）。变量引用：${字段名}，用于在正则中引用已捕获的值。常见动作：动作含义-> Record保存当前所有 Value 值作为一条记录，然后清空（除非指定 Filldown）-> Continue继续匹配当前行的其他规则-> Clear清空当前已捕获的 Value，不保存-> Error模板解析错误，停止处理-> 状态名跳转到另一个状态三、编写第一个模板场景：解析 show interface brief 输出设备输出（Cisco 风格）：Interface IP-Address OK? Method Status Protocol GigabitEthernet0/0 192.168.1.1 YES manual up up GigabitEthernet0/1 unassigned YES unset administratively down down Loopback0 10.0.0.1 YES manual up up目标：提取接口名、IP 地址、状态、协议。模板 interface_brief.textfsm：Value INTF (\S+) Value IP (\S+) Value STATUS (up|down|administratively down) Value PROTO (up|down) Start # 跳过表头行 ^Interface ^\s*$$ # 匹配数据行 ^${INTF}\s+${IP}\s+\S+\s+\S+\s+${STATUS}\s+${PROTO}\s*$$ -> Record ^. -> ErrorPython 调用：import textfsm from io import StringIO with open('interface_brief.textfsm') as f: fsm = textfsm.TextFSM(f) output = """ Interface IP-Address OK? Method Status Protocol GigabitEthernet0/0 192.168.1.1 YES manual up up GigabitEthernet0/1 unassigned YES unset administratively down down Loopback0 10.0.0.1 YES manual up up """ result = fsm.ParseText(output) headers = fsm.header for row in result: print(dict(zip(headers, row)))输出：{'INTF': 'GigabitEthernet0/0', 'IP': '192.168.1.1', 'STATUS': 'up', 'PROTO': 'up'} {'INTF': 'GigabitEthernet0/1', 'IP': 'unassigned', 'STATUS': 'administratively down', 'PROTO': 'down'} {'INTF': 'Loopback0', 'IP': '10.0.0.1', 'STATUS': 'up', 'PROTO': 'up'}四、进阶技巧4.1 多状态机（处理复杂输出）当设备输出包含多个段落（如接口列表 + VLAN 列表）时，可以用多个状态分别处理。示例：解析 show version 和 show running-config 合并输出。Value HOSTNAME (\S+) Value VERSION (\S+) Value INTERFACE (\S+) Start # 匹配主机名行 ^Hostname:\s+${HOSTNAME} -> VersionState ^. -> Error VersionState ^Version\s+${VERSION} -> InterfaceState ^. -> Error InterfaceState ^interface\s+${INTERFACE} -> Record ^\s*$$ ^. -> Error4.2 可选字段与填空（Filldown）有些行缺少某个字段，希望沿用上一行的值。Value Filldown NAME (\S+) Value IP (\d+\.\d+\.\d+\.\d+) Start ^interface\s+${NAME} ^\s+ip address\s+${IP} -> Record这样，每个接口的 IP 都会记录，而接口名会自动向下填充到没有 interface 行的段落。4.3 列表字段（List）捕获同一行中的多个匹配项（例如 ACL 条目中的多个 IP）。Value List IP_ADDR (\d+\.\d+\.\d+\.\d+) Start ^permit\s+ip\s+host\s+${IP_ADDR} -> Record4.4 跨行捕获（Continue）有时一个字段的值跨越多行（如描述信息），可以使用 Continue 累积。Value DESCRIPTION (.*) Value NAME (\S+) Start ^interface\s+${NAME} ^\s+description\s+${DESCRIPTION} -> Continue ^\s+ip address -> RecordContinue 动作会让当前状态继续对同一行的其他规则进行匹配，也可以配合状态跳转实现复杂逻辑。五、调试与常见错误5.1 语法错误：Invalid state name: 'Value ...'原因：Value 定义行前有空格或空行，TextFSM 误将其当作状态名。解决：保证 Value 顶格写，或者用 textwrap.dedent 处理多行字符串。5.2 匹配不上任何行调试步骤：将模板中的正则一步步简化，先用 .* 测试。使用 textfsm 提供的 --debug 模式（命令行工具）。在 Python 中打印 fsm.ParseText 的异常信息。try: result = fsm.ParseText(data) except textfsm.TextFSMError as e: print("解析错误:", e)5.3 行尾符号 $ vs $$官方文档使用的是 $，但部分早期版本要求 $$。建议用 $ 并测试。如果你的模板总是匹配不到行尾，可以尝试 $$。5.4 贪婪与非贪婪正则默认贪婪，若要匹配最短内容，请用 .*?。例如捕获括号内的内容：Value VAR \(.*?\)。六、实战：解析 RouterOS 接口列表（你的场景）基于你之前遇到的问题，我们重写一个适配 RouterOS /interface print 输出的模板。原始输出（省略表头）：0 ether1 ether 1500 00:0C:29:2E:99:9C 1 RS ether2 ether 1500 00:0C:29:2E:99:A6 2 R bridge bridge 1500 65535 00:0C:29:2E:99:A6模板（忽略不完整的行）：Value ID (\d+) Value FLAGS (\S*) Value NAME (\S+) Value TYPE (\S+) Value ACTUAL_MTU (\d+) Value L2MTU (\d+) Value MAC_ADDRESS ([0-9a-fA-F:]+) Start ^Flags: ^Columns: ^\s*# ^\s*$$ ^\s*${ID}\s+${FLAGS}\s+${NAME}\s+${TYPE}\s+${ACTUAL_MTU}(?:\s+${L2MTU})?\s+${MAC_ADDRESS}\s*$$ -> Record ^. -> Error解读：\s+${FLAGS} 捕获 RS、R 或空。(?:\s+${L2MTU})? 表示 L2MTU 列可选（因为第 2 行有，第 1 行没有）。缺少 ACTUAL_MTU 的行（如第 7 行）会被跳过，这符合预期。扩展标志解析：在 Python 中进一步拆分 FLAGS：for row in result: flags = row['FLAGS'] row['RUNNING'] = 'R' in flags row['SLAVE'] = 'S' in flags七、最佳实践与工具推荐在线测试：使用 TextFSM 在线编辑器 快速验证模板。复用社区模板：GitHub 上有大量 ntc-templates，覆盖主流网络设备。结合 Nornir/Ansible：TextFSM 是 nornir_netmiko 和 ansible.netcommon.cli_parse 的默认解析引擎。编写健壮的正则：尽量使用非空白符 \S+ 而非 .*，避免过度匹配。八、总结TextFSM 模板的核心就是定义字段 → 逐行匹配 → 保存记录。掌握它的语法后，你可以轻松将各种 CLI 输出转化为结构化数据，极大提升网络自动化的效率。开始动手写你的第一个模板吧！先用 show version 试试，然后逐步挑战更复杂的命令。遇到问题，记得检查空格、行尾符号和状态跳转。💡 小贴士：保留一份常用模板库，每次新设备只需微调正则，生产力翻倍。扩展阅读：TextFSM 官方文档NTC Templates 源码学习希望这篇博客能帮助你彻底理解 TextFSM 模板的写法。如有疑问，欢迎留言交流！
设计思路两台 MikroTik 路由器，各自有 6 条 PPPoE 线路（pppoe-out1~6）。要求：只有当本端 6 条 PPPoE 全部断开时，本机才放弃 VRRP Master 角色；当线路恢复后，本机恢复优先级，但 不自动抢回 Master（避免网络波动）。实现方法：VRRP 设置为 preemption-mode=no（非抢占）。通过脚本定期检测所有 6 条 PPPoE 接口的 running 状态。如果全部断开 → 将本机 VRRP priority 设为 0（立即放弃 Master）。如果至少一条恢复 → 将本机 VRRP priority 恢复为正常值（如 150 或 140），但不抢回 Master。脚本配置ROS1（正常优先级 150）:local pppoeList {"pppoe-out1";"pppoe-out2";"pppoe-out3";"pppoe-out4";"pppoe-out5";"pppoe-out6"} :local allDown true :foreach i in=$pppoeList do={ :if ([/interface get $i running]) do={ :set allDown false } } :if ($allDown) do={ /interface vrrp set [find name="vrrp1"] priority=0 } else={ :delay 10 /interface vrrp set [find name="vrrp1"] priority=150 }ROS2（正常优先级 140）:local pppoeList {"pppoe-out1";"pppoe-out2";"pppoe-out3";"pppoe-out4";"pppoe-out5";"pppoe-out6"} :local allDown true :foreach i in=$pppoeList do={ :if ([/interface get $i running]) do={ :set allDown false } } :if ($allDown) do={ /interface vrrp set [find name="vrrp1"] priority=0 } else={ :delay 10 /interface vrrp set [find name="vrrp1"] priority=140 } VRRP 基础配置ROS1（正常优先级 150）/interface vrrp add name=vrrp1 vrid=1 interface=bridge priority=150 preemption-mode=noROS2（正常优先级 140）/interface vrrp add name=vrrp1 vrid=1 interface=bridge priority=140 preemption-mode=no 最终效果：任意一台检测到自己的 6 条 PPPoE 全断 → priority=0 → 立即让出 Master。恢复后 priority 回到正常值，但因 preemption-mode=no 不会抢回。
ESXi 显卡直通完全指南：从核显屏蔽到性能调优
在虚拟化环境中，将物理显卡直接分配给虚拟机（俗称“直通”）可以显著提升图形处理性能，适用于游戏串流、3D 渲染或机器学习等场景。本文将手把手教你如何在 ESXi 上完整配置显卡直通，并解决因屏蔽核显导致的管理界面丢失问题。⚠️ 适用环境：ESXi 6.7 / 7.0 / 8.0 ⚠️ 硬件要求：CPU 支持 VT-d / AMD-Vi，主板 BIOS 支持 Above 4G Decoding 及集显优先输出📋 准备工作确认你的独立显卡（NVIDIA/AMD）已正确安装在服务器上确保 ESXi 主机有 SSH 访问权限建议提前备份现有虚拟机配置1️⃣ BIOS 设置：强制优先使用集显在直通独立显卡前，必须让 BIOS 将集成显卡设为系统主显示输出。这样才能释放 PCI 通道的独显用于直通。操作步骤： 进入主板 BIOS → 找到 Primary Graphics Adapter 或 Init Display First → 选择 IGD（集成显卡）或 Internal VGA。 下图为典型 BIOS 设置界面示例：（不同主板界面略有差异，请以实际选项为准）保存设置并重启 ESXi 主机。2️⃣ Web 界面开启 PCI 设备直通登录 ESXi 主机 Web 管理界面，执行设备直通切换：导航至 管理 → 硬件 → PCI 设备找到你的独立显卡（通常包含 VGA 控制器 和 音频设备 两个功能）勾选这两个设备，点击 切换直通系统会要求重新启动主机使其生效💡 记得同时直通显卡上的 HDMI 音频设备，否则虚拟机可能无声音输出。3️⃣ 屏蔽 ESXi 对核显的占用（命令行关键步骤）默认情况下，ESXi 内核会占用核显作为控制台输出，导致核显无法直通给其他虚拟机。通过以下命令禁用 vga 模块，完全释放核显：# 屏蔽核显（禁止 ESXi 调用） esxcli system settings kernel set -s vga -v FALSE若未来需要恢复 ESXi 对核显的调用（例如故障排查），执行：# 恢复 ESXi 显卡调用 esxcli system settings kernel set -s vga -v TRUE4️⃣ 修改 passthru.map 添加设备 IDESXi 需要识别显卡的 PCI 设备 ID 才能正确直通。通过 SSH 连接主机，先获取显卡 ID：lspci -v在输出中找到你的显卡（例如 NVIDIA/AMD），记录下类似 XXXX:XX:XX.X 格式的 设备 ID 和括号中的 供应商 ID。![lspci 获取显卡设备 ID]][3]接着使用 vi 编辑配置文件：vi /etc/vmware/passthru.map根据显卡类型在文件末尾添加一行（示例为 NVIDIA 显卡）：# NVIDIA 显卡直通配置 10de XXXXX d3d0 false10de：NVIDIA 供应商 IDXXXXX：你的设备 IDd3d0：直通类型参数（常见为 d3d0 或 link）false：禁用 ACS 检查具体值请参考下图中的修改示例：保存并退出（:wq）。5️⃣ 修改 esx.conf 强制设置设备拥有者（高级操作）此步骤可确保 PCI 设备被正确标记为直通设备。使用 vi 编辑：vi /etc/vmware/esx.conf在文件末尾添加以下内容（将 0000:02:00.0 替换为你的显卡 PCI 设备 ID）：/device/0000:02:00.0/owner = "passthru"🔧 设备 ID 格式：通过 lspci 获得的完整 PCI 地址，例如 02:00.0 应写成 0000:02:00.0。如果你的显卡包含音频功能，需要为两个设备分别添加此条目。保存后重启主机：reboot6️⃣ 配置虚拟机高级参数（解决显卡识别与内存寻址）编辑虚拟机 .vmx 文件或 Web 界面添加参数在 Web 管理界面： 编辑目标虚拟机 → 虚拟机选项 → 高级 → 配置参数 → 添加参数。需手动添加以下三个关键参数：参数名值作用hypervisor.cpuid.v0FALSE隐藏虚拟机监控程序签名，解决 NVIDIA 消费级显卡的 Code 43 错误pciPassthru.use64bitMMIOTRUE启用 64 位 MMIO 内存映射，解决大显存显卡无法识别问题pciPassthru.64bitMMIOSizeGB64分配 MMIO 地址空间大小（必须为 2 的幂，如 64GB）下图展示了 Web 界面的添加示例：🧠 提示：对于显存超过 8GB 的显卡（如 RTX 3080 / RX 6800），建议将 64bitMMIOSizeGB 设为 64 或更高。🖥️ 管理替代方案：当 Web 管理界面无法显示时由于我们屏蔽了核显，ESXi 直接连接的显示器将不再显示任何内容（包括 DCUI 管理界面）。此时你可以通过以下两种方式管理主机：方式一：SSH + 文本控制台SSH 登录后，直接调出经典的 ESXi 文本管理界面：dcui执行后，当前 SSH 会话会切换到类似本地控制台的菜单，可配置网络、重启服务等。方式二：完全远程管理日常运维通过 ESXi Web 界面（https://你的ESXi-IP）或 vCenter 进行，物理显示器仅在底层故障时使用。✅ 验证直通是否成功启动配置了直通的虚拟机在虚拟机内安装对应显卡驱动程序（NVIDIA/AMD 官方驱动）连接显示器到直通显卡的物理接口若能正常显示画面，且设备管理器识别到显卡，表示直通成功🎮 游戏玩家：建议额外添加 msiEnabled = "FALSE" 参数（部分旧显卡中断问题），并根据需求配置 USB 直通。
使用 MikroTik RouterOS 搭建主备隧道（WireGuard + L2TP）并通过 VXLAN 实现二层桥接
引言在企业网络或数据中心场景中，经常需要将两个不同地理位置的三层网络通过二层打通，使两端的设备像在同一个交换机下一样工作。同时，为了保障链路的可靠性，还需要建立主备隧道，当主隧道故障时业务能自动切换到备用隧道。本文将详细介绍如何在两台 MikroTik RouterOS 路由器上：建立 WireGuard 隧道作为主链路建立 L2TP/IPsec 隧道作为备份链路在两个隧道之上运行 VXLAN，并将两端的 ether3 物理接口桥接起来通过 Netwatch 实现隧道的主备自动切换拓扑规划假设两台路由器分别称为 Router-1（站点A） 和 Router-2（站点B）。它们各有至少一个公网接口（ether1）用于建立隧道，以及一个需要二层互通的本地接口 ether3。IP 地址规划角色Router-1Router-2公网 IP203.0.113.1（示例）203.0.113.2（示例）WireGuard 隧道 IP10.10.1.1/2410.10.1.2/24L2TP 隧道 IP10.10.2.1/24（作为服务器）10.10.2.2/24（作为客户端）VXLAN VNI100100VXLAN 底层端口8472（默认）8472前提条件两台 RouterOS 设备（建议 7.x 版本）至少一侧拥有固定的公网 IP（如果两端都有更好）防火墙已放行必要端口：WireGuard: UDP 51820L2TP/IPsec: UDP 500, 4500, IP 协议 50(ESP)VXLAN: UDP 8472确保两端 ether3 接口为空（未配置 IP 地址，准备加入桥）第一步：配置 WireGuard 主隧道WireGuard 配置简单、性能高，适合做主隧道。Router-1（站点A）# 生成密钥对（若未生成） /interface wireguard generate-seed # 创建 WireGuard 接口 /interface wireguard add name=wg-main mtu=1420 listen-port=51820 # 配置隧道 IP /ip address add address=10.10.1.1/24 interface=wg-main # 添加对端（Router-2） /interface wireguard peers add \ interface=wg-main \ public-key="<Router-2-Public-Key>" \ allowed-address=10.10.1.2/32Router-2（站点B）/interface wireguard add name=wg-main mtu=1420 listen-port=51820 /ip address add address=10.10.1.2/24 interface=wg-main /interface wireguard peers add \ interface=wg-main \ public-key="<Router-1-Public-Key>" \ allowed-address=10.10.1.1/32注意：<Router-X-Public-Key> 需要通过 /interface wireguard print 查看对方接口的公钥后填写。测试时可以用 ping 10.10.1.2 确认主隧道已通。第二步：配置 L2TP 备用隧道L2TP/IPsec 在 NAT 环境下表现良好，适合作为备用路径。Router-1（L2TP 服务器）# 开启 L2TP 服务器并设置 IPsec 共享密钥 /interface l2tp-server server set enabled=yes default-profile=default \ ipsec-secret=MySecretKey123 # 创建 L2TP 配置文件（分配隧道 IP） /ppp profile add name=l2tp-profile \ local-address=10.10.2.1 \ remote-address=10.10.2.10-10.10.2.20 # 创建 PPP 用户 /ppp secret add name=backup-user password=123456 \ profile=l2tp-profile service=l2tpRouter-2（L2TP 客户端）# 添加 L2TP 客户端接口，连接 Router-1 的公网 IP /interface l2tp-client add name=l2tp-backup \ connect-to=203.0.113.1 \ user=backup-user password=123456 \ profile=default \ ipsec-secret=MySecretKey123 # 稍等几秒后，检查是否获取到 IP /ip address print interface=l2tp-backup # 预期会看到 10.10.2.2/32 之类的地址此时从 Router-2 能 ping 通 Router-1 的 L2TP 隧道 IP 10.10.2.1 即表示备用隧道就绪。第三步：创建 VXLAN 并桥接 ether3VXLAN 可以将二层帧封装在 UDP 中，通过底层 IP 隧道传输。在两台路由器上分别执行# 1. 创建 VXLAN 接口，绑定到主隧道（先只配置主） /interface vxlan add name=vxlan-bridge vni=100 port=8472 interface=wg-main # 2. 添加远端 VTEP（Virtual Tunnel End Point）地址 /interface vxlan vteps add interface=vxlan-bridge remote-ip=10.10.1.2 # 3. 创建一个二层桥 /interface bridge add name=bridge-vxlan # 4. 将 VXLAN 接口和物理接口 ether3 加入桥 /interface bridge port add bridge=bridge-vxlan interface=vxlan-bridge /interface bridge port add bridge=bridge-vxlan interface=ether3检查：执行 /interface bridge port print，确保两个端口状态为 R（Running）。此时，连接在 ether3 上的两台 PC 应该已经可以二层互通了（如同在一个交换机下）。注意：VXLAN 默认使用 UDP 8472 端口，请确保防火墙允许该端口在 wg-main 和 l2tp-backup 接口上通过。第四步：实现主备自动切换目前 VXLAN 只使用了 WireGuard 隧道。为了当主隧道故障时自动切换到 L2TP，我们需要利用 Netwatch 监控主隧道网关，并动态修改 VXLAN 的 VTEP 地址。切换逻辑主隧道正常：VXLAN 的 VTEP 指向 10.10.1.2（WireGuard）主隧道断开：删除主 VTEP，添加备用 VTEP 10.10.2.2（L2TP）主隧道恢复：切回主 VTEP在 Router-1 和 Router-2 上分别配置以下 Netwatch 规则（注意 IP 地址为对方对应隧道的 IP）：/tool netwatch add host=10.10.1.2 interval=10s timeout=1s \ down-script=" # 移除主 VTEP，添加备用 VTEP /interface vxlan vteps remove [find interface=vxlan-bridge remote-ip=10.10.1.2] /interface vxlan vteps add interface=vxlan-bridge remote-ip=10.10.2.2 :log info \"VXLAN switched to L2TP backup tunnel\" " \ up-script=" # 移除备用 VTEP，添加主 VTEP /interface vxlan vteps remove [find interface=vxlan-bridge remote-ip=10.10.2.2] /interface vxlan vteps add interface=vxlan-bridge remote-ip=10.10.1.2 :log info \"VXLAN switched back to WireGuard main tunnel\" "重要提示：在 Router-1 上监控 10.10.1.2，在 Router-2 上监控 10.10.1.1；两端的备用 VTEP 分别是 10.10.2.2 和 10.10.2.1。请根据实际 IP 调整脚本中的地址。高级优化：MTU 问题VXLAN 封装增加 50 字节开销，WireGuard 又增加约 80 字节，L2TP/IPsec 也有额外开销。如果 MTU 处理不当，大包会被丢弃。解决方案（需要在两台路由器同时操作）：物理外网接口（ether1）设置 MTU 1500（或 ISP 允许的最大值，如 1520）WireGuard 接口：设置 mtu=1420L2TP 接口：设置 mtu=1400（因为 L2TP+IPsec 开销更大）VXLAN 接口：保持默认 MTU 1500（它会根据底层自动分片，但最好限制客户端 MTU）客户端设备：将接在 ether3 上的所有设备（PC、服务器）的网卡 MTU 改为 1400 或更低这样即使最大帧也不会超过底层隧道的承载能力。验证与故障排查验证二层连通性在两台接在 ether3 的 PC 上配置同网段 IP（如 192.168.100.10/24 和 192.168.100.20/24）互相 ping 测试执行大包测试：ping -l 1472 -f 192.168.100.20（Windows）或 ping -M do -s 1472 192.168.100.20（Linux）验证主备切换手动禁用 WireGuard 接口：/interface wireguard disable wg-main观察 Netwatch 日志，确认 VTEP 已切换到 L2TP IP再次 ping 对端 PC，应该仅有短暂中断（约 10 秒）重新启用 WireGuard，隧道应自动切回常见问题排查现象可能原因解决动作VXLAN 桥接不通VTEP 未添加或 IP 错误/interface vxlan vteps print 检查二层能通但大包不通MTU 问题按上文降低客户端 MTU切换后不通L2TP 隧道未获 IP检查 /ip address 看 l2tp 接口是否拿到 10.10.2.xNetwatch 不触发监控的目标 IP 不可达？先手动 ping 确认，再检查防火墙是否允许 ICMP
引言在企业网络或数据中心场景中，经常需要将两个不同地理位置的三层网络通过二层打通，使两端的设备像在同一个交换机下一样工作。同时，为了保障链路的可靠性，还需要建立主备隧道，当主隧道故障时业务能自动切换到备用隧道。本文将详细介绍如何在两台 MikroTik RouterOS 路由器上：建立 WireGuard 隧道作为主链路建立 L2TP/IPsec 隧道作为备份链路在两个隧道之上运行 VXLAN，并将两端的 ether3 物理接口桥接起来通过 Netwatch 实现隧道的主备自动切换重点：针对视频监控业务（UDP 大流量）进行 MTU 优化，解决花屏、卡顿问题拓扑规划假设两台路由器分别称为 Router-1（站点A） 和 Router-2（站点B）。它们各有至少一个公网接口（ether1）用于建立隧道，以及一个需要二层互通的本地接口 ether3。IP 地址规划角色Router-1Router-2公网 IP203.0.113.1（示例）203.0.113.2（示例）WireGuard 隧道 IP10.10.1.1/2410.10.1.2/24L2TP 隧道 IP10.10.2.1/24（作为服务器）10.10.2.2/24（作为客户端）VXLAN VNI100100VXLAN 底层端口8472（默认）8472前提条件两台 RouterOS 设备（建议 7.x 版本）至少一侧拥有固定的公网 IP（本文假设 Router-1 拥有静态公网 IP）防火墙已放行必要端口：WireGuard: UDP 51820L2TP/IPsec: UDP 500, 4500, IP 协议 50(ESP)VXLAN: UDP 8472确保两端 ether3 接口为空（未配置 IP 地址，准备加入桥）第一步：配置 WireGuard 主隧道WireGuard 配置简单、性能高，适合做主隧道。Router-1（站点A）# 生成密钥对（若未生成） /interface wireguard generate-seed # 创建 WireGuard 接口 /interface wireguard add name=wg-main mtu=1450 listen-port=51820 # 配置隧道 IP /ip address add address=10.10.1.1/24 interface=wg-main # 添加对端（Router-2） /interface wireguard peers add \ interface=wg-main \ public-key="<Router-2-Public-Key>" \ allowed-address=10.10.1.2/32Router-2（站点B）/interface wireguard add name=wg-main mtu=1450 listen-port=51820 /ip address add address=10.10.1.2/24 interface=wg-main /interface wireguard peers add \ interface=wg-main \ public-key="<Router-1-Public-Key>" \ allowed-address=10.10.1.1/32注意：此处 WireGuard 接口的 MTU 设为 1450，这是经过计算后针对视频监控优化的值，后面会详细解释。第二步：配置 L2TP 备用隧道L2TP/IPsec 在 NAT 环境下表现良好，适合作为备用路径。Router-1（L2TP 服务器）# 开启 L2TP 服务器并设置 IPsec 共享密钥 /interface l2tp-server server set enabled=yes default-profile=default \ ipsec-secret=MySecretKey123 # 创建 L2TP 配置文件（分配隧道 IP） /ppp profile add name=l2tp-profile \ local-address=10.10.2.1 \ remote-address=10.10.2.10-10.10.2.20 # 创建 PPP 用户 /ppp secret add name=backup-user password=123456 \ profile=l2tp-profile service=l2tpRouter-2（L2TP 客户端）# 添加 L2TP 客户端接口，连接 Router-1 的公网 IP /interface l2tp-client add name=l2tp-backup \ connect-to=203.0.113.1 \ user=backup-user password=123456 \ profile=default \ ipsec-secret=MySecretKey123 \ mtu=1400 # 稍等几秒后，检查是否获取到 IP /ip address print interface=l2tp-backup # 预期会看到 10.10.2.2/32 之类的地址这里特意将 L2TP 接口 MTU 设为 1400，因为 L2TP+IPsec 封装开销较大，预留空间。第三步：创建 VXLAN 并桥接 ether3VXLAN 可以将二层帧封装在 UDP 中，通过底层 IP 隧道传输。在两台路由器上分别执行# 1. 创建 VXLAN 接口，绑定到主隧道 /interface vxlan add name=vxlan-bridge vni=100 port=8472 interface=wg-main mtu=1400 # 2. 添加远端 VTEP（Virtual Tunnel End Point）地址 /interface vxlan vteps add interface=vxlan-bridge remote-ip=10.10.1.2 # 3. 创建一个二层桥 /interface bridge add name=bridge-vxlan # 4. 将 VXLAN 接口和物理接口 ether3 加入桥 /interface bridge port add bridge=bridge-vxlan interface=vxlan-bridge /interface bridge port add bridge=bridge-vxlan interface=ether3关键点：VXLAN 接口的 MTU 设为 1400。这个数值与 WireGuard 的 1450 配合：1450 (WG MTU) - 50 (VXLAN 头) = 1400。这样可以在 VXLAN 封装之前就由 RouterOS 对原始数据包进行分片（如果需要），避免多层分片带来的性能损耗。第四步：实现主备自动切换目前 VXLAN 只使用了 WireGuard 隧道。为了当主隧道故障时自动切换到 L2TP，我们需要利用 Netwatch 监控主隧道网关，并动态修改 VXLAN 的 VTEP 地址。切换逻辑主隧道正常：VXLAN 的 VTEP 指向 10.10.1.2（WireGuard）主隧道断开：删除主 VTEP，添加备用 VTEP 10.10.2.2（L2TP）主隧道恢复：切回主 VTEP在 Router-1 和 Router-2 上分别配置以下 Netwatch 规则（注意 IP 地址为对方对应隧道的 IP）：/tool netwatch add host=10.10.1.2 interval=10s timeout=1s \ down-script=" # 移除主 VTEP，添加备用 VTEP /interface vxlan vteps remove [find interface=vxlan-bridge remote-ip=10.10.1.2] /interface vxlan vteps add interface=vxlan-bridge remote-ip=10.10.2.2 :log info \"VXLAN switched to L2TP backup tunnel\" " \ up-script=" # 移除备用 VTEP，添加主 VTEP /interface vxlan vteps remove [find interface=vxlan-bridge remote-ip=10.10.2.2] /interface vxlan vteps add interface=vxlan-bridge remote-ip=10.10.1.2 :log info \"VXLAN switched back to WireGuard main tunnel\" "重要提示：在 Router-1 上监控 10.10.1.2，在 Router-2 上监控 10.10.1.1；两端的备用 VTEP 分别是 10.10.2.2 和 10.10.2.1。请根据实际 IP 调整脚本中的地址。第五步：针对视频监控业务的 MTU 优化（核心）如果你的业务主要是视频监控（RTSP/RTP over UDP），那么普通的 MTU 优化思路（比如仅仅调低接口 MTU 或依靠 TCP MSS Clamping）是不够的。因为：UDP 没有 MSS 概念：无法在防火墙里通过 change-mss 修改 UDP 包大小。监控摄像头通常是嵌入式设备：无法修改其网卡 MTU。视频流对分片和丢包敏感：过多分片会导致接收端重组开销，可能引起花屏或卡顿。因此，我们必须从隧道层面主动设计分片策略，而不是试图避免分片。5.1 为什么分片无法避免？以典型场景为例：摄像头发出一个 1500 字节的 UDP 包（包含 RTP 头 + 视频数据）。经过 VXLAN 封装：+50 字节 → 1550 字节经过 WireGuard 封装：+80 字节 → 1630 字节加上外网以太网头：+14 字节 → 1644 字节而公网路径的 MTU 普遍是 1500 字节。所以 1644 > 1500，最终数据包在某个节点（通常是 ISP 侧）必然会被分片。我们的优化目标不是避免分片，而是控制分片发生的位置，尽量减少 RouterOS 内部的多层分片，将分片压力转移给 ISP 或发生在 VXLAN 封装之前。5.2 配置策略：统一 MTU 链我们通过精心设置每一层接口的 MTU，实现“一次分片，尽早分片”。接口层推荐 MTU计算依据作用物理外网接口 ether11500运营商链路标准与外网匹配WireGuard 隧道 wg-main1450比物理接口低 50，预留 VXLAN 头部分空间使得 WG 封装后的包略大于 1500，迫使 ISP 进行一次分片VXLAN 接口 vxlan-bridge14001450 - 50 (VXLAN 头)让 VXLAN 在封装前主动对原始视频包分片（硬件分片）L2TP 备用接口1400预留 L2TP+IPsec 开销保证备用隧道切换后性能5.3 为何不通过 Mangle 修改客户端 MTU？很多管理员会问：能不能在 mangle 里修改客户端发来的包的 MTU？答案：不能。 因为 mangle 只能修改 IP/TCP 头，而 MTU 是接口属性，不是数据包里的字段。change-mss 动作修改的是 TCP 的 MSS，对 UDP 毫无作用。所以对于监控这种 UDP 业务，无法通过 mangle 解决。5.4 添加防火墙辅助规则虽然不能改 UDP 包大小，但我们可以做一些辅助配置：允许 ICMP（重要）确保防火墙没有丢弃所有 ICMP 包。路径 MTU 发现（PMTUD）依赖 ICMP 的“需要分片”消息。如果丢弃了这些消息，可能会导致某些 TCP 控制流量挂起。/ip firewall filter add chain=input protocol=icmp action=accept /ip firewall filter add chain=forward protocol=icmp action=acceptTCP MSS Clamping（优化隧道内少量 TCP 管理流量）为隧道中的 TCP 连接（如网页访问、SSH）钳制 MSS，避免 TCP 大包问题。/ip firewall mangle add chain=forward protocol=tcp tcp-flags=syn tcp-mss=1451-65535 action=change-mss new-mss=clamp-to-pmtu5.5 备用隧道的同步优化备用 L2TP 隧道封装开销更大（约 100+ 字节），因此 MTU 设为 1400 是合理的。这样在切换后，视频流仍然可以工作，只是可能会多一些分片，但保持连续性。第六步：验证与故障排查验证二层连通性在两台接在 ether3 的 PC 上配置同网段 IP（如 192.168.100.10/24 和 192.168.100.20/24）互相 ping 测试执行大包测试：ping -l 1472 -f 192.168.100.20（Windows）或 ping -M do -s 1472 192.168.100.20（Linux）验证视频流性能将实际摄像头和 NVR 分别接到两端 ether3观察监控画面是否流畅，是否有花屏、卡顿在 RouterOS 上使用 /tool torch interface=bridge-vxlan 查看流量和分片统计验证主备切换手动禁用 WireGuard 接口：/interface wireguard disable wg-main观察 Netwatch 日志，确认 VTEP 已切换到 L2TP IP监控画面应只有短暂中断（约 10 秒）重新启用 WireGuard，隧道应自动切回常见问题排查现象可能原因解决动作视频严重花屏大量 IP 分片丢失检查公网链路质量；考虑降低摄像头码率切换后不通L2TP 隧道未获 IP 或 MTU 不匹配/ip address print 检查；ping -s 1400 测试 L2TP 隧道大包 ping 不通MTU 设置过于激进逐步降低客户端测试包的 size，找出路径 MTUNetwatch 不触发监控的目标 IP 不可达？先手动 ping 确认，再检查防火墙是否允许 ICMP
纯热备场景下，用最少的组件（Keepalived + MySQL 原生复制）实现故障全自动切换、自动恢复、零数据丢失。兼容 MySQL 5.7 / 8.0 / MariaDB。一、设计思路1.1 核心问题在做这个方案之前，先想清楚三个问题：问题 1：为什么不用 MHA / ProxySQL？MHA 的 Manager 是单点，且切换后 Manager 自动退出，旧主恢复后仍需人工处理ProxySQL 功能强但引入了新的高可用问题（ProxySQL 本身也要做 HA），纯热备场景下得不偿失Keepalived 极其成熟，几万行 C 代码跑了二十年，比任何"智能"方案都可靠问题 2：为什么用双主而不是主从？传统主从 + VIP 的问题：切换后需要 CHANGE MASTER 反转拓扑。双主（互为主从）的好处是：不管 VIP 在哪台，另一台本来就是它的从库，切换时无需改复制拓扑，VIP 漂过去就能直接写。问题 3：如何防止恢复后 VIP 自动回切？这是整个方案最核心的设计点。来看看常规方案的问题：常规方案（仅用 priority + weight）： db-a priority=100 (主), db-b priority=90 (备) db-a 宕机 → db-a=80, db-b=90 → db-b 接管 ✓ db-a 恢复 → db-a=100, db-b=90 → db-a 抢回 ✗ （不必要的第二次中断！）如果加 nopreempt：nopreempt 只对 Keepalived 进程启动时的状态转换生效对 track_script 引起的动态权重变化不生效更糟的是，如果两台都加 nopreempt，连正常的故障切换都会被阻止本方案的解法：让 health check 脚本利用 MySQL 原生的复制 IO 线程状态来判断"主库是否还活着"：check_mysql.sh 逻辑： 1. MySQL 进程是否存活？→ 否 → exit 1（降权） 2. 我是只读（read_only=ON）吗？ - 是 → IO 线程是否在运行？ - 是 → 主库还活着，我安心当备机 → exit 1（降权，不抢占） - 否 → 主库挂了，我可以竞选 → exit 0 - 否 → 我已是主库 → exit 0这样恢复后的节点自动检测到当前主库还活着，主动降低优先级，不会抢占 VIP。1.2 方案架构应用 ──► VIP (192.168.7.33) ──► 当前主库 (read_only=OFF) └─► 备机 (read_only=ON) 双向复制 (GTID + 半同步) | check 脚本监控复制 IO 线程 判断主库是否存活 → 控制优先级1.3 故障切换时序正常状态： db-a (priority=100, read_only=OFF, IO-thread 活跃) db-b (priority=90, read_only=ON, IO-thread 活跃 → check 返回 1 → 实际=70) db-a 宕机后 8 秒内： db-a check 连续失败 2 次 → priority=80 db-b check 发现 IO 线程断开 → 返回 0 → priority=90 90 > 80 → db-b 接管 VIP notify_master 将 db-b read_only 关闭 db-a 恢复： MySQL 启动，read_only=ON（my.cnf 默认） IO 线程连上 db-b → 活跃 → check 返回 1 → priority=80 db-b 仍是 priority=90 80 < 90 → VIP 留在 db-b（不回切！）1.4 Priority 推演表场景db-a 状态db-a 实际 prioritydb-b 状态db-b 实际 priorityVIP 在初始正常read_only=OFF100read_only=ON, IO 活跃70 (90-20)db-adb-a 宕机MySQL 挂了80 (100-20)read_only=ON, IO 断开90db-bdb-a 恢复read_only=ON, IO 活跃80 (100-20)read_only=OFF90db-b（不回切）db-b 宕机read_only=ON, IO 断开100MySQL 挂了70 (90-20)db-a二、环境信息角色IP初始状态Keepalived prioritydb-a192.168.7.31read_only=ON，等 Keepalived 竞选100db-b192.168.7.32read_only=ON，等 Keepalived 竞选90VIP192.168.7.33——注意：两台 my.cnf 都配置 read_only=ON。谁成为 MASTER 谁由 notify 脚本打开写入。这样恢复后的节点总是以只读模式启动，不会出现双主同时可写的窗口。三、多版本兼容速查配置项MySQL 5.7MySQL 8.0MariaDB 10.x过期日志expire_logs_days=7binlog_expire_logs_seconds=604800expire_logs_days=7从库更新log_slave_updateslog_replica_updateslog_slave_updates半同步主插件semisync_master.sosemisync_source.sosemisync_master.so半同步从插件semisync_slave.sosemisync_replica.sosemisync_slave.so半同步主变量rpl_semi_sync_master_enabledrpl_semi_sync_source_enabledrpl_semi_sync_master_enabled复制语句CHANGE MASTER TOCHANGE REPLICATION SOURCE TOCHANGE MASTER TO查看复制状态SHOW SLAVE STATUSSHOW REPLICA STATUSSHOW SLAVE STATUSsuper_read_only支持 (5.7.8+)支持不支持（去掉所有相关行）MASTER_AUTO_POSITION支持支持不支持（用 file+pos）loose- 前缀支持支持支持四、MySQL 配置4.1 db-a /etc/my.cnf[mysqld] # ---- 基础配置 ---- server-id = 1 # 全局唯一，db-b 设为 2 log-bin = mysql-bin # 开启 binlog binlog_format = ROW # 行级复制，保证数据一致性 gtid_mode = ON # 开启 GTID enforce_gtid_consistency = ON # GTID 强制一致性 # ---- 日志与复制 ---- # MySQL 8.0 用 log_replica_updates；5.7/MariaDB 用 log_slave_updates log_replica_updates = ON # MySQL 8.0 用 binlog_expire_logs_seconds；5.7/MariaDB 用 expire_logs_days = 7 binlog_expire_logs_seconds = 604800 # 7 天自动清理 # ---- 自增冲突预防 ---- # 双主架构下必须错开自增值，否则同时插入会冲突 auto_increment_increment = 2 # 步长：2 auto_increment_offset = 1 # 偏移：A 生成奇数 (1,3,5...)；B 设为 2 生成偶数 # ---- 半同步复制（MySQL 8.0）---- # 5.7/MariaDB 请替换为：semisync_master.so / semisync_slave.so # 使用 plugin_load_add 而非 plugin-load，避免覆盖默认插件列表 plugin_load_add = "semisync_source.so;semisync_replica.so" # loose- 前缀：插件未加载时仅警告，不阻止 MySQL 启动 # 避免首次初始化时因变量不存在而报错 loose_rpl_semi_sync_source_enabled = 1 loose_rpl_semi_sync_replica_enabled = 1 rpl_semi_sync_source_timeout = 10000 # 半同步超时 10 秒后降级为异步 # ---- 读写状态 ---- # 初始只读！谁成为 MASTER 由 Keepalived notify 脚本打开写入 read_only = ON super_read_only = ON # MariaDB 删掉此行4.2 db-b 与 db-a 的差异# db-b 仅以下三处不同： server-id = 2 # 唯一 ID auto_increment_offset = 2 # B 生成偶数 (2,4,6...) read_only = ON # 同上，初始只读 super_read_only = ON4.3 MariaDB 特别注意事项# MariaDB 的 my.cnf 去掉这两行： # super_read_only = ON ← 删除，不支持 # plugin_load_add = "..." ← 用下面代替MariaDB 半同步插件在安装时自动加载，通常不需要在 my.cnf 中配置 plugin_load_add。如需手动加载：INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so'; INSTALL PLUGIN rpl_semi_sync_slave SONAME 'semisync_slave.so';五、双向复制搭建5.1 创建复制用户（在 db-a 上执行一次即可，会自动同步到 db-b）CREATE USER 'repl'@'%' IDENTIFIED BY 'Repl@123'; GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%'; FLUSH PRIVILEGES;5.2 建立复制MySQL 8.0 语法：-- 在 db-a 上执行：指向 db-b CHANGE REPLICATION SOURCE TO SOURCE_HOST='192.168.7.32', SOURCE_USER='repl', SOURCE_PASSWORD='Repl@123', SOURCE_AUTO_POSITION=1; -- 基于 GTID 自动定位 START REPLICA; -- 在 db-b 上执行：指向 db-a CHANGE REPLICATION SOURCE TO SOURCE_HOST='192.168.7.31', SOURCE_USER='repl', SOURCE_PASSWORD='Repl@123', SOURCE_AUTO_POSITION=1; START REPLICA;MySQL 5.7 语法：-- 将 SOURCE 替换为 MASTER，REPLICA 替换为 SLAVE CHANGE MASTER TO MASTER_HOST='192.168.7.32', MASTER_USER='repl', MASTER_PASSWORD='Repl@123', MASTER_AUTO_POSITION=1; START SLAVE;MariaDB 语法（不支持 MASTER_AUTO_POSITION）：-- 先在 db-b 上查看当前 binlog 位置： SHOW MASTER STATUS; -- 记录 File 和 Position -- 在 db-a 上执行： CHANGE MASTER TO MASTER_HOST='192.168.7.32', MASTER_USER='repl', MASTER_PASSWORD='Repl@123', MASTER_LOG_FILE='mysql-bin.000003', -- 替换为实际值 MASTER_LOG_POS=237; -- 替换为实际值 START SLAVE; -- db-b 指向 db-a 同理5.3 验证-- MySQL 8.0 SHOW REPLICA STATUS\G -- MySQL 5.7 / MariaDB SHOW SLAVE STATUS\G -- 确保以下两项均为 Yes： -- Replica_IO_Running / Slave_IO_Running: Yes -- Replica_SQL_Running / Slave_SQL_Running: Yes六、Keepalived 配置6.1 密码安全加固# 在两台服务器上执行，避免脚本中出现明文密码 mysql_config_editor set --login-path=keepalived_check \ --host=localhost --user=root --password # 输入 MySQL root 密码 # 验证 mysql --login-path=keepalived_check -e "SELECT 1"6.2 db-a /etc/keepalived/keepalived.confvrrp_script chk_mysql { script "/etc/keepalived/check_mysql.sh" interval 2 # 每 2 秒检查一次 weight -20 # 检查失败时扣减 20 点优先级 fall 2 # 连续失败 2 次才触发扣减 rise 2 # 连续成功 2 次才恢复优先级 } vrrp_instance VI_MYSQL { state BACKUP # 两台都设 BACKUP，靠 priority 决出 MASTER interface ens192 # 根据实际网卡名修改 virtual_router_id 51 # 同一组 VRRP 必须相同 priority 100 # 主机 100；备机 90 advert_int 1 # VRRP 通告间隔 1 秒 authentication { auth_type PASS auth_pass MySqlVIP # 两台密码必须一致 } virtual_ipaddress { 192.168.7.33/24 dev ens192 # VIP 地址 } track_script { chk_mysql # 关联健康检查脚本 } notify_master "/etc/keepalived/notify_master.sh" notify_backup "/etc/keepalived/notify_backup.sh" notify_fault "/etc/keepalived/notify_fault.sh" }db-b 配置完全相同，仅 priority 改为 90。6.3 check_mysql.sh —— 核心健康检查脚本#!/bin/bash # ============================================================ # 设计思路（这是整个方案最关键的部分）： # # 常规方案的致命缺陷： # 如果只在健康检查中判断 MySQL 是否存活，那么恢复后的节点 # （优先级更高）会立即抢回 VIP，导致不必要的第二次中断。 # # 如果同时判断 read_only 状态，备机的 read_only=ON 会 # 导致其优先级永远被扣减，主库宕机后 80 > 70 无法切换。 # # 本方案的解法： # 利用 MySQL 复制 IO 线程的状态作为"主库是否还活着"的代理指标。 # - 备机（read_only=ON）+ IO 线程运行中 → 主库活着 → 不抢占 # - 备机（read_only=ON）+ IO 线程中断 → 主库挂了 → 可以接管 # - 主库（read_only=OFF） → 已经是主 → 保持 # # 兼容性： # - MySQL 5.7: Slave_IO_Running # - MySQL 8.0: Replica_IO_Running # - MariaDB: Slave_IO_Running # 脚本同时 grep 两个关键字，覆盖所有版本 # ============================================================ export HOME=/root # Keepalived 不设 HOME，必须显式指定 # 1. 检查 MySQL 进程是否存活 mysqladmin --login-path=keepalived_check ping &>/dev/null || exit 1 # 2. 如果我是只读状态，判断主库是否还活着 READ_ONLY=$(mysql --login-path=keepalived_check -e "SELECT @@read_only" -sN 2>/dev/null) if [ "$READ_ONLY" -eq 1 ]; then # 检查复制 IO 线程：兼容 MySQL 5.7/8.0 和 MariaDB IO_RUNNING=$(mysql --login-path=keepalived_check \ -e "SHOW REPLICA STATUS\G" 2>/dev/null \ | grep -iE "Replica_IO_Running|Slave_IO_Running" \ | awk '{print $2}') if [ "$IO_RUNNING" = "Yes" ]; then # 主库还活着（IO 线程正常接收 binlog） # 我继续当备机，降低优先级，不参与竞选 exit 1 fi # 主库挂了（IO 线程中断），我可以竞选主库 fi exit 06.4 notify_master.sh —— 升级为主库#!/bin/bash # 当本机赢得 VRRP 选举成为 MASTER 时由 Keepalived 自动调用 # 作用：关闭 read_only，允许应用写入 export HOME=/root # 必须！否则 mysql 找不到登录配置 LOG=/var/log/keepalived_notify.log echo "$(date): 升级为 MASTER，关闭 read_only" >> $LOG # MySQL 8.0/5.7 需要同时关闭 super_read_only # MariaDB 不支持 super_read_only → 删掉 ", SET GLOBAL super_read_only=OFF" 部分 mysql --login-path=keepalived_check \ -e "SET GLOBAL read_only=OFF; SET GLOBAL super_read_only=OFF;" \ >> $LOG 2>&16.5 notify_backup.sh —— 降级为备机#!/bin/bash # 当本机失去 MASTER 身份降级为 BACKUP 时由 Keepalived 自动调用 # 作用：开启 read_only，防止双主同时写入 export HOME=/root LOG=/var/log/keepalived_notify.log echo "$(date): 降级为 BACKUP，开启 read_only" >> $LOG mysql --login-path=keepalived_check \ -e "SET GLOBAL read_only=ON; SET GLOBAL super_read_only=ON;" \ >> $LOG 2>&16.6 notify_fault.sh —— 故障时的最后防线#!/bin/bash # 当 VRRP 检测到异常状态（如脑裂）时由 Keepalived 自动调用 # 作用：强制删除本机 VIP，确保不会出现双端同时持有 VIP export HOME=/root LOG=/var/log/keepalived_notify.log echo "$(date): FAULT 状态，强制下线 VIP" >> $LOG /sbin/ip addr del 192.168.7.33/24 dev ens192 2>/dev/null6.7 部署# 赋予执行权限 chmod +x /etc/keepalived/check_mysql.sh chmod +x /etc/keepalived/notify_*.sh # 启动 systemctl start keepalived systemctl enable keepalived # 验证 VIP ip addr show ens192 | grep 192.168.7.33七、填坑实录以下是实际部署中遇到的每一个坑，以及排查思路。坑 1：<<EOF 不加引号，变量被提前展开（致命）现象：脚本写入了，但 VIP 死活不切换。停掉 MySQL 后 Keepalived 毫无反应。排查：cat /etc/keepalived/check_mysql.sh 一看，脚本内容变成了：# $? 变成了字面量 0！永远不会 exit 1 if [ 0 -ne 0 ]; then exit 1 fi # $(mysql ...) 被替换成了写入时的返回值 READ_ONLY=0 # $READ_ONLY 被展开成了空字符串 if [ "" -eq 1 ]; then exit 1 fi根因：cat > file <<EOF 中 heredoc 的分隔符没有用引号括起来，bash 在写入文件前对内容执行了变量替换和命令替换。$?、$(date)、$LOG、$READ_ONLY 全部变成了写入时的字面值。解决：所有 heredoc 使用 <<'EOF'（单引号阻止任何展开）。# 错误 ❌ —— 变量会被展开 cat > /etc/keepalived/check_mysql.sh <<EOF ... EOF # 正确 ✅ —— 内容原样写入 cat > /etc/keepalived/check_mysql.sh <<'EOF' ... EOF教训：写完脚本后立刻 cat 检查内容。如果看到硬编码的数字和日期，那就是 heredoc 没加引号。坑 2：Keepalived 执行脚本时不设置 $HOME现象：VIP 漂移成功了，但 notify_master.sh 执行 mysql --login-path 时报错：ERROR 1045 (28000): Access denied for user 'root'@'localhost' (using password: NO)注意 (using password: NO)——说明 mysql 客户端根本没读到密码。排查：手工执行脚本正常，但 Keepalived 触发时就失败。原因是 Keepalived 启动的子进程环境变量几乎是空的，$HOME 为空或指向 /，mysql 找不到 ~/.mylogin.cnf。解决：所有脚本开头添加 export HOME=/root。mysql 和 mysqladmin 都需要这个变量。教训：systemd / Keepalived 启动的进程环境变量极少。$HOME、$PATH、$USER 都不要假设存在。坑 3：在健康检查中判断 read_only 导致备机永远无法接管现象：主库宕机后，备机不接管。priority 推演如下：db-a 宕机前：db-a=100, db-b=70 (90-20，因为 read_only=ON → check 失败) db-a 宕机后：db-a=80 (100-20), db-b=70 80 > 70 → VIP 仍在 db-a ✗根因：备机的 read_only=ON 是正常状态，不应该被当作"故障"来扣减优先级。解决：不检查 read_only 状态来判断故障。改用复制 IO 线程状态（见第六节 check_mysql.sh 的设计思路）。坑 4：nopreempt 对 track_script 的动态权重变化无效现象：加了 nopreempt，恢复后的节点仍然抢回 VIP。根因：Keepalived 的 nopreempt 只在 VRRP 实例初次启动时生效（BACKUP → MASTER 的初始转换）。当 track_script 引起优先级动态变化时，nopreempt 不阻止抢占。更糟的是，如果两台都加 nopreempt，连正常的故障切换都会被阻止——备机不会抢占优先级已经降低的主库。解决：不使用 nopreempt，完全依靠 check 脚本中的复制 IO 感知逻辑来控制优先级。坑 5：MariaDB 不支持 super_read_only现象：MariaDB 上执行 SET GLOBAL super_read_only=ON 报错：ERROR 1193 (HY000): Unknown system variable 'super_read_only'解决：所有配置和脚本中出现 super_read_only 的地方，MariaDB 都要删掉：# MySQL 8.0 / 5.7.8+： mysql -e "SET GLOBAL read_only=OFF; SET GLOBAL super_read_only=OFF;" # MariaDB（仅保留 read_only）： mysql -e "SET GLOBAL read_only=OFF;"坑 6：MySQL 8.0 初始化时半同步变量报错现象：mysqld --initialize 时报：unknown variable 'rpl_semi_sync_source_enabled=1'根因：初始化阶段半同步插件尚未安装，MySQL 不认识这些变量。解决：使用 loose- 前缀：# ❌ 初始化报错 rpl_semi_sync_source_enabled = 1 # ✅ 插件未加载时仅警告 loose_rpl_semi_sync_source_enabled = 1坑 7：--initialize 在已有数据目录上失败现象：修改 my.cnf 后重新初始化报错退出。解决：清空数据目录后重新初始化：systemctl stop mysqld rm -rf /var/lib/mysql/* # 注意备份！ mysqld --initialize --user=mysql grep 'temporary password' /var/log/mysqld.log # 查找临时密码 systemctl start mysqld八、VRRP 切换的业务中断问题8.1 问题的本质Keepalived 的 VIP 漂移本质上是网络层切换：VIP 从一个网卡解绑、ARP 广播更新、再绑定到另一个网卡。这个过程必然导致：现有 TCP 连接被 RST（复位）应用必须重建连接在 ARP 缓存刷新前（最多几秒），数据包可能发往旧节点在不改应用代码的前提下，无法做到绝对的零中断。 但可以通过几个层次来大幅缩短中断时间。8.2 方案一：缩短检测窗口 + JDBC 自动重连（改动最小）当前中断时长：≈ 6-8 秒（interval 2 × fall 2 + VRRP 协商 + ARP 刷新）优化后：≈ 2-3 秒Keepalived 调优——把检测参数压到最激进：vrrp_script chk_mysql { script "/etc/keepalived/check_mysql.sh" interval 1 # 每秒检查，原来 2 秒 weight -20 fall 1 # 失败 1 次立刻触发，原来 2 次 rise 2 # 恢复保持 2 次，避免抖动 }fall 设为 1 的风险：MySQL 瞬时抖动可能触发误切换。如果 MySQL 稳定性没问题可以这么设；保守的话保留 fall=2，中断也仅多 1 秒。JDBC 连接串（不改代码，只改连接配置）：# MySQL Connector/J 8.0 jdbc:mysql://192.168.7.33:3306/db?autoReconnect=true&failOverReadOnly=false&maxReconnects=3&initialTimeout=1 # 参数说明： # autoReconnect=true → 连接断开后自动重连 # failOverReadOnly=false → 重连后不设为只读（热备场景不需要） # maxReconnects=3 → 最多重试 3 次 # initialTimeout=1 → 首次重试间隔 1 秒对大多数业务，2-3 秒的短暂中断 + 连接池自动重连已经可以接受。8.3 方案二：加 HAProxy TCP 代理层（推荐，效果最好）在两台 MySQL 服务器上各部署一个 HAProxy（TCP mode），应用连 HAProxy 而不是直连 MySQL：应用 ──► VIP ──► HAProxy (:3307) ──► 本机 MySQL (:3306) │ └─► 对端 MySQL (:3306) [备胎] │ └─ Keepalived 管 VIP └─ HAProxy 管 MySQL 流量分发HAProxy 配置 (/etc/haproxy/haproxy.cfg)：global log /dev/log local0 maxconn 4096 defaults mode tcp # TCP 模式，不做 SQL 解析 timeout connect 3s timeout client 30s timeout server 30s log global frontend mysql_frontend bind 192.168.7.33:3306 # 监听 VIP 的 3306 端口 default_backend mysql_backend backend mysql_backend option tcp-check # TCP 检查：连接 3306 端口成功即认为健康 server db-a 127.0.0.1:3306 check port 3306 inter 1s fall 1 rise 2 server db-b 192.168.7.32:3306 check port 3306 inter 1s fall 1 rise 2 backup两台服务器上 HAProxy 配置几乎相同，只是 server 行里把自己写前面、对端写 backup。为什么这个方案好：HAProxy 持有应用连接，后端 MySQL 切换时应用连接不断开（HAProxy 内部重连）切换检测更快：inter 1s fall 1，1 秒完成不需要 Keepalived 的复杂 check 脚本——HAProxy 自己检测后端可以做到灰度切换：旧连接的查询跑完再切，不丢事务如果还担心 HAProxy 是单点：HAProxy 跟 MySQL 部署在同一台，MySQL 挂了 HAProxy 也没用，不需要额外 HA如果真的要求 HAProxy 也要高可用，可以用 Keepalived 保 HAProxy 的 VIP（就像现在保 MySQL 的 VIP 一样）代价：多部署一个 HAProxy（yum install 一条命令），多一个进程要管理。8.4 方案三：MySQL Router（MySQL 官方方案）MySQL 8.0 + InnoDB ReplicaSet 或 InnoDB Cluster 自带 MySQL Router：应用 ──► MySQL Router (:6446 r/w) ──► 主库 └─► MySQL Router (:6447 r/o) ──► 备库Router 自动检测主备切换，把读写流量指向新主库。但：需要 MySQL 8.0+需要额外部署 Router 进程对 InnoDB ReplicaSet 的支持比 InnoDB Cluster 弱对当前的"纯双主热备"架构来说，Router 有点重了。8.5 四种方案对比 中断时间架构改动额外组件适用场景当前方案（VIP 直连）6-8 秒无无可容忍短暂中断方案一（调优+JDBC重连）2-3 秒无（只改连接串）无大多数业务方案二（+HAProxy）1-2 秒，连接不断加代理层HAProxy要求零感知方案三（MySQL Router）秒级换架构MySQL RouterInnoDB Cluster 用户8.6 建议先做方案一——5 分钟改完，中断压到 2-3 秒。绝大多数业务都能接受如果业务方坚持"不能丢一个连接"，上方案二——加 HAProxy，应用连接跟后端 MySQL 解耦方案三留到将来如果要迁到 InnoDB Cluster 再考虑九、验证清单#测试项操作预期结果1初始状态两台都启动db-a 持有 VIP，read_only=OFF；db-b read_only=ON2主库宕机systemctl stop mysqld 在 db-a8 秒内 VIP 漂移到 db-b，db-b 的 read_only=OFF3主库恢复（不回切）systemctl start mysqld 在 db-adb-a read_only=ON，VIP 留在 db-b4新主库宕机systemctl stop mysqld 在 db-bVIP 漂移到 db-a，db-a 的 read_only=OFF5双向复制SHOW REPLICA STATUS\GIO/SQL 线程均为 Yes，延迟为 06脑裂防护检查两台 VIP同一时刻仅一台持有 VIP7脚本变量完整性cat /etc/keepalived/check_mysql.sh$?、$LOG 等变量未被展开8密码安全grep -r 'password' /etc/keepalived/*.sh无明文密码9notify 日志cat /var/log/keepalived_notify.log无 ERROR 字样十、故障排查速查# 查看 VIP 在哪台 ip addr show ens192 | grep 192.168.7.33 # Keepalived 实时日志 journalctl -u keepalived -f # 切换历史 tail -20 /var/log/keepalived_notify.log # 手动测试 check 脚本（含逐行调试） bash -x /etc/keepalived/check_mysql.sh; echo "exit code: $?" # 模拟 Keepalived 环境执行 notify（干净环境变量） env -i HOME=/root bash /etc/keepalived/notify_master.sh # 查看 MySQL 复制状态 mysql -e "SHOW REPLICA STATUS\G" 2>/dev/null | grep -E "Running|Seconds_Behind|Error" # 查看当前只读状态和 hostname mysql -e "SELECT @@hostname, @@read_only, @@super_read_only" # 验证 mysql_config_editor mysql_config_editor print --login-path=keepalived_check mysql --login-path=keepalived_check -e "SELECT 1" # 检查脚本内容（变量是否被展开） cat /etc/keepalived/check_mysql.sh | grep -E '\$\?|\$\(|\$LOG|\$READ_ONLY'十一、总结这套方案的核心思路只有三点：双主免拓扑切换——不管 VIP 在哪台，另一台本来就是它的从库，省去了 CHANGE MASTER 的麻烦复制 IO 线程感知——利用 MySQL 原生的复制状态判断主库是否存活，优雅地解决了"恢复后不回切"的问题，不需要 nopreempt，不需要 flag 文件最少依赖——整个方案只多了一个 Keepalived（几十万行稳定代码），没有引入任何新的单点部署时记住三条铁律：heredoc 加引号、notify 脚本设 HOME、检查脚本用 IO 线程不要用 read_only。
1. 前言Windows 10 桌面版（专业版/企业版）默认只允许一个活跃的远程桌面会话。当你尝试让第二个用户通过远程桌面连接时，要么会把当前用户挤下线，要么会直接锁屏。这是因为微软在 termsrv.dll 文件中设置了硬编码的单用户限制。要突破这个限制，实现多人同时远程登录、互不影响，最常见的方式是使用开源工具 RDPWrap。本文将完整讲解从系统准备、安装破解到验证配置的全过程，并给出替代方案。2. 系统要求在开始之前，请确认你的系统满足以下条件：项目要求操作系统版本Windows 10 专业版、企业版或教育版（家庭版不支持远程桌面主机功能）远程桌面权限已在“设置 → 系统 → 远程桌面”中开启独立用户账户在“设置 → 账户 → 家庭和其他用户”中，为每位远程用户创建独立的本地账户，并添加到 Remote Desktop Users 组中github开源地址：https://github.com/stascorp/rdpwrap/releases/tag/v1.6.23. 使用 RDPWrap 破解多用户限制RDPWrap 是一个开源项目，通过封装和修补系统文件 termsrv.dll，在不对原始文件直接修改的情况下，让 Windows 桌面版支持多个并发远程桌面会话。⚠️ 重要：RDPWrap 原项目多年来已停止更新，但社区通过维护更新的 rdpwrap.ini 配置文件，使其能持续支持新版本的系统。3.1 详细操作步骤第一步：下载并安装从以下任一资源获取必要文件：官方原版（v1.6.2）：虽然原作者已停更，但安装器仍可正常使用。社区维护版：sebaxakerhtc/rdpwrap，持续更新 rdpwrap.ini 文件，支持更多新版本。推荐：下载社区维护版，直接解压即可使用，省去手动替换配置文件的步骤。以管理员身份运行 install.bat。脚本会自动将程序部署到 C:\Program Files\RDP Wrapper，并安装所需服务。第二步：更新配置文件（最关键的一步）如果用的是官方原版，需要手动替换新的配置文件：停止服务：以管理员身份运行命令 net stop termservice从 sebaxakerhtc/rdpwrap.ini 仓库下载最新的 rdpwrap.ini用下载的文件覆盖 C:\Program Files\RDP Wrapper\rdpwrap.ini启动服务：以管理员身份运行命令 net start termservice如果用的是社区维护版，通常已包含较新的配置文件，可直接进入验证步骤。若不成功，仍可参照上述方法更新。第三步：配置本地组策略按 Win+R 打开运行，输入 gpedit.msc 并回车。定位到：计算机配置 → 管理模板 → Windows 组件 → 远程桌面服务 → 远程桌面会话主机 → 连接。双击 “将远程桌面服务用户限制到单独的远程桌面服务会话”，设置为 “已禁用”。双击 “限制连接的数量”，设置为 “已启用”，并在选项中填入允许的最大并发连接数（例如 5 或 999999）。3.2 安装RDPwrap补丁防火墙会进行拦截3.3 如何验证是否成功？运行 C:\Program Files\RDP Wrapper 目录下的 RDPConf.exe，检查状态面板。如果 所有条目都显示为绿色，说明破解成功。你也可以运行 RDPCheck.exe 进行直观测试：如果能弹出一个新的远程桌面窗口，且没有挤掉当前会话，就说明多用户同时登录功能已生效。4. 常见问题与故障排查4.1 运行 RDPConf.exe 后出现红色或黄色警告这通常是因为 rdpwrap.ini 配置与当前系统版本不兼容。解决方法：按 Win+R，输入 winver 并回车，查看系统详细版本和 内部版本号（OS Build）。访问 sebaxakerhtc/rdpwrap.ini 的 GitHub 仓库，查找是否有针对你内部版本号的更新配置文件。下载该文件，覆盖到 C:\Program Files\RDP Wrapper\rdpwrap.ini。重启远程桌面服务或重启电脑。4.2 重启或系统更新后失效原因：系统大版本更新（如从 22H2 升级到 24H2）会替换部分系统文件，导致 RDPWrap 失效。解决方法：重新运行安装和配置文件更新步骤。
在云原生时代，Docker 已成为应用打包、交付和运行的事实标准。本文将以 CentOS 7 系统为例，详细介绍如何利用阿里云镜像站在线安装 Docker CE、配置国内镜像加速源、安装 Docker Compose，并演示一个典型的多服务容器化部署流程。一、安装 Docker CE（社区版）1.1 安装必要系统工具首先需要安装 yum-utils，它提供了 yum-config-manager 工具，方便我们管理软件源。sudo yum install -y yum-utils1.2 添加阿里云 Docker 软件源使用阿里云提供的官方仓库地址，能显著提升下载速度和稳定性。yum-config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo1.3 安装 Docker 及相关组件执行以下命令安装 Docker 引擎、CLI 工具、容器运行时以及构建插件和 Compose 插件。sudo yum install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y注：docker-compose-plugin 是 Docker Compose V2 的插件形式，若习惯使用 docker-compose 命令，也可以单独安装传统版本的 Compose（见后文）。1.4 启动 Docker 服务sudo systemctl enable --now docker1.5 校验安装查看 Docker 版本信息，确认安装成功。docker version如果正常显示客户端和服务端版本号，说明 Docker 已正确安装并运行。二、配置国内镜像加速源（重要）由于网络原因，直接从 Docker Hub 拉取镜像可能非常缓慢。通过配置国内镜像加速器，可以大幅提升拉取速度。2.1 创建/编辑 daemon.jsonvim /etc/docker/daemon.json2.2 写入以下内容{ "registry-mirrors": [ "https://51mbg7lq.mirror.aliyuncs.com", "https://9cpn8tt6.mirror.aliyuncs.com", "https://registry.docker-cn.com", "https://a0f4d5484dea442caf5d1193f09abdd4.mirror.swr.myhuaweicloud.com", "https://do.nark.eu.org", "https://dc.j8.work", "https://docker.m.daocloud.io", "https://dockerproxy.com", "https://docker.mirrors.ustc.edu.cn", "https://docker.nju.edu.cn", "https://hub-mirror.c.163.com", "https://mirror.baidubce.com", "https://ccr.ccs.tencentyun.com" ] }说明：这里列出了多个国内常用镜像源（阿里云、华为云、中科大、南京大学、网易、百度、腾讯云等），按优先级排列。你可以根据实际网络情况增删。2.3 重新加载配置并重启 Dockersudo systemctl daemon-reload sudo systemctl restart docker2.4 设置开机自启sudo systemctl enable docker完成上述步骤后，Docker 的基础环境就准备好了。三、安装 Docker Compose（可选）如果你的项目需要使用 docker-compose.yml 进行多容器编排，建议安装 Docker Compose。 若已通过 docker-compose-plugin 安装了 V2 版本，可以直接使用 docker compose 命令（注意中间没有横线）。但传统 docker-compose 命令在很多教程中仍被广泛使用，因此下面也提供独立安装方法。3.1 下载稳定版二进制文件以 v2.5.1 为例（你也可以到 GitHub Releases 查看最新版本号）：sudo curl -L "https://github.com/docker/compose/releases/download/v2.5.1/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose3.2 赋予执行权限sudo chmod +x /usr/local/bin/docker-compose3.3 添加到 PATH（可选）通常 /usr/local/bin 已经在 PATH 中，若需要手动添加：export PATH=$PATH:/usr/bin/docker-compose3.4 校验安装docker-compose version如果输出版本信息，说明安装成功。四、部署多服务应用（通用示例）在实际项目中，一个应用往往由多个服务组成（如数据库、缓存、消息队列、业务后端等）。下面以典型的 Web 应用 + MySQL + Redis 为例，演示如何使用 Docker Compose 进行容器化部署。假设你的项目目录结构如下：/your-project/docker/ ├── docker-compose.yml ├── app/ │ └── Dockerfile └── ...4.1 进入项目 docker 目录cd /your-project/docker4.2 构建所有镜像docker-compose build ● 代码已是最新，但 Docker 仍报旧错误。BuildKit 内部缓存没清掉。 # 清理 BuildKit 缓存 docker builder prune -af # 禁用 BuildKit 重新构建 DOCKER_BUILDKIT=0 docker compose build --no-cach4.3 后台启动所有服务docker-compose up -d4.4 查看服务状态docker-compose ps4.5 单服务管理（推荐）当项目较大或需要调试单个服务时，可以分别操作：操作命令示例构建单个镜像（无缓存）docker-compose build --no-cache mysql启动单个服务（前台运行）docker-compose up mysql停止单个服务docker-compose stop mysql启动已停止的单个服务docker-compose start mysql查看单个服务实时日志docker-compose logs -f mysql提示：建议按依赖顺序依次启动：先启动数据库、缓存等基础服务，最后启动业务主服务。五、常见问题与优化建议权限问题：如果不想每次使用 docker 都加 sudo，可以将当前用户加入 docker 用户组：sudo usermod -aG docker $USER newgrp docker镜像拉取失败：检查 /etc/docker/daemon.json 中的镜像源是否可用，可只保留两三个响应最快的地址。服务启动顺序：可以在 docker-compose.yml 中使用 depends_on 定义依赖关系，但注意 depends_on 并不等待服务完全就绪，可能需要配合健康检查（healthcheck）使用。日志管理：长时间运行的服务可能产生大量日志，建议在 daemon.json 中配置日志轮转策略。
具体来说，需要在 mangle 表中标记外部进入的连接，并在 route 表中为这些连接创建一个专属的、指定了源IP和网关的路由，从而强制回程流量从正确的接口出去。⚙️ 详细配置步骤基础网络设置确保所有WAN口已经正确配置了IP地址、网关，并确保网络通畅。标记连接进入 IP -> Firewall -> Mangle 标签页，为从每个公网IP进入的连接打上“连接标记”。Chain: preroutingSrc. Address: 留空（或填写特定公网IP）Dst. Address: 填写你要映射的公网IP地址，子网掩码为 /32In. Interface: 选择该公网IP所在的物理接口（如 ether1）Action: mark connectionNew Connection Mark: 为这个连接起一个名字，例如 conn_public_ip_1Passthrough: yes（必须选“是”，让标记继续传递）为其他公网IP，重复此步骤。标记路由在同一个 Mangle 表中，创建规则，将上一步的“连接标记”转换为“路由标记”。Chain: outputConnection Mark: 选择上一步创建的标记名，例如 conn_public_ip_1Action: mark routingNew Routing Mark: 为路由表起一个名字，例如 route_public_ip_1Passthrough: no为其他公网IP，重复此步骤。创建专属路由表进入 IP -> Routes 标签页，为每个公网IP创建一个新的路由表。Dst. Address: 0.0.0.0/0（默认路由）Gateway: 选择该公网IP对应的网关地址Routing Mark: 选择上一步创建的“路由标记”，例如 route_public_ip_1为其他公网IP，重复此步骤。配置端口映射最后，创建端口映射（dstnat）规则。Chain: dstnatDst. Address: 填写要映射的公网IP地址Protocol: 选择要映射的协议（如 tcp）Dst. Port: 填写要映射的端口号Action: dst-natTo Addresses: 填写内网服务器的IP地址To Ports: 填写内网服务器的端口号📝 完整配置脚本示例假设你有两条线路，线路A的接口是ether1，IP为203.0.113.1，网关203.0.113.254；线路B的接口是ether2，IP为198.51.100.1，网关198.51.100.254。你想把内网192.168.1.100的80端口分别映射到这两个公网IP的80端口上。你可以在New Terminal中执行以下脚本：# --- 1. 标记连接 (Mangle) --- /ip firewall mangle add chain=prerouting dst-address=203.0.113.1 in-interface=ether1 action=mark-connection new-connection-mark=conn_lineA passthrough=yes /ip firewall mangle add chain=prerouting dst-address=198.51.100.1 in-interface=ether2 action=mark-connection new-connection-mark=conn_lineB passthrough=yes # --- 2. 标记路由 (Mangle) --- /ip firewall mangle add chain=output connection-mark=conn_lineA action=mark-routing new-routing-mark=route_lineA passthrough=no /ip firewall mangle add chain=output connection-mark=conn_lineB action=mark-routing new-routing-mark=route_lineB passthrough=no # --- 3. 创建策略路由 (Routes) --- /ip route add dst-address=0.0.0.0/0 gateway=203.0.113.254 routing-mark=route_lineA /ip route add dst-address=0.0.0.0/0 gateway=198.51.100.254 routing-mark=route_lineB # --- 4. 端口映射 (NAT) --- /ip firewall nat add chain=dstnat dst-address=203.0.113.1 protocol=tcp dst-port=80 action=dst-nat to-addresses=192.168.1.100 to-ports=80 /ip firewall nat add chain=dstnat dst-address=198.51.100.1 protocol=tcp dst-port=80 action=dst-nat to-addresses=192.168.1.100 to-ports=80 # --- 5. 配置回程源地址转换 (SRCNAT) --- /ip firewall nat add chain=srcnat out-interface=ether1 action=src-nat to-addresses=203.0.113.1 /ip firewall nat add chain=srcnat out-interface=ether2 action=src-nat to-addresses=198.51.100.1⚠️ 注意事项output vs prerouting：标记路由时，必须使用output链而非prerouting，因为对于发往本地服务或内网的回包，路由决策在output链进行，标记必须作用在这里才能生效。关于 UDP 协议：在处理 UDP 流量时（例如 WireGuard），上述基于 connection-mark 的方法可能偶尔失效。建议改用 IP -> Routing -> Rules 创建基于源地址的策略，或者在防火墙中增加对 UDP 协议的专门处理规则。src-nat vs masquerade：多个公网IP时，必须使用明确的 action=src-nat 并指定 to-addresses，不能使用 masquerade，否则路由器会错误地使用主接口IP发出回程流量。简化配置：如果你的公网IP是直接配置在WAN接口上的（如静态IP），可以创建更简洁的规则，例如：/ip firewall mangle add chain=input in-interface=ether1 action=mark-connection new-connection-mark=conn_lineA passthrough=yes /ip firewall mangle add chain=output connection-mark=conn_lineA action=mark-routing new-routing-mark=route_lineA passthrough=no /ip route add gateway=203.0.113.254 routing-mark=route_lineA此方法利用 input 链标记，同样有效。