跳到主要内容

6 篇博文 含有标签「DevOps」

查看所有标签

容器日志吃满服务器磁盘?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 -d1docker 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 --forcepnpm 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.jsonoverrides 钉住有漏洞的传递依赖,二次部署两端归零。

问题现象

部署脚本先后在前端(仓库根)和后端(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 链。

解决方案

步骤 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.jsonoverrides 强制钉版本,然后重新 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.levelnameWARNING/CRITICAL——直写或简单 .lower() 产出的是 warning/critical,按契约过滤永远匹配不上。修复原则两条:在写出口做单点映射WARNING→warnCRITICAL/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→fatalFATAL→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→warnCRITICAL→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_schematable_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_dagdag_codedag_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 进元数据表(dagserialized_dagdag_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 ✅

reserializedag / 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_runserialized_dagdag_versiondag → orphan dag_code);③跑 airflow dags reserialize,然后查 dag 表确认该 dag_id 行数不再重建为 0。

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

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


CCLEE

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

合作咨询

dotenv 值被 # 静默截断?.env 变量加双引号包裹

· 阅读需 4 分钟

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

TL;DR

dotenv 把未加引号的值中 # 当作行内注释。KEY=value#hash 实际加载的值是 value#hash 被丢弃且无任何报错。解法:.env 中含 #、空格、特殊字符的值,一律用双引号包裹 —— KEY="value#hash"

问题现象

后端调用上游服务,一直返回 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=ooGR0^kThVI&ag#RyCpUmbIr

但 Node.js 进程实际加载到的 process.env.AIRFLOW_PASSWORD 长度只有 10,#RyCpUmbIr 整段消失。用 CLAUDE.md 记录的完整密码直接 curl 上游鉴权接口 → 返回 201;用 .env 解析出来的残缺值 → 返回 401。密码账号没问题,是 .env 加载出来的值被截断了

根因

dotenv 沿用 shell 习惯,把未加引号的值中 # 之后的内容视为行内注释:

# .env
AIRFLOW_PASSWORD=ooGR0^kThVI&ag#RyCpUmbIr
# dotenv 实际解析为:
# AIRFLOW_PASSWORD = "ooGR0^kThVI&ag"
# #RyCpUmbIr ← 被丢弃

这个行为符合 dotenv 文档,但没有任何警告或日志,运行时拿到的就是个静默截断的字符串。叠加 &、空格、$ 等字符的 shell 转义语义,问题更隐蔽:

字符未加引号时的行为
#之后内容当行内注释,截断
(空格)之后内容被丢弃
$VAR触发变量展开(可能为空字符串)
&shell 后台符号,在 dotenv 中通常保留但在 shell 拼接命令时再次踩坑

JWT_SECRETAPI_KEYDATABASE_URL 这类强随机串经常包含 #,是高发雷区。

解决方案

.env 中含特殊字符的值用双引号包裹:

# .env
AIRFLOW_PASSWORD="ooGR0^kThVI&ag#RyCpUmbIr"
JWT_SECRET="abc#def$ghi jkl"
DATABASE_URL="postgres://user:p@ss#word@host:5432/db"

重启服务使新值生效:

# pm2
pm2 restart analytics-api --update-env

# docker compose
docker compose restart api

# systemd
sudo systemctl restart api

为什么有效:dotenv 看到双引号包裹后,会按字面值读取到右引号为止,#、空格、$ 都不会被特殊处理(除非显式开启 expand 选项)。改完后立即验证加载结果:

// 启动时验证关键变量长度,提前拦截截断
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 引号`);
}
}

这样把 dotenv 的静默失败转成启动失败,下次踩坑时第一时间暴露。

注意事项

  • 单引号也能用,但 dotenv 在单引号内不展开 $VAR,双引号会展开。涉及密码通常希望字面值,建议双引号 + 不写 ${...}
  • dotenv 版本:v15+ 默认行为如上;早期版本(v8 之前)对 # 的处理略有差异,升级前先看 CHANGELOG。
  • Docker / Kubernetes Secret:通过 environment: 注入的变量不走 dotenv,不受此坑影响;只有 .env 文件、dotenv.config() 路径才受影响。
  • CI 环境:GitHub Actions、GitLab CI 把 secret 注入到 env 上下文,也不走 dotenv。

常见问题

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

dotenv 默认把 # 之后的内容当作行内注释丢弃。未加引号的值 KEY=value#hash 实际加载为 value,没有报错也没有日志。把值用双引号包裹 KEY="value#hash" 即可保留完整内容。

dotenv 不生效怎么排查?

三步:先确认 dotenv.config() 在所有 import 之前执行(ES Module 的 import 是静态提升的,详见 JWT 签名静默失败排查);再确认 .env 值不含未转义的 # 或空格;最后在进程启动后打印 process.env.XXX 的长度与字符,与 .env 源文件逐一比对。

CCLEE

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

合作咨询