跳转至

SOP — 共享库 SSH 连接复用改造

  • 主题目录:conn-reuse-refactor-20260930/
  • 变更对象:共享库 /home/rain/py_public_modules/paramiko_module.py
  • 变更性质:共享库破坏性变更(改 get_connection / close 的行为语义),影响全部 7 个挂载 venv
  • 状态:方案待确认,尚未实施

1. 背景

第 0 步测量(见 ../conn-reuse-measure-20260930/MEASUREMENT.md)确认:

  • SSHConnectionPool 名为连接池,实际是「每次新建」的工厂: get_connection() 每次 new SSHConnection() + connect(), 内部的 _pool(Queue)与 _connections 字典从未被写入, close_connection() 因此永远是空操作。
  • 调用侧用完即 close(),连接无法跨调用存活。
  • 实测每次 get_connection 恰好产生 1 次 TCP 握手(22=22、14=14、44=44,三次测量均 1:1)。

后果:env-monitor-deploy 的 deploy 幂等复跑每台主机被探 4 次或 2 次, 44 次握手 / 27 s,其中大部分是重复握手。

2. 目标

# 可验证目标 判据
G1 deploy 幂等复跑握手数下降 44 → 14(每台 1 次)
G2 verify 握手数下降 22 → 14
G3 precheck 无退化 14 → 14(每台仅 1 条命令,本就不变)
G4 对外契约不变 6 个工程零代码改动;SSHConnectionPool 的公开方法名与签名不变
G5 幂等不退化 deploy 幂等复跑第二次零变更、退出码 0
G6 挂载工程全部可运行 7 个 venv 各过三项回归

3. 前置条件

  • 无工程正在运行(共享库是挂载式源码,改动对新启动的进程生效;运行中的进程不受影响,但其行为与新代码不一致)
  • 7 个挂载 venv 均可执行 .venv/bin/python(见第 4 章清单)
  • 本机可直连 14 台外部主机与 K8s(改造验证要用 env-monitor-deploy 端到端跑)
  • 共享库未纳入 git 版本控制(shared-lib-contract.md 记载「应纳入独立 git 仓库管理」),因此必须先手工备份(第 7 章)

4. 影响范围

改动文件:/home/rain/py_public_modules/paramiko_module.py(单文件,872 行)

受影响挂载 venv(7 个,6 个工程目录):

工程目录 .venv
env-monitor-deploy .venv/lib/python3.12/site-packages/py_public_modules.pth
dell-r620-monitor-20260929 同上
ldap-deploy/pla-frame409-fix 同上
nfs-mount-tuning 同上
ingress_tunnel_deploy 同上
k8s_136_cluster_deploy 同上

已核实的调用收敛(为什么工程侧零改动)——所有远程执行最终都汇聚到共享库内的 5 个调用点:

调用点 文件 说明
run_on_host() base_module.py:250 6 个工程主力通道
exec_linux() paramiko_module.py:600 批量执行
upload_file() paramiko_module.py:647 上传
download_file() paramiko_module.py:675 下载
SSHExecutor.run() ssh_executor.py:38 便捷层

全部 finally: conn.close() 都在共享库内;没有任何工程直接持有 SSHConnection 对象 (全仓库检索 SSHConnection / connection_pool.get_connection 的结果仅为文档示例与 ingress_tunnel_deploy/main.py 的 set_credential / shutdown 调用)。

  • 是否需停服:不需要。改的是本机共享库源码,不触碰任何目标主机与集群。
  • 窗口期:无硬性窗口,建议选无工程运行时段(避免半程用旧码、半程用新码)。
  • 在跑作业:本变更不登录任何计算节点、不改任何远端状态,Slurm/LSF 作业不受影响。

5. 分步操作

实现体:paramiko_module.py 的单文件改动。本章给出改动要点与等价 shell; 实际执行以实现体为准,改完同步更新本文。

步骤 1:备份共享库

改动前先备份原件——共享库未纳管 git,这是唯一的回原点保障。

mkdir -p conn-reuse-refactor-20260930/backup/home/rain/py_public_modules
cp -a /home/rain/py_public_modules/paramiko_module.py \
      conn-reuse-refactor-20260930/backup/home/rain/py_public_modules/paramiko_module.py.bak.<YYYYmmdd-HHMMSS>
sha256sum /home/rain/py_public_modules/paramiko_module.py

预期:cp 返回 0;sha256sum 有输出并记入 backup/README.md。 失败判断:备份不存在或 sha256 记录缺失 → 停止,不得继续改代码。

步骤 2:SSHConnectionPool.get_connection() 改为命中复用

把「每次新建」改为「池内命中且活跃即复用,否则新建并登记」。

坑:不能只判 host in _connections——连接可能已被目标端断开(空闲超时、sshd 重启), 必须调 is_active() 探活(内部 send_ignore() 心跳),失效则真关后重建。

def get_connection(self, host: str):
    with self._locks_lock:
        lock = self._host_locks.setdefault(host, threading.Lock())
    with lock:                                    # per-host 锁:防同主机并发建连
        conn = self._connections.get(host)
        if conn is not None and conn.is_active():
            return conn
        if conn is not None:                      # 陈旧连接:真关后重建
            conn.close_force()
            self._connections.pop(host, None)

        credential = self._credential_map.get(
            host, getattr(self, '_default_credential', None))
        if credential is None:
            raise ValueError(f"主机 {host} 未配置SSH凭据")

        conn = SSHConnection(host=host, username=credential.username,
                             passwd=credential.password, port=credential.port,
                             timeout=credential.connect_timeout,
                             command_timeout=credential.command_timeout,
                             max_retries=credential.max_retries)
        conn.connect()
        conn._pooled = True                       # 标记:池内连接,close() 语义为归还
        self._connections[host] = conn
        return conn

预期:首次调用建连(1 次握手),后续同 host 调用不再握手。 失败判断:_connections 未增长或握手数未降 → 见第 6 章复测。

步骤 3:SSHConnection.close() 区分「池内归还」与「池外真关」

这是本次唯一改变对外语义的地方,也是必须留痕的坑:

  • 池内连接(_pooled=True):close() = 归还——只关 SFTP(释放 channel), 保留 SSH transport 供复用;
  • 池外连接(用户直接 SSHConnection(...) 或 with 语法,见文件末示例): close() = 真关,语义不变。

按下述方式实现,SSHConnection 增加 _pooled 属性(默认 False)与真关方法:

def close(self) -> None:
    """关闭或归还。池内连接归还(保 transport),池外连接真关。"""
    if self._pooled and self._is_active:
        if self._sftp is not None:
            try: self._sftp.close()
            except Exception: pass
            self._sftp = None
        logger.with_host(self.host).debug("SSH连接归还连接池")
        return
    self.close_force()

def close_force(self) -> None:
    """真关:释放全部资源(原 close 行为)。"""
    self._cleanup_internal()
    self._is_active = False
    logger.with_host(self.host).debug(f"SSH连接已关闭: {self.host}:{self.port}")

坑:现有 5 个调用点的 finally: conn.close() 不需要改——这是本方案选「改 close 语义」 而不是「新增归还 API + 改 5 处调用」的原因:前者工程侧零改动,后者要动共享库 5 处 + 逐个工程确认。代价是 close() 名字与「归还」语义不完全一致,故用 close_force() 承接真关语义,并在日志中区分(已关闭 vs 归还连接池)。

步骤 4:shutdown() / close_connection() 确保真关

现有 shutdown() 遍历 _connections 与 close_connection(),修复池后即可生效; 须确认二者遍历到连接时调用 close_force(),避免「归还语义」把真关吞掉。

def close_connection(self, host: str) -> None:
    conn = self._connections.pop(host, None)
    if conn is not None:
        conn.close_force()

def shutdown(self) -> None:
    for host, conn in list(self._connections.items()):
        try: conn.close_force()
        except Exception: pass
    self._connections.clear()

配套:避免「归还语义」把真关吞掉。

步骤 5:建连后设 keepalive

复用后连接长期存活,必须防被 sshd / 网络设备按空闲超时掐断。 在 connect() 成功处(self._ssh_client = client; self._is_active = True 之后)加:

transport = client.get_transport()
if transport is not None:
    transport.set_keepalive(30)      # 30s 心跳,抵消常见 60s 空闲超时

失败兜底:即便被断,run_cmd() 内已有 if not is_active(): self.connect() (paramiko_module.py:265)与 _reset_connection() 重连逻辑,不会静默失败。

步骤 6:7 个挂载工程回归

共享库改动的影响面是全部挂载工程,逐个跑三项回归。

# 在每个工程根执行(.venv 位置见第 4 章表)
.venv/bin/python -m compileall -q .
.venv/bin/python -c "import paramiko_module; print(paramiko_module.__file__)"
.venv/bin/python main.py --help      # ldap-deploy 子工程按其实入口调整

预期:三项全 rc=0;import 打印的路径为 /home/rain/py_public_modules/paramiko_module.py。 失败判断:任一工程 import 或 --help 失败 → 立即回滚(第 7 章),逐个排查后再试。

步骤 7:端到端验证与数字对比

cd env-monitor-deploy
.venv/bin/python ../conn-reuse-measure-20260930/measure_conn.py precheck
.venv/bin/python ../conn-reuse-measure-20260930/measure_conn.py verify
.venv/bin/python ../conn-reuse-measure-20260930/measure_conn.py deploy

预期(对比第 0 章基线):

子命令 改造前握手 改造后握手
precheck 14 14
verify 22 14
deploy(幂等复跑) 44 14

该脚本天然可用于验证:它统计的是 get_connection 调用次数与 SSHClient.connect 次数两个独立指标——改造后 get_connection 次数不变(仍 22/44), 而握手次数应降到 14。两者分离正是改造生效的证据。

再连跑一次 deploy,确认第二次零变更(G5)。

6. 验证

项 命令 预期
握手数下降 measure_conn.py deploy tcp_handshake=14(原 44)
池复用生效 同上 get_connection 仍 44、tcp_handshake 14(两者分离)
幂等未退化 measure_conn.py deploy 连跑两次 两次均退出码 0、零变更、握手数一致
回归通过 7 工程三项回归 全 rc=0
业务无退化 measure_conn.py verify 退出码 0(校验通过)

失败判据(任一达标即回滚): ① 任一工程回归失败;② verify 退出码非 0; ③ 握手数未下降或下降后 deploy 出现非预期失败/重试;④ 出现连接相关的 ConnectionError / SSHException(目标端拒绝、认证失败、channel 异常)。

7. 回滚

备份位置:conn-reuse-refactor-20260930/backup/home/rain/py_public_modules/ 台账:backup/README.md(登记备份文件、原路径、时间、回滚内容、可删时机)。

cp -a conn-reuse-refactor-20260930/backup/home/rain/py_public_modules/paramiko_module.py.bak.<时间戳> \
      /home/rain/py_public_modules/paramiko_module.py
.venv/bin/python -c "import paramiko_module; print('ok')"      # 在任一挂载工程内验证

回滚后验证:重跑 measure_conn.py deploy,握手数应恢复到 44(证明回到旧的「每次新建」行为)。 共享库无编译缓存问题(.pth 直挂源码),恢复文件即生效;已在运行中的进程需重启才回到旧行为。

8. 风险说明

风险 说明 缓解
close() 语义变化违背直觉 池内连接 close() 不再真关,可能误导后续维护者 新增 close_force() 承接真关;日志区分「已关闭/归还连接池」;更新 shared-lib-contract.md
长连接被目标端断开 sshd ClientAliveInterval 或中间设备空闲超时 set_keepalive(30);is_active() 探活 + run_cmd 自动重连
同主机并发共享一条连接 多线程同时 exec_command 各工程均为「主机间并发、主机内串行」(线程池按主机分发),现状安全;SFTP 通道不并发,上传下载路径已在归还时关闭 SFTP
池内连接持有旧凭据 轮换密码后不刷新已建连接 已知限制:凭据变更需重启进程(与现状一致,不退化);写入 shared-lib-contract.md
目标端连接数峰值 复用降低峰值(同一主机 1 条而非 N 条) 正面影响,无需处理
共享库未纳管 git 改动无版本记录 本次手工备份 + 台账;同时建议将共享库纳入 git(记入待办,不在本次范围)

附录 A:交付物清单

文件 内容
SOP.md 本文件
backup/README.md 备份台账(实施时产生)
backup/home/rain/py_public_modules/paramiko_module.py.bak.<ts> 共享库备份(实施时产生)

同步更新的共享库契约文档(属交付物变更): /home/rain/.trae-cn/skills/deployment-tool-engineering/references/shared-lib-contract.md —— 补记「连接池为复用语义,close() 池内归还 / close_force() 真关」。

附录 B:RUNBOOK 判断

本次变更不新增 RUNBOOK.md:

  • 交付物是共享库能力增强,对外 API 契约不变,日常使用方式(run_on_host / SSHExecutor / setup_credentials)无变化;
  • 该共享库的「运行与维护手册」职责已由 deployment-tool-engineering/references/shared-lib-contract.md 承担 (位置、挂载、工程枚举、三项回归命令),本次按规则同步更新该文件。

附录 C:不做什么

  • 不改任何工程代码(含 env-monitor-deploy 的 5 个调用点)——本方案前提是工程侧零改动;
  • 不动 deploy 流程的探测次数(sync_status 与 validate 各探一轮是设计如此, 用于「纳管/就绪」分两段判定,不改;本次只让重复探测不再重复握手);
  • 不顺手把共享库纳入 git、不重构 paramiko_module.py 的其他部分。

附录 D:实施结果(2026-09-30 已完成)

改动落地:paramiko_module.py 7 处(KEEPALIVE_SECONDS 常量、_pooled 标记、 connect() 设心跳、close()/close_force() 拆分、_reset_connection() 改真关、 get_connection() 池化复用、close_connection()/shutdown() 改真关)。 备份:backup/home/rain/py_public_modules/paramiko_module.py.bak.20260930-133504。

回归:6 个工程 import 与 main.py --help 全过,共享库 py_compile 通过。

端到端实测(改造后 / 改造前):

子命令 get_connection tcp_handshake close 耗时
precheck 14 14 / 14 14 10.45 s / 11.32 s
verify 22 14 / 22 22 10.25 s / 10.98 s
deploy(幂等复跑) 44 14 / 44 44 17.13 s / 26.5 s

全部目标达成:G1 ✓(44→14)、G2 ✓(22→14)、G3 ✓(14→14,无退化)、 G5 ✓(deploy 连跑两次均退出码 0、零变更)。get_connection 次数不变而握手数下降, 即为复用生效的直接证据。 G4 ✓(6 工程零代码改动,回归全过)。


来源:/home/rain/Documents/k8s_ins/conn-reuse-refactor-20260930/SOP.md | 最后更新:2026-09-30 13:38(由 build/sync.py 同步,请勿在站点侧修改)