YAML 避坑完全指南:从挪威问题到不安全的加载器
2026-08-06
打开任何一个 Kubernetes 清单、GitHub Actions 工作流或者 Docker Compose 文件,你看到的都是 YAML。它号称”人类友好的数据序列化格式”,写起来确实比 JSON 清爽:不用引号、不用大括号、不用逗号。代价是——解析器会替你做大量隐式的类型猜测,而这些猜测经常是错的。写一个 no,你可能得到 false;写一个版本号 1.10,你可能得到浮点数 1.1。这篇文章把 YAML 最常见、最伤人的坑系统性地过一遍,并给出能落地的防御策略。
YAML 是 JSON 的超集(大部分情况下)
YAML 的设计目标之一是成为 JSON 的超集:几乎所有合法的 JSON 都是合法的 YAML。也就是说,下面这段既是 JSON 也是 YAML:
{"name": "app", "replicas": 3, "debug": true}
这带来一个实用的推论:拿不准 YAML 怎么写时,你随时可以在 YAML 文件里直接写 JSON 语法——加引号、加括号,一切行为立刻变得明确。但”超集”这个说法有个坑:YAML 允许 tab 之外的许多东西,而 JSON 的规范严格得多;反过来,同一个文件在 YAML 解析器和 JSON 解析器眼里的类型可能完全不同。调试 YAML 和 JSON 互相转换时,可以直接用 YAML/JSON 转换器 把 YAML 展开成 JSON,解析器到底猜出了什么类型,一眼就能看清。
挪威问题:yes/no/on/off 的布尔陷阱
这是 YAML 最著名的坑,著名到有专门的名字:挪威问题(Norway Problem)。YAML 1.1 规范规定了一堆布尔值的同义词:y、n、yes、no、on、off、true、false,以及它们的大小写变体,全部会被解析成布尔值。
问题在于 NO 恰好是挪威的 ISO 3166 国家代码。如果你写一个不带引号的国家列表:
countries:
- CN
- US
- NO
一个遵循 YAML 1.1 的解析器会给你 ["CN", "US", false]。程序不会报错,数据就这么悄悄变了。同样中招的还有给按键开关命名 on:/off: 的配置、用 yes 回答的问卷数据。
需要强调的是:这是 YAML 1.1 的行为。YAML 1.2 收紧了规则,布尔值只认 true 和 false。但现实是大量主流解析库(尤其是老牌库,比如早期的 PyYAML、Ruby 的 Psych 某些版本)默认仍按 1.1 解析,所以这个问题到今天依然活跃。防御方法只有一条铁律:所有可能是字符串的标量,一律加引号——"NO"、"no"、"off" 永远是字符串。
版本号变成浮点数:1.10 不等于 1.10
YAML 解析器看到 1.10 会毫不犹豫地把它当成浮点数,而浮点数 1.10 和 1.1 是同一个数。于是你的依赖版本、API 版本、软件版本全部失真:
dependencies:
mylib: 1.10 # 解析成 1.1
更隐蔽的是混合场景:1.9 会变成浮点 1.9,1.10 变成 1.1,而 v1.10 因为带了字母反而是字符串——同一个列表里类型都不一致,排序和比较逻辑立刻混乱。所有版本号都应该写成字符串:version: "1.10"。
六十进制数字的幽灵
YAML 1.1 还有一个几乎没人知道的特性:支持六十进制(sexagesimal)数字,模仿时钟的写法。12:34:56 会被解析成 12×3600 + 34×60 + 56 = 45296:
duration: 12:34:56 # YAML 1.1 解析器:整数 45296
YAML 1.2 已经把这个特性删掉了,12:34:56 在 1.2 解析器里就是字符串。但只要你的工具链里还有一个 1.1 时代的解析器,任何长得像时间的冒号数字都有被”算术化”的风险。遇到冒号数字,加引号,别无他法。
锚点与别名:省事的代价
YAML 的锚点(&)和别名(*)可以复用配置块,配合合并键 << 还能做”继承”:
defaults: &defaults
timeout: 30
retries: 3
production:
<<: *defaults
timeout: 60
这在 Docker Compose 和 CI 配置里很好用,但有两个坑。第一,别名是引用而不是拷贝——某些解析器产出的对象里,改一处会改所有引用它的地方。第二,合并键 << 是 YAML 1.1 时代由特定解析器引入的扩展,不在核心规范里,不同语言的库支持程度参差不齐。更危险的是”别名炸弹”:构造大量层层嵌套的别名,展开后内存指数级膨胀,可以打垮解析服务。处理不可信 YAML 时务必限制别名展开。
多行字符串:| 与 > 的区别
YAML 有两种块标量,行为完全不同:
literal: |
第一行
第二行
folded: >
这两行
会被折叠
|(literal)保留换行,结果是 "第一行\n第二行\n";>(folded)把换行折叠成空格,结果是 "这两行 会被折叠\n"。写 SQL、Shell 脚本、证书内容时用 |;写长段描述文字时用 >。两者还各有一个”换行符收尾”修饰符:| 默认结尾保留一个换行,| - 去掉结尾换行,| + 保留所有结尾换行。在 Kubernetes ConfigMap 里嵌脚本时,选错符号会让脚本莫名其妙多一个或少一个换行,够你查半天。
Tab 是禁忌,缩进即语义
YAML 明文规定:缩进只能用空格,绝对不能用 Tab。文件里混进一个 Tab,大多数解析器直接报错,少数则给出难以理解的行号。更麻烦的是缩进本身就是语义——多缩进两个空格,键就从顶层滑进了上一级的嵌套对象里,而格式看起来”差不多”:
server:
host: example.com
port: 8080 # 少一个空格:port 变成了顶层键
这类错误肉眼极难发现。建议:编辑器里打开”显示空白字符”,团队约定统一的缩进宽度(2 或 4),并且永远在改完配置后实际解析一遍再提交。把 YAML 粘到 JSON 格式化工具 支持的结构化视图里看层级,比在编辑器里数空格可靠得多。
重复键:安静的最后者胜
JSON 和 YAML 的规范都没说清楚重复键该怎么办,于是大多数解析器选择了最危险的策略:不报错,后面的值静默覆盖前面的:
database:
host: db1.internal
# ……一百行之后……
host: db2.internal # 这个赢了,上面的白写了
大文件里合并配置时最容易踩中。部分现代解析器(如 Go 的某些库、Python 的 ruamel.yaml 严格模式)可以对重复键报错,值得在 CI 里打开这个选项。排查”配置明明改了却不生效”的问题时,先用 JSON 差异对比 把解析前后的配置结构比一遍,被覆盖的键立刻现形。
安全:永远不要直接 load 不可信的 YAML
Python 的 PyYAML 早年有个臭名昭著的 API:yaml.load() 默认会实例化任意 Python 对象。一段恶意的 YAML 可以借此执行系统命令:
!!python/object/apply:os.system ["cat /etc/passwd"]
这类”反序列化即代码执行”的问题不是 Python 独有——Ruby、Java(SnakeYAML 的某些用法)都有类似历史。铁律是:处理任何外部输入的 YAML,只用 safe loader(yaml.safe_load、SnakeYAML 的 SafeConstructor),它只构造字符串、数字、列表、字典这些纯数据结构,拒绝一切自定义类型。PyYAML 新版本已经默认要求显式指定 Loader,这个改动本身就说明问题有多严重。
什么时候用 YAML,什么时候用 JSON
两者不是竞争关系,而是场景不同:
- 人写人读的配置文件(CI 流水线、容器编排、应用配置):用 YAML。注释、多行字符串、锚点在这里是真价值。
- 机器间传输的 API 数据:用 JSON。类型规则严格、解析快、没有隐式转换的惊喜,每种语言都有成熟实现。
- 需要注释但又想用 JSON 的场景:考虑 JSON5、JSONC 这类变体,而不是硬把 YAML 塞进不适合的地方。
转换和调试时,YAML/JSON 转换器 可以快速在两者间互转,顺便暴露隐式类型转换的痕迹——no 变成 false 这种事,转成 JSON 后一目了然。
防御性验证策略
最后给一套可落地的防御清单:
- 字符串标量加引号:凡是可能被猜成布尔、数字、时间的值(
no、1.10、12:30、2024-01-01),全部写成"..."。 - 只用 safe loader:不可信输入永远
safe_load,并限制别名展开深度。 - 上 Schema 验证:Kubernetes 生态有 kubeval/kubeconform,通用场景可以用 JSON Schema 校验 YAML(先转 JSON 再校验),CI 里跑一遍比线上排障便宜得多。
- 严格模式:能开”重复键报错”就开。
- 改完就解析:任何手工编辑的 YAML,提交前实际 load 一次并比对结构——用 JSON 格式化工具 看层级,用 JSON 差异对比 看变更。
YAML 的核心矛盾在于:它为了让人写得爽,把大量的解释工作推给了机器。理解了隐式类型转换、锚点展开和不安全加载器这三件事,你就能享受 YAML 的便利而不被它暗算。