Linux 基础体系 · 第 46/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。
命令行结构化数据:jq、yq、JSON、YAML、流式处理和安全更新
在 Linux 自动化中,配置文件、API 响应、容器清单、CI 参数和状态文件经常以 JSON 或 YAML 表示。它们看起来像“文本”,但真正的处理对象是具有层次结构的数据:
{
"service": {
"name": "api",
"replicas": 3,
"ports": [8080, 8443]
}
}
如果使用 grep、sed 或简单的正则表达式修改这类内容,脚本实际上是在猜测格式。字段可能换行、重新排序、嵌套、出现重复键,字符串中也可能包含与语法相似的字符。jq 和 yq 的核心价值,是把输入解析为结构化数据,再依据路径、类型和条件进行变换。
本文使用以下工具约定:
jq:处理 JSON。yq:文中命令以 Go 实现的 Mike Farahyqv4 为例。不同项目也有名为yq的工具,例如 Python 包kislyuk/yq,命令参数和表达式语法并不完全相同。- 示例面向现代主流 Linux 发行版。
- 使用
bash语法;涉及flock、mktemp等命令时,会说明其实现和移植边界。
一、先区分文本、序列化格式和数据结构
1.1 JSON 和 YAML 表示什么
数据结构是内存中的对象,例如:
- 对象:键到值的映射;
- 数组:有顺序的值序列;
- 字符串;
- 数字;
- 布尔值;
- 空值。
JSON 是这种结构的一种严格序列化格式。JSON 对象、数组和标量分别对应:
{
"name": "web",
"enabled": true,
"ports": [80, 443],
"limits": null
}
JSON 的重要约束包括:
- 对象键必须是字符串;
- 字符串使用双引号;
- 布尔值只能是
true或false; - 空值是
null; - 不能写注释;
- 一个 JSON 文本通常表示一个 JSON 值。
因此,下面内容是一个 JSON 文本:
{"a": 1}
但下面内容不是一个完整的单一 JSON 文档:
{"a": 1}
{"a": 2}
它可以是 JSON Lines 或 NDJSON,即“每行一个独立 JSON 值”,但不能直接当作一个 JSON 数组交给普通 jq 处理。
YAML 也是结构化数据的序列化格式,但语法更加宽松:
service:
name: web
enabled: true
ports:
- 80
- 443
YAML 通常支持:
- 缩进表示层次;
- 注释;
- 多文档;
- 锚点和别名;
- 多种标量写法;
- 标签和类型系统。
这种灵活性带来兼容性和安全边界:不同 YAML 解析器对日期、数字、标签、重复键和锚点的处理可能不同。因此,YAML 文件“看起来正确”,并不等于所有工具会把它解释成同一个数据结构。
1.2 YAML 不是“带注释的 JSON”
YAML 的一部分语法可以表示 JSON,但二者的语义并不完全相同。例如:
enabled: yes
date: 2024-01-01
某些 YAML 版本或解析器可能把 yes 解释为布尔值,把日期解释为带类型的值;另一些实现可能把它们保留为字符串。为了避免跨工具歧义,配置文件中应显式写出字符串:
enabled: "yes"
date: "2024-01-01"
YAML 中以下值也容易造成误解:
a: on
b: off
c: null
d: 0123
它们究竟是字符串、布尔值、空值还是数字,与 YAML 版本和解析器有关。yq 会把 YAML 解析为自己的节点模型,再输出 YAML 或 JSON;转换过程中可能发生类型规范化、引号变化、注释丢失或格式重排。
因此:
- 需要严格机器接口时,JSON 通常更容易约束;
- 需要人工维护、注释和多文档时,YAML 更方便;
- 不能把“YAML 语法可读”当作“所有解析器语义一致”。
二、jq 的核心模型:输入值、过滤器和输出值
2.1 jq 不是文本替换器
jq 接收 JSON 输入,并执行一个过滤器(filter)。可以把一个过滤器抽象为:
其中 是 JSON 值集合。一个输入值可以产生零个、一个或多个输出值。
最简单的过滤器是 .:
printf '%s\n' '{"name":"api","replicas":3}' | jq .
输出:
{
"name": "api",
"replicas": 3
}
这里 . 表示“原样传递当前输入值”,只是默认格式化输出。
字段访问:
printf '%s\n' '{"name":"api","replicas":3}' | jq '.name'
输出:
"api"
注意:默认输出仍是 JSON 字符串,因此包含双引号。如果希望得到适合传给 Shell 的原始字符串:
printf '%s\n' '{"name":"api","replicas":3}' | jq -r '.name'
输出:
api
-r 只改变输出编码,不改变 jq 内部的数据类型。
2.2 数组访问和管道
输入:
{
"services": [
{"name": "api", "replicas": 3},
{"name": "worker", "replicas": 5}
]
}
取得所有服务名:
jq -r '.services[].name' services.json
处理过程可以逐步理解为:
.services:得到数组;.services[]:对数组中的每个元素分别产生一个输出;.services[].name:从每个元素中取name。
结果是两个独立输出值:
api
worker
jq 管道不是 Shell 管道。下面的表达式:
.services[] | .name
表示把左侧产生的每个值依次交给右侧,而不是启动操作系统进程。
2.3 条件、构造对象和更新
筛选副本数大于 3 的服务:
jq '.services[] | select(.replicas > 3)' services.json
构造新对象:
jq '.services[] | {service: .name, count: .replicas}' services.json
如果输入为:
{"services":[{"name":"api","replicas":3},{"name":"worker","replicas":5}]}
输出为两个 JSON 对象:
{"service":"api","count":3}
{"service":"worker","count":5}
修改当前对象中的字段:
jq '.replicas = .replicas + 1' service.json
这里 .replicas = ... 是结构化更新,不是字符串替换。若字段不存在,通常会创建它:
printf '%s\n' '{}' | jq '.replicas = 1'
输出:
{
"replicas": 1
}
嵌套更新:
jq '.service.replicas = 4' config.json
数组中按条件更新:
jq '
.services |= map(
if .name == "api"
then .replicas = 4
else .
end
)
' services.json
处理逻辑是:
.services取出服务数组;map(...)逐个处理数组元素;- 名称为
api的元素更新replicas; - 其他元素由
else .原样返回; |=把新数组写回.services。
2.4 缺失、空值和错误不是一回事
以下三种状态必须区分:
{}
字段不存在;
{"replicas": null}
字段存在但值为空;
{"replicas": 0}
字段存在且值为数字零。
表达式:
jq '.replicas // 1' input.json
使用“空值合并”逻辑:当左侧结果为 null 或 false 时使用 1。它并不只判断字段是否缺失。
如果必须要求字段存在且类型为数字,可以显式验证:
jq -e '
if (.replicas | type) == "number"
then .
else error("replicas must be a number")
end
' input.json
-e 会让 jq 根据最终输出值设置更有区分度的退出状态:输出 false 或 null 时也会被视为失败场景。脚本不能只检查“有没有输出”,还应检查退出码。
三、Shell 与 jq 的边界:引用、变量和注入风险
3.1 不要把 Shell 变量直接拼进过滤器
下面写法有两个问题:
name='api"; .admin = true'
jq ".services[] | select(.name == \"$name\")" config.json
Shell 会先展开变量,变量内容又可能改变 jq 过滤器的语法。即使没有恶意输入,也会因为引号、反斜杠或换行导致解析失败。
应使用 --arg:
name='api"; .admin = true'
jq --arg name "$name" '
.services[]
| select(.name == $name)
' config.json
--arg name "$name" 将 Shell 字符串作为 JSON 字符串值绑定到 $name,不会把内容重新解释为 jq 程序。
数字参数应使用 --argjson:
replicas=4
jq --argjson replicas "$replicas" '
.replicas = $replicas
' service.json
--arg replicas "$replicas" 得到的是字符串 "4";--argjson 得到的是数字 4。二者类型不同:
printf '%s\n' '{}' | jq --arg x 4 '.x = $x'
# {"x":"4"}
printf '%s\n' '{}' | jq --argjson x 4 '.x = $x'
# {"x":4}
--argjson 的参数必须是合法 JSON。若变量为空或内容不是 JSON,命令会失败,因此外部输入需要先验证。
3.2 Shell 命令替换会丢失边界
下面代码试图读取多个服务名:
for name in $(jq -r '.services[].name' config.json); do
echo "$name"
done
命令替换结果会经过 Shell 的词分割和路径名展开,服务名中包含空格、通配符或换行时会被错误拆分。
更安全的方式是使用 NUL 分隔:
while IFS= read -r -d '' name; do
printf 'service=%s\n' "$name"
done < <(jq -j '.services[] | .name, "\u0000"' config.json)
若程序链路可以接受 JSON,优先让下游继续处理 JSON,而不是把结构化数据降级成空格分隔文本。
四、jq 的输入模式:单文档、JSON Lines、数组和流式解析
4.1 普通模式:一次读取一个 JSON 值
给定:
{"id":1}
{"id":2}
普通 jq 可以逐个读取这两个顶层 JSON 值:
printf '%s\n' '{"id":1}' '{"id":2}' | jq '.id'
输出:
1
2
但是每个输入值仍是独立文档。若要将它们收集成一个数组,可以使用 -s 或 --slurp:
printf '%s\n' '{"id":1}' '{"id":2}' | jq -s .
输出:
[
{
"id": 1
},
{
"id": 2
}
]
-s 的代价是必须把所有输入值收集起来。输入量很大时,这会增加内存消耗。
如果只是逐条转换,直接流式处理更合适:
printf '%s\n' '{"id":1}' '{"id":2}' |
jq -c '{id, doubled: (.id * 2)}'
输出:
{"id":1,"doubled":2}
{"id":2,"doubled":4}
-c 使每个输出对象压缩为一行,适合 JSON Lines。它不是任意 JSON 的安全分隔协议;只有当上游保证“一行一个 JSON 值”时,按行读取才成立。
4.2 --stream:将嵌套 JSON 拆成路径和值
普通 jq 需要构造完整的 JSON 值。--stream 则把叶子值和空数组、空对象表示成路径事件:
printf '%s\n' '{"users":[{"name":"a"},{"name":"b"}]}' |
jq --stream .
典型输出形态为:
[
["users", 0, "name"],
"a"
]
[
["users", 1, "name"],
"b"
]
每条事件通常是:
[path, value]
其中 path 是数组,表示从根到当前值的路径;value 是对应值。
筛选所有用户名称:
printf '%s\n' '{"users":[{"name":"a"},{"name":"b"}]}' |
jq --stream '
select(length == 2 and .[0][-1] == "name") | .[1]
'
--stream 适合:
- 只需扫描大量文档中的局部叶子;
- 想在解析阶段减少完整对象的内存占用;
- 处理极大的数组或对象。
它不适合直接替代普通更新语法。要对整个对象进行复杂的跨字段变换,通常需要完整上下文;强行使用路径事件会增加状态机复杂度。
还要注意:--stream 的输出是事件序列,不是原始 JSON 的简单“逐行版本”。数组索引、空容器和路径重建都必须按事件语义处理。
4.3 流式处理的内存条件
假设有 条独立 JSON Lines 记录,每条记录平均大小为 。
- 逐条执行:理想情况下内存接近 ;
jq -s收集全部记录:内存接近 ;- 将整个文件先读入 Shell 变量:还会受到 Shell 字符串、命令替换和空字节限制;
--stream处理一个巨大 JSON 文档:解析状态和当前事件规模通常远小于完整树,但复杂聚合仍可能需要自己保存状态。
例如,下面命令并没有真正保持低内存:
jq -s 'map(select(.enabled))' huge.jsonl
它先收集所有记录,再筛选。若只需要逐条筛选:
jq -c 'select(.enabled == true)' huge.jsonl
如果要统计总数,reduce 可以逐条累加,但状态本身必须保存在内存中:
jq -s 'map(select(.enabled == true)) | length' huge.jsonl
对于 JSON Lines,若只需计数,也可以用外部工具:
jq -c 'select(.enabled == true)' huge.jsonl | wc -l
不过这要求每个输入值恰好产生一行输出,且不能使用多行格式化输出。
五、yq 的核心模型:YAML 解析、节点保留和格式转换
5.1 用 yq 处理 YAML
假设 deployment.yaml 为:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
template:
spec:
containers:
- name: web
image: example/web:1.0
读取字段:
yq '.spec.replicas' deployment.yaml
输出:
2
读取镜像:
yq -r '.spec.template.spec.containers[0].image' deployment.yaml
对于 Mike Farah yq,-r 可用于原始字符串输出;具体选项应以已安装版本的 yq --help 为准,因为不同实现的参数并不完全相同。
更新副本数:
yq '.spec.replicas = 3' deployment.yaml
默认输出修改后的内容,但不会自动覆盖原文件。可以显式重定向:
yq '.spec.replicas = 3' deployment.yaml > deployment.new.yaml
或者使用该实现提供的原地选项:
yq -i '.spec.replicas = 3' deployment.yaml
-i 方便,但不是事务机制。进程在写入途中崩溃、磁盘满、权限异常或被并发进程覆盖时,原文件可能处于不完整或非预期状态。因此生产更新不应把 -i 等同于安全写入。
5.2 YAML 路径与数组更新
给容器镜像统一加标签:
yq '
.spec.template.spec.containers[] .image
' deployment.yaml
上面的空格容易造成表达式阅读困难,更明确的写法是:
yq '
.spec.template.spec.containers[].image
' deployment.yaml
只更新名称为 web 的容器:
yq '
.spec.template.spec.containers |= map(
if .name == "web"
then .image = "example/web:1.1"
else .
end
)
' deployment.yaml
这与 jq 中数组 map 的思路相同:先得到数组,再对元素逐个变换,最后写回数组。
5.3 yq 与 JSON 的互操作
YAML 可以转换为 JSON:
yq -o=json '.' deployment.yaml | jq .
JSON 也可以转成 YAML:
jq '.' config.json | yq -P -o=yaml
更直接地:
yq -p=json -o=yaml '.' config.json
参数名称和组合在不同 yq 实现中可能不同,应先确认:
yq --version
yq --help
转换时需要明确一个事实:这是“解析后重新序列化”,不是保留原始字节。以下内容可能变化:
- 缩进;
- 引号;
- 键顺序;
- 注释;
- 空行;
- YAML 锚点和别名表示;
- 某些标量的类型或格式。
如果代码评审依赖稳定 diff,应固定工具版本,并接受格式化输出的规范化结果。
5.4 多文档 YAML
YAML 可以在一个文件中放置多个文档:
---
kind: ConfigMap
metadata:
name: a
---
kind: ConfigMap
metadata:
name: b
普通 yq 操作通常会按文档处理。需要跨文档聚合时,应显式使用对应的 eval-all 或合并表达式;不要假设“文件里有多个 ---”就等价于一个数组。
跨文档处理的难点在于:每个文档都是独立根值,当前文档、文件索引和文档顺序都可能影响表达式。对于 Kubernetes 清单等场景,先明确目标是:
- 修改每个文档;
- 选择某一类文档;
- 把全部文档收集成数组;
- 跨文档合并同名对象。
这四种操作的语义不同,不能只依靠一条模糊的合并命令。
六、为什么 grep、sed、awk 不能代替结构化解析
传统文本工具仍然非常有用:
grep:筛选行或匹配文本;sed:按规则替换文本;awk:按记录和字段进行计算;cut:截取分隔字段;sort、uniq:排序和去重;- 正则:描述局部文本模式。
但是 JSON 和 YAML 的结构边界不由固定行决定。例如:
{"description":"the string contains } and \"quotes\"","enabled":true}
正则无法可靠判断字符串中的 } 是数据还是对象结束符。嵌套数组、转义字符、Unicode、YAML 多行字符串和注释会进一步破坏“按行猜结构”的假设。
反例:
sed -i 's/replicas: 2/replicas: 3/' deployment.yaml
它只在以下条件同时成立时才可能工作:
- 字段正好叫
replicas; - 值正好是
2; - 该字段独占一行;
- 没有重复字段;
- 没有注释或特殊缩进;
- 不需要验证 YAML 结构;
- 不存在同名的其他字段。
若文件改成:
spec:
replicas: 2
仍可能替换;但若出现:
metadata:
replicas: 2
spec:
replicas: 2
它会修改两个位置,通常已经违背意图。结构化工具表达的是路径:
yq '.spec.replicas = 3' deployment.yaml
这才明确限定了目标。
反过来,若任务确实是修改注释、保留特殊排版、替换固定模板中的一段原文,文本工具可能比结构化工具合适。选择依据不是“哪个工具更高级”,而是目标是字节布局还是数据结构。
七、验证:语法正确不等于业务正确
结构化工具通常会完成语法解析,但不会自动知道业务约束。例如:
spec:
replicas: -10
这是可能合法的 YAML,但未必是合法部署配置。
7.1 语法验证
验证 JSON:
jq empty config.json
若 JSON 合法,通常没有标准输出并以零退出;若语法错误,会输出诊断信息并返回非零。
验证 YAML:
yq '.' config.yaml > /dev/null
这只验证 yq 能否解析,不验证 Kubernetes、应用程序或自定义协议的约束。
7.2 类型和范围验证
验证 JSON 中的 replicas:
jq -e '
(.spec.replicas | type) == "number"
and (.spec.replicas | floor) == .spec.replicas
and .spec.replicas >= 0
' config.json
这个表达式要求:
- 字段类型为数字;
- 数字是整数;
- 数值不小于零。
若还要限制上限:
jq -e '
(.spec.replicas | type) == "number"
and (.spec.replicas | floor) == .spec.replicas
and .spec.replicas >= 0
and .spec.replicas <= 100
' config.json
验证数组非空:
jq -e '
(.spec.template.spec.containers | type) == "array"
and (.spec.template.spec.containers | length) > 0
' deployment.json
验证字段存在但允许值为 null,不能简单使用 //。可以用 has:
jq -e '.spec | has("replicas")' config.json
7.3 先变换,再验证,再提交
安全更新的逻辑应是:
其中 是结构化变换。关键点是:验证对象必须是变换后的完整结果,而不是只验证一个单独字段。
例如:
set -euo pipefail
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
jq '
.spec.replicas = (.spec.replicas + 1)
' config.json >"$tmp"
jq -e '
(.spec.replicas | type) == "number"
and (.spec.replicas | floor) == .spec.replicas
and .spec.replicas >= 0
and .spec.replicas <= 100
' "$tmp" >/dev/null
jq -e empty "$tmp" >/dev/null
只有所有步骤成功,才应替换原文件。set -e 不是完整错误处理机制,但配合显式检查可以避免忽略关键失败。
八、安全更新:临时文件、原子替换和权限
8.1 为什么不能直接覆盖原文件
命令:
jq '.version = "2"' config.json > config.json
存在明确错误:Shell 会先打开并截断 config.json,然后 jq 才开始读取输入。结果通常是输入变成空文件。
下面命令避免了同一个文件作为输入和输出:
jq '.version = "2"' config.json > config.new.json
但它仍不是安全提交:
- 目标文件写到一半时进程可能退出;
- 磁盘可能写满;
- 新文件权限可能与旧文件不同;
- 多个进程可能同时生成并覆盖结果;
- 失败时可能留下误导性的半成品。
8.2 同目录临时文件和 mv
在同一个文件系统中,使用临时文件写入完整内容,再用 mv 替换:
set -euo pipefail
file=/etc/myapp/config.json
dir=$(dirname -- "$file")
tmp=$(mktemp "$dir/.config.json.XXXXXX")
trap 'rm -f -- "$tmp"' EXIT
jq '.version = "2"' "$file" >"$tmp"
jq -e empty "$tmp" >/dev/null
chmod --reference="$file" "$tmp"
chown --reference="$file" "$tmp"
mv -f -- "$tmp" "$file"
trap - EXIT
每一步的作用:
mktemp "$dir/...":临时文件位于目标文件同一目录,避免跨文件系统;- 重定向到临时文件:原文件在变换期间保持不变;
jq -e empty:确认生成物仍是合法 JSON;chmod --reference、chown --reference:尽量保留权限和所有者;mv:同一文件系统内通常是原子的目录项替换;trap:失败退出时删除临时文件。
这里的“原子”主要指观察者不会看到一个只写了一半的新目录项:要么看到旧文件,要么看到新文件。它不自动保证以下性质:
- 新数据已经落盘;
- 多个进程没有互相覆盖;
- 应用程序已经重新加载新配置;
- 更新和相关外部操作是一个事务。
8.3 fsync、目录持久化和断电边界
系统调用层面的 rename 或 mv 原子性,不等同于断电后的持久性。若要求强持久性,通常还需要:
- 将临时文件内容写入;
- 对临时文件执行
fsync; rename替换目标;- 对父目录执行
fsync。
GNU/Linux 用户空间常见命令不一定直接提供完整的文件和目录同步流程。可以使用专门程序、Python/Perl 系统调用,或让应用程序自身完成持久化协议。普通配置脚本若未明确要求抗断电一致性,不应声称简单 mv 已经提供该保证。
8.4 文件权限和敏感信息
mktemp 通常创建仅所有者可读写的临时文件,但最终权限仍应显式确认。尤其是:
- 原文件是
0600,新文件不能意外变成0644; - 临时文件位于受控目录;
- 不要把带密码的 JSON 通过命令行参数传递,因为参数可能出现在进程列表;
- 不要把秘密打印到日志;
- Shell 日志、CI 输出和错误信息都可能泄露配置内容。
jq 的 --arg 可以避免将变量拼进过滤器,但不会自动替你隐藏输出。如果过滤器输出整个对象,秘密仍会出现在标准输出。
九、并发更新:原子替换不等于无丢失更新
考虑两个进程同时读取:
{"replicas":2}
进程 A 计算 replicas = 3,进程 B 计算 replicas = 4。若二者分别写临时文件并依次 mv:
- A 读取旧值 2,生成 3;
- B 读取旧值 2,生成 4;
- A 提交 3;
- B 提交 4。
最终结果是 4,A 的更新丢失。每次 mv 都可能是原子的,但整个“读—改—写”不是串行化操作。
需要互斥时,可使用 flock:
(
flock -x 9
file=/var/lib/myapp/state.json
dir=$(dirname -- "$file")
tmp=$(mktemp "$dir/.state.json.XXXXXX")
trap 'rm -f -- "$tmp"' EXIT
jq '.counter += 1' "$file" >"$tmp"
jq -e '
(.counter | type) == "number"
and .counter >= 0
' "$tmp" >/dev/null
chmod --reference="$file" "$tmp"
chown --reference="$file" "$tmp"
mv -f -- "$tmp" "$file"
) 9>/var/lock/myapp-state.lock
这里的锁文件与数据文件分离。需要注意:
flock是 Linux 常见实现,不是所有 Unix 都提供相同命令;- 锁只对遵守同一锁协议的进程有效;
- 如果其他程序绕过锁直接写文件,锁无法阻止它;
- NFS 等网络文件系统上的锁语义需要单独确认;
- 锁的权限必须允许实际运行用户打开锁文件。
如果应用本身支持条件更新,比较文件版本或哈希往往更可靠:
- 读取旧内容和版本;
- 计算新内容;
- 提交时确认版本仍未改变;
- 若改变则重新读取并重试或报告冲突。
这类似乐观并发控制,适合不希望长时间持锁的场景。
十、配置更新的完整脚本
下面脚本将 JSON 配置中的服务副本数增加 1,并执行类型、范围和语法校验:
#!/usr/bin/env bash
set -Eeuo pipefail
file=${1:?usage: update-replicas FILE}
dir=$(dirname -- "$file")
base=$(basename -- "$file")
lock="$dir/.$base.lock"
(
flock -x 9
tmp=$(mktemp "$dir/.$base.tmp.XXXXXX")
backup=$(mktemp "$dir/.$base.backup.XXXXXX")
cleanup() {
rm -f -- "$tmp" "$backup"
}
trap cleanup EXIT
# 先保存旧文件,备份失败时不继续。
cp --preserve=mode,ownership,timestamps -- "$file" "$backup"
# 读取、变换并写入临时文件;原文件此时不变。
jq '
if (.spec.replicas | type) != "number"
then error("spec.replicas is not a number")
else .spec.replicas += 1
end
' "$file" >"$tmp"
# 对完整结果做业务验证。
jq -e '
(.spec.replicas | type) == "number"
and (.spec.replicas | floor) == .spec.replicas
and .spec.replicas >= 0
and .spec.replicas <= 100
' "$tmp" >/dev/null
# 保留旧文件的基本元数据。
chmod --reference="$file" "$tmp"
chown --reference="$file" "$tmp"
# 提交更新。
mv -f -- "$tmp" "$file"
trap - EXIT
printf 'updated: %s\n' "$file"
printf 'backup: %s\n' "$backup"
) 9>"$lock"
执行前提:
- 调用者对配置目录有写权限;
- 调用者有权执行
chown --reference;普通用户在某些情况下可能没有权限; - 目录所在文件系统支持预期的
rename语义; - 所有修改者都使用同一个锁文件。
这个脚本仍有边界:
- 备份文件名是临时随机名,恢复流程需要记录或由外层系统管理;
cp成功不表示备份已抗断电;- 服务进程可能在替换瞬间读取旧配置,也可能在之后读取新配置;
- 配置加载失败时,脚本无法自动保证应用回滚,除非再实现“重新加载—健康检查—恢复”的流程。
一个简单恢复动作是:
cp --preserve=all -- backup.json config.json
生产环境还应在恢复后重新执行语法验证和应用级健康检查。
十一、管道错误、pipefail 和部分输出
Shell 管道默认返回最后一个命令的退出状态:
producer | jq . | consumer
如果 producer 失败,但 consumer 仍然成功,整个管道可能返回成功。使用:
set -o pipefail
后,只要管道中的命令失败,整体通常会失败。
但流式管道还有一个故障路径:下游提前退出时,上游可能收到 SIGPIPE。例如:
jq -c '.items[]' huge.json | head -n 1
head 读到一行后退出,jq 可能因管道关闭而返回非零。这个非零不一定表示输入 JSON 损坏,而可能表示下游主动停止消费。诊断时要结合退出码、标准错误和业务意图判断。
不要将诊断信息与数据混在一起:
if ! result=$(jq -e '.required' input.json); then
printf '%s\n' 'invalid input or missing required field' >&2
exit 1
fi
命令替换适合小结果;大 JSON 不应完整装入 Shell 变量。若需要保存结果,应写入临时文件并检查退出状态。
十二、YAML 的生产风险:重复键、别名和类型歧义
12.1 重复键
YAML 文档可能出现:
replicas: 2
replicas: 5
不同解析器可能:
- 取最后一个值;
- 取第一个值;
- 报错;
- 保留重复键节点。
如果配置来源不可信,不能假设“最后一个覆盖前一个”是普遍规范保证。应在选定解析器的文档和测试中确认行为,或者使用能检测重复键的验证器。
JSON 也不应依赖重复键。虽然语法层面很多解析器接受:
{"a": 1, "a": 2}
但对象键唯一性和冲突处理在工具之间可能不一致。生成配置时应主动拒绝重复键,而不是利用覆盖行为。
12.2 锚点和别名
YAML 可以写:
defaults: &defaults
timeout: 30
retries: 3
service:
<<: *defaults
timeout: 60
这不是普通 JSON 的引用。解析器可能在读取时展开合并,也可能保留节点关系。经过 yq 转换和重新输出后,锚点可能被保留、展开或重排。
YAML 锚点还可能造成别名展开数量远大于原始文本,形成资源消耗问题。处理不可信 YAML 时,应限制输入大小、解析时间和别名展开行为,并避免把“不可信 YAML”直接交给具有额外对象构造能力的语言库。
12.3 不可信 YAML 与对象构造
“YAML 是数据”不表示所有 YAML 库都只进行无害数据解析。某些语言生态的 YAML 库历史上支持自定义标签和对象反序列化,可能触发构造器、文件访问或代码执行风险。
命令行 yq 的具体风险取决于实现及其底层库;不能因为命令名相同就推导出统一安全性质。处理外部输入时:
- 固定
yq实现和版本; - 使用安全加载模式;
- 限制文件大小和权限;
- 不把 YAML 字段直接作为 Shell 命令执行;
- 对输出进行结构和范围验证。
例如,下面代码把配置字段直接作为命令执行,是命令注入:
cmd=$(jq -r '.command' config.json)
bash -c "$cmd"
结构化解析解决了字段定位问题,但不会自动使字段内容可信。
十三、JSON 数字、Shell 数字和精度边界
JSON 语法允许数字,但没有规定所有实现都以任意精度整数保存。jq 的数字处理受实现和版本影响,通常使用双精度浮点语义的部分特征。超过安全整数范围的大整数可能发生精度变化。
例如,JavaScript 的安全整数上限常用:
但这不是 JSON 的通用限制,而是某些运行时数字表示的限制。跨语言传输订单号、雪花 ID、数据库主键等大整数时,通常应将其序列化为字符串:
{"id":"9007199254740993"}
而不是:
{"id":9007199254740993}
Shell 算术本身也有实现和范围边界。不要把结构化数据中的任意大数无条件交给:
$((value + 1))
如果数值精度或范围重要,应在产生数据的一侧定义类型,并在处理链中保持一致。
十四、从文件到发布的状态机
一个可靠的结构化配置更新可以表示为以下状态:
stateDiagram-v2
[*] --> ReadOld
ReadOld --> ParseFailed: 解析失败
ReadOld --> Transform
Transform --> TransformFailed: jq/yq 非零退出
Transform --> Validate
Validate --> Invalid: 语法或业务校验失败
Validate --> Commit
Commit --> CommitFailed: 权限、磁盘或并发错误
Commit --> Reload
Reload --> HealthCheck
HealthCheck --> Rollback: 应用未正常加载
HealthCheck --> Published: 健康检查通过
Rollback --> Published: 恢复旧文件成功
ParseFailed --> [*]
TransformFailed --> [*]
Invalid --> [*]
CommitFailed --> [*]
Published --> [*]
关键路径不是“执行一条 yq -i 命令”,而是:
- 读取旧状态;
- 解析;
- 计算新状态;
- 验证新状态;
- 提交文件;
- 让消费者重新加载;
- 检查消费者是否接受;
- 必要时恢复。
如果第 4 步成功而第 6 步失败,文件可能是合法 YAML,但应用仍无法启动。语法正确性、业务正确性和运行时可用性是三个不同层次。
十五、完整示例:读取 YAML、用 JSON 逻辑筛选并安全写回
假设 YAML 文件为:
services:
- name: api
enabled: true
replicas: 2
- name: worker
enabled: false
replicas: 5
目标:只将启用服务的副本数加 1,并限制结果不超过 10。
使用 yq 直接变换:
yq '
.services |= map(
if .enabled == true
then .replicas = (.replicas + 1)
else .
end
)
' services.yaml
输出:
services:
- name: api
enabled: true
replicas: 3
- name: worker
enabled: false
replicas: 5
加入验证:
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
yq '
.services |= map(
if .enabled == true
then .replicas = (.replicas + 1)
else .
end
)
' services.yaml >"$tmp"
yq -e '
(.services | type) == "!!seq"
and all(.services[];
(.replicas | type) == "!!int"
and .replicas >= 0
and .replicas <= 10
)
' "$tmp" >/dev/null
这里必须注意 yq 的类型表达式和版本实现差异。某些版本会使用 YAML 节点类型名称,例如 !!int;若要减少版本敏感性,也可以先转换为 JSON,再使用 jq 做严格验证:
yq -o=json '.' "$tmp" |
jq -e '
(.services | type) == "array"
and all(.services[];
(.replicas | type) == "number"
and (.replicas | floor) == .replicas
and .replicas >= 0
and .replicas <= 10
)
' >/dev/null
这段流程体现了工具分工:
yq负责 YAML 输入和 YAML 输出;jq负责严格的 JSON 类型与数值逻辑;- 临时文件负责让验证针对完整结果;
- 最终还需要安全提交,而不是直接覆盖原文件。
十六、测试:Fixture、ShellCheck、Bats 和故障注入
结构化处理脚本的错误往往不在主表达式,而在边界条件:
- 字段缺失;
- 字段类型错误;
- 数组为空;
- 文件不可读;
- 目标目录不可写;
- 输入包含引号、换行和 Unicode;
- 两个进程同时更新;
- 磁盘空间不足;
- 下游提前退出。
可以使用 Fixture,即固定测试输入文件:
tests/fixtures/config.json
tests/fixtures/missing-replicas.json
tests/fixtures/wrong-type.json
tests/fixtures/empty-services.json
一个 Bats 测试示例:
@test "increments replicas" {
run ./update-replicas tests/fixtures/config.json
[ "$status" -eq 0 ]
run jq -e '.spec.replicas == 3' tests/fixtures/config.json
[ "$status" -eq 0 ]
}
测试脚本还应覆盖失败路径:
@test "rejects wrong type" {
cp tests/fixtures/wrong-type.json "$BATS_TEST_TMPDIR/config.json"
run ./update-replicas "$BATS_TEST_TMPDIR/config.json"
[ "$status" -ne 0 ]
}
ShellCheck 可检查未引用变量、错误的命令替换、潜在的词分割等问题:
shellcheck update-replicas
故障注入的目标不是制造随机混乱,而是验证状态不变量。例如:
- 让临时目录不可写;
- 让输入文件不可读;
- 让校验命令返回失败;
- 在提交前终止进程;
- 同时启动两个更新进程。
每种故障都应验证:
- 原文件是否仍可解析;
- 原文件是否被部分覆盖;
- 临时文件是否按预期清理;
- 退出码是否非零;
- 是否产生足够的诊断信息;
- 并发更新是否丢失。
十七、常见误解和对应诊断
误解一:jq 输出为空就是成功
过滤器可能合法但产生零个输出:
jq '.items[] | select(.status == "ready")' input.json
没有 ready 项时可能没有输出,但这不一定是错误。若业务要求至少一个结果,应显式检查:
count=$(
jq '[.items[] | select(.status == "ready")] | length' input.json
)
if (( count == 0 )); then
printf '%s\n' 'no ready item' >&2
exit 1
fi
误解二:-r 可以安全地产生 Shell 参数
jq -r 只去掉 JSON 字符串的引号。它不会为 Shell 转义空格、换行、通配符或命令替换语法。输出若要进入 Shell,应使用 NUL 分隔、数组传参或保持结构化格式。
误解三:yq -i 提供事务性
-i 只是原地更新选项。它不等于:
- 先验证后提交;
- 原子替换;
- 自动备份;
- 并发锁;
- 断电持久化;
- 应用健康检查。
生产配置修改需要自行组合这些步骤。
误解四:YAML 转 JSON 后一定能无损转回
转换会重新解析和序列化。注释、锚点、引号风格和排版可能丢失。若要求保留人工格式,必须使用专门支持注释和节点保真的工具,并用测试确认其行为;不能仅凭文件扩展名推断保真程度。
误解五:先用 grep 找字段,再用 jq 修改
这种流程把结构化定位和文本定位混合,容易产生不一致。若要确认字段是否存在,应让 jq 或 yq 完成判断:
jq -e 'has("service") and (.service | has("name"))' config.json
grep 适合快速人工排查日志,不应承担 JSON/YAML 语义验证。
十八、如何选择处理方式
可以按数据边界选择工具:
| 任务 | 适合方式 |
|---|---|
| 检查 JSON 语法 | jq empty |
| 提取 JSON 字段 | jq -r |
| 修改 JSON 路径 | jq 更新表达式 |
| 逐条处理 JSON Lines | 不使用 -s,按记录处理 |
| 扫描巨大 JSON 的叶子值 | jq --stream |
| 读取或修改 YAML | 与实现匹配的 yq |
| YAML 转 JSON 后做严格验证 | yq -o=json 配合 jq -e |
| 修改注释或固定排版 | 文本工具或支持保真的专用编辑器 |
| 配置文件安全提交 | 临时文件、验证、同目录 mv |
| 多进程读改写 | flock 或版本冲突检测 |
| 不可信输入 | 限制大小、固定版本、禁用危险加载能力、严格校验 |
一个实用的判断问题是:你要保持的是原始字节,还是数据语义?
- 保持原始字节:结构化工具往往会重排内容,不一定适合;
- 保持数据语义:结构化解析和重新序列化通常更可靠;
- 两者都要:需要支持格式保真的 AST 工具,并接受更复杂的测试和版本管理。
结构化数据处理的关键不是记住某一条 jq 或 yq 命令,而是明确数据模型、输入边界、输出协议和提交状态。解析负责识别结构,过滤器负责计算结果,验证负责约束语义,临时文件和锁负责控制写入风险,健康检查则负责确认消费者真正接受了新状态。只有这些环节的因果关系完整,命令行自动化才不会停留在“看起来能工作”的文本拼接。
系列导航与关联阅读
- 系列入口:Linux 完整学习路线:从内核与文件系统到网络、性能和生产运维
- 上一篇:Shell 脚本测试与质量:ShellCheck、Bats、Fixture 和故障注入
- 下一篇:Linux 终端与作业控制:TTY、Session、前后台、nohup 和挂断
- 延伸:Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则
官方资料
本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论
0 条讨论