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同步,请勿在站点侧修改)