<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>iDing</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <icon>https://blog.952405.xyz/img/favicon.png</icon>
  <id>https://blog.952405.xyz/</id>
  <link href="https://blog.952405.xyz/" rel="alternate"/>
  <link href="https://blog.952405.xyz/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, iDing</rights>
  <subtitle>个人技术分享与生活记录</subtitle>
  <title>iDing's 博客</title>
  <updated>2026-09-16T02:00:00.000Z</updated>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="基础设施" scheme="https://blog.952405.xyz/categories/%E5%9F%BA%E7%A1%80%E8%AE%BE%E6%96%BD/"/>
    <category term="Linux" scheme="https://blog.952405.xyz/tags/Linux/"/>
    <category term="Homelab" scheme="https://blog.952405.xyz/tags/Homelab/"/>
    <category term="PVE" scheme="https://blog.952405.xyz/tags/PVE/"/>
    <category term="ext4" scheme="https://blog.952405.xyz/tags/ext4/"/>
    <category term="Ceph" scheme="https://blog.952405.xyz/tags/Ceph/"/>
    <content>
      <![CDATA[<h2 id="引言：上一篇的结论，这次只对了一半"><a href="#引言：上一篇的结论，这次只对了一半" class="headerlink" title="引言：上一篇的结论，这次只对了一半"></a>引言：上一篇的结论，这次只对了一半</h2><p>半年前我写过一篇<a href="/2026/06/pve-storage-deadlock-recovery/">《PVE 存储全盘问号、本地盘读不到的死锁故障排查与虚拟机跨盘导出恢复》</a>，结论是：外部存储（PBS&#x2F;NFS）不可达 → <code>pvestatd</code> 轮询时陷入 I&#x2F;O 等待 → 管理面单线程被拖死 → 网页端全盘问号。处置手段是 <code>killall -9 pvesm/pvestatd/pvedaemon/pveproxy</code> + 给失联存储加 <code>disable 1</code> + 重启服务。</p><p>2026 年 9 月 16 日傍晚，同样的”全盘问号”再次出现。我照着那套流程走了一遍——<strong>完全无效</strong>。</p><p>这一次的卡点根本不在 <code>pvestatd</code>，而在内核态：Ceph RBD 设备的读 I&#x2F;O 卡住了，<code>mount</code> 进程停在不可中断的 <code>D</code> 状态。<code>killall -9</code> 连信号都送不进去，杀掉 <code>pvestatd</code> 只会让它在几秒后重新卡在同一个位置。</p><p>本文是那篇的续集与<strong>纠错</strong>：把这次的证据链完整摆出来，并给出真正有效的排查顺序。</p><h2 id="一、现象：同样全盘问号，但关键指标和上次不一样"><a href="#一、现象：同样全盘问号，但关键指标和上次不一样" class="headerlink" title="一、现象：同样全盘问号，但关键指标和上次不一样"></a>一、现象：同样全盘问号，但关键指标和上次不一样</h2><ul><li>PVE 网页端左侧树状图里，节点、虚拟机、容器、<code>local</code>、<code>local-lvm</code> 全带灰色问号；</li><li><code>pvestatd</code> 日志里出现单次状态更新耗时 <strong>806 秒 &#x2F; 1163 秒 &#x2F; 1217 秒</strong>——管理面确实被拖死了十几分钟；</li><li>但 <code>pvesm status</code> <strong>1.5 秒正常返回</strong>，所有存储都是 <code>active</code>；</li><li><code>df -h</code>、<code>/etc/pve/nodes</code>、corosync 仲裁全部正常。</li></ul><p>一句话总结这个差异：<strong>管理面确实被拖死了，但它不是”死锁在 pvestatd 自己的轮询逻辑里”，而是被内核 I&#x2F;O 拖住的。</strong></p><p>而 <code>pvesm status</code> 快不快，恰好就是区分这两种情况的第一个分水岭。</p><h2 id="二、排查过程：六步定位"><a href="#二、排查过程：六步定位" class="headerlink" title="二、排查过程：六步定位"></a>二、排查过程：六步定位</h2><h3 id="Step-1：先量化现象边界，别急着-killall"><a href="#Step-1：先量化现象边界，别急着-killall" class="headerlink" title="Step 1：先量化现象边界，别急着 killall"></a>Step 1：先量化现象边界，别急着 killall</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 管理面到底卡不卡？给它加超时，别把自己也卡死</span><br><span class="hljs-keyword">time</span> <span class="hljs-built_in">timeout</span> 20 pvesm status<br><br><span class="hljs-comment"># 网页端同源的存储视图（看 status 字段）</span><br>pvesh get /cluster/resources --<span class="hljs-built_in">type</span> storage<br><br><span class="hljs-comment"># 管理面的卡顿证据</span><br>journalctl -u pvestatd | grep -E <span class="hljs-string">&quot;status update time|not online|got timeout|mount error&quot;</span><br></code></pre></td></tr></table></figure><p>本次输出：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">Name              Type     Status           Total            Used       Available        %<br>BIGSTOR            nfs     active    303692830720      4116769664    299576061056    1.36%<br>DevStore           rbd     active     45966258717      4842885661     41123373056   10.54%<br>local              dir     active        98497780        11121816        82326416   11.29%<br>local-lvm      lvmthin     active      2965176320       507341668      2457834651   17.11%<br>...<br>real    0m1.549s<br></code></pre></td></tr></table></figure><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">pvestatd: storage &#x27;BIGSTOR&#x27; is not online<br>pvestatd: mount error: Job failed. See &quot;journalctl -xe&quot; for details.<br>pvestatd: got timeout<br>pvestatd: status update time (1217.621 seconds)<br></code></pre></td></tr></table></figure><p><code>pvesm</code> 不卡、<code>pvestatd</code> 报存储挂载失败——说明问题不在 PVE 自己的轮询代码里，继续往内核走。</p><h3 id="Step-2：抓-D-状态进程与内核栈（本次的破局点）"><a href="#Step-2：抓-D-状态进程与内核栈（本次的破局点）" class="headerlink" title="Step 2：抓 D 状态进程与内核栈（本次的破局点）"></a>Step 2：抓 D 状态进程与内核栈（本次的破局点）</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">ps -eo <span class="hljs-built_in">stat</span>,pid,ppid,etime,wchan:34,args --no-headers | grep -E <span class="hljs-string">&quot;^D&quot;</span><br></code></pre></td></tr></table></figure><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">D  12895  4821  01:34 bh_uptodate_or_lock  mount /dev/rbd0 /var/lib/lxc/.pve-staged-mounts/rootfs<br></code></pre></td></tr></table></figure><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">cat</span> /proc/12895/stack<br></code></pre></td></tr></table></figure><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">[&lt;0&gt;] bh_uptodate_or_lock+0x9b/0xa0<br>[&lt;0&gt;] jread+0xf4/0x390<br>[&lt;0&gt;] do_one_pass+0xdc/0xde0<br>[&lt;0&gt;] jbd2_journal_recover+0x89/0x130<br>[&lt;0&gt;] jbd2_journal_load+0x143/0x410<br>[&lt;0&gt;] ext4_load_and_init_journal+0x2aa/0xc60<br>[&lt;0&gt;] ext4_fill_super+0x2f89/0x30e0<br>[&lt;0&gt;] path_mount+0x4e1/0xb20<br>[&lt;0&gt;] __x64_sys_mount+0x127/0x160<br></code></pre></td></tr></table></figure><p>这一段栈信息是整次排查的转折点，翻译过来就是：</p><blockquote><p>容器 rootfs 挂在 Ceph RBD 设备（<code>/dev/rbd0</code>）上，挂载时要做 ext4 日志回放（<code>jbd2_journal_recover</code>），而读日志块（<code>jread</code> → <code>bh_uptodate_or_lock</code>）<strong>一直等不到 I&#x2F;O 返回</strong>。</p></blockquote><p>内核态 D 状态 + RBD 设备 → 矛头立刻指向 Ceph，而不是 NFS。</p><blockquote><p><strong>经验</strong>：<code>D</code> 状态的进程是杀不掉的，<code>kill -9</code> 只会挂在信号队列里。看到 D 状态，第一件事是 <code>cat /proc/&lt;PID&gt;/stack</code>，而不是 killall。</p></blockquote><h3 id="Step-3：检查-Ceph-集群与-RBD-锁"><a href="#Step-3：检查-Ceph-集群与-RBD-锁" class="headerlink" title="Step 3：检查 Ceph 集群与 RBD 锁"></a>Step 3：检查 Ceph 集群与 RBD 锁</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs bash">ceph -s<br>ceph health detail<br>ceph osd tree | <span class="hljs-built_in">head</span><br>rbd showmapped<br>dmesg -T | grep -iE <span class="hljs-string">&quot;rbd|libceph&quot;</span><br></code></pre></td></tr></table></figure><p>抓到四组异常：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">HEALTH_WARN clock skew detected on mon.cl3<br>            nodown flag(s) set<br>            Slow OSD heartbeats on back (longest 1636.017ms)<br>            Slow OSD heartbeats on front (longest 1509.350ms)<br><br>[WRN] MON_CLOCK_SKEW: clock skew detected on mon.cl3<br>    mon.cl3 clock skew 6.68564e+07s &gt; max 0.05s<br><br>    osd: 16 osds: 16 up (since 2m), 16 in (since 17M)<br></code></pre></td></tr></table></figure><ul><li><strong><code>clock skew detected on mon.cl3</code>：偏差 6.68e7 秒，约 774 天</strong>——有一台节点的时钟差得离谱；</li><li><code>nodown flag(s) set</code>：有人给 Ceph 设置了 <code>nodown</code>，OSD 永远不会被判为 down，客户端会一直 hang 而不是切换；</li><li><code>Slow OSD heartbeats</code>：前端&#x2F;后端网络心跳最长 1.6 秒；</li><li><code>16 osds: 16 up (since 2m)</code>：<strong>所有 OSD 两分钟前刚重启过</strong>——这是最可疑的一条。</li></ul><p>再看内核日志：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">rbd: rbd0: no lock owners detected<br>rbd: rbd0: no lock owners detected<br>rbd: rbd0: breaking header lock owned by client464914337<br>rbd: rbd0: capacity 214748364800 features 0x3d<br>EXT4-fs warning (device rbd0): ext4_multi_mount_protect:326: MMP interval 42 higher than expected, please wait.<br></code></pre></td></tr></table></figure><p>重启前的老客户端（<code>client464914337</code>）没释放 RBD 镜像锁，新挂载要反复等待、最后强制破锁——这一步会白白卡掉几十秒，正好是”挂载卡住”的表象之一。</p><h3 id="Step-4：回溯上一个-boot，问一句”OSD-为什么刚全部重启”"><a href="#Step-4：回溯上一个-boot，问一句”OSD-为什么刚全部重启”" class="headerlink" title="Step 4：回溯上一个 boot，问一句”OSD 为什么刚全部重启”"></a>Step 4：回溯上一个 boot，问一句”OSD 为什么刚全部重启”</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs bash">journalctl --list-boots<br>journalctl -b -1 | grep -iE <span class="hljs-string">&quot;clock was stepped|System clock wrong|loop take too long&quot;</span><br>journalctl -b -1 --since <span class="hljs-string">&quot;19:31:30&quot;</span> --<span class="hljs-keyword">until</span> <span class="hljs-string">&quot;19:31:52&quot;</span><br></code></pre></td></tr></table></figure><p>关键三行（注意：日志前缀里的 <code>Jun 16</code> 是<strong>错误时钟自己的时间戳</strong>）：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">Jun 16 17:46:15 cl4 chronyd[2153]: System clock wrong by 72927926.233466 seconds<br>9 月 16 日 19:31:41 cl4 chronyd[2153]: System clock was stepped by 72927926.233466 seconds<br>9 月 16 日 19:31:43 cl4 pve-ha-crm[2854]: loop take too long (72927931 seconds)<br></code></pre></td></tr></table></figure><p><strong>系统时钟被 chrony 一次性向前拨了 72,927,926 秒（约 844 天）</strong>，从错误时间直接跳到当前时间。<code>pve-ha-crm</code> 那句 <code>loop take too long (72927931 seconds)</code> 就是时钟跳变的”指纹”。</p><p>时钟一跳，systemd 的 <code>OnCalendar</code> 定时器集体补跑：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">9 月 16 日 19:31:41 cl4 systemd[1]: Starting logrotate.service - Rotate log files...<br>9 月 16 日 19:31:41 cl4 systemd[1]: Starting dpkg-db-backup.service - Daily dpkg database backup service...<br>9 月 16 日 19:31:41 cl4 systemd[1]: Starting e2scrub_all.service - Online ext4 Metadata Check for All Filesystems...<br></code></pre></td></tr></table></figure><p>另外 RRD 也会报警，因为它没法接受时间倒退&#x2F;跳跃：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">pmxcfs: RRD update error ...: illegal attempt to update using time 1699576046<br>        when last update time is 1791451796 (minimum one second step)<br></code></pre></td></tr></table></figure><h3 id="Step-5：谁-HUP-了-Ceph？——logrotate-的-postrotate"><a href="#Step-5：谁-HUP-了-Ceph？——logrotate-的-postrotate" class="headerlink" title="Step 5：谁 HUP 了 Ceph？——logrotate 的 postrotate"></a>Step 5：谁 HUP 了 Ceph？——logrotate 的 postrotate</h3><p>紧接着的下一行日志，凶手就自己报了名：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">9 月 16 日 19:31:42 cl4 ceph-mon[2323]: received signal: Hangup from<br>  killall -q -1 ceph-mon ceph-mgr ceph-mds ceph-osd ceph-fuse radosgw rbd-mirror cephfs-mirror<br>  (PID: 2879) UID: 0<br>9 月 16 日 19:31:42 cl4 systemd[1]: ceph-osd@12.service: Deactivated successfully.<br>9 月 16 日 19:31:42 cl4 systemd[1]: ceph-mgr@cl4.service: Deactivated successfully.<br>9 月 16 日 19:31:42 cl4 systemd[1]: ceph-mds@cl4-cl4-mate.service: Deactivated successfully.<br></code></pre></td></tr></table></figure><p>追这条 <code>killall</code> 的出处：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">grep -rn <span class="hljs-string">&quot;killall&quot;</span> /etc/logrotate.d/<br></code></pre></td></tr></table></figure><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">/etc/logrotate.d/ceph-common:7:<br>    killall -q -1 ceph-mon ceph-mgr ceph-mds ceph-osd ceph-fuse radosgw rbd-mirror cephfs-mirror \<br>      || pkill -1 -x &quot;ceph-mon|ceph-mgr|ceph-mds|ceph-osd|ceph-fuse|radosgw|rbd-mirror|cephfs-mirror&quot; || true<br></code></pre></td></tr></table></figure><p><strong>这是 logrotate 轮转 Ceph 日志后，让守护进程重新打开日志文件的常规 postrotate 动作。</strong> 平时它人畜无害（每天轮转一次，发个 SIGHUP 让 Ceph 换日志句柄而已）；但在时钟跳变的那一秒，它和其它定时任务一起被”补跑”，于是这一记 SIGHUP 精准地砸在了刚起身、还没稳住的 Ceph 守护进程上。</p><p>至此链条完全闭合：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">RTC 时间错乱<br>  → 节点开机后 chrony 大步长 step 系统时钟<br>  → systemd OnCalendar 定时器集体补跑<br>  → logrotate 轮转 Ceph 日志并执行 postrotate: killall -q -1 ceph-*<br>  → 本节点 Ceph 守护进程相继退出/重载，OSD 全部重启<br>  → RBD 读 I/O 停摆、旧客户端锁未释放<br>  → 各节点 mount /dev/rbdN /var/lib/lxc/.pve-staged-mounts/rootfs 卡在内核 D 状态<br>  → pvestatd 单线程被拖死 800~1200 秒<br>  → 网页端全盘问号<br></code></pre></td></tr></table></figure><h3 id="Step-6：另一条独立病因——硬挂载-NFS-撞上后端重启"><a href="#Step-6：另一条独立病因——硬挂载-NFS-撞上后端重启" class="headerlink" title="Step 6：另一条独立病因——硬挂载 NFS 撞上后端重启"></a>Step 6：另一条独立病因——硬挂载 NFS 撞上后端重启</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash">mount | grep -E <span class="hljs-string">&quot;nfs&quot;</span><br>journalctl -b -1 | grep <span class="hljs-string">&quot;nfs: server&quot;</span><br></code></pre></td></tr></table></figure><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">192.168.0.108:/mnt/bigstor/DS01  on /mnt/pve/BIGSTOR type nfs4 (...,soft,timeo=10,retrans=2,...)<br>192.168.0.108:/mnt/bigstor/kstor on /mnt/pve/Kstor   type nfs4 (...,hard,timeo=600,retrans=2,...)<br>192.168.0.108:/mnt/bigstor/DSEtc on /mnt/pve/DS01    type nfs4 (...,hard,timeo=600,retrans=2,...)<br></code></pre></td></tr></table></figure><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">cl2 kernel: nfs: server 192.168.0.252 not responding, timed out<br>cl2 kernel: nfs: server 192.168.0.252 not responding, timed out<br>cl2 kernel: nfs: server 192.168.0.252 not responding, timed out<br></code></pre></td></tr></table></figure><ul><li>一台节点在 17:29–17:32 被 <code>192.168.0.252</code> 的 NFS 超时刷屏，之后日志直接中断（没能留下正常关机记录）；</li><li>存储服务器（192.168.0.108）当天重启了多次，重启后 PVE 立刻报 <code>storage &#39;BIGSTOR&#39; is not online</code>；</li><li>三个 NFS 存储里，只有 <code>BIGSTOR</code> 加固成了 <code>soft,timeo=10,retrans=2,retry=0</code>，<code>DS01</code> 和 <code>Kstor</code> 仍是默认的 <code>hard,timeo=600,retrans=2</code>。</li></ul><p><strong>结论：NFS 是这次的”引信”，Ceph 是”炸药”。</strong> 两者都会把 <code>pvestatd</code> 拖死，但处置方式完全不同。</p><h2 id="三、根因总结"><a href="#三、根因总结" class="headerlink" title="三、根因总结"></a>三、根因总结</h2><table><thead><tr><th>链条</th><th>触发</th><th>传导</th><th>结果</th></tr></thead><tbody><tr><td>时钟链条</td><td>RTC&#x2F;BIOS 时间错（实测三台节点分别偏 1063 &#x2F; 774 &#x2F; 844 天）</td><td>chrony 大步长 step → 定时器补跑 → logrotate 对全部 Ceph 守护进程 <code>killall -1</code></td><td>OSD 集体重启 → RBD I&#x2F;O 停摆 → 挂载卡 D 状态 → <code>pvestatd</code> 被拖死 800~1200 秒</td></tr><tr><td>NFS 链条</td><td>存储服务器重启、<code>192.168.0.252</code> 失联</td><td><code>hard,timeo=600</code> 挂载进入不可中断等待</td><td>同样是 D 状态、同样拖死管理面</td></tr></tbody></table><h2 id="四、与上一篇的差异（更正）"><a href="#四、与上一篇的差异（更正）" class="headerlink" title="四、与上一篇的差异（更正）"></a>四、与上一篇的差异（更正）</h2><table><thead><tr><th>对比项</th><th>上一篇的结论</th><th>本次实测</th></tr></thead><tbody><tr><td>现象</td><td><code>pvesm status</code> 卡死、无响应</td><td><code>pvesm status</code> <strong>1.5 秒正常返回</strong>，存储全 <code>active</code>，问号照样出现</td></tr><tr><td>卡点层级</td><td><code>pvestatd</code> 轮询外部存储陷入 I&#x2F;O 等待</td><td><strong>内核态 RBD 日志回放读 I&#x2F;O</strong>（<code>jbd2_journal_recover</code> → D 状态）</td></tr><tr><td><code>killall -9 pvesm/pvestatd</code></td><td>有效，能解开死锁</td><td><strong>无效</strong>：D 状态进程送不进信号；杀掉 <code>pvestatd</code> 只是让它几秒后重新卡在同一处</td></tr><tr><td>处置顺序</td><td>先杀进程 → 禁用存储 → 重启服务</td><td>先救后端（Ceph&#x2F;NFS）→ 对不可达存储 <code>disable 1</code> → 必要时重启节点 → <strong>最后</strong>才动管理面服务</td></tr><tr><td>排查入口</td><td>检查 <code>storage.cfg</code> 外置存储</td><td>先 <code>ps</code> 抓 D 状态进程 + <code>cat /proc/&lt;PID&gt;/stack</code>，再按设备类型分流</td></tr></tbody></table><p>上一篇的核心逻辑并没有错——外置存储不可达确实会拖死管理面，这次也再次验证了（NFS 那条链条）。错的是<strong>把”killall 管理进程”当成了通用解法</strong>：它只对”管理面自己的轮询死循环”有效，对内核 D 状态 I&#x2F;O 完全无能为力。</p><h2 id="五、更新后的应急处置顺序（Runbook）"><a href="#五、更新后的应急处置顺序（Runbook）" class="headerlink" title="五、更新后的应急处置顺序（Runbook）"></a>五、更新后的应急处置顺序（Runbook）</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 1) 先定位卡在哪一层：管理面 还是 内核</span><br><span class="hljs-keyword">time</span> <span class="hljs-built_in">timeout</span> 20 pvesm status<br>ps -eo <span class="hljs-built_in">stat</span>,pid,etime,wchan:30,args --no-headers | grep -E <span class="hljs-string">&quot;^D&quot;</span><br><span class="hljs-built_in">cat</span> /proc/&lt;PID&gt;/stack | <span class="hljs-built_in">head</span> -12<br>ceph -s; ceph health detail<br><br><span class="hljs-comment"># 2) 若 D 状态卡在 NFS 挂载点：后端救不回来就先禁用该存储</span><br><span class="hljs-comment">#    编辑 /etc/pve/storage.cfg，在对应存储块末尾加一行 disable 1</span><br>mount -f -l /mnt/pve/&lt;storage&gt;   <span class="hljs-comment"># 或直接重启该节点</span><br><br><span class="hljs-comment"># 3) 若 D 状态卡在 /dev/rbdN：先救 Ceph，别碰 PVE 管理服务</span><br>ceph -s<br>ceph osd <span class="hljs-built_in">unset</span> nodown            <span class="hljs-comment"># 清掉残留的 nodown，让 OSD 能正常判 down</span><br>systemctl restart ceph.target    <span class="hljs-comment"># 必要时（单节点）</span><br><br><span class="hljs-comment"># 4) 校时：这一步经常被忽略，但本次就是它引发的</span><br>timedatectl; hwclock -r<br>chronyc tracking<br><br><span class="hljs-comment"># 5) 最后才重启管理面服务</span><br>systemctl restart pvestatd pvedaemon pveproxy<br></code></pre></td></tr></table></figure><p>顺序原则：<strong>先数据面（Ceph&#x2F;NFS 后端），再存储配置，再节点，最后管理面。</strong> 反过来做，只会反复卡在同一个位置。</p><h2 id="六、预防措施"><a href="#六、预防措施" class="headerlink" title="六、预防措施"></a>六、预防措施</h2><ol><li><strong>修 RTC&#x2F;CMOS（最高优先级）</strong>：本次三台节点开机时 RTC 时间分别错误 1063 &#x2F; 774 &#x2F; 844 天。只要 RTC 不修，每次重启都会重演”时钟跳变 → 定时器风暴 → logrotate HUP Ceph”这套组合拳。换主板电池、进 BIOS 校时，并用 <code>hwclock -r</code> 与 <code>timedatectl</code> 复核。</li><li><strong>加固 chrony</strong>：<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs bash">grep -E <span class="hljs-string">&quot;makestep|rtcsync&quot;</span> /etc/chrony/chrony.conf<br><span class="hljs-comment"># 建议保留 makestep（让时钟尽快对齐），但更关键的是 rtcsync——</span><br><span class="hljs-comment"># 让 chrony 把正确时间写回 RTC，避免下次开机又从头错起</span><br></code></pre></td></tr></table></figure></li><li><strong>收敛 logrotate 的 Ceph 段</strong>：确认 <code>/etc/logrotate.d/ceph-common</code> 的 postrotate 是否必须对<strong>所有</strong> Ceph 守护进程发信号；可以缩小到必要进程，或改为夜间固定时段执行，避免与开机时的时间跳变撞车。</li><li><strong>统一 NFS 挂载参数</strong>：至少不要让 <code>timeo=600</code> 的 <code>hard</code> 挂载对着一个会重启的存储服务器。<code>soft,timeo=10,retrans=2,retry=0</code> 的代价是写失败会返回 <code>EIO</code>（需要应用层重试），但对 PVE 管理面稳定性收益明显。注意 NFS 选项写在 <code>storage.cfg</code> 里才持久。</li><li><strong>清理 Ceph 的 <code>nodown</code></strong>：这个 flag 会让 OSD 永不判 down，客户端一直 hang 而不切换。确认无正在进行的维护后执行 <code>ceph osd unset nodown</code>。</li><li><strong>盯住 PBS 容量</strong>：本次检查发现备份 datastore 已用 <strong>81.97%</strong>（14.76 TiB 只剩 1.92 TiB），且裁剪策略是 <code>keep-all=1</code>（<strong>永不裁剪</strong>）。要么改保留策略，要么扩容，否则备份迟早写失败。</li><li><strong>四类告警信号</strong>（任一出现都值得立刻看）：<ul><li><code>pvestatd: status update time (N seconds)</code>，N 超过几十秒；</li><li>内核 <code>nfs: server X not responding</code>；</li><li><code>chronyd: System clock was stepped</code>；</li><li><code>ceph health</code> 不再是 <code>HEALTH_OK</code>。</li></ul></li></ol><h2 id="七、一句话总结"><a href="#七、一句话总结" class="headerlink" title="七、一句话总结"></a>七、一句话总结</h2><blockquote><p>PVE 的”全盘问号”只是一个<strong>症状</strong>。真正要问的是两个问题：<strong>问号出现时，<code>pvesm status</code> 还跑得动吗？D 状态进程卡在哪个设备上？</strong></p><p>前者决定管理面是不是自己死锁（那就 killall + 重启服务），后者决定你是该去救 Ceph、还是该去修时钟。</p></blockquote><p>只要 SSH 还能连上、<code>df</code> 还能跑，数据大概率是安全的——但”遇事不慌”的前提是<strong>知道该看哪个指标</strong>，而不是背一套固定的命令。</p>]]>
    </content>
    <id>https://blog.952405.xyz/2026/09/pve-storage-question-mark-ceph-clock-step/</id>
    <link href="https://blog.952405.xyz/2026/09/pve-storage-question-mark-ceph-clock-step/"/>
    <published>2026-09-16T02:00:00.000Z</published>
    <summary>同样的全盘问号再次出现，照上一篇的 killall 流程走却完全无效。这次卡点不在 pvestatd，而在内核 D 状态的 RBD 读 I/O——顺着内核栈一路挖下去，真凶是节点 RTC 时间错乱引发的定时器风暴，以及 logrotate 对全部 Ceph 守护进程的那一记 killall。</summary>
    <title>PVE 存储全盘问号复盘（下）：真凶不是 pvestatd，而是时钟跳变引爆的 Ceph 雪崩</title>
    <updated>2026-09-16T02:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="基础设施" scheme="https://blog.952405.xyz/categories/%E5%9F%BA%E7%A1%80%E8%AE%BE%E6%96%BD/"/>
    <category term="Docker Compose" scheme="https://blog.952405.xyz/tags/Docker-Compose/"/>
    <category term="Docker Swarm" scheme="https://blog.952405.xyz/tags/Docker-Swarm/"/>
    <category term="DevOps" scheme="https://blog.952405.xyz/tags/DevOps/"/>
    <category term="Kafka" scheme="https://blog.952405.xyz/tags/Kafka/"/>
    <content>
      <![CDATA[<h2 id="引言"><a href="#引言" class="headerlink" title="引言"></a>引言</h2><p>客户现场有一套自建的 Kafka，用 Docker Swarm 部署，单节点 KRaft 模式，全公司好几个业务系统都挂在上面收发消息。某天接到反馈：<strong>有一个应用一直连不上 Kafka，其他应用全都好好的</strong>。</p><p>这种”别人都没事，就它不行”的故障是最容易把人带沟里的。第一反应几乎必然是——是不是这个应用自己的配置写错了？是不是它跑的那台机器网络有问题？</p><p>最后的结论确实和 Kafka 配置有关，但<strong>根因不在那个应用身上，也不在其他任何一个应用身上</strong>，而在一个大多数人都配过、却很少有人真正理解其语义的参数：<code>KAFKA_ADVERTISED_LISTENERS</code>。</p><p>更准确地说：这个参数配错的故障表现，天生就是”<strong>选择性的</strong>“——它不会打死所有人，只会打死那些网络路径和它不匹配的客户端。这也正是它最迷惑人的地方。</p><h2 id="一、故障现象"><a href="#一、故障现象" class="headerlink" title="一、故障现象"></a>一、故障现象</h2><p>先交代一下环境拓扑，这是后面所有推理的基础：</p><ul><li>Kafka 跑在客户的 Swarm 集群里，通过一个统一的对外入口访问，下文把这个入口的域名脱敏为 <code>pfscene.example.com</code>（真实域名保留在客户环境里）。</li><li><strong>大部分业务应用部署在内网</strong>，和 Kafka 处在可以互通的网段。</li><li><strong>出问题的那个应用部署在另外一台独立机器上</strong>，它连不到 Kafka 的内网地址，只能走 <code>pfscene.example.com</code> 这个对外入口。</li></ul><p>故障表现的形态很典型。应用启动之后日志里反复刷：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs text">[Producer clientId=producer-1] Connection to node 1 (192.168.0.196/192.168.0.196:9092) could not be established. Broker may not be available.<br>...<br>org.apache.kafka.common.errors.TimeoutException: Topic order-events not present in metadata after 60000 ms.<br></code></pre></td></tr></table></figure><p>Python 客户端那边是另一个长相，但本质一样：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs text">kafka.errors.NoBrokersAvailable: NoBrokersAvailable<br># 或者卡很久之后<br>kafka.errors.KafkaTimeoutError: KafkaTimeoutError: Failed to update metadata after 60.0 secs<br></code></pre></td></tr></table></figure><p>这里有个细节值得先圈出来：<strong>报错里的地址是 <code>192.168.0.196:9092</code>，一个内网地址</strong>。而那个应用配置里写的连接地址明明是 <code>pfscene.example.com</code>。它从来没主动连过 <code>192.168.0.196</code>，这个地址是<strong>Kafka 主动告诉它的</strong>。</p><p>当时没人注意到这个细节，包括我在内。</p><h2 id="二、第一轮排查：先证明它到底能不能连上"><a href="#二、第一轮排查：先证明它到底能不能连上" class="headerlink" title="二、第一轮排查：先证明它到底能不能连上"></a>二、第一轮排查：先证明它到底能不能连上</h2><p>面对”连不上”的报障，第一步永远是把”连不上”这三个字拆开——是<strong>网络层不通</strong>，还是<strong>连上了但用不了</strong>？这两者的排查方向完全相反。</p><p>先从那台故障机器上做最朴素的端口探测：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 从故障机器探测对外入口，看四层是否可达</span><br>nc -zv pfscene.example.com 19092<br></code></pre></td></tr></table></figure><p>结果：<strong>通</strong>。</p><p>再让用户确认防火墙、安全组、出网策略，也都没有拦。也就是说：</p><blockquote><p>网络是通的，应用能建立到 Kafka 的 TCP 连接，但它依然起不来。</p></blockquote><p>这一步排除掉了一大半可能性（路由、ACL、防火墙、DNS 解析失败），同时把问题压缩到了一个很窄的范围里：<strong>连接建立得起来，但会话建立不起来</strong>。在 Kafka 的语境下，这几乎等价于一句话——客户端拿到的集群信息是错的。</p><p>同时，另一个事实也在反复敲打我们：其他应用全都正常。如果 Kafka 本身配置有问题，为什么它们没事？</p><blockquote><p>说实话，到这里我的直觉也是偏向”那个应用自己的问题”——毕竟其他应用都正常这个反证太强了。这恰恰是这次排查里最大的思维陷阱：”其他人都正常”只能证明<strong>故障不是全局的</strong>，不能证明<strong>故障不在被怀疑的服务端</strong>。</p></blockquote><h2 id="三、关键一击：让-Kafka-自己交代它广播了什么"><a href="#三、关键一击：让-Kafka-自己交代它广播了什么" class="headerlink" title="三、关键一击：让 Kafka 自己交代它广播了什么"></a>三、关键一击：让 Kafka 自己交代它广播了什么</h2><p>既然是”连上了但用不了”，那就要看客户端在握手之后拿到了什么。Kafka 的元数据是可以用命令行直接问出来的，这也是这次排查里最快的一击：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 站在故障机器的视角，向对外入口要一份集群元数据</span><br>kcat -b pfscene.example.com:19092 -L<br></code></pre></td></tr></table></figure><p>输出大意如下（已脱敏）：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs text">Metadata for all topics (from broker -1: pfscene.example.com:19092/bootstrap):<br> 1 brokers:<br>  broker 1 at 192.168.0.196:9092 (controller)<br> 0 topics:<br></code></pre></td></tr></table></figure><p>真相就在这里，一行字：</p><ul><li><code>from broker -1: pfscene.example.com:19092/bootstrap</code>——这一行是<strong>我主动连的</strong>地址，也就是 bootstrap 地址，Kafka 标记为 <code>broker -1</code>，意思是”还没分配身份的入口”。</li><li><code>broker 1 at 192.168.0.196:9092</code>——这一行是 <strong>Kafka 告诉我”你应该来这儿找我”</strong> 的地址。</li></ul><p>于是整个故障链条一下子闭合了：</p><ol><li>应用连 <code>pfscene.example.com:19092</code> → 成功（所以 <code>nc</code> 是通的）。</li><li>Kafka 回了一句”我这个 broker 在 <code>192.168.0.196:9092</code>“。</li><li>应用老老实实去连 <code>192.168.0.196:9092</code> → <strong>那台独立机器根本不在这个内网里，路由不可达</strong> → 超时、重试、再超时。</li></ol><p>补一刀验证这个推断：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 从故障机器分别探测两个地址，对比结果</span><br>nc -zv pfscene.example.com 19092   <span class="hljs-comment"># 通</span><br>nc -zv 192.168.0.196 9092          <span class="hljs-comment"># 超时</span><br></code></pre></td></tr></table></figure><p>再回到 broker 侧确认配置本身：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># Swarm 下容器名形如 &lt;stack&gt;_kafka.1.&lt;hash&gt;，没有固定名字，按服务名过滤取容器</span><br>docker <span class="hljs-built_in">exec</span> <span class="hljs-string">&quot;<span class="hljs-subst">$(docker ps -q -f name=kafka | head -n1)</span>&quot;</span> <span class="hljs-built_in">env</span> | grep -i ADVERTISED<br><br><span class="hljs-comment"># 输出：</span><br><span class="hljs-comment"># KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://192.168.0.196:9092</span><br></code></pre></td></tr></table></figure><p>现场的 compose 片段（缩进已整理）：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">services:</span><br>  <span class="hljs-attr">kafka:</span><br>    <span class="hljs-attr">image:</span> <span class="hljs-string">apache/kafka:4.1.1</span><br>    <span class="hljs-attr">container_name:</span> <span class="hljs-string">broker</span><br>    <span class="hljs-attr">environment:</span><br>      <span class="hljs-comment"># broker 实际监听的地址：0.0.0.0:9092，容器内外都能进来</span><br>      <span class="hljs-attr">KAFKA_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://:9092,CONTROLLER://:9093</span><br>      <span class="hljs-comment"># ↓↓↓ 问题就在这一行：广播出去的是宿主机内网 IP</span><br>      <span class="hljs-attr">KAFKA_ADVERTISED_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://192.168.0.196:9092</span><br>      <span class="hljs-attr">KAFKA_CONTROLLER_QUORUM_VOTERS:</span> <span class="hljs-number">1</span><span class="hljs-string">@kafka:9093</span><br>      <span class="hljs-attr">KAFKA_NODE_ID:</span> <span class="hljs-number">1</span><br>      <span class="hljs-attr">KAFKA_PROCESS_ROLES:</span> <span class="hljs-string">broker,controller</span><br>      <span class="hljs-attr">KAFKA_CONTROLLER_LISTENER_NAMES:</span> <span class="hljs-string">CONTROLLER</span><br>      <span class="hljs-attr">KAFKA_LISTENER_SECURITY_PROTOCOL_MAP:</span> <span class="hljs-string">CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT</span><br>    <span class="hljs-attr">ports:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-attr">target:</span> <span class="hljs-number">9092</span><br>        <span class="hljs-attr">published:</span> <span class="hljs-number">9092</span><br>        <span class="hljs-attr">protocol:</span> <span class="hljs-string">tcp</span><br>        <span class="hljs-attr">mode:</span> <span class="hljs-string">host</span><br>    <span class="hljs-attr">networks:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">net</span><br></code></pre></td></tr></table></figure><p><code>KAFKA_LISTENERS</code> 里写 <code>PLAINTEXT://:9092</code>（即绑 <code>0.0.0.0:9092</code>）不是问题的根源——它决定了 broker 在哪张网卡上接客，是<strong>服务端视角</strong>的配置。真正捅娄子的是 <code>KAFKA_ADVERTISED_LISTENERS</code>，它是<strong>客户端视角</strong>的配置，含义是：</p><blockquote><p>“客户端你好，请你用这个地址回来找我。”</p></blockquote><p>而这个地址，被硬编码成了一个外部客户端永远够不着的内网 IP。</p><h2 id="四、原理：bootstrap-只是”敲门砖”"><a href="#四、原理：bootstrap-只是”敲门砖”" class="headerlink" title="四、原理：bootstrap 只是”敲门砖”"></a>四、原理：bootstrap 只是”敲门砖”</h2><p>如果要问 Kafka 网络配置里最容易踩、后果最隐蔽的一个坑是什么，我会毫不犹豫投给 <code>advertised.listeners</code>。它隐蔽的根本原因在于：<strong>Kafka 客户端不是只连一个地址，而是连两次。</strong></p><p>一次完整的客户端接入过程是这样的：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs text">外部应用                    pfscene 入口              Kafka Broker<br>   |                            |                        |<br>   |--- ① 用 bootstrap 连接 ---&gt;|--- 转发 -------------&gt; |   成功（所以 nc 通）<br>   |                            |                        |<br>   |&lt;-- ② Metadata 响应：      --------------------------|   返回「我在<br>   |     &quot;broker 1 在 192.168.0.196:9092&quot;                |    192.168.0.196:9092」<br>   |                            |                        |<br>   |--- ③ 改用 ② 给出的地址直连 -----------------------✗ |   路由不可达 → 超时<br></code></pre></td></tr></table></figure><ul><li><strong>第一阶段（bootstrap）</strong>：客户端拿 <code>bootstrap.servers</code> 里的地址，随便挑一个连上去。这个地址的作用仅仅是<strong>敲门</strong>——它只要能带着客户端进入集群即可，可以是任何一个 broker。</li><li><strong>第二阶段（metadata）</strong>：连上之后客户端会请求一份集群元数据。broker 在响应里给出”分区 leader 在哪台 broker 上”，而<strong>这些 broker 的地址，就是它们各自 <code>advertised.listeners</code> 里配的值</strong>，由 broker 原样广播出去。</li><li><strong>第三阶段（真正干活）</strong>：客户端<strong>丢弃 bootstrap 地址</strong>，改用 metadata 里返回的地址去连真正的 leader 收发消息。</li></ul><p>关键点在于：<strong>Kafka 完全不关心它广播出去的地址对客户端是否可达。</strong> 它只是如实转述配置里写的东西。地址能不能通，是配置者的责任。</p><p>所以现场的现象不是巧合，而是必然：</p><table><thead><tr><th>客户端位置</th><th>bootstrap 用的地址</th><th>metadata 拿到的 broker 地址</th><th>结果</th></tr></thead><tbody><tr><td>内网 &#x2F; Swarm 内的应用</td><td><code>kafka:9092</code> 或内网 IP</td><td><code>192.168.0.196:9092</code></td><td>可达 → <strong>正常</strong></td></tr><tr><td>那台独立机器上的应用</td><td><code>pfscene.example.com:19092</code></td><td><code>192.168.0.196:9092</code></td><td>不可达 → <strong>超时</strong></td></tr></tbody></table><p>这张表就是”其他应用都正常、只有它不行”的全部答案：<strong>同一个 broker、同一份配置，对不同网络路径的客户端产生了不同的结果</strong>。advertised 配错的故障面不是”全体下线”，而是”走到不可达路径的那部分客户端”——这就是它被称为选择性故障的原因。</p><p>顺带说一句，如果 <code>advertised.listeners</code> 干脆不配会怎样？Kafka 会退而使用 <code>listeners</code> 的值；而如果 <code>listeners</code> 里的 host 部分留空（就像上面那样写成 <code>PLAINTEXT://:9092</code>），Kafka 会拿 <code>java.net.InetAddress.getCanonicalHostName()</code> 去猜——在容器里通常得到的是<strong>容器主机名或容器 IP</strong>，那是一个比内网 IP 更不可达的地址。所以这个参数不但要配，还必须配对。</p><h2 id="五、为什么”把那一行改掉”不是正确答案"><a href="#五、为什么”把那一行改掉”不是正确答案" class="headerlink" title="五、为什么”把那一行改掉”不是正确答案"></a>五、为什么”把那一行改掉”不是正确答案</h2><p>知道了根因，最直觉的修复是：把广播地址改成对外入口不就行了？</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-comment"># 看似能治病的改法</span><br><span class="hljs-attr">KAFKA_ADVERTISED_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://pfscene.example.com:19092</span><br></code></pre></td></tr></table></figure><p>这个改法确实能让那台独立机器连上。<strong>但它会顺手打死另一批客户端。</strong></p><p>别忘了，同一套服务里还躺着一个内部组件——Kafdrop（Kafka 的 Web 管理界面），它就在同一个 Swarm 网络里：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">kafdrop:</span><br>  <span class="hljs-attr">image:</span> <span class="hljs-string">obsidiandynamics/kafdrop:latest</span><br>  <span class="hljs-attr">environment:</span><br>    <span class="hljs-attr">KAFKA_BROKERCONNECT:</span> <span class="hljs-string">&quot;kafka:9092&quot;</span>   <span class="hljs-comment"># 它走的是内部服务名</span><br></code></pre></td></tr></table></figure><p>改完之后会发生什么？</p><ol><li>Kafdrop 用 <code>kafka:9092</code> 做 bootstrap → 依然连得上。</li><li>broker 回它一句”broker 1 在 <code>pfscene.example.com:19092</code>“。</li><li>Kafdrop 转身去连 <code>pfscene.example.com</code> → 如果容器内的 DNS 解析不到这个名字，或者解析出来但回程路由不通（对外入口多半做了 NAT 甚至反向代理），它照样挂。</li></ol><p>于是就成了按下葫芦浮起瓢：<strong>修好了外部的，打死了内部的。</strong></p><p>问题的本质在于：<code>advertised.listeners</code> 是<strong>按 listener 广播</strong>的，而当时只有一个 listener，所以<strong>全天下所有客户端只能听到同一个地址</strong>。可现实里客户端来自两条不同的网络路径，一条走内部服务名，一条走对外入口。<strong>一个地址伺候不了两条路径。</strong></p><h2 id="六、正确解法：拆成内、外两个-listener"><a href="#六、正确解法：拆成内、外两个-listener" class="headerlink" title="六、正确解法：拆成内、外两个 listener"></a>六、正确解法：拆成内、外两个 listener</h2><p>Kafka 早就为这种场景准备好了机制——<strong>多 listener</strong>：broker 在不同网卡&#x2F;端口上监听，并针对每类客户端广播各自可达的地址。</p><p>改造后的完整 compose（Swarm stack 文件，可直接 <code>docker stack deploy</code>）：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">&#x27;3.8&#x27;</span><br><br><span class="hljs-attr">services:</span><br>  <span class="hljs-attr">kafka:</span><br>    <span class="hljs-attr">image:</span> <span class="hljs-string">apache/kafka:4.1.1</span><br>    <span class="hljs-attr">container_name:</span> <span class="hljs-string">broker</span>          <span class="hljs-comment"># 注：Swarm 模式下该字段不生效，容器名形如 &lt;stack&gt;_kafka.1.&lt;hash&gt;</span><br>    <span class="hljs-attr">environment:</span><br>      <span class="hljs-comment"># ---------- 监听器：内部 9092 / 外部 19092 / controller 9093 ----------</span><br>      <span class="hljs-attr">KAFKA_LISTENERS:</span> <span class="hljs-string">INTERNAL://:9092,EXTERNAL://:19092,CONTROLLER://:9093</span><br><br>      <span class="hljs-comment"># ---------- 广播地址：每类客户端只听到自己那条路径上可达的地址 ----------</span><br>      <span class="hljs-comment"># INTERNAL 用 Swarm 服务名，不写死宿主机 IP，换节点/换机器都不用改配置</span><br>      <span class="hljs-comment"># EXTERNAL 用对外入口的域名和端口</span><br>      <span class="hljs-attr">KAFKA_ADVERTISED_LISTENERS:</span> <span class="hljs-string">INTERNAL://kafka:9092,EXTERNAL://pfscene.example.com:19092</span><br><br>      <span class="hljs-comment"># 所有 listener 都必须在这里登记，漏一个 broker 就起不来</span><br>      <span class="hljs-attr">KAFKA_LISTENER_SECURITY_PROTOCOL_MAP:</span> <span class="hljs-string">INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT</span><br><br>      <span class="hljs-comment"># 多 listener 场景必须显式指定集群内部走哪个 listener，否则启动即报配置异常</span><br>      <span class="hljs-attr">KAFKA_INTER_BROKER_LISTENER_NAME:</span> <span class="hljs-string">INTERNAL</span><br><br>      <span class="hljs-comment"># ---------- KRaft 单节点 ----------</span><br>      <span class="hljs-attr">KAFKA_NODE_ID:</span> <span class="hljs-number">1</span><br>      <span class="hljs-attr">KAFKA_PROCESS_ROLES:</span> <span class="hljs-string">broker,controller</span><br>      <span class="hljs-attr">KAFKA_CONTROLLER_LISTENER_NAMES:</span> <span class="hljs-string">CONTROLLER</span><br>      <span class="hljs-attr">KAFKA_CONTROLLER_QUORUM_VOTERS:</span> <span class="hljs-number">1</span><span class="hljs-string">@kafka:9093</span><br><br>      <span class="hljs-comment"># ---------- 单副本兜底 ----------</span><br>      <span class="hljs-attr">KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR:</span> <span class="hljs-number">1</span><br>      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR:</span> <span class="hljs-number">1</span><br>      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_MIN_ISR:</span> <span class="hljs-number">1</span><br>      <span class="hljs-attr">KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS:</span> <span class="hljs-number">0</span><br>      <span class="hljs-attr">KAFKA_NUM_PARTITIONS:</span> <span class="hljs-number">1</span><br><br>    <span class="hljs-attr">ports:</span><br>      <span class="hljs-comment"># 内部端口：主要是给宿主机侧的 kcat / nc 排查用，不需要对外暴露</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-attr">target:</span> <span class="hljs-number">9092</span><br>        <span class="hljs-attr">published:</span> <span class="hljs-number">9092</span><br>        <span class="hljs-attr">protocol:</span> <span class="hljs-string">tcp</span><br>        <span class="hljs-attr">mode:</span> <span class="hljs-string">host</span><br>      <span class="hljs-comment"># 外部端口：pfscene 入口最终要指向这里</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-attr">target:</span> <span class="hljs-number">19092</span><br>        <span class="hljs-attr">published:</span> <span class="hljs-number">19092</span><br>        <span class="hljs-attr">protocol:</span> <span class="hljs-string">tcp</span><br>        <span class="hljs-attr">mode:</span> <span class="hljs-string">host</span><br><br>    <span class="hljs-attr">deploy:</span><br>      <span class="hljs-attr">replicas:</span> <span class="hljs-number">1</span><br>      <span class="hljs-attr">restart_policy:</span><br>        <span class="hljs-attr">condition:</span> <span class="hljs-string">on-failure</span><br><br>    <span class="hljs-attr">networks:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">net</span><br><br>  <span class="hljs-comment"># 内部客户端的代表：它连 kafka:9092，拿到的也必须是内部可达的地址</span><br>  <span class="hljs-attr">kafdrop:</span><br>    <span class="hljs-attr">image:</span> <span class="hljs-string">obsidiandynamics/kafdrop:latest</span><br>    <span class="hljs-attr">container_name:</span> <span class="hljs-string">kafdrop</span><br>    <span class="hljs-attr">environment:</span><br>      <span class="hljs-attr">KAFKA_BROKERCONNECT:</span> <span class="hljs-string">&quot;kafka:9092&quot;</span><br>      <span class="hljs-attr">JVM_OPTS:</span> <span class="hljs-string">&quot;-Xms256M -Xmx1024M&quot;</span><br>      <span class="hljs-attr">SERVER_PORT:</span> <span class="hljs-number">9000</span><br>    <span class="hljs-attr">ports:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-attr">target:</span> <span class="hljs-number">9000</span><br>        <span class="hljs-attr">published:</span> <span class="hljs-number">19000</span><br>        <span class="hljs-attr">protocol:</span> <span class="hljs-string">tcp</span><br>        <span class="hljs-attr">mode:</span> <span class="hljs-string">host</span><br>    <span class="hljs-attr">depends_on:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka</span>          <span class="hljs-comment"># 注：Swarm 模式下 depends_on 同样不生效，这里只表达启动顺序意图</span><br>    <span class="hljs-attr">networks:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">net</span><br><br><span class="hljs-attr">networks:</span><br>  <span class="hljs-attr">net:</span><br>    <span class="hljs-attr">external:</span><br>      <span class="hljs-attr">name:</span> <span class="hljs-string">sf_net</span><br></code></pre></td></tr></table></figure><p>和现场原配置逐项对比，实质改动其实只有四处：</p><table><thead><tr><th>配置项</th><th>改造前</th><th>改造后</th></tr></thead><tbody><tr><td><code>KAFKA_LISTENERS</code></td><td><code>PLAINTEXT://:9092,CONTROLLER://:9093</code></td><td>拆成 <code>INTERNAL</code> &#x2F; <code>EXTERNAL</code> &#x2F; <code>CONTROLLER</code> 三个</td></tr><tr><td><code>KAFKA_ADVERTISED_LISTENERS</code></td><td><code>PLAINTEXT://192.168.0.196:9092</code></td><td>按 listener 分别广播内外两个地址</td></tr><tr><td><code>KAFKA_LISTENER_SECURITY_PROTOCOL_MAP</code></td><td>只登记 <code>CONTROLLER</code> &#x2F; <code>PLAINTEXT</code></td><td>登记全部三个，并新增 <code>KAFKA_INTER_BROKER_LISTENER_NAME</code></td></tr><tr><td><code>ports</code></td><td>只发布 9092</td><td>追加发布 19092（网关侧同步调整指向）</td></tr></tbody></table><p>其余（KRaft 单节点、副本因子、<code>mode: host</code>、外部网络 <code>sf_net</code>）都保持原样。顺带说明两处”看着像配置、实际不生效”的字段：<code>container_name</code> 和 <code>depends_on</code> 在 Swarm 模式下都会被忽略，前者由 <code>&lt;stack&gt;_&lt;service&gt;.&lt;序号&gt;.&lt;hash&gt;</code> 规则生成容器名，后者只剩语义表达——它们不影响功能，但排查时别拿容器名去 <code>docker exec</code>（下一节验证时会用到这一点）。</p><p>几个容易忽略的细节：</p><ul><li><strong><code>KAFKA_INTER_BROKER_LISTENER_NAME</code> 不能省。</strong> 单 listener 时它无所谓，一旦配了多个 listener，broker 就不知道集群内部通信该走哪一个，启动时会直接抛配置异常。这里指定 <code>INTERNAL</code>，因为 broker 之间（当前是单节点自连）走内部网络最稳。</li><li><strong><code>advertised</code> 的端口允许和实际监听端口不一致。</strong> 上面 <code>EXTERNAL://pfscene.example.com:19092</code> 里的 19092 是”告诉客户端来连这个端口”，broker 自身则在 <code>EXTERNAL://:19092</code> 上监听。这是网关&#x2F;NAT 场景下的合法用法（早年很多”端口映射后客户端连不上”的问题，根源就在于此）——代价是<strong>所有外部客户端都必须真的能从那个地址和端口进来</strong>。</li><li><strong>内部地址用服务名而不是宿主内网 IP。</strong> 原来写 <code>192.168.0.196</code> 的问题是：集群迁到别的节点、宿主机换 IP，配置全废。改成 Swarm 服务名 <code>kafka:9092</code> 之后，内部客户端解析的是集群 DNS，跟宿主机 IP 解耦。</li><li><strong><code>mode: host</code> 发布端口时，端口落在运行容器的那个节点上</strong>，不经过 Swarm 的 routing mesh。所以 <code>pfscene</code> 入口必须指向<strong>实际跑着 broker 的那个节点</strong>，别指望随便指一个节点都能转发。</li></ul><p>改完 <code>advertised.listeners</code> 属于静态配置，<strong>必须重启 broker 才生效</strong>，不能靠动态配置下发：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 滚动重启该服务（单副本，会有秒级中断）</span><br>docker service update --force &lt;stack&gt;_kafka<br></code></pre></td></tr></table></figure><h2 id="七、验证：用两个视角各问一次"><a href="#七、验证：用两个视角各问一次" class="headerlink" title="七、验证：用两个视角各问一次"></a>七、验证：用两个视角各问一次</h2><p>修复之后不要急着宣告成功，用同一个命令从<strong>两条路径</strong>分别验证一次，比什么都直观：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 视角一：内部（在 Swarm 网络内的任意容器里执行）</span><br>kcat -b kafka:9092 -L<br><span class="hljs-comment"># 期望看到：broker 1 at kafka:9092</span><br><br><span class="hljs-comment"># 视角二：外部（在故障机器上执行）</span><br>kcat -b pfscene.example.com:19092 -L<br><span class="hljs-comment"># 期望看到：broker 1 at pfscene.example.com:19092</span><br></code></pre></td></tr></table></figure><p>这里有一条可以刻进肌肉记忆的判据：</p><blockquote><p><strong><code>kcat -L</code> 输出里 <code>broker N at ...</code> 那一行的地址，必须和你的 bootstrap 地址属于同一条可达路径。</strong><br>如果两者一个是内网 IP、一个是对外域名，那这个客户端迟早要出事——出事的时刻，就是它真正开始收发消息的时候。</p></blockquote><p>顺带提醒两个行为：</p><ul><li>修改生效后，客户端可能还抱着旧的 metadata 缓存不放。Java 客户端的 <code>metadata.max.age.ms</code> 默认 5 分钟，急的话直接重启客户端验证。</li><li>如果客户端报的是 <code>Topic xxx not present in metadata</code>，别急着去查 topic 存不存在。这个报错的真实含义往往是”我连不上任何 leader”，<strong>元数据获取失败和 topic 不存在，在这里是同一个症状</strong>。</li></ul><h2 id="八、经验固化"><a href="#八、经验固化" class="headerlink" title="八、经验固化"></a>八、经验固化</h2><p>这次故障的技术含量其实不高，但很值得记下来，因为它踩中了好几个经典的认知陷阱。</p><p><strong>一、”其他人都正常”证明不了”服务端没问题”。</strong><br>这个反证只能说明故障不是全局的。凡是”部分客户端异常”的故障，都要先问一句：<strong>这部分客户端有什么共同点？</strong> 这次答案是”它们在另一条网络路径上”——而这句话直接指向了 advertised 配置。</p><p><strong>二、报错里出现的陌生 IP，是服务端主动告诉客户端的。</strong><br>应用配置里明明写的是 <code>pfscene.example.com</code>，日志里却在连 <code>192.168.0.196</code>。这种”身份不明的地址”不要放过，它八成来自服务端的元数据广播，而不是客户端配置。Kafka 的这个地址就是 <code>advertised.listeners</code>。</p><p><strong>三、”端口通”不等于”能连上”。</strong><br><code>nc -zv</code> 通只验证了四层可达，Kafka 的会话能不能建立，还要看第二阶段拿到的地址对不对。凡是”端口通、应用不通”的中间件故障，都该往<strong>元数据&#x2F;协商</strong>层去想。</p><p><strong>四、配置来源要区分”服务端视角”和”客户端视角”。</strong><br><code>listeners</code> 是服务端绑在哪里，<code>advertised.listeners</code> 是告诉客户端去哪里。混为一谈就会写出一个”服务端自己觉得没问题、客户端全都连不上”的配置。类似的还有 Redis 的 cluster announce、Elasticsearch 的 <code>publish_address</code>，套路如出一辙。</p><p><strong>五、动手前先问一句”这个改动会打死谁”。</strong><br>把 broadcast 地址改成对外域名，能救外部客户端，但会顺手打死内部客户端（比如 Kafdrop）。<strong>在只有一个 listener 的前提下，任何单点修改都是在两类客户端之间二选一</strong>——正确的动作不是选边，而是把 listener 拆开。</p><p>最后一句话收尾：这个参数的正确写法从来没有绝对答案，它取决于<strong>你的客户端从哪里来</strong>。想清楚有哪些网络路径，就配几个 listener，让每条路径上的客户端都听到自己够得着的地址——这才是 <code>advertised.listeners</code> 的正确打开方式。</p>]]>
    </content>
    <id>https://blog.952405.xyz/2026/08/kafka-advertised-listeners-troubleshooting/</id>
    <link href="https://blog.952405.xyz/2026/08/kafka-advertised-listeners-troubleshooting/"/>
    <published>2026-08-15T02:00:00.000Z</published>
    <summary>客户现场一个应用始终连不上 Kafka，而其他应用全部正常。排查后发现根因是 advertised.listeners 广播了内部地址，外部客户端拿到后无法回连。本文完整复盘这次&quot;选择性故障&quot;的定位过程，并给出内外双 listener 的正确修复方案。</summary>
    <title>Kafka 只有它连不上：advertised.listeners 广播地址配错排查复盘</title>
    <updated>2026-08-15T02:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="基础设施" scheme="https://blog.952405.xyz/categories/%E5%9F%BA%E7%A1%80%E8%AE%BE%E6%96%BD/"/>
    <category term="Milvus" scheme="https://blog.952405.xyz/tags/Milvus/"/>
    <category term="etcd" scheme="https://blog.952405.xyz/tags/etcd/"/>
    <category term="K8s" scheme="https://blog.952405.xyz/tags/K8s/"/>
    <content>
      <![CDATA[<h2 id="引言"><a href="#引言" class="headerlink" title="引言"></a>引言</h2><p>Milvus 作为热门的向量数据库，承载着 RAG 应用中关键的向量检索环节。它有一个看似不起眼却至关重要的依赖——etcd。etcd 负责存储 Milvus 的全部元数据：集合结构、分区信息、索引状态、租约等等。一旦 etcd 出问题，Milvus 轻则功能异常，重则整个服务不可用。</p><p>某天我照例检查服务状态时，发现 Milvus 查询接口报错、集合加载失败，而背后的元凶正是 etcd 的 MVCC（多版本并发控制）历史版本无限堆积，把默认 2GB 的存储配额撑爆，触发了 NOSPACE 告警。本文将完整复盘这次从”定位元凶”到”紧急止血”再到”配额加固防复发”的排查修复全过程。</p><h2 id="一、故障现象"><a href="#一、故障现象" class="headerlink" title="一、故障现象"></a>一、故障现象</h2><p>最初的表象全在 Milvus 这一层，非常具有迷惑性：</p><ul><li>Milvus 查询接口报错，集合加载失败；</li><li>Milvus 日志中频繁出现 <code>etcdserver: mvcc: database space exceeded</code> 或 <code>request quota exceed</code> 相关错误；</li><li>新建集合、创建索引等元数据操作全部失败；</li><li>但 Milvus Pod 本身是 Running 状态，数据节点的向量检索在个别场景下似乎仍能工作。</li></ul><p>此时如果只盯着 Milvus 看，很容易走弯路——问题的根源根本不在它身上，而在它背后的 etcd。</p><h2 id="二、故障定位：锁定-etcd-NOSPACE"><a href="#二、故障定位：锁定-etcd-NOSPACE" class="headerlink" title="二、故障定位：锁定 etcd NOSPACE"></a>二、故障定位：锁定 etcd NOSPACE</h2><h3 id="1-直接查看-etcd-状态"><a href="#1-直接查看-etcd-状态" class="headerlink" title="1. 直接查看 etcd 状态"></a>1. 直接查看 etcd 状态</h3><p>既然 Milvus 的日志指向了 etcd，先确认 etcd 的健康状态：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 进入 etcd 容器（以 milvus 部署自带的 etcd 为例，实际容器名以 kubectl get pods 为准）</span><br>kubectl <span class="hljs-built_in">exec</span> -it etcd-xxx -- sh<br><br><span class="hljs-comment"># 查看告警</span><br>etcdctl endpoint status --write-out=table<br>etcdctl alarm list<br></code></pre></td></tr></table></figure><p>正常情况 <code>alarm list</code> 应该没有输出；如果出现 <code>memberID:xxxx alarm:NOSPACE</code>，说明 etcd 的存储配额已被耗尽，问题确认。</p><h3 id="2-查看数据库实际大小"><a href="#2-查看数据库实际大小" class="headerlink" title="2. 查看数据库实际大小"></a>2. 查看数据库实际大小</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 查看 dbSize，对比默认 2GB 配额</span><br>etcdctl endpoint status --write-out=table<br></code></pre></td></tr></table></figure><p>输出中 <code>DB SIZE</code> 一项如果显示 2.0 GB 左右且不再增长，同时 alarm 里有 NOSPACE，就实锤了：不是数据本身有多大，而是<strong>历史版本把配额吃满了</strong>。</p><h3 id="3-原理：MVCC-历史版本堆积"><a href="#3-原理：MVCC-历史版本堆积" class="headerlink" title="3. 原理：MVCC 历史版本堆积"></a>3. 原理：MVCC 历史版本堆积</h3><p>etcd 使用 MVCC 机制，每次写入都会产生新的 revision，旧版本数据不会立即删除，而是保留一段时间供 watcher 读取历史变更。Milvus 这类元数据操作频繁的系统，会持续写入和更新键值。如果没有开启自动压缩（auto-compaction），历史版本会无限累积，最终把默认 2GB 配额全部占满，触发 NOSPACE 后 etcd 拒绝一切写请求。</p><p>此时 etcd 只读不写，Milvus 的元数据操作自然全线失败。</p><h2 id="三、紧急修复：compact-defrag-双管齐下"><a href="#三、紧急修复：compact-defrag-双管齐下" class="headerlink" title="三、紧急修复：compact + defrag 双管齐下"></a>三、紧急修复：compact + defrag 双管齐下</h2><p>紧急修复的思路很直接：<strong>先压缩历史版本释放逻辑空间，再整理碎片释放物理空间</strong>。</p><h3 id="1-压缩（compact）：回收历史版本"><a href="#1-压缩（compact）：回收历史版本" class="headerlink" title="1. 压缩（compact）：回收历史版本"></a>1. 压缩（compact）：回收历史版本</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 获取当前最新的 revision</span><br>rev=$(etcdctl endpoint status --write-out=<span class="hljs-string">&quot;json&quot;</span> | jq -r <span class="hljs-string">&#x27;.[0].Status.header.revision&#x27;</span>)<br><span class="hljs-built_in">echo</span> <span class="hljs-variable">$rev</span><br><br><span class="hljs-comment"># 压缩到这个 revision，此前的所有历史版本都会被回收</span><br>etcdctl compact <span class="hljs-variable">$rev</span><br></code></pre></td></tr></table></figure><p><code>compact</code> 之后，历史版本被标记删除，但<strong>物理空间并不会立即释放</strong>——这部分空间只会标记为可复用。如果此时查看 dbSize，往往变化不大，这很正常，接下来需要 defrag。</p><h3 id="2-碎片整理（defrag）：释放物理空间"><a href="#2-碎片整理（defrag）：释放物理空间" class="headerlink" title="2. 碎片整理（defrag）：释放物理空间"></a>2. 碎片整理（defrag）：释放物理空间</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 整理磁盘碎片，把 compact 后释放的逻辑空间真正归还给文件系统</span><br>etcdctl defrag<br><br><span class="hljs-comment"># 再次查看告警，此时 NOSPACE 应该已经消除</span><br>etcdctl alarm list<br><br><span class="hljs-comment"># 查看 dbSize，应已明显下降</span><br>etcdctl endpoint status --write-out=table<br></code></pre></td></tr></table></figure><p>执行完 defrag 后，dbSize 会显著下降，NOSPACE 告警解除，etcd 恢复读写。此时 Milvus 的元数据操作应恢复正常，可以重新尝试之前的失败操作（加载集合、创建索引等）。</p><h3 id="3-需要重启-Milvus-的情况"><a href="#3-需要重启-Milvus-的情况" class="headerlink" title="3. 需要重启 Milvus 的情况"></a>3. 需要重启 Milvus 的情况</h3><p>如果 compact + defrag 之后，Milvus 的某些功能仍然异常（例如连接池中残留了失败的连接、租约状态错乱），可以重启 Milvus Pod 让它重新建立与 etcd 的连接：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs bash">kubectl delete pod milvus-standalone-xxxx<br><span class="hljs-comment"># 或者使用 deployment 滚动重启</span><br>kubectl rollout restart deployment milvus-standalone<br></code></pre></td></tr></table></figure><p>Pod 删除后由控制器自动重建，观察状态和日志确认恢复正常：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash">kubectl get pods<br>kubectl logs -f milvus-standalone-xxxx<br></code></pre></td></tr></table></figure><blockquote><p>⚠️ 注意顺序：<strong>先修复 etcd，再重启 Milvus</strong>。如果 etcd 还没恢复就重启 Milvus，它起来后依然连不上、写不进，问题依旧。</p></blockquote><h2 id="四、防复发：etcd-配额加固"><a href="#四、防复发：etcd-配额加固" class="headerlink" title="四、防复发：etcd 配额加固"></a>四、防复发：etcd 配额加固</h2><p>到这一步只是”止血”，如果不改配置，历史版本很快又会堆满配额，同样的故障会再次上演。防复发需要做三件事：<strong>开启自动压缩、调高配额、定期整理碎片</strong>。</p><h3 id="1-修改-etcd-yaml：通过环境变量注入参数"><a href="#1-修改-etcd-yaml：通过环境变量注入参数" class="headerlink" title="1. 修改 etcd.yaml：通过环境变量注入参数"></a>1. 修改 etcd.yaml：通过环境变量注入参数</h3><p>我当时的 etcd 部署原本没有 <code>args</code> 字段，数据目录是靠环境变量 <code>ETCD_DATA_DIR</code> 指定的。经过反复踩坑，最终采用的方案是<strong>继续沿用环境变量方式</strong>新增参数，而不是新增 <code>args</code> 块。</p><p>原因在于：etcd 镜像支持将启动参数转成环境变量传入（规则：<code>--auto-compaction-mode</code> → <code>ETCD_AUTO_COMPACTION_MODE</code>）。用环境变量<strong>不会覆盖镜像默认的启动命令</strong>，风格与现有配置统一，风险最小；而一旦新增 <code>args</code>，它会完全替换镜像默认参数，基础参数（如 <code>etcd</code>、<code>--name</code>）漏写就会直接启动失败。</p><p>修改前：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">containers:</span><br><span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">etcd</span><br>  <span class="hljs-attr">image:</span> <span class="hljs-string">quay.io/coreos/etcd:v3.5.5</span><br>  <span class="hljs-attr">env:</span><br>    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">ETCD_DATA_DIR</span><br>      <span class="hljs-attr">value:</span> <span class="hljs-string">/var/lib/etcd</span><br>  <span class="hljs-attr">ports:</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-attr">containerPort:</span> <span class="hljs-number">2379</span><br>  <span class="hljs-attr">volumeMounts:</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">etcd-data</span><br>    <span class="hljs-attr">mountPath:</span> <span class="hljs-string">/var/lib/etcd</span><br></code></pre></td></tr></table></figure><p>修改后（新增三个环境变量）：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">containers:</span><br><span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">etcd</span><br>  <span class="hljs-attr">image:</span> <span class="hljs-string">quay.io/coreos/etcd:v3.5.5</span><br>  <span class="hljs-attr">env:</span><br>    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">ETCD_DATA_DIR</span><br>      <span class="hljs-attr">value:</span> <span class="hljs-string">/var/lib/etcd</span><br>    <span class="hljs-comment"># ===== 新增：自动压缩 + 配额调高 =====</span><br>    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">ETCD_AUTO_COMPACTION_MODE</span><br>      <span class="hljs-attr">value:</span> <span class="hljs-string">revision</span><br>    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">ETCD_AUTO_COMPACTION_RETENTION</span><br>      <span class="hljs-attr">value:</span> <span class="hljs-string">&quot;1000&quot;</span><br>    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">ETCD_QUOTA_BACKEND_BYTES</span><br>      <span class="hljs-attr">value:</span> <span class="hljs-string">&quot;8000000000&quot;</span><br>  <span class="hljs-attr">ports:</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-attr">containerPort:</span> <span class="hljs-number">2379</span><br>  <span class="hljs-attr">volumeMounts:</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">etcd-data</span><br>    <span class="hljs-attr">mountPath:</span> <span class="hljs-string">/var/lib/etcd</span><br></code></pre></td></tr></table></figure><p>三个参数的含义：</p><table><thead><tr><th>环境变量</th><th>对应启动参数</th><th>作用</th></tr></thead><tbody><tr><td><code>ETCD_AUTO_COMPACTION_MODE</code></td><td><code>--auto-compaction-mode=revision</code></td><td>按 revision 号自动压缩历史版本</td></tr><tr><td><code>ETCD_AUTO_COMPACTION_RETENTION</code></td><td><code>--auto-compaction-retention=1000</code></td><td>只保留最近 1000 个 revision 历史，防止 MVCC 无限堆积</td></tr><tr><td><code>ETCD_QUOTA_BACKEND_BYTES</code></td><td><code>--quota-backend-bytes=8000000000</code></td><td>后端配额从默认 2GB 调到 8GB（单位是字节，不是 8G）</td></tr></tbody></table><p>小坑提醒：</p><ul><li><code>ETCD_QUOTA_BACKEND_BYTES</code> 的值必须是<strong>纯数字字节</strong>，不能写成 <code>8G</code>，不要加引号以外的任何单位（yaml 中字符串数值建议加引号防止被解析为数字溢出）；</li><li><code>ETCD_AUTO_COMPACTION_RETENTION</code> 不要设太小（比如 10），否则必要的历史版本会被过早压缩，Milvus 元数据可能异常，1000 是稳妥值；</li><li>如果原本是走 Helm 管理的部署，不要直接改 yaml，应该在 values.yaml 中配置后 <code>helm upgrade</code>。</li></ul><h3 id="2-apply-并验证生效"><a href="#2-apply-并验证生效" class="headerlink" title="2. apply 并验证生效"></a>2. apply 并验证生效</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">kubectl apply -f etcd.yaml<br></code></pre></td></tr></table></figure><p>apply 后 etcd Deployment 会滚动更新，Pod 自动重建，Milvus 会短暂断连 etcd，属于正常现象。</p><p>验证参数是否生效：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 找到新的 etcd Pod</span><br>kubectl get pods<br><br><span class="hljs-comment"># 查看环境变量</span><br>kubectl describe pod etcd-xxx<br></code></pre></td></tr></table></figure><p>在 <code>Environment</code> 区域确认三个新增环境变量全部存在。另外可以进入容器用 <code>ps</code> 查看进程参数，或执行 <code>etcdctl endpoint status</code> 确认配额数字已变化。</p><h3 id="3-定期-defrag（可选）"><a href="#3-定期-defrag（可选）" class="headerlink" title="3. 定期 defrag（可选）"></a>3. 定期 defrag（可选）</h3><p>自动压缩解决的是”历史版本堆积”问题，但 defrag 不会自动执行。如果写入频繁，建议设置一个周期任务定期执行碎片整理：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 例如配置一个 cron 定时执行（在 etcd 容器内）</span><br>etcdctl defrag<br></code></pre></td></tr></table></figure><p>数据量不大的场景下，开启自动压缩后碎片增长缓慢，半年一年手动 defrag 一次也完全够用。</p><h3 id="4-重启-Milvus-验证"><a href="#4-重启-Milvus-验证" class="headerlink" title="4. 重启 Milvus 验证"></a>4. 重启 Milvus 验证</h3><p>etcd Pod 正常 Running 之后，重启 Milvus 让它重新建立连接：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs bash">kubectl delete pod milvus-standalone-xxxx<br>kubectl get pods<br>kubectl logs -f milvus-standalone-xxxx<br></code></pre></td></tr></table></figure><p>确认 Milvus 正常 Running、集合加载和查询恢复后，整个修复闭环才算完成。</p><h2 id="五、总结"><a href="#五、总结" class="headerlink" title="五、总结"></a>五、总结</h2><p>这次故障的完整链路可以归结为：<strong>etcd 未开启自动压缩 → MVCC 历史版本无限堆积 → 超过默认 2GB 配额 → NOSPACE 只读 → Milvus 元数据操作全线失败</strong>。</p><p>复盘下来有几个经验值得记下：</p><ol><li><strong>Milvus 报错不一定是 Milvus 的锅</strong>。它依赖 MinIO、etcd、Pulsar 等多个组件，排查时先顺着日志往下游找，本例的问题根源就在 etcd。</li><li><strong>NOSPACE 的修复顺序是 compact → defrag</strong>。compact 回收历史版本、defrag 释放物理空间，只做 compact 不 defrag，dbSize 不会下降，效果不明显。</li><li><strong>防复发才是真正意义上的修复</strong>。开启自动压缩 + 调高配额 + 定期 defrag，三件事缺一不可，否则故障会周期性重演。</li><li><strong>改 etcd 配置优先考虑环境变量而非 args</strong>。args 会整体覆盖镜像默认启动参数，容易因漏写基础参数导致启动失败；环境变量方案不覆盖默认命令，风格统一且风险更小。</li></ol><p>向量数据库是 RAG 应用的命脉，而 etcd 是 Milvus 的命脉。把这个”幕后组件”的配置做好，才能让 Milvus 稳定地跑下去。</p>]]>
    </content>
    <id>https://blog.952405.xyz/2026/07/milvus-etcd-nospace-recovery/</id>
    <link href="https://blog.952405.xyz/2026/07/milvus-etcd-nospace-recovery/"/>
    <published>2026-07-14T02:00:00.000Z</published>
    <summary>Milvus 突然无法访问、集合加载失败？背后的元凶很可能是 etcd 数据库 MVCC 历史版本堆积导致 NOSPACE。本文完整复盘从定位到 compact、defrag 再到配额加固的全过程。</summary>
    <title>Milvus 服务异常：etcd MVCC 爆满 NOSPACE 故障排查与修复</title>
    <updated>2026-07-14T02:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="基础设施" scheme="https://blog.952405.xyz/categories/%E5%9F%BA%E7%A1%80%E8%AE%BE%E6%96%BD/"/>
    <category term="Homelab" scheme="https://blog.952405.xyz/tags/Homelab/"/>
    <category term="PVE" scheme="https://blog.952405.xyz/tags/PVE/"/>
    <content>
      <![CDATA[<h2 id="引言"><a href="#引言" class="headerlink" title="引言"></a>引言</h2><p>在玩 Homelab 或管理 PVE（Proxmox VE）虚拟化集群时，你是否遇到过这样的”惊魂时刻”：登录 PVE 网页端，发现左侧树状图里所有的节点、虚拟机、容器，连同本地硬盘（local、local-lvm）全部变成了一个个灰色的问号（?），不仅虚拟机无法控制，连本地盘的容量都读取不出来了。</p><p>遇到这种情况先别慌！只要你的 SSH 还能连上，数据就大概率完好无损。本文将带你完整复盘一次真实的 PVE 存储死锁故障排查过程，并手把手教你如何在本地空间不足的情况下，安全地将数百 G 的虚拟机平滑导出。</p><h2 id="一、故障现象与原因分析"><a href="#一、故障现象与原因分析" class="headerlink" title="一、故障现象与原因分析"></a>一、故障现象与原因分析</h2><h3 id="1-现象"><a href="#1-现象" class="headerlink" title="1. 现象"></a>1. 现象</h3><ul><li>PVE 网页端所有存储和虚拟机图标全带问号（?）。</li><li>执行 <code>pvesm status</code> 检查存储状态时命令彻底锁死，终端无响应。</li><li>执行 <code>df -h</code> 查看系统挂载，发现系统底层文件系统其实一切正常。</li></ul><h3 id="2-根源：存储网络死锁引发的连锁雪崩"><a href="#2-根源：存储网络死锁引发的连锁雪崩" class="headerlink" title="2. 根源：存储网络死锁引发的连锁雪崩"></a>2. 根源：存储网络死锁引发的连锁雪崩</h3><p>PVE 内部有一个状态收集守护进程叫 <code>pvestatd</code>，它会定期轮询 <code>storage.cfg</code> 中配置的所有存储后端。</p><p>如果你的配置里包含 <strong>PBS（Proxmox Backup Server）、NFS 或外部 K8s 存储</strong>，一旦这些外部网络存储由于断电、断网或配置变动导致无法连接，<code>pvestatd</code> 在轮询时就会陷入无休止的 I&#x2F;O 等待。由于 PVE 的管理服务是单线程顺序处理的，一个网络存储死锁，会直接导致整个管理后台无法读取本地的 <code>local</code> 和 <code>local-lvm</code>，从而引发网页端全盘问号的假死异象。</p><h2 id="二、第一阶段：解开死锁，恢复本地盘读取"><a href="#二、第一阶段：解开死锁，恢复本地盘读取" class="headerlink" title="二、第一阶段：解开死锁，恢复本地盘读取"></a>二、第一阶段：解开死锁，恢复本地盘读取</h2><p>既然知道了是外部存储造成的死锁，我们就需要强制结束卡死的进程，并临时禁用有问题的存储。</p><h3 id="Step-1：强行终止卡死的服务进程"><a href="#Step-1：强行终止卡死的服务进程" class="headerlink" title="Step 1：强行终止卡死的服务进程"></a>Step 1：强行终止卡死的服务进程</h3><p>直接通过 SSH 登录 PVE 后台，强制杀死所有卡住的管理进程（放心，这不会影响正在运行的虚拟机）：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs bash">killall -9 pvesm<br>killall -9 pvestatd<br>killall -9 pvedaemon<br>killall -9 pveproxy<br></code></pre></td></tr></table></figure><h3 id="Step-2：修改存储配置，禁用嫌疑存储"><a href="#Step-2：修改存储配置，禁用嫌疑存储" class="headerlink" title="Step 2：修改存储配置，禁用嫌疑存储"></a>Step 2：修改存储配置，禁用嫌疑存储</h3><p>编辑 PVE 的存储配置文件：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">nano /etc/pve/storage.cfg<br></code></pre></td></tr></table></figure><p>找到失联的外部存储（例如 <code>pbs-pve</code> 或 <code>k8stor</code>），在它们的配置块末尾手动添加一行 <code>disable 1</code>：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">pbs: pbs-pve<br>        datastore backup<br>        server 192.168.1.100<br>        content backup<br>        disable 1  # &lt;-- 添加这一行临时禁用<br></code></pre></td></tr></table></figure><p>保存并退出（<code>Ctrl+O</code> 回车，<code>Ctrl+X</code> 退出）。</p><h3 id="Step-3：重启-PVE-核心服务"><a href="#Step-3：重启-PVE-核心服务" class="headerlink" title="Step 3：重启 PVE 核心服务"></a>Step 3：重启 PVE 核心服务</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs bash">systemctl restart pve-cluster<br>systemctl restart pvestatd<br>systemctl restart pvedaemon<br>systemctl restart pveproxy<br></code></pre></td></tr></table></figure><p>此时再次运行 <code>pvesm status</code>，命令如果能够秒出结果，回到网页端刷新，本地硬盘的问号就会全部消失，恢复正常！</p><h2 id="三、第二阶段：本地空间不足时的虚拟机导出方案"><a href="#三、第二阶段：本地空间不足时的虚拟机导出方案" class="headerlink" title="三、第二阶段：本地空间不足时的虚拟机导出方案"></a>三、第二阶段：本地空间不足时的虚拟机导出方案</h2><p>本地盘恢复后，如果你需要将虚拟机迁移到其他节点或做备份，但本地剩余空间不足以存放完整镜像，可以使用以下方案。</p><h3 id="方案一：管道直传（本地空间完全不够时）"><a href="#方案一：管道直传（本地空间完全不够时）" class="headerlink" title="方案一：管道直传（本地空间完全不够时）"></a>方案一：管道直传（本地空间完全不够时）</h3><p>直接将虚拟磁盘通过管道传输到目标机器，不落盘：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 将 VM 100 的磁盘直接传输到目标机器</span><br>qm list                        <span class="hljs-comment"># 查看所有虚拟机</span><br><span class="hljs-built_in">dd</span> <span class="hljs-keyword">if</span>=/dev/pve/vm-100-disk-0 bs=4M status=progress | ssh user@target-host <span class="hljs-string">&quot;dd of=/mnt/backup/vm-100-disk-0.raw bs=4M&quot;</span><br></code></pre></td></tr></table></figure><h3 id="方案二：挂载外置存储中转"><a href="#方案二：挂载外置存储中转" class="headerlink" title="方案二：挂载外置存储中转"></a>方案二：挂载外置存储中转</h3><p>如果有 USB 硬盘或 NFS 共享，直接挂载后导出：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 挂载 USB 硬盘</span><br><span class="hljs-built_in">mkdir</span> -p /mnt/usb-backup<br>mount /dev/sdb1 /mnt/usb-backup<br><br><span class="hljs-comment"># 或挂载临时 NFS</span><br>mount -t nfs 192.168.1.200:/volume1/backup /mnt/nfs-backup<br><br><span class="hljs-comment"># 导出虚拟机配置</span><br>vzdump 100 --dumpdir /mnt/usb-backup --mode stop<br></code></pre></td></tr></table></figure><h3 id="方案三：瘦导出（精简置备磁盘可用）"><a href="#方案三：瘦导出（精简置备磁盘可用）" class="headerlink" title="方案三：瘦导出（精简置备磁盘可用）"></a>方案三：瘦导出（精简置备磁盘可用）</h3><p>如果虚拟磁盘内部实际使用空间远小于分配空间：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 先使用 qemu-img 转换并压缩</span><br>qemu-img convert -O qcow2 /dev/pve/vm-100-disk-0 /mnt/backup/vm-100.qcow2 -c<br><br><span class="hljs-comment"># 然后传输压缩后的文件</span><br>rsync -avP /mnt/backup/vm-100.qcow2 user@target-host:/mnt/backup/<br></code></pre></td></tr></table></figure><blockquote><p><strong>提示</strong>：<code>-c</code> 参数启用压缩，对于内部空闲空间较多的磁盘效果显著。</p></blockquote><h2 id="四、总结与预防建议"><a href="#四、总结与预防建议" class="headerlink" title="四、总结与预防建议"></a>四、总结与预防建议</h2><h3 id="故障复盘"><a href="#故障复盘" class="headerlink" title="故障复盘"></a>故障复盘</h3><table><thead><tr><th>阶段</th><th>操作</th><th>目的</th></tr></thead><tbody><tr><td>现象</td><td><code>pvesm status</code> 卡死、网页端全盘问号</td><td>确认是管理面死锁而非数据面故障</td></tr><tr><td>定位</td><td>检查 <code>storage.cfg</code> 中外置存储配置</td><td>确认是否有不可达的外部存储后端</td></tr><tr><td>恢复</td><td><code>killall</code> 卡死进程 → 禁用故障存储 → 重启服务</td><td>恢复本地盘读取和管理面可用</td></tr><tr><td>导出</td><td>管道直传 &#x2F; 外置存储 &#x2F; 瘦导出</td><td>在空间不足时完成虚拟机迁移</td></tr></tbody></table><h3 id="预防建议"><a href="#预防建议" class="headerlink" title="预防建议"></a>预防建议</h3><ol><li><strong>外置存储独立监控</strong>：对 PBS、NFS 等外部存储设置独立健康检查，避免单点影响整个 PVE 管理面。</li><li><strong>定期巡检 <code>storage.cfg</code></strong>：确保不使用的存储条目及时移除或设为 <code>disable 1</code>。</li><li><strong>预留本地空间</strong>：每个 PVE 节点至少预留 50-100GB 的临时空间以应对紧急导出场景。</li><li><strong>配置告警机制</strong>：结合 <code>pvesm status</code> 的返回值编写 cron 脚本，出现异常时主动通知。</li></ol><p>只要 SSH 还能连上，PVE 的数据就大概率是安全的——牢记这一点，遇事不慌，按步骤排查即可。</p>]]>
    </content>
    <id>https://blog.952405.xyz/2026/06/pve-storage-deadlock-recovery/</id>
    <link href="https://blog.952405.xyz/2026/06/pve-storage-deadlock-recovery/"/>
    <published>2026-06-30T02:00:00.000Z</published>
    <summary>PVE 网页端所有存储和虚拟机变灰问号？揭秘 pvestatd 网络存储死锁引发的连锁雪崩故障，手把手教你强制恢复本地盘读取，并在空间不足时平滑导出数百GB虚拟机。</summary>
    <title>PVE 存储全盘问号、本地盘读不到的死锁故障排查与虚拟机跨盘导出恢复</title>
    <updated>2026-06-30T02:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="基础设施" scheme="https://blog.952405.xyz/categories/%E5%9F%BA%E7%A1%80%E8%AE%BE%E6%96%BD/"/>
    <category term="GPU" scheme="https://blog.952405.xyz/tags/GPU/"/>
    <category term="PVE" scheme="https://blog.952405.xyz/tags/PVE/"/>
    <category term="PCIe" scheme="https://blog.952405.xyz/tags/PCIe/"/>
    <content>
      <![CDATA[<h2 id="背景"><a href="#背景" class="headerlink" title="背景"></a>背景</h2><p>在使用 PVE（Proxmox VE）做 GPU 直通时，偶尔会遇到**显卡”掉卡”**的情况：宿主机还在正常运行，但显卡从 PCIe 总线上”消失”了，导致依赖该显卡的虚拟机无法启动，或者正在运行的虚拟机突然异常。</p><p>传统做法是直接重启整个 PVE 宿主机——但生产环境里重启代价很大，所有虚拟机都得跟着停。</p><p>其实有一种<strong>纯软件层面的自救手段</strong>，相当于在操作系统层面对 PCIe 设备执行”拔出再插回”，大多数情况下能让显卡重新被识别，虚拟机恢复正常。</p><h2 id="操作步骤"><a href="#操作步骤" class="headerlink" title="操作步骤"></a>操作步骤</h2><h3 id="1-找到显卡的-PCIe-编号"><a href="#1-找到显卡的-PCIe-编号" class="headerlink" title="1. 找到显卡的 PCIe 编号"></a>1. 找到显卡的 PCIe 编号</h3><p>通过 SSH 登录 PVE 宿主机，先确认显卡在哪个 PCIe 插槽上：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">lspci | grep -i NVIDIA  <span class="hljs-comment"># 你的显卡类型</span><br></code></pre></td></tr></table></figure><p>输出示例：</p><figure class="highlight armasm"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs armasm"><span class="hljs-number">01</span>:<span class="hljs-number">00</span>.<span class="hljs-number">0</span> <span class="hljs-number">3</span>D controller: NVIDIA Corporation AD104GL [L4] (<span class="hljs-keyword">rev</span> <span class="hljs-built_in">a1</span>)<br><span class="hljs-number">21</span>:<span class="hljs-number">00</span>.<span class="hljs-number">0</span> <span class="hljs-number">3</span>D controller: NVIDIA Corporation AD104GL [L4] (<span class="hljs-keyword">rev</span> <span class="hljs-built_in">a1</span>)<br><span class="hljs-number">41</span>:<span class="hljs-number">00</span>.<span class="hljs-number">0</span> <span class="hljs-number">3</span>D controller: NVIDIA Corporation AD104GL [L4] (<span class="hljs-keyword">rev</span> <span class="hljs-built_in">a1</span>)<br><span class="hljs-number">43</span>:<span class="hljs-number">00</span>.<span class="hljs-number">0</span> <span class="hljs-number">3</span>D controller: NVIDIA Corporation AD102GL [L40] (<span class="hljs-keyword">rev</span> <span class="hljs-built_in">a1</span>)<br></code></pre></td></tr></table></figure><p>记住目标显卡的编号，例如 <code>0000:01:00.0</code>（格式为 <code>0000:总线:设备.功能</code>）。多卡环境下会有多条输出，每张卡的编号不同，操作时选择你需要重置的那张即可。</p><h3 id="2-强制”拔掉”设备"><a href="#2-强制”拔掉”设备" class="headerlink" title="2. 强制”拔掉”设备"></a>2. 强制”拔掉”设备</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">echo</span> 1 &gt; /sys/bus/pci/devices/0000\:01\:00.0/remove<br></code></pre></td></tr></table></figure><blockquote><p><strong>注意</strong>：路径中的冒号前要加反斜杠转义。请将 <code>01:00.0</code> 替换为你实际的 PCI 编号。</p></blockquote><p>执行后，显卡在 <code>lspci</code> 中就会消失。等待 2 秒让系统完成清理。</p><h3 id="3-重新扫描-PCIe-总线"><a href="#3-重新扫描-PCIe-总线" class="headerlink" title="3. 重新扫描 PCIe 总线"></a>3. 重新扫描 PCIe 总线</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">sleep</span> 2<br><span class="hljs-built_in">echo</span> 1 &gt; /sys/bus/pci/rescan<br></code></pre></td></tr></table></figure><p>系统会重新枚举所有 PCIe 设备，显卡应该会重新出现在 <code>lspci</code> 列表中。</p><h3 id="4-验证恢复"><a href="#4-验证恢复" class="headerlink" title="4. 验证恢复"></a>4. 验证恢复</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">lspci | grep -i NVIDIA<br></code></pre></td></tr></table></figure><p>如果能再次看到你的显卡，说明”掉卡”已自救成功，虚拟机可以正常开机了。</p><h3 id="一键脚本"><a href="#一键脚本" class="headerlink" title="一键脚本"></a>一键脚本</h3><p>为了方便下次使用，可以把以上命令保存为一个脚本 <code>/usr/local/bin/pve-gpu-reset</code>：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-meta">#!/bin/bash</span><br><span class="hljs-comment"># PVE 显卡强制重置脚本</span><br><span class="hljs-comment"># 用法: pve-gpu-reset 01:00.0</span><br><br><span class="hljs-keyword">if</span> [ -z <span class="hljs-string">&quot;<span class="hljs-variable">$1</span>&quot;</span> ]; <span class="hljs-keyword">then</span><br>    <span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;用法: <span class="hljs-variable">$0</span> &lt;PCI_ID&gt;&quot;</span><br>    <span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;示例: <span class="hljs-variable">$0</span> 01:00.0&quot;</span><br>    <span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;先执行 lspci | grep -i NVIDIA 查看显卡编号&quot;</span><br>    <span class="hljs-built_in">exit</span> 1<br><span class="hljs-keyword">fi</span><br><br>PCI_ID=<span class="hljs-variable">$1</span><br>DEVICE_PATH=<span class="hljs-string">&quot;/sys/bus/pci/devices/0000:<span class="hljs-variable">$&#123;PCI_ID&#125;</span>&quot;</span><br><br><span class="hljs-keyword">if</span> [ ! -d <span class="hljs-string">&quot;<span class="hljs-variable">$DEVICE_PATH</span>&quot;</span> ]; <span class="hljs-keyword">then</span><br>    <span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;错误: 设备 <span class="hljs-variable">$DEVICE_PATH</span> 不存在&quot;</span><br>    <span class="hljs-built_in">exit</span> 1<br><span class="hljs-keyword">fi</span><br><br><span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;正在移除设备 0000:<span class="hljs-variable">$&#123;PCI_ID&#125;</span> ...&quot;</span><br><span class="hljs-built_in">echo</span> 1 &gt; <span class="hljs-string">&quot;<span class="hljs-variable">$DEVICE_PATH</span>/remove&quot;</span><br><br><span class="hljs-built_in">sleep</span> 2<br><br><span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;正在重新扫描 PCIe 总线...&quot;</span><br><span class="hljs-built_in">echo</span> 1 &gt; /sys/bus/pci/rescan<br><br><span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;完成！验证结果：&quot;</span><br>lspci | grep -i NVIDIA<br></code></pre></td></tr></table></figure><p>赋予执行权限：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">chmod</span> +x /usr/local/bin/pve-gpu-reset<br></code></pre></td></tr></table></figure><p>以后掉卡直接：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">pve-gpu-reset 01:00.0<br></code></pre></td></tr></table></figure><h2 id="适用场景与注意事项"><a href="#适用场景与注意事项" class="headerlink" title="适用场景与注意事项"></a>适用场景与注意事项</h2><table><thead><tr><th>场景</th><th>是否适用</th></tr></thead><tbody><tr><td>显卡在 <code>lspci</code> 中消失</td><td>✅ 适用</td></tr><tr><td>虚拟机报 “device not found”</td><td>✅ 适用</td></tr><tr><td>显卡出现在 <code>lspci</code> 中但显示为 <code>(rev ff)</code></td><td>✅ 可以尝试</td></tr><tr><td>显卡驱动内核 panic</td><td>⚠️ 可能需要重启</td></tr><tr><td>硬件物理故障（散热、供电）</td><td>❌ 不适用，应排查硬件</td></tr></tbody></table><ul><li><strong>不影响其他虚拟机</strong>：该操作只针对指定的 PCIe 设备，宿主机和其他使用不同设备的虚拟机不受影响。</li><li><strong>正在使用该显卡的虚拟机</strong> 执行前需要先关闭。</li><li>此方法本质上是触发 Linux 内核的 PCIe 热插拔机制，比 <code>vfio-pci</code> 解绑再重新绑定更底层。</li></ul><h2 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h2><ul><li><a href="https://www.kernel.org/doc/Documentation/ABI/testing/sysfs-bus-pci">Linux Kernel PCI Documentation</a></li><li>Proxmox VE Wiki: <a href="https://pve.proxmox.com/wiki/PCI_Passthrough">PCI Passthrough</a></li></ul>]]>
    </content>
    <id>https://blog.952405.xyz/2026/06/pve-gpu-reset-rescan/</id>
    <link href="https://blog.952405.xyz/2026/06/pve-gpu-reset-rescan/"/>
    <published>2026-06-23T02:00:00.000Z</published>
    <summary>当 PVE 宿主机显卡突然“掉卡”导致虚拟机无法开机时，通过命令行强制移除并重新扫描 PCIe 设备，免重启恢复可用性。</summary>
    <title>PVE 显卡“掉卡”自救：免重启强制重置 PCIe 设备</title>
    <updated>2026-06-23T02:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="网络与代理" scheme="https://blog.952405.xyz/categories/%E7%BD%91%E7%BB%9C%E4%B8%8E%E4%BB%A3%E7%90%86/"/>
    <category term="Proxy" scheme="https://blog.952405.xyz/tags/Proxy/"/>
    <category term="Clash" scheme="https://blog.952405.xyz/tags/Clash/"/>
    <category term="OpenWrt" scheme="https://blog.952405.xyz/tags/OpenWrt/"/>
    <category term="OpenClash" scheme="https://blog.952405.xyz/tags/OpenClash/"/>
    <content>
      <![CDATA[<h2 id="为什么需要多订阅聚合？"><a href="#为什么需要多订阅聚合？" class="headerlink" title="为什么需要多订阅聚合？"></a>为什么需要多订阅聚合？</h2><p>玩软路由的朋友或多或少都经历过——机场跑路、节点失联、线路绕路、晚高峰爆炸。单靠一家机场，稳定性永远是个玄学问题。</p><p><strong>多订阅聚合</strong>的核心思路很简单：把你的几个机场订阅合在一起，配合自动测速切换策略，死一个订阅其他依旧能用。</p><p>但 OpenClash 默认订阅面板只支持<strong>单链接</strong>。想实现多机场聚合 + 精细化分流，需要手动编辑 <code>config.yaml</code>，用上 <code>proxy-providers</code> 和 <code>rule-providers</code> 两个高级功能。</p><p>这篇文章以两个机场为例，从零讲清楚如何配置，直接对着改就行。</p><hr><h2 id="整体架构一览"><a href="#整体架构一览" class="headerlink" title="整体架构一览"></a>整体架构一览</h2><figure class="highlight css"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br></pre></td><td class="code"><pre><code class="hljs css">┌──────────────────────────────────┐<br>│         proxy-providers          │<br>│  ┌────────────┐ ┌────────────┐  │<br>│  │   机场<span class="hljs-selector-tag">A</span>    │ │   机场<span class="hljs-selector-tag">B</span>    │  │<br>│  └─────┬──────┘ └─────┬──────┘  │<br>│        │               │         │<br>│        └───────┬───────┘         │<br>│                │                 │<br>│                ▼                 │<br>│  ┌─────────────────────┐        │<br>│  │  ♻️ 自动选择        │        │<br>│  │  (url-test)         │        │<br>│  └─────────┬───────────┘        │<br>│            │                    │<br>│            ▼                    │<br>│  ┌─────────────────────┐        │<br>│  │  🌍 国外兜底        │        │<br>│  │  (<span class="hljs-selector-tag">select</span>)           │        │<br>│  └─────────┬───────────┘        │<br>│            │                    │<br>│            ▼                    │<br>│  ┌─────────────────────┐        │<br>│  │  🚀 手动选择        │        │<br>│  │  (<span class="hljs-selector-tag">select</span>)           │        │<br>│  └─────────────────────┘        │<br>└──────────────────────────────────┘<br>                  │<br>                  ▼<br>      ┌─────────────────────┐<br>      │   rule-providers    │<br>      │  ┌─────────────────┐│<br>      │  │ OpenAI 规则集    ││<br>      │  │ Proxy 规则集     ││<br>      │  └─────────────────┘│<br>      └─────────────────────┘<br>                  │<br>                  ▼<br>            分流规则引擎<br>     (GEOIP → DIRECT / MATCH → 国外兜底)<br></code></pre></td></tr></table></figure><p>三层策略：</p><ol><li><strong>proxy-providers</strong> — 聚合多个机场的节点</li><li><strong>proxy-groups</strong> — 按策略组织节点（手动选 &#x2F; 自动测速 &#x2F; 兜底故障转移）</li><li><strong>rule-providers + rules</strong> — 精细控制哪些流量走代理、哪些直连</li></ol><hr><h2 id="第一步：proxy-providers-—-聚合多个机场"><a href="#第一步：proxy-providers-—-聚合多个机场" class="headerlink" title="第一步：proxy-providers — 聚合多个机场"></a>第一步：proxy-providers — 聚合多个机场</h2><p>OpenClash 中，<code>proxy-providers</code> 相当于”动态节点来源”。把你的机场订阅链接填进去，Clash 会定期拉取并合并所有节点。</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">proxy-providers:</span><br>  <span class="hljs-string">机场A:</span><br>    <span class="hljs-attr">type:</span> <span class="hljs-string">http</span><br>    <span class="hljs-attr">url:</span> <span class="hljs-string">https://你的机场A订阅链接</span><br>    <span class="hljs-attr">interval:</span> <span class="hljs-number">3600</span>        <span class="hljs-comment"># 每小时更新一次</span><br>    <span class="hljs-attr">proxy:</span> <span class="hljs-string">DIRECT</span>         <span class="hljs-comment"># 订阅请求走直连（不走代理）</span><br>    <span class="hljs-attr">path:</span> <span class="hljs-string">./providers/机场A.yaml</span><br>    <span class="hljs-attr">exclude-filter:</span> <span class="hljs-string">(?i)(距离下次重置)|(到期)|(流量)|(套餐)|(官网)</span><br>    <span class="hljs-attr">health-check:</span><br>      <span class="hljs-attr">enable:</span> <span class="hljs-literal">true</span><br>      <span class="hljs-attr">url:</span> <span class="hljs-string">https://cp.cloudflare.com/generate_204</span><br>      <span class="hljs-attr">interval:</span> <span class="hljs-number">300</span>       <span class="hljs-comment"># 每5分钟检测节点可用性</span><br>    <span class="hljs-attr">override:</span><br>      <span class="hljs-attr">skip-cert-verify:</span> <span class="hljs-literal">false</span><br><br>  <span class="hljs-string">机场B:</span><br>    <span class="hljs-attr">type:</span> <span class="hljs-string">http</span><br>    <span class="hljs-attr">url:</span> <span class="hljs-string">https://你的机场B订阅链接</span><br>    <span class="hljs-attr">interval:</span> <span class="hljs-number">3600</span><br>    <span class="hljs-attr">proxy:</span> <span class="hljs-string">DIRECT</span><br>    <span class="hljs-attr">path:</span> <span class="hljs-string">./providers/机场B.yaml</span><br>    <span class="hljs-attr">exclude-filter:</span> <span class="hljs-string">(?i)(距离下次重置)|(到期)|(流量)|(套餐)|(官网)</span><br>    <span class="hljs-attr">health-check:</span><br>      <span class="hljs-attr">enable:</span> <span class="hljs-literal">true</span><br>      <span class="hljs-attr">url:</span> <span class="hljs-string">https://cp.cloudflare.com/generate_204</span><br>      <span class="hljs-attr">interval:</span> <span class="hljs-number">300</span><br>    <span class="hljs-attr">override:</span><br>      <span class="hljs-attr">skip-cert-verify:</span> <span class="hljs-literal">true</span>   <span class="hljs-comment"># 如果机场证书有问题就设为true</span><br></code></pre></td></tr></table></figure><h3 id="关键参数说明"><a href="#关键参数说明" class="headerlink" title="关键参数说明"></a>关键参数说明</h3><table><thead><tr><th>参数</th><th>作用</th><th>推荐值</th></tr></thead><tbody><tr><td><code>type: http</code></td><td>订阅来源为 HTTP(S) URL</td><td>必填</td></tr><tr><td><code>interval</code></td><td>自动更新间隔（秒）</td><td>3600（1小时）</td></tr><tr><td><code>proxy</code></td><td>拉取订阅时走哪个出站</td><td><code>DIRECT</code> 即可</td></tr><tr><td><code>health-check.url</code></td><td>节点存活检测地址</td><td><code>https://cp.cloudflare.com/generate_204</code></td></tr><tr><td><code>health-check.interval</code></td><td>检测间隔（秒）</td><td>300（5分钟）</td></tr><tr><td><code>override.skip-cert-verify</code></td><td>跳过证书验证</td><td>大部分机场 <code>false</code>；证书有问题的填 <code>true</code></td></tr></tbody></table><h3 id="exclude-filter-—-排除杂项节点"><a href="#exclude-filter-—-排除杂项节点" class="headerlink" title="exclude-filter — 排除杂项节点"></a>exclude-filter — 排除杂项节点</h3><p>机场订阅里常常混入一堆无用信息节点——“剩余流量”、”到期时间”、”官网链接”。不去掉的话会在面板里显示成假节点。</p><p>用正则一键过滤：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">exclude-filter:</span> <span class="hljs-string">(?i)(距离下次重置)|(到期)|(流量)|(套餐)|(官网)</span><br></code></pre></td></tr></table></figure><p><code>(?i)</code> 表示不区分大小写。你也可以按自己机场的实际命名加减关键词。</p><hr><h2 id="第二步：proxy-groups-—-策略组设计"><a href="#第二步：proxy-groups-—-策略组设计" class="headerlink" title="第二步：proxy-groups — 策略组设计"></a>第二步：proxy-groups — 策略组设计</h2><p>三个策略组，各司其职：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">proxy-groups:</span><br>  <span class="hljs-comment"># 第一层：手动主控</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">🚀</span> <span class="hljs-string">手动选择</span><br>    <span class="hljs-attr">type:</span> <span class="hljs-string">select</span><br>    <span class="hljs-attr">use:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">机场A</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">机场B</span><br>    <span class="hljs-attr">proxies:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">♻️</span> <span class="hljs-string">自动选择</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">🌍</span> <span class="hljs-string">国外兜底</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">DIRECT</span><br><br>  <span class="hljs-comment"># 第二层：自动测速选优</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">♻️</span> <span class="hljs-string">自动选择</span><br>    <span class="hljs-attr">type:</span> <span class="hljs-string">url-test</span><br>    <span class="hljs-attr">url:</span> <span class="hljs-string">http://www.gstatic.com/generate_204</span><br>    <span class="hljs-attr">interval:</span> <span class="hljs-number">300</span>        <span class="hljs-comment"># 每5分钟重新测速</span><br>    <span class="hljs-attr">tolerance:</span> <span class="hljs-number">50</span>        <span class="hljs-comment"># 延迟差在50ms以内不切换</span><br>    <span class="hljs-attr">use:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">机场A</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">机场B</span><br><br>  <span class="hljs-comment"># 第三层：兜底保底</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">🌍</span> <span class="hljs-string">国外兜底</span><br>    <span class="hljs-attr">type:</span> <span class="hljs-string">select</span><br>    <span class="hljs-attr">use:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">机场A</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">机场B</span><br>    <span class="hljs-attr">proxies:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">♻️</span> <span class="hljs-string">自动选择</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">DIRECT</span><br></code></pre></td></tr></table></figure><h3 id="策略组职责"><a href="#策略组职责" class="headerlink" title="策略组职责"></a>策略组职责</h3><table><thead><tr><th>策略组</th><th>类型</th><th>作用</th></tr></thead><tbody><tr><td>🚀 手动选择</td><td><code>select</code></td><td>手动指定走哪个订阅或哪个具体节点，最高优先级</td></tr><tr><td>♻️ 自动选择</td><td><code>url-test</code></td><td>自动测速，选<strong>延迟最低</strong>的节点</td></tr><tr><td>🌍 国外兜底</td><td><code>select</code></td><td>当其他节点全挂时兜底，可切回 <code>DIRECT</code></td></tr></tbody></table><h3 id="url-test-的工作方式"><a href="#url-test-的工作方式" class="headerlink" title="url-test 的工作方式"></a>url-test 的工作方式</h3><p><code>url-test</code> 策略组每隔 <code>interval</code> 秒向所有节点发送一次 HTTP 请求，测量延迟，然后自动把流量切到<strong>延迟最低</strong>的那个节点。</p><p><code>tolerance: 50</code> 意味着：当前节点延迟 200ms，另一个节点 160ms——差距超过 50ms，就切换过去。避免”因为 2ms 的微小差异就频繁跳切”。</p><h3 id="为什么国外兜底要包含-DIRECT？"><a href="#为什么国外兜底要包含-DIRECT？" class="headerlink" title="为什么国外兜底要包含 DIRECT？"></a>为什么国外兜底要包含 DIRECT？</h3><p>如果所有机场同时挂掉（虽然概率不大但玩路由久了就知道什么妖魔鬼怪都有），<code>🌍 国外兜底</code> 可以手动切到 <code>DIRECT</code>，至少保证国内网站正常访问，不会整个网络瘫痪。</p><hr><h2 id="第三步：rule-providers-—-规则集"><a href="#第三步：rule-providers-—-规则集" class="headerlink" title="第三步：rule-providers — 规则集"></a>第三步：rule-providers — 规则集</h2><p>规则集决定了<strong>哪些流量走代理，哪些直连</strong>。OpenClash 自带大量规则集，你也可以自定义。</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">rule-providers:</span><br>  <span class="hljs-attr">OpenAI:</span><br>    <span class="hljs-attr">type:</span> <span class="hljs-string">file</span><br>    <span class="hljs-attr">behavior:</span> <span class="hljs-string">classical</span><br>    <span class="hljs-attr">path:</span> <span class="hljs-string">./rule_provider/OpenAI</span><br>    <span class="hljs-attr">interval:</span> <span class="hljs-number">86400</span>      <span class="hljs-comment"># 每天更新一次</span><br><br>  <span class="hljs-attr">Proxy:</span><br>    <span class="hljs-attr">type:</span> <span class="hljs-string">file</span><br>    <span class="hljs-attr">behavior:</span> <span class="hljs-string">classical</span><br>    <span class="hljs-attr">path:</span> <span class="hljs-string">./rule_provider/Proxy</span><br>    <span class="hljs-attr">interval:</span> <span class="hljs-number">86400</span><br></code></pre></td></tr></table></figure><ul><li><strong>OpenAI 规则集</strong> — 包含 OpenAI &#x2F; ChatGPT 相关域名，保证 AI 服务走代理</li><li><strong>Proxy 规则集</strong> — 包含所有需要代理的常见域名（Google &#x2F; YouTube &#x2F; Twitter 等）</li></ul><p><code>behavior: classical</code> 表示经典域名匹配模式。一般用 <code>classical</code> 就够了，性能最好。</p><h3 id="内置规则集位置"><a href="#内置规则集位置" class="headerlink" title="内置规则集位置"></a>内置规则集位置</h3><p>OpenClash 预置了很多规则集文件，通常在 <code>/etc/openclash/rule_provider/</code> 下。你也可以从 <a href="https://github.com/Loyalsoldier/clash-rules">Loyalsoldier&#x2F;clash-rules</a> 下载自定义规则集。</p><hr><h2 id="第四步：rules-—-分流规则"><a href="#第四步：rules-—-分流规则" class="headerlink" title="第四步：rules — 分流规则"></a>第四步：rules — 分流规则</h2><p>最后一步，把所有策略串起来：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">rules:</span><br>  <span class="hljs-comment"># OpenAI 相关流量 → 手动选择策略组</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">RULE-SET,OpenAI,🚀</span> <span class="hljs-string">手动选择</span><br><br>  <span class="hljs-comment"># 需要代理的常见域名 → 手动选择策略组</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">RULE-SET,Proxy,🚀</span> <span class="hljs-string">手动选择</span><br><br>  <span class="hljs-comment"># 中国大陆 IP → 直连</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">GEOIP,CN,DIRECT</span><br><br>  <span class="hljs-comment"># 以上都没命中 → 国外兜底</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">MATCH,🌍</span> <span class="hljs-string">国外兜底</span><br></code></pre></td></tr></table></figure><h3 id="规则匹配顺序（从上到下，命中即停止）"><a href="#规则匹配顺序（从上到下，命中即停止）" class="headerlink" title="规则匹配顺序（从上到下，命中即停止）"></a>规则匹配顺序（从上到下，命中即停止）</h3><figure class="highlight gams"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><code class="hljs gams">请求进入<br>    │<br>    ▼<br>┌─ RULE-<span class="hljs-keyword">SET</span>,OpenAI ──→ 🚀 手动选择（命中则走代理）<br>├─ RULE-<span class="hljs-keyword">SET</span>,Proxy  ──→ 🚀 手动选择（命中则走代理）<br>├─ GEOIP,CN         ──→ <span class="hljs-comment">DIRECT</span>（国内 <span class="hljs-comment">IP</span> 直连）<br>└─ MATCH            ──→ 🌍 国外兜底（兜底走代理）<br></code></pre></td></tr></table></figure><p><strong>设计原则</strong>：白名单思维——明确知道需要代理的走代理（OpenAI + Proxy 规则集），明确知道国内的走直连（GEOIP），剩下的也走代理兜底。比”黑名单”更不容易漏网，也更安全。</p><hr><h2 id="第五步：在-OpenClash-中加载配置"><a href="#第五步：在-OpenClash-中加载配置" class="headerlink" title="第五步：在 OpenClash 中加载配置"></a>第五步：在 OpenClash 中加载配置</h2><h3 id="方法一：直接替换配置文件"><a href="#方法一：直接替换配置文件" class="headerlink" title="方法一：直接替换配置文件"></a>方法一：直接替换配置文件</h3><ol><li>将上述配置合并成完整的 YAML 文件</li><li>放置到 <code>/etc/openclash/config/</code> 目录下</li><li>在 OpenClash 面板中切换到该配置</li></ol><h3 id="方法二：OpenClash-面板分段编辑"><a href="#方法二：OpenClash-面板分段编辑" class="headerlink" title="方法二：OpenClash 面板分段编辑"></a>方法二：OpenClash 面板分段编辑</h3><p>OpenClash 的 Luci 页面提供了<strong>配置文件管理</strong>界面，你可以在”配置文件订阅”中添加 proxy-provider 订阅，在”规则附加”中修改 rules。</p><p>但多订阅聚合建议直接用方法一——手动编辑 YAML 更灵活可控，不会被面板的模板语法限制。</p><h3 id="完整的-config-yaml-骨架"><a href="#完整的-config-yaml-骨架" class="headerlink" title="完整的 config.yaml 骨架"></a>完整的 config.yaml 骨架</h3><p>为了方便你快速上手，这里给出一个可直接使用的完整配置。替换订阅链接即可。</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">port:</span> <span class="hljs-number">7890</span><br><span class="hljs-attr">socks-port:</span> <span class="hljs-number">7891</span><br><span class="hljs-attr">allow-lan:</span> <span class="hljs-literal">true</span><br><span class="hljs-attr">mode:</span> <span class="hljs-string">rule</span><br><span class="hljs-attr">log-level:</span> <span class="hljs-string">info</span><br><span class="hljs-attr">external-controller:</span> <span class="hljs-number">127.0</span><span class="hljs-number">.0</span><span class="hljs-number">.1</span><span class="hljs-string">:9090</span><br><br><span class="hljs-attr">proxy-providers:</span><br>  <span class="hljs-string">机场A:</span><br>    <span class="hljs-attr">type:</span> <span class="hljs-string">http</span><br>    <span class="hljs-attr">url:</span> <span class="hljs-string">https://你的机场A订阅链接</span><br>    <span class="hljs-attr">interval:</span> <span class="hljs-number">3600</span><br>    <span class="hljs-attr">proxy:</span> <span class="hljs-string">DIRECT</span><br>    <span class="hljs-attr">path:</span> <span class="hljs-string">./providers/机场A.yaml</span><br>    <span class="hljs-attr">exclude-filter:</span> <span class="hljs-string">(?i)(距离下次重置)|(到期)|(流量)|(套餐)|(官网)</span><br>    <span class="hljs-attr">health-check:</span><br>      <span class="hljs-attr">enable:</span> <span class="hljs-literal">true</span><br>      <span class="hljs-attr">url:</span> <span class="hljs-string">https://cp.cloudflare.com/generate_204</span><br>      <span class="hljs-attr">interval:</span> <span class="hljs-number">300</span><br>    <span class="hljs-attr">override:</span><br>      <span class="hljs-attr">skip-cert-verify:</span> <span class="hljs-literal">false</span><br><br>  <span class="hljs-string">机场B:</span><br>    <span class="hljs-attr">type:</span> <span class="hljs-string">http</span><br>    <span class="hljs-attr">url:</span> <span class="hljs-string">https://你的机场B订阅链接</span><br>    <span class="hljs-attr">interval:</span> <span class="hljs-number">3600</span><br>    <span class="hljs-attr">proxy:</span> <span class="hljs-string">DIRECT</span><br>    <span class="hljs-attr">path:</span> <span class="hljs-string">./providers/机场B.yaml</span><br>    <span class="hljs-attr">exclude-filter:</span> <span class="hljs-string">(?i)(距离下次重置)|(到期)|(流量)|(套餐)|(官网)</span><br>    <span class="hljs-attr">health-check:</span><br>      <span class="hljs-attr">enable:</span> <span class="hljs-literal">true</span><br>      <span class="hljs-attr">url:</span> <span class="hljs-string">https://cp.cloudflare.com/generate_204</span><br>      <span class="hljs-attr">interval:</span> <span class="hljs-number">300</span><br>    <span class="hljs-attr">override:</span><br>      <span class="hljs-attr">skip-cert-verify:</span> <span class="hljs-literal">true</span><br><br><span class="hljs-attr">rule-providers:</span><br>  <span class="hljs-attr">OpenAI:</span><br>    <span class="hljs-attr">type:</span> <span class="hljs-string">file</span><br>    <span class="hljs-attr">behavior:</span> <span class="hljs-string">classical</span><br>    <span class="hljs-attr">path:</span> <span class="hljs-string">./rule_provider/OpenAI</span><br>    <span class="hljs-attr">interval:</span> <span class="hljs-number">86400</span><br>  <span class="hljs-attr">Proxy:</span><br>    <span class="hljs-attr">type:</span> <span class="hljs-string">file</span><br>    <span class="hljs-attr">behavior:</span> <span class="hljs-string">classical</span><br>    <span class="hljs-attr">path:</span> <span class="hljs-string">./rule_provider/Proxy</span><br>    <span class="hljs-attr">interval:</span> <span class="hljs-number">86400</span><br><br><span class="hljs-attr">proxy-groups:</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">🚀</span> <span class="hljs-string">手动选择</span><br>    <span class="hljs-attr">type:</span> <span class="hljs-string">select</span><br>    <span class="hljs-attr">use:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">机场A</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">机场B</span><br>    <span class="hljs-attr">proxies:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">♻️</span> <span class="hljs-string">自动选择</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">🌍</span> <span class="hljs-string">国外兜底</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">DIRECT</span><br><br>  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">♻️</span> <span class="hljs-string">自动选择</span><br>    <span class="hljs-attr">type:</span> <span class="hljs-string">url-test</span><br>    <span class="hljs-attr">url:</span> <span class="hljs-string">http://www.gstatic.com/generate_204</span><br>    <span class="hljs-attr">interval:</span> <span class="hljs-number">300</span><br>    <span class="hljs-attr">tolerance:</span> <span class="hljs-number">50</span><br>    <span class="hljs-attr">use:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">机场A</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">机场B</span><br><br>  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">🌍</span> <span class="hljs-string">国外兜底</span><br>    <span class="hljs-attr">type:</span> <span class="hljs-string">select</span><br>    <span class="hljs-attr">use:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">机场A</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">机场B</span><br>    <span class="hljs-attr">proxies:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">♻️</span> <span class="hljs-string">自动选择</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">DIRECT</span><br><br><span class="hljs-attr">rules:</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">RULE-SET,OpenAI,🚀</span> <span class="hljs-string">手动选择</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">RULE-SET,Proxy,🚀</span> <span class="hljs-string">手动选择</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">GEOIP,CN,DIRECT</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">MATCH,🌍</span> <span class="hljs-string">国外兜底</span><br></code></pre></td></tr></table></figure><hr><h2 id="常见问题排查"><a href="#常见问题排查" class="headerlink" title="常见问题排查"></a>常见问题排查</h2><h3 id="1-节点列表为空-订阅拉取失败"><a href="#1-节点列表为空-订阅拉取失败" class="headerlink" title="1. 节点列表为空 &#x2F; 订阅拉取失败"></a>1. 节点列表为空 &#x2F; 订阅拉取失败</h3><ul><li>检查机场订阅链接是否有效（在浏览器里直接访问看能不能下载）</li><li>检查 <code>proxy: DIRECT</code> — 部分网络下拉取订阅本身可能需要走代理，换成 <code>proxy: 🚀 手动选择</code> 试试</li><li>OpenClash 日志里看具体报错：<code>log-level: debug</code></li></ul><h3 id="2-url-test-频繁跳切"><a href="#2-url-test-频繁跳切" class="headerlink" title="2. url-test 频繁跳切"></a>2. url-test 频繁跳切</h3><ul><li>调大 <code>tolerance</code> 值（比如 100-150ms），减少微小延迟波动导致的切换</li><li>加大 <code>interval</code>（比如 600 秒），降低测速频率</li></ul><h3 id="3-规则不生效"><a href="#3-规则不生效" class="headerlink" title="3. 规则不生效"></a>3. 规则不生效</h3><ul><li>确认 <code>rule-providers</code> 的 <code>path</code> 文件真实存在</li><li>确认 <code>behavior</code> 类型与规则集文件格式匹配</li><li>检查 OpenClash 面板中是否勾选了”禁用规则”</li></ul><h3 id="4-部分机场节点证书报错"><a href="#4-部分机场节点证书报错" class="headerlink" title="4. 部分机场节点证书报错"></a>4. 部分机场节点证书报错</h3><ul><li>把对应 <code>proxy-provider</code> 的 <code>override.skip-cert-verify</code> 设为 <code>true</code></li></ul><hr><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>这套配置的核心优势：</p><ul><li><strong>高可用</strong> — 多个机场节点池，自动测速选最优，一个挂了不影响全局</li><li><strong>精细分流</strong> — OpenAI 单独规则集，国内 IP 直连，国外流量走代理</li><li><strong>低维护</strong> — proxy-provider 每小时自动更新，rule-provider 每天更新，基本不用手动管</li><li><strong>灵活切换</strong> — 🚀 手动选择 → ♻️ 自动测速 → 🌍 兜底保底，三层故障转移</li></ul><p>折腾完这一套，自此告别”机场又挂了”的烦恼。</p>]]>
    </content>
    <id>https://blog.952405.xyz/2026/06/openclash-multi-subscription-rules/</id>
    <link href="https://blog.952405.xyz/2026/06/openclash-multi-subscription-rules/"/>
    <published>2026-06-19T03:47:00.000Z</published>
    <summary>将多个机场订阅聚合到 OpenClash 中，通过 proxy-provider 合并节点池、rule-provider 实现精细化分流，打造高可用、自动故障切换的科学上网方案。</summary>
    <title>OpenClash 多订阅聚合与分流规则配置指南</title>
    <updated>2026-06-19T03:47:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="网络与代理" scheme="https://blog.952405.xyz/categories/%E7%BD%91%E7%BB%9C%E4%B8%8E%E4%BB%A3%E7%90%86/"/>
    <category term="SSH" scheme="https://blog.952405.xyz/tags/SSH/"/>
    <category term="Cloudflare" scheme="https://blog.952405.xyz/tags/Cloudflare/"/>
    <category term="Cloudflare Tunnel" scheme="https://blog.952405.xyz/tags/Cloudflare-Tunnel/"/>
    <content>
      <![CDATA[<h2 id="使用-cloudflared-穿透内网实现-SSH-免密连接"><a href="#使用-cloudflared-穿透内网实现-SSH-免密连接" class="headerlink" title="使用 cloudflared 穿透内网实现 SSH 免密连接"></a>使用 cloudflared 穿透内网实现 SSH 免密连接</h2><p>在日常开发和运维中，我们经常需要 SSH 连接到内网服务器。如果服务器没有公网 IP，或者隐藏在防火墙 &#x2F; NAT 后面，传统的直连方式就行不通了。借助 <strong>Cloudflare Tunnel（cloudflared）+ Cloudflare Access</strong>，可以安全、稳定地实现 SSH 内网穿透，并配合 SSH config 做到”一键连接”。</p><h3 id="工作原理"><a href="#工作原理" class="headerlink" title="工作原理"></a>工作原理</h3><p>整个链路分为两段：</p><ol><li><strong>服务端 → Cloudflare 边缘节点</strong>：内网服务器运行 <code>cloudflared tunnel</code>，与 Cloudflare 边缘建立持久化的 HTTP&#x2F;2 隧道（国内用户建议阅读 <a href="/2026/05/cloudflare-tunnel-http2-china/">Cloudflare Tunnel HTTP&#x2F;2 优化指南</a>）。</li><li><strong>客户端 → Cloudflare 边缘节点</strong>：本地使用 <code>cloudflared access tcp</code> 作为 SSH 的 <code>ProxyCommand</code>，由 cloudflared 负责与 Cloudflare 边缘建立连接并转发 TCP 流量。</li></ol><figure class="highlight stylus"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><code class="hljs stylus">本地 SSH 客户端<br>    │<br>    ▼<br>cloudflared access tcp <span class="hljs-attr">--hostname</span> giteassh<span class="hljs-selector-class">.cq</span><span class="hljs-selector-class">.de5</span><span class="hljs-selector-class">.net</span><br>    │<br>    ▼<br>Cloudflare 边缘节点（全球加速）<br>    │<br>    ▼<br>内网 cloudflared tunnel（HTTP/<span class="hljs-number">2</span> 长连接）<br>    │<br>    ▼<br>内网 SSH 服务（如 Gitea / GitLab）<br></code></pre></td></tr></table></figure><h3 id="安装-cloudflared"><a href="#安装-cloudflared" class="headerlink" title="安装 cloudflared"></a>安装 cloudflared</h3><p><strong>Linux (Debian&#x2F;Ubuntu)</strong>：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 添加 Cloudflare 源并安装</span><br>curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | <span class="hljs-built_in">sudo</span> <span class="hljs-built_in">tee</span> /usr/share/keyrings/cloudflare-main.gpg &gt; /dev/null<br><span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared <span class="hljs-subst">$(lsb_release -cs)</span> main&quot;</span> | <span class="hljs-built_in">sudo</span> <span class="hljs-built_in">tee</span> /etc/apt/sources.list.d/cloudflared.list<br><span class="hljs-built_in">sudo</span> apt update &amp;&amp; <span class="hljs-built_in">sudo</span> apt install cloudflared<br></code></pre></td></tr></table></figure><p><strong>手动下载（通用）</strong>：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 以 Linux amd64 为例</span><br>wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64<br><span class="hljs-built_in">chmod</span> +x cloudflared-linux-amd64<br><span class="hljs-built_in">sudo</span> <span class="hljs-built_in">mv</span> cloudflared-linux-amd64 /usr/local/bin/cloudflared<br></code></pre></td></tr></table></figure><h3 id="登录-Cloudflare-Access"><a href="#登录-Cloudflare-Access" class="headerlink" title="登录 Cloudflare Access"></a>登录 Cloudflare Access</h3><p>在客户端侧，需要先通过 <code>cloudflared login</code> 获取认证凭据。如果你的 Tunnel 绑定了 Cloudflare Access 策略（如一次性 PIN 验证），首次使用时会自动打开浏览器提示登录：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">cloudflared access login<br></code></pre></td></tr></table></figure><p>浏览器会自动打开，选择对应的团队域名完成授权即可。授权成功后凭据会保存到 <code>~/.cloudflared/</code> 目录，后续连接无需再次登录。</p><h3 id="配置-SSH-ProxyCommand"><a href="#配置-SSH-ProxyCommand" class="headerlink" title="配置 SSH ProxyCommand"></a>配置 SSH ProxyCommand</h3><p>在 <code>~/.ssh/config</code> 中添加如下配置：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><code class="hljs ssh-config">Host giteassh.cq.de5.net<br>    HostName giteassh.cq.de5.net<br>    User git<br>    IdentityFile ~/.ssh/fzno1<br>    IdentitiesOnly yes<br>    # 保持长连接，每 10 秒发送心跳包，连续 3 次无回应才断开<br>    ServerAliveInterval 10<br>    ServerAliveCountMax 3<br>    # 强制使用轻量级加密算法，降低 CPU 占用<br>    Ciphers chacha20-poly1305@openssh.com<br>    # 允许更高的数据吞吐<br>    IPQoS throughput<br>    ProxyCommand /home/iding/bin/cloudflared access tcp --hostname %h<br></code></pre></td></tr></table></figure><p><strong>配置说明</strong>：</p><table><thead><tr><th>字段</th><th>含义</th></tr></thead><tbody><tr><td><code>Host</code></td><td>别名，用于 <code>ssh &lt;别名&gt;</code> 直接连接</td></tr><tr><td><code>HostName</code></td><td>实际主机名，必须与 Cloudflare Access 中配置的公开主机名一致</td></tr><tr><td><code>User</code></td><td>SSH 登录用户名</td></tr><tr><td><code>IdentityFile</code></td><td>指定私钥文件路径</td></tr><tr><td><code>IdentitiesOnly yes</code></td><td>仅使用指定的私钥，不尝试其他密钥（避免 <code>Too many authentication failures</code>）</td></tr><tr><td><code>ProxyCommand</code></td><td>将 SSH 流量通过 cloudflared 转发，<code>%h</code> 自动替换为 HostName</td></tr><tr><td><code>ServerAliveInterval 10</code></td><td>每 10 秒发送一个心跳包，防止长连接被防火墙或 NAT 设备断开</td></tr><tr><td><code>ServerAliveCountMax 3</code></td><td>连续 3 次心跳无响应才判定断线，避免网络抖动导致误断</td></tr><tr><td><code>Ciphers</code></td><td>指定加密算法，<code>chacha20-poly1305</code> 比默认 AES 更省 CPU，适合低配机器</td></tr><tr><td><code>IPQoS throughput</code></td><td>标记 SSH 流量为高吞吐类型，减少路由设备的 QoS 限速</td></tr></tbody></table><p><strong>关键点</strong>：</p><ul><li><code>ProxyCommand</code> 中的 <code>--hostname %h</code>，<code>%h</code> 是 SSH 的内置变量，会自动展开为 <code>HostName</code> 字段的值。这样当你修改 <code>HostName</code> 时，无需同步修改 <code>ProxyCommand</code>。</li><li>cloudflared 二进制路径建议使用绝对路径，避免 <code>PATH</code> 查找问题。</li><li><code>IdentitiesOnly yes</code> 很重要——如果本地有多个 SSH 密钥，不开启此选项可能会一次性尝试所有密钥，导致目标服务器返回 <code>Too many authentication failures</code>。</li><li><code>ServerAliveInterval</code> + <code>ServerAliveCountMax</code> 组合确保连接稳定性，尤其适合经过 cloudflared 隧道这种中间有 NAT&#x2F;防火墙的场景。</li><li><code>Ciphers chacha20-poly1305</code> 在无 AES-NI 指令集的 CPU（如某些 ARM 或老旧 x86）上性能远优于默认的 AES-GCM，能显著降低加密开销。</li><li><code>IPQoS throughput</code> 告诉底层网络将此连接的 DSCP 标记为高吞吐类型，部分路由器会给予更高的带宽优先级。</li></ul><h3 id="验证连接"><a href="#验证连接" class="headerlink" title="验证连接"></a>验证连接</h3><p>配置完成后，直接使用 <code>ssh</code> 别名即可连接：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">ssh giteassh.cq.de5.net<br></code></pre></td></tr></table></figure><p><strong>测试连通性（详细输出）</strong>：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">ssh -v giteassh.cq.de5.net 2&gt;&amp;1 | <span class="hljs-built_in">head</span> -20<br></code></pre></td></tr></table></figure><p>正常情况下，你会看到类似输出：</p><figure class="highlight vim"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs vim">debug1: Executing proxy <span class="hljs-keyword">command</span>: exec /home/iding/bin/cloudflared access tcp --<span class="hljs-built_in">hostname</span> giteassh.<span class="hljs-keyword">cq</span>.de5.net<br>debug1: identity <span class="hljs-keyword">file</span> /home/iding/.ssh/fzno1 <span class="hljs-built_in">type</span> -<span class="hljs-number">1</span><br>debug1: identity <span class="hljs-keyword">file</span> /home/iding/.ssh/fzno1-cert <span class="hljs-built_in">type</span> -<span class="hljs-number">1</span><br>debug1: Local <span class="hljs-keyword">version</span> <span class="hljs-built_in">string</span> SSH-<span class="hljs-number">2.0</span>-OpenSSH_8.<span class="hljs-number">9</span>p1<br>...<br></code></pre></td></tr></table></figure><h3 id="使用场景"><a href="#使用场景" class="headerlink" title="使用场景"></a>使用场景</h3><h4 id="场景一：Git-免密克隆-推送"><a href="#场景一：Git-免密克隆-推送" class="headerlink" title="场景一：Git 免密克隆 &#x2F; 推送"></a>场景一：Git 免密克隆 &#x2F; 推送</h4><p>内网部署了 Gitea &#x2F; GitLab &#x2F; GitBucket 等代码托管平台，通过 cloudflared 暴露 SSH 端口后，可以像使用 GitHub 一样正常操作：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs bash">git <span class="hljs-built_in">clone</span> ssh://giteassh.cq.de5.net/username/repo.git<br><span class="hljs-comment"># 或者在现有的仓库中推送</span><br>git remote set-url origin ssh://giteassh.cq.de5.net/username/repo.git<br>git push<br></code></pre></td></tr></table></figure><p>由于 SSH config 中已配置了 <code>IdentityFile</code> 和 <code>IdentitiesOnly</code>，整个过程无需输入密码。</p><h4 id="场景二：管理多台内网服务器"><a href="#场景二：管理多台内网服务器" class="headerlink" title="场景二：管理多台内网服务器"></a>场景二：管理多台内网服务器</h4><p>如果有多个内网 SSH 服务暴露在同一个域名下不同端口，或者不同的子域名：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><code class="hljs ssh-config">Host home-server<br>    HostName ssh-home.example.com<br>    User root<br>    Port 22<br>    IdentityFile ~/.ssh/home_key<br>    ProxyCommand /usr/local/bin/cloudflared access tcp --hostname %h<br><br>Host dev-server<br>    HostName ssh-dev.example.com<br>    User ubuntu<br>    Port 2222<br>    IdentityFile ~/.ssh/dev_key<br>    ProxyCommand /usr/local/bin/cloudflared access tcp --hostname %h<br></code></pre></td></tr></table></figure><h4 id="场景三：SCP-SFTP-文件传输"><a href="#场景三：SCP-SFTP-文件传输" class="headerlink" title="场景三：SCP &#x2F; SFTP 文件传输"></a>场景三：SCP &#x2F; SFTP 文件传输</h4><p>连接方式与 SSH 完全一致：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># SCP</span><br>scp myfile.txt giteassh.cq.de5.net:/path/to/destination/<br><br><span class="hljs-comment"># SFTP</span><br>sftp giteassh.cq.de5.net<br></code></pre></td></tr></table></figure><h3 id="常见问题"><a href="#常见问题" class="headerlink" title="常见问题"></a>常见问题</h3><h4 id="Q-连接时报-failed-to-dial-to-tunnel-错误"><a href="#Q-连接时报-failed-to-dial-to-tunnel-错误" class="headerlink" title="Q: 连接时报 failed to dial to tunnel 错误"></a>Q: 连接时报 <code>failed to dial to tunnel</code> 错误</h4><p>首先检查 <code>cloudflared access login</code> 是否已完成认证。如果凭据过期，重新执行登录即可。</p><h4 id="Q-连接超时卡住不动"><a href="#Q-连接超时卡住不动" class="headerlink" title="Q: 连接超时卡住不动"></a>Q: 连接超时卡住不动</h4><p>国内网络环境下，可能与 QUIC 协议被运营商干扰有关。可以参考 <a href="/2026/05/cloudflare-tunnel-http2-china/">Cloudflare Tunnel HTTP&#x2F;2 优化指南</a> 将协议强制切换为 HTTP&#x2F;2。</p><h4 id="Q-Permission-denied-publickey-认证失败"><a href="#Q-Permission-denied-publickey-认证失败" class="headerlink" title="Q: Permission denied (publickey) 认证失败"></a>Q: <code>Permission denied (publickey)</code> 认证失败</h4><ul><li>确认 <code>IdentityFile</code> 路径正确且私钥文件权限为 <code>600</code></li><li>确认公钥已添加到目标服务器的 <code>~/.ssh/authorized_keys</code></li><li>尝试用 <code>ssh -v</code> 查看详细的认证流程</li></ul><h4 id="Q-Too-many-authentication-failures-错误"><a href="#Q-Too-many-authentication-failures-错误" class="headerlink" title="Q: Too many authentication failures 错误"></a>Q: <code>Too many authentication failures</code> 错误</h4><p>确保配置中设置了 <code>IdentitiesOnly yes</code>，它会让 SSH 客户端只使用你指定的那一个密钥，而不会把所有本地密钥都试一遍。</p><h4 id="Q-如何让-cloudflared-在后台持续运行？"><a href="#Q-如何让-cloudflared-在后台持续运行？" class="headerlink" title="Q: 如何让 cloudflared 在后台持续运行？"></a>Q: 如何让 cloudflared 在后台持续运行？</h4><p>对于客户端侧，<code>cloudflared access tcp</code> 是每次 SSH 连接时由 ProxyCommand 临时启动的，无需后台常驻。服务端则需要将 <code>cloudflared tunnel</code> 配置为 systemd 服务：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">sudo</span> cloudflared service install<br></code></pre></td></tr></table></figure><h3 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h3><p><code>cloudflared access tcp</code> + SSH ProxyCommand 的组合，让我们可以在不暴露公网端口、不修改防火墙规则的前提下，安全地访问内网 SSH 服务。配合 SSH config 的别名机制，使用体验与直连几乎无差异。无论是 Git 免密推送、远程服务器管理还是文件传输，这套方案都能胜任。</p>]]>
    </content>
    <id>https://blog.952405.xyz/2026/06/cloudflared-ssh-tunnel/</id>
    <link href="https://blog.952405.xyz/2026/06/cloudflared-ssh-tunnel/"/>
    <published>2026-06-14T02:00:00.000Z</published>
    <summary>介绍如何通过 cloudflared access tcp 命令作为 SSH ProxyCommand，实现穿透内网防火墙安全连接到 Git 服务器或其他 SSH 服务。</summary>
    <title>使用 cloudflared 穿透内网实现 SSH 免密连接</title>
    <updated>2026-06-14T02:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="开发实践" scheme="https://blog.952405.xyz/categories/%E5%BC%80%E5%8F%91%E5%AE%9E%E8%B7%B5/"/>
    <category term="Git" scheme="https://blog.952405.xyz/tags/Git/"/>
    <category term="PAT" scheme="https://blog.952405.xyz/tags/PAT/"/>
    <category term="GitHub" scheme="https://blog.952405.xyz/tags/GitHub/"/>
    <content>
      <![CDATA[<h2 id="使用-PAT-进行-Git-免密推送"><a href="#使用-PAT-进行-Git-免密推送" class="headerlink" title="使用 PAT 进行 Git 免密推送"></a>使用 PAT 进行 Git 免密推送</h2><p>在日常开发中，每次 <code>git push</code> 都输入账号密码非常繁琐。GitHub 早已禁用密码认证，Gitea、GitLab 等平台也推荐使用 <strong>Personal Access Token (PAT)</strong> 代替密码进行 HTTPS 操作。本文将介绍如何使用 PAT + <code>credential.helper store</code> 实现免密推送，并说明其工作原理和安全考量。</p><h3 id="为什么需要-PAT"><a href="#为什么需要-PAT" class="headerlink" title="为什么需要 PAT"></a>为什么需要 PAT</h3><p>2021 年 8 月起，GitHub 不再支持密码认证，所有 HTTPS Git 操作必须使用 PAT 或 SSH Key。原因很简单：密码容易被撞库、泄露，而 PAT 具有以下优势：</p><ul><li><strong>最小权限</strong>：可以指定 Token 仅用于 <code>repo</code>（仓库读写）等特定 scope</li><li><strong>可随时吊销</strong>：Token 泄露后可以在平台后台一键撤销，不影响账号密码</li><li><strong>无 2FA 干扰</strong>：即使账号开启了两步验证，PAT 也无需额外验证</li></ul><p>其他平台同样推荐此方式——Gitea 从 1.17 开始支持细粒度 Token，GitLab 的 PAT 机制也非常成熟。</p><h3 id="credential-helper-store-是怎么工作的"><a href="#credential-helper-store-是怎么工作的" class="headerlink" title="credential.helper store 是怎么工作的"></a>credential.helper store 是怎么工作的</h3><p><code>git config --global credential.helper store</code> 的作用是：<strong>让 Git 将凭据以明文形式永久保存到本地文件中，之后访问远程仓库时自动读取，无需再次输入。</strong></p><h4 id="命令拆解"><a href="#命令拆解" class="headerlink" title="命令拆解"></a>命令拆解</h4><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">git config --global credential.helper store<br></code></pre></td></tr></table></figure><table><thead><tr><th>参数</th><th>说明</th></tr></thead><tbody><tr><td><code>git config</code></td><td>修改 Git 配置</td></tr><tr><td><code>--global</code></td><td>对当前用户生效，写入 <code>~/.gitconfig</code></td></tr><tr><td><code>credential.helper</code></td><td>Git 的凭据管理器配置项</td></tr><tr><td><code>store</code></td><td>使用 store 方式永久保存凭据</td></tr></tbody></table><h4 id="工作流程"><a href="#工作流程" class="headerlink" title="工作流程"></a>工作流程</h4><p>假设你第一次执行：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">git <span class="hljs-built_in">clone</span> https://github.com/user/repo.git<br></code></pre></td></tr></table></figure><p>Git 会提示输入用户名和密码（或 Token），输入后凭据保存到：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">~/.git-credentials<br></code></pre></td></tr></table></figure><p>内容格式为：</p><figure class="highlight dts"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs dts"><span class="hljs-symbol">https:</span><span class="hljs-comment">//username:ghp_xxxxxxxxxxxxxxxxx@github.com</span><br></code></pre></td></tr></table></figure><p>之后执行 <code>git pull</code>、<code>git push</code>、<code>git fetch</code> 等操作时，Git 会自动读取这个文件，不再询问。</p><h4 id="查看与验证"><a href="#查看与验证" class="headerlink" title="查看与验证"></a>查看与验证</h4><p>查看当前凭据配置：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash">git config --global credential.helper<br><span class="hljs-comment"># 输出: store</span><br></code></pre></td></tr></table></figure><p>查看保存的凭据文件：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">cat</span> ~/.git-credentials<br></code></pre></td></tr></table></figure><h3 id="完整配置步骤"><a href="#完整配置步骤" class="headerlink" title="完整配置步骤"></a>完整配置步骤</h3><h4 id="第一步：创建-PAT"><a href="#第一步：创建-PAT" class="headerlink" title="第一步：创建 PAT"></a>第一步：创建 PAT</h4><p><strong>GitHub：</strong></p><ol><li>访问 <strong>Settings → Developer settings → Personal access tokens → Tokens (classic)</strong> 或 <strong>Fine-grained tokens</strong></li><li>点击 <strong>Generate new token</strong></li><li>填写 Note（备注名），勾选 <code>repo</code> scope（完整控制私有仓库）</li><li>点击生成，<strong>立即复制 Token</strong>（离开页面后无法再次查看）</li></ol><p><img src="/../../../img/tools/creategithubtoken.png" alt="GitHub PAT 创建页面"></p><p><strong>Gitea：</strong></p><ol><li>访问 <strong>设置 → 应用 → 管理 Access Token</strong></li><li>填写 Token 名称，选择权限范围</li><li>点击生成并复制</li></ol><blockquote><p>Gitea 生成的 Token 格式类似 <code>a1b2c3...</code>（与 GitHub 的 <code>ghp_</code> 前缀不同），使用方式完全一致。</p></blockquote><h4 id="第二步：配置-credential-helper"><a href="#第二步：配置-credential-helper" class="headerlink" title="第二步：配置 credential.helper"></a>第二步：配置 credential.helper</h4><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">git config --global credential.helper store<br></code></pre></td></tr></table></figure><h4 id="第三步：触发保存"><a href="#第三步：触发保存" class="headerlink" title="第三步：触发保存"></a>第三步：触发保存</h4><p>执行任意需要认证的 Git 操作（如 <code>git clone</code> 或 <code>git pull</code>）：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">git <span class="hljs-built_in">clone</span> https://github.com/your-username/private-repo.git<br></code></pre></td></tr></table></figure><p>提示输入时：</p><ul><li><strong>Username</strong>：你的平台用户名</li><li><strong>Password</strong>：粘贴刚才复制的 PAT（不是登录密码！）</li></ul><p>输入成功后，凭据自动写入 <code>~/.git-credentials</code>，后续操作无需再次输入。</p><blockquote><p>⚠️ 请勿输入真实的平台登录密码。store 方式会明文保存，使用 PAT 即使泄露也可以单独吊销。</p></blockquote><h4 id="第四步：验证免密"><a href="#第四步：验证免密" class="headerlink" title="第四步：验证免密"></a>第四步：验证免密</h4><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">cd</span> your-repo<br><span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;test&quot;</span> &gt;&gt; test.txt<br>git add . &amp;&amp; git commit -m <span class="hljs-string">&quot;test push&quot;</span><br>git push<br></code></pre></td></tr></table></figure><p>如果推送成功且没有提示输入凭据，说明配置生效。</p><h3 id="多仓库-多平台配置"><a href="#多仓库-多平台配置" class="headerlink" title="多仓库 &#x2F; 多平台配置"></a>多仓库 &#x2F; 多平台配置</h3><p><code>~/.git-credentials</code> 支持多行，每个仓库或平台一行：</p><figure class="highlight dts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs dts"><span class="hljs-symbol">https:</span><span class="hljs-comment">//user1:ghp_xxxx@github.com</span><br><span class="hljs-symbol">https:</span><span class="hljs-comment">//user2:glpat-yyyy@gitlab.com</span><br><span class="hljs-symbol">https:</span><span class="hljs-comment">//ding:z3x2c1@git.example.com</span><br></code></pre></td></tr></table></figure><p>也可以按域名分段匹配，让同一平台的不同仓库共用凭据：</p><figure class="highlight dts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs dts"><span class="hljs-symbol">https:</span><span class="hljs-comment">//user1:ghp_xxxx@github.com</span><br><span class="hljs-symbol">https:</span><span class="hljs-comment">//user2:glpat-yyyy@gitlab.com</span><br></code></pre></td></tr></table></figure><p>Git 会按最长前缀匹配。例如 <code>github.com/user1/repo-a</code> 和 <code>github.com/user1/repo-b</code> 都匹配第一行。</p><h3 id="安全风险"><a href="#安全风险" class="headerlink" title="安全风险"></a>安全风险</h3><p><code>store</code> 最大的问题是 <strong>明文保存</strong>：</p><figure class="highlight dts"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs dts"><span class="hljs-symbol">https:</span><span class="hljs-comment">//ding:ghp_xxxxxxxxxxxxx@github.com</span><br></code></pre></td></tr></table></figure><p>任何能访问 <code>~/.git-credentials</code> 的人都能直接看到 Token。因此：</p><table><thead><tr><th>场景</th><th>是否推荐</th></tr></thead><tbody><tr><td>个人开发机（仅自己使用）</td><td>✅ 可以使用</td></tr><tr><td>服务器（多人登录）</td><td>❌ 不推荐</td></tr><tr><td>共享电脑</td><td>❌ 不推荐</td></tr><tr><td>CI&#x2F;CD 环境</td><td>❌ 应使用环境变量或 Secrets</td></tr></tbody></table><h3 id="更安全的替代方案"><a href="#更安全的替代方案" class="headerlink" title="更安全的替代方案"></a>更安全的替代方案</h3><p>如果你的环境不适合明文保存，可以选择以下方式：</p><h4 id="Linux-—-credential-cache（缓存模式）"><a href="#Linux-—-credential-cache（缓存模式）" class="headerlink" title="Linux — credential-cache（缓存模式）"></a>Linux — credential-cache（缓存模式）</h4><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">git config --global credential.helper cache<br></code></pre></td></tr></table></figure><p>默认缓存 <strong>15 分钟</strong>，可指定时长：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">git config --global credential.helper <span class="hljs-string">&#x27;cache --timeout=3600&#x27;</span><br></code></pre></td></tr></table></figure><p>凭据缓存在内存中，不会写入磁盘，超时自动失效。</p><h4 id="Linux-—-libsecret（GNOME-密钥环）"><a href="#Linux-—-libsecret（GNOME-密钥环）" class="headerlink" title="Linux — libsecret（GNOME 密钥环）"></a>Linux — libsecret（GNOME 密钥环）</h4><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 安装依赖</span><br><span class="hljs-built_in">sudo</span> apt install libsecret-1-0 libsecret-1-dev<br><span class="hljs-built_in">cd</span> /usr/share/doc/git/contrib/credential/libsecret<br><span class="hljs-built_in">sudo</span> make<br><span class="hljs-comment"># 配置</span><br>git config --global credential.helper /usr/share/doc/git/contrib/credential/libsecret/git-credential-libsecret<br></code></pre></td></tr></table></figure><p>凭据存储在系统密钥环中，而非明文文件。</p><h4 id="macOS-—-osxkeychain"><a href="#macOS-—-osxkeychain" class="headerlink" title="macOS — osxkeychain"></a>macOS — osxkeychain</h4><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">git config --global credential.helper osxkeychain<br></code></pre></td></tr></table></figure><p>凭据保存在 macOS 钥匙串中，系统级加密。</p><h4 id="Windows-—-manager-core"><a href="#Windows-—-manager-core" class="headerlink" title="Windows — manager-core"></a>Windows — manager-core</h4><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">git config --global credential.helper manager-core<br></code></pre></td></tr></table></figure><p>凭据保存在 Windows Credential Manager 中，不会明文暴露。</p><h3 id="删除已保存的凭据"><a href="#删除已保存的凭据" class="headerlink" title="删除已保存的凭据"></a>删除已保存的凭据</h3><p>如果不再需要 store 模式或想清除已保存的凭据：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 删除配置</span><br>git config --global --<span class="hljs-built_in">unset</span> credential.helper<br><br><span class="hljs-comment"># 删除凭据文件</span><br><span class="hljs-built_in">rm</span> ~/.git-credentials<br></code></pre></td></tr></table></figure><p>Windows 用户：</p><figure class="highlight cmd"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs cmd"><span class="hljs-built_in">del</span> <span class="hljs-variable">%USERPROFILE%</span>\.git-credentials<br></code></pre></td></tr></table></figure><h3 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h3><table><thead><tr><th>方案</th><th>安全性</th><th>便利性</th><th>适用场景</th></tr></thead><tbody><tr><td><code>credential.helper store</code> + PAT</td><td>⭐⭐</td><td>⭐⭐⭐</td><td>个人开发机</td></tr><tr><td><code>credential.helper cache</code> + PAT</td><td>⭐⭐⭐</td><td>⭐⭐</td><td>临时使用 &#x2F; 服务器</td></tr><tr><td><code>osxkeychain</code> &#x2F; <code>manager-core</code> &#x2F; <code>libsecret</code></td><td>⭐⭐⭐</td><td>⭐⭐⭐</td><td>所有场景（推荐）</td></tr><tr><td>SSH Key</td><td>⭐⭐⭐</td><td>⭐⭐⭐</td><td>所有场景</td></tr></tbody></table><p>核心原则：<strong>不要将登录密码用于 Git 认证，永远使用 PAT 或 SSH Key。</strong> 如果环境允许使用系统凭据管理器，优先选择 <code>osxkeychain</code>、<code>manager-core</code> 或 <code>libsecret</code>，它们比 <code>store</code> 更安全。</p>]]>
    </content>
    <id>https://blog.952405.xyz/2026/06/git-pat-https-push/</id>
    <link href="https://blog.952405.xyz/2026/06/git-pat-https-push/"/>
    <published>2026-06-08T02:00:00.000Z</published>
    <summary>介绍如何使用Personal Access Token(PAT)配合Git credential.helper实现HTTPS免密推送，并对比各平台凭据管理方式的安全差异。</summary>
    <title>使用PAT进行Git免密推送（支持HTTPS）</title>
    <updated>2026-06-08T02:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="基础设施" scheme="https://blog.952405.xyz/categories/%E5%9F%BA%E7%A1%80%E8%AE%BE%E6%96%BD/"/>
    <category term="PVE" scheme="https://blog.952405.xyz/tags/PVE/"/>
    <category term="ifplugd" scheme="https://blog.952405.xyz/tags/ifplugd/"/>
    <content>
      <![CDATA[<h2 id="问题背景"><a href="#问题背景" class="headerlink" title="问题背景"></a>问题背景</h2><p>在玩 PVE（Proxmox VE）轻量软路由、All-in-One 或者给 PVE 接入随身 WiFi（USB 网卡）时，经常会遇到一个痛点：</p><p><strong>一旦随身 WiFi 重启或断开，PVE 底层的物理链路变成 DOWN。当随身 WiFi 重新开机恢复信号后，虽然物理链路变回 UP，但 PVE 的静态 IP 和默认路由并不会自动绑定回去，导致网络”假死”。</strong></p><p>使用 <code>ifplugd</code> 盯紧网卡状态并在恢复时自动刷新，是目前最优雅、最及时的解决方案。但如果你的 PVE 使用了虚拟网桥（vmbr0），按照网上的常规教程配置，<strong>极易导致 PVE 直接彻底断网失联</strong>。本文将分享如何完美避开这个大坑。</p><span id="more"></span><h2 id="常见误区与”断网”大坑分析"><a href="#常见误区与”断网”大坑分析" class="headerlink" title="常见误区与”断网”大坑分析"></a>常见误区与”断网”大坑分析</h2><p>在标准的 PVE 网络布局中，我们的静态 IP（如 <code>192.168.0.250</code>）和网关通常<strong>没有直接配在物理网卡</strong>（如 <code>enx889e9...</code>）上，而是<strong>配在虚拟网桥 vmbr0 上</strong>，物理网卡只是作为 vmbr0 的一个桥接端口。</p><h3 id="❌-误区一：只刷新物理网卡"><a href="#❌-误区一：只刷新物理网卡" class="headerlink" title="❌ 误区一：只刷新物理网卡"></a>❌ 误区一：只刷新物理网卡</h3><p>网上很多教程让 <code>ifplugd</code> 恢复时去执行 <code>ifup usb0</code>。但在网桥架构下，真正卡死需要刷新的是上层的 <strong>vmbr0</strong>，只刷新物理网卡根本无法恢复网络。</p><h3 id="❌-误区二：在-down-动作里执行-ifdown-–force-vmbr0"><a href="#❌-误区二：在-down-动作里执行-ifdown-–force-vmbr0" class="headerlink" title="❌ 误区二：在 down 动作里执行 ifdown –force vmbr0"></a>❌ 误区二：在 down 动作里执行 ifdown –force vmbr0</h3><p><strong>这是最大的隐患！</strong> 随身 WiFi 在刚插上或重启时，物理链路会频繁闪烁（UP&#x2F;DOWN 快速切换）。如果脚本在检测到 DOWN 时去无脑关闭 vmbr0，就会瞬间切断 PVE 的管理网络，导致 Web 页面和 SSH 彻底失联，再也无法触发后续的恢复脚本。</p><h2 id="完美的终极解决方案（只升不降，绝对安全）"><a href="#完美的终极解决方案（只升不降，绝对安全）" class="headerlink" title="完美的终极解决方案（只升不降，绝对安全）"></a>完美的终极解决方案（只升不降，绝对安全）</h2><p><strong>核心逻辑：网络断开时，什么都不做，确保 PVE 本地管理网络绝对不死；网络恢复时，强制刷新&#x2F;拉起 vmbr0。</strong></p><h3 id="第一步：安装-ifplugd"><a href="#第一步：安装-ifplugd" class="headerlink" title="第一步：安装 ifplugd"></a>第一步：安装 ifplugd</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">apt update &amp;&amp; apt install -y ifplugd<br></code></pre></td></tr></table></figure><h3 id="第二步：配置-ifplugd-盯紧物理网卡"><a href="#第二步：配置-ifplugd-盯紧物理网卡" class="headerlink" title="第二步：配置 ifplugd 盯紧物理网卡"></a>第二步：配置 ifplugd 盯紧物理网卡</h3><p>打开 <code>ifplugd</code> 默认配置文件：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">nano /etc/default/ifplugd<br></code></pre></td></tr></table></figure><p>修改为以下内容（<strong>请将 <code>enx889e966aed98</code> 替换为你实际的随身 WiFi 网卡名称</strong>）：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">INTERFACES=&quot;enx889e966aed98&quot;<br>HOTPLUG_INTERFACES=&quot;enx889e966aed98&quot;<br>ARGS=&quot;-q -f -u0 -d1 -w&quot;<br>SUSPEND_ACTION=&quot;stop&quot;<br></code></pre></td></tr></table></figure><blockquote><p><strong>注意：</strong> 务必去掉参数中的 <code>-I</code>，否则在 PVE (Debian) 环境下可能导致脚本不被调用；<code>-d1</code> 设置为 1 秒即可快速检测断开，但实际恢复由脚本中的 25 秒等待保证随身 WiFi 内部系统完全就绪。</p></blockquote><h3 id="第三步：编写安全联动脚本"><a href="#第三步：编写安全联动脚本" class="headerlink" title="第三步：编写安全联动脚本"></a>第三步：编写安全联动脚本</h3><p>打开 <code>ifplugd</code> 的动作触发脚本：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">nano /etc/ifplugd/ifplugd.action<br></code></pre></td></tr></table></figure><p>清空旧内容，完整复制并粘贴以下针对网桥环境优化的安全版代码：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-meta">#!/bin/sh</span><br><span class="hljs-built_in">set</span> -e<br><br>INTERFACE=<span class="hljs-variable">$1</span><br>ACTION=<span class="hljs-variable">$2</span><br><br><span class="hljs-comment"># ⚠️ 请在这里定义你 OpenWrt 虚拟机的实际 ID</span><br>VM_ID=<span class="hljs-string">&quot;100&quot;</span><br><br><span class="hljs-keyword">if</span> [ <span class="hljs-string">&quot;<span class="hljs-variable">$INTERFACE</span>&quot;</span> = <span class="hljs-string">&quot;enx889e966aed98&quot;</span> ]; <span class="hljs-keyword">then</span><br>    <span class="hljs-keyword">case</span> <span class="hljs-string">&quot;<span class="hljs-variable">$ACTION</span>&quot;</span> <span class="hljs-keyword">in</span><br>        up)<br>            <span class="hljs-comment"># ─── ⚡ 终极时间差补丁 ───</span><br>            <span class="hljs-comment"># 随身WiFi刚亮灯/发出UP信号时，系统还没开完机。</span><br>            <span class="hljs-comment"># 我们在这里强行让 PVE 死等 25 秒钟，等随身WiFi内部彻底初始化完毕！</span><br>            <span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;[ifplugd] 随身WiFi硬件已亮灯，死等 25 秒让其内部系统彻底开机...&quot;</span><br>            <span class="hljs-built_in">sleep</span> 25<br>            <span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;[ifplugd] 随身WiFi物理链路已就绪，开始执行修复逻辑...&quot;</span><br>            <span class="hljs-comment"># 1. 强制刷新网桥状态</span><br>            ifup --force vmbr0 || <span class="hljs-literal">true</span><br>            ifup --ifaddrs vmbr0 || <span class="hljs-literal">true</span><br>            <span class="hljs-comment"># 2. 确保物理网卡处于 UP 和混杂模式</span><br>            ip <span class="hljs-built_in">link</span> <span class="hljs-built_in">set</span> dev enx889e966aed98 up<br>            ip <span class="hljs-built_in">link</span> <span class="hljs-built_in">set</span> dev enx889e966aed98 promisc on<br>            <span class="hljs-comment"># 3. 【核心补丁】热刷新 OpenWrt 虚拟机的网卡，彻底解决虚拟机孤岛问题</span><br>            <span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;[ifplugd] 正在激活 OpenWrt (ID: <span class="hljs-variable">$VM_ID</span>) 的虚拟网卡通道...&quot;</span><br>            qm <span class="hljs-built_in">set</span> <span class="hljs-variable">$VM_ID</span> --net0 virtio,bridge=vmbr0 || <span class="hljs-literal">true</span><br>            <span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;[ifplugd] 所有网络通道已全部疏通！&quot;</span><br>            ;;<br>        down)<br>            <span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;[ifplugd] 随身WiFi暂时断开，保持 vmbr0 状态以防失联...&quot;</span><br>            ;;<br>    <span class="hljs-keyword">esac</span><br><span class="hljs-keyword">fi</span><br></code></pre></td></tr></table></figure><p>保存退出后，赋予脚本可执行权限：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">chmod</span> +x /etc/ifplugd/ifplugd.action<br></code></pre></td></tr></table></figure><h3 id="第四步：重启服务使配置生效"><a href="#第四步：重启服务使配置生效" class="headerlink" title="第四步：重启服务使配置生效"></a>第四步：重启服务使配置生效</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">systemctl restart ifplugd<br></code></pre></td></tr></table></figure><h2 id="验证效果"><a href="#验证效果" class="headerlink" title="验证效果"></a>验证效果</h2><p>在 PVE 终端执行以下命令挂起实时日志监控：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">journalctl -u ifplugd -f<br></code></pre></td></tr></table></figure><p>此时尝试拔掉随身 WiFi 或将其重启。当随身 WiFi 再次开机亮起蓝灯时，你会看到控制台瞬间滚动日志：</p><figure class="highlight prolog"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs prolog">[ifplugd] 随身<span class="hljs-symbol">WiFi</span>物理链路已就绪，正在尝试激活/刷新 vmbr0...<br>[ifplugd] vmbr0 刷新完成，网络已恢复！<br></code></pre></td></tr></table></figure><p>此时静态 IP 和默认路由会完美、丝滑地自动挂载回来，PVE 成功恢复外网访问，且管理端全程不会失联！</p><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>这套方案的核心优势：</p><ul><li>✅ <strong>专为 PVE 网桥架构设计</strong>，直接刷新 vmbr0 而非物理网卡</li><li>✅ <strong>绝对安全</strong>，断开时不做任何操作，避免管理网络失联</li><li>✅ <strong>响应迅速</strong>，物理链路恢复后 5 秒内自动激活</li><li>✅ <strong>兼容 PVE 8 &#x2F; ifupdown2</strong>，使用 <code>--ifaddrs</code> 确保地址刷新</li><li>✅ <strong>防止频繁闪烁</strong>，只升不降策略避免误触发</li></ul><p>对于在 PVE 上使用随身 WiFi 的用户，这是目前最稳定可靠的自动重连方案。</p>]]>
    </content>
    <id>https://blog.952405.xyz/2026/06/pve-ifplugd-auto-reconnect/</id>
    <link href="https://blog.952405.xyz/2026/06/pve-ifplugd-auto-reconnect/"/>
    <published>2026-06-01T02:00:00.000Z</published>
    <summary>解决 Proxmox VE 8 虚拟网桥场景下随身 WiFi 重启导致网络假死问题，基于 ifplugd 的完美方案，避免 vmbr0 断网失联大坑。</summary>
    <title>PVE 8 随身 WiFi 重启后网络假死终极解决方案（ifplugd 避坑指南）</title>
    <updated>2026-06-01T02:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="工具与效率" scheme="https://blog.952405.xyz/categories/%E5%B7%A5%E5%85%B7%E4%B8%8E%E6%95%88%E7%8E%87/"/>
    <category term="VS Code" scheme="https://blog.952405.xyz/tags/VS-Code/"/>
    <category term="Todo Tree" scheme="https://blog.952405.xyz/tags/Todo-Tree/"/>
    <category term="ripgrep" scheme="https://blog.952405.xyz/tags/ripgrep/"/>
    <content>
      <![CDATA[<p>在 VS Code 新版本中使用 Todo Tree 插件时，可能会遇到 <code>command &#39;todo-tree.refresh&#39; not found</code> 的错误，即便 Todo Tree 已经安装且显示为”已启用”状态。本文记录排查过程和解决方案。</p><h2 id="问题现象"><a href="#问题现象" class="headerlink" title="问题现象"></a>问题现象</h2><ul><li>按下 <code>Ctrl+Shift+P</code> 搜不到 <code>Todo Tree: Refresh</code> 命令</li><li>左侧活动栏不显示 Todo Tree 图标</li><li>手动在命令面板执行 <code>todo-tree.refresh</code> 提示 <code>command &#39;todo-tree.refresh&#39; not found</code></li></ul><h2 id="根本原因"><a href="#根本原因" class="headerlink" title="根本原因"></a>根本原因</h2><p>Todo Tree 依赖 <a href="https://github.com/BurntSushi/ripgrep">ripgrep</a>（<code>rg</code>）来做文件搜索，但插件有时候无法从系统 <code>$PATH</code> 中定位到 ripgrep 可执行文件，导致插件初始化失败，所有命令都不可用。</p><p>这个问题比较隐蔽，因为 VS Code 不会弹出任何错误提示——插件只是静默失败。</p><h2 id="排查方法"><a href="#排查方法" class="headerlink" title="排查方法"></a>排查方法</h2><p>先把插件<strong>禁用 → 重新加载窗口 → 启用</strong>，让插件重新初始化，这时候打开 VS Code 的开发者工具（<code>Ctrl+Shift+I</code> 或 <code>Help → Toggle Developer Tools</code>），在 Console 面板中就能看到真正的错误日志，其中会明确指出 <code>rg</code> 查找失败。</p><h2 id="解决方案"><a href="#解决方案" class="headerlink" title="解决方案"></a>解决方案</h2><p>在 VS Code 设置中显式指定 ripgrep 的绝对路径，绕过 <code>$PATH</code> 查找：</p><h3 id="1-找到-ripgrep-的绝对路径"><a href="#1-找到-ripgrep-的绝对路径" class="headerlink" title="1. 找到 ripgrep 的绝对路径"></a>1. 找到 ripgrep 的绝对路径</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">which</span> rg<br><span class="hljs-comment"># 输出示例: /usr/bin/rg</span><br></code></pre></td></tr></table></figure><p>Windows 用户可以在 Git Bash 或 WSL 中运行，或者直接在文件资源管理器中找到 <code>rg.exe</code> 的路径。</p><h3 id="2-在-VS-Code-配置中添加路径"><a href="#2-在-VS-Code-配置中添加路径" class="headerlink" title="2. 在 VS Code 配置中添加路径"></a>2. 在 VS Code 配置中添加路径</h3><p>打开 <code>settings.json</code>（<code>Ctrl+Shift+P</code> → <code>Preferences: Open User Settings (JSON)</code>），添加：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-attr">&quot;todo-tree.ripgrep.ripgrep&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;/usr/bin/rg&quot;</span><br></code></pre></td></tr></table></figure><p>Windows 示例（注意双反斜杠转义）：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-attr">&quot;todo-tree.ripgrep.ripgrep&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;C:\\Program Files\\ripgrep\\rg.exe&quot;</span><br></code></pre></td></tr></table></figure><h3 id="3-重新加载插件"><a href="#3-重新加载插件" class="headerlink" title="3. 重新加载插件"></a>3. 重新加载插件</h3><p>配置生效后，再次<strong>禁用 → 重新加载窗口 → 启用</strong> Todo Tree，此时插件应该能正常找到 ripgrep 并完成初始化。</p><h2 id="自定义标签配置"><a href="#自定义标签配置" class="headerlink" title="自定义标签配置"></a>自定义标签配置</h2><p>Todo Tree 默认只高亮 <code>TODO</code>、<code>FIXME</code> 等几个标签，可以根据需要添加自定义标签。以下是我常用的配置：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-attr">&quot;todo-tree.general.tags&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><br>    <span class="hljs-string">&quot;BUG&quot;</span><span class="hljs-punctuation">,</span><br>    <span class="hljs-string">&quot;HACK&quot;</span><span class="hljs-punctuation">,</span><br>    <span class="hljs-string">&quot;FIXME&quot;</span><span class="hljs-punctuation">,</span><br>    <span class="hljs-string">&quot;TODO&quot;</span><span class="hljs-punctuation">,</span><br>    <span class="hljs-string">&quot;XXX&quot;</span><span class="hljs-punctuation">,</span><br>    <span class="hljs-string">&quot;[x]&quot;</span><span class="hljs-punctuation">,</span><br>    <span class="hljs-string">&quot;TODO-z&quot;</span><span class="hljs-punctuation">,</span><br>    <span class="hljs-string">&quot;BUG-z&quot;</span><span class="hljs-punctuation">,</span><br>    <span class="hljs-string">&quot;NOTE-z&quot;</span><br><span class="hljs-punctuation">]</span><br></code></pre></td></tr></table></figure><p>这样代码中所有带这些标记的注释都会被 Todo Tree 统一管理，点击即可跳转。</p><p>配置后的效果：</p><ul><li><code>TODO-z</code>、<code>BUG-z</code>、<code>NOTE-z</code> 带 <code>-z</code> 后缀是我个人的标记习惯，方便与团队通用的 <code>TODO</code> &#x2F; <code>BUG</code> 区分</li><li><code>[x]</code> 用于标记已完成待清理的临时代码</li><li><code>XXX</code> 用于标记需要紧急关注的问题</li></ul><h2 id="延伸：ripgrep-全局配置"><a href="#延伸：ripgrep-全局配置" class="headerlink" title="延伸：ripgrep 全局配置"></a>延伸：ripgrep 全局配置</h2><p>如果你使用 Linux&#x2F;WSL，还可以给 ripgrep 配一个全局配置文件 <code>~/.ripgreprc</code>，让命令行搜索体验更好：</p><figure class="highlight routeros"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><code class="hljs routeros">--smart-case<br><span class="hljs-attribute">--max-columns</span>=200<br>--max-columns-preview<br><span class="hljs-attribute">--colors</span>=match:fg:yellow<br><span class="hljs-attribute">--colors</span>=match:style:bold<br><span class="hljs-attribute">--colors</span>=line:fg:blue<br><span class="hljs-attribute">--colors</span>=path:fg:green<br></code></pre></td></tr></table></figure><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>这个问题本质上不是 Todo Tree 本身的 bug，而是 VS Code 扩展的运行时环境和交互式 shell 的 <code>$PATH</code> 可能存在差异。<strong>直接写绝对路径是最稳妥的解决方式</strong>，<code>which rg</code> 一步就能拿到路径，配置一条 JSON 即可永久修复。</p>]]>
    </content>
    <id>https://blog.952405.xyz/2026/05/vscode-todo-tree-ripgrep-fix/</id>
    <link href="https://blog.952405.xyz/2026/05/vscode-todo-tree-ripgrep-fix/"/>
    <published>2026-05-27T08:30:00.000Z</published>
    <summary>解决 VSCode TODO Tree 插件 ripgrep 错误：路径配置、权限修复、性能优化。</summary>
    <title>VS Code Todo Tree 插件无法运行（ripgrep 未找到）的修复方法</title>
    <updated>2026-05-27T08:30:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="网络与代理" scheme="https://blog.952405.xyz/categories/%E7%BD%91%E7%BB%9C%E4%B8%8E%E4%BB%A3%E7%90%86/"/>
    <category term="Cloudflare" scheme="https://blog.952405.xyz/tags/Cloudflare/"/>
    <category term="HTTP/2" scheme="https://blog.952405.xyz/tags/HTTP-2/"/>
    <category term="QUIC" scheme="https://blog.952405.xyz/tags/QUIC/"/>
    <category term="Cloudflare Tunnel" scheme="https://blog.952405.xyz/tags/Cloudflare-Tunnel/"/>
    <content>
      <![CDATA[<h2 id="问题背景"><a href="#问题背景" class="headerlink" title="问题背景"></a>问题背景</h2><p>在使用 Cloudflare Tunnel（cloudflared）进行内网穿透时，国内用户经常会遇到连接不稳定、延迟极高甚至完全无法访问的问题。这背后的根本原因是：</p><p><strong>中国大陆部分运营商（如中国电信、中国联通、中国移动）对 UDP 流量（尤其是 443 端口的 UDP）存在极其严格的 QoS（服务质量）限制甚至直接丢包</strong>。</p><p>这就导致基于 UDP 的 HTTP&#x2F;3 (QUIC) 协议在跨国连接时经常处于”半死不活”的状态。</p><p>而 <strong>Cloudflare Tunnel 默认会优先尝试使用 QUIC 协议 (HTTP&#x2F;3)</strong> 来建立本地服务器到 Cloudflare 边缘节点的连接。如果遭遇运营商拦截，就会导致：</p><ul><li>🔴 连接不断断开重连</li><li>🔴 延迟极高（数秒到十几秒）</li><li>🔴 部分请求超时</li><li>🔴 完全无法访问</li></ul><h2 id="解决方案核心思路"><a href="#解决方案核心思路" class="headerlink" title="解决方案核心思路"></a>解决方案核心思路</h2><p><strong>强制 cloudflared 在本地回源时使用基于 TCP 的 HTTP&#x2F;2 协议</strong>，彻底绕过 UDP 限制。</p><p>根据你的部署方式（命令行、配置文件或 Docker），选择对应的修改方法。</p><hr><h2 id="🛠️-方法一：命令行启动方式"><a href="#🛠️-方法一：命令行启动方式" class="headerlink" title="🛠️ 方法一：命令行启动方式"></a>🛠️ 方法一：命令行启动方式</h2><p>如果你是通过命令行直接启动 Cloudflare Tunnel，只需在 <code>cloudflared tunnel run</code> 命令后面加上 <code>--protocol http2</code> 参数。</p><h3 id="示例命令"><a href="#示例命令" class="headerlink" title="示例命令"></a>示例命令</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">cloudflared tunnel --protocol http2 run &lt;你的隧道名称或ID&gt;<br></code></pre></td></tr></table></figure><h3 id="完整示例"><a href="#完整示例" class="headerlink" title="完整示例"></a>完整示例</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 假设你的隧道名称是 my-tunnel</span><br>cloudflared tunnel --protocol http2 run my-tunnel<br></code></pre></td></tr></table></figure><h3 id="验证是否生效"><a href="#验证是否生效" class="headerlink" title="验证是否生效"></a>验证是否生效</h3><p>启动后，观察输出日志中是否包含类似信息：</p><figure class="highlight routeros"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs routeros"><span class="hljs-built_in">INFO</span><span class="hljs-built_in"> Connection </span>established <span class="hljs-attribute">protocol</span>=http2<br></code></pre></td></tr></table></figure><p>如果看到 <code>protocol=http2</code>，说明配置生效。</p><hr><h2 id="🛠️-方法二：使用配置文件（推荐）"><a href="#🛠️-方法二：使用配置文件（推荐）" class="headerlink" title="🛠️ 方法二：使用配置文件（推荐）"></a>🛠️ 方法二：使用配置文件（推荐）</h2><p>如果你使用配置文件管理 Cloudflare Tunnel，这是最推荐的方式，便于长期维护和版本控制。</p><h3 id="配置文件位置"><a href="#配置文件位置" class="headerlink" title="配置文件位置"></a>配置文件位置</h3><p>通常位于以下路径之一：</p><ul><li>Linux&#x2F;macOS: <code>~/.cloudflared/config.yml</code></li><li>Windows: <code>%USERPROFILE%\.cloudflared\config.yml</code></li><li>自定义路径: 通过 <code>--config</code> 参数指定</li></ul><h3 id="配置示例"><a href="#配置示例" class="headerlink" title="配置示例"></a>配置示例</h3><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">tunnel:</span> <span class="hljs-string">&lt;你的Tunnel-ID&gt;</span><br><span class="hljs-attr">credentials-file:</span> <span class="hljs-string">/root/.cloudflared/&lt;Tunnel-ID&gt;.json</span><br><br><span class="hljs-comment"># 🎯 核心配置：强制使用 http2 替代 quic</span><br><span class="hljs-attr">protocol:</span> <span class="hljs-string">http2</span><br><br><span class="hljs-attr">ingress:</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-attr">hostname:</span> <span class="hljs-string">yoursite.com</span><br>    <span class="hljs-attr">service:</span> <span class="hljs-string">http://localhost:8080</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-attr">hostname:</span> <span class="hljs-string">api.yoursite.com</span><br>    <span class="hljs-attr">service:</span> <span class="hljs-string">http://localhost:3000</span><br>  <span class="hljs-comment"># 捕获所有其他请求，返回 404</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-attr">service:</span> <span class="hljs-string">http_status:404</span><br></code></pre></td></tr></table></figure><h3 id="关键配置说明"><a href="#关键配置说明" class="headerlink" title="关键配置说明"></a>关键配置说明</h3><table><thead><tr><th>配置项</th><th>说明</th></tr></thead><tbody><tr><td><code>tunnel</code></td><td>你的 Tunnel ID（在 Cloudflare Zero Trust 后台创建隧道时生成）</td></tr><tr><td><code>credentials-file</code></td><td>凭证文件路径（创建隧道时自动生成）</td></tr><tr><td><code>protocol: http2</code></td><td><strong>核心配置</strong>，强制使用 HTTP&#x2F;2 协议</td></tr><tr><td><code>ingress</code></td><td>路由规则，定义域名到本地服务的映射</td></tr></tbody></table><h3 id="修改后重启服务"><a href="#修改后重启服务" class="headerlink" title="修改后重启服务"></a>修改后重启服务</h3><p>如果你使用 systemd 管理服务：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">sudo</span> systemctl restart cloudflared<br></code></pre></td></tr></table></figure><p>如果是手动启动：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">cloudflared tunnel run<br></code></pre></td></tr></table></figure><hr><h2 id="🛠️-方法三：Systemd-服务托管方式"><a href="#🛠️-方法三：Systemd-服务托管方式" class="headerlink" title="🛠️ 方法三：Systemd 服务托管方式"></a>🛠️ 方法三：Systemd 服务托管方式</h2><p>如果你是使用 <code>systemctl</code> 托管的 cloudflared 服务，只需要修改 Systemd 服务配置文件或其关联的环境配置文件即可。</p><h3 id="1-查找服务文件位置"><a href="#1-查找服务文件位置" class="headerlink" title="1. 查找服务文件位置"></a>1. 查找服务文件位置</h3><p>通常，cloudflared 官方安装脚本会自动创建名为 <code>cloudflared.service</code> 的服务。你可以通过以下命令直接编辑：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">sudo</span> systemctl edit --full cloudflared.service<br></code></pre></td></tr></table></figure><blockquote><p>💡 提示：如果习惯使用 vim，也可以手动执行 <code>sudo vim /etc/systemd/system/cloudflared.service</code></p></blockquote><h3 id="2-修改配置（根据启动方式选择）"><a href="#2-修改配置（根据启动方式选择）" class="headerlink" title="2. 修改配置（根据启动方式选择）"></a>2. 修改配置（根据启动方式选择）</h3><p>进入编辑界面后，根据你的具体服务内容选择对应的修改方式：</p><h4 id="情况-A：通过-Token-启动的服务"><a href="#情况-A：通过-Token-启动的服务" class="headerlink" title="情况 A：通过 Token 启动的服务"></a>情况 A：通过 Token 启动的服务</h4><p>如果你的服务文件里 <code>ExecStart</code> 后面跟着的是 <code>--token</code>，请直接在后面加上 <code>--protocol http2</code>。</p><p><strong>修改前：</strong></p><figure class="highlight ini"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs ini"><span class="hljs-section">[Service]</span><br><span class="hljs-attr">ExecStart</span>=/usr/local/bin/cloudflared tunnel run --token eyJ...<br></code></pre></td></tr></table></figure><p><strong>修改后：</strong></p><figure class="highlight ini"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs ini"><span class="hljs-section">[Service]</span><br><span class="hljs-attr">ExecStart</span>=/usr/local/bin/cloudflared tunnel --protocol http2 run --token eyJ...<br></code></pre></td></tr></table></figure><h4 id="情况-B：通过配置文件（config-yml）启动的服务"><a href="#情况-B：通过配置文件（config-yml）启动的服务" class="headerlink" title="情况 B：通过配置文件（config.yml）启动的服务"></a>情况 B：通过配置文件（config.yml）启动的服务</h4><p>如果你的服务文件里指定了 <code>config.yml</code> 的路径：</p><p><strong>修改前：</strong></p><figure class="highlight ini"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs ini"><span class="hljs-section">[Service]</span><br><span class="hljs-attr">ExecStart</span>=/usr/local/bin/cloudflared --config /etc/cloudflared/config.yml tunnel run<br></code></pre></td></tr></table></figure><p><strong>修改后：</strong></p><figure class="highlight ini"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs ini"><span class="hljs-section">[Service]</span><br><span class="hljs-attr">ExecStart</span>=/usr/local/bin/cloudflared --config /etc/cloudflared/config.yml tunnel --protocol http2 run<br></code></pre></td></tr></table></figure><blockquote><p>💡 提示：如果是情况 B，你其实也可以不用动这个服务文件，直接去修改 <code>/etc/cloudflared/config.yml</code>，在里面加一行 <code>protocol: http2</code>，效果是完全一样的（推荐使用配置文件方式，更便于维护）。</p></blockquote><h3 id="3-重新加载并重启服务"><a href="#3-重新加载并重启服务" class="headerlink" title="3. 重新加载并重启服务"></a>3. 重新加载并重启服务</h3><p>修改并保存服务文件后，必须刷新 Systemd 守护进程并重启服务才能生效：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 1. 重新加载 Systemd 配置</span><br><span class="hljs-built_in">sudo</span> systemctl daemon-reload<br><br><span class="hljs-comment"># 2. 重启 cloudflared 服务</span><br><span class="hljs-built_in">sudo</span> systemctl restart cloudflared<br><br><span class="hljs-comment"># 3. 检查服务状态是否正常</span><br><span class="hljs-built_in">sudo</span> systemctl status cloudflared<br></code></pre></td></tr></table></figure><h3 id="4-验证是否成功切换为-HTTP-2"><a href="#4-验证是否成功切换为-HTTP-2" class="headerlink" title="4. 验证是否成功切换为 HTTP&#x2F;2"></a>4. 验证是否成功切换为 HTTP&#x2F;2</h3><p>重启后，查看 cloudflared 的实时日志，确认它是否已经成功使用了 <code>http2</code> 协议：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">sudo</span> journalctl -u cloudflared.service -n 50 -f<br></code></pre></td></tr></table></figure><p><strong>成功标志</strong>：</p><ul><li>日志中出现 <code>protocol=http2</code> 或 <code>Protocol: http2</code></li><li>连接端口变成了 <strong>7844</strong>（Cloudflare 的 HTTP&#x2F;2 隧道专用端口）</li></ul><p><strong>失败标志</strong>：</p><ul><li>依然在使用 <strong>443 端口</strong>并提示 <code>quic</code></li><li>请检查上面的参数位置是否放错（<code>--protocol http2</code> 必须在 <code>run</code> 之前）</li></ul><h3 id="完整服务文件示例"><a href="#完整服务文件示例" class="headerlink" title="完整服务文件示例"></a>完整服务文件示例</h3><p>以下是一个完整的 <code>cloudflared.service</code> 文件示例供参考：</p><figure class="highlight ini"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><code class="hljs ini"><span class="hljs-section">[Unit]</span><br><span class="hljs-attr">Description</span>=Cloudflare Tunnel<br><span class="hljs-attr">After</span>=network.target<br><br><span class="hljs-section">[Service]</span><br><span class="hljs-attr">Type</span>=simple<br><span class="hljs-attr">User</span>=root<br><span class="hljs-attr">ExecStart</span>=/usr/local/bin/cloudflared tunnel --protocol http2 run --token eyJhIjoiY2xvdWRmbGFyZS10dW5uZWwiLCJ0IjoiZXhhbXBsZS10b2tlbi1oZXJlIn0<br><span class="hljs-attr">Restart</span>=<span class="hljs-literal">on</span>-failure<br><span class="hljs-attr">RestartSec</span>=<span class="hljs-number">5</span>s<br><br><span class="hljs-section">[Install]</span><br><span class="hljs-attr">WantedBy</span>=multi-user.target<br></code></pre></td></tr></table></figure><hr><h2 id="🛠️-方法四：Docker-部署方式"><a href="#🛠️-方法四：Docker-部署方式" class="headerlink" title="🛠️ 方法四：Docker 部署方式"></a>🛠️ 方法四：Docker 部署方式</h2><p>如果你通过 Docker 容器运行 Cloudflare Tunnel，需要在启动命令中加入 <code>--protocol http2</code> 参数。</p><h3 id="Docker-Run-方式"><a href="#Docker-Run-方式" class="headerlink" title="Docker Run 方式"></a>Docker Run 方式</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><code class="hljs bash">docker run -d \<br>  --name cloudflared \<br>  --restart unless-stopped \<br>  -v ~/.cloudflared:/etc/cloudflared \<br>  cloudflare/cloudflared:latest \<br>  tunnel --protocol http2 run --token &lt;你的TUNNEL_TOKEN&gt;<br></code></pre></td></tr></table></figure><h3 id="Docker-Compose-方式（推荐）"><a href="#Docker-Compose-方式（推荐）" class="headerlink" title="Docker Compose 方式（推荐）"></a>Docker Compose 方式（推荐）</h3><p>创建 <code>docker-compose.yml</code> 文件：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">&#x27;3&#x27;</span><br><span class="hljs-attr">services:</span><br>  <span class="hljs-attr">cloudflared:</span><br>    <span class="hljs-attr">image:</span> <span class="hljs-string">cloudflare/cloudflared:latest</span><br>    <span class="hljs-attr">container_name:</span> <span class="hljs-string">cloudflared</span><br>    <span class="hljs-attr">restart:</span> <span class="hljs-string">unless-stopped</span><br>    <span class="hljs-attr">command:</span> <span class="hljs-string">tunnel</span> <span class="hljs-string">--protocol</span> <span class="hljs-string">http2</span> <span class="hljs-string">run</span> <span class="hljs-string">--token</span> <span class="hljs-string">&lt;你的TUNNEL_TOKEN&gt;</span><br>    <span class="hljs-comment"># 如果使用配置文件方式，可以挂载配置文件</span><br>    <span class="hljs-comment"># volumes:</span><br>    <span class="hljs-comment">#   - ./config.yml:/etc/cloudflared/config.yml</span><br>    <span class="hljs-comment">#   - ./tunnel-credentials.json:/etc/cloudflared/tunnel-credentials.json</span><br></code></pre></td></tr></table></figure><h3 id="启动容器"><a href="#启动容器" class="headerlink" title="启动容器"></a>启动容器</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">docker-compose up -d<br></code></pre></td></tr></table></figure><h3 id="查看日志验证"><a href="#查看日志验证" class="headerlink" title="查看日志验证"></a>查看日志验证</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">docker logs -f cloudflared<br></code></pre></td></tr></table></figure><p>确认日志中包含 <code>protocol=http2</code> 字样。</p><hr><h2 id="💡-原理解析：为什么这样就能解决问题？"><a href="#💡-原理解析：为什么这样就能解决问题？" class="headerlink" title="💡 原理解析：为什么这样就能解决问题？"></a>💡 原理解析：为什么这样就能解决问题？</h2><h3 id="默认状态（使用-QUIC，问题状态）"><a href="#默认状态（使用-QUIC，问题状态）" class="headerlink" title="默认状态（使用 QUIC，问题状态）"></a>默认状态（使用 QUIC，问题状态）</h3><figure class="highlight awk"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs awk">cloudflared ──(UDP<span class="hljs-regexp">/443)──&gt; 运营商 QoS 限制（拦截/</span>丢包） ──✗──&gt; Cloudflare 节点<br>                                    ❌ 连接不稳定/失败<br></code></pre></td></tr></table></figure><h3 id="优化后状态（使用-HTTP-2，正常状态）"><a href="#优化后状态（使用-HTTP-2，正常状态）" class="headerlink" title="优化后状态（使用 HTTP&#x2F;2，正常状态）"></a>优化后状态（使用 HTTP&#x2F;2，正常状态）</h3><figure class="highlight nginx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs nginx"><span class="hljs-attribute">cloudflared</span> ──(TCP/<span class="hljs-number">7844</span> 或 <span class="hljs-number">443</span>)──&gt; 运营商放行 ──✓──&gt; Cloudflare 节点<br>                                    ✅ 稳定连接<br></code></pre></td></tr></table></figure><h3 id="关键点说明"><a href="#关键点说明" class="headerlink" title="关键点说明"></a>关键点说明</h3><ol><li><p><strong>QUIC 基于 UDP</strong>：HTTP&#x2F;3 使用 QUIC 协议，底层是 UDP。国内运营商对 UDP（尤其是 443 端口）有严格限制。</p></li><li><p><strong>HTTP&#x2F;2 基于 TCP</strong>：当指定 <code>protocol: http2</code> 后，Cloudflare Tunnel 会改用标准的 TCP 协议（通常是 7844 端口或 443 端口的 TCP）与 Cloudflare 边缘节点建立长连接。</p></li><li><p><strong>TCP 优先级更高</strong>：TCP 协议在国内跨国网络中的优先级和稳定性远高于 UDP，从而彻底避免了因 QUIC 被墙或被限制导致的无法访问问题。</p></li></ol><hr><h2 id="🎯-进阶优化：配合优选-IP-效果更佳"><a href="#🎯-进阶优化：配合优选-IP-效果更佳" class="headerlink" title="🎯 进阶优化：配合优选 IP 效果更佳"></a>🎯 进阶优化：配合优选 IP 效果更佳</h2><p>仅仅修改为 <code>http2</code> 只能保证<strong>你的内网服务器到 Cloudflare 节点</strong>这一段是稳定的。</p><p>但<strong>中国用户到 Cloudflare 节点</strong>这一段（即公网访问段），依然可能会因为 Cloudflare 默认分配的 Anycast IP 在国内被限速。</p><h3 id="优化建议"><a href="#优化建议" class="headerlink" title="优化建议"></a>优化建议</h3><h4 id="1-在-Cloudflare-后台关闭-HTTP-3-QUIC"><a href="#1-在-Cloudflare-后台关闭-HTTP-3-QUIC" class="headerlink" title="1. 在 Cloudflare 后台关闭 HTTP&#x2F;3 (QUIC)"></a>1. 在 Cloudflare 后台关闭 HTTP&#x2F;3 (QUIC)</h4><p>这样可以确保国内用户访问你的网站时，浏览器也会强制降级到 HTTP&#x2F;2 (TCP) 访问，进一步提升稳定性。</p><p><strong>操作步骤</strong>：</p><ol><li>登录 <a href="https://dash.cloudflare.com/">Cloudflare 仪表盘</a></li><li>选择你的域名</li><li>进入 <strong>网络 (Network)</strong> 菜单</li><li>找到 <strong>HTTP&#x2F;3 (with QUIC)</strong> 开关，将其关闭</li></ol><p><img src="/../../../img/cloudflare-tunnel/network-http3-disable.png" alt="Cloudflare 网络设置"></p><h4 id="2-使用-Cloudflare-优选-IP（SaaS-域名重定向）"><a href="#2-使用-Cloudflare-优选-IP（SaaS-域名重定向）" class="headerlink" title="2. 使用 Cloudflare 优选 IP（SaaS 域名重定向）"></a>2. 使用 Cloudflare 优选 IP（SaaS 域名重定向）</h4><p>配合市面上的 Cloudflare 优选 IP 脚本，将国内用户的流量解析到国内延迟较低的 Cloudflare 边缘 IP 上。</p><p><strong>实现方式</strong>：</p><ul><li>使用 DNS 智能解析（如 Cloudflare Workers 或第三方 DNS 服务）</li><li>针对国内 IP 返回优选的 Cloudflare IP</li><li>针对海外 IP 返回默认的 Anycast IP</li></ul><p><strong>优选 IP 工具推荐</strong>：</p><ul><li><a href="https://github.com/XIU2/CloudflareSpeedTest">CloudflareSpeedTest</a></li><li><a href="https://github.com/badafans/better-cloudflare-ip">better-cloudflare-ip</a></li></ul><h4 id="3-启用-Argo-Smart-Routing（付费功能）"><a href="#3-启用-Argo-Smart-Routing（付费功能）" class="headerlink" title="3. 启用 Argo Smart Routing（付费功能）"></a>3. 启用 Argo Smart Routing（付费功能）</h4><p>Cloudflare Argo 可以智能选择最优路由路径，进一步提升跨国访问速度和稳定性。</p><p><strong>价格</strong>：$5&#x2F;月 + $0.10&#x2F;GB 流量费</p><p>对于高流量或对稳定性要求极高的业务，Argo 是值得考虑的选项。</p><hr><h2 id="📋-常见问题-FAQ"><a href="#📋-常见问题-FAQ" class="headerlink" title="📋 常见问题 FAQ"></a>📋 常见问题 FAQ</h2><h3 id="Q1-修改为-HTTP-2-会影响性能吗？"><a href="#Q1-修改为-HTTP-2-会影响性能吗？" class="headerlink" title="Q1: 修改为 HTTP&#x2F;2 会影响性能吗？"></a>Q1: 修改为 HTTP&#x2F;2 会影响性能吗？</h3><p><strong>A</strong>: 理论上 QUIC (HTTP&#x2F;3) 比 HTTP&#x2F;2 性能更好，但在国内网络环境下，由于 UDP 被限制，QUIC 反而会导致极差的体验。<strong>使用 HTTP&#x2F;2 后，虽然理论性能略低，但实际体验会大幅提升</strong>，因为稳定性是第一位的。</p><h3 id="Q2-我的-cloudflared-版本较老，支持-protocol-参数吗？"><a href="#Q2-我的-cloudflared-版本较老，支持-protocol-参数吗？" class="headerlink" title="Q2: 我的 cloudflared 版本较老，支持 --protocol 参数吗？"></a>Q2: 我的 cloudflared 版本较老，支持 <code>--protocol</code> 参数吗？</h3><p><strong>A</strong>: <code>--protocol</code> 参数在较新版本中引入，建议升级到最新版本：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># Linux/macOS</span><br>cloudflared update<br><br><span class="hljs-comment"># 或重新下载安装</span><br>wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb<br><span class="hljs-built_in">sudo</span> dpkg -i cloudflared-linux-amd64.deb<br></code></pre></td></tr></table></figure><h3 id="Q3-如何验证当前使用的协议？"><a href="#Q3-如何验证当前使用的协议？" class="headerlink" title="Q3: 如何验证当前使用的协议？"></a>Q3: 如何验证当前使用的协议？</h3><p><strong>A</strong>: 查看 cloudflared 的日志输出，搜索 <code>protocol</code> 关键字：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 查看 systemd 日志</span><br><span class="hljs-built_in">sudo</span> journalctl -u cloudflared -f | grep protocol<br><br><span class="hljs-comment"># 查看 Docker 日志</span><br>docker logs -f cloudflared | grep protocol<br></code></pre></td></tr></table></figure><p>正常情况下应该看到 <code>protocol=http2</code>。</p><h3 id="Q4-配置后仍然不稳定怎么办？"><a href="#Q4-配置后仍然不稳定怎么办？" class="headerlink" title="Q4: 配置后仍然不稳定怎么办？"></a>Q4: 配置后仍然不稳定怎么办？</h3><p><strong>A</strong>: 依次检查以下几点：</p><ol><li>确认配置已生效（查看日志中的 <code>protocol=http2</code>）</li><li>检查是否在 Cloudflare 后台关闭了 HTTP&#x2F;3</li><li>尝试使用优选 IP</li><li>检查本地网络是否稳定（排除本地网络问题）</li><li>尝试更换 Cloudflare Tunnel 的边缘节点（重启服务会自动重新连接）</li></ol><hr><h2 id="🎉-总结"><a href="#🎉-总结" class="headerlink" title="🎉 总结"></a>🎉 总结</h2><p>通过强制 Cloudflare Tunnel 使用 HTTP&#x2F;2 协议，可以有效解决国内运营商对 UDP&#x2F;QUIC 的限制问题，大幅提升连接稳定性和访问速度。</p><h3 id="核心要点"><a href="#核心要点" class="headerlink" title="核心要点"></a>核心要点</h3><p>✅ <strong>必做</strong>：在 cloudflared 配置中添加 <code>protocol: http2</code><br>✅ <strong>推荐</strong>：在 Cloudflare 后台关闭 HTTP&#x2F;3 (QUIC)<br>✅ <strong>进阶</strong>：配合优选 IP 进一步提升国内访问速度</p><p>希望这篇教程能帮助你彻底解决 Cloudflare Tunnel 在国内的连接问题！</p><hr><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ul><li><a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/">Cloudflare Tunnel 官方文档</a></li><li><a href="https://github.com/cloudflare/cloudflared">cloudflared GitHub 仓库</a></li><li><a href="https://datatracker.ietf.org/doc/html/rfc9000">QUIC 协议详解</a></li></ul>]]>
    </content>
    <id>https://blog.952405.xyz/2026/05/cloudflare-tunnel-http2-china/</id>
    <link href="https://blog.952405.xyz/2026/05/cloudflare-tunnel-http2-china/"/>
    <published>2026-05-19T06:00:00.000Z</published>
    <summary>解决 Cloudflare Tunnel 在国内连接不稳定问题：通过强制使用 HTTP/2 协议替代 QUIC，绕过运营商 UDP 限制，大幅提升内网穿透稳定性和访问速度。</summary>
    <title>Cloudflare Tunnel 国内优化指南：强制使用 HTTP/2 解决 QUIC 连接问题</title>
    <updated>2026-05-19T06:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="基础设施" scheme="https://blog.952405.xyz/categories/%E5%9F%BA%E7%A1%80%E8%AE%BE%E6%96%BD/"/>
    <category term="Docker" scheme="https://blog.952405.xyz/tags/Docker/"/>
    <category term="Cloudflare Tunnel" scheme="https://blog.952405.xyz/tags/Cloudflare-Tunnel/"/>
    <category term="PostgreSQL" scheme="https://blog.952405.xyz/tags/PostgreSQL/"/>
    <category term="SSL" scheme="https://blog.952405.xyz/tags/SSL/"/>
    <category term="Navicat" scheme="https://blog.952405.xyz/tags/Navicat/"/>
    <content>
      <![CDATA[<h2 id="背景"><a href="#背景" class="headerlink" title="背景"></a>背景</h2><p>在使用 Docker 部署 PostgreSQL 时，为了提升数据传输安全性，我们需要为数据库连接开启 SSL&#x2F;TLS 加密。</p><p>本文记录在 <strong>Docker + Cloudflare Tunnel + Navicat</strong> 环境下，为 PostgreSQL 启用 SSL 的完整过程与踩坑总结。</p><h2 id="环境说明"><a href="#环境说明" class="headerlink" title="环境说明"></a>环境说明</h2><table><thead><tr><th>组件</th><th>说明</th></tr></thead><tbody><tr><td>操作系统</td><td>Linux（Docker Host）</td></tr><tr><td>数据库</td><td>PostgreSQL 18</td></tr><tr><td>部署方式</td><td>Docker &#x2F; Portainer Stack</td></tr><tr><td>客户端</td><td>Navicat</td></tr><tr><td>使用场景</td><td>内网访问 + Cloudflare Tunnel</td></tr></tbody></table><h2 id="SSL-基本概念"><a href="#SSL-基本概念" class="headerlink" title="SSL 基本概念"></a>SSL 基本概念</h2><p>PostgreSQL 的 SSL 加密依赖两类文件：</p><ul><li><code>server.crt</code>：证书（公钥，可分发）</li><li><code>server.key</code>：私钥（必须严格保密）</li></ul><p>作用：</p><ul><li>加密客户端与数据库之间的通信</li><li>防止数据被抓包</li><li>提升连接安全性</li></ul><h2 id="生成-SSL-证书"><a href="#生成-SSL-证书" class="headerlink" title="生成 SSL 证书"></a>生成 SSL 证书</h2><h3 id="创建目录"><a href="#创建目录" class="headerlink" title="创建目录"></a>创建目录</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">mkdir</span> -p /home/safone/data/postgresql/ssl<br><span class="hljs-built_in">cd</span> /home/safone/data/postgresql/ssl<br></code></pre></td></tr></table></figure><h3 id="生成证书"><a href="#生成证书" class="headerlink" title="生成证书"></a>生成证书</h3><p>使用 OpenSSL 生成自签名证书：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs bash">openssl req -new -x509 -days 365 -nodes \<br>  -out server.crt \<br>  -keyout server.key<br></code></pre></td></tr></table></figure><h3 id="设置权限（非常重要）"><a href="#设置权限（非常重要）" class="headerlink" title="设置权限（非常重要）"></a>设置权限（非常重要）</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">sudo</span> <span class="hljs-built_in">chown</span> 999:999 /home/safone/data/postgresql/ssl/server.key<br><span class="hljs-built_in">chmod</span> 600 server.key<br><span class="hljs-built_in">chmod</span> 644 server.crt<br></code></pre></td></tr></table></figure><blockquote><p><strong>注意</strong>：</p><ul><li><code>chown 999:999</code>：Docker 容器中 PostgreSQL 以 <code>postgres</code> 用户（UID 999）运行，宿主机上的私钥文件必须归属该用户，否则容器内无法读取</li><li><code>chmod 600</code>：如果私钥权限过于宽松（group 或 world 可读），PostgreSQL 会拒绝启动，这是安全机制的一部分</li></ul></blockquote><h2 id="Docker-配置-PostgreSQL-SSL"><a href="#Docker-配置-PostgreSQL-SSL" class="headerlink" title="Docker 配置 PostgreSQL SSL"></a>Docker 配置 PostgreSQL SSL</h2><p>修改 <code>docker-compose</code> &#x2F; Portainer Stack：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">&quot;3.8&quot;</span><br><br><span class="hljs-attr">services:</span><br>  <span class="hljs-attr">postgres:</span><br>    <span class="hljs-attr">image:</span> <span class="hljs-string">postgres:18</span><br>    <span class="hljs-attr">container_name:</span> <span class="hljs-string">postgres</span><br>    <span class="hljs-attr">restart:</span> <span class="hljs-string">unless-stopped</span><br><br>    <span class="hljs-attr">environment:</span><br>      <span class="hljs-attr">POSTGRES_USER:</span> <span class="hljs-string">admin</span><br>      <span class="hljs-attr">POSTGRES_PASSWORD:</span> <span class="hljs-string">&quot;your_password&quot;</span><br>      <span class="hljs-attr">POSTGRES_DB:</span> <span class="hljs-string">iding</span><br><br>    <span class="hljs-attr">ports:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">&quot;5432:5432&quot;</span><br><br>    <span class="hljs-attr">volumes:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">/home/safone/data/postgresql:/var/lib/postgresql</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">/home/safone/data/postgresql/ssl/server.crt:/var/lib/postgresql/server.crt</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">/home/safone/data/postgresql/ssl/server.key:/var/lib/postgresql/server.key</span><br><br>    <span class="hljs-attr">command:</span> <span class="hljs-string">&gt;</span><br><span class="hljs-string">      postgres</span><br><span class="hljs-string">      -c ssl=on</span><br><span class="hljs-string">      -c ssl_cert_file=/var/lib/postgresql/server.crt</span><br><span class="hljs-string">      -c ssl_key_file=/var/lib/postgresql/server.key</span><br></code></pre></td></tr></table></figure><p>关键点：</p><ul><li>证书和私钥通过 <strong>volume 挂载</strong> 注入容器</li><li><code>command</code> 中的 <code>-c ssl=on</code> 显式开启 SSL</li><li><code>ssl_cert_file</code> 和 <code>ssl_key_file</code> 指向容器内的挂载路径</li></ul><h2 id="启动与验证"><a href="#启动与验证" class="headerlink" title="启动与验证"></a>启动与验证</h2><h3 id="重启容器"><a href="#重启容器" class="headerlink" title="重启容器"></a>重启容器</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">docker compose up -d<br></code></pre></td></tr></table></figure><h3 id="检查-SSL-是否开启"><a href="#检查-SSL-是否开启" class="headerlink" title="检查 SSL 是否开启"></a>检查 SSL 是否开启</h3><p>进入容器：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">docker <span class="hljs-built_in">exec</span> -it postgres psql -U admin -d iding<br></code></pre></td></tr></table></figure><p>执行：</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs sql"><span class="hljs-keyword">SHOW</span> ssl;<br></code></pre></td></tr></table></figure><p>返回 <code>on</code> 表示 SSL 已开启成功。</p><h2 id="Navicat-连接配置"><a href="#Navicat-连接配置" class="headerlink" title="Navicat 连接配置"></a>Navicat 连接配置</h2><h3 id="基本配置"><a href="#基本配置" class="headerlink" title="基本配置"></a>基本配置</h3><table><thead><tr><th>字段</th><th>值</th></tr></thead><tbody><tr><td>Host</td><td>服务器 IP</td></tr><tr><td>Port</td><td>5432</td></tr><tr><td>User</td><td>admin</td></tr><tr><td>Password</td><td>your_password</td></tr></tbody></table><h3 id="SSL-配置"><a href="#SSL-配置" class="headerlink" title="SSL 配置"></a>SSL 配置</h3><p>在 Navicat 的 SSL 选项卡中：</p><ul><li><strong>SSL Mode</strong>：选择 <code>require</code></li></ul><p>或根据需要选择更严格的模式：</p><ul><li><code>verify-ca</code>：需要客户端验证 CA 证书</li><li><code>verify-full</code>：验证证书 + 主机名</li></ul><h2 id="证书分发说明"><a href="#证书分发说明" class="headerlink" title="证书分发说明"></a>证书分发说明</h2><table><thead><tr><th>文件</th><th>是否可分发</th><th>说明</th></tr></thead><tbody><tr><td><code>server.crt</code></td><td>✅ 可以</td><td>公钥证书，可发送给客户端</td></tr><tr><td><code>server.key</code></td><td>❌ 禁止</td><td>私钥，<strong>绝对不能泄露</strong></td></tr></tbody></table><h2 id="常见问题（踩坑总结）"><a href="#常见问题（踩坑总结）" class="headerlink" title="常见问题（踩坑总结）"></a>常见问题（踩坑总结）</h2><h3 id="启动失败：找不到证书"><a href="#启动失败：找不到证书" class="headerlink" title="启动失败：找不到证书"></a>启动失败：找不到证书</h3><p><strong>错误信息</strong>：</p><figure class="highlight livecodeserver"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs livecodeserver">could <span class="hljs-keyword">not</span> <span class="hljs-built_in">load</span> server certificate <span class="hljs-built_in">file</span> <span class="hljs-string">&quot;server.crt&quot;</span><br></code></pre></td></tr></table></figure><p><strong>原因</strong>：</p><ul><li>Docker 未正确挂载证书文件</li><li><code>command</code> 中配置的路径与实际挂载路径不一致</li></ul><p><strong>解决</strong>：检查 <code>volumes</code> 挂载和 <code>ssl_cert_file</code> 路径是否一致。</p><h3 id="权限错误"><a href="#权限错误" class="headerlink" title="权限错误"></a>权限错误</h3><p><strong>错误信息</strong>：</p><figure class="highlight vbnet"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs vbnet"><span class="hljs-symbol">FATAL:</span> <span class="hljs-keyword">private</span> <span class="hljs-keyword">key</span> file has <span class="hljs-keyword">group</span> <span class="hljs-built_in">or</span> world access<br></code></pre></td></tr></table></figure><p><strong>解决</strong>：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">chmod</span> 600 server.key<br></code></pre></td></tr></table></figure><p>PostgreSQL 强制要求私钥文件权限为 <code>600</code>，否则拒绝启动。</p><h3 id="Navicat-无法连接"><a href="#Navicat-无法连接" class="headerlink" title="Navicat 无法连接"></a>Navicat 无法连接</h3><p><strong>可能原因</strong>：</p><ul><li>SSL mode 不匹配（服务端开启了 SSL，客户端却未启用）</li><li>自签名证书未在客户端信任</li></ul><p><strong>解决</strong>：在 Navicat 中将 SSL Mode 设为 <code>require</code>（不验证证书），或导入证书后使用 <code>verify-ca</code>。</p><h2 id="架构建议"><a href="#架构建议" class="headerlink" title="架构建议"></a>架构建议</h2><p>在实际生产或小型项目中，更推荐以下方案：</p><h3 id="方案-A（推荐）"><a href="#方案-A（推荐）" class="headerlink" title="方案 A（推荐）"></a>方案 A（推荐）</h3><ul><li>PostgreSQL <strong>不对公网开放</strong></li><li>使用 SSH 隧道访问数据库</li><li>数据库仅监听 <code>127.0.0.1</code> 或内网 IP</li></ul><h3 id="方案-B"><a href="#方案-B" class="headerlink" title="方案 B"></a>方案 B</h3><ul><li>API 层访问数据库</li><li>数据库仅内网通信</li><li>应用层处理鉴权与加密</li></ul><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>PostgreSQL SSL 的几个要点：</p><ol><li><strong>本质是传输加密</strong>，而非身份认证系统</li><li><strong>Docker 环境需要手动挂载证书</strong>，注意路径和权限</li><li>自签名证书足以满足内网加密需求</li><li>对于个人服务器或小工具系统，SSL 并非必须，但<strong>公网访问场景下强烈建议开启</strong></li><li>更安全的做法是 <strong>不暴露数据库端口到公网</strong>，通过 SSH 隧道或内网访问</li></ol>]]>
    </content>
    <id>https://blog.952405.xyz/2026/05/postgresql-ssl-docker/</id>
    <link href="https://blog.952405.xyz/2026/05/postgresql-ssl-docker/"/>
    <published>2026-05-11T07:00:00.000Z</published>
    <summary>Docker 环境下配置 PostgreSQL SSL/TLS 加密连接：证书生成、配置优化、客户端连接验证。</summary>
    <title>PostgreSQL 开启 SSL（Docker 环境完整实践记录）</title>
    <updated>2026-05-11T07:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="基础设施" scheme="https://blog.952405.xyz/categories/%E5%9F%BA%E7%A1%80%E8%AE%BE%E6%96%BD/"/>
    <category term="Ubuntu" scheme="https://blog.952405.xyz/tags/Ubuntu/"/>
    <category term="ext4" scheme="https://blog.952405.xyz/tags/ext4/"/>
    <category term="Windows" scheme="https://blog.952405.xyz/tags/Windows/"/>
    <category term="WSL2" scheme="https://blog.952405.xyz/tags/WSL2/"/>
    <content>
      <![CDATA[<h2 id="问题背景"><a href="#问题背景" class="headerlink" title="问题背景"></a>问题背景</h2><p>WSL2 的 Linux 发行版数据存储在 Windows 宿主机的一个 <code>.vhdx</code> 虚拟磁盘文件中（通常位于 <code>D:\WSL\&lt;发行版名&gt;\ext4.vhdx</code>）。即使 WSL2 默认使用稀疏文件（Sparse VHDX），这个文件依然存在一个很”坑”的特性：</p><p><strong>虚拟磁盘只会动态增长，不会自动缩小。</strong></p><p>举个例子：你在 Ubuntu 里编译了一个大项目，产生了 20G 的临时文件，<code>ext4.vhdx</code> 膨胀到了 25G。之后你把那些临时文件删了，Ubuntu 里 <code>df -h</code> 显示只用了 5G，但在 Windows 文件资源管理器里，<code>ext4.vhdx</code> 依然占据 25G 的硬盘空间。</p><h2 id="为什么会这样？"><a href="#为什么会这样？" class="headerlink" title="为什么会这样？"></a>为什么会这样？</h2><p>WSL2 的虚拟磁盘本质上是一个稀疏分配的 VHDX 文件。稀疏分配（Sparse）意味着文件实际大小 &#x3D; 实际写入过的数据量，而不是虚拟磁盘的容量上限。</p><p>但问题在于：<strong>WSL2 只做了”标记空间为可用”这件事，并没有通知 Windows 宿主机回收这部分空闲区域。</strong> 对于宿主机来说，那些曾经被写入过、后来被 Linux 标记为”已删除”的区块，仍然是”有效数据”。</p><p>解决思路也很直接：用工具对虚拟磁盘执行一次 <strong>Compact（压缩）</strong> 操作，把内部已标记为空闲的区块真正释放回宿主机。</p><h2 id="准备工作"><a href="#准备工作" class="headerlink" title="准备工作"></a>准备工作</h2><blockquote><p>⚠️ 操作前请确保 WSL 中的重要工作已经保存。</p></blockquote><h3 id="第一步：彻底关闭-WSL"><a href="#第一步：彻底关闭-WSL" class="headerlink" title="第一步：彻底关闭 WSL"></a>第一步：彻底关闭 WSL</h3><p>压缩操作要求虚拟磁盘文件不被任何进程占用，因此必须先关闭所有 WSL 实例。</p><p>打开 <strong>Windows PowerShell</strong>（建议以管理员身份运行），执行：</p><figure class="highlight powershell"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs powershell">wsl <span class="hljs-literal">--shutdown</span><br></code></pre></td></tr></table></figure><p>确认所有实例已停止：</p><figure class="highlight powershell"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs powershell">wsl <span class="hljs-literal">--list</span> <span class="hljs-literal">--verbose</span><br></code></pre></td></tr></table></figure><p>输出中所有发行版的 <code>STATE</code> 都应为 <code>Stopped</code>。</p><h2 id="核心步骤：diskpart-压缩"><a href="#核心步骤：diskpart-压缩" class="headerlink" title="核心步骤：diskpart 压缩"></a>核心步骤：diskpart 压缩</h2><p><code>diskpart</code> 是 Windows 自带的磁盘管理命令行工具，无需安装任何第三方软件。</p><p>在 PowerShell 中输入 <code>diskpart</code> 进入交互模式：</p><figure class="highlight powershell"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs powershell">diskpart<br></code></pre></td></tr></table></figure><p>你会看到提示符变为 <code>DISKPART&gt;</code>，然后按顺序执行以下命令：</p><h3 id="1-选择虚拟磁盘文件"><a href="#1-选择虚拟磁盘文件" class="headerlink" title="1. 选择虚拟磁盘文件"></a>1. 选择虚拟磁盘文件</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">DISKPART&gt; select vdisk file=&quot;D:\WSL\Ubuntu2404\ext4.vhdx&quot;<br></code></pre></td></tr></table></figure><blockquote><p>路径请替换为你自己的实际路径。如果不确定路径，可以用 <code>wsl --list --verbose</code> 查看发行版名称，然后去对应的安装目录下找 <code>.vhdx</code> 文件。</p></blockquote><p>选择成功后，diskpart 会提示已选择虚拟磁盘文件。</p><h3 id="2-以只读模式挂载"><a href="#2-以只读模式挂载" class="headerlink" title="2. 以只读模式挂载"></a>2. 以只读模式挂载</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">DISKPART&gt; attach vdisk readonly<br></code></pre></td></tr></table></figure><p>只读挂载是压缩前的必要步骤，它让 diskpart 能够读取磁盘内部的空闲空间信息，同时又不会修改任何数据。</p><h3 id="3-执行压缩"><a href="#3-执行压缩" class="headerlink" title="3. 执行压缩"></a>3. 执行压缩</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">DISKPART&gt; compact vdisk<br></code></pre></td></tr></table></figure><p>这是核心步骤——diskpart 会分析虚拟磁盘内部哪些区块是真正被占用的、哪些是空闲的，然后把空闲区块从宿主机文件中释放。<strong>执行时间取决于虚拟磁盘大小和硬盘速度</strong>，通常在几秒到几分钟之间。</p><p>压缩进度和结果会直接显示在当前窗口中。</p><h3 id="4-分离并退出"><a href="#4-分离并退出" class="headerlink" title="4. 分离并退出"></a>4. 分离并退出</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">DISKPART&gt; detach vdisk<br>DISKPART&gt; exit<br></code></pre></td></tr></table></figure><p>至此，压缩完成！</p><h2 id="验证结果"><a href="#验证结果" class="headerlink" title="验证结果"></a>验证结果</h2><p>回到 <code>.vhdx</code> 文件所在的目录查看大小：</p><figure class="highlight powershell"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs powershell"><span class="hljs-built_in">Get-ChildItem</span> <span class="hljs-string">&quot;D:\WSL\Ubuntu2404\ext4.vhdx&quot;</span> | <span class="hljs-built_in">Select-Object</span> Name, <span class="hljs-selector-tag">@</span>&#123;Name=<span class="hljs-string">&quot;Size(GB)&quot;</span>;Expression=&#123;[<span class="hljs-type">math</span>]::Round(<span class="hljs-variable">$_</span>.Length/<span class="hljs-number">1</span>GB,<span class="hljs-number">2</span>)&#125;&#125;<br></code></pre></td></tr></table></figure><p>或者在文件资源管理器中右键查看属性，你会发现文件大小已经有了明显缩水，硬盘空间真正被释放了。</p><h2 id="一键脚本"><a href="#一键脚本" class="headerlink" title="一键脚本"></a>一键脚本</h2><p>如果你不想每次都手动输入 diskpart 命令，可以把上述步骤写成一个批处理脚本 <code>compact-wsl.ps1</code>：</p><figure class="highlight powershell"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><code class="hljs powershell"><span class="hljs-comment"># compact-wsl.ps1</span><br><span class="hljs-comment"># 使用前请先保存 WSL 中的工作，然后以管理员身份运行</span><br><br><span class="hljs-keyword">param</span>(<br>    [<span class="hljs-built_in">string</span>]<span class="hljs-variable">$VhdxPath</span> = <span class="hljs-string">&quot;D:\WSL\Ubuntu2404\ext4.vhdx&quot;</span><br>)<br><br><span class="hljs-built_in">Write-Host</span> <span class="hljs-string">&quot;正在关闭 WSL...&quot;</span> <span class="hljs-literal">-ForegroundColor</span> Yellow<br>wsl <span class="hljs-literal">--shutdown</span><br><span class="hljs-built_in">Start-Sleep</span> <span class="hljs-literal">-Seconds</span> <span class="hljs-number">3</span><br><br><span class="hljs-built_in">Write-Host</span> <span class="hljs-string">&quot;开始压缩虚拟磁盘: <span class="hljs-variable">$VhdxPath</span>&quot;</span> <span class="hljs-literal">-ForegroundColor</span> Yellow<br><br><span class="hljs-variable">$script</span> = <span class="hljs-string">@&quot;</span><br><span class="hljs-string">select vdisk file=&quot;<span class="hljs-variable">$VhdxPath</span>&quot;</span><br><span class="hljs-string">attach vdisk readonly</span><br><span class="hljs-string">compact vdisk</span><br><span class="hljs-string">detach vdisk</span><br><span class="hljs-string">exit</span><br><span class="hljs-string">&quot;@</span><br><br><span class="hljs-variable">$script</span> | diskpart<br><br><span class="hljs-built_in">Write-Host</span> <span class="hljs-string">&quot;压缩完成！&quot;</span> <span class="hljs-literal">-ForegroundColor</span> Green<br><br><span class="hljs-comment"># 显示压缩后大小</span><br><span class="hljs-built_in">Get-ChildItem</span> <span class="hljs-variable">$VhdxPath</span> | <span class="hljs-built_in">ForEach-Object</span> &#123;<br>    <span class="hljs-variable">$sizeGB</span> = [<span class="hljs-type">math</span>]::Round(<span class="hljs-variable">$_</span>.Length / <span class="hljs-number">1</span>GB, <span class="hljs-number">2</span>)<br>    <span class="hljs-built_in">Write-Host</span> <span class="hljs-string">&quot;当前虚拟磁盘大小: <span class="hljs-variable">$</span>&#123;sizeGB&#125; GB&quot;</span> <span class="hljs-literal">-ForegroundColor</span> Green<br>&#125;<br></code></pre></td></tr></table></figure><p>以后只需要右键以管理员身份运行这个脚本即可。</p><h2 id="常见问题"><a href="#常见问题" class="headerlink" title="常见问题"></a>常见问题</h2><h3 id="Q-压缩会影响-WSL-里的数据吗？"><a href="#Q-压缩会影响-WSL-里的数据吗？" class="headerlink" title="Q: 压缩会影响 WSL 里的数据吗？"></a>Q: 压缩会影响 WSL 里的数据吗？</h3><p>不会。<code>compact vdisk</code> 只是释放已经被 Linux 标记为空闲的空间，不会触碰任何有效数据。而且我们是以 <code>readonly</code> 模式挂载的，对原数据零风险。</p><h3 id="Q-需要多久操作一次？"><a href="#Q-需要多久操作一次？" class="headerlink" title="Q: 需要多久操作一次？"></a>Q: 需要多久操作一次？</h3><p>不需要固定周期。当你发现 WSL 里删了大量文件但 Windows 硬盘空间没有明显释放时，做一次压缩就好。</p><h3 id="Q-操作失败提示”文件被占用”？"><a href="#Q-操作失败提示”文件被占用”？" class="headerlink" title="Q: 操作失败提示”文件被占用”？"></a>Q: 操作失败提示”文件被占用”？</h3><p>确保执行了 <code>wsl --shutdown</code>。如果仍然提示被占用，重启一次 Windows 后再试。</p><h3 id="Q-能不能让-WSL2-自动回收空间？"><a href="#Q-能不能让-WSL2-自动回收空间？" class="headerlink" title="Q: 能不能让 WSL2 自动回收空间？"></a>Q: 能不能让 WSL2 自动回收空间？</h3><p>目前 WSL2 本身没有内置的自动压缩机制。不过微软在较新的 WSL 版本中加入了 <code>[wsl2] sparseVhd=true</code> 的 <code>.wslconfig</code> 配置项（默认已启用），这只是保证新数据稀疏写入，并不能回收已膨胀的空间。手动 compact 仍是唯一可靠的方式。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>WSL2 虚拟磁盘膨胀是个老生常谈的问题，好在解决起来非常简单：</p><ol><li><code>wsl --shutdown</code> 关闭 WSL</li><li><code>diskpart</code> → <code>compact vdisk</code> 压缩虚拟磁盘</li><li>硬盘空间回来了 🎉</li></ol><p>建议收藏本文或把上面的 PowerShell 脚本存下来，以备不时之需。</p>]]>
    </content>
    <id>https://blog.952405.xyz/2026/05/wsl2-vhdx-compact/</id>
    <link href="https://blog.952405.xyz/2026/05/wsl2-vhdx-compact/"/>
    <published>2026-05-07T08:00:00.000Z</published>
    <summary>WSL2 的 ext4.vhdx 虚拟磁盘只会膨胀不会自动缩小，即使删除了文件，Windows 硬盘空间也不会释放。本文介绍使用 diskpart 对虚拟磁盘进行压缩的完整步骤，真正回收被占用的空间。</summary>
    <title>WSL2 虚拟磁盘压缩瘦身指南（diskpart 一键回收空间）</title>
    <updated>2026-05-07T08:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="基础设施" scheme="https://blog.952405.xyz/categories/%E5%9F%BA%E7%A1%80%E8%AE%BE%E6%96%BD/"/>
    <category term="Cloudflare" scheme="https://blog.952405.xyz/tags/Cloudflare/"/>
    <category term="Docker" scheme="https://blog.952405.xyz/tags/Docker/"/>
    <category term="Cloudflare Tunnel" scheme="https://blog.952405.xyz/tags/Cloudflare-Tunnel/"/>
    <category term="Docker Swarm" scheme="https://blog.952405.xyz/tags/Docker-Swarm/"/>
    <category term="DevOps" scheme="https://blog.952405.xyz/tags/DevOps/"/>
    <content>
      <![CDATA[<h2 id="引言"><a href="#引言" class="headerlink" title="引言"></a>引言</h2><p>在利用 Docker Swarm 部署微服务时，很多初学者常常会遇到集群网络错综复杂、容器间通信延迟高、以及宿主机端口暴露过多的安全隐患。</p><p>本文将以 <strong>Portainer（集群可视化面板）</strong> 的安装为例，带大家构建一个符合生产标准的 Docker Swarm 网络架构。我们将通过引入自定义 Overlay 全局网络，让 Portainer、Cloudflare Tunnel 穿透服务以及你的业务容器（如 Gitea、Redis、Postgres）全内网极速互通，并彻底收紧宿主机端口。</p><h2 id="一、核心架构设计"><a href="#一、核心架构设计" class="headerlink" title="一、核心架构设计"></a>一、核心架构设计</h2><p>在传统的单机 <code>docker-compose</code> 中，我们习惯于使用 <code>network_mode: &quot;host&quot;</code> 或通过宿主机 IP + 端口进行容器间转发。但在 Docker Swarm 集群模式下，这种做法不仅性能损耗大，还会带来宿主机 IP 变动导致穿透断开的风险。</p><p>标准的 Swarm 架构应当建立清晰的网络隔离边界：</p><ul><li><p><strong><code>portainer_agent_network</code>（管理专属私道）</strong>：专供 Portainer Server 与各节点 Agent 进行高特权集群管理通信，与业务完全隔离。</p></li><li><p><strong><code>iding-net</code>（业务&#x2F;穿透专用专线）</strong>：我们手动创建的全局 Overlay 网络。Cloudflare Tunnel 和你的所有业务容器（Portainer、Gitea 等）都插在这根网线上，通过服务名（Service Name）直接在内存中高速交换数据。</p></li></ul><h2 id="二、基础准备：创建全局-Overlay-网络"><a href="#二、基础准备：创建全局-Overlay-网络" class="headerlink" title="二、基础准备：创建全局 Overlay 网络"></a>二、基础准备：创建全局 Overlay 网络</h2><p>在开始部署任何服务之前，我们需要先在 Swarm 管理节点（Manager）上，手动创建一个支持容器直接挂载的全局覆盖网络：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">sudo</span> docker network create --driver=overlay --attachable iding-net<br></code></pre></td></tr></table></figure><ul><li><code>--driver=overlay</code>：允许跨多台服务器节点进行容器间通信。</li><li><code>--attachable</code>：允许独立的常规容器或不同 Stack 的服务自由加入该网络。</li></ul><h2 id="三、在-Swarm-中安装与部署-Portainer"><a href="#三、在-Swarm-中安装与部署-Portainer" class="headerlink" title="三、在 Swarm 中安装与部署 Portainer"></a>三、在 Swarm 中安装与部署 Portainer</h2><p>Portainer 在 Swarm 中通常以 Stack 形式部署，包含一个处于 Manager 节点的 Server，和分布在每个节点的 Agent。</p><h3 id="1-编写配置文件-portanier-yml"><a href="#1-编写配置文件-portanier-yml" class="headerlink" title="1. 编写配置文件 portanier.yml"></a>1. 编写配置文件 <code>portanier.yml</code></h3><p>创建并编辑 <code>portanier.yml</code>，我们将 Portainer 引入双网卡设计：一根线连 <code>agent_network</code> 用于集群管理，一根线连 <code>iding-net</code> 用于内网穿透。</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">&#x27;3.8&#x27;</span><br><br><span class="hljs-attr">services:</span><br>  <span class="hljs-attr">agent:</span><br>    <span class="hljs-attr">image:</span> <span class="hljs-string">portainer/agent:lts</span><br>    <span class="hljs-attr">environment:</span><br>      <span class="hljs-comment"># 汇报当前节点上的常规容器状态</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">AGENT_CLUSTER_ADDR=tasks.agent</span><br>    <span class="hljs-attr">volumes:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">/var/run/docker.sock:/var/run/docker.sock</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">/var/lib/docker/volumes:/var/lib/docker/volumes</span><br>    <span class="hljs-attr">networks:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">agent_network</span><br>    <span class="hljs-attr">deploy:</span><br>      <span class="hljs-attr">mode:</span> <span class="hljs-string">global</span> <span class="hljs-comment"># 确保集群中每台机器都自动运行一个 Agent</span><br>      <span class="hljs-attr">restart_policy:</span><br>        <span class="hljs-attr">condition:</span> <span class="hljs-string">on-failure</span><br>      <span class="hljs-attr">update_config:</span><br>        <span class="hljs-attr">delay:</span> <span class="hljs-string">10s</span><br>        <span class="hljs-attr">order:</span> <span class="hljs-string">start-first</span><br><br>  <span class="hljs-attr">portainer:</span><br>    <span class="hljs-attr">image:</span> <span class="hljs-string">portainer/portainer-ce:lts</span><br>    <span class="hljs-attr">command:</span> <span class="hljs-string">-H</span> <span class="hljs-string">tcp://tasks.agent:9001</span> <span class="hljs-string">--tlsskipverify</span><br>    <span class="hljs-attr">environment:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">TZ=Asia/Shanghai</span><br><br>    <span class="hljs-comment"># ⚠️ 提示：当外网穿透测试成功后，下面这三行 ports 映射可以直接删掉/注释掉</span><br>    <span class="hljs-attr">ports:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">&quot;9443:9443&quot;</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">&quot;9000:9000&quot;</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">&quot;8000:8000&quot;</span><br><br>    <span class="hljs-attr">volumes:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">portainer_data:/data</span><br><br>    <span class="hljs-attr">networks:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">agent_network</span>  <span class="hljs-comment"># 保持原样，用于管理集群</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">iding-net</span>      <span class="hljs-comment"># ➕ 关键：加入穿透网络，让穿透容器能找到它</span><br><br>    <span class="hljs-attr">deploy:</span><br>      <span class="hljs-attr">mode:</span> <span class="hljs-string">replicated</span><br>      <span class="hljs-attr">replicas:</span> <span class="hljs-number">1</span><br>      <span class="hljs-attr">placement:</span><br>        <span class="hljs-attr">constraints:</span><br>          <span class="hljs-bullet">-</span> <span class="hljs-string">node.role</span> <span class="hljs-string">==</span> <span class="hljs-string">manager</span> <span class="hljs-comment"># 强绑定在管理节点</span><br><br><span class="hljs-attr">networks:</span><br>  <span class="hljs-attr">agent_network:</span><br>    <span class="hljs-attr">driver:</span> <span class="hljs-string">overlay</span><br>    <span class="hljs-attr">attachable:</span> <span class="hljs-literal">true</span><br><br>  <span class="hljs-comment"># ========================================================</span><br>  <span class="hljs-comment"># 引入我们提前手动创建好的外部全局网络</span><br>  <span class="hljs-comment"># ========================================================</span><br>  <span class="hljs-attr">iding-net:</span><br>    <span class="hljs-attr">external:</span> <span class="hljs-literal">true</span><br><br><span class="hljs-attr">volumes:</span><br>  <span class="hljs-attr">portainer_data:</span><br></code></pre></td></tr></table></figure><h3 id="2-使用-Stack-部署上线"><a href="#2-使用-Stack-部署上线" class="headerlink" title="2. 使用 Stack 部署上线"></a>2. 使用 Stack 部署上线</h3><p>在终端执行以下命令将 Portainer 部署到集群中，Stack 名称指定为 <code>portainer</code>：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">sudo</span> docker stack deploy -c portanier.yml portainer<br></code></pre></td></tr></table></figure><h3 id="3-获取-Setup-Token-完成初始化"><a href="#3-获取-Setup-Token-完成初始化" class="headerlink" title="3. 获取 Setup Token 完成初始化"></a>3. 获取 Setup Token 完成初始化</h3><p>由于我们使用 <code>docker stack deploy</code> 方式部署，首次访问 Portainer 需要 Setup Token 来创建管理员账户。在终端运行以下命令查看 Portainer 服务日志：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">sudo</span> docker service logs portainer_portainer<br></code></pre></td></tr></table></figure><p>在日志输出中，你会看到类似下面这样的一段提示：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs plaintext">xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx<br>Portainer is running in initial setup mode.<br>Please use the following setup token to authorize the creation of the first administrator user:<br><br>st-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx<br><br>This token will expire in 30 minutes.<br>xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx<br></code></pre></td></tr></table></figure><p>操作步骤：</p><ol><li>复制日志中以 <code>st-</code> 开头的长字符串（即 Setup Token）</li><li>回到浏览器，将其粘贴到网页的 <strong>Setup token</strong> 输入框中</li><li>设定你自己的用户名和密码（密码至少 12 位）</li><li>点击创建，即可成功进入 Portainer 管理面板</li></ol><blockquote><p>⚠️ 注意：该 Token 有效期为 30 分钟。如果过期，只需运行 <code>sudo docker service update --force portainer_portainer</code> 强制重启服务，新 Token 会重新生成。</p></blockquote><h2 id="四、实现-Cloudflare-Tunnel-极速内网穿透"><a href="#四、实现-Cloudflare-Tunnel-极速内网穿透" class="headerlink" title="四、实现 Cloudflare Tunnel 极速内网穿透"></a>四、实现 Cloudflare Tunnel 极速内网穿透</h2><p>当 Portainer 接入 <code>iding-net</code> 后，你的 <code>cloudflare-tunnel</code> 容器也应当同步加入该网络。</p><h3 id="1-优化后的-cloudflared-部署配置"><a href="#1-优化后的-cloudflared-部署配置" class="headerlink" title="1. 优化后的 cloudflared 部署配置"></a>1. 优化后的 cloudflared 部署配置</h3><p>放弃传统的 <code>network_mode: &quot;host&quot;</code>，改为全内网标准互通：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">&#x27;3.8&#x27;</span><br><br><span class="hljs-attr">services:</span><br>  <span class="hljs-attr">cloudflare-tunnel:</span><br>    <span class="hljs-attr">image:</span> <span class="hljs-string">cloudflare/cloudflared:latest</span><br>    <span class="hljs-attr">deploy:</span><br>      <span class="hljs-attr">restart_policy:</span><br>        <span class="hljs-attr">condition:</span> <span class="hljs-string">on-failure</span><br>    <span class="hljs-attr">update_config:</span><br>      <span class="hljs-attr">delay:</span> <span class="hljs-string">10s</span><br>      <span class="hljs-attr">order:</span> <span class="hljs-string">start-first</span><br>    <span class="hljs-attr">entrypoint:</span> [<span class="hljs-string">&quot;cloudflared&quot;</span>, <span class="hljs-string">&quot;tunnel&quot;</span>]<br>    <span class="hljs-attr">command:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">&quot;run&quot;</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">&quot;--token&quot;</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">&quot;YOUR_CLOUDFLARE_TUNNEL_TOKEN&quot;</span> <span class="hljs-comment"># 填入你的真实 Token</span><br><br>    <span class="hljs-attr">environment:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">TUNNEL_METRICS=0.0.0.0:2000</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">TCP_KEEPALIVE=30s</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">NO_AUTOUPDATE=true</span><br><br>    <span class="hljs-attr">networks:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">iding-net</span> <span class="hljs-comment"># 接入统一的穿透网络</span><br><br><span class="hljs-attr">networks:</span><br>  <span class="hljs-attr">iding-net:</span><br>    <span class="hljs-attr">external:</span> <span class="hljs-literal">true</span><br></code></pre></td></tr></table></figure><h3 id="2-Cloudflare-Zero-Trust-后台配置"><a href="#2-Cloudflare-Zero-Trust-后台配置" class="headerlink" title="2. Cloudflare Zero Trust 后台配置"></a>2. Cloudflare Zero Trust 后台配置</h3><p>由于 <code>cloudflared</code> 和 <code>portainer</code> 都在同一个 <code>iding-net</code> 网络中，在 Cloudflare 网页后台配置 Public Hostnames 时，内网 URL 填写的逻辑将变得极其优雅和安全：</p><ul><li><strong>Portainer 穿透</strong>：<code>http://portainer_portainer:9000</code>（格式为：<code>http://Stack名_服务名:内部端口</code>）</li><li><strong>Gitea 穿透</strong>：<code>http://gitea_gitea:3000</code></li><li><strong>Redis 内部调用</strong>：在 Gitea 或其他业务配置中，直接填写 <code>redis_redis:6379</code></li></ul><h2 id="五、全内网架构的终极优势"><a href="#五、全内网架构的终极优势" class="headerlink" title="五、全内网架构的终极优势"></a>五、全内网架构的终极优势</h2><p>相比于传统的宿主机 IP 映射转发，这种”全内网”设计带来了质的飞跃：</p><h3 id="速度大幅提升（延迟降低-30-50-）"><a href="#速度大幅提升（延迟降低-30-50-）" class="headerlink" title="速度大幅提升（延迟降低 30% ~ 50%）"></a>速度大幅提升（延迟降低 30% ~ 50%）</h3><p>流量完全在 Docker 内存的内核态（VxLAN 隧道）直接复制和封装，绕过了宿主机的物理网卡、Loopback 协议栈及 iptables 规则匹配，容器间 Ping 值通常能从 1.5ms 压缩至 0.2ms。</p><h3 id="免受宿主机-IP-变动影响"><a href="#免受宿主机-IP-变动影响" class="headerlink" title="免受宿主机 IP 变动影响"></a>免受宿主机 IP 变动影响</h3><p>不论宿主机的局域网 IP 怎么因重启或 DHCP 改变，Docker 内部的 DNS 永远能精准根据服务名找到目标容器，穿透坚如磐石。</p><h3 id="安全性拉满（零端口暴露）"><a href="#安全性拉满（零端口暴露）" class="headerlink" title="安全性拉满（零端口暴露）"></a>安全性拉满（零端口暴露）</h3><p>外网测通后，你可以安全地把 Portainer、Redis、Postgres 中的 <code>ports</code> 模块全部删掉。此时你的服务器在局域网内处于”隐身状态”，完全免疫局域网端口扫描，只有通过你授权的 Cloudflare 域名才能安全访问！</p><h2 id="六、常用运维命令小结"><a href="#六、常用运维命令小结" class="headerlink" title="六、常用运维命令小结"></a>六、常用运维命令小结</h2><p>查看当前运行的所有服务状态：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">sudo</span> docker service <span class="hljs-built_in">ls</span><br></code></pre></td></tr></table></figure><p>强行滚动重启某个 Swarm 服务（不影响业务）：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">sudo</span> docker service update --force portainer_portainer<br></code></pre></td></tr></table></figure><p>检查容器连入网络的虚拟网线（veth）数量：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">ip a | grep veth<br></code></pre></td></tr></table></figure><blockquote><p>注：每个联网容器都会在宿主机生成一根虚拟网线，数量等于容器总数是完全正常的底层表现。</p></blockquote><hr><p>现在，你的 Docker Swarm 集群已经拥有了最标准、健康的”大楼电梯与暗道”网络结构。赶快尝试一下，享受零端口暴露带来的极致安全与内网飙车的快感吧！</p>]]>
    </content>
    <id>https://blog.952405.xyz/2026/05/docker-swarm-portainer-cloudflare/</id>
    <link href="https://blog.952405.xyz/2026/05/docker-swarm-portainer-cloudflare/"/>
    <published>2026-05-02T05:16:00.000Z</published>
    <summary>在 Docker Swarm 集群中告别宿主机端口暴露和容器间通信延迟高的困扰。本文以 Portainer + Cloudflare Tunnel 为例，手把手教你构建基于自定义 Overlay 网络的全内网穿透架构，实现零端口暴露与极速服务互通。</summary>
    <title>Docker Swarm 最佳实践：从零构建安全高效的 Portainer 与全内网穿透架构</title>
    <updated>2026-05-02T05:16:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="AI 应用" scheme="https://blog.952405.xyz/categories/AI-%E5%BA%94%E7%94%A8/"/>
    <category term="Gemini" scheme="https://blog.952405.xyz/tags/Gemini/"/>
    <category term="AI" scheme="https://blog.952405.xyz/tags/AI/"/>
    <category term="Cloudflare" scheme="https://blog.952405.xyz/tags/Cloudflare/"/>
    <category term="Proxy" scheme="https://blog.952405.xyz/tags/Proxy/"/>
    <content>
      <![CDATA[<p>在使用 Gemini 时可能会遇到以下提示：</p><ul><li><code>gemini 发生错误 请稍后再试。了解详情</code></li><li><code>gemini please try again later error status unavailable</code></li><li><code>gemini something went wrong try again later. learn more</code></li><li><code>gemini 你所在的国家和地区不可用</code></li><li><code>gemini 你所在的國家/地區目前不支援 gemini。請密切留意後續消息</code></li></ul><h2 id="无法使用-Gemini-的可能原因"><a href="#无法使用-Gemini-的可能原因" class="headerlink" title="无法使用 Gemini 的可能原因"></a>无法使用 Gemini 的可能原因</h2><h3 id="1-年龄限制"><a href="#1-年龄限制" class="headerlink" title="1. 年龄限制"></a>1. 年龄限制</h3><p>建议年满 18 周岁才能使用工作帐户访问 Gemini。可以前往 <a href="https://myaccount.google.com/personal-info?gar=WzJd&hl=zh_CN&utm_source=OGB&utm_medium=act">Google 账号信息页面</a> 查看，或参考 <a href="https://policies.google.com/terms?hl=zh">Google 服务条款</a> 了解详细规定。</p><h3 id="2-地区不支持"><a href="#2-地区不支持" class="headerlink" title="2. 地区不支持"></a>2. 地区不支持</h3><p>Gemini 目前并非在所有国家和地区都可用。可以查询 <a href="https://support.google.com/gemini/answer/13575153?hl=zh">Gemini 支持的国家和地区</a> 确认是否在支持列表中。</p><p>如果不在支持范围内，可以尝试 <a href="https://policies.google.com/country-association-form">更改 Google 账号的国家或地区设置</a>。</p><h2 id="创建你的-Gem"><a href="#创建你的-Gem" class="headerlink" title="创建你的 Gem"></a>创建你的 Gem</h2><p>除了直接使用 Gemini，你还可以创建自定义的 Gem 来满足特定需求。</p><h3 id="操作步骤"><a href="#操作步骤" class="headerlink" title="操作步骤"></a>操作步骤</h3><ol><li>打开 Gemini，点击左侧菜单中的 <strong><a href="https://gemini.google.com/gems/create?hl=zh,li3">“创建你的 Gem”</a></strong></li><li>填写以下信息：</li></ol><table><thead><tr><th>字段</th><th>内容</th></tr></thead><tbody><tr><td><strong>Name</strong></td><td>每天工作</td></tr><tr><td><strong>Description</strong></td><td>把每天所想的内容有条理的梳理出来</td></tr><tr><td><strong>Instructions</strong></td><td>见下方</td></tr></tbody></table><h3 id="Instructions-配置"><a href="#Instructions-配置" class="headerlink" title="Instructions 配置"></a>Instructions 配置</h3><figure class="highlight"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><code class="hljs">角色扮演：你是一个内容整理师AI<br><br>前景描述：我每天散步的时候，会用苹果手机自带的录音转文字的功能，<br>用口述的方式，把每天学到的内容整理一遍，中间可能会存在一些啰嗦或者重复的内容；<br><br>最终目的：你需要把这些内容重新整理一遍，方便我阅读和使用；<br><br>功能要点：我会经常给你发送内容，你需要按照时间线的方式，<br>整理出来，方便我日后按照时间线定位查阅。<br></code></pre></td></tr></table></figure><h3 id="使用建议"><a href="#使用建议" class="headerlink" title="使用建议"></a>使用建议</h3><ul><li>每次发送内容时，可以标注大致的时间点（如”2026年5月12日 下午”）</li><li>Gem 会自动根据时间线归类整理</li><li>定期回顾已整理的内容，方便复盘和检索</li></ul><h2 id="写在最后"><a href="#写在最后" class="headerlink" title="写在最后"></a>写在最后</h2><p>Gemini 作为 Google 推出的 AI 助手，在部分地区存在访问限制是常见问题。如果上述方法仍无法解决，也可以考虑使用其他替代方案，如 Claude、ChatGPT 等。</p>]]>
    </content>
    <id>https://blog.952405.xyz/2026/04/gemini-unavailable-solutions/</id>
    <link href="https://blog.952405.xyz/2026/04/gemini-unavailable-solutions/"/>
    <published>2026-04-30T02:00:00.000Z</published>
    <summary>解决 Google Gemini API 在国内无法访问问题的多种方案：Cloudflare Workers 反向代理、第三方中转服务等。</summary>
    <title>Gemini 不可用的解决方法</title>
    <updated>2026-04-30T02:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="AI 应用" scheme="https://blog.952405.xyz/categories/AI-%E5%BA%94%E7%94%A8/"/>
    <category term="GPU" scheme="https://blog.952405.xyz/tags/GPU/"/>
    <category term="GPUStack" scheme="https://blog.952405.xyz/tags/GPUStack/"/>
    <category term="vLLM" scheme="https://blog.952405.xyz/tags/vLLM/"/>
    <category term="LLM" scheme="https://blog.952405.xyz/tags/LLM/"/>
    <category term="Docker Compose" scheme="https://blog.952405.xyz/tags/Docker-Compose/"/>
    <content>
      <![CDATA[<h2 id="概述"><a href="#概述" class="headerlink" title="概述"></a>概述</h2><p>在实际生产环境中，GPUStack 内置的 vLLM 版本可能无法及时适配最新的模型。例如某些新模型需要 <code>transformers 5.5.0</code> 以上版本以及 <code>vllm[audio]</code> 依赖，而官方镜像尚未包含这些依赖。</p><p>本文介绍如何通过 Docker Compose + 自定义 Dockerfile 的方式，快速构建适配 <code>vllm/vllm-openai:v0.19.0</code> 的推理镜像并部署。</p><h2 id="构建自定义镜像"><a href="#构建自定义镜像" class="headerlink" title="构建自定义镜像"></a>构建自定义镜像</h2><h3 id="Dockerfile"><a href="#Dockerfile" class="headerlink" title="Dockerfile"></a>Dockerfile</h3><p>使用国内镜像源加速拉取 vLLM 官方镜像：</p><figure class="highlight dockerfile"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs dockerfile"><span class="hljs-keyword">FROM</span> docker.<span class="hljs-number">1</span>ms.run/vllm/vllm-openai:v0.<span class="hljs-number">19.0</span><br><br><span class="hljs-keyword">RUN</span><span class="language-bash"> uv pip install --system vllm[audio] \</span><br><span class="language-bash">  &amp;&amp; uv pip install --system transformers==5.5.0</span><br></code></pre></td></tr></table></figure><blockquote><p>这里使用 <code>docker.1ms.run</code> 作为 Docker Hub 的国内镜像代理，解决网络访问问题。如果你可以直接访问 Docker Hub，将 <code>FROM</code> 改为 <code>vllm/vllm-openai:v0.19.0</code> 即可。</p></blockquote><h3 id="构建命令"><a href="#构建命令" class="headerlink" title="构建命令"></a>构建命令</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">docker build -t vllm/vllm-openai:v0.19.0-custom .<br></code></pre></td></tr></table></figure><p>构建完成后，本地会生成一个包含 <code>vllm[audio]</code> 和 <code>transformers==5.5.0</code> 的推理引擎镜像。</p><h2 id="Docker-Compose-部署"><a href="#Docker-Compose-部署" class="headerlink" title="Docker Compose 部署"></a>Docker Compose 部署</h2><h3 id="docker-compose-yml"><a href="#docker-compose-yml" class="headerlink" title="docker-compose.yml"></a>docker-compose.yml</h3><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">services:</span><br>  <span class="hljs-attr">vllm:</span><br>    <span class="hljs-attr">image:</span> <span class="hljs-string">vllm/vllm-openai:v0.19.0-custom</span><br>    <span class="hljs-attr">container_name:</span> <span class="hljs-string">vllm-server</span><br>    <span class="hljs-attr">restart:</span> <span class="hljs-string">unless-stopped</span><br>    <span class="hljs-attr">ports:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">&quot;8000:8000&quot;</span><br>    <span class="hljs-attr">volumes:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">./models:/models</span><br>    <span class="hljs-attr">environment:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">HF_HOME=/models</span><br>    <span class="hljs-attr">deploy:</span><br>      <span class="hljs-attr">resources:</span><br>        <span class="hljs-attr">reservations:</span><br>          <span class="hljs-attr">devices:</span><br>            <span class="hljs-bullet">-</span> <span class="hljs-attr">driver:</span> <span class="hljs-string">nvidia</span><br>              <span class="hljs-attr">count:</span> <span class="hljs-string">all</span><br>              <span class="hljs-attr">capabilities:</span> [<span class="hljs-string">gpu</span>]<br>    <span class="hljs-attr">command:</span> <span class="hljs-string">&gt;-</span><br><span class="hljs-string">      vllm serve /models/your-model-name</span><br><span class="hljs-string">      --host 0.0.0.0</span><br><span class="hljs-string">      --port 8000</span><br><span class="hljs-string">      --served-model-name your-model-name</span><br></code></pre></td></tr></table></figure><h3 id="启动服务"><a href="#启动服务" class="headerlink" title="启动服务"></a>启动服务</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">docker compose up -d<br></code></pre></td></tr></table></figure><h3 id="验证服务"><a href="#验证服务" class="headerlink" title="验证服务"></a>验证服务</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 检查容器状态</span><br>docker compose ps<br><br><span class="hljs-comment"># 查看日志</span><br>docker compose logs -f vllm<br><br><span class="hljs-comment"># 测试 API</span><br>curl http://localhost:8000/v1/models<br></code></pre></td></tr></table></figure><h2 id="对接-GPUStack"><a href="#对接-GPUStack" class="headerlink" title="对接 GPUStack"></a>对接 GPUStack</h2><p>如果要将这个自定义镜像用于 GPUStack，需要在 GPUStack 的推理后端中添加自定义版本：</p><h3 id="方式一：UI-界面配置"><a href="#方式一：UI-界面配置" class="headerlink" title="方式一：UI 界面配置"></a>方式一：UI 界面配置</h3><p>在推理后端菜单中，编辑 vLLM，添加新版本：</p><table><thead><tr><th>配置项</th><th>值</th></tr></thead><tbody><tr><td>版本</td><td>0.19.0-custom</td></tr><tr><td>镜像名称</td><td>vllm&#x2F;vllm-openai:v0.19.0-custom</td></tr><tr><td>框架</td><td>CUDA</td></tr><tr><td>覆盖镜像入口命令（ENTRYPOINT）</td><td><code>vllm serve</code></td></tr><tr><td>执行命令</td><td><code>&#123;&#123;model_path&#125;&#125; --host &#123;&#123;worker_ip&#125;&#125; --port &#123;&#123;port&#125;&#125; --served-model-name &#123;&#123;model_name&#125;&#125;</code></td></tr></tbody></table><h3 id="方式二：YAML-模式导入"><a href="#方式二：YAML-模式导入" class="headerlink" title="方式二：YAML 模式导入"></a>方式二：YAML 模式导入</h3><p>如果需要同时保留其他自定义版本，可以在 YAML 模式下统一导入：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">backend_name:</span> <span class="hljs-string">vLLM</span><br><span class="hljs-attr">version_configs:</span><br>  <span class="hljs-attr">0.19.0-custom:</span><br>    <span class="hljs-attr">image_name:</span> <span class="hljs-string">vllm/vllm-openai:v0.19.0-custom</span><br>    <span class="hljs-attr">entrypoint:</span> <span class="hljs-string">vllm</span> <span class="hljs-string">serve</span><br>    <span class="hljs-attr">run_command:</span> <span class="hljs-string">&gt;-</span><br><span class="hljs-string">      &#123;&#123;model_path&#125;&#125; --host &#123;&#123;worker_ip&#125;&#125; --port &#123;&#123;port&#125;&#125; --served-model-name</span><br><span class="hljs-string">      &#123;&#123;model_name&#125;&#125;</span><br><span class="hljs-string"></span>    <span class="hljs-attr">env:</span> &#123;&#125;<br>    <span class="hljs-attr">custom_framework:</span> <span class="hljs-string">cuda</span><br></code></pre></td></tr></table></figure><blockquote><p><strong>注意</strong>：如果已有其他自定义版本，需要将所有版本一并写入 <code>version_configs</code>，否则导入后会覆盖旧版本配置。</p></blockquote><h2 id="国内镜像源说明"><a href="#国内镜像源说明" class="headerlink" title="国内镜像源说明"></a>国内镜像源说明</h2><p>构建过程中使用的 <code>docker.1ms.run</code> 是一个 Docker Hub 镜像代理服务，适用于国内网络环境。常见的 Docker Hub 国内镜像源包括：</p><table><thead><tr><th>镜像源</th><th>格式示例</th></tr></thead><tbody><tr><td>docker.1ms.run</td><td><code>docker.1ms.run/vllm/vllm-openai:v0.19.0</code></td></tr><tr><td>阿里云（需配置）</td><td><code>registry.cn-hangzhou.aliyuncs.com/...</code></td></tr><tr><td>直接拉取（需科学上网）</td><td><code>vllm/vllm-openai:v0.19.0</code></td></tr></tbody></table><p>如果 Worker 节点无法直接访问 Docker Hub，可以提前拉取镜像并重新 tag：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash">docker pull docker.1ms.run/vllm/vllm-openai:v0.19.0<br>docker tag docker.1ms.run/vllm/vllm-openai:v0.19.0 vllm/vllm-openai:v0.19.0<br></code></pre></td></tr></table></figure><h2 id="注意事项"><a href="#注意事项" class="headerlink" title="注意事项"></a>注意事项</h2><ol><li><strong>依赖兼容性</strong>：<code>transformers==5.5.0</code> 与 <code>vllm[audio]</code> 需要匹配 vLLM 的对应版本，升级前建议查阅 vLLM 的 release notes 确认兼容性。</li><li><strong>GPU 驱动</strong>：确保 Worker 节点已正确安装 NVIDIA 驱动和 Container Toolkit。</li><li><strong>镜像推送</strong>：如果是多节点部署，需要将构建好的镜像推送到 Worker 节点可访问的私有仓库。</li><li><strong>模板变量</strong>：GPUStack 配置中的 <code>&#123;&#123;&#125;&#125;</code> 模板变量保持不变，运行时会自动替换为实际值。</li></ol><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>通过 Dockerfile 自定义构建 + Docker Compose 部署的组合，可以快速适配 vLLM 新版本所需的依赖，解决官方镜像尚未更新时的兼容性问题。核心步骤就是三步：</p><ol><li>编写 Dockerfile，在官方镜像基础上安装额外依赖</li><li>构建自定义镜像</li><li>在 GPUStack 中添加自定义推理后端版本</li></ol>]]>
    </content>
    <id>https://blog.952405.xyz/2026/04/gpustack-custom-transformers-vllm/</id>
    <link href="https://blog.952405.xyz/2026/04/gpustack-custom-transformers-vllm/"/>
    <published>2026-04-17T07:00:00.000Z</published>
    <summary>在 GPUStack 中添加自定义 Transformers 和 vLLM 版本，解决模型兼容性问题，支持最新模型部署。</summary>
    <title>GPUStack 使用 Docker Compose 自定义 vLLM 镜像升级 Transformers</title>
    <updated>2026-04-17T07:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="网络与代理" scheme="https://blog.952405.xyz/categories/%E7%BD%91%E7%BB%9C%E4%B8%8E%E4%BB%A3%E7%90%86/"/>
    <category term="SSH" scheme="https://blog.952405.xyz/tags/SSH/"/>
    <category term="Linux" scheme="https://blog.952405.xyz/tags/Linux/"/>
    <category term="OpenWrt" scheme="https://blog.952405.xyz/tags/OpenWrt/"/>
    <category term="Homelab" scheme="https://blog.952405.xyz/tags/Homelab/"/>
    <content>
      <![CDATA[<p>刚刷好 OpenWrt 或者新部署了一台软路由，有两项基础配置建议第一时间搞定：<strong>时区</strong> 和 <strong>主机名</strong>。</p><p>时区不对会导致日志时间错乱、定时任务跑偏；主机名不改的话，路由器列表里全是默认的 <code>OpenWrt</code>，设备多了根本分不清谁是谁。</p><p>下面分别介绍这两种修改的 <strong>Web 网页端（LuCI）</strong> 和 <strong>SSH 命令行</strong> 操作方法，选你觉得顺手的方式就行。</p><h2 id="⏰-修改时区"><a href="#⏰-修改时区" class="headerlink" title="⏰ 修改时区"></a>⏰ 修改时区</h2><h3 id="方案一：Web-网页端"><a href="#方案一：Web-网页端" class="headerlink" title="方案一：Web 网页端"></a>方案一：Web 网页端</h3><ol><li>打开并登录 OpenWrt 网页后台（LuCI）</li><li>左侧菜单依次点击：<strong>系统 (System) → 系统 (System)</strong></li><li>在”常规设置”区域找到 <strong>时区 (Timezone)</strong> 下拉框</li><li>国内用户选择 <code>Asia/Shanghai</code>（对应 UTC+8）</li><li>点击右下角 <strong>保存并应用 (Save &amp; Apply)</strong></li></ol><p><img src="/" alt="时区设置示意"></p><blockquote><p>💡 如果你发现时区列表里选项太多不好找，直接在浏览器页面按 <code>Ctrl+F</code> 搜索 <code>Shanghai</code> 即可快速定位。</p></blockquote><h3 id="方案二：SSH-命令行"><a href="#方案二：SSH-命令行" class="headerlink" title="方案二：SSH 命令行"></a>方案二：SSH 命令行</h3><p>SSH 连上 OpenWrt 后，两条命令搞定：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 1. 设置时区为 Asia/Shanghai（UTC+8）</span><br>uci <span class="hljs-built_in">set</span> system.@system[0].zonename=<span class="hljs-string">&#x27;Asia/Shanghai&#x27;</span><br>uci <span class="hljs-built_in">set</span> system.@system[0].timezone=<span class="hljs-string">&#x27;CST-8&#x27;</span><br><br><span class="hljs-comment"># 2. 提交并重启系统服务</span><br>uci commit system &amp;&amp; /etc/init.d/system restart<br></code></pre></td></tr></table></figure><p>执行完后可以用 <code>date</code> 命令验证当前时间是否已经变成北京时间：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">date</span><br><span class="hljs-comment"># 输出示例: Sat Jul  4 18:30:00 CST 2026</span><br></code></pre></td></tr></table></figure><h2 id="🏷️-修改主机名"><a href="#🏷️-修改主机名" class="headerlink" title="🏷️ 修改主机名"></a>🏷️ 修改主机名</h2><h3 id="方案一：Web-网页端（最直观）"><a href="#方案一：Web-网页端（最直观）" class="headerlink" title="方案一：Web 网页端（最直观）"></a>方案一：Web 网页端（最直观）</h3><ol><li>打开并登录 OpenWrt 网页后台（LuCI）</li><li>左侧菜单依次点击：<strong>系统 (System) → 系统 (System)</strong></li><li>在”常规设置”区域找到 <strong>主机名 (Hostname)</strong> 输入框（默认通常是 <code>OpenWrt</code>）</li><li>改成你想要的名字（建议使用英文、数字或连字符 <code>-</code>，<strong>不建议用中文</strong>）</li><li>点击右下角 <strong>保存并应用 (Save &amp; Apply)</strong></li></ol><p><img src="/" alt="主机名设置示意"></p><p>修改完成后，浏览器页面的标题栏就会显示新的主机名。</p><h3 id="方案二：SSH-命令行（最快）"><a href="#方案二：SSH-命令行（最快）" class="headerlink" title="方案二：SSH 命令行（最快）"></a>方案二：SSH 命令行（最快）</h3><p>如果你习惯敲命令，SSH 连上后直接复制运行下面两行，一秒搞定：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 1. 使用 uci 工具直接修改系统主机名（把 &quot;My-OpenWrt&quot; 换成你想要的名字）</span><br>uci <span class="hljs-built_in">set</span> system.@system[0].hostname=<span class="hljs-string">&#x27;My-OpenWrt&#x27;</span><br><br><span class="hljs-comment"># 2. 提交并应用修改</span><br>uci commit system &amp;&amp; /etc/init.d/system restart<br></code></pre></td></tr></table></figure><h3 id="💡-小提示"><a href="#💡-小提示" class="headerlink" title="💡 小提示"></a>💡 小提示</h3><p>修改完成后，终端左下角的提示符（比如 <code>root@OpenWrt:~#</code>）可能不会立刻刷新。这时候只需要输入 <code>exit</code> 退出当前 SSH 连接，重新连进来，就能看到它已经变成 <code>root@你改的名字:~#</code> 了！</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">exit</span><br>ssh root@你的新主机名    <span class="hljs-comment"># 或者直接用 IP 连接</span><br></code></pre></td></tr></table></figure><h2 id="📋-总结"><a href="#📋-总结" class="headerlink" title="📋 总结"></a>📋 总结</h2><table><thead><tr><th>配置项</th><th>Web 路径</th><th>SSH 命令</th></tr></thead><tbody><tr><td>时区</td><td>系统 → 系统 → 时区</td><td><code>uci set system.@system[0].zonename=&#39;Asia/Shanghai&#39;</code></td></tr><tr><td>主机名</td><td>系统 → 系统 → 主机名</td><td><code>uci set system.@system[0].hostname=&#39;新名称&#39;</code></td></tr></tbody></table><p>两条配置共用同一个提交命令：<code>uci commit system &amp;&amp; /etc/init.d/system restart</code>，可以改完一起提交，不用分两次重启服务。</p><p>这两项配置虽然简单，但属于软路由到手后的”必做项”，花一分钟设好，后面使用体验会舒服很多。</p>]]>
    </content>
    <id>https://blog.952405.xyz/2026/04/openwrt-timezone-hostname/</id>
    <link href="https://blog.952405.xyz/2026/04/openwrt-timezone-hostname/"/>
    <published>2026-04-10T03:43:00.000Z</published>
    <summary>在 OpenWrt 系统中，正确设置时区和主机名是基础但重要的配置步骤。本文介绍通过 LuCI 网页后台和 SSH 命令行两种方式快速完成时区与主机名的修改。</summary>
    <title>OpenWrt 修改时区与主机名称的两种方式（Web 网页端 + SSH 命令行）</title>
    <updated>2026-04-10T03:43:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="基础设施" scheme="https://blog.952405.xyz/categories/%E5%9F%BA%E7%A1%80%E8%AE%BE%E6%96%BD/"/>
    <category term="Linux" scheme="https://blog.952405.xyz/tags/Linux/"/>
    <category term="Nix" scheme="https://blog.952405.xyz/tags/Nix/"/>
    <category term="WSL2" scheme="https://blog.952405.xyz/tags/WSL2/"/>
    <content>
      <![CDATA[<h2 id="Nix-在-WSL2-中的安装"><a href="#Nix-在-WSL2-中的安装" class="headerlink" title="Nix 在 WSL2 中的安装"></a>Nix 在 WSL2 中的安装</h2><p>之前在 WSL2 中安装 Nix 需要打补丁、处理各种兼容性问题，社区也长期讨论 WSL2 支持。现在好消息来了：<strong>新版本的 Nix 安装脚本在 WSL2 上和 Linux 完全一致，无需额外处理</strong>。</p><h3 id="安装-direnv"><a href="#安装-direnv" class="headerlink" title="安装 direnv"></a>安装 direnv</h3><p>direnv 是 Nix 的好搭档，可以根据目录自动切换环境。</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 安装 direnv</span><br><span class="hljs-built_in">sudo</span> apt update &amp;&amp; <span class="hljs-built_in">sudo</span> apt install direnv -y<br><br><span class="hljs-comment"># 挂载到 Zsh（其他 shell 替换 zsh 即可）</span><br><span class="hljs-built_in">echo</span> <span class="hljs-string">&#x27;eval &quot;$(direnv hook zsh)&quot;&#x27;</span> &gt;&gt; ~/.zshrc<br><br><span class="hljs-comment"># 挂载到 Bash</span><br><span class="hljs-built_in">echo</span> <span class="hljs-string">&#x27;eval &quot;$(direnv hook bash)&quot;&#x27;</span> &gt;&gt; ~/.bashrc<br><br><span class="hljs-comment"># 使配置生效</span><br><span class="hljs-built_in">source</span> ~/.zshrc<br><span class="hljs-built_in">source</span> ~/.bashrc<br></code></pre></td></tr></table></figure><h3 id="安装-Nix"><a href="#安装-Nix" class="headerlink" title="安装 Nix"></a>安装 Nix</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">sh &lt;(curl --proto <span class="hljs-string">&#x27;=https&#x27;</span> --tlsv1.2 -L https://nixos.org/nix/install) --daemon<br></code></pre></td></tr></table></figure><p>这条命令会自动检测系统环境，WSL2 会被识别为 Linux，直接走标准安装流程。<code>--daemon</code> 参数会使用 systemd 风格的 daemon 方式运行 Nix，这是目前推荐的安装方式。</p><h3 id="安装过程"><a href="#安装过程" class="headerlink" title="安装过程"></a>安装过程</h3><ol><li>脚本会提示确认安装，按回车继续或 <code>Ctrl+C</code> 取消</li><li>自动创建 nixbld 用户组和用户</li><li>配置 systemd 服务（或 equivalent）</li><li>完成后提示重新加载 shell 或重启终端</li></ol><h3 id="验证安装"><a href="#验证安装" class="headerlink" title="验证安装"></a>验证安装</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 检查 Nix 版本</span><br>nix --version<br><br><span class="hljs-comment"># 测试安装包</span><br>nix-env --version<br><br><span class="hljs-comment"># 安装一个简单的包测试</span><br>nix-env -iA nixpkgs.hello<br>hello<br></code></pre></td></tr></table></figure><h3 id="常见问题"><a href="#常见问题" class="headerlink" title="常见问题"></a>常见问题</h3><p><strong>Q: 安装脚本没反应？</strong><br>检查网络连接，确保能访问 <code>nixos.org</code>。也可以加 <code>-v</code> 参数查看详细日志。</p><p><strong>Q: 提示权限错误？</strong><br>确保以普通用户运行，脚本会自动处理 sudo 提权，不需要手动 <code>sudo</code>。</p><p><strong>Q: 提示 experimental-features 需要手动开启？</strong><br>服务器上新装 Nix 后默认不开启 flakes，需要手动配置：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">mkdir</span> -p ~/.config/nix<br><span class="hljs-built_in">echo</span> <span class="hljs-string">&#x27;experimental-features = flakes nix-command&#x27;</span> &gt;&gt; ~/.config/nix/nix.conf<br></code></pre></td></tr></table></figure><p>重启 shell 后即可正常使用 <code>nix</code> 命令和 flakes 相关功能。</p><p><strong>Q: 卸载重装？</strong><br>参考官方文档清理残留文件后重新运行安装命令即可。</p><h3 id="写在最后"><a href="#写在最后" class="headerlink" title="写在最后"></a>写在最后</h3><p>Nix 是一个声明式、可复现的包管理器，支持多版本共存，非常适合开发环境管理。WSL2 支持的完善让在 Windows 上使用 Nix 变得更加简单，感兴趣的可以试试。</p>]]>
    </content>
    <id>https://blog.952405.xyz/2026/04/nix-installation-wsl2/</id>
    <link href="https://blog.952405.xyz/2026/04/nix-installation-wsl2/"/>
    <published>2026-04-10T02:00:00.000Z</published>
    <summary>在 WSL2 环境下安装和配置 Nix 包管理器：系统初始化、flakes 配置、常见问题解决。</summary>
    <title>Nix 在 WSL2 中的安装</title>
    <updated>2026-04-10T02:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="网络与代理" scheme="https://blog.952405.xyz/categories/%E7%BD%91%E7%BB%9C%E4%B8%8E%E4%BB%A3%E7%90%86/"/>
    <category term="Linux" scheme="https://blog.952405.xyz/tags/Linux/"/>
    <category term="OpenWrt" scheme="https://blog.952405.xyz/tags/OpenWrt/"/>
    <category term="PVE" scheme="https://blog.952405.xyz/tags/PVE/"/>
    <category term="LVM" scheme="https://blog.952405.xyz/tags/LVM/"/>
    <category term="ext4" scheme="https://blog.952405.xyz/tags/ext4/"/>
    <content>
      <![CDATA[<h2 id="PVE-虚拟机-OpenWrt-无损扩容终极教程（LVM-Thin-架构）"><a href="#PVE-虚拟机-OpenWrt-无损扩容终极教程（LVM-Thin-架构）" class="headerlink" title="PVE 虚拟机 OpenWrt 无损扩容终极教程（LVM-Thin 架构）"></a>PVE 虚拟机 OpenWrt 无损扩容终极教程（LVM-Thin 架构）</h2><p>PVE 中跑 OpenWrt 一段时间后，装了几个插件、跑了一些日志，突然发现根目录 <code>overlay</code> 吃满了。这时候你会想：去网页端把磁盘拉大，然后进系统跑个 <code>resize2fs</code> 不就完了？</p><p>结果一敲命令：</p><figure class="highlight vim"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs vim">resize2fs: Invalid <span class="hljs-keyword">argument</span> <span class="hljs-keyword">while</span> trying <span class="hljs-keyword">to</span> <span class="hljs-keyword">resize</span> /dev/root<br></code></pre></td></tr></table></figure><p><strong>PVE 虚拟机 OpenWrt (LVM-Thin 架构) 终极无损扩容教程</strong>，建议收藏。以后碰到任何嵌入式 Linux 固件扩容，直接用这套”外部物理碾压法”，100% 成功。</p><h3 id="📝-为什么不能在-OpenWrt-内部直接扩容？"><a href="#📝-为什么不能在-OpenWrt-内部直接扩容？" class="headerlink" title="📝 为什么不能在 OpenWrt 内部直接扩容？"></a>📝 为什么不能在 OpenWrt 内部直接扩容？</h3><h4 id="内核死锁"><a href="#内核死锁" class="headerlink" title="内核死锁"></a>内核死锁</h4><p>OpenWrt 运行时，根目录 <code>/dev/root</code> 被内核死死咬住，无法在线修改。即使用 <code>mount -o remount,ro /</code> 重新挂载为只读也是不行的 —— rootfs 一旦挂载就锁死了。</p><h4 id="特性阉割"><a href="#特性阉割" class="headerlink" title="特性阉割"></a>特性阉割</h4><p>固件编译时通常关闭了 <code>resize_inode</code> 特性。即使你想尽办法把分区表改了，<code>resize2fs</code> 也会直接报错 <code>Invalid argument</code>：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash">resize2fs 1.47.0 (5-Feb-2023)<br>resize2fs: Invalid argument <span class="hljs-keyword">while</span> trying to resize /dev/root<br></code></pre></td></tr></table></figure><p>根本原因：固件开发者为了缩小编译产物体积，在 <code>make menuconfig</code> 时把 ext4 的在线扩容特性给关掉了。</p><h4 id="环境残缺"><a href="#环境残缺" class="headerlink" title="环境残缺"></a>环境残缺</h4><p>即便进入安全模式（Failsafe），也会因为没有挂载原盘而导致找不到 <code>resize2fs</code> 命令 —— 安全模式跑的是 ramdisk，rootfs 根本没挂上。</p><h3 id="🛠️-终极降维打击：PVE-宿主机外部离线扩容法"><a href="#🛠️-终极降维打击：PVE-宿主机外部离线扩容法" class="headerlink" title="🛠️ 终极降维打击：PVE 宿主机外部离线扩容法"></a>🛠️ 终极降维打击：PVE 宿主机外部离线扩容法</h3><p>这是最优雅、最暴力的解法：<strong>直接让 OpenWrt 关机，利用 PVE 宿主机的完美工具链，从外部强行把空间灌进去。</strong></p><h4 id="前提条件"><a href="#前提条件" class="headerlink" title="前提条件"></a>前提条件</h4><p>在 PVE 网页端 → OpenWrt 虚拟机 → 硬件 中，<strong>已经手动把硬盘大小在线扩展到了你想要的大小</strong>（例如从 1G 拉到 4G）。</p><p>操作路径：选中 <code>scsi0</code> 磁盘 → 点击上方的 <strong>调整大小 (Resize disk)</strong> → 输入目标大小增量（例如 <code>+3G</code>）。</p><h3 id="第一步：让-OpenWrt-虚拟机彻底断电"><a href="#第一步：让-OpenWrt-虚拟机彻底断电" class="headerlink" title="第一步：让 OpenWrt 虚拟机彻底断电"></a>第一步：让 OpenWrt 虚拟机彻底断电</h3><p>在 OpenWrt 终端输入 <code>poweroff</code>，或者直接在 PVE 网页端对该虚拟机点击 <strong>停止 (Stop)</strong>。</p><blockquote><p>⚠️ <strong>核心逻辑</strong>：必须关机，释放文件系统所有的内核锁。在线扩容在此不适用。</p></blockquote><h3 id="第二步：登录-PVE-宿主机的-Shell-终端"><a href="#第二步：登录-PVE-宿主机的-Shell-终端" class="headerlink" title="第二步：登录 PVE 宿主机的 Shell 终端"></a>第二步：登录 PVE 宿主机的 Shell 终端</h3><p>注意：是 <strong>PVE 节点自身的 Shell</strong>（输入 <code>qm importdisk</code> 的那个大后台），不是虚拟机的控制台。</p><p>依次复制并运行以下命令：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 1. 临时加载 NBD (Network Block Device) 内核模块</span><br>modprobe nbd<br><br><span class="hljs-comment"># 2. 建立 LVM-Thin 虚拟磁盘与宿主机的物理映射</span><br><span class="hljs-comment"># 【注意】将 105 替换为你实际的虚拟机 ID；如果你的盘不是 scsi0 而是 scsi1，对应修改末尾的 disk-X</span><br>qemu-nbd -f raw --connect=/dev/nbd0 /dev/pve/vm-105-disk-1<br><br><span class="hljs-comment"># 3. 强行修复并清理虚拟磁盘 2 号分区（即 OpenWrt 的根目录 rootfs）的位图错误</span><br>e2fsck -f -y /dev/nbd0p2<br><br><span class="hljs-comment"># 4. 把 ext4 文件系统彻底拉满到你刚才在 PVE 分配的物理边界</span><br>resize2fs /dev/nbd0p2<br><br><span class="hljs-comment"># 5. 安全断开映射，释放设备锁</span><br>qemu-nbd --disconnect /dev/nbd0<br></code></pre></td></tr></table></figure><blockquote><p>💡 <strong>命令解读</strong>：</p><ul><li><code>modprobe nbd</code>：加载内核的 NBD 模块，让宿主机能把虚拟磁盘”映射”成一个本地块设备。</li><li><code>qemu-nbd --connect</code>：把 <code>/dev/pve/vm-105-disk-1</code> 这个 LVM-Thin 卷暴露为 <code>/dev/nbd0</code>，之后 <code>/dev/nbd0p2</code> 就是 OpenWrt 的 rootfs 分区。</li><li><code>e2fsck -f -y</code>：强制检查并自动修复文件系统错误。<code>resize2fs</code> 要求文件系统是”干净”的，这一步不能省。</li><li><code>resize2fs</code>：将 ext4 文件系统扩展到分区边界。PVE 侧已经扩了磁盘，但文件系统还不知道，这一步就是”通知”文件系统。</li><li><code>qemu-nbd --disconnect</code>：断开映射，否则虚拟机会因为设备锁而无法启动。</li></ul></blockquote><h3 id="第三步：开机验收"><a href="#第三步：开机验收" class="headerlink" title="第三步：开机验收"></a>第三步：开机验收</h3><p>回到 PVE 网页端，点击 <strong>启动</strong> 你的 OpenWrt 虚拟机。</p><p>进入 OpenWrt 终端，输入：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">df</span> -h<br></code></pre></td></tr></table></figure><p>完美结果：你会看到 <code>/dev/root</code> 的 <strong>Size</strong> 已经稳稳当当地变成了 4.0 GiB，同时网络配置、后台插件完好无损，无痛通关！</p><h3 id="💡-避坑小结"><a href="#💡-避坑小结" class="headerlink" title="💡 避坑小结"></a>💡 避坑小结</h3><h4 id="不要用-fdisk-手动删分区重建"><a href="#不要用-fdisk-手动删分区重建" class="headerlink" title="不要用 fdisk 手动删分区重建"></a>不要用 fdisk 手动删分区重建</h4><p>这会改变 GPT 分区的 <code>PARTUUID</code>，导致 OpenWrt 开机直接卡死在：</p><figure class="highlight routeros"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs routeros">Waiting <span class="hljs-keyword">for</span> root device <span class="hljs-attribute">PARTUUID</span>=xxx...<br></code></pre></td></tr></table></figure><p>OpenWrt 的 <code>/etc/config/fstab</code> 和内核启动参数都依赖这个 UUID，一旦变了就 GG。</p><h4 id="认清-LVM-Thin-存储"><a href="#认清-LVM-Thin-存储" class="headerlink" title="认清 LVM-Thin 存储"></a>认清 LVM-Thin 存储</h4><p>如果 PVE 存储使用的是 LVM-Thin，虚拟磁盘<strong>不是 <code>.raw</code> 或 <code>.qcow2</code> 文件</strong>，它存在于 <code>/dev/pve/vm-XX-disk-XX</code> 的块设备中，<code>qemu-nbd</code> 必须加 <code>-f raw</code> 显式指定格式，否则报错：</p><figure class="highlight apache"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs apache"><span class="hljs-attribute">qemu</span>-nbd: Could not open &#x27;/dev/pve/vm-<span class="hljs-number">105</span>-disk-<span class="hljs-number">1</span>&#x27;: ...<br></code></pre></td></tr></table></figure><h4 id="万一映射后看不到分区怎么办？"><a href="#万一映射后看不到分区怎么办？" class="headerlink" title="万一映射后看不到分区怎么办？"></a>万一映射后看不到分区怎么办？</h4><p>偶尔 <code>nbd0</code> 挂上后 <code>/dev/nbd0p2</code> 没自动出现（和内核的 GPT 分区扫描有关），手动触发一下即可：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash">partprobe /dev/nbd0<br>lsblk /dev/nbd0   <span class="hljs-comment"># 确认 p1、p2 分区出现后再跑 e2fsck</span><br></code></pre></td></tr></table></figure><h4 id="为什么是-p2-而不是-p1？"><a href="#为什么是-p2-而不是-p1？" class="headerlink" title="为什么是 p2 而不是 p1？"></a>为什么是 p2 而不是 p1？</h4><p>OpenWrt <code>combined-efi</code> 固件通常有两个分区：</p><ul><li><code>p1</code>：EFI 系统分区（ESP），FAT32 格式，存放引导文件。</li><li><code>p2</code>：rootfs，ext4 格式，你的所有数据都在这里，<strong>扩容只扩 p2</strong>。</li></ul><h3 id="📝-总结"><a href="#📝-总结" class="headerlink" title="📝 总结"></a>📝 总结</h3><p>这套”外部物理碾压法”的核心思路只有一句话：<strong>关机 → 映射 → 修盘 → 扩容 → 断开 → 开机</strong>。无论是 OpenWrt 还是其他嵌入式 Linux（Armbian、LibreELEC 等），只要跑在 PVE LVM-Thin 上，这套方法通杀。收藏起来，下次扩容不用再满世界翻教程！</p>]]>
    </content>
    <id>https://blog.952405.xyz/2026/04/pve-openwrt-lvm-thin-resize/</id>
    <link href="https://blog.952405.xyz/2026/04/pve-openwrt-lvm-thin-resize/"/>
    <published>2026-04-09T04:10:00.000Z</published>
    <summary>介绍通过 PVE 宿主机 nbd + e2fsck + resize2fs 外部离线扩容法，完美解决 OpenWrt 固件 resize_inode 缺失导致的在线扩容失败问题。</summary>
    <title>PVE 虚拟机 OpenWrt 无损扩容终极教程（LVM-Thin 架构）</title>
    <updated>2026-04-09T04:10:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>iDing</name>
    </author>
    <category term="网络与代理" scheme="https://blog.952405.xyz/categories/%E7%BD%91%E7%BB%9C%E4%B8%8E%E4%BB%A3%E7%90%86/"/>
    <category term="Linux" scheme="https://blog.952405.xyz/tags/Linux/"/>
    <category term="OpenWrt" scheme="https://blog.952405.xyz/tags/OpenWrt/"/>
    <category term="Homelab" scheme="https://blog.952405.xyz/tags/Homelab/"/>
    <category term="PVE" scheme="https://blog.952405.xyz/tags/PVE/"/>
    <content>
      <![CDATA[<h2 id="【避坑指南】PVE-虚拟机快速安装-OpenWrt-25-的正确姿势（拒绝卡-UEFI-报错）"><a href="#【避坑指南】PVE-虚拟机快速安装-OpenWrt-25-的正确姿势（拒绝卡-UEFI-报错）" class="headerlink" title="【避坑指南】PVE 虚拟机快速安装 OpenWrt 25 的正确姿势（拒绝卡 UEFI 报错）"></a>【避坑指南】PVE 虚拟机快速安装 OpenWrt 25 的正确姿势（拒绝卡 UEFI 报错）</h2><p>在 Proxmox VE (PVE) 中折腾软路由，很多人习惯了用传统的网络教程去导入镜像、配置 UEFI 引导。但在最新的 OpenWrt 25.x 版本中，由于其固件分区和 PVE 的 UEFI (OVMF) 固件偶尔存在兼容性冲突，不少小伙伴在开机时都会一头撞上这个经典报错：</p><figure class="highlight livecodeserver"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs livecodeserver">Failed <span class="hljs-built_in">to</span> <span class="hljs-built_in">load</span> Boot <span class="hljs-string">&quot;UEFI QEMU HARDDISK...&quot;</span><br></code></pre></td></tr></table></figure><p>今天分享一个最干净、最快速的<strong>直接注入安装法</strong>，并教你如何搞定 UEFI 引导报错！</p><h3 id="🛠️-核心思路：为什么要”直接注入”？"><a href="#🛠️-核心思路：为什么要”直接注入”？" class="headerlink" title="🛠️ 核心思路：为什么要”直接注入”？"></a>🛠️ 核心思路：为什么要”直接注入”？</h3><p>传统的安装方法是使用 <code>qm importdisk</code> 导入镜像，再去网页端挂载、删除旧盘，步骤繁琐。</p><p>其实，只要你的虚拟机已经创建好了虚拟磁盘，我们完全可以直接使用 <code>dd</code> 或 <code>qemu-img</code> 命令，将解压后的固件直接写入现有的虚拟磁盘块中。这种方法速度极快，且不容易产生”未使用磁盘”的残留碎屑。</p><h3 id="🚀-极简安装步骤"><a href="#🚀-极简安装步骤" class="headerlink" title="🚀 极简安装步骤"></a>🚀 极简安装步骤</h3><h4 id="1-准备工作：解压固件"><a href="#1-准备工作：解压固件" class="headerlink" title="1. 准备工作：解压固件"></a>1. 准备工作：解压固件</h4><p>首先，通过 SSH 连接到你的 PVE 后台。找到你下载好的 <code>.img.gz</code> 固件。因为它是压缩格式，我们需要先用 <code>gunzip</code> 命令将它解压：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">gunzip openwrt-25.12.5-x86-64-generic-ext4-combined-efi.img.gz<br></code></pre></td></tr></table></figure><p>解压后，你会得到一个标准的 <code>.img</code> 镜像文件。</p><h4 id="2-关键一步：直接注入现有磁盘"><a href="#2-关键一步：直接注入现有磁盘" class="headerlink" title="2. 关键一步：直接注入现有磁盘"></a>2. 关键一步：直接注入现有磁盘</h4><p>假设你的 OpenWrt 虚拟机 ID 是 <code>105</code>，且你在创建虚拟机时，已经在 <code>local-lvm</code> 存储上默认建立了一个主磁盘（假设是 <code>scsi0</code>，在底层对应的设备名通常为 <code>vm-105-disk-1</code>）。</p><p>在 PVE 命令行中，直接执行以下命令，将镜像”怼”进这个磁盘：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">dd</span> <span class="hljs-keyword">if</span>=openwrt-25.12.5-x86-64-generic-ext4-combined-efi.img of=/dev/pve/vm-105-disk-1 bs=4M status=progress &amp;&amp; <span class="hljs-built_in">sync</span><br></code></pre></td></tr></table></figure><blockquote><p>💡 <strong>小提示</strong>：如果不确定自己的磁盘名字，可以在 PVE 网页端查看虚拟机 <code>105</code> 的 <strong>硬件</strong> 列表，确认 <code>scsi0</code> 后面写的是不是 <code>local-lvm:vm-105-disk-1</code>。</p></blockquote><h3 id="🚨-避坑：遇到-Failed-to-load-Boot-怎么办？"><a href="#🚨-避坑：遇到-Failed-to-load-Boot-怎么办？" class="headerlink" title="🚨 避坑：遇到 Failed to load Boot 怎么办？"></a>🚨 避坑：遇到 Failed to load Boot 怎么办？</h3><p>很多同学 dd 注入完成后开机，结果一头撞上这个报错：</p><figure class="highlight livecodeserver"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs livecodeserver">Failed <span class="hljs-built_in">to</span> <span class="hljs-built_in">load</span> Boot <span class="hljs-string">&quot;UEFI QEMU HARDDISK...&quot;</span><br></code></pre></td></tr></table></figure><p>或者直接弹进 <code>Shell&gt;</code> 界面。不要慌，这通常不是固件的问题，而是 <strong>PVE 虚拟机的硬件配置和固件分区没对上</strong>。</p><h4 id="为什么会这样？"><a href="#为什么会这样？" class="headerlink" title="为什么会这样？"></a>为什么会这样？</h4><p>UEFI 固件通过 <strong>EFI 系统分区（ESP）</strong> 来查找引导文件（<code>bootx64.efi</code>）。如果 PVE 虚拟机创建时没有添加 EFI 磁盘，或者 EFI 磁盘没有正确关联到系统盘的 ESP 分区，开机时 UEFI 固件就找不到可引导的操作系统，直接报错。</p><p>UEFI 是新硬件的标配，支持安全启动、更快的引导速度、GPT 分区表等现代特性。在软路由场景下，使用 UEFI 引导同样是推荐方案，关键是把配置做对。</p><h4 id="检查清单"><a href="#检查清单" class="headerlink" title="检查清单"></a>检查清单</h4><p>遇到上述报错时，按以下顺序排查：</p><h5 id="①-确认固件类型"><a href="#①-确认固件类型" class="headerlink" title="① 确认固件类型"></a>① 确认固件类型</h5><p>你下载的 OpenWrt 固件是否包含 UEFI 支持？<strong>务必使用 <code>combined-efi</code> 版本的固件</strong>（文件名带 <code>efi</code> 字样），例如：</p><figure class="highlight apache"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs apache"><span class="hljs-attribute">openwrt</span>-<span class="hljs-number">25</span>.<span class="hljs-number">12</span>.<span class="hljs-number">5</span>-x86-<span class="hljs-number">64</span>-generic-ext4-combined-efi.img.gz<br></code></pre></td></tr></table></figure><p>如果你用的是不带 <code>efi</code> 的固件（如 <code>generic-ext4-combined</code>），它只支持传统 BIOS 引导，在 OVMF 下必然启动失败。</p><h5 id="②-确认-BIOS-类型正确"><a href="#②-确认-BIOS-类型正确" class="headerlink" title="② 确认 BIOS 类型正确"></a>② 确认 BIOS 类型正确</h5><p>在 PVE 网页端 → 虚拟机 → <strong>硬件 (Hardware)</strong>，检查 BIOS 项是否为 <strong>OVMF (UEFI)</strong>。如果创建虚拟机时选错了，关机后双击 BIOS 修改即可。</p><h5 id="③-最关键的一步：正确添加-EFI-磁盘"><a href="#③-最关键的一步：正确添加-EFI-磁盘" class="headerlink" title="③ 最关键的一步：正确添加 EFI 磁盘"></a>③ 最关键的一步：正确添加 EFI 磁盘</h5><p>PVE 的 UEFI 虚拟机需要一个专门的 EFI 磁盘来存储引导变量。如果创建虚拟机时没有自动添加，你需要手动添加：</p><ol><li>关机虚拟机。</li><li><strong>硬件 (Hardware) → 添加 (Add) → EFI 磁盘 (EFI Disk)</strong>。</li><li>存储选择默认（通常与系统盘相同即可）。</li><li><strong>注意不要勾选”预注册密钥 (Pre-Enroll Keys)”</strong>，OpenWrt 不需要安全启动。</li></ol><h5 id="④-检查引导顺序"><a href="#④-检查引导顺序" class="headerlink" title="④ 检查引导顺序"></a>④ 检查引导顺序</h5><p>前往 <strong>选项 (Options) → 引导顺序 (Boot Order)</strong>，确保：</p><ul><li>系统盘（<code>scsi0</code> 或 <code>virtio0</code>）已勾选，并拖到最顶部。</li><li>EFI 磁盘（<code>efidisk0</code>）不需要放在第一位，但需要存在。</li></ul><h5 id="⑤-如果依然失败：UEFI-Shell-手动引导"><a href="#⑤-如果依然失败：UEFI-Shell-手动引导" class="headerlink" title="⑤ 如果依然失败：UEFI Shell 手动引导"></a>⑤ 如果依然失败：UEFI Shell 手动引导</h5><p>极少数情况，开机后仍进入 <code>Shell&gt;</code> 界面。这时可以手动引导一次，系统启动后会自动修复引导项：</p><p>在 Shell 提示符下输入：</p><figure class="highlight stata"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs stata"><span class="hljs-keyword">Shell</span>&gt; fs0:<br>fs0:\&gt; <span class="hljs-keyword">cd</span> efi\<span class="hljs-keyword">boot</span><br>fs0:\efi\<span class="hljs-keyword">boot</span>\&gt; bootx64.efi<br></code></pre></td></tr></table></figure><p>系统正常启动后，后续开机就不会再卡 Shell 了。</p><h3 id="🎉-开机与首次配置"><a href="#🎉-开机与首次配置" class="headerlink" title="🎉 开机与首次配置"></a>🎉 开机与首次配置</h3><p>重新点击 <strong>启动</strong> 虚拟机，你会发现系统顺利进入 OpenWrt 跑码画面！</p><p><strong>首次配置网络：</strong></p><p>OpenWrt 25 默认的 LAN 口 IP 通常是 <code>192.168.1.1</code>。为了防止和家里主路由冲突，在控制台跑码结束后按回车，输入以下命令修改 IP：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">vi /etc/config/network<br></code></pre></td></tr></table></figure><ul><li>按 <code>i</code> 进入编辑模式。</li><li>找到 <code>config interface &#39;lan&#39;</code>，把 <code>option ipaddr &#39;192.168.1.1&#39;</code> 改为你需要的 IP（例如 <code>192.168.31.2</code>）。</li><li>按 <code>Esc</code>，输入 <code>:wq</code> 保存退出。</li><li>输入 <code>/etc/init.d/network restart</code> 重启网络服务。</li></ul><p><strong>重置网络并添加 WAN 口：</strong></p><p>如果你需要<strong>彻底重置网络配置</strong>，或者给软路由添加一个 DHCP 协议的 WAN 口（绑定到 <code>eth1</code>），可以直接用 <code>uci</code> 命令批量完成，干净利落：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 1. 添加具有 DHCP 协议的 WAN 接口，并绑定到 eth1</span><br>uci <span class="hljs-built_in">set</span> network.wan=interface<br>uci <span class="hljs-built_in">set</span> network.wan.device=<span class="hljs-string">&#x27;eth1&#x27;</span><br>uci <span class="hljs-built_in">set</span> network.wan.proto=<span class="hljs-string">&#x27;dhcp&#x27;</span><br><br><span class="hljs-comment"># 2. 添加 IPv6 的 wan6 接口</span><br>uci <span class="hljs-built_in">set</span> network.wan6=interface<br>uci <span class="hljs-built_in">set</span> network.wan6.device=<span class="hljs-string">&#x27;eth1&#x27;</span><br>uci <span class="hljs-built_in">set</span> network.wan6.proto=<span class="hljs-string">&#x27;dhcpv6&#x27;</span><br><br><span class="hljs-comment"># 3. 提交并保存配置</span><br>uci commit network<br><br><span class="hljs-comment"># 4. 清理可能存在的缓存并重启网络服务</span><br><span class="hljs-built_in">rm</span> -rf /tmp/luci-indexcache /tmp/luci-modulecache/<br>/etc/init.d/network restart<br></code></pre></td></tr></table></figure><p>现在，你就可以在浏览器里输入刚刚设置的 IP，正式开启你的 OpenWrt 25 冲浪之旅了！</p><h3 id="📝-总结"><a href="#📝-总结" class="headerlink" title="📝 总结"></a>📝 总结</h3><p>虚拟化折腾软路由，<code>dd</code> 直接注入法比传统 <code>qm importdisk</code> 更简洁高效。遇到 UEFI 引导报错不要慌，检查三个关键点：<strong>① 固件类型选 <code>combined-efi</code></strong>、<strong>② 正确添加 EFI 磁盘</strong>、<strong>③ 引导顺序把系统盘放第一位</strong>。按这个思路排查，能帮你省下 90% 的排错时间！如果这篇教程帮到了你，欢迎点赞收藏！</p>]]>
    </content>
    <id>https://blog.952405.xyz/2026/04/pve-openwrt-25-install-avoid-uefi-error/</id>
    <link href="https://blog.952405.xyz/2026/04/pve-openwrt-25-install-avoid-uefi-error/"/>
    <published>2026-04-08T00:32:00.000Z</published>
    <summary>在 PVE 中安装 OpenWrt 25.x 时，dd 直接注入法比传统 qm importdisk 更简洁高效。本文分享完整安装流程，并解决常见的 Failed to load Boot UEFI 引导报错。</summary>
    <title>【避坑指南】PVE 虚拟机快速安装 OpenWrt 25 的正确姿势（拒绝卡 UEFI 报错）</title>
    <updated>2026-04-08T00:32:00.000Z</updated>
  </entry>
</feed>
