跳到主要内容

10 篇博文 含有标签「DevOps」

查看所有标签

Claude Code 沙箱内诊断结果失真?macOS 沙箱阴性结果不作数

· 阅读需 7 分钟

在用 Claude Code 排查 workspace-mcp 本机服务时,沙箱内 curl 和 lsof 的结果一切「正常」,服务却始终不通。后来发现更严重的事:带 quarantine 属性的二进制在沙箱里会无限挂起——此前基于沙箱的所有检查结论,全部不可信。

TL;DR​

  • Claude Code 在 macOS 上的沙箱,拦截行为不是「报错」,而是静默返回看似正常的空结果:curl 连不通但不报错、lsof 看不到真实监听、带 quarantine 属性的二进制直接无限挂起(0 CPU、无任何输出)。
  • 排查本机服务时,沙箱内的阴性结果(无监听、无进程、命令挂起)一律不作数;用 dangerouslyDisableSandbox 绕过沙箱重跑同一批命令,才能拿到可信结论。

问题现象:一切「正常」的排障,结论全错​

矛盾点在于:检查命令全部「正常」返回,服务却不通——现象分两层。

第一层是常规排障失真。workspace-mcp 服务不通,我在 Claude Code 会话里跑了一轮标准检查:curl 127.0.0.1:8000 没有报错、lsof -iTCP 没有发现异常——按经验,端口没监听、连接被拒这类信号一个都没出现,命令都「正常返回」了。但服务就是不通。我在沙箱内反复重试,结论始终一样。

第二层暴露得更晚,也更严重:排查 Syncthing 时,它的核心二进制(带 com.apple.quarantine 隔离属性)在沙箱内执行直接无限挂起——进程存在,CPU 占用 0,没有任何报错,就那么停着。

这两层现象指向同一个本质:沙箱内的诊断命令在「说谎」。不是报错让人警觉,而是阴性结果让人放心——这才是最危险的形态。

Claude Code 沙箱内的诊断结果为什么失真?​

根因:沙箱把拦截伪装成了「正常」。它对命令的影响分三类,严重程度递增。

网络探测失真。 沙箱内的 curl 127.0.0.1:8000 连不上目标端口,但不抛出连接错误,表现为「正常返回」。你看到的不是 connection refused,而是一个没有报错的空结果——于是「端口没监听」这个错误结论就被静默写进了排查记录。同义的说法还有「连接被拒」「127.0.0.1 连不上」,在沙箱内这些症状全都可能被吞掉。

进程枚举失真。 沙箱内的 lsof -iTCP 看不到宿主机上其他进程的 socket,输出为空。端口明明在监听,你就是看不到——「看不到监听端口」不等于「端口没监听」。基于它做的「服务没起来」判断自然是错的。

二进制执行挂起。 这是最隐蔽的一类。执行带 com.apple.quarantine 隔离属性的二进制(Syncthing 核心)时,沙箱策略与 Gatekeeper 检查叠加,进程直接停在 0 CPU 的挂起态,无任何报错。命令不返回、不崩溃、不留线索。

雪上加霜的是补救路径也被堵死了。我尝试用 xattr -dr com.apple.quarantine 删掉隔离属性,对 /Applications 下的 App 全部返回 EPERM——macOS 27 开始系统锁定了已装 App 的隔离属性,删不掉。这条看似常规的自救路,在新版 macOS 上已经走不通了。

解法:绕过沙箱重跑同一批命令​

结论先行:macOS 上诊断本机服务(端口监听、进程状态、Gatekeeper、统一日志、执行带 quarantine 的二进制),一律绕过沙箱核实。

Claude Code 沙箱内外诊断结果对比:沙箱内三类静默失真,绕过后恢复真实

做法很简单——同一批命令,加 dangerouslyDisableSandbox 绕过沙箱重跑一遍。对照结果:

命令沙箱内绕过沙箱
syncthing --version无限挂起,0 CPU秒级返回,版本号正常输出
端口检查空结果真实监听状态
进程检查空结果真实进程列表
统一日志查不到正常可查

绕过之后,先前「全部正常」的假象消失,端口、进程、统一日志全部恢复真实结果——之前那轮排查里所有「阴性」的检查项,几乎全部翻了案。

这次经历沉淀成一条硬规则:沙箱内的阴性结果不作数。注意这不是说沙箱内的输出全不可信——阳性结果(查到了监听、查到了进程)依然是有效信息;失真的是那些「什么都没查到」的阴性结果,它们无法区分「真的没有」和「沙箱不让你看」。Sandbox 这类防护机制的静默影响不止这一处,Claude Code 的 Hook 拦截同样会误伤正常命令,判据都要锚定执行语义而不是表面输出。

边界与注意事项​

注意事项

  • 绕过沙箱用于只读诊断核实:端口、进程、日志、版本号这类只读命令风险可控;日常构建与执行仍留在沙箱内,不要图省事全局绕过。
  • 执行不可信代码时反过来:沙箱的隔离价值在跑陌生代码时才成立,那条路径恰恰不能绕。
  • macOS 27 上别再尝试 xattr 删隔离属性:对已装 App 一律 EPERM,这条自救路已死,直接绕沙箱执行更快。
  • 回查历史结论:如果你之前在沙箱内做过「服务没起来」「端口没监听」这类判断,值得用绕过沙箱的方式复核一遍。

排 workspace-mcp 这类本机 MCP 服务时,如果你也在自建工具链,可以参考这篇用 FastMCP 搭自定义 MCP 工具库的做法,把诊断命令封装成 MCP 工具时同样要留意沙箱执行层。

常见问题​

Claude Code 沙箱内 curl 127.0.0.1 连不上、lsof 查不到端口,是服务没起来吗?​

大概率不是,是沙箱把拦截伪装成了正常的空结果。macOS 上沙箱内 curl 连不通但不报错、lsof 无输出但端口实际在监听、带 quarantine 属性的二进制甚至无限挂起(0 CPU、无任何报错)。结论:沙箱内的阴性结果一律不作数,用 dangerouslyDisableSandbox 绕过沙箱重跑同一批命令再下结论。

为什么二进制程序在 Claude Code 沙箱里会静默挂起?​

执行带 com.apple.quarantine 隔离属性的二进制(如 Syncthing 核心)时,沙箱策略与 Gatekeeper 叠加,进程停在 0 CPU 的挂起态且没有任何报错。想删掉隔离属性也删不掉:macOS 27 起已装 App 的 quarantine 属性被系统锁定,xattr -dr 全部返回 EPERM。结论:这类二进制只能绕过沙箱执行。

Claude Code 沙箱限制太严,怎么安全地绕过?​

排查本机服务时用 dangerouslyDisableSandbox 绕过沙箱重跑诊断命令。实测同一批命令绕过后全部恢复真实结果:syncthing --version 秒级返回,端口、进程、统一日志全齐。结论:绕过用于只读诊断核实,日常构建与执行仍留在沙箱内。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Airflow 删了 DAG 文件元数据还在?清表顺序错了会复活

· 阅读需 5 分钟

把某个 DAG 的 .py 文件从代码库里删掉、部署完成——Airflow 的 DAG 列表里它还稳稳挂着;手动清了元数据表,过一会儿它又回来了。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。平台的 DAG 管道迭代下线旧分析类型时,元数据残留让列表页越积越乱。

问题现象:文件删了,元数据「阴魂不散」​

两类症状,取决于操作顺序:

  • 先删文件、再查库:dag、serialized_dag、dag_code、dag_version 表里该 dag_id 的行全都还在,UI 列表照常显示;
  • 先清表、后删文件:更诡异——刚 DELETE 干净的行,过几分钟自己回来了,DAG「复活」。

如果你搜的是「Airflow delete DAG still shows in UI」「删除 DAG 后还在」「dag metadata not removed」,都是同一件事。

根因:dag-processor 扫到文件就注册,reserialize 不清旧行​

「复活」的机制是 Airflow 的设计行为,不是灵异事件:

  • dag-processor 定期扫描 DAG 目录。只要 .py 文件还在目录里,扫描进程就会解析它并重新注册元数据行——这解释了「先清表后删文件」的复活:清表时文件还在,下一次扫描立刻重建。
  • airflow dags reserialize 只做增量注册。它把「目录里现存的文件」序列化进表,但不会清理「文件已消失」的旧行——删文件后跑 reserialize,残留纹丝不动。

所以「删文件」和「清元数据」的顺序天然是单向的:文件在,清了也白清;文件没了,清一次就是最终态。

解决方案:删文件 → 按外键序清表 → reserialize 验证​

三步,顺序不能反:

  1. 先删 .py 文件。生产环境 DAG 目录是 volume 挂载的,文件要在宿主机 git pull 同步到位,而不是进容器里删:
ssh <host> "cd /root/workspace/ai_dag && git pull"
# DAG 目录(挂载进容器 /opt/airflow/dags)此刻已无该 .py
  1. 再清元数据,按外键顺序 DELETE(dag_run 的子表 task_instance 随级联清理):
DELETE FROM dag_run        WHERE dag_id = 'old_dag';      -- 级联 task_instance
DELETE FROM serialized_dag WHERE dag_id = 'old_dag';
DELETE FROM dag_code WHERE dag_id = 'old_dag';
DELETE FROM dag_version WHERE dag_id = 'old_dag';
DELETE FROM dag WHERE dag_id = 'old_dag';
  1. reserialize 一次做验证:airflow dags reserialize 跑完后查 dag 表——该 dag_id 不再重建,才算清干净。

整个过程一分钟内完成,文件已不在目录里,dag-processor 扫描时不会再注册它。

边界与变体​

  • 清表前先确认调度器/处理器没有正在解析该 DAG,避免清表与注册赛跑;低峰操作最省心。
  • dag_run 历史要留证据的话,先 SELECT 导出再删——运行历史是排查「当时发生了什么」的唯一记录,删了就没了。
  • 同一文件名换目录/换平台目录的场景,dag_id 若相同,残留逻辑同本文:先让旧文件从目录消失,再做元数据手术。
  • Airflow 各版本的元数据表结构有差异(如 dag_code/dag_version 是较新版本引入),DELETE 前先 \d 确认本环境的表与外键,别照抄表名。

注意事项

  • 顺序是铁律:文件未删先清表 = 必然复活;把「删文件」放在流程第一步,后面全是顺路。
  • SQL 直连生产元数据库属于高风险操作:WHERE 条件必须精确到 dag_id,先在事务里 BEGIN 查影响行数再提交。
  • UI 里「删除」按钮(较新版本)走的是 API 删除路径,与手工清表效果不同;本文流程针对「需要精确控制清理范围」的场景。

常见问题​

Airflow 删除 DAG 文件后为什么 UI 里还在?​

元数据残留在 dag、serialized_dag、dag_code、dag_version 等表里,UI 按表渲染;reserialize 只 upsert 现存文件、不清理文件已消失的旧行,需要按外键顺序手动 DELETE。

Airflow 清了元数据表为什么又复活?​

清表时 DAG 文件还在:dag-processor 定期扫描 DAG 目录,扫到文件就重新注册元数据。必须先删文件(git pull 同步到挂载目录),再清表,最后 reserialize 验证不再重建。

彻底删除一个 Airflow DAG 的正确顺序是什么?​

①删 .py 文件并同步到 DAG 目录;②按外键顺序清表:dag_run(级联 task_instance)→ serialized_dag → dag_code → dag_version → dag;③airflow dags reserialize 确认 dag 表不再重建。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Airflow 3 密码改了不生效?真源是 passwords.json 而非数据库

· 阅读需 5 分钟

在 Airflow 3 上把用户密码更新之后,用新密码请求 /auth/token 仍返回 401——而数据库里的密码哈希确实已经改成了新值。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。平台的 DAG 触发链路依赖这套 Airflow 的 JWT 鉴权,密码轮换卡住,管道授权跟着停摆。

问题现象:密码改了,/auth/token 还认旧密码​

新密码请求 /auth/token 返回 401、旧密码仍返回 201——修改动作每一步都「成功」,实际生效的始终是旧密码。这次轮换的场景是安全处置:旧密码已泄露,必须立刻换掉,所以「改了但没生效」的每一分钟都在裸奔。

第一反应是用官方 CLI 重置:

$ airflow users reset-password -u apiuser -p <new>
AttributeError: 'AirflowSecurityManagerV2' object has no attribute 'find_user'

报错指向 FAB(Flask-AppBuilder)认证体系的安全管理器,看起来像 3.x 的版本 bug。既然 CLI 坏了,那就绕过它直接改数据库——这步走岔了,为后面更深的迷惑埋了伏笔。

根因:SimpleAuthManager 不读数据库,密码在 passwords.json​

这套部署的认证管理器不是报错里暗示的 FAB,而是 SimpleAuthManager——用一条命令就能看到真相:

airflow config get-value core auth_manager
# airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager

理清这三层,现象就完全解释得通了:

  • CLI 报错是假导。airflow users 系列 CLI 属于 FAB 认证体系;SimpleAuthManager 部署上跑它,内部代码路径直接撞上 AttributeError。报错里的 AirflowSecurityManagerV2 类确实存在(作为遗留组件),但跟当前生效的认证管理器无关。
  • ab_user 表是遗留数据。从 Airflow 2.x 升级上来的部署,库里留着 FAB 时代的用户表。用 SQLAlchemy 直写 UPDATE ab_user SET password=... (werkzeug generate_password_hash 生成 scrypt 哈希),返回 rowcount=1,再用 check_password_hash 验证——新密码与库里的哈希完全匹配。可 /auth/token 照旧 401:表是真改了,只是认证流程根本不读它。
  • 真源是一个 JSON 文件。SimpleAuthManager 的用户密码来自 AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_PASSWORDS_FILE 指向的 passwords.json,内容就是扁平映射:
{
"apiuser": "<密码>",
"admin": "<密码>"
}

用户-角色对应关系则由 AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS(形如 apiuser:admin,admin:admin)声明。排查这类问题的第一步应该是确认「谁在管认证」,而不是急着修「认证坏了」的表象。

解决方案:改 passwords.json 后必须 force-recreate 容器​

三步:

  1. 改宿主机上的 passwords.json(compose 挂载进容器的那个源文件):
python3 - <<'EOF'
import json
d = json.load(open('/root/workspace/ai_dag/deploy/passwords.json'))
d['apiuser'] = '<新密码>'
json.dump(d, open('/root/workspace/ai_dag/deploy/passwords.json', 'w'), indent=2)
EOF
  1. 重建 api-server 容器。这一步不可省——密码文件仅启动时读取,改文件对运行中的进程没有任何影响:
cd /root/workspace/ai_dag/deploy
docker compose up -d --force-recreate airflow-api-server
  1. 等服务就绪后双向验证——新密码必须通过、旧密码必须失效,两个断言缺一不可:
# 探活:重建后约半分钟内恢复 200
curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/api/v2/monitor/health

# 新密码 → 期望 201
curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost:8080/auth/token \
-H 'Content-Type: application/json' \
-d '{"username":"apiuser","password":"<新密码>"}'

# 旧密码 → 期望 401
curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost:8080/auth/token \
-H 'Content-Type: application/json' \
-d '{"username":"apiuser","password":"<旧密码>"}'

实测结果:新密码 201、旧密码 401,轮换闭环。只验「新密码能用」不验「旧密码失效」是这类操作最常见的收尾漏洞。

注意事项

  • 先确认认证管理器再动手:airflow config get-value core auth_manager 的输出决定密码改在哪——FAB 在数据库,SimpleAuthManager 在文件,两者路径完全不同。
  • 改密码文件必须重建容器:文件仅启动时读取,docker compose restart 不会让它重新加载。
  • 依赖该认证的服务要同步换新密码并重启加载,否则管道在「旧密码 401、新密码未接线」的窗口里全断。
  • 重建 api-server 有秒级到半分钟的服务中断,挑低峰执行,探活循环等 health 回 200 再做验证。

常见问题​

Airflow 2 升级到 3 后重置密码的命令为什么报错?​

3.x 部署若使用 SimpleAuthManager,FAB 的 airflow users 系列 CLI 不再可用(实测报 AttributeError: 'AirflowSecurityManagerV2' object has no attribute 'find_user')。先跑 airflow config get-value core auth_manager 确认实际认证管理器,再决定改数据库还是改文件。

SimpleAuthManager 的用户密码存在哪?​

存在 passwords.json 文件里(flat JSON,形如 {"用户名": "密码"}),路径由 AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_PASSWORDS_FILE 指定。文件仅容器启动时读取,改完必须 docker compose up -d --force-recreate airflow-api-server 才生效。

为什么直接更新 ab_user 表的密码哈希不生效?​

ab_user 是 FAB 认证管理器的用户表。从 2.x 升级到 SimpleAuthManager 的部署里它是遗留数据,认证流程根本不读它——实测 rowcount=1 写入 scrypt 哈希成功,/auth/token 照旧返回 401。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Claude Code Hook 误拦正常命令?拦截判据要锚定执行语义

· 阅读需 11 分钟

在给 Claude Code 配 Bash 拦截 Hook 防止 AI 直写生产库时,pm2 restart、grep、Python 调试命令被接连误拦——该拦的 SQL 一条没跑掉,不该拦的运维命令也一片躺枪。这类问题在英文社区的讨论里常被叫做 hook overblocking 或 false positive。这篇文章记录判据从「关键字匹配」收窄到「执行语义」的完整过程。

在运维 CCLee 服务器哨兵 背后的生产服务器时遇到此问题——Linux 服务器监控预警托管:性能、可用性、安全、备份四类监控,告警归并成结论并附处置指引。

TL;DR​

用「命令文本包含写库关键字」做 Hook 判据,注定误伤:sys.path.insert、pm2 --update-env、grep 'UPDATE …' 全都含整词关键字,\b 词边界一个也救不了。把判据收窄为三条件齐备——ssh 生产机前缀 + 命令含 psql + 词边界关键字——8 类误伤清零。同时把 SQL 写库的正路定为文件管道(关键字不进命令行),正路天然不触发。最后把判据表写成 22 个回归样例:改判据先改测试,口径不再靠记忆。

问题现象:pm2 restart 被拦,正常运维命令躺枪​

v1 拦截 Hook 上线当天,pm2 restart、远端 grep、Python 调试命令接连被误拦——被拦的命令没有一条真的在写库。

背景先交代一句:我们的开发流程允许 Claude Code 直连生产库做只读排查——先查 logs 表定位 trace、时间、服务,形成假设再读代码。这条正路依赖一个前提:INSERT / UPDATE / DROP 这类写操作绝不能由 AI 直发。规则先写进了 CLAUDE.md,但规则是提示性的,模型在长会话高压下会忘。于是加了一层机制:PreToolUse Hook,凡是 ssh 到生产机的命令,文本里出现写库关键字就直接阻断,退出码 2。

误伤来得很快。以下命令全部被拦,没有一条在写库:

# 容器里调 Python 路径(sys.path.insert 含 "insert")
ssh prod "docker exec app python3 -c 'import sys; sys.path.insert(0, \"/app/lib\")'"

# pm2 滚动更新环境变量(--update-env 含 "update")
ssh prod "pm2 restart api --update-env"

# 重启服务(pm2 delete 含 "delete")
ssh prod "cd /app && pm2 delete api 2>/dev/null; pm2 start ecosystem.config.js && pm2 save"

# 远端搜代码(grep 的参数含 "UPDATE")
ssh prod "grep -rn 'UPDATE api_keys' /app/server/"

# 数日志里的报错次数
ssh prod "tail -5 error.log | grep -c UPDATE"

# 容器里跑 pandas(df.update 含 "update")
ssh prod "docker exec airflow python3 -c 'df.update(other)'"

每一条被拦的命令,Claude 都得停下来换姿势绕路,排查链路被打断。误伤清单攒到 8 类(回归测试收录了其中 6 个代表样例),v1 判据宣告失败。

根因:关键字匹配命中的是文本,不是执行语义​

词边界解决不了 shell 命令的误伤——v1 的正则里 \b 一直在,误伤照样发生:

grep -qiE '\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|GRANT|REVOKE)\b'

但词边界解决不了 shell 命令的误伤,原因很反直觉:编程语法里的标点本身就是单词边界。sys.path.insert 的 .、--update-env 的 -、df.update() 的 . 和 (),在正则眼里都是非单词字符,insert、update 在这些位置全都构成「整词」。所以 v1 的词边界一个误伤都防不住。

往深一层看,根因是判据锚错了对象:关键字出现在命令文本里,不等于这条命令要执行该关键字的语义。grep 'UPDATE api_keys' 的语义是「搜索」,不是「更新」;pm2 --update-env 的语义是「重启」,不是「改数据」。文本层面的任何匹配技巧(词边界、大小写、上下文窗口)都无法区分这两者,因为区分它们的信息不在文本里,而在执行者上——真正危险的是「psql 收到一条写语句」,不是「命令里出现了 UPDATE 这个词」。

所以收窄方向不是细化匹配,而是换锚点:锚定执行语义。一条命令要构成写库风险,必须同时满足三件事:命令是发往生产机的 ssh、命令里真的调用了 psql、psql 要执行的文本里出现写库关键字。三者缺一,都不构成「内联写库」。

Claude Code Hook 判定流:三条件齐备才拦​

判定流如下图:三个条件串联成闸门,任何一路「否」都放行,只有三路全「是」才阻断。

bash-guard 三条件判定流:ssh 生产机前缀、命令含 psql、词边界写库关键字齐备才阻断,SQL 走文件管道天然放行

v2 与 v1 的全部差异只有一行——在关键字判断前加了一个 psql 条件:

# v1:只看关键字(已废弃)
if echo "$CMD" | grep -qiE '\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|GRANT|REVOKE)\b'; then

# v2:psql 存在 + 关键字,两条件同查
if echo "$CMD" | grep -qi 'psql' && echo "$CMD" | grep -qiE '\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|GRANT|REVOKE)\b'; then

完整的 Hook 脚本(17 行,prod 换成你的生产机 ssh 别名):

#!/bin/bash
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty')
[ "$TOOL" != "Bash" ] && exit 0

CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

if echo "$CMD" | grep -qi '^ssh prod'; then
if echo "$CMD" | grep -qi 'psql' && echo "$CMD" | grep -qiE '\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|GRANT|REVOKE)\b'; then
echo "检测到 psql 内联写库关键字,已阻断: $CMD" >&2
exit 2
fi
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"只读 ssh 命令"}}'
exit 0
fi

exit 0

注册到 settings.json,matcher 限定 Bash 工具,timeout 5 秒防止脚本挂起拖住会话:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "/path/to/bash-guard.sh", "timeout": 5 }
]
}
]
}
}

收窄只解决了一半问题——拦得准,还要放得开。写库的需求本身是存在的(schema 变更、数据修复),不能因为 Hook 存在就没了出路。我们把正路定为文件管道:SQL 落盘,stdin 进 psql,写库关键字从头到尾不进命令行:

cat x.sql | ssh prod "docker exec -i db psql -U app -d appdb"
# 或者
ssh prod "docker exec -i db psql -U app -d appdb" < x.sql

这个设计有个顺带的好处:正路天然不触发 Hook,不需要任何白名单或豁免逻辑。管道两侧的命令(cat、ssh … psql 不带 -c)不含关键字,判据和正路在结构上互斥,而不是靠枚举例外。真正该写进规则的是:禁止的不是「写库」,是「关键字内联进命令行」——这也让团队约定和 Hook 判据说同一句话。

判据表即测试:22 个回归样例锁住口径​

Hook 是判据,判据就需要回归测试。我们把判据表直接写成了测试表——每行一个样例:命令、期望结果(2 = 拦,0 = 放),实跑只验证、不协商:

run_case 2 '内联 INSERT'   $'ssh prod "docker exec -i db psql -c \'INSERT INTO customers ...\'"'
run_case 0 'pm2 restart --update-env' $'ssh prod "pm2 restart api --update-env"'
run_case 0 '远端 grep 关键字' $'ssh prod "grep -rn \'UPDATE api_keys\' /app/server/"'
run_case 0 '文件管道 cat|' $'cat /tmp/x.sql | ssh prod "docker exec -i db psql"'
run_case 0 'cd && 前缀绕过(权限确认层兜底)' $'cd /x && ssh prod "psql -c \'DROP TABLE t\'"'
run_case 2 '只读查询字面含关键字(残余)' $'ssh prod "psql -c \'SELECT body FROM logs WHERE body LIKE \\'%UPDATE%\\'\'"'

22 个样例分四组:7 条真该拦、6 条 v1 误伤修复、6 条只读与正路、3 条已知残余面。测试表头部有一句注释:「期望列 = 已确认契约,实跑只验证不协商」——判据改动必须先改期望列再过测试,防止口头口径漂移。

这套测试很快还了一次人情。heredoc 的口径曾记录为「同样被拦」,同一天又被撤销,撤销的理由如今已不可考。口靠文档记忆会失忆,但现在口径有仲裁:本地 heredoc 落盘(cat > x.sql <<'EOF',以 cat 开头,不进 ssh 前缀门)不拦;远端 heredoc(ssh prod "… psql …" <<'EOF',SQL 全文进命令行)拦。两个样例都在测试表里,跑一遍就知道答案。

残余面:这道防线拦不住什么​

注意事项

Hook 拦截是纵深防御的一层,不是绝对安全。以下残余面是已知且接受的,放在这里供你评估自己的场景:

  • cd x && ssh prod "…" 前缀绕过:判据锚定 ^ssh 前缀,命令以 cd 开头就漏过。这层的兜底不在 Hook——Claude Code 对未在白名单的命令仍会弹权限确认,人还在环上。
  • 只读查询字面含关键字仍误拦:SELECT body FROM logs WHERE body LIKE '%UPDATE%' 会被拦。误拦率极低且方向安全(宁拦勿放),接受。
  • psql 子串误伤:路径含 psql 且命令含关键字时会误拦,例如 grep UPDATE /tmp/psql-dump.log。
  • pg_restore 不在枚举内:pg_restore 恢复备份不经 SQL 关键字,ssh prod "pg_restore -d appdb snap.sql" 会放行。枚举的是 SQL 写关键字,工具级的危险命令要另外覆盖。

为什么选 Hook 而不是只在 CLAUDE.md 里写规则?因为两者不是替代关系:规则是提示层,模型会忘;Hook 是机制层,忘了也拦得住;权限确认是人机层,Hook 漏掉的它兜底。三层各管一段,任何一层单独都不够。这套判据如果只记一句话:拦「执行语义」,不拦「文本出现」。

这已经是我们 Claude Code 工程化的第二次踩坑记录,第一次是 VS Code 面板 500 与 GLM 多会话拒绝——两篇共同点都是症状指向「随机故障」,根因都在机制层。

常见问题​

Claude Code Hook 怎么加?​

在 settings.json 的 hooks.PreToolUse 里配置 matcher 为 Bash 的 command hook,指向脚本绝对路径并加 timeout: 5。脚本从 stdin 读取含 tool_name 和 tool_input.command 的 JSON,exit 2 阻断命令并把 stderr 反馈给模型,exit 0 放行。

Claude Code Hook 的作用是什么?​

在工具调用真正执行前做一道静默闸门,适合拦高危副作用操作,比如 AI 直连生产库写数据。它不能替代权限确认与代码审查——本文 22 个回归样例之外仍有 cd 前缀绕过、pg_restore 漏拦等残余面。

Claude Code Hook 的拦截机制是怎样的?​

PreToolUse 钩子在 Bash 执行前收到含完整命令文本的 JSON,脚本用退出码表态:exit 2 阻断执行、stderr 内容回传给模型作为拦截原因,exit 0 放行。本文判据为三条件齐备:ssh 生产机前缀 + 命令含 psql + 词边界写库关键字。

Hook 拦截误伤正常命令怎么办?​

把判据从「文本包含关键字」换成「执行语义齐备」。词边界救不了 shell 命令:sys.path.insert、--update-env 这类符号本身就是边界,v1 拦截照样误伤 8 类命令;加上「命令含 psql」这个执行者条件后误伤清零,再把每条判据固化成回归样例。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

容器日志吃满服务器磁盘?docker system df 的 reclaimable 是误报

· 阅读需 7 分钟

告警邮件:生产服务器根分区用到 81%(30G/40G,超过 80% 红线)。第一反应是查 docker system df——它显示 images 一栏 RECLAIMABLE 100% (7),看起来删掉镜像就能回血;但这 7 个镜像全在运行,真按提示 docker image prune -a 就是生产事故。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析平台,自动洞察市场趋势、用户行为与销售数据;出问题的是跑它 Airflow 数据管道的 Docker 服务器。

TL;DR​

磁盘超红线时别信 docker system df 的 RECLAIMABLE——它是「无容器引用空间」的估算口径,active 镜像照样标 100%。正确姿势:sudo du -xh -d1 / 逐层找大头。这次的三大隐形消耗都不在业务数据里:容器可写层里无限累积的 task 日志(Dockerfile 没给日志配卷)、包管理器缓存(npm + pnpm 共 ~4G)、只增不删的备份脚本(每次全量克隆从不清理)。对应清理:缓存直删、可写层 force-recreate 回收、备份脚本加 TTL 剪枝——81% 回落到 64%。

问题现象​

$ df -h /
Filesystem Size Used Use% Mounted on
/dev/vda1 40G 30G 81% /

$ docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 7 7 4.2GB 100% (7) ← 全是运行中镜像
Containers 5 5 810MB 0%

按 docker system df 的提示去做文章(清理镜像)无从下手——RECLAIMABLE 100% 但 ACTIVE 也是 7/7。真正的空间去哪了,docker system df 完全没体现:

sudo du -xh -d1 / | sort -rh | head
# 部分输出:
# 2.7G /root/.npm ← npm 缓存
# 1.1G /root/.local/share/pnpm ← pnpm store
# 857M /root/backups-git ← 备份工作区,只增不删
# (overlay2 内藏:airflow scheduler 可写层 468M + dag-processor 257M)

根因​

三类消耗在「业务数据」视角里全部隐形。

容器可写层吃日志。 Airflow 的 task 日志写在容器内部、没挂外置卷——scheduler 可写层 468M、dag-processor 257M,约两周累积 780M,随时间单调增长。镜像层不变,变的都是可写层;docker system df 把它归在 Containers 的 SIZE 里,混在 810M 的总数里毫无存在感。

包管理器缓存只进不出。 频繁部署/构建的服务器上,~/.npm(2.7G)和 pnpm store(1.1G)持续累积,没有人会主动去清。

备份脚本只增不删。 备份脚本每次全量克隆仓库工作区推 GitHub,旧的克隆目录从不剪枝——857M 里大部分是历史重复。更隐蔽的是孤本:某些备份目录对应的分支从未推送成功,直接删有风险,需要先核对。

而 docker system df 的 RECLAIMABLE 是按「没有运行容器引用」估算的统计口径,active 镜像也可能显示 100% reclaimable(本次 7/7 全如此)——它是误报来源,不是清理依据。

解决方案​

步骤 1:du 逐层定位,先看再删​

sudo du -xh -d1 / | sort -rh | head        # 根分区逐层
sudo du -xh -d1 /var/lib/docker | sort -rh | head # Docker 目录下钻
docker ps -as --format "table {{.Names}}\t{{.Size}}" # 各容器可写层

du -x 不跨文件系统,避开 proc/sys 噪音和 overlay 混淆;docker ps -as 的 SIZE 列直接暴露每个容器的可写层大小——这是定位「日志写进容器」类问题的关键一条。

步骤 2:缓存直删​

npm cache clean --force        # 或直接 rm -rf ~/.npm/_cacache
pnpm store prune # 只清未引用的包

缓存类共回收 ~5.1G,无风险、可随时重下。

步骤 3:可写层 force-recreate 回收​

docker compose up -d --force-recreate   # 可写层随旧容器删除而回收

本次回收 ~780M。两个前提:选低峰期(服务会重启);容器内还有用要先捞出来——比如 docker cp 把 task 日志拷出,否则随容器一起没了。

步骤 4:备份脚本加 TTL,防复发​

# 备份目录保留 7 天,超过自动剪
find /root/backups-git -maxdepth 1 -type d -mtime +7 -exec rm -rf {} +

把剪枝加进备份脚本尾部(本例 github-backup-push.sh 已部署);删除前核对未推送的孤本分支(git ls-remote 对比),确认无独有提交再删——本次 325M 孤本经确认后清理。

清理完成后:81% → 64%,缓存/剪枝/可写层三处合计回收 ~6.7G。

注意事项

  • docker system df 的 RECLAIMABLE ≠ 可删空间:active 镜像也可能标 100%(本次 7 个全在用)。清理决策以 du + docker ps -as 实测为准。
  • force-recreate 会重启服务且回收可写层——需要留存的日志先 docker cp 出来;根治是给日志配外置卷 + 保留期轮转,可写层回收只是这一次的止血。
  • 删备份目录前必核对:有没有从未推送成功的孤本分支,删之前 git ls-remote 确认。
  • 磁盘红线告警要配「定位命令」一起给,收到告警的人第一件事是 du -xh -d1 /,而不是猜。
  • 同一台服务器更早的「磁盘 93% + CPU 160%」排查复盘见 2 核 7G 服务器 Docker 资源黑洞三步排查。

常见问题​

docker system df 的 RECLAIMABLE 显示 100% 能直接删吗?​

不能。RECLAIMABLE 是「当前无容器引用」空间的估算口径,运行中的 active 镜像也可能被标 100% reclaimable——实测 7 个镜像全在用仍显示如此。它回答的是「理论上有多少未被引用」,不回答「能不能删」。动手前用 du -xh -d1 和 docker ps -as 实测定位。

docker ps 的 SIZE 和 docker system df 的 SIZE 有什么区别?​

docker ps -as 的 SIZE 是单个容器可写层的大小;docker system df 是镜像/容器/卷/缓存的分类汇总。容器内日志无限累积只体现在可写层(docker ps -as 能看到),不会体现在任何 image size 上——这就是「镜像看起来不大、磁盘却在涨」的原因。

Docker 服务器磁盘满了怎么清理?​

分三类:包管理缓存直删(npm cache clean --force、pnpm store prune),无风险;容器可写层用 docker compose up -d --force-recreate 回收(会重启服务,日志先备份);备份、日志、克隆目录这类加 TTL 定期剪枝防复发。任何删除动作前,先用 du -xh -d1 / 确认大头位置。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

npm audit 报警归因错目录?多包部署先按 audited N 对包树

· 阅读需 5 分钟

在一次前后端同仓的多包项目部署时,部署日志里 npm audit 输出 3 个 high 漏洞,顺着日志把它记到了后端名下——修完才确认这 3 个 high 全在前端包树里,后端从始至终是另一组 moderate。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析平台,自动洞察市场趋势、用户行为与销售数据;前端在仓库根目录、后端在 server/ 目录,同仓多包分开部署。

TL;DR​

npm audit 的摘要行只有数量、不带路径;部署流顺序执行多个目录的 npm install 时输出串联在一起,摘要无法区分归属。解法:用每棵包树的唯一指纹——audited N packages 的 N——先对号入座,再用 package.json 的 overrides 钉住有漏洞的传递依赖,二次部署两端归零。

问题现象​

部署脚本先后在前端(仓库根)和后端(server/)执行 npm install,日志混在同一股输出里:

# 部署流输出(摘要行不带路径)
added 546 packages in 41s
found 3 high severity vulnerabilities

added 372 packages in 24s
found 4 moderate severity vulnerabilities

交接记录把「3 high」归因到了 server/。按这个方向去查后端依赖链,怎么都对不上——server/ 的 audit 无论在本地还是服务器跑,结果都是 4 moderate,从没出现过 high。「部署日志看得见、归属对不上」是混合部署流的常见病,此前踩过的前端部署后线上未更新也是这一类。

根因​

npm audit 摘要行只有「found X vulnerabilities」,不带目录信息;紧挨着的 audited 546 packages 也很少有人下意识当成归属线索。两棵包树规模差异巨大(546 vs 372),这恰好是唯一稳定的指纹。

前端仓库根装的是 Vite + React + Ant Design Pro 全家桶,包树大;@ant-design/pro-components → @ant-design/pro-layout 引用了旧版 path-to-regexp,这正是 3 个 high 的来源。后端 server/ 是 Express + tsx 的精简依赖树,唯一的问题是 tsx → @esbuild-kit/core-utils → 旧版 esbuild 这条 moderate 链。

「报错位置与真因错位」在部署排查里不止一例:另一次是 .env 密码含 # 被 dotenv 静默截断——鉴权 401 把矛头指向凭据,真因却藏在 dotenv 的解析规则里。

解决方案​

步骤 1:按 audited N 对目录​

在本地各目录分别 npm install(或直接读部署日志的 added N packages),记录包树规模:

cd <repo-root> && npm install 2>&1 | tail -2   # added 546 packages ...
cd server && npm install 2>&1 | tail -2 # added 372 packages ...

部署日志里 found 3 high 紧跟在 added 546 后面 → 前端;4 moderate 跟在 added 372 后面 → 后端。归属定对了,后面才不用白跑。

步骤 2:展开漏洞链​

npm audit                # 看 Path 字段,完整依赖链
npm ls path-to-regexp # 或反查某个包被谁依赖

前端输出确认链路:@ant-design/pro-components → @ant-design/pro-layout → path-to-regexp(旧版本,3 high)。

步骤 3:用 overrides 钉住传递依赖​

前端仓库根 package.json:

{
"overrides": {
"path-to-regexp": "^8.4.2"
}
}

后端 server/package.json(顺带把 tsx 升到新版):

{
"overrides": {
"@esbuild-kit/core-utils": {
"esbuild": "^0.25.12"
}
}
}

overrides 支持嵌套写法,只影响指定父依赖之下的子依赖版本——比全局覆盖一个包名更精准。

步骤 4:重装验证​

rm -rf node_modules package-lock.json && npm install && npm audit

两端重跑部署后,audit 均为 0 vulnerabilities。

注意事项

  • overrides 是 npm 8.3+ 的能力,只写在包根 package.json 生效;改完必须重新 npm install 刷新 lockfile,否则不生效。
  • 把依赖钉到跨大版本(如 path-to-regexp 旧版 → 8.x)时,API 可能不兼容依赖它的上层库。合入前务必跑通构建并对关键页面做回归,别只看 audit 归零。
  • 「audited N」指纹只在包树稳定时可靠:依赖一变 N 就变。用它做归属判断没问题,别把它写进长期脚本当断言。

常见问题​

npm audit fix 跑了为什么漏洞还在?​

npm audit fix 只会升级 semver 允许范围内的版本。漏洞出在传递依赖上、且被上层包的版本范围钉死时,fix 改不动它,需要用 package.json 的 overrides 强制钉版本,然后重新 npm install。

怎么定位 npm audit 报的漏洞在哪条依赖链上?​

看 npm audit 完整输出的 Path 字段,它列出从直接依赖到漏洞包的完整链路;也可以用 npm ls <包名> 反查依赖方。摘要行只有数量,不带任何路径信息。

多包项目的 npm audit 结果怎么对应到具体子项目?​

部署日志的摘要不带目录名,按各目录安装时 added N packages / audited N packages 的包树规模对号入座即可。在本地分别对每个目录 npm install 一次,记录各自的 N,之后就能稳定对应。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

监控漏报 38 行日志?Python 的 WARNING 不等于契约里的 warn

· 阅读需 6 分钟

排查一个监控漏报:server-monitor 按日志级别 warn 过滤告警,但 logs 表里有 38 行告警级日志的 level 写的是 warning——过滤条件一个字符都对不上,这 38 行在监控眼里不存在。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析平台,自动洞察市场趋势、用户行为与销售数据;server-monitor 是它的告警模块,消费四个服务共写的一张日志表。

TL;DR​

跨语言日志契约定义的级名是小写 warn/fatal,而 Python stdlib 的 record.levelname 是 WARNING/CRITICAL——直写或简单 .lower() 产出的是 warning/critical,按契约过滤永远匹配不上。修复原则两条:在写出口做单点映射(WARNING→warn、CRITICAL/FATAL→fatal),别让每个消费方兼容多种拼写;契约文档写「应该的实现」而不是复制现状——这次漂移能长期存在,正因为契约文档的 Python 列照抄了错误实现。

问题现象​

四个服务写同一张 logs 表,契约规定 level 取值:debug / info / warn / error / fatal。对账查询:

SELECT service, level, count(*)
FROM logs
GROUP BY service, level ORDER BY 1, 2;

结果里混着契约之外的拼写:

 service    | level    | count
------------+----------+-------
ai-dag | warning | 21 ← 契约里没有
rag-service| warning | 17 ← 契约里没有
... | warn | ... ← 这才是契约级名

监控按 level = 'warn' 过滤,这 38 行告警级日志静默蒸发。

根因​

第一层是字面差异:Python stdlib 的级别体系是 DEBUG / INFO / WARNING / ERROR / CRITICAL——没有 WARN(那是个废弃别名),也没有 FATAL。两服务把 record.levelname 直接送进了日志表:一个原样写(大写 WARNING),一个 .lower() 后写(warning)。无论哪种,和契约的 warn 都对不上。

第二层更值得警惕:契约文档本身写着错误实现。跨项目日志契约的字段对照表里,Python 两服务的 level 列写的就是「record.levelname」「record.levelname.lower()」——文档在描述现状,而不是规定应该怎样。于是错误实现拿到了「契约背书」,两个服务各自照做,谁也没怀疑。这和 try/except 吞异常导致的静默失败是同一种危害形态:不出错、只是悄悄少东西,等发现时已经积了几十行漏报。

解决方案​

步骤 1:出口单点映射​

每个服务定义一个归一化函数,所有落库/输出路径统一走它:

_LEVEL_NAME_MAP = {"WARNING": "warn", "CRITICAL": "fatal", "FATAL": "fatal"}

def normalize_level(levelname: str) -> str:
"""WARNING→warn、CRITICAL/FATAL→fatal,其余小写。"""
return _LEVEL_NAME_MAP.get(levelname.upper(), levelname.lower())
payload = {"level": normalize_level(record.levelname)}   # 永远产出契约级名

关键在「单点」:JSON formatter 和落库 handler 共用同一个函数,映射规则改一处即可,不存在第二个实现。

步骤 2:契约文档改为「应该的实现」​

字段对照表里 Python 两服务的 level 列改为 normalize_level(record.levelname),并新增一节「level 名映射」:写明映射规则、反模式(禁直写/禁裸 lower)、两个服务的函数入口。契约是规范,不是现状快照。

步骤 3:加对账查询,让漂移可发现​

SELECT level, count(*) FROM logs
WHERE service IN ('ai-dag', 'rag-service')
GROUP BY level ORDER BY 2 DESC;

出现 warn 之外的拼写即漂移。这条查询可以进监控巡检,把「契约 vs 实现」从口头约定变成可断言的检查。

步骤 4:清洗存量(可选)​

修复后新数据不再产生错误级名,存量 38 行按需处理:

UPDATE logs SET level = 'warn' WHERE level = 'warning';

量小可忽略(自然过期),量大或影响历史统计时统一 UPDATE。

注意事项

  • 归一化必须在写出口做,别指望消费方兼容多种拼写——消费方清单会持续增长(监控、告警、BI、排障脚本),每加一个消费方就多一处要兼容。
  • 映射函数要覆盖非标级别:CRITICAL→fatal、FATAL→fatal,缺了这条,fatal 级告警会以 critical 的拼写漏过监控。
  • 契约文档里每个「来源/实现」列都是规范的一部分:写下它之前先问一句「这是应该的写法,还是今天恰好是这么写的?」
  • 跨服务日志的字段契约(级名、traceId、service 名)建议集中一处维护,四服务引用同一份,避免各写各的。

常见问题​

Python 的 WARNING 为什么不能直接写进日志表?​

stdlib 级名的字面量是 WARNING/CRITICAL,跨语言契约通常定义为 warn/fatal——直写或 .lower() 得到的 warning/critical 在契约世界里是未知级别,所有按契约级名过滤的消费方(监控、告警)都会漏掉这些行。Python 侧必须在写出口映射成契约级名。

WARNING 和 WARN 是同一个级别吗?​

语义相同、字面不同。Python 的 logging 没有 WARN 级别(WARN 是废弃别名,实际输出永远是 WARNING),也没有 FATAL(对应 CRITICAL)。所以「小写一下」解决不了问题——需要在出口做显式映射:WARNING→warn、CRITICAL→fatal。

怎么发现日志契约和实现已经漂移?​

定期按契约级名做对账查询(GROUP BY level),出现契约外的拼写即为漂移。更重要的是契约文档要写「应该的实现」并注明映射函数入口,而不是复制某个服务的现状——文档照抄实现,错误就有了背书,这是本次漂移存活已久的根源。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

PostgreSQL 数据库迁移后留下废弃空表?用 information_schema 审计 schema 残留

· 阅读需 7 分钟

在一次广告数据源从日表切换到周表后,我发现 schema 里还躺着一张只在最早期的 migration 里建过、0 行数据、运行时代码从不引用的空表——还带着过时的字段名和没规范化的中文列。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。这次数据源切换后,写入端早已迁到新的周表,旧日表的清理 migration 也补了,唯独一张只在 baseline 里 CREATE 过的月表被遗漏——它既没有对应的新写入,也没有 DROP,就那样潜伏在 schema 里,带着早已废弃的字段定义。

TL;DR​

废弃表的典型特征:只在早期/baseline migration 里 CREATE、当前代码 0 引用、常带旧字段或未规范化的列名。批量迁移时它们不会被自动处理,需要主动用 information_schema.tables 列出 schema 全表,再与代码引用比对,定位 orphan 表后写一条 DROP migration 清理——而不是手动 psql 删完就了事。

问题现象​

一张典型的废弃表长这样:

  • 0 行数据——业务早已不再写入它;
  • 0 运行时引用——代码里 grep 不到任何 SELECT/INSERT,只剩 migration 文件里的 CREATE;
  • 旧字段残留——字段名是上一版命名(如 ad_plan_id/product_id),与当前规范不一致;
  • 未规范化的列名——甚至还有中文列名没来得及改。

它不报错、不影响线上运行,所以从「线上没出问题」的视角完全无感。但它的危害是隐性的:误导后来者以为它仍在用、占用 schema 命名空间、在跨表审计时制造噪音,还可能被某个误判的 SELECT * 意外读到脏数据。

根因​

数据库迁移有一个普遍的模式:migration 是「加法」的。

一次数据源切换通常这样演进:

  1. 早期 baseline migration CREATE 了一批表(日表、月表);
  2. 业务跑通后,写入端开始依赖这些表;
  3. 需求变化,引入新表(周表),写入端逐步迁移过去;
  4. 旧表的写入停了,补一条 migration DROP 旧日表;
  5. 但月表/其他只在 baseline 建过、从未被写入端直接引用的表,没有对应的 DROP。

问题出在第 5 步:迁移注意力集中在「现在用到的表」上——哪些表在写入、哪些 SQL 在查。而「曾经存在、但从未进入主链路」的表既不在写入端、也不在查询端,自然不会触发任何 DROP,于是成了 orphan。这类残留和 Airflow 删除 DAG 后元数据残留 是同一类问题:「删了入口、忘了清结构」,是迁移类问题的高发区。

解决方案​

核心流程:列全表 → 比对引用 → 确认空表 → 写 migration DROP → 验证。

步骤 1:用 information_schema 列出 schema 下所有基础表​

-- 列出某 schema 下所有基础表(排除视图)
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'your_schema'
AND table_type = 'BASE TABLE'
ORDER BY table_name;

information_schema.tables 是 SQL 标准目录视图,跨 PostgreSQL/MySQL/SQL Server 通用,字段稳定,非常适合写进审计脚本。

步骤 2:grep 代码库确认运行时引用​

对每张候选表,在代码库里搜索引用,排除 migration 文件本身:

# 搜索运行时代码引用,排除 migrations 目录
grep -rn "ad_product_monthly_stats" src/ --include="*.py" \
| grep -v "migrations/"
# 0 行输出 → 运行时无引用,进入候选

0 引用是判定 orphan 的关键证据。注意一定要排除 migration 目录——baseline 里的 CREATE 不算「引用」。

步骤 3:确认是空表​

SELECT count(*) FROM your_schema.ad_product_monthly_stats;
-- 0 → 确认无数据,可安全清理

对有数据的表要格外谨慎:先确认它真的废弃(而非只是近期没写入),有疑问就先做逻辑备份再处理。

步骤 4:写一条 migration DROP(而非手动删)​

-- db-migrations/{project}/027_drop_ad_product_monthly_stats.sql
DROP TABLE IF EXISTS your_schema.ad_product_monthly_stats;

务必走 migration 文件:它会被版本控制、在所有环境(开发/预发/生产)一致重放,留下审计轨迹。手动 psql 删一次,换台机器就又长回来了。

步骤 5:验证已删除​

SELECT to_regclass('your_schema.ad_product_monthly_stats');
-- 返回 NULL 表示表已不存在

to_regclass() 是验证表是否存在的标准手段,返回 NULL 即确认删除成功。

批量审计:按前缀一次性排查同类遗漏​

单张表清掉后,按前缀把同类表全部列出来逐个核对,避免「清了一张、漏了兄弟」:

-- 列出某前缀下所有表,逐个走 步骤2-5
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'your_schema'
AND table_name LIKE 'ad_%'
ORDER BY table_name;

注意事项

  • DROP 前先备份/快照:生产库删表不可逆。对任何有数据的表,先确认废弃再做逻辑备份(如 CREATE TABLE ... AS SELECT 导出到归档库)。
  • 外键依赖要排查:如果有其他表的外键指向它,DROP TABLE 会失败。确认依赖已解除或有意 CASCADE——但 CASCADE 会连带删除依赖对象,生产环境慎用。
  • 走 migration,不要手动 psql:手动删除只在当前环境生效,迁移文件才能保证多环境一致并留下记录。
  • 用前缀批量审计:一次切换通常涉及一组同前缀的表(如 ad_*),清完一张后用 LIKE 'ad_%' 把兄弟表都过一遍,主动发现同类遗漏。

常见问题​

怎么列出 PostgreSQL 数据库中的所有表?​

查 information_schema.tables,过滤 table_schema 和 table_type = 'BASE TABLE',即可列出某 schema 下所有基础表。它比 psql 的 \dt 更适合写进脚本做自动化审计,且是 SQL 标准、跨数据库通用,代码可移植性更好。

PostgreSQL 怎么找出没被使用的废弃表?​

用 information_schema.tables 列出全部表,再与代码库或查询日志的引用做比对,运行时代码 0 引用且无写入的表即为废弃候选;空表可进一步用 SELECT count(*) 确认行数,确认无数据、无外键依赖后再写 migration DROP 清理。

information_schema 和 pg_catalog 有什么区别?​

information_schema 是 SQL 标准定义的目录视图,跨 PostgreSQL/MySQL/SQL Server 通用、字段稳定不易变,适合写可移植的审计脚本;pg_catalog 是 PostgreSQL 专有目录,信息更全更细(如精确行数估算、存储细节),但版本间可能调整。做通用 schema 审计优先用 information_schema。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Airflow 删除 DAG 后它还在列表里?元数据没清干净 + 正确清理顺序

· 阅读需 7 分钟

在 Airflow 里删掉某个 DAG 的 .py 文件想下线它,Web UI 的 DAG 列表和数据库里却还挂着这个 dag_id;更诡异的是——如果先清元数据再删文件,刚刚清空的行会「复活」重新出现。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析流水线,下线旧版报表 DAG 时要把它的元数据一起清干净,否则 UI 列表和定时扫描会被残留行干扰。

TL;DR​

Airflow 的 dag-processor 会定期扫描 DAG 目录重新注册,而 airflow dags reserialize 也不会清理「文件已删除」的孤儿 dag 行——所以光删 .py 文件,UI 和数据库里的 dag_id 不会自动消失;反过来先清元数据再删文件,processor 扫到文件还在,会把清空的行重新注册(「复活」)。正确顺序:①先删文件让 processor 不再注册 → ②按外键顺序 SQL DELETE 清元数据 → ③跑 airflow dags reserialize 验证。

问题现象​

下线 shop_report_aggregation 这个 DAG,删了它的 .py 文件后:

$ ls /opt/airflow/project/airflow_dags/shop_report_aggregation.py
ls: cannot access '.../shop_report_aggregation.py': No such file or directory

$ # 但数据库里还在
$ docker exec cclhub-db psql -U airflow -d airflow -c \
"SELECT dag_id, is_paused, is_active FROM dag WHERE dag_id='shop_report_aggregation';"
dag_id | is_paused | is_active
--------------------------+-----------+-----------
shop_report_aggregation | f | t ← 仍残留

不只 dag 表,serialized_dag、dag_code、dag_version 表里对应行也全在,于是 Web UI 的 DAG 列表继续显示这个已「删除」的 DAG。

更坑的是反向操作——先清元数据、后删文件:

T0  DELETE FROM dag WHERE dag_id='shop_report_aggregation';   ← 清空
T1 (此时还没删 .py 文件)
T2 dag-processor 扫描周期到达,发现文件存在、dag 表无对应行 → 重新注册
T3 SELECT ... FROM dag WHERE dag_id='shop_report_aggregation'; ← 又回来了(复活)

根因​

两个机制叠加:

1. dag-processor 定期扫描并重新注册。 Airflow 的 dag-processor(Scheduler 的一部分)按 processor_poll_interval(默认约 5 分钟)周期性扫描 dags_folder 目录,解析每个 .py 文件并 upsert 进元数据表(dag、serialized_dag、dag_version)。只要文件还在,下一个扫描周期就会重新写入对应行。 这是「复活」的直接来源——你清了行,文件还在,processor 把它当新 DAG 重新登记。

2. reserialize 不管「文件已消失」的旧行。 airflow dags reserialize 的职责是把现有 DAG 文件重新序列化、刷新 serialized_dag;它不会去删除「文件已经不存在」的孤儿 dag 行。而 airflow dags cleanup 默认只清理过期的 dag_run 运行历史,也不动 dag / serialized_dag / dag_code / dag_version 这几张元数据表。所以删了文件后,元数据行成了无人清理的孤儿。

┌─ dag-processor ──────────────────────────────┐
│ 扫描 dags_folder │
│ ├─ 文件在 → upsert dag / serialized_dag ... │ ← 复活来源
│ └─ 文件不在 → 跳过,不删旧行 │ ← 孤儿残留
└──────────────────────────────────────────────┘

结论:要让元数据真正消失,必须让 processor 没有文件可注册(先删文件),再手动清掉残留的元数据行。

解决方案​

第 1 步:先删文件​

让 .py 文件从 DAG 目录消失,dag-processor 就不会再注册它。

# 生产环境通常经 git pull 同步到 volume 挂载的 DAG 目录
# /opt/airflow/project/airflow_dags/
git pull # 让 shop_report_aggregation.py 从仓库移除并同步到目录

# 或直接删除(确认无其他依赖后)
rm /opt/airflow/project/airflow_dags/shop_report_aggregation.py

第 2 步:按外键顺序清元数据​

按外键依赖顺序 DELETE,避免约束冲突。dag_run 删除会 CASCADE 到 task_instance:

BEGIN;

-- 1. 运行历史(CASCADE 带 task_instance)
DELETE FROM dag_run WHERE dag_id = 'shop_report_aggregation';

-- 2. 序列化 DAG
DELETE FROM serialized_dag WHERE dag_id = 'shop_report_aggregation';

-- 3. 版本
DELETE FROM dag_version WHERE dag_id = 'shop_report_aggregation';

-- 4. dag 主表
DELETE FROM dag WHERE dag_id = 'shop_report_aggregation';

-- 5. dag_code 按源码 hash 存,多个 DAG 可能共享同一份代码;
-- 只删已经没有任何 serialized_dag 引用的 orphan hash
DELETE FROM dag_code
WHERE dag_hash NOT IN (SELECT dag_hash FROM serialized_dag);

COMMIT;

第 3 步:验证​

airflow dags reserialize

# 确认 dag 表不再重建该行
docker exec cclhub-db psql -U airflow -d airflow -c \
"SELECT count(*) FROM dag WHERE dag_id='shop_report_aggregation';"
# count
# -------
# 0 ✅

reserialize 后 dag / serialized_dag / dag_code / dag_version 对该 dag_id 全部为 0,且下一个 processor 扫描周期过去也不再重建,说明清理稳定。

顺带一提,同一条流水线上 pandas NaN 进 XCom 导致任务无声崩溃是另一个值得收藏的坑。

注意事项​

注意事项

  • dag_code 是按源码 hash 共享的:多个 DAG 可能引用同一份源码 hash,删除前务必用 orphan 判定(dag_hash NOT IN (SELECT dag_hash FROM serialized_dag)),不要按 dag_id 直接删——这张表压根没有 dag_id 列。
  • 别指望 airflow dags cleanup 清元数据:它默认只删过期的 dag_run(由 max_active_runs / retention 控制),不动 dag / serialized_dag / dag_code / dag_version。清元数据得手写 SQL。
  • 删文件后等一个扫描周期再清更稳:极端竞态下,删文件和清元数据之间若正好夹一个 processor 扫描,可能在文件已被你删但 processor 还没刷新的窗口里写入。实践中按「先删文件→再清元数据→reserialize 验证」顺序操作即可,必要时清完再跑一次 reserialize 确认。
  • 删 DAG 前确认无下游依赖:其他 DAG 可能用 ExternalTaskSensor 等待这个 DAG,或 TriggerDagRunOperator 触发它。下线前 grep 一遍 dag_id 引用。

常见问题​

Airflow 删除 DAG 的 .py 文件后为什么还在列表里?​

因为删除文件不会清理数据库元数据。dag / serialized_dag / dag_code / dag_version 这几张表的旧行仍然存在,Web UI 读这些表来渲染列表,所以已删的 DAG 还会显示。Airflow 没有内置命令自动清这些孤儿行,需要手动按外键顺序 SQL DELETE。

Airflow 怎么彻底删除一个 DAG 及其全部元数据?​

三步:①先删 .py 文件,让 dag-processor 不再注册它;②按外键顺序 SQL DELETE 清理(dag_run → serialized_dag → dag_version → dag → orphan dag_code);③跑 airflow dags reserialize,然后查 dag 表确认该 dag_id 行数不再重建为 0。

Airflow 清理 DAG 元数据的正确顺序是什么?为什么不能先清元数据再删文件?​

必须先删文件、后清元数据。反过来操作的话,.py 文件还在,dag-processor 下一个扫描周期会重新把清空的 dag 行注册回来,元数据「复活」。只有先让文件消失、processor 无文件可注册,再清残留的元数据行,才能彻底下线。


CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

dotenv 值被 # 静默截断?.env 特殊字符加双引号并刷新进程

· 阅读需 7 分钟

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。

TL;DR​

dotenv 把未加引号的值中任意位置的 # 当作行内注释。KEY=value#hash 实际加载的值是 value,#hash 被丢弃且无任何报错。解法分两步:.env 中含 # 的值用双引号包裹,改完用 pm2 restart 应用名 --update-env 强制刷新进程环境——不刷新等于白改,PM2 会把缓存的旧值原样注入新进程。

问题现象​

后端调用上游服务,一直返回 401 Invalid credentials:

POST /api/v1/dag/trigger → 500
日志堆栈:Airflow JWT auth failed (401): {"detail":"Invalid credentials"}
at getJwtToken (airflow-client.ts)

第一反应是密码配错或账号失效。排查发现 .env 文件里写的密码是 24 位、含 # 和 &:

AIRFLOW_PASSWORD=Aq7#mZx&V3nKp9RtWu2yBc4d

但 Node.js 进程实际加载到的 process.env.AIRFLOW_PASSWORD 长度只剩 3——# 后面的 21 个字符整段消失。用完整密码直接 curl 上游鉴权接口 → 返回 201;用 .env 解析出来的残缺值 → 返回 401。账号没问题,是 .env 加载出来的值被截断了。如果你搜的是「.env 配置不生效」「环境变量值不对」「密码明明正确却提示密码错误」,都是同一类问题。

根因:dotenv 把未加引号值中的 # 当行内注释​

dotenv 解析 .env 时,未加引号的值遇到 # 就结束,# 及其后内容按行内注释丢弃,整个过程没有任何警告。这个行为符合 dotenv 文档,但静默是它最毒的地方——不报错、不告警,新版启动时多打印一行 injected env 摘要(实测 18.0.4),也只显示注入了几个键,不提示截断。

用项目里的 dotenv 16.6.1 和当时的最新版 18.0.4 各跑了一遍解析矩阵,结果完全一致:

.env 写法实际加载值
A=val#hashval
B=val #hashval
C="val#hash"val#hash
H='val#hash'val#hash
D=val&moreval&more
E=val with spaceval with space
I="val" # commentval

三件事值得注意:

  • 不需要空格。shell 里 # 前有空格才开注释,dotenv 不挑——val#hash 这种紧贴写法照样截断。密码生成器产出的强随机串(JWT_SECRET、API_KEY、DATABASE_URL)里 # 出现在任意位置,正是高发雷区。
  • 引号是字面开关。单双引号内的 # 都按普通字符保留;引号结束后再写 # comment 仍按注释处理。
  • & 和空格在 dotenv 里是安全的。实测两者都不触发截断,会咬人的只有 #;core dotenv 也不做 $VAR 变量展开(那是 dotenv-expand 插件的事)。

解决方案:值加双引号,重启强制刷新环境​

第一步,.env 里含 # 的值用双引号包裹:

# 被截断:进程里实际拿到 Aq7
AIRFLOW_PASSWORD=Aq7#mZx&V3nKp9RtWu2yBc4d

# 正确:完整保留
AIRFLOW_PASSWORD="Aq7#mZx&V3nKp9RtWu2yBc4d"

第二步,重启时带上 --update-env:

pm2 restart analytics-api --update-env

这步不能省,原因是两个「默认不覆盖」叠在一起:

  1. PM2 缓存进程首次启动时快照的环境变量,pm2 restart 默认把旧快照原样注入新进程;
  2. dotenv 默认不覆盖 process.env 里已存在的同名键(实测:先设 process.env.X='oldvalue' 再 dotenv.config(),X 仍是 oldvalue)。

改了文件但不刷新进程环境,等于白改——进程还是拿 PM2 缓存的旧值。docker compose、systemd 场景同理,改完都要让服务真正重建环境(docker compose up -d --force-recreate、systemctl restart)。

改完核对一次进程实际读到的值,别靠猜:

# 打印长度,和 .env 里的原值对比
node -e "console.log(process.env.AIRFLOW_PASSWORD.length)"

更进一步,把关键变量的长度校验放进启动流程,让静默失败变成启动失败,下次第一时间暴露:

// 启动时验证关键变量长度,提前拦截截断
const required = ['AIRFLOW_PASSWORD', 'JWT_SECRET', 'DATABASE_URL'] as const;
for (const key of required) {
const v = process.env[key];
if (!v || v.length < 16) {
throw new Error(`${key} 未正确加载(长度 ${v?.length ?? 0}),请检查 .env 引号`);
}
}

边界与变体:docker compose 的规则和 dotenv 不一样​

# 截断不是所有 env 解析器的统一行为——docker compose 的规则恰好与 dotenv 相反,同一份文件跨工具结果不同。compose-spec 明文规定:「Inline comments for unquoted values must be preceded with a space」(未加引号值的行内注释必须以空格开头),官方示例里 VAR=VAL# not a comment 的加载结果就是 VAL# not a comment,原样保留。

同一行 API_PASSWORD=Kx9#mPwdotenv 实测docker compose(spec)
加载结果Kx9Kx9#mPw

单看 dotenv,只有 # 危险;但同一份 .env 常常被多个工具读——本地 shell、docker compose、PM2、CI 各有一套解析器。与其记每个解析器的差异,我们的取舍是统一一条硬规则:值里出现 #、&、空格,一律双引号包裹。多打两个字符,换跨工具的确定性。

注意事项

  • 双引号 + 不写 ${...}:涉及密码通常想要字面值,双引号内直接写字面内容最稳。
  • 别把 dotenv 的规则当通用规则:compose 行内注释必须带空格(见上表),跨工具共用一份文件时只依赖引号。
  • 容器注入不受此坑影响:Docker/Kubernetes 通过 environment: 注入的变量不走 dotenv;CI(GitHub Actions、GitLab CI)注入到 env 上下文的 secret 同样绕过 dotenv——受影响的只有 .env 文件 + dotenv.config() 这条路径。

常见问题​

为什么 .env 中含 # 的密码会变短?​

dotenv 把未加引号值中任意位置的 # 当行内注释,# 后内容全部丢弃——KEY=value#hash 实际只加载 value,无任何报错(实测 dotenv 16.6.1 与 18.0.4 行为一致)。用双引号包裹整个值即可完整保留。

dotenv 不生效怎么排查?​

三步:先确认 dotenv.config() 在所有 import 之前执行(ES Module 的 import 是静态提升的,详见 JWT 签名静默失败排查);再确认 .env 值里没有未加引号的 #;最后打印 process.env.XXX 的长度与 .env 原值比对——长度对不上就是被截断,而不是没加载。

docker compose 的 env 文件也把 # 当注释吗?​

规则不同。compose-spec 规定未加引号值的行内注释必须以空格开头,VAL=B#C 原样保留;dotenv 实测任意位置的 # 都截断成 B。结论:同一份 env 文件跨工具结果可能不同,双引号是唯一一致的写法。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询