<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>舞浅静的博客</title><description>No description</description><link>https://fuwari.vercel.app/</link><language>zh_CN</language><item><title>SSH 远程端口转发无法启动：直接登录正常但隧道卡住</title><link>https://fuwari.vercel.app/posts/sshtunnelfailure/</link><guid isPermaLink="true">https://fuwari.vercel.app/posts/sshtunnelfailure/</guid><description>SSH 远程端口转发（Remote Forwarding）时连接卡住，直接登录却正常的问题排查与解决方法，涉及残留 sshd 子进程占用端口的清理。</description><pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;环境&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;客户端：OpenSSH 任意版本&lt;/li&gt;
&lt;li&gt;服务端：Ubuntu 24.04、OpenSSH不知道什么版本&lt;/li&gt;
&lt;li&gt;认证方式：公钥认证&lt;/li&gt;
&lt;li&gt;转发类型：远程端口转发（&lt;code&gt;-R&lt;/code&gt;）&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;问题&lt;/h2&gt;
&lt;p&gt;今天心血来潮想喝杯奶茶，出门时直接笔记本一盖就出门了，到了奶茶店想要通过 SSH Tunnel 让服务器使用本机代理时，发现一直无法建立隧道。排查日志发现通过 SSH 建立远程端口转发隧道时，客户端日志显示认证成功、连接建立，但在 &lt;code&gt;Starting a new Remote Port-Forwarding rule&lt;/code&gt; 后不再有任何进展，隧道无法使用。但直接执行 SSH 登录（不带 &lt;code&gt;-R&lt;/code&gt; 参数）可以正常连接和操作。&lt;/p&gt;
&lt;h2&gt;排查过程&lt;/h2&gt;
&lt;h3&gt;1. 观察客户端日志&lt;/h3&gt;
&lt;p&gt;日志关键部分如下（已脱敏）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;👤 Starting a new connection to: &quot;login.example.com&quot; port &quot;22&quot;
⚙️ Agreed KEX algorithm: curve25519-sha256
⚙️ Handshake finished
👤 Authenticated to &quot;login.example.com&quot;:&quot;22&quot;
👤 Starting a new Remote Port-Forwarding rule   # 此后卡住
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;没有报错，也没有成功绑定端口的提示。直接 &lt;code&gt;ssh user@host -p 22&lt;/code&gt; 则可正常登录。&lt;/p&gt;
&lt;h3&gt;2. 检查服务器端口占用&lt;/h3&gt;
&lt;p&gt;登录到远程服务器（通过普通 SSH 会话），查看拟转发的端口是否已被监听：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo netstat -tulnp | grep :7897
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;发现该端口处于 &lt;code&gt;LISTEN&lt;/code&gt; 状态，且对应的进程是 &lt;code&gt;sshd&lt;/code&gt; 的子进程。这意味着之前的某个 SSH 隧道没有正常关闭，残留的 &lt;code&gt;sshd&lt;/code&gt; 进程仍然占用了转发端口。&lt;/p&gt;
&lt;h3&gt;3. 清理残留进程&lt;/h3&gt;
&lt;p&gt;找到残留进程的 PID 后，可以直接杀死该进程，或者终止当前用户的所有 &lt;code&gt;sshd&lt;/code&gt; 子进程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pkill -u &amp;lt;myuser&amp;gt; sshd
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;注意：该命令会同时杀死您当前正在使用的 SSH 会话，需要重新登录。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;4. 重新尝试隧道&lt;/h3&gt;
&lt;p&gt;清理完成后，再次发起远程端口转发：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh -R 7897:localhost:7897 &amp;lt;myuser@login.example.com&amp;gt; -p &amp;lt;port&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;隧道成功建立，问题解决。&lt;/p&gt;
&lt;h2&gt;根因分析&lt;/h2&gt;
&lt;h3&gt;正常流程&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;当客户端通过 &lt;code&gt;-R&lt;/code&gt; 请求远程转发时，SSH 服务器会启动一个监听进程绑定到指定的远程端口。&lt;/li&gt;
&lt;li&gt;客户端断开连接时，正常情况下服务器端的监听进程也会随之关闭，端口被释放。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;异常残留&lt;/h3&gt;
&lt;p&gt;以下情况可能导致服务器的子进程未能正常退出：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;客户端被强制终止（&lt;code&gt;kill -9&lt;/code&gt;、网络中断、电源断电）&lt;/li&gt;
&lt;li&gt;SSH 复用（&lt;code&gt;ControlMaster&lt;/code&gt;）异常&lt;/li&gt;
&lt;li&gt;服务器端 &lt;code&gt;sshd&lt;/code&gt; 配置问题或资源限制&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;残留的 &lt;code&gt;sshd&lt;/code&gt; 进程仍保持着对目标端口的绑定。当新客户端请求相同端口的转发时，由于端口已被占用（&lt;code&gt;Address already in use&lt;/code&gt;），服务器不会返回成功信息，客户端可能表现为卡住或超时。&lt;/p&gt;
&lt;h3&gt;为什么普通登录正常？&lt;/h3&gt;
&lt;p&gt;普通登录（不包含 &lt;code&gt;-R&lt;/code&gt; 选项）不涉及端口绑定，因此不受残留进程的影响。&lt;/p&gt;
&lt;h2&gt;附录：脱敏后的完整日志供参考&lt;/h2&gt;
&lt;p&gt;以下为脱敏后的客户端日志（包含成功认证和启动转发卡住的片段）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;👤 Starting a new connection to: &quot;login.example.com&quot; port &quot;22&quot;
⚙️ Starting address resolution of &quot;login.example.com&quot;
⚙️ Address resolution finished
⚙️ Connecting to &quot;203.0.113.10&quot; port &quot;22&quot;
👤 Connection to &quot;login.example.com&quot; established
⚙️ Starting SSH session
⚙️ Remote server: SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.16
⚙️ Agreed KEX algorithm: curve25519-sha256
⚙️ Agreed Host Key algorithm: ecdsa-sha2-nistp256
⚙️ Agreed server-to-client cipher: aes256-gcm@openssh.com MAC: INTEGRATED-AES-GCM
⚙️ Agreed client-to-server cipher: aes256-gcm@openssh.com MAC: INTEGRATED-AES-GCM
⚙️ Agreed client-to-server compression: none
⚙️ Agreed server-to-client compression: none
⚙️ Handshake finished
👤 Checking host key: SHA256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
👤 Host &quot;login.example.com&quot;:&quot;22&quot; is known and matches
👤 Authenticating to &quot;login.example.com&quot;:&quot;22&quot; as &quot;myuser&quot;
⚙️ Available client authentication methods: publickey,password,keyboard-interactive
⚙️ Authentication that can continue: publickey
👤 Authenticating using publickey method
👤 Authentication succeeded (publickey)
👤 Authenticated to &quot;login.example.com&quot;:&quot;22&quot;
👤 Starting a new Remote Port-Forwarding rule   # 此处卡住
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;SSH 远程端口转发失败但普通登录正常时，&lt;strong&gt;首先怀疑服务器端是否有残留的 &lt;code&gt;sshd&lt;/code&gt; 进程占用了目标端口&lt;/strong&gt;。通过 &lt;code&gt;pkill -u 用户名 sshd&lt;/code&gt; 清理后问题大多可解决。建议在客户端添加 &lt;code&gt;ExitOnForwardFailure=yes&lt;/code&gt; 以便快速发现冲突，并在服务器端合理配置连接保活参数，减少残留发生的概率。&lt;/p&gt;
</content:encoded></item><item><title>Slurm NFS 挂载后 IO error 排障记录</title><link>https://fuwari.vercel.app/posts/slurm_nfs_ioerr_debug/</link><guid isPermaLink="true">https://fuwari.vercel.app/posts/slurm_nfs_ioerr_debug/</guid><description>记录一次 Slurm 计算节点无法稳定访问 NFS 的排障过程：从降级NFS版本，修改MTU，到最终恢复 NFSv4.2。</description><pubDate>Fri, 22 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;为了信息脱敏，正文内所有的用户名和具体ip等敏感内容用&amp;lt;&amp;gt;代替&lt;/p&gt;
&lt;h1&gt;背景&lt;/h1&gt;
&lt;p&gt;集群里有一台登录节点 &lt;code&gt;2.login.slurm.lan&lt;/code&gt;，三台计算节点，均在&lt;code&gt;&amp;lt;allowed ip range&amp;gt;&lt;/code&gt;下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1.compute.slurm.lan&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;2.compute.slurm.lan&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;3.compute.slurm.lan&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;故障发生前，集群交换机经历了一次异常断电并恢复。恢复后，计算节点上的 NFS 挂载表面存在，但访问 &lt;code&gt;/home&lt;/code&gt; 和 &lt;code&gt;/opt&lt;/code&gt; 时会出现超时或 &lt;code&gt;Input/output error&lt;/code&gt;，进而导致 Slurm 任务无法正常启动。&lt;/p&gt;
&lt;p&gt;存储服务器通过 NFS 导出两个目录：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;&amp;lt;nfs server ip&amp;gt;:/mnt/Data/slurm/userhome&lt;/code&gt; 挂载到 &lt;code&gt;/home&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;lt;nfs server ip&amp;gt;:/mnt/Data/slurm/opt&lt;/code&gt; 挂载到 &lt;code&gt;/opt&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;其中 &lt;code&gt;/home&lt;/code&gt; 提供用户目录，例如 &lt;code&gt;/home/sshusers/&amp;lt;user&amp;gt;&lt;/code&gt;；&lt;code&gt;/opt&lt;/code&gt; 里有 Slurm 的 &lt;code&gt;TaskProlog&lt;/code&gt; 依赖脚本：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/opt/shell_related/task_prolog.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此这两个 NFS 挂载都必须正常。只挂上 &lt;code&gt;/home&lt;/code&gt; 不够，&lt;code&gt;/opt&lt;/code&gt; 访问失败会直接导致 Slurm 任务启动失败。&lt;/p&gt;
&lt;h1&gt;故障现象&lt;/h1&gt;
&lt;p&gt;最初在 &lt;code&gt;2.compute.slurm.lan&lt;/code&gt; 上运行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;srun -c 8 -w 2.compute.slurm.lan hostname
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;报错：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;srun: error: 2.compute.slurm.lan: task 0: Exited with exit code 1
[2026-05-22T15:40:21.638] error: run_command: slurm task_prolog can not be executed (/opt/shell_related/task_prolog.sh) No such file or directory
[2026-05-22T15:40:21.638] error: slurm task_prolog did not exit normally. reason: Run command failed - configuration error
[2026-05-22T15:40:21.638] error: TaskProlog failed status=1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;节点上直接访问 NFS 路径时，可见类似现象：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ls /home/sshusers/&amp;lt;user&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;返回：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ls: reading directory &apos;/home/sshusers/&amp;lt;user&amp;gt;&apos;: Input/output error
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;访问 &lt;code&gt;/opt&lt;/code&gt; 也会失败：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;head -1 /opt/shell_related/task_prolog.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;返回：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;head: cannot open &apos;/opt/shell_related/task_prolog.sh&apos; for reading: Input/output error
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;内核日志里能看到 NFS 超时：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nfs: server &amp;lt;nfs server ip&amp;gt; not responding, timed out
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;复现方式&lt;/h1&gt;
&lt;p&gt;在计算节点上复现主要看三个层面。&lt;/p&gt;
&lt;p&gt;检查 &lt;code&gt;/home&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;timeout 10 ls -la /home/sshusers/&amp;lt;user&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;检查 &lt;code&gt;/opt&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;timeout 10 head -1 /opt/shell_related/task_prolog.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;检查 Slurm：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;srun -c 8 -w 2.compute.slurm.lan hostname
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对 GPU 节点 &lt;code&gt;1.compute.slurm.lan&lt;/code&gt;，需要指定 GPU 分区和资源：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;srun -p gpu --gres=gpu:1 -c 8 -w 1.compute.slurm.lan hostname
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果 NFS 正常，这些命令应该分别能读目录、读 prolog 脚本，并输出节点 hostname。&lt;/p&gt;
&lt;h1&gt;初始检查&lt;/h1&gt;
&lt;p&gt;查看 NFS 导出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;showmount -e &amp;lt;nfs server ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;导出内容包含：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/mnt/Data/slurm/opt      &amp;lt;allowed ip range&amp;gt;
/mnt/Data/slurm/userhome &amp;lt;allowed ip range&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;说明计算节点所在网段 &lt;code&gt;&amp;lt;allowed ip range&amp;gt;&lt;/code&gt; 有权限访问导出。&lt;/p&gt;
&lt;p&gt;检查 RPC 服务：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rpcinfo -p &amp;lt;nfs server ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;能看到 NFS v3 和 v4：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;100003    3   tcp   2049  nfs
100003    4   tcp   2049  nfs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这说明服务端 NFS 服务本身是可见的。&lt;/p&gt;
&lt;h1&gt;尝试不同NFS参数和版本&lt;/h1&gt;
&lt;p&gt;一开始计算节点上的 &lt;code&gt;/etc/fstab&lt;/code&gt; 使用过 NFSv3、systemd automount、soft 等不同组合。为了先恢复访问，尝试过 NFSv3：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;nfs server ip&amp;gt;:/mnt/Data/slurm/userhome /home nfs _netdev,defaults,noatime,nolock,nordirplus,soft,timeo=30,retrans=2,actimeo=1800,vers=3,proto=tcp,mountproto=tcp 0 0
&amp;lt;nfs server ip&amp;gt;:/mnt/Data/slurm/opt /opt nfs _netdev,defaults,noatime,nolock,nordirplus,soft,timeo=30,retrans=2,actimeo=1800,vers=3,proto=tcp,mountproto=tcp 0 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中 &lt;code&gt;nordirplus&lt;/code&gt; 很关键：在早期故障状态下，NFSv3 默认的 &lt;code&gt;readdirplus&lt;/code&gt; 读目录会触发 &lt;code&gt;Input/output error&lt;/code&gt;，加上 &lt;code&gt;nordirplus&lt;/code&gt; 后能暂时让目录读取恢复。&lt;/p&gt;
&lt;p&gt;但这只是绕过了一部分症状。后续发现真正的底层问题不在 NFS 版本，而在网络 MTU。&lt;/p&gt;
&lt;h1&gt;定位到 MTU&lt;/h1&gt;
&lt;p&gt;登录节点访问 NFS 正常，而计算节点访问 NFS 出现 &lt;code&gt;Input/output error&lt;/code&gt;。对比网络配置时发现：&lt;/p&gt;
&lt;p&gt;登录节点到存储网络的 MTU 是 &lt;code&gt;1500&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;enp4s0np0 mtu 1500
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;计算节点 2、3 的存储网络是 bond，MTU 是 &lt;code&gt;9000&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;bond0 mtu 9000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;计算节点 1 的存储网卡是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;enp196s0d1 mtu 9000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当交换机端口还没有完全打开巨型帧时，小包 ping 能通：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ping -c 1 &amp;lt;nfs server ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但巨型帧 ping 不通：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ping -M do -s 8972 -c 1 &amp;lt;nfs server ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;NFS 的表现是：挂载握手、&lt;code&gt;stat&lt;/code&gt; 这类小元数据请求可能成功，但读目录、读文件这种较大的请求会超时或 &lt;code&gt;Input/output error&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这解释了为什么故障看起来像 NFS 版本或 fstab 选项问题，其实底层是链路 MTU 不一致。&lt;/p&gt;
&lt;h1&gt;临时验证&lt;/h1&gt;
&lt;p&gt;先在 &lt;code&gt;2.compute.slurm.lan&lt;/code&gt; 上临时把 MTU 改成 &lt;code&gt;1500&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ip link set dev bond0 mtu 1500
ip link set dev ens2 mtu 1500
ip link set dev ens2d1 mtu 1500
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后重新挂载：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;umount -fl /home /opt
mount -v /home
mount -v /opt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;验证通过：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;timeout 10 ls -la /home/sshusers/&amp;lt;user&amp;gt;
timeout 10 head -1 /opt/shell_related/task_prolog.sh
srun -c 8 -w 2.compute.slurm.lan hostname
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这证明故障与巨型帧链路有关。&lt;/p&gt;
&lt;h1&gt;交换机修复&lt;/h1&gt;
&lt;p&gt;随后在交换机侧把相关端口 MTU 打开到 &lt;code&gt;9216&lt;/code&gt;。再次在计算节点上测试 9000 MTU。&lt;/p&gt;
&lt;p&gt;在交换机端口 MTU 设置完成并等待端口重新上线后，NFS 访问恢复正常。结合故障前的异常断电，推测原因是交换机之前只修改了运行时配置，但没有把配置写入 ROM；交换机重启后端口实际支持的帧大小回退，低于计算节点的 MTU 9000，导致 NFS 大包读目录、读文件时失败。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;2.compute.slurm.lan&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ping -M do -s 8972 -c 1 &amp;lt;nfs server ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;成功：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;8980 bytes from &amp;lt;nfs server ip&amp;gt;: icmp_seq=1 ttl=64 time=0.148 ms
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;NFS 访问也成功：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;timeout 10 ls -la /home/sshusers/&amp;lt;user&amp;gt;
timeout 10 head -1 /opt/shell_related/task_prolog.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Slurm 验证成功：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;srun -c 8 -w 2.compute.slurm.lan hostname
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;TaskProlog executed at Fri May 22 16:09:44 UTC 2026
2.compute.slurm.lan
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;后续补开 &lt;code&gt;1.compute.slurm.lan&lt;/code&gt; 对应交换机端口后，1 的巨型帧 ping 也恢复：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;8980 bytes from &amp;lt;nfs server ip&amp;gt;: icmp_seq=1 ttl=64 time=0.090 ms
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;最终切回 NFSv4.2&lt;/h1&gt;
&lt;p&gt;在 MTU 修复后，重新测试 NFSv4.2，发现三台计算节点均可正常使用。最终不再需要 NFSv3 的 &lt;code&gt;nolock&lt;/code&gt;、&lt;code&gt;nordirplus&lt;/code&gt;、&lt;code&gt;mountproto=tcp&lt;/code&gt; 等选项。&lt;/p&gt;
&lt;p&gt;最终三台计算节点的 &lt;code&gt;/etc/fstab&lt;/code&gt; NFS 行统一为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;nfs server ip&amp;gt;:/mnt/Data/slurm/userhome /home nfs _netdev,defaults,noatime,soft,timeo=30,retrans=2,actimeo=1800,proto=tcp,vers=4.2 0 0
&amp;lt;nfs server ip&amp;gt;:/mnt/Data/slurm/opt /opt nfs _netdev,defaults,noatime,soft,timeo=30,retrans=2,actimeo=1800,proto=tcp,vers=4.2 0 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;实际挂载确认：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nfsstat -m
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看到：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/home from &amp;lt;nfs server ip&amp;gt;:/mnt/Data/slurm/userhome
 Flags: rw,noatime,vers=4.2,...

/opt from &amp;lt;nfs server ip&amp;gt;:/mnt/Data/slurm/opt
 Flags: rw,noatime,vers=4.2,...
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;最终验证&lt;/h1&gt;
&lt;p&gt;&lt;code&gt;1.compute.slurm.lan&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;srun -p gpu --gres=gpu:1 -c 8 -w 1.compute.slurm.lan hostname
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;TaskProlog executed at Sat May 23 00:18:41 CST 2026
1.compute.slurm.lan
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;2.compute.slurm.lan&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;srun -c 8 -w 2.compute.slurm.lan hostname
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;TaskProlog executed at Fri May 22 16:18:01 UTC 2026
2.compute.slurm.lan
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;3.compute.slurm.lan&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;srun -c 8 -w 3.compute.slurm.lan hostname
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;TaskProlog executed at Fri May 22 16:20:12 UTC 2026
3.compute.slurm.lan
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三台节点均确认：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ls /home/sshusers/&amp;lt;user&amp;gt;
head -1 /opt/shell_related/task_prolog.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;均正常。&lt;/p&gt;
&lt;h1&gt;总结&lt;/h1&gt;
&lt;p&gt;这次故障表面上是 NFS 挂载失败和 Slurm &lt;code&gt;TaskProlog&lt;/code&gt; 执行失败，实际根因是计算节点到存储服务器之间的巨型帧 MTU 配置不一致。更具体地说，交换机异常断电恢复后，运行时 MTU 配置没有从 ROM 中恢复到预期状态，导致交换机端口支持的帧大小小于计算节点 MTU。&lt;/p&gt;
&lt;p&gt;排查时几个关键点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;不要只看 &lt;code&gt;mount&lt;/code&gt; 是否成功。NFS 可能已经挂上，但读目录或读文件时才报 &lt;code&gt;Input/output error&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;showmount&lt;/code&gt;、&lt;code&gt;rpcinfo&lt;/code&gt; 成功只能说明服务可见，不代表数据路径没有 MTU 问题。&lt;/li&gt;
&lt;li&gt;对巨型帧场景，必须用 &lt;code&gt;ping -M do -s 8972 &amp;lt;server&amp;gt;&lt;/code&gt; 验证端到端 MTU。&lt;/li&gt;
&lt;li&gt;记得在设置完交换机的巨型帧后，要把运行时配置写入 ROM，避免异常断电或重启后配置丢失。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最终修复手段为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;存储网络链路端到端支持 MTU 9000，交换机端口 MTU 开到 9216。&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>MACE多卡训练无法保存模型</title><link>https://fuwari.vercel.app/posts/maceddperror/</link><guid isPermaLink="true">https://fuwari.vercel.app/posts/maceddperror/</guid><description>有关于Mace 使用pytorch进行多卡训练时出现无法保存模型异常的排查与修复</description><pubDate>Thu, 19 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;环境&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Python 3.11&lt;/li&gt;
&lt;li&gt;PyTorch（通过 &lt;code&gt;torchrun&lt;/code&gt; 进行 DDP 分布式多进程训练）&lt;/li&gt;
&lt;li&gt;e3nn（含 &lt;code&gt;@compile_mode(&quot;script&quot;)&lt;/code&gt; 装饰器）&lt;/li&gt;
&lt;li&gt;CP-MACE（&lt;code&gt;deps/CP-MACE/&lt;/code&gt;）&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;问题描述&lt;/h2&gt;
&lt;p&gt;使用 CP-MACE 进行多卡（DDP）训练时，训练过程本身正常完成，但在训练结束后的模型保存阶段崩溃，抛出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;_pickle.PickleError: ScriptFunction cannot be pickled
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;排查过程&lt;/h2&gt;
&lt;h3&gt;1. 现象：训练完成后崩溃&lt;/h3&gt;
&lt;p&gt;WandB 上观察到模型总是在训练成功结束后立即崩溃。将 &lt;code&gt;max_num_epochs&lt;/code&gt; 设为 1 以快速复现，日志显示训练本身能正常启动和收敛：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;2026-03-18 22:28:11.051 INFO: Using gradient clipping with tolerance=10.000
2026-03-18 22:28:11.052 INFO: ===========TRAINING===========
2026-03-18 22:28:11.052 INFO: Started training, reporting errors on validation set
2026-03-18 22:28:11.052 INFO: Loss metrics on validation set
2026-03-18 22:28:42.950 INFO: Initial: head: default, loss=0.83482060, RMSE_E_per_atom= 949.164 meV, RMSE_F= 267.246 meV / A, RMSE_P=  0.0069 V,

# 后续将没有内容了，wandb上status显示crashed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;初步判断问题出在训练完成后的收尾阶段。&lt;/p&gt;
&lt;h3&gt;2. 定位到 DDP 分布式进程异常&lt;/h3&gt;
&lt;p&gt;查看 torchrun 日志，发现子进程抛出了 &lt;code&gt;ChildFailedError&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Traceback (most recent call last):
  File &quot;/.conda/envs/catdt/bin/torchrun&quot;, line 6, in &amp;lt;module&amp;gt;
    sys.exit(main())
  File &quot;.../torch/distributed/run.py&quot;
    elastic_launch(...)
  File &quot;.../torch/distributed/launcher/api.py&quot;
    return launch_agent(self._config, self._entrypoint, list(args))
  ...
torch.distributed.elastic.multiprocessing.errors.ChildFailedError:
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;该错误是 torchrun 对子进程内部异常的包装——注意 DDP 使用的是&lt;strong&gt;多进程&lt;/strong&gt;，具体错误原因被隐藏在内层堆栈中，排查难度较大。&lt;/p&gt;
&lt;h3&gt;3. 定位到 &lt;code&gt;deepcopy(model)&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;进一步追踪 rank0 的详细堆栈，在测试将 &lt;code&gt;deepcopy(model)&lt;/code&gt; 单独拎出后，最终确认真正的异常出在 &lt;code&gt;run_train.py&lt;/code&gt; 模型保存阶段调用的该深拷贝函数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;_pickle.PickleError: ScriptFunction cannot be pickled

[rank0]: Traceback (most recent call last):
[rank0]:   File &quot;deps/CP-MACE/mace/cli/run_train.py&quot;, line 865, in run
[rank0]:     model_to_save = deepcopy(model)
[rank0]:                     ^^^^^^^^^^^^^^^
[rank0]:   File &quot;copy.py&quot;, line 172, in deepcopy
[rank0]:     y = _reconstruct(x, memo, *rv)
[rank0]:   File &quot;copy.py&quot;, line 271, in _reconstruct
[rank0]:     state = deepcopy(state, memo)
[rank0]:   File &quot;copy.py&quot;, line 231, in _deepcopy_dict
[rank0]:     y[deepcopy(key, memo)] = deepcopy(value, memo)
              ...（递归遍历模型 __dict__）...
[rank0]:   File &quot;torch/jit/_script.py&quot;, line 71, in _reduce
[rank0]:     raise pickle.PickleError(&quot;ScriptFunction cannot be pickled&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;根因分析&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;@compile_mode(&quot;script&quot;)&lt;/code&gt; 与 e3nn codegen&lt;/h3&gt;
&lt;p&gt;CP-MACE 的模型类及其子模块使用了 e3nn 的 &lt;code&gt;@compile_mode(&quot;script&quot;)&lt;/code&gt; 装饰器：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# deps/CP-MACE/mace/modules/models.py

@compile_mode(&quot;script&quot;)       # line 111
class MACE(torch.nn.Module):
    ...

@compile_mode(&quot;script&quot;)       # line 418
class ScaleShiftMACE(MACE):
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当 e3nn 的 &lt;code&gt;jit_script_fx&lt;/code&gt; 优化选项处于启用状态（默认行为）时，模型在实例化过程中会通过 e3nn codegen 将部分方法编译为 &lt;code&gt;torch.jit.ScriptFunction&lt;/code&gt; 对象。&lt;/p&gt;
&lt;h3&gt;为何 &lt;code&gt;deepcopy&lt;/code&gt; 失败&lt;/h3&gt;
&lt;p&gt;Python 的 &lt;code&gt;copy.deepcopy()&lt;/code&gt; 内部依赖 pickle 协议进行序列化。而 &lt;code&gt;torch.jit.ScriptFunction&lt;/code&gt; 显式禁止了 pickle：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# torch/jit/_script.py
def _reduce(self):
    raise pickle.PickleError(&quot;ScriptFunction cannot be pickled&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此，当模型内部包含 &lt;code&gt;ScriptFunction&lt;/code&gt; 对象时，&lt;code&gt;deepcopy(model)&lt;/code&gt; 必然失败。&lt;/p&gt;
&lt;h3&gt;故障链&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;模型实例化（e3nn codegen 默认启用）
  → @compile_mode(&quot;script&quot;) 使模型内部生成 ScriptFunction 对象
    → 训练正常完成，进入保存阶段
      → deepcopy(model) 被调用，试图创建模型副本
        → deepcopy 递归遍历模型 __dict__
          → 遇到 ScriptFunction，触发 pickle 序列化
            → ScriptFunction._reduce() 抛出 PickleError
              → 异常未被捕获，rank0 进程崩溃
                → torchrun 抛出 ChildFailedError
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;修复方案&lt;/h2&gt;
&lt;h3&gt;核心思路&lt;/h3&gt;
&lt;p&gt;不使用 &lt;code&gt;deepcopy&lt;/code&gt;，而是&lt;strong&gt;在 e3nn codegen 关闭的上下文中重新构建一个不含 &lt;code&gt;ScriptFunction&lt;/code&gt; 的干净模型&lt;/strong&gt;，再通过 &lt;code&gt;load_state_dict()&lt;/code&gt; 加载训练好的权重。&lt;/p&gt;
&lt;p&gt;e3nn 提供了 &lt;code&gt;disable_e3nn_codegen()&lt;/code&gt; 上下文管理器（位于 &lt;code&gt;mace/tools/compile.py&lt;/code&gt;），在该上下文中创建的模型不会生成 &lt;code&gt;ScriptFunction&lt;/code&gt; 对象：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# mace/tools/compile.py
@contextmanager
def disable_e3nn_codegen():
    init_val = get_optimization_defaults()[&quot;jit_script_fx&quot;]
    set_optimization_defaults(jit_script_fx=False)
    yield
    set_optimization_defaults(jit_script_fx=init_val)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;修改文件&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;deps/CP-MACE/mace/cli/run_train.py&lt;/code&gt;，模型保存阶段（&lt;code&gt;for swa_eval in swas:&lt;/code&gt; 循环内，&lt;code&gt;if rank == 0:&lt;/code&gt; 分支）。&lt;/p&gt;
&lt;h3&gt;修改前（原始代码）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;        if rank == 0:
            # Save entire model
            if swa_eval:
                model_path = Path(args.checkpoints_dir) / (tag + &quot;_stagetwo.model&quot;)
            else:
                model_path = Path(args.checkpoints_dir) / (tag + &quot;.model&quot;)
            logging.info(f&quot;Saving model to {model_path}&quot;)
            model_to_save = deepcopy(model)          # ← 此处崩溃
            if args.enable_cueq:
                model_to_save = run_cueq_to_e3nn(deepcopy(model), device=device)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;修改后&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;        if rank == 0:
            # 在 e3nn codegen 关闭的上下文中重建模型，避免生成 ScriptFunction
            with disable_e3nn_codegen():
                model_to_save, _ = configure_model(
                    args, train_loader, atomic_energies,
                    model_foundation, heads, z_table,
                )
            model_to_save.to(device)
            model_to_save.load_state_dict(model.state_dict())

            # Save entire model
            if swa_eval:
                model_path = Path(args.checkpoints_dir) / (tag + &quot;_stagetwo.model&quot;)
            else:
                model_path = Path(args.checkpoints_dir) / (tag + &quot;.model&quot;)
            logging.info(f&quot;Saving model to {model_path}&quot;)
            if args.enable_cueq:
                model_to_save = run_cueq_to_e3nn(model_to_save, device=device)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;修改要点&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;修改内容&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;移除 &lt;code&gt;model_to_save = deepcopy(model)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;不再对含有 &lt;code&gt;ScriptFunction&lt;/code&gt; 的模型做深拷贝&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;新增 &lt;code&gt;disable_e3nn_codegen()&lt;/code&gt; + &lt;code&gt;configure_model()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;重建一个结构相同但不含 &lt;code&gt;ScriptFunction&lt;/code&gt; 的干净模型&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;新增 &lt;code&gt;load_state_dict(model.state_dict())&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;将训练好的参数从原模型复制到新模型&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;CUEQ 转换改用 &lt;code&gt;model_to_save&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;新模型已是独立副本，无需再 &lt;code&gt;deepcopy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;附录：涉及 &lt;code&gt;@compile_mode(&quot;script&quot;)&lt;/code&gt; 的类&lt;/h2&gt;
&lt;p&gt;以下所有类在 e3nn codegen 启用时均会在实例化过程中产生 &lt;code&gt;ScriptFunction&lt;/code&gt;，均可能受此问题影响：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;模型层&lt;/strong&gt;（&lt;code&gt;mace/modules/models.py&lt;/code&gt;）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;MACE&lt;/code&gt;、&lt;code&gt;ScaleShiftMACE&lt;/code&gt;、&lt;code&gt;AtomicDipolesMACE&lt;/code&gt;、&lt;code&gt;EnergyDipolesMACE&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;构建模块&lt;/strong&gt;（&lt;code&gt;mace/modules/blocks.py&lt;/code&gt;）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;LinearNodeEmbeddingBlock&lt;/code&gt;、&lt;code&gt;LinearReadoutBlock&lt;/code&gt;、&lt;code&gt;NonLinearReadoutBlock&lt;/code&gt;、&lt;code&gt;LinearDipoleReadoutBlock&lt;/code&gt;、&lt;code&gt;NonLinearDipoleReadoutBlock&lt;/code&gt;、&lt;code&gt;AtomicEnergiesBlock&lt;/code&gt;、&lt;code&gt;RadialEmbeddingBlock&lt;/code&gt;、&lt;code&gt;EquivariantProductBasisBlock&lt;/code&gt;、&lt;code&gt;InteractionBlock&lt;/code&gt;、&lt;code&gt;TensorProductWeightsBlock&lt;/code&gt; 等共 19 个 Block 类&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;径向基函数&lt;/strong&gt;（&lt;code&gt;mace/modules/radial.py&lt;/code&gt;）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;BesselBasis&lt;/code&gt;、&lt;code&gt;ChebychevBasis&lt;/code&gt;、&lt;code&gt;GaussianBasis&lt;/code&gt;、&lt;code&gt;PolynomialCutoff&lt;/code&gt;、&lt;code&gt;ZBLBasis&lt;/code&gt;、&lt;code&gt;AgnesiTransform&lt;/code&gt;、&lt;code&gt;SoftTransform&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>宝塔使用Cloudflare API Token部署ACME</title><link>https://fuwari.vercel.app/posts/btcfacme/</link><guid isPermaLink="true">https://fuwari.vercel.app/posts/btcfacme/</guid><description>宝塔使用pyhton，搭配Cloudflare API Token部署ACME</description><pubDate>Mon, 09 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;目录&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#1-%E9%A1%B9%E7%9B%AE%E7%AE%80%E4%BB%8B&quot;&gt;1. 项目简介&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#2-%E5%AE%9E%E7%8E%B0%E5%8E%9F%E7%90%86&quot;&gt;2. 实现原理&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#3-%E9%83%A8%E7%BD%B2%E5%89%8D%E5%87%86%E5%A4%87&quot;&gt;3. 部署前准备&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#4-%E5%9C%A8%E5%AE%9D%E5%A1%94%E4%B8%AD%E9%83%A8%E7%BD%B2-python-%E6%9C%8D%E5%8A%A1&quot;&gt;4. 在宝塔中部署 Python 服务&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#5-%E5%88%9D%E5%A7%8B%E5%8C%96-ssl-%E7%8A%B6%E6%80%81%E5%BB%BA%E8%AE%AE&quot;&gt;5. 初始化 SSL 状态（建议）&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#6-%E9%85%8D%E7%BD%AE%E8%AE%A1%E5%88%92%E4%BB%BB%E5%8A%A1%E8%A7%A6%E5%8F%91%E6%9B%B4%E6%96%B0&quot;&gt;6. 配置计划任务触发更新&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#7-%E9%AA%8C%E8%AF%81%E9%83%A8%E7%BD%B2%E6%98%AF%E5%90%A6%E6%88%90%E5%8A%9F&quot;&gt;7. 验证部署是否成功&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#8-%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F%E8%AF%B4%E6%98%8E%E5%AE%8C%E6%95%B4&quot;&gt;8. 环境变量说明（完整）&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#9-%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98%E4%B8%8E%E6%8E%92%E6%9F%A5&quot;&gt;9. 常见问题与排查&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;1. 项目简介&lt;/h2&gt;
&lt;p&gt;宝塔面板当前内置的 Let&apos;s Encrypt DNS 验证方式，目前11版本依旧只能使用 Cloudflare Global API Key作为DNS解析管理的令牌。私以为该方案权限过大，不利于最小权限控制。&lt;/p&gt;
&lt;p&gt;本项目通过 Cloudflare API Token + ACME DNS-01 自动签发证书，并调用宝塔 API 自动部署到指定站点。&lt;/p&gt;
&lt;p&gt;核心特点如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;使用 Cloudflare API Token，避免使用高权限 Global API Key。&lt;/li&gt;
&lt;li&gt;通过宝塔 API 读取站点 SSL 信息并自动更新证书。&lt;/li&gt;
&lt;li&gt;主进程采用信号触发机制，未触发时 &lt;code&gt;signal.pause()&lt;/code&gt; 深度休眠，几乎不消耗 CPU。&lt;/li&gt;
&lt;li&gt;证书更新在子进程执行，支持超时强制回收，避免卡死。&lt;/li&gt;
&lt;li&gt;验证当前证书是否受信任 CA 签发，异常证书可自动重签。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 实现原理&lt;/h2&gt;
&lt;p&gt;脚本运行后会启动守护进程并写入 &lt;code&gt;update_ssl.pid&lt;/code&gt;。平时不主动执行续签，只有收到 &lt;code&gt;SIGUSR1&lt;/code&gt; 信号时才触发一次更新流程：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;调用宝塔接口获取站点 SSL 信息。&lt;/li&gt;
&lt;li&gt;判断是否需要续签：
&lt;ul&gt;
&lt;li&gt;如果当前证书不受信任，则直接重签。&lt;/li&gt;
&lt;li&gt;如果证书受信任，则在到期前 30 天内进入续签窗口。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;使用 ACME + Cloudflare DNS-01 签发新证书。&lt;/li&gt;
&lt;li&gt;调用宝塔接口部署证书。&lt;/li&gt;
&lt;li&gt;恢复休眠，等待下一次信号触发。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;3. 部署前准备&lt;/h2&gt;
&lt;h3&gt;3.1 获取宝塔 API 信息&lt;/h3&gt;
&lt;p&gt;进入 &lt;code&gt;宝塔面板 -&amp;gt; 面板设置 -&amp;gt; API接口&lt;/code&gt;，开启 API 后准备以下信息：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;BT_KEY&lt;/code&gt;：API 密钥。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BT_PANEL&lt;/code&gt;：API 地址，建议使用本机地址，例如 &lt;code&gt;https://127.0.0.1:8888&lt;/code&gt;（按实际端口替换）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;建议将 API 白名单限制为 &lt;code&gt;127.0.0.1&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;3.2 准备 Cloudflare Token&lt;/h3&gt;
&lt;p&gt;创建 Cloudflare API Token，至少包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Zone -&amp;gt; DNS -&amp;gt; Edit&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Zone -&amp;gt; Zone -&amp;gt; Read&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Token 仅授权目标 Zone，避免过大权限。&lt;/p&gt;
&lt;p&gt;可参考教程：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cloudflare API Token 获取说明：https://zhuanlan.zhihu.com/p/1918449030331073934&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3.3 上传项目文件&lt;/h3&gt;
&lt;p&gt;在宝塔文件管理中创建项目目录，创建并编辑以下文件：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;update_ssl.py&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;requirements.txt&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;requirements.txt&lt;/code&gt; 内容如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;acme==5.3.1
certifi==2026.2.25
dnspython==2.7.0
josepy==2.2.0
pyOpenSSL==25.3.0
python-dotenv==1.2.2
requests==2.32.5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;update_ssl.py&lt;/code&gt; 内容如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import hashlib
import json
import logging
import logging.handlers
import multiprocessing
import os
import re
import signal
import ssl
import sys
import time
from datetime import datetime, timedelta

import certifi
import dns.resolver
import josepy as jose
import requests
from dotenv import load_dotenv

load_dotenv()
from acme import challenges, client as acme_client, crypto_util, messages
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from OpenSSL import crypto as openssl_crypto

# ========================= 日志配置 =========================

LOG_TO_FILE = os.environ.get(&quot;LOG_TO_FILE&quot;, &quot;false&quot;).lower() in (&quot;true&quot;, &quot;1&quot;, &quot;yes&quot;)
LOG_FILE = os.environ.get(
    &quot;LOG_FILE&quot;,
    os.path.join(os.path.dirname(os.path.abspath(__file__)), &quot;update_ssl.log&quot;),
)

logger = logging.getLogger(&quot;update_ssl&quot;)
logger.setLevel(logging.DEBUG)

if LOG_TO_FILE:
    _file_handler = logging.handlers.RotatingFileHandler(
        LOG_FILE, maxBytes=5 * 1024 * 1024, backupCount=1, encoding=&quot;utf-8&quot;
    )
    _file_handler.setLevel(logging.DEBUG)
    _file_handler.setFormatter(
        logging.Formatter(&quot;%(asctime)s [%(levelname)s] %(message)s&quot;, datefmt=&quot;%Y-%m-%d %H:%M:%S&quot;)
    )
    logger.addHandler(_file_handler)

_console_handler = logging.StreamHandler()
_console_handler.setLevel(logging.INFO)
_console_handler.setFormatter(
    logging.Formatter(&quot;[%(levelname)s] %(message)s&quot;)
)
logger.addHandler(_console_handler)

# ========================= 配置变量（从环境变量读取） =========================

def _require_env(name):
    &quot;&quot;&quot;读取必需的环境变量，缺失则报错退出&quot;&quot;&quot;
    val = os.environ.get(name)
    if not val:
        logger.error(f&quot;缺少必需的环境变量: {name}&quot;)
        sys.exit(1)
    return val

# 宝塔面板配置
BT_KEY = _require_env(&quot;BT_KEY&quot;)
BT_PANEL = _require_env(&quot;BT_PANEL&quot;)

# 站点名称（宝塔面板中的站点标识）
SITE_NAME = _require_env(&quot;SITE_NAME&quot;)

# Cloudflare 配置
CF_API = os.environ.get(&quot;CF_API&quot;, &quot;https://api.cloudflare.com/client/v4&quot;)
CF_API_TOKEN = _require_env(&quot;CF_API_TOKEN&quot;)
CF_ZONE_NAME = _require_env(&quot;CF_ZONE_NAME&quot;)
CF_RECORD_NAME = _require_env(&quot;CF_RECORD_NAME&quot;)

# ACME (Let&apos;s Encrypt) 配置
ACME_DIRECTORY_URL = os.environ.get(&quot;ACME_DIRECTORY_URL&quot;, &quot;https://acme-v02.api.letsencrypt.org/directory&quot;)
ACME_EMAIL = os.environ.get(&quot;ACME_EMAIL&quot;, &quot;test@message.com&quot;)

# 子进程超时（秒），默认5分钟
CHILD_TIMEOUT = int(os.environ.get(&quot;CHILD_TIMEOUT&quot;, &quot;300&quot;))

# 证书续签配置
RENEW_DAYS_BEFORE = 30
DNS_PROPAGATION_TIMEOUT = 120
DNS_CHECK_INTERVAL = 10
DNS_NAMESERVERS = [&quot;223.5.5.5&quot;, &quot;8.8.8.8&quot;] # 阿里DNS和Google DNS

# ========================= 宝塔 API 类 =========================


class BtApi:
    def __init__(self, bt_panel, bt_key):
        self.__BT_PANEL = bt_panel
        self.__BT_KEY = bt_key

    def __get_md5(self, s):
        m = hashlib.md5()
        m.update(s.encode(&quot;utf-8&quot;))
        return m.hexdigest()

    def __get_key_data(self):
        now_time = int(time.time())
        p_data = {
            &quot;request_token&quot;: self.__get_md5(
                str(now_time) + &quot;&quot; + self.__get_md5(self.__BT_KEY)
            ),
            &quot;request_time&quot;: now_time,
        }
        return p_data

    def __http_post_cookie(self, url, p_data, timeout=1800):
        import http.cookiejar
        import urllib.request

        cookie_file = &quot;./&quot; + self.__get_md5(self.__BT_PANEL) + &quot;.cookie&quot;
        cookie_obj = http.cookiejar.MozillaCookieJar(cookie_file)
        if os.path.exists(cookie_file):
            cookie_obj.load(cookie_file, ignore_discard=True, ignore_expires=True)

        # 忽略SSL证书验证（宝塔面板自签名证书）
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        handler = urllib.request.HTTPCookieProcessor(cookie_obj)
        https_handler = urllib.request.HTTPSHandler(context=ctx)

        data = urllib.parse.urlencode(p_data).encode(&quot;utf-8&quot;)
        req = urllib.request.Request(url, data)
        opener = urllib.request.build_opener(handler, https_handler)
        response = opener.open(req, timeout=timeout)
        cookie_obj.save(ignore_discard=True, ignore_expires=True)
        result = response.read()
        if isinstance(result, bytes):
            result = result.decode(&quot;utf-8&quot;)
        return result

    def get_ssl(self, site_name):
        &quot;&quot;&quot;获取站点SSL信息&quot;&quot;&quot;
        url = self.__BT_PANEL + &quot;/site?action=GetSSL&quot;
        p_data = self.__get_key_data()
        p_data[&quot;siteName&quot;] = site_name
        result = self.__http_post_cookie(url, p_data)
        return json.loads(result)

    def set_ssl(self, site_name, key, csr):
        &quot;&quot;&quot;设置站点SSL证书&quot;&quot;&quot;
        url = self.__BT_PANEL + &quot;/site?action=SetSSL&quot;
        p_data = self.__get_key_data()
        p_data[&quot;type&quot;] = -1
        p_data[&quot;siteName&quot;] = site_name
        p_data[&quot;key&quot;] = key
        p_data[&quot;csr&quot;] = csr
        result = self.__http_post_cookie(url, p_data)
        return json.loads(result)


# ========================= 证书验证 =========================


def verify_cert_trusted(cert_pem):
    &quot;&quot;&quot;
    使用 certifi CA 库验证证书是否由权威机构颁发。
    cert_pem 可能包含 fullchain（叶子证书 + 中间证书）。
    返回 True 表示受信任，False 表示不受信任。
    &quot;&quot;&quot;
    # 加载 CA 证书库
    store = openssl_crypto.X509Store()
    with open(certifi.where(), &quot;r&quot;) as f:
        ca_bundle = f.read()
    ca_pems = re.findall(
        r&quot;-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----&quot;,
        ca_bundle,
        re.DOTALL,
    )
    for ca_pem in ca_pems:
        try:
            ca_cert = openssl_crypto.load_certificate(
                openssl_crypto.FILETYPE_PEM, ca_pem
            )
            store.add_cert(ca_cert)
        except openssl_crypto.Error:
            pass

    # 从 cert_pem 中提取叶子和中间证书
    all_certs_pem = re.findall(
        r&quot;-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----&quot;,
        cert_pem,
        re.DOTALL,
    )
    if not all_certs_pem:
        return False

    # 第一个是叶子证书，其余是中间证书
    leaf_cert = openssl_crypto.load_certificate(
        openssl_crypto.FILETYPE_PEM, all_certs_pem[0]
    )
    intermediate_certs = []
    for ic_pem in all_certs_pem[1:]:
        intermediate_certs.append(
            openssl_crypto.load_certificate(openssl_crypto.FILETYPE_PEM, ic_pem)
        )

    # 验证时提供中间证书链
    store_ctx = openssl_crypto.X509StoreContext(
        store, leaf_cert, intermediate_certs
    )
    try:
        store_ctx.verify_certificate()
        return True
    except openssl_crypto.X509StoreContextError:
        return False


# ========================= Cloudflare DNS =========================


def cf_headers():
    &quot;&quot;&quot;返回 Cloudflare API 请求头&quot;&quot;&quot;
    return {
        &quot;Authorization&quot;: f&quot;Bearer {CF_API_TOKEN}&quot;,
        &quot;Content-Type&quot;: &quot;application/json&quot;,
    }


def cf_get_zone_id():
    &quot;&quot;&quot;获取 Cloudflare Zone ID&quot;&quot;&quot;
    resp = requests.get(
        f&quot;{CF_API}/zones&quot;,
        params={&quot;name&quot;: CF_ZONE_NAME},
        headers=cf_headers(),
    )
    resp.raise_for_status()
    data = resp.json()
    if not data.get(&quot;result&quot;):
        raise RuntimeError(f&quot;未找到 Zone: {CF_ZONE_NAME}&quot;)
    return data[&quot;result&quot;][0][&quot;id&quot;]


def cf_create_txt_record(zone_id, record_name, value):
    &quot;&quot;&quot;在 Cloudflare 创建 TXT 记录，返回记录 ID&quot;&quot;&quot;
    payload = {&quot;type&quot;: &quot;TXT&quot;, &quot;name&quot;: record_name, &quot;content&quot;: value, &quot;ttl&quot;: 120}
    resp = requests.post(
        f&quot;{CF_API}/zones/{zone_id}/dns_records&quot;,
        json=payload,
        headers=cf_headers(),
    )
    resp.raise_for_status()
    result = resp.json()
    if not result.get(&quot;success&quot;):
        raise RuntimeError(f&quot;创建 TXT 记录失败: {result.get(&apos;errors&apos;)}&quot;)
    return result[&quot;result&quot;][&quot;id&quot;]


def cf_delete_txt_record(zone_id, record_id):
    &quot;&quot;&quot;删除 Cloudflare TXT 记录&quot;&quot;&quot;
    resp = requests.delete(
        f&quot;{CF_API}/zones/{zone_id}/dns_records/{record_id}&quot;,
        headers=cf_headers(),
    )
    resp.raise_for_status()


# ========================= ACME 证书签发 =========================


def generate_account_key():
    &quot;&quot;&quot;每次运行时生成新的 ACME 账户密钥，返回 josepy.JWKRSA&quot;&quot;&quot;
    logger.info(&quot;生成 ACME 账户密钥...&quot;)
    private_key = rsa.generate_private_key(
        public_exponent=65537, key_size=2048, backend=default_backend()
    )
    return jose.JWKRSA(key=private_key)


def wait_for_dns_propagation(record_name, expected_value):
    &quot;&quot;&quot;等待 DNS TXT 记录传播，使用公共 DNS 服务器检查&quot;&quot;&quot;
    logger.info(
        f&quot;等待 DNS 传播: {record_name} -&amp;gt; {expected_value[:20]}...&quot;
    )
    resolver = dns.resolver.Resolver()
    resolver.nameservers = DNS_NAMESERVERS

    start = time.time()
    while time.time() - start &amp;lt; DNS_PROPAGATION_TIMEOUT:
        try:
            answers = resolver.resolve(record_name, &quot;TXT&quot;)
            for rdata in answers:
                for txt_string in rdata.strings:
                    if txt_string.decode(&quot;utf-8&quot;) == expected_value:
                        elapsed = int(time.time() - start)
                        logger.info(f&quot;DNS 传播完成 (耗时 {elapsed}s)&quot;)
                        return True
        except (
            dns.resolver.NXDOMAIN,
            dns.resolver.NoAnswer,
            dns.resolver.NoNameservers,
            dns.exception.Timeout,
        ):
            pass
        time.sleep(DNS_CHECK_INTERVAL)

    logger.warning(f&quot;DNS 传播超时 ({DNS_PROPAGATION_TIMEOUT}s)，继续尝试验证...&quot;)
    return False


def issue_certificate(domain):
    &quot;&quot;&quot;
    使用 ACME 协议通过 Cloudflare DNS 验证签发证书。
    返回 (private_key_pem: str, fullchain_pem: str)，失败返回 (None, None)。
    &quot;&quot;&quot;
    # 1. 加载/创建账户密钥
    account_key = generate_account_key()

    # 2. 创建 ACME 客户端
    logger.info(f&quot;连接 ACME 服务器: {ACME_DIRECTORY_URL}&quot;)
    net = acme_client.ClientNetwork(account_key, user_agent=&quot;bt-update-ssl/1.0&quot;)
    directory = messages.Directory.from_json(
        net.get(ACME_DIRECTORY_URL).json()
    )
    acme = acme_client.ClientV2(directory, net=net)

    # 3. 注册/获取账户
    logger.info(f&quot;注册 ACME 账户 (邮箱: {ACME_EMAIL})...&quot;)
    registration = messages.NewRegistration.from_data(
        terms_of_service_agreed=True, email=ACME_EMAIL
    )
    try:
        acme.new_account(registration)
    except Exception as e:
        # 账户可能已存在，尝试获取
        if &quot;already&quot; in str(e).lower() or &quot;conflict&quot; in str(e).lower():
            logger.info(&quot;ACME 账户已存在，继续使用&quot;)
        else:
            raise
    logger.info(&quot;ACME 账户就绪&quot;)

    # 4. 生成证书私钥和 CSR
    logger.info(f&quot;为域名 {domain} 生成私钥和 CSR...&quot;)
    private_key = rsa.generate_private_key(
        public_exponent=65537, key_size=2048, backend=default_backend()
    )
    private_key_pem = private_key.private_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.TraditionalOpenSSL,
        encryption_algorithm=serialization.NoEncryption(),
    )
    csr_pem = crypto_util.make_csr(private_key_pem, [domain])

    # 5. 创建证书订单
    logger.info(&quot;创建证书订单...&quot;)
    order = acme.new_order(csr_pem)

    # 6. 处理 DNS-01 验证
    zone_id = cf_get_zone_id()
    created_records = []  # 记录创建的 DNS 记录以便清理

    try:
        for authz in order.authorizations:
            authz_domain = authz.body.identifier.value
            logger.info(f&quot;处理域名验证: {authz_domain}&quot;)

            # 查找 DNS-01 挑战
            dns01_chall = None
            for chall_body in authz.body.challenges:
                if isinstance(chall_body.chall, challenges.DNS01):
                    dns01_chall = chall_body
                    break

            if dns01_chall is None:
                raise RuntimeError(
                    f&quot;未找到 DNS-01 验证方式: {authz_domain}&quot;
                )

            # 获取验证值
            response, validation = dns01_chall.response_and_validation(
                account_key
            )
            txt_record_name = dns01_chall.chall.validation_domain_name(
                authz_domain
            )

            # 在 Cloudflare 创建 TXT 记录
            logger.info(
                f&quot;创建 DNS TXT 记录: {txt_record_name} = {validation}&quot;
            )
            record_id = cf_create_txt_record(zone_id, txt_record_name, validation)
            created_records.append(record_id)

            # 等待 DNS 传播
            wait_for_dns_propagation(txt_record_name, validation)

            # 通知 ACME 服务器验证
            logger.info(&quot;通知 ACME 服务器进行验证...&quot;)
            acme.answer_challenge(dns01_chall, response)

        # 7. 等待并完成订单
        logger.info(&quot;等待 ACME 服务器完成验证并签发证书...&quot;)
        deadline = datetime.now() + timedelta(seconds=240)
        finalized_order = acme.poll_and_finalize(order, deadline=deadline)

        fullchain_pem = finalized_order.fullchain_pem
        key_str = private_key_pem.decode(&quot;utf-8&quot;) if isinstance(
            private_key_pem, bytes
        ) else private_key_pem

        logger.info(&quot;证书签发成功！&quot;)
        return key_str, fullchain_pem

    except Exception as e:
        logger.error(f&quot;证书签发失败: {type(e).__name__}: {e}&quot;)
        return None, None

    finally:
        # 清理 DNS 记录
        for record_id in created_records:
            try:
                logger.info(f&quot;清理 DNS TXT 记录: {record_id}&quot;)
                cf_delete_txt_record(zone_id, record_id)
            except Exception as e:
                logger.warning(f&quot;清理 DNS 记录失败: {type(e).__name__}: {e}&quot;)


# ========================= 证书更新逻辑 =========================


def update_certificate():
    &quot;&quot;&quot;完整的证书检查与更新流程（在子进程中执行）&quot;&quot;&quot;
    try:
        _do_update_certificate()
    except Exception as e:
        logger.error(f&quot;无法处理的异常: {type(e).__name__}: {e}&quot;, exc_info=True)


def _do_update_certificate():
    &quot;&quot;&quot;证书更新的具体实现&quot;&quot;&quot;
    bt = BtApi(BT_PANEL, BT_KEY)

    # --- 获取当前SSL信息 ---
    logger.info(f&quot;获取站点 {SITE_NAME} 的SSL信息...&quot;)
    try:
        ssl_info = bt.get_ssl(SITE_NAME)
    except Exception as e:
        logger.error(f&quot;获取SSL信息异常: {type(e).__name__}: {e}, 请检查宝塔面板连接和站点名称是否正确&quot;)
        return

    # 步骤0: 如果 status 为 false，exit 0
    if not ssl_info.get(&quot;status&quot;, False):
        logger.info(&quot;SSL状态为false，退出&quot;)
        return

    # 步骤1: 如果 cert_data 为空，exit 0
    cert_data = ssl_info.get(&quot;cert_data&quot;)
    if not cert_data:
        logger.info(&quot;cert_data为空，退出&quot;)
        return

    # 步骤2: 验证当前证书是否由权威机构颁发
    cert_pem = ssl_info.get(&quot;csr&quot;, &quot;&quot;)
    need_renew = False

    if cert_pem:
        try:
            trusted = verify_cert_trusted(cert_pem)
        except Exception as e:
            logger.error(f&quot;证书验证异常: {type(e).__name__}: {e}&quot;)
            trusted = False
        if not trusted:
            logger.warning(&quot;当前证书不受信任（非权威CA颁发），需要重新签发&quot;)
            need_renew = True

    # 步骤3: 如果证书受信任，检查是否需要续签
    if not need_renew:
        not_after_str = cert_data.get(&quot;notAfter&quot;, &quot;&quot;)
        if not_after_str:
            not_after = datetime.strptime(not_after_str, &quot;%Y-%m-%d&quot;)
            renew_date = not_after - timedelta(days=RENEW_DAYS_BEFORE)
            today = datetime.now()

            if today &amp;lt; renew_date:
                days_left = (not_after - today).days
                logger.info(
                    f&quot;证书有效期至 {not_after_str}，距到期还有 {days_left} 天，&quot;
                    f&quot;续签阈值为到期前 {RENEW_DAYS_BEFORE} 天，无需续签&quot;
                )
                return
            else:
                logger.info(
                    f&quot;证书将于 {not_after_str} 到期，已进入续签窗口，开始续签&quot;
                )
                need_renew = True

    if not need_renew:
        logger.info(&quot;无需续签&quot;)
        return

    # 步骤4-5: 使用 ACME 签发证书
    logger.info(f&quot;域名: {CF_RECORD_NAME}&quot;)
    key, fullchain = issue_certificate(CF_RECORD_NAME)

    if not key or not fullchain:
        logger.error(&quot;证书签发失败&quot;)
        return

    # 步骤6: 将证书写入宝塔面板
    logger.info(f&quot;正在将证书部署到站点 {SITE_NAME}...&quot;)
    try:
        result = bt.set_ssl(SITE_NAME, key, fullchain)
    except Exception as e:
        logger.error(f&quot;部署证书异常: {type(e).__name__}: {e}&quot;)
        return

    if result.get(&quot;status&quot;):
        logger.info(f&quot;证书部署成功: {result.get(&apos;msg&apos;, &apos;&apos;)}&quot;)
    else:
        logger.error(f&quot;证书部署失败: {result.get(&apos;msg&apos;, &apos;未知错误&apos;)}&quot;)


# ========================= 守护进程主逻辑 =========================


def main():
    child_proc = None
    should_update = False

    def handle_sigusr1(signum, frame):
        nonlocal should_update
        should_update = True

    def handle_sigterm(signum, frame):
        &quot;&quot;&quot;收到 SIGTERM/SIGINT 时退出&quot;&quot;&quot;
        nonlocal child_proc
        logger.info(&quot;收到退出信号，正在退出...&quot;)
        if child_proc and child_proc.is_alive():
            child_proc.terminate()
            child_proc.join(timeout=10)
            if child_proc.is_alive():
                child_proc.kill()
        # 删除 PID 文件
        if os.path.exists(pid_file):
            os.unlink(pid_file)
        sys.exit(0)

    # 注册信号
    signal.signal(signal.SIGUSR1, handle_sigusr1)
    signal.signal(signal.SIGTERM, handle_sigterm)
    signal.signal(signal.SIGINT, handle_sigterm)

    # 写入 PID 文件
    pid_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), &quot;update_ssl.pid&quot;)
    with open(pid_file, &quot;w&quot;) as f:
        f.write(str(os.getpid()))

    logger.info(&quot;=&quot; * 30)
    logger.info(f&quot;守护进程已启动，PID: {os.getpid()}&quot;)
    logger.info(f&quot;启动时间: {datetime.now().strftime(&apos;%Y-%m-%d %H:%M:%S&apos;)}&quot;)
    logger.info(f&quot;PID 文件: {pid_file}&quot;)
    logger.info(f&quot;日志文件: {LOG_FILE}&quot;)
    logger.info(f&quot;发送 SIGUSR1 触发证书更新: kill -USR1 $(cat {pid_file})&quot;)
    logger.info(f&quot;子进程超时: {CHILD_TIMEOUT}s&quot;)

    while True:
        should_update = False

        # 休眠，等待系统中断信号唤醒
        signal.pause()

        if not should_update:
            continue

        # 启动子进程执行更新
        logger.info(&quot;收到 SIGUSR1，启动子进程执行证书更新...&quot;)
        child_proc = multiprocessing.Process(
            target=update_certificate, daemon=True
        )
        child_proc.start()

        # 等待子进程完成（期间忽略 SIGUSR1）
        child_proc.join(timeout=CHILD_TIMEOUT)

        if child_proc.is_alive():
            logger.warning(f&quot;子进程超时 ({CHILD_TIMEOUT}s)，强制终止&quot;)
            child_proc.terminate()
            child_proc.join(timeout=5)
            if child_proc.is_alive():
                child_proc.kill()
                child_proc.join()

        exit_code = child_proc.exitcode
        if exit_code == 0:
            logger.info(&quot;子进程正常完成&quot;)
        else:
            logger.warning(f&quot;子进程退出码: {exit_code}&quot;)

        child_proc = None
        should_update = False
        logger.info(&quot;恢复休眠，等待下次信号...&quot;)


if __name__ == &quot;__main__&quot;:
    main()

&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3.4 准备 &lt;code&gt;SITE_NAME&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;SITE_NAME&lt;/code&gt; 必须与宝塔 API 中站点标识一致，不等同于域名。&lt;/p&gt;
&lt;p&gt;推荐获取方式：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;打开目标站点 SSL 设置页面。&lt;/li&gt;
&lt;li&gt;按 &lt;code&gt;F12&lt;/code&gt; 打开开发者工具，切换到“网络”。&lt;/li&gt;
&lt;li&gt;在页面点击一次 &lt;code&gt;SSL&lt;/code&gt;，观察请求 &lt;code&gt;/site?action=GetSSL&lt;/code&gt; 中 &lt;code&gt;负载&lt;/code&gt; 的 &lt;code&gt;siteName&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;将该值填入环境变量 &lt;code&gt;SITE_NAME&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. 在宝塔中部署 Python 服务&lt;/h2&gt;
&lt;h3&gt;4.1 创建 Python 项目&lt;/h3&gt;
&lt;p&gt;进入 &lt;code&gt;网站 -&amp;gt; Python项目 -&amp;gt; 添加项目&lt;/code&gt;，建议配置：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;项目路径：&lt;code&gt;update_ssl.py&lt;/code&gt; 所在目录。&lt;/li&gt;
&lt;li&gt;Python 版本：建议 &lt;code&gt;3.13.3&lt;/code&gt;（若无可先安装）。&lt;/li&gt;
&lt;li&gt;虚拟环境：新建即可，名称自定义。&lt;/li&gt;
&lt;li&gt;启动命令：&lt;code&gt;python3 update_ssl.py&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;启动用户：建议使用普通用户；使用 &lt;code&gt;www&lt;/code&gt; 也可运行。&lt;/li&gt;
&lt;li&gt;安装依赖：选择 &lt;code&gt;requirements.txt&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;4.2 配置环境变量&lt;/h3&gt;
&lt;p&gt;在 Python 项目环境变量中配置（见第 8 章完整说明）。最少需要填写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;BT_KEY=你的宝塔API密钥
BT_PANEL=https://127.0.0.1:你的宝塔端口
SITE_NAME=宝塔站点标识siteName
CF_API_TOKEN=Cloudflare API Token
CF_ZONE_NAME=主域名，例如example.com
CF_RECORD_NAME=签发证书的完整域名，例如www.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4.3 启动项目&lt;/h3&gt;
&lt;p&gt;启动 Python 项目后，脚本会写入 PID 文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;项目目录&amp;gt;/update_ssl.pid
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;后续计划任务通过该 PID 文件向主进程发送 &lt;code&gt;SIGUSR1&lt;/code&gt; 信号。&lt;/p&gt;
&lt;h3&gt;4.4 项目日志配置&lt;/h3&gt;
&lt;p&gt;进入 &lt;code&gt;Python项目 -&amp;gt; 设置 -&amp;gt; 项目日志&lt;/code&gt;，建议将日志目录设在项目目录下 &lt;code&gt;logs/&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;默认日志输出到控制台（可在宝塔项目日志查看）。&lt;/li&gt;
&lt;li&gt;若需要额外文件日志，请设置 &lt;code&gt;LOG_TO_FILE=true&lt;/code&gt; 并配置 &lt;code&gt;LOG_FILE&lt;/code&gt;（见第 8 章）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;5. 初始化 SSL 状态（建议）&lt;/h2&gt;
&lt;p&gt;脚本在读取宝塔 SSL 信息时，若 &lt;code&gt;status=false&lt;/code&gt; 或 &lt;code&gt;cert_data&lt;/code&gt; 为空会直接退出本次更新。&lt;/p&gt;
&lt;p&gt;因此建议先在宝塔给站点配置一张临时证书（可自签），确保 SSL 状态已启用且存在证书数据，然后再交由脚本自动续签替换。&lt;/p&gt;
&lt;p&gt;可选参考（自签证书生成工具）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;top.tools：https://tools.top/certificate-generate.html&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;6. 配置计划任务触发更新&lt;/h2&gt;
&lt;h3&gt;6.1 新建计划任务&lt;/h3&gt;
&lt;p&gt;进入 &lt;code&gt;计划任务 -&amp;gt; 添加任务 -&amp;gt; Shell脚本&lt;/code&gt;，脚本内容如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kill -USR1 $(cat /path/to/your/project/update_ssl.pid)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;将 &lt;code&gt;/path/to/your/project&lt;/code&gt; 替换为实际项目路径。&lt;/p&gt;
&lt;p&gt;建议：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;任务执行用户与 Python 项目启动用户保持一致。&lt;/li&gt;
&lt;li&gt;执行周期按需求设置（例如每天一次）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;6.2 手动执行一次&lt;/h3&gt;
&lt;p&gt;保存任务后先手动执行一次，确认触发链路正常。&lt;/p&gt;
&lt;h2&gt;7. 验证部署是否成功&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;网站 -&amp;gt; Python项目 -&amp;gt; 你的项目 -&amp;gt; 设置 -&amp;gt; 项目日志&lt;/code&gt; 观察日志。&lt;/p&gt;
&lt;p&gt;关键日志示例：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;收到 SIGUSR1，启动子进程执行证书更新...&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;证书签发成功！&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;证书部署成功: ...&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;恢复休眠，等待下次信号...&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;同时到站点 SSL 页面确认新证书是否已自动部署。&lt;/p&gt;
&lt;h2&gt;8. 环境变量说明（完整）&lt;/h2&gt;
&lt;h3&gt;8.1 必填变量&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;变量名&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;th&gt;示例&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BT_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;宝塔 API 密钥&lt;/td&gt;
&lt;td&gt;&lt;code&gt;xxxx&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BT_PANEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;宝塔 API 地址&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://127.0.0.1:8888&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SITE_NAME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;宝塔站点标识（&lt;code&gt;siteName&lt;/code&gt;）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;example.com&lt;/code&gt; 或面板内部标识&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CF_API_TOKEN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cloudflare API Token&lt;/td&gt;
&lt;td&gt;&lt;code&gt;xxxx&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CF_ZONE_NAME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cloudflare Zone 名称&lt;/td&gt;
&lt;td&gt;&lt;code&gt;example.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CF_RECORD_NAME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;需要签发证书的完整域名&lt;/td&gt;
&lt;td&gt;&lt;code&gt;www.example.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;8.2 选填变量&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;变量名&lt;/th&gt;
&lt;th&gt;默认值&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CF_API&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://api.cloudflare.com/client/v4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cloudflare API 基础地址&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ACME_DIRECTORY_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://acme-v02.api.letsencrypt.org/directory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ACME 目录地址（默认生产环境）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ACME_EMAIL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;test@message.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ACME 账户邮箱&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CHILD_TIMEOUT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;300&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;子进程超时时间（秒）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LOG_TO_FILE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;是否写入文件日志（&lt;code&gt;true/1/yes&lt;/code&gt; 生效）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LOG_FILE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;项目目录&amp;gt;/update_ssl.log&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;文件日志路径&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;8.3 固定参数（需改代码）&lt;/h3&gt;
&lt;p&gt;以下参数当前写死在脚本中，如需调整需修改代码：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;续签窗口：&lt;code&gt;RENEW_DAYS_BEFORE = 30&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;DNS 传播超时：&lt;code&gt;DNS_PROPAGATION_TIMEOUT = 120&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;DNS 查询间隔：&lt;code&gt;DNS_CHECK_INTERVAL = 10&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;DNS 解析服务器：&lt;code&gt;223.5.5.5&lt;/code&gt;、&lt;code&gt;8.8.8.8&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;9. 常见问题与排查&lt;/h2&gt;
&lt;h3&gt;9.1 定时任务执行但无更新日志&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;检查 &lt;code&gt;update_ssl.pid&lt;/code&gt; 是否存在。&lt;/li&gt;
&lt;li&gt;检查 PID 是否对应运行中的 Python 进程。&lt;/li&gt;
&lt;li&gt;检查任务用户是否有权限读取 PID 文件并发送信号。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;9.2 日志提示缺少环境变量&lt;/h3&gt;
&lt;p&gt;脚本启动时会校验必填变量，缺失即退出。请逐项核对第 8.1 节。&lt;/p&gt;
&lt;h3&gt;9.3 Cloudflare 相关报错&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;检查 Token 权限是否包含 &lt;code&gt;DNS Edit&lt;/code&gt; 与 &lt;code&gt;Zone Read&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;检查 &lt;code&gt;CF_ZONE_NAME&lt;/code&gt; 与实际 Zone 是否一致。&lt;/li&gt;
&lt;li&gt;检查 &lt;code&gt;CF_RECORD_NAME&lt;/code&gt; 是否属于该 Zone。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;9.4 ACME 验证失败&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;检查 DNS 记录是否成功创建。&lt;/li&gt;
&lt;li&gt;检查域名是否正确解析到当前站点。&lt;/li&gt;
&lt;li&gt;如 DNS 生效慢，可适当增大 &lt;code&gt;DNS_PROPAGATION_TIMEOUT&lt;/code&gt;（改代码）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;9.5 宝塔部署失败&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;检查 &lt;code&gt;BT_PANEL&lt;/code&gt; 地址和端口。&lt;/li&gt;
&lt;li&gt;检查 &lt;code&gt;BT_KEY&lt;/code&gt; 是否正确。&lt;/li&gt;
&lt;li&gt;检查 &lt;code&gt;SITE_NAME&lt;/code&gt; 是否准确。&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>使用我们的Slurm集群</title><link>https://fuwari.vercel.app/posts/usingslurm/usingslurm/</link><guid isPermaLink="true">https://fuwari.vercel.app/posts/usingslurm/usingslurm/</guid><description>本篇文章主要介绍了怎么使用我们内部的slurm集群</description><pubDate>Sun, 01 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Slurm集群使用指南&lt;/h1&gt;
&lt;p&gt;欢迎使用本集群。本文档将引导您完成从环境配置到作业提交的完整流程，帮助您高效利用计算资源。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;0. 系统架构&lt;/h2&gt;
&lt;p&gt;集群由六台服务器组成，通过一台 &lt;strong&gt;40G 以太网交换机&lt;/strong&gt;互连，构建高速计算网络。&lt;/p&gt;
&lt;p&gt;&amp;lt;!-- ```mermaid
graph TD
Switch[Mellanox SX6012 40G 交换机]&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Login[登录服务器]
GPU[&quot;GPU服务器&amp;lt;br/&amp;gt;64c128t | 384GB RAM&amp;lt;br/&amp;gt;8×NVIDIA 4090 48GB&quot;]
CPU1[&quot;CPU服务器1&amp;lt;br/&amp;gt;48c96t | 128GB RAM&quot;]
CPU2[&quot;CPU服务器2&amp;lt;br/&amp;gt;48c96t | 128GB RAM&quot;]
Storage[&quot;存储服务器&amp;lt;br/&amp;gt;48TB 阵列&amp;lt;br/&amp;gt;1TB NVMe SSD 缓存&amp;lt;br/&amp;gt;256GB Optane PMEM&quot;]

Login --- Switch
GPU --- Switch
CPU1 --- Switch
CPU2 --- Switch
Storage --- Switch
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;--&amp;gt;


**硬件配置明细**：

- **控制服务器**：控制节点，用于作业调度、提供REST API。
- **登录服务器**：用户接入节点，用于作业提交和管理。
- **GPU服务器**：64核心128线程，120GB内存，8张NVIDIA 4090 48GB显卡。
- **CPU服务器1**：48核心96线程，128GB内存。
- **CPU服务器2**：48核心96线程，256GB内存。
- **存储服务器**：48TB硬盘阵列，配备1TB NVMe SSD高速缓存和256GB Intel Optane PMEM超高速缓存。

&amp;lt;img width=&quot;1512&quot; height=&quot;867&quot; alt=&quot;零信任平台入口&quot; src=&quot;/posts/usingSlurm/topology.png&quot; /&amp;gt;

注意：控制节点和登陆节点是一台mac mini上跑的虚拟机。因此，对于登陆节点，请不要再上面编译程序或者运行代码，基本上编不出来，编出来了你在计算节点上也跑不了~~你要交叉编译那我也没啥好说的~~。

---

## 1. 环境与基本配置

出于安全因素，除SSH，所有的敏感业务访问都必须通过 `Cloudflare 零信任平台` 进行登录，Cloudflare会记录你的用户态，单次有效期最长为24小时。你的密码修改，证书获取，以及查看我们的web面板，都属于敏感业务。

### 1.1 登录零信任平台
访问 [https://ai4qc-hkust.cloudflareaccess.com/](https://ai4qc-hkust.cloudflareaccess.com/)。  
**注意**：必须使用已在 `ai4qc` 组织内的 GitHub 帐户登录，否则无法获得授权。  
成功登录后，您将看到如下界面：  
&amp;lt;img width=&quot;1512&quot; height=&quot;867&quot; alt=&quot;零信任平台入口&quot; src=&quot;/posts/usingSlurm/zt-entrypoint.png&quot; /&amp;gt;



### 1.2 访问用户管理界面
点击零信任平台中的 **profile** 或直接访问 [https://slurm-profile.thy.icu/](https://slurm-profile.thy.icu/) 进入用户管理界面。  
&amp;lt;img width=&quot;1512&quot; height=&quot;867&quot; alt=&quot;image&quot; src=&quot;/posts/usingSlurm/go-ldap-login.png&quot; /&amp;gt;


### 1.3 首次登录与初始密码
- 点击 **OAuth 登录**，使用您的 GitHub 账号授权。初次登录将自动完成用户注册。
- 登录成功后进入个人主页。**新用户会弹出初始密码**，请妥善保存；您可以立即修改或暂时忽略。  
&amp;lt;img width=&quot;1512&quot; height=&quot;867&quot; alt=&quot;image&quot; src=&quot;/posts/usingSlurm/profile-page.png&quot; /&amp;gt;

## 1.4 配置终端（可选）
默认登录 Shell 为 **zsh**，其具备强大的自动补全和主题功能。如需切换为 bash，请在登录后进入 **“终端设置”** → **“Shell”** 进行修改。


### 1.5 获取 SSH 证书
在个人主页中，找到 **“生成并下载 SSH 证书”** 区域，点击 **“生成”** 按钮。系统将生成一对证书并自动下载为一个压缩包：
- **私钥文件**：文件名格式为 `[用户名]-[日期]`（例如 `zhangsan-20250302`）
- **签名证书**：文件名格式为 `[用户名]-[日期]-cert.crt`（例如 `zhangsan-20250302-cert.crt`）

解压后，您可以选择以下任一方式使用证书连接集群：

#### 注意事项
```bash
以防有人不仔细看，SSH 记得加端口9933
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;1.5.1 使用命令行直接登录&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;ssh -i /path/to/private_key_file &amp;lt;username&amp;gt;@login.thy.icu -p 9933
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;（SSH 客户端会自动寻找同名的证书文件，无需额外指定。）&lt;/p&gt;
&lt;h4&gt;1.5.2 配置 SSH config&lt;/h4&gt;
&lt;p&gt;在 &lt;code&gt;~/.ssh/config&lt;/code&gt; 中添加如下配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Host slurm-login
    HostName login.thy.icu
    User &amp;lt;your-username&amp;gt;
    Port 9933
    IdentityFile /path/to/private_key_file
    CertificateFile /path/to/cert_file
    AddKeysToAgent yes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;之后可直接使用 &lt;code&gt;ssh slurm-login&lt;/code&gt; 登录。&lt;/p&gt;
&lt;h4&gt;1.5.3 在 VS Code 中使用&lt;/h4&gt;
&lt;p&gt;参考官方文档 &lt;a href=&quot;https://code.visualstudio.com/docs/remote/troubleshooting#_improving-your-security-with-a-dedicated-key&quot;&gt;Improving your security with a dedicated key&lt;/a&gt;，配置 Remote-SSH 使用证书。&lt;/p&gt;
&lt;h4&gt;1.5.4 在 Termius 中使用&lt;/h4&gt;
&lt;p&gt;Termius 支持直接导入 SSH 证书，请参阅 &lt;a href=&quot;https://termius.com/documentation/ssh-certificates&quot;&gt;Import SSH Certificate&lt;/a&gt; 完成设置。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;2. Slurm 作业调度系统使用&lt;/h2&gt;
&lt;p&gt;一些基本说明&lt;/p&gt;
&lt;h3&gt;2.1 分区说明&lt;/h3&gt;
&lt;p&gt;集群包含两个分区（Partition）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;cpu&lt;/strong&gt;：用于纯 CPU 计算任务（默认分区）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;gpu&lt;/strong&gt;：用于 GPU 加速任务。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;提交作业时若未指定分区，默认使用 &lt;code&gt;cpu&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;2.2 登入登陆节点&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;进入登陆节点后，会有一些基本的信息提示
&amp;lt;img width=&quot;1512&quot; height=&quot;867&quot; alt=&quot;image&quot; src=&quot;/posts/usingSlurm/login-default.png&quot; /&amp;gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;在个人目录下，有个共享文件夹，你可以在这放置共享文件，如协作目录等
&amp;lt;img width=&quot;1512&quot; height=&quot;867&quot; alt=&quot;image&quot; src=&quot;/posts/usingSlurm/shared.png&quot; /&amp;gt;&lt;/p&gt;
&lt;p&gt;共享目录默认的文件权限为 &lt;code&gt;所有人可创建; 文件只有创建者可读写; 他人只读&lt;/code&gt; ，如果需要创建协作目录，建议参考以下命令&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 如果协作目录是 你可读写，他人只读
chmod 777 &amp;lt;你的协作目录&amp;gt;
# 如果协作目录下是 所有人可创建; 文件只有创建者可读写，他人只读
chmod 1777 &amp;lt;你的协作目录&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2.3 查看集群信息&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;你可以使用 &lt;code&gt;sinfo&lt;/code&gt; 查看节点和分区信息
&amp;lt;img width=&quot;1512&quot; height=&quot;867&quot; alt=&quot;image&quot; src=&quot;/posts/usingSlurm/sinfo.png&quot; /&amp;gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;你可以使用 &lt;code&gt;module ava&lt;/code&gt; 支持的查看软件包模块
&amp;lt;img width=&quot;1512&quot; height=&quot;867&quot; alt=&quot;image&quot; src=&quot;/posts/usingSlurm/module-ava.png&quot; /&amp;gt;
之后，你可以使用module load加载你想要的软件包&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2.4 提交作业示例&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;GPU 作业&lt;/strong&gt;：&lt;strong&gt;系统会自动分配一张显卡&lt;/strong&gt;。如需多卡，请使用 &lt;code&gt;-G&lt;/code&gt; 或 &lt;code&gt;--gres&lt;/code&gt; 参数显式指定。&lt;pre&gt;&lt;code&gt;# 自动分配一张显卡（默认）
sbatch -p gpu -c 4 your_program

# 显式指定使用4张显卡
sbatch -p gpu -G 4 --cpus-per-task=4 your_program
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CPU 作业&lt;/strong&gt;：需自行指定核心数量。&lt;pre&gt;&lt;code&gt;sbatch -p cpu -c 8 your_program
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2.5 注意事项&lt;/h3&gt;
&lt;p&gt;暂无，不要在登陆节点跑重型负载即可&lt;/p&gt;
&lt;h3&gt;2.6 更多参考&lt;/h3&gt;
&lt;p&gt;以下页面提供了详尽的 Slurm 使用指南，如有需要强烈建议阅读：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;上海交通大学超算平台: &lt;a href=&quot;https://docs.hpc.sjtu.edu.cn/job/slurm.html&quot;&gt;Slurm 作业调度系统¶&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Slurm Worker Manager: &lt;a href=&quot;https://slurm.schedmd.com/documentation.html&quot;&gt;Slurm Documentation&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;CLab Server Docs: &lt;a href=&quot;https://clab-hkust-gz.github.io/server-docs/userguide/lmod.html&quot;&gt;LMod 使用指南&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;LMod: &lt;a href=&quot;https://lmod.readthedocs.io/en/latest/010_user.html&quot;&gt;User Guide for Lmod&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;3. 任务后台查看&lt;/h2&gt;
&lt;p&gt;我们有个非常漂亮的后台，以下是它的使用教程&lt;/p&gt;
&lt;h3&gt;3.1 访问 Dashboard&lt;/h3&gt;
&lt;p&gt;打开浏览器访问 &lt;a href=&quot;https://slurm-dashboard.thy.icu/&quot;&gt;https://slurm-dashboard.thy.icu/&lt;/a&gt;。&lt;br /&gt;
若浏览器会话中无有效的 Access Token，系统将自动重定向至 Cloudflare 零信任网关重新登录。认证成功后，将跳转至 Dashboard, 即可查看作业状态、资源使用情况等。&lt;/p&gt;
&lt;p&gt;更详细的使用说明，请阅读&lt;a href=&quot;https://docs.rackslab.io/slurm-web/overview/overview.html&quot;&gt;Slurm-web/Overview&lt;/a&gt;
&amp;lt;img width=&quot;1512&quot; height=&quot;867&quot; alt=&quot;image&quot; src=&quot;/posts/usingSlurm/slurm-web.png&quot; /&amp;gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;4. 传输文件&lt;/h2&gt;
&lt;p&gt;传输文件支持以下几种方式(可点击超链接跳转教程)：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://blog.axiaoxin.com/post/rsync-guide/&quot;&gt;rsync&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://zhuanlan.zhihu.com/p/697476464&quot;&gt;SFTP&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://zhuanlan.zhihu.com/p/1960328529683132667&quot;&gt;百度网盘&lt;/a&gt;，教程可以忽略前面的&quot;下载&quot;部分，我已经部署好了，直接从 “登录” 开始看即可&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;5. 密码管理&lt;/h2&gt;
&lt;p&gt;&lt;s&gt;以防你忘记密码了，虽然说这东西没啥用&lt;/s&gt;&lt;/p&gt;
&lt;h3&gt;5.1 登录 profile 页面&lt;/h3&gt;
&lt;p&gt;访问 &lt;a href=&quot;https://slurm-profile.thy.icu/&quot;&gt;https://slurm-profile.thy.icu/&lt;/a&gt; 并使用 GitHub OAuth 登录。&lt;/p&gt;
&lt;h3&gt;5.2 修改密码&lt;/h3&gt;
&lt;p&gt;在 &lt;strong&gt;“修改帐户密码”&lt;/strong&gt; 区域，您可以通过以下两种方式验证身份：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;原密码&lt;/strong&gt;：输入当前密码。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OAuth&lt;/strong&gt;：使用 GitHub 快速验证（推荐，邮箱验证暂不可用）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;验证通过后，在右侧输入新密码两次，确保一致，点击 &lt;strong&gt;提交&lt;/strong&gt; 即可。&lt;/p&gt;
&lt;h3&gt;5.3 注意事项&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;密码修改成功后，您会被强制登出，后续访问需使用新密码重新登录。&lt;/li&gt;
&lt;li&gt;请妥善保管密码，避免泄露。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;如有任何问题，请联系系统管理员。&lt;/p&gt;
</content:encoded></item></channel></rss>