ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ClickHouse 测试框架进程管理深度解析:基于 PGID 跟踪与分组 PID 文件的孤儿进程清理方案

ClickHouse 测试框架进程管理深度解析:基于 PGID 跟踪与分组 PID 文件的孤儿进程清理方案 ClickHouse 测试框架进程管理深度解析基于 PGID 跟踪与分组 PID 文件的孤儿进程清理方案【免费下载链接】ClickHouseClickHouse® is a real-time analytics database management system项目地址: https://gitcode.com/GitHub_Trending/cli/ClickHouseclickhouse-test是 ClickHouse 功能测试stateless/functional tests的核心运行器负责以.sql/.sh/.expect等方式逐条启动并执行数以万计的测试用例。本篇文章以仓库文档 tests/clickhouse-test-process-management.md 为主线深入剖析该测试框架如何通过进程组Process Group跟踪 每个 worker 独立的分组 PID 文件机制解决“runner 异常死亡后遗留孤儿测试进程”这一经典工程难题。读完本文你将掌握该框架从进程启动、信号清理、孤儿兜底到已知缺陷的完整设计与源码实现。问题背景runner 意外死亡时的孤儿进程clickhouse-test为每个.sh测试启动独立的进程组process group。在 tests/clickhouse-test 中测试用例通过如下方式拉起proc Popen(command, shellTrue, executableBASH, envtests_env, start_new_sessionTrue, preexec_fnpreexec_fn)start_new_sessionTrue会让子进程成为一个新会话的领头进程同时PGID PID。这么做的核心原因是os.killpg可以一次性杀掉整个测试的进程树bash 默认不会把信号转发给它的子进程超时、Sanitizer 失败等场景需要“整组销毁”。优雅关闭路径cleanup_child_processes在正常情况下clickhouse-test通过信号处理器捕获SIGTERM/SIGINT/SIGHUP并调用 cleanup_child_processes 进行优雅关闭def cleanup_child_processes(pid): processes [p[0] for p in pgrep(ppidpid)] for child in processes: child_pgid os.getpgid(child) if child_pgid ! pgid: kill_process_group(child_pgid, None) # SIGKILL should not be sent, since this will kill the script itself os.killpg(pgid, signal.SIGTERM)它用pgrep --parent遍历直接子进程对每个子进程的 PGID 调用kill_process_group。注意代码注释里的关键点由于start_new_sessionTrue仅按 PGID 杀是不够的还需要遍历子进程——因为测试内部可能通过timeout(1)等工具又创建了新的进程组。致命缺陷SIGKILL 无法被拦截当clickhouse-test或其父进程fast_test.py被SIGKILL例如 OOM Killer击杀时上述信号处理器根本没有机会执行。此时测试子进程被重新托管re-parent给init/launchd继续运行由于它们各自位于独立会话中pgrep --parent再也找不到它们父进程关系已断裂在 Linux Docker CI 中容器退出会连带销毁一切进程问题被容器边界掩盖但在macOS Darwin CI无 Docker、无 cgroups上孤儿进程会跨测试轮次不断累积污染后续测试结果。解决方案核心PGID 永不重置的内核事实解决思路的关键依据是操作系统内核语义内核把 PGID 直接存储在进程描述符process descriptor中进程被 re-parent 时PGID 永远不会被重置。因此只要知道某个孤儿进程组的 PGIDkill_process_group(pgid)就能绕过“父进程链遍历”直接命中它——无需再依赖父进程关系。整个方案的实质是把“孤儿进程的发现”从运行时遍历提前固化为“运行时的记账文件”。机制一每个 worker 独立的分组 PID 文件在 tests/clickhouse-test#L59-L67 中定义了文件路径与命名规则# __file__ 是 {repo}/tests/clickhouse-test因此解析为 {repo}/ci/tmp/clickhouse_test_group_pid _GROUP_PID_PATH Path(__file__).resolve().parent.parent / ci / tmp _GROUP_PID_NAME clickhouse_test_group_pid即分组文件位于仓库的ci/tmp/目录命名为clickhouse_test_group_pid.worker_pid。设计要点每个 worker 进程os.getpid()写自己的文件一个文件只记录一个 PGID因为每个 worker 独占文件完全不需要跨进程锁原子写入通过 write_text_atomic 实现——先写同名.tmp兄弟文件再os.replace原子改名def write_text_atomic(path: Path, text: str) - None: path.parent.mkdir(parentsTrue, exist_okTrue) tmp path.with_name(path.name .tmp) tmp.write_text(text) os.replace(tmp, path)原子写入保证--cleanup永远不会读到半截partial write内容即便写失败也会打印 Warning 而不是崩溃。机制二每测试记账Per-test bookkeeping在 run_single_test 中每次启动一个测试用例时写入 PGID并在finally块中清理proc Popen(command, shellTrue, executableBASH, envtests_env, start_new_sessionTrue, preexec_fnpreexec_fn) # proc.pid PGID after start_new_sessionTrue原子写入 # 以便即使我们被 SIGKILLclickhouse-test --cleanup 也能找到它 _gpid_file _GROUP_PID_PATH / f{_GROUP_PID_NAME}.{os.getpid()} write_text_atomic(_gpid_file, f{proc.pid}\n) try: proc.wait(args.timeout) except subprocess.TimeoutExpired: pass # 是否超时稍后判定 finally: if cgroup_name: cleanup_cgroup(cgroup_name) _gpid_file.unlink(missing_okTrue)正常流程下每个启动的测试都会在finally块删除自己的文件因此clickhouse-test正常退出时ci/tmp/中不残留任何文件。而一旦clickhouse-test被 SIGKILL当前正在运行的那个测试所对应的文件就会遗留其中保存着它的 PGID——这正是后续兜底清理的“钥匙”。机制三--cleanup模式clickhouse-test新增了专用命令行参数解析处clickhouse-test --cleanup入口逻辑tests/clickhouse-test#L7608-L7610与核心函数 cleanup_test_groups 如下def cleanup_test_groups(): for _pgid_file in _GROUP_PID_PATH.glob(f{_GROUP_PID_NAME}.*): if _pgid_file.name.endswith(.tmp): continue try: pgid int(_pgid_file.read_text()) kill_process_group(pgid, None) except Exception as e: print(fWarning: could not read {_pgid_file}: {e}, filesys.stderr) continue finally: _pgid_file.unlink(missing_okTrue) # main: if args.cleanup: cleanup_test_groups() sys.exit(0)执行步骤为glob 匹配clickhouse_test_group_pid.*跳过.tmp中间文件→ 读取每个文件中的 PGID → 调用kill_process_group(pgid, None)整组击杀 → 删除文件。kill_process_group的信号递送细节kill_process_group 的完整信号策略值得展开先 SIGTERM后 SIGKILLos.killpg(pgid, SIGTERM)后睡眠 0.1s 再发SIGKILL这样timeout(1)这类工具也有机会先终止自己的子进程信号是异步的不能保证 100% 成功Sanitizer 模式SANITIZED击杀前先打印进程组内残留进程并等待 60 秒给 sanitizer 留出输出报告的时间可选堆栈捕获CAPTURE_CLIENT_STACKTRACE先发SIGTSTP暂停 clickhouse 客户端进程再用 lldb 抓取堆栈帮助定位死锁类问题平台差异处理macOS 上killpg对包含僵尸进程已退出但未被回收的组会返回EPERM而 Linux 会忽略僵尸进程代码对ESRCH与EPERM都做了容错。机制四clickhouse-test启动时的自我保护在 tests/clickhouse-test#L7612-L7620clickhouse-test启动时会尝试把自己移入新的进程组避免控制终端的信号如 CtrlC误伤调用方# 移动到新的进程组这样控制终端的信号不会到达我们的调用者。 # 如果调用方已经使用了 start_new_sessionTruerun_test() 就是这样 # 我们已经是进程组领头进程setpgid 会抛出 PermissionError——这没关系。 if os.getpid() ! os.getpgid(0): try: os.setpgid(0, 0) except PermissionError: pass # 已经是 session/process-group 领头进程同时SIGTERM/SIGINT/SIGHUP 均被注册为signal_handlertests/clickhouse-test#L7630-L7632把信号转成Terminated异常以走正常的清理路径。机制五调用方清理与 Post-Hook 兜底fast_test.py侧的run_testfinally 块在 ci/jobs/scripts/clickhouse_proc.py#L749-L796 中run_test以start_new_sessionTrue启动clickhouse-test并在finally块中无条件执行finally: # 击杀任何在 clickhouse-test 自身清理之后幸存的测试进程 # 例如它自己在信号处理器运行前就被 SIGKILL。 # clickhouse-test 启动时会自己写分组 pid 文件--cleanup 读取 # 并击杀所有孤儿测试进程组。 _clickhouse_test Path(__file__).resolve().parent.parent.parent.parent / tests / clickhouse-test subprocess.run([sys.executable, str(_clickhouse_test), --cleanup], checkFalse)Post-Hook覆盖fast_test.py自身被 SIGKILL 的场景若fast_test.py本身被 SIGKILL如 OOM上面的finally块同样不会执行。为此CI 在 ci/defs/job_configs.py#L290-L320 为 Darwin 的 fast test job 注册了 post-execution hookdarwin_fast_test_jobs Job.Config( ... post_hooks[ ... python3 ./ci/jobs/scripts/job_hooks/clickhouse_test_cleanup_hook.py, ... ], )post-hook 脚本 ci/jobs/scripts/job_hooks/clickhouse_test_cleanup_hook.py不包含任何自身的杀进程逻辑只是再次调用clickhouse-test --cleanup从而让“孤儿清理”始终保持单一事实来源single source of truthrepo_path Path(__file__).resolve().parent.parent.parent.parent.parent subprocess.run( [sys.executable, str(repo_path / tests / clickhouse-test), --cleanup], checkFalse, )另外stress 测试运行器 ci/jobs/scripts/stress/stress.py#L1199 同样会调用test_runner --cleanup说明该机制被多个 CI 子系统复用。多层清理防线总览原文档以表格形式给出了完整的清理层级这里结合源码逐一对应清理层级触发时机执行机制cleanup_child_processesSIGTERM/SIGINT/SIGHUP到达clickhouse-test遍历直接子进程对每个子进程 PGID 执行killpgtests/clickhouse-test#L855测试finally块每个测试代码路径的任何退出含 worker 被 SIGKILL_gpid_file.unlink删除该 worker 的分组文件tests/clickhouse-test#L3952run_test()finally块clickhouse-test的任何退出含 SIGKILLclickhouse-test --cleanup→ 按每个 PGID 文件执行kill_process_groupci/jobs/scripts/clickhouse_proc.py#L790-L796Post-Hookfast_test.py的任何退出含 SIGKILL同样执行clickhouse-test --cleanupci/defs/job_configs.py#L311可以看到设计遵循“每一层只解决上一层覆盖不到的窗口”的原则层层递进直至 post-hook 覆盖整个 CI job 生命周期。已知问题正常退出时进程组不会被清理原文档明确记录了一个已知局限当 bash 测试脚本正常退出exit code 已设置时kill_process_group不会被调用。对应源码路径为run_single_test无论正常退出还是超时finally块都会删除 PGID 文件process_result_impl只有proc.returncode is None即发生TimeoutExpired时才执行kill_process_group(os.getpgid(proc.pid), ...)if proc.returncode is None: # 仅在 TimeoutExpired 时为 True kill_process_group(os.getpgid(proc.pid), self.fatal_sanitizer_prefix)后果bash 退出后若进程组内仍有残留进程例如测试脚本启动了后台任务但没有wait这些进程不会被击杀而此时 PGID 文件已被删除--cleanup也无法再触达它们。实际影响有限绝大多数 shell 测试在结尾都会调用wait所有后台任务在 bash 退出前完成进程组为空。只有不调用wait、或在组内派生出脱离的子子进程的测试才会静默泄漏进程。修复方向文档明示在删除文件之前无条件或至少当pgrep(pgidproc.pid)仍显示存活成员时对 PGID 调用kill_process_group。当前未这样做是为了避免在每个正常通过的测试上引入额外开销——这是典型“正确性与性能”的工程权衡。边界与剩余局限原文档最后坦诚指出了方案的边界若runner.py本身在 post-hook 执行之前就被击杀则没有任何机制兜底清理对专用 macOS runner这要求发生机器级故障重启会清空所有进程对 Linux 生产 CIDocker 容器边界已经覆盖了这一场景。也就是说这套 PGID 文件方案的核心价值在于补齐了Darwin无容器、无 cgroups环境下“无法借助内核/容器回收孤儿进程”的空档属于用“应用层记账”替代“内核级隔离”的务实设计。总结一次可复用的进程清理范式clickhouse-test的进程管理方案可以抽象为三个可复用的工程模式记账先于清理与其在事故发生后靠pgrep反查进程树会被 re-parent 破坏不如在每次启动时就原子化落盘记录 PGID文件即无锁共享用“每进程一个文件”天然避免跨进程锁用“临时文件 rename”保证读端永远看不到半截数据单点真相 多层触发所有兜底路径都收敛到clickhouse-test --cleanup这一个入口从信号处理器、finally块到 CI post-hook 逐层覆盖避免各层各自实现一套清理逻辑导致的漂移。无论你是维护 CI 基础设施还是编写长时间运行的多进程测试框架本文所述的设计都值得作为“孤儿进程治理”的参考实现。如需深入阅读源码建议从 tests/clickhouse-test#L59-L78记账基础设施、tests/clickhouse-test#L777-L884击杀逻辑、tests/clickhouse-test#L7576-L7589cleanup 模式三处入手。【免费下载链接】ClickHouseClickHouse® is a real-time analytics database management system项目地址: https://gitcode.com/GitHub_Trending/cli/ClickHouse创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表