rg、jq、lnav 与 tail 本地日志排障工具手册
应用刚报错时,最快的证据往往还在当前机器。此时直接部署日志平台会引入采集、网络、索引和权限等新变量;先确认原始文件里究竟写了什么,通常更接近故障本身。
本地排障不是“执行一次 grep”。文件可能正在写入,日志可能刚刚轮转,JSON 可能只有部分行损坏,两个服务可能使用不同的时区,异常栈还可能跨越十几行。tail、ripgrep、jq 和 lnav 分别处理实时跟踪、文本定位、结构化投影和多文件时间线,四者组合后才是一条完整证据链。
先建立一组可破坏的日志
不要拿生产日志练习,也不要复用当前目录下可能已经存在的 log-lab。先让系统在临时目录中原子创建一个唯一实验根目录,再写入随机所有权值;后续清理只有同时匹配路径和标记才会继续:
LAB_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/log-lab.XXXXXXXX")
LAB_OWNER=$(openssl rand -hex 16)
printf '%s\n' "$LAB_OWNER" > "$LAB_ROOT/.log-lab-owner"
LOG_DIR="$LAB_ROOT/logs"
mkdir "$LOG_DIR"
printf 'LAB_ROOT=%s\n' "$LAB_ROOT"
cat > "$LOG_DIR/api.jsonl" <<'EOF'
{"time":"<EVENT_TIMESTAMP>","level":"INFO","service":"api","environment":"lab","version":"1.4.0","trace_id":"trace-demo-001","path":"/orders/42","duration_ms":18,"message":"request accepted"}
{"time":"<EVENT_TIMESTAMP>","level":"ERROR","service":"api","environment":"lab","version":"1.4.0","trace_id":"trace-demo-001","path":"/orders/42","duration_ms":820,"message":"inventory timeout"}
{"time":"<EVENT_TIMESTAMP>","level":"INFO","service":"api","environment":"lab","version":"1.4.0","trace_id":"trace-demo-002","path":"/health","duration_ms":3,"message":"ok"}
EOF
cat > "$LOG_DIR/inventory.jsonl" <<'EOF'
{"time":"<EVENT_TIMESTAMP>","level":"INFO","service":"inventory","environment":"lab","version":"2.1.3","trace_id":"trace-demo-001","message":"stock lookup started"}
{"time":"<EVENT_TIMESTAMP>","level":"ERROR","service":"inventory","environment":"lab","version":"2.1.3","trace_id":"trace-demo-001","message":"database pool exhausted"}
EOFPowerShell 使用 GUID 目录和同样的所有权标记,可以生成完全相同的文件。New-Item 不带 -Force,如果极小概率发生名称冲突会立即失败,不会接管原目录:
$LabOwner = [guid]::NewGuid().ToString('N')
$LabRoot = Join-Path ([IO.Path]::GetTempPath()) "log-lab-$LabOwner"
$LogDir = Join-Path $LabRoot 'logs'
New-Item -ItemType Directory -Path $LabRoot -ErrorAction Stop | Out-Null
Set-Content -LiteralPath (Join-Path $LabRoot '.log-lab-owner') -Value $LabOwner -Encoding ascii
New-Item -ItemType Directory -Path $LogDir -ErrorAction Stop | Out-Null
Write-Host "LabRoot=$LabRoot"
@'
{"time":"<EVENT_TIMESTAMP>","level":"INFO","service":"api","environment":"lab","version":"1.4.0","trace_id":"trace-demo-001","path":"/orders/42","duration_ms":18,"message":"request accepted"}
{"time":"<EVENT_TIMESTAMP>","level":"ERROR","service":"api","environment":"lab","version":"1.4.0","trace_id":"trace-demo-001","path":"/orders/42","duration_ms":820,"message":"inventory timeout"}
{"time":"<EVENT_TIMESTAMP>","level":"INFO","service":"api","environment":"lab","version":"1.4.0","trace_id":"trace-demo-002","path":"/health","duration_ms":3,"message":"ok"}
'@ | Set-Content -Encoding utf8 (Join-Path $LogDir 'api.jsonl')
@'
{"time":"<EVENT_TIMESTAMP>","level":"INFO","service":"inventory","environment":"lab","version":"2.1.3","trace_id":"trace-demo-001","message":"stock lookup started"}
{"time":"<EVENT_TIMESTAMP>","level":"ERROR","service":"inventory","environment":"lab","version":"2.1.3","trace_id":"trace-demo-001","message":"database pool exhausted"}
'@ | Set-Content -Encoding utf8 (Join-Path $LogDir 'inventory.jsonl')每行都是一个完整 JSON 值。时间带偏移量,服务、环境、版本和 trace id 可关联,业务标识使用虚构值。异常栈若需要换行,应作为 JSON 字符串中的 \n 保存;让一条事件占多行,会使逐行工具和后续采集器都难以确定事件边界。
安装并冻结工具入口
macOS 自带的是 BSD tail,下面的轮转实验使用 GNU 参数,因此一并安装 coreutils,并明确调用其 gtail 命令:
brew install ripgrep jq lnav coreutils
TAIL=gtail
gtail --version
rg --version
jq --version
lnav --versionDebian/Ubuntu 的发行版仓库提供这些包,仓库版本可能落后于上游,但更容易进入系统补丁流程:
sudo apt-get update
sudo apt-get install ripgrep jq lnav
TAIL=tail
tail --version
rg --version
jq --version
lnav --versionWindows 原生终端可安装 ripgrep 与 jq:
winget install --id BurntSushi.ripgrep.MSVC --exact
winget install --id jqlang.jq --exact
rg --version
jq --versiontail 与 lnav 的主要工作流建立在 POSIX 文件、inode 和终端能力上。Windows 团队通常在 WSL 中安装 coreutils 与 lnav,或在 PowerShell 里用 Get-Content -Wait 完成最小跟踪;两者的轮转语义不能想当然地视为相同。lnav 的预编译包和平台支持应从官方发布页确认,离线安装时同时转运校验值和来源记录。
团队脚本应记录经过验证的最低版本,而不是把某台开发机上的版本号写成永久标准。升级门禁应重新执行 JSON 解析、轮转和时间线实验。
用 ripgrep 找到第一条证据
先按固定字符串查 trace id:
rg -n -F -C 1 'trace-demo-001' "$LOG_DIR"-F 禁用正则解释,适合 trace id、订单号和错误码;-n 输出行号;-C 1 保留前后一行。输出应同时命中 api.jsonl 与 inventory.jsonl,这证明两个服务记录了同一条调用链。
需要组合错误类型时再使用正则:
rg -n '"level":"(ERROR|WARN)"|timeout|exhausted' "$LOG_DIR"ripgrep 默认尊重 .gitignore、.ignore 和 .rgignore,跳过隐藏文件、二进制文件和符号链接目标。日志目录经常被 .gitignore 排除,因此“没有输出”首先可能是搜索策略问题:
rg --files "$LOG_DIR"
rg --debug -F 'trace-demo-001' "$LOG_DIR"
rg --no-ignore -F 'trace-demo-001' "$LOG_DIR"不要一上来使用 -uuu。它会逐步关闭 ignore、隐藏文件和二进制保护,可能扫描缓存、转储文件和挂载盘。先用 --files 看实际搜索集合,再只放开确实需要的一层过滤。
压缩日志不是普通目录
轮转后的 .gz 可以用 -z 搜索:
gzip -c "$LOG_DIR/api.jsonl" > "$LOG_DIR/api.jsonl.1.gz"
rg -z -F 'trace-demo-001' "$LOG_DIR/api.jsonl.1.gz"ripgrep 会调用系统中的 gzip、xz、zstd 等外部程序完成解压;缺少对应程序时应先看到解压错误,而不是得到“日志不存在”的结论。-z 支持压缩文件,不代表能递归搜索 tar.gz 归档。归档要先列目录并解到受控临时目录,避免路径穿越和磁盘失控。
多行异常需要明确开启
普通文本异常栈若确实跨行,可以使用 -U:
cat > "$LOG_DIR/app.log" <<'EOF'
<EVENT_TIMESTAMP> ERROR java.lang.IllegalStateException: order rejected
at example.OrderService.submit(OrderService.java:42)
Caused by: java.net.SocketTimeoutException: inventory timeout
EOF
rg -U -n 'IllegalStateException(?s:.*?)Caused by:' "$LOG_DIR/app.log"多行模式扩大匹配窗口;在大文件上应同时限制文件和模式,不要从磁盘根目录执行。更稳妥的长期方案是让应用输出单事件 JSONL,把 stack 保存为一个转义字段。
用 jq 验证结构而不是美化颜色
先让 jq 逐行解析全部 JSON 值:
jq -e . "$LOG_DIR/api.jsonl" > /dev/null-e 会根据最后一个输出值设置退出码。解析错误时,错误信息包含行号附近的位置;这比查询时静默丢行更早暴露格式问题。
随后只投影诊断需要的字段:
jq -c '
select(.trace_id == "trace-demo-001")
| {time, level, service, version, trace_id, duration_ms, message}
' "$LOG_DIR"/*.jsonl预期得到四条紧凑 JSON,且不包含请求体、Cookie 或连接串。-c 保持一事件一行,便于继续管道处理。数值条件要按数值比较:
jq -c 'select((.duration_ms // 0) >= 500)' "$LOG_DIR/api.jsonl"// 0 给缺失字段提供默认值,但不能掩盖数据契约问题。生产脚本应先统计缺失字段,再决定是否容忍:
jq -s '
{
total: length,
missing_trace_id: map(select(.trace_id == null)) | length,
invalid_level: map(select(.level | IN("INFO", "WARN", "ERROR") | not)) | length
}
' "$LOG_DIR/api.jsonl"这个小文件可以安全 -s/--slurp。它把所有 JSON 值读入一个数组,因此不适合无边界的大日志。JSONL 的普通过滤本来就是逐值流式的;--stream 主要用于逐路径处理单个超大嵌套 JSON,不能作为所有大文件问题的万能开关。
用坏行证明失败不会被忽略
追加一个截断事件:
printf '%s\n' '{"time":"<EVENT_TIMESTAMP>","level":"ERROR"' >> "$LOG_DIR/api.jsonl"
jq -e . "$LOG_DIR/api.jsonl" > /dev/null
echo $?jq 应报告 parse error 并返回非零。此时不能用 try ... catch empty 直接吞掉坏行,因为这会让故障证据消失。先保全原文件,再定位生产端为什么写入半行:进程崩溃、多个进程共享文件、非原子轮转或日志框架配置错误。修复实验文件时只删除最后一行:
sed -i.bak '$d' "$LOG_DIR/api.jsonl"
jq -e . "$LOG_DIR/api.jsonl" > /dev/nullmacOS 与不同 sed 实现的原地编辑参数不同。共享脚本应写临时文件、验证后再原子替换,避免一次“修日志”破坏原证据。
跟踪写入并亲手触发轮转
GNU tail -f 默认跟随已经打开的文件描述符。文件改名后,写入旧描述符的进程仍可能继续输出到旧文件;新文件同名出现时,当前 tail 不一定自动切换。tail -F 等价于按名称跟随并持续重试,更适合常见轮转:
: > "$LOG_DIR/live.log"
"$TAIL" --sleep-interval=0.2 --max-unchanged-stats=1 -n 0 -F "$LOG_DIR/live.log"确认第一个终端已经开始等待后,再在另一个终端写入第一行:
printf '%s\n' 'before-rotate trace-demo-003' >> "$LOG_DIR/live.log"必须先在第一个终端看到 before-rotate,再继续轮转。这个观察点比固定等待一秒更可靠:
mv "$LOG_DIR/live.log" "$LOG_DIR/live.log.1"
sleep 1
printf '%s\n' 'after-rotate trace-demo-003' > "$LOG_DIR/live.log"屏幕应依次出现 before-rotate 和 after-rotate。如果只出现第一行,先确认使用的是 GNU tail 还是 BSD/BusyBox 实现,再检查轮转策略是 rename-create 还是 copy-truncate。
tail -F 仍然不是采集器。它没有持久 position 文件、磁盘队列、背压、发送确认和重放协议;终端断开后也不会形成可靠交付。容器把日志写入临时文件时,容器删除会让证据一起消失。
PowerShell 的最小跟踪命令是:
New-Item -ItemType File -Path (Join-Path $LogDir 'live.log') -ErrorAction Stop | Out-Null
Get-Content -LiteralPath (Join-Path $LogDir 'live.log') -Tail 0 -Wait它适合开发机观察新行,但要单独验证 rename-create 轮转后的行为。不要因为命令名称相近,就把它的文件句柄语义写成 GNU tail -F。
用 lnav 合并时间线
两个样本一个使用 +08:00,一个使用 Z,但它们表示同一分钟。任意 JSONL 不会因为扩展名正确就自动成为日志格式;lnav 只会把匹配已知格式的文件放进日志视图,否则文件进入普通文本视图。先为实验数据声明时间、级别、正文和操作 ID:
FORMAT_DIR="$LAB_ROOT/formats"
mkdir "$FORMAT_DIR"
cat > "$FORMAT_DIR/lab-json.json" <<'EOF'
{
"$schema": "https://lnav.org/schemas/format-v1.schema.json",
"lab_json": {
"title": "Observability lab JSONL",
"description": "JSONL used by the local log troubleshooting lab",
"file-pattern": ".*\\.jsonl$",
"json": true,
"timestamp-field": "time",
"timestamp-format": [
"%Y-%m-%dT%H:%M:%S.%L%z",
"%Y-%m-%dT%H:%M:%S%z"
],
"level-field": "level",
"body-field": "message",
"opid-field": "trace_id",
"ordered-by-time": true,
"line-format": [
{"field": "__timestamp__"}, " ",
{"field": "__level__", "min-width": 5}, " ",
{"field": "service"}, " ",
{"field": "trace_id"}, " ",
{"field": "message"}
],
"value": {
"service": {"kind": "string", "identifier": true},
"version": {"kind": "string", "identifier": true},
"trace_id": {"kind": "string", "identifier": true},
"duration_ms": {"kind": "integer"},
"message": {"kind": "string"}
},
"sample": [
{
"line": "{\"time\":\"<EVENT_TIMESTAMP>\",\"level\":\"INFO\",\"service\":\"api\",\"trace_id\":\"trace-demo-001\",\"message\":\"request accepted\"}"
}
]
}
}
EOF
lnav -m -I "$FORMAT_DIR" format lab_json test "$LOG_DIR/api.jsonl"格式测试通过后,再把同一临时目录作为配置入口加载两份日志:
lnav -I "$FORMAT_DIR" "$LOG_DIR/api.jsonl" "$LOG_DIR/inventory.jsonl"进入界面后,按 / 搜索 trace-demo-001,用 n 跳到下一处。若格式被识别,API 与 inventory 的事件应按绝对时间交错,而不是按文件顺序堆叠。
lnav 还把识别后的日志暴露给 SQLite 查询。格式名 lab_json 同时是表名;按 ; 进入 SQL 提示符,可以先查看字段,再查询同一 trace:
.schema lab_json
SELECT log_time, service, trace_id, message
FROM lab_json
WHERE trace_id = 'trace-demo-001'
ORDER BY log_time;查询应返回 API 与 inventory 的四条事件,并按绝对时间排列。团队真实格式的表名和字段由格式定义决定;先查看 schema,再写查询。格式无法识别时,第一证据是管理命令的测试结果、状态栏和 debug 日志,而不是“lnav 排序有 bug”。
时间错序的反向实验
再写一条没有时区的日志:
printf '%s\n' '{"time":"<EVENT_TIME>","level":"ERROR","service":"legacy","trace_id":"trace-demo-001","message":"timezone missing"}' > "$LOG_DIR/legacy.jsonl"
lnav -I "$FORMAT_DIR" "$LOG_DIR"/*.jsonllab_json 的时间格式要求时区,因此格式测试或加载信息应暴露这条记录不匹配,而不是把它静默放进可信时间线。这条记录不能证明自己属于哪个时区。即使为文件临时指定本机时区,跨地区团队仍无法复核同一顺序。修复点在日志契约:统一 RFC 3339/ISO 8601 并携带 Z 或明确偏移量,不是在查询时人工加八小时。
自定义格式可以让 lnav 识别遗留文本,但格式文件必须进入版本控制并有样本测试。错误的正则或时间字段会把所有后续判断建立在假时间线上。
把一次排障整理成证据包
共享给团队的不是整个日志目录,而是一份可复核、最小化的证据包:
<唯一实验根目录>/incident-evidence-<所有权值>/
README.md
query.jq
command.txt
result.jsonl
checksums.txtREADME.md 记录环境、服务版本、工具版本、原始时间表达、实际筛选条件、结果覆盖的真实时间窗口、trace id、原文件保管人和删除日期;query.jq 与 command.txt 保存真正执行的过滤逻辑;result.jsonl 只保留经过明确规则处理的必要事件;checksums.txt 用于证明文件在传递中没有被修改。
EVIDENCE_DIR="$LAB_ROOT/incident-evidence-$LAB_OWNER"
mkdir "$EVIDENCE_DIR"
printf '%s\n' "$LAB_OWNER" > "$EVIDENCE_DIR/.evidence-owner"
cat > "$EVIDENCE_DIR/query.jq" <<'EOF'
select(.trace_id == "trace-demo-001")
| walk(
if type == "object" then
with_entries(
select((.key | ascii_downcase) as $k
| ["authorization", "cookie", "set-cookie", "password", "token", "access_token", "refresh_token", "request_body"]
| index($k) | not)
)
else . end
)
| if .path? then .path |= sub("\\?.*$"; "?<redacted>") else . end
EOF
printf '%s\n' 'jq -c -f query.jq api.jsonl inventory.jsonl' > "$EVIDENCE_DIR/command.txt"
jq -c -f "$EVIDENCE_DIR/query.jq" \
"$LOG_DIR/api.jsonl" "$LOG_DIR/inventory.jsonl" \
> "$EVIDENCE_DIR/result.jsonl"
cat > "$EVIDENCE_DIR/README.md" <<EOF
environment: lab
services: api 1.4.0, inventory 2.1.3
tool-versions:
ripgrep: $(rg --version | head -n 1)
jq: $(jq --version)
lnav: $(lnav --version | head -n 1)
export-created-at: $(date -u +%Y-%m-%dT%H:%M:%SZ)
timestamp-handling: source RFC 3339 strings preserved; no UTC conversion performed
filter: trace_id equals trace-demo-001; no timestamp predicate applied
result-absolute-window: <EVENT_TIMESTAMP> through <EVENT_TIMESTAMP>; metadata calculation only, result values unchanged
trace-id: trace-demo-001
source-custodian: local operator
delete-after: replace with an approved date
EOF
(cd "$EVIDENCE_DIR" && sha256sum README.md query.jq command.txt result.jsonl > checksums.txt)这里实现的边界是:递归删除一组不区分大小写的敏感键,并把 path 的 query 部分整体替换;它没有能力可靠识别自由文本 message、SQL 参数、堆栈、换名字段或编码后的秘密。因此样本只使用合成消息,真实导出还必须按数据分类逐字段允许、人工复核结果,并在上游禁止记录秘密。不能把这段 jq 描述成通用脱敏器。
Windows 可以使用 Get-FileHash -Algorithm SHA256。哈希只能证明内容一致,不能证明内容真实、完整或合规;原始文件的访问控制和保留策略仍然需要单独管理。
诊断“搜不到”而不是扩大扫描
当预期 trace id 没有命中时,按证据层逐级判断:
应用是否真的处理了请求,日志缓冲是否已经刷新。当前路径是否是进程实际写入路径,容器和宿主机路径是否混淆。文件是否已经轮转、压缩、移动或删除。
当前账号是否有目录遍历和文件读取权限。ripgrep 是否因 ignore、隐藏、二进制或编码规则跳过文件。trace id 是否在网关、异步队列或线程切换处丢失。
查询窗口与日志时区是否一致。
每一步都应拿到第一证据再继续。用管理员权限和 -uuu 扫描整块磁盘会同时扩大性能影响与数据暴露,却不一定让诊断更接近根因。
大文件和故障机器的资源边界
本地搜索消耗页缓存、磁盘吞吐、CPU 和终端输出带宽。在磁盘已满、I/O 等待升高或节点正在抖动时,无边界递归扫描可能放大故障。
先用文件时间和大小缩小集合,再用固定字符串筛选:
find "$LOG_DIR" -type f -name '*.log*' -mmin -30 -size -2G -print
rg -F --max-filesize 2G 'trace-demo-001' "$LOG_DIR/api.jsonl" "$LOG_DIR/inventory.jsonl"对历史压缩日志,优先复制到受控分析机并核对哈希后再搜索。rg --text 会关闭二进制检测,大文件中可能输出控制字符并占用更多内存;只有确认文件本质是文本但误含 NUL 时才使用。
判断资源影响时看趋势而不是万能阈值:搜索开始后 I/O wait 是否持续上升、业务延迟是否恶化、剩余磁盘是否下降、解压临时空间是否受控。出现放大迹象就停止本地扫描,转移副本或进入集中平台。
从本地文件升级到集中日志
以下信号说明文件工具已经无法稳定完成任务:
一次请求需要登录多台机器或进入多个容器才能拼出证据。轮转速度快于响应速度,值班人员到达时日志已经删除。多人需要共享查询、审计访问和保存故障时间线。
需要按服务、环境、版本和 trace id 做跨文件历史检索。故障节点不可登录,或本地扫描会明显干扰业务。需要证明采集丢失率、保留执行和敏感数据删除。
进入集中平台前先修正字段、时区、事件边界和脱敏。平台不会自动把坏日志变成好证据,只会更快、更久、更昂贵地保存原有问题。下一步可以根据查询模型、规模和团队能力选择 Elastic、Loki 或云日志平台。
团队基线与退出动作
应用负责人持有日志字段和级别语义,平台负责人持有目录权限、轮转和磁盘上限,排障脚本负责人持有跨平台测试,安全负责人持有字段分级、导出和删除规则。小团队可以一人兼任多个角色,但每项动作必须有明确 owner。
共享脚本只接收日志根目录、时间窗口和查询值,不写死生产绝对路径、账号或凭证。临时提高日志级别必须同时记录关闭时间;临时日志、分析副本和导出证据必须在事件结束后按约定删除。
实验结束后只删除系统创建且仍由当前会话持有的实验根目录。下面先解析绝对路径,要求它的父目录就是系统临时目录、目录名符合 log-lab.*,再核对根标记和证据标记;任何一项不符都停止:
TMP_ROOT=$(cd "${TMPDIR:-/tmp}" && pwd -P)
LAB_REAL=$(cd "$LAB_ROOT" && pwd -P)
test "$(dirname "$LAB_REAL")" = "$TMP_ROOT"
case "$(basename "$LAB_REAL")" in log-lab.*) ;; *) echo "refuse: unexpected lab path" >&2; exit 1;; esac
test "$(cat "$LAB_REAL/.log-lab-owner")" = "$LAB_OWNER"
test "$(cat "$EVIDENCE_DIR/.evidence-owner")" = "$LAB_OWNER"
rm -rf -- "$LAB_REAL"PowerShell 同样核对解析后的临时目录父路径、名称和标记,不根据当前工作目录删除同名文件夹:
$ResolvedLab = (Resolve-Path -LiteralPath $LabRoot -ErrorAction Stop).Path
$TempParent = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\')
$OwnerOnDisk = (Get-Content -Raw -LiteralPath (Join-Path $ResolvedLab '.log-lab-owner')).Trim()
if ((Split-Path $ResolvedLab -Parent).TrimEnd('\') -ne $TempParent -or
(Split-Path $ResolvedLab -Leaf) -notlike 'log-lab-*' -or
$OwnerOnDisk -ne $LabOwner) {
throw 'Refusing to remove an unowned or unexpected path'
}
Remove-Item -LiteralPath $ResolvedLab -Recurse -Force删除前确认当前目录和目标路径,不能把变量拼接出的未知路径直接递归删除。真正的完成标准不是终端里找到一条红色日志,而是另一名工程师能用相同输入复核时间线、排除错误解释,并且证据在任务结束后得到受控清理。
