awk 结合 NR 从 journalctl 导出日志中截取指定时间窗口

把线上故障时间点定位到分钟级之后,多数人第一反应是翻 journalctl -u,但真正到复盘、提交工单、给外部同事看的时候,往往需要把「某个服务在 14:05 到 14:20 之间的全部日志」导出成一个文件。问题在于:日志导出后可能存成 app.log、app.log.1,也可能是别人从别的机器 journalctl --since ... --until

把线上故障时间点定位到分钟级之后,多数人第一反应是翻 journalctl -u,但真正到复盘、提交工单、给外部同事看的时候,往往需要把「某个服务在 14:05 到 14:20 之间的全部日志」导出成一个文件。问题在于:日志导出后可能存成 app.log、app.log.1,也可能是别人从别的机器 journalctl --since ... --until ... > dump.log 拷过来的纯文本,行首有的是传统 syslog 时间格式,有的是 --output=short-iso 的 ISO8601 格式,还夹着 systemd 自动插入的 -- Boot 3a... -- 分隔行、[ 1234.567] 内核时间戳行。这时如果还靠 grep '14:1[0-9]' 这种写法,跨小时、跨天、跨月就立刻错乱。

本文给出一套只依赖 POSIX awk、用 NR 与正则配合的办法:先扫描一遍确定时间窗口对应的行号区间,再用 NR>=start && NR<=end 精确切片。这种做法比逐行做时间字符串比较更稳,也比「假装 awk 能解析时间」的写法更容易排错。下面按「先看日志长什么样、再定位行号、最后切片与校验」的顺序展开。

先确认导出日志的真实格式,别一上来就写正则

journalctl 的默认输出(--output=short)没有年份,且月份是英文缩写:

$ journalctl -u nginx --since "2024-05-20 14:00" --until "2024-05-20 14:30" > /tmp/nginx_dump.log
$ head -3 /tmp/nginx_dump.log
May 20 14:00:01 Web01 nginx[1832]: 2024/05/20 14:00:01 [warn] 1832#1832: conflicting server name "api.example.com" on 0.0.0.0:80, ignored
May 20 14:00:01 Web01 nginx[1832]: 2024/05/20 14:00:01 [notice] 1832#1832: signal process started
May 20 14:00:07 Web01 systemd[1]: Started A high performance web server and a reverse proxy server.

注意字段布局:$1 是月份,$2 是日,$3 是 HH:MM:SS。加上主机名后 $4 才是 Web01,$5 是 nginx[1832]:。如果你用 --output=short-iso 导出,格式会变成:

$ journalctl -u nginx --output=short-iso | tail -2
2024-05-20T14:29:58+0800 Web01 nginx[1832]: 2024/05/20 14:29:58 [notice] 1832#1832: signal 15 (SIGTERM) received
2024-05-20T14:29:59+0800 Web01 systemd[1]: Stopping A high performance web server and a reverse proxy server...

两者对 awk 的要求完全不同:short 格式要把「月份英文 → 数字」做映射,ISO 格式可以直接按字典序字符串比较。更麻烦的是日志里可能混入 journalctl --output=verbose 的 __REALTIME_TIMESTAMP= 行或者内核时间行:

$ grep -n 'Boot' /tmp/nginx_dump.log | head -2
1:-- Boot 9f1c2a7b4d3e4f5a8b9c0d1e2f3a4b5c --
2:May 20 13:59:47 Web01 systemd-journald[512]: Journal started

这些「非标准行」是后面 NR 区间判断能否正确的关键:如果逐行做时间比较,这类行的时间字段是空、会被误判;而用行号切片时,它们天然包含在区间内,正好保留日志连续性。所以第一步永远不是写过滤条件,而是 head -20 + file + wc -l 把数据看清楚:

$ file /tmp/nginx_dump.log
/tmp/nginx_dump.log: UTF-8 Unicode text
$ wc -l /tmp/nginx_dump.log
   48213 /tmp/nginx_dump.log

用 NR 把「时间窗口」翻译成「行号窗口」

思路很直接:时间在日志里是单调递增的(同一主机、单文件导出基本成立),所以「第一个满足 t >= since 的行号」和「最后一个满足 t <= until 的行号」就确定了窗口。awk 里用 NR 记录当前行号,用两条变量分别存起止行号:

#!/bin/sh
# win_scan.awk —— 扫描日志,输出窗口的起止行号(short 格式)
# 用法: awk -v since="14:05:00" -v until="14:20:00" -f win_scan.awk app.log

function mon2num(m) {
    return (m=="Jan")?1:(m=="Feb")?2:(m=="Mar")?3:(m=="Apr")?4:(m=="May")?5:(m=="Jun")?6:\
           (m=="Jul")?7:(m=="Aug")?8:(m=="Sep")?9:(m=="Oct")?10:(m=="Nov")?11:(m=="Dec")?12:0
}

BEGIN { start = 0; end = 0 }

{
    # 只处理形如 "May 20 14:00:01 ..." 的行
    if ($0 ~ /^[A-Z][a-z]{2} [ 0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9] /) {
        mon = mon2num($1)
        day = $2 + 0
        # 把日期+时间拼成可比较的 "MMDDHHMMSS" 数字串
        split($3, t, ":")
        key = sprintf("%02d%02d%02d%02d%02d", mon, day, t[1], t[2], t[3])
        if (start == 0 && key >= skeys) { start = NR }
        if (key <= ekeys)            { end   = NR }
    }
}

BEGIN {
    split(since, s, ":"); skeys = sprintf("%02d%02d%02d%02d%02d", smon, sday, s[1], s[2], s[3])
    split(until, u, ":"); ekeys = sprintf("%02d%02d%02d%02d%02d", umon, uday, u[1], u[2], u[3])
}

END { printf "START=%d END=%d\n", start, end }

上面这段演示了核心机制,但把年月日塞进 BEGIN 会让调用变得啰嗦。更实用的做法是:调用方先用 date 算出目标窗口的「月 日 时:分:秒」四段,作为 -v 变量传进去,脚本只负责比较。下面是可复制即用的完整版本,支持跨零点(since 与 until 不在同一天),并且把解析失败的非标准行标记出来但不影响定位:

#!/bin/sh
# find_window.sh —— 根据时间窗口输出日志的行号范围
# 用法: ./find_window.sh app.log "2024-05-20 14:05:00" "2024-05-20 14:20:00"

set -eu
LOG="$1"
SINCE="$2"
UNTIL="$3"

# 拆成 awk 需要的字段:月 日 时 分 秒
S_MON=$(date -d "$SINCE" +%m); S_DAY=$(date -d "$SINCE" +%d)
S_H=$(date -d "$SINCE" +%H);   S_M=$(date -d "$SINCE" +%M); S_S=$(date -d "$SINCE" +%S)
U_MON=$(date -d "$UNTIL" +%m); U_DAY=$(date -d "$UNTIL" +%d)
U_H=$(date -d "$UNTIL" +%H);   U_M=$(date -d "$UNTIL" +%M); U_S=$(date -d "$UNTIL" +%S)

awk -v smon="$S_MON" -v sday="$S_DAY" -v sh="$S_H" -v sm="$S_M" -v ss="$S_S" \
    -v umon="$U_MON" -v uday="$U_DAY" -v uh="$U_H" -v um="$U_M" -v us="$U_S" '
function mon2num(m) {
    return (m=="Jan")?1:(m=="Feb")?2:(m=="Mar")?3:(m=="Apr")?4:(m=="May")?5:(m=="Jun")?6:\
           (m=="Jul")?7:(m=="Aug")?8:(m=="Sep")?9:(m=="Oct")?10:(m=="Nov")?11:(m=="Dec")?12:0
}
BEGIN {
    skeys = sprintf("%02d%02d%02d%02d%02d", smon+0, sday+0, sh+0, sm+0, ss+0)
    ekeys = sprintf("%02d%02d%02d%02d%02d", umon+0, uday+0, uh+0, um+0, us+0)
    start = 0; end = 0; bad = 0
}
{
    if ($0 ~ /^[A-Z][a-z][a-z] [ 0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9] /) {
        mon = mon2num($1); day = $2 + 0
        split($3, t, ":")
        key = sprintf("%02d%02d%02d%02d%02d", mon, day, t[1]+0, t[2]+0, t[3]+0)
        if (start == 0 && key >= skeys) start = NR
        if (key <= ekeys) end = NR
    } else {
        bad++
    }
}
END {
    if (start == 0) { print "ERROR: no line >= since in file" > "/dev/stderr"; exit 3 }
    if (end   == 0) { print "ERROR: no line <= until in file" > "/dev/stderr"; exit 4 }
    if (end < start) { print "ERROR: end < start, file not time-ordered?" > "/dev/stderr"; exit 5 }
    printf "START=%d END=%d SKIPPED=%d TOTAL=%d\n", start, end, bad, NR
}' "$LOG"

跑一遍:

$ ./find_window.sh /tmp/nginx_dump.log "2024-05-20 14:05:00" "2024-05-20 14:20:00"
START=1042 END=3791 SKIPPED=3 TOTAL=48213

三个数字各有含义:START/END 就是最终切片区间;SKIPPED=3 是 Boot 分隔行那种不带时间戳的行数,符合预期;TOTAL 是文件总行数,用来确认脚本没读到被截断的日志。如果 SKIPPED 突然等于 TOTAL,说明格式判断写错了——最常见的原因是日志用 --output=short-iso 导出,行首是 2024-05-20T14:...,不匹配 ^[A-Z][a-z][a-z] 。

按行号切片,并处理跨零点与多文件拼接

拿到行号后,切割本身就是一次 NR 判断,用 awk 而不是 sed -n 的好处是可以顺便做编码、行数统计、以及保留行首空格:

#!/bin/sh
# cut_window.sh —— 按已求得的行号区间切片
set -eu
LOG="$1"; START="$2"; END="$3"
awk -v s="$START" -v e="$END" 'NR>=s && NR<=e' "$LOG"

验证一下边界不偏移:

$ awk -v s=1042 -v e=3791 'NR>=s && NR<=e' /tmp/nginx_dump.log > /tmp/win.log
$ wc -l /tmp/win.log
   2750 /tmp/win.log
$ echo $((3791-1042+1))
2750
$ head -1 /tmp/win.log && tail -1 /tmp/win.log
May 20 14:05:00 Web01 nginx[1832]: 2024/05/20 14:05:00 [notice] 1832#1832: using the "epoll" event method
May 20 14:20:00 Web01 systemd[1]: Started A high performance web server and a reverse proxy server.

行数完全吻合,两条边界也都在窗口内。这里有个容易踩的细节:s、e 从 shell 传进 awk 时是字符串,NR>=s 会触发 awk 的字符串/数字比较规则。POSIX 规定:若比较双方一个是数值、一个是字符串,则字符串先转数值。实践中 NR 是 number,s 是 strnum,awk 会按数值比较,没问题;但如果你写成 NR>="s" 传入非数字的脏值,行为就不确定了。稳妥做法是用 -v s="$START"+0 或在 awk 内 s+0,gawk、mawk、busybox awk 都支持。

跨零点的场景必须单独验证,因为「月日时分秒」拼成的数字串在跨天时仍单调(5 月 20 日 23:59 的 key 小于 5 月 21 日 00:01 的 key),所以脚本不用改;但如果窗口跨月(比如 5 月 31 日 23:00 到 6 月 1 日 01:00),key 会从 0531230000 变成 0601010000,依然单调,仍然可用。真正会出问题的是「跨年」:1231235900 > 0101000000,此时 end < start 触发退出码 5,提示你改用 ISO 格式。这也是下一节推荐 --output=short-iso 的原因。

另一个现实问题是文件可能被拆分归档:app.log、app.log.1。此时不要把多个文件一次性喂给 awk(NR 会跨文件累加,行号失去意义),而是用 FNR 分别处理,或者干脆逐个文件跑一次 find_window.sh:

for f in /var/log/archive/app.log /var/log/archive/app.log.1; do
    echo "== $f"
    ./find_window.sh "$f" "2024-05-20 14:05:00" "2024-05-20 14:20:00" || true
done

|| true 是为了让某个文件全部落在窗口外(返回 3/4)时循环继续,这在批量导出时很常见。

换成 short-iso 格式后,脚本要删掉月份映射

前面反复提到 --output=short-iso。它的行首是 2024-05-20T14:05:00+0800,是天然的字典序可比较字符串,把脚本简化到十几行,而且没有跨年问题:

#!/bin/sh
# find_window_iso.sh —— 适配 journalctl --output=short-iso 导出的日志
set -eu
LOG="$1"; SINCE="$2"; UNTIL="$3"
SKEY=$(date -d "$SINCE" +%Y-%m-%dT%H:%M:%S)
UKEY=$(date -d "$UNTIL" +%Y-%m-%dT%H:%M:%S)

awk -v sk="$SKEY" -v uk="$UKEY" '
{
    ts = substr($1, 1, 19)
    if (ts ~ /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}$/) {
        if (start == 0 && ts >= sk) start = NR
        if (ts <= uk)              end   = NR
    }
}
END {
    if (start == 0 || end == 0 || end < start) { print "ERROR: window not found" > "/dev/stderr"; exit 3 }
    printf "START=%d END=%d TOTAL=%d\n", start, end, NR
}' "$LOG"

注意 substr($1,1,19) 只取到秒,把 +0800 时区后缀丢掉再比较——如果保留后缀,"2024-05-20T14:05:00+0800" 和 "2024-05-20T14:05:00" 长度不同,字典序比较会出现「短串是长串前缀则短串小」的边界问题,虽然本例里 + 的 ASCII 值小于数字,结果恰好正确,但不值得依赖。short-iso-precise 输出带微秒 2024-05-20T14:05:00.123456+0800,同样用 substr(...,1,19) 就能统一。

如果日志是 --output=json 导出的,每行一个 JSON 对象,时间在 __REALTIME_TIMESTAMP(微秒整数)。这时不适合用 grep/awk 做时间解析,推荐直接 jq 过滤后再交给 awk 切片,或者干脆回到源头用 journalctl 的 --since/--until 重新导出——保留 NR 切片是为了处理「已经拷走、无法重查」的日志,两者场景不同。

一套可复用的封装:导出 + 切片 + 校验

把上面几步串成一个脚本,日常故障复盘直接调:

#!/bin/bash
# export_window.sh
# 用法: ./export_window.sh "2024-05-20 14:05:00" "2024-05-20 14:20:00" nginx
set -euo pipefail

SINCE="$1"; UNTIL="$2"; UNIT="${3:-}"
OUT="/tmp/journal_$(echo "$UNIT" | tr '/' '_')_$(date -d "$SINCE" +%Y%m%d%H%M).log"

if [[ -n "$UNIT" ]]; then
    journalctl -u "$UNIT" --since "$SINCE" --until "$UNTIL" --output=short-iso > "$OUT"
else
    journalctl --since "$SINCE" --until "$UNTIL" --output=short-iso > "$OUT"
fi

TOTAL=$(wc -l < "$OUT")
if [[ "$TOTAL" -eq 0 ]]; then
    echo "WARN: journalctl returned 0 lines for [$SINCE, $UNTIL]" >&2
    echo "HINT: 检查 systemd 是否用 -D /var/log/journal 持久化存储" >&2
    exit 2
fi

RES=$(./find_window_iso.sh "$OUT" "$SINCE" "$UNTIL")
echo "$RES"
START=$(echo "$RES" | sed -n 's/.*START=\([0-9]*\).*/\1/p')
END=$(echo "$RES"   | sed -n 's/.*END=\([0-9]*\).*/\1/p')

# 首次命中行往前补 5 行上下文,方便看清触发点左右发生了什么
S=$(( START > 5 ? START - 5 : 1 ))
awk -v s="$S" -v e="$END" 'NR>=s && NR<=e' "$OUT" > "${OUT}.win"
echo "written: ${OUT}.win ($(wc -l < "${OUT}.win") lines)"

几个工程细节值得说明。第一,--since/--until 在 journalctl 里本身就能过滤,为什么还要 awk?因为反复出现的两个坑:journalctl 的时间过滤基于 entry 时间戳,但输出行里显示的时间可能因时区(--utc、TZ 环境变量)不一致而对不上;另外单位(-u)过滤会把同一时刻其他 unit 的行排除,而复盘时常需要看同机同刻的其他服务,这时只能导出全量再用行号窗口切。第二,START-5 的上下文补偿是刻意设计的,故障现场的触发行往往单独看没有意义。第三,脚本对 0 行做了显式判断,因为 journalctl 在 unit 名拼错时返回 0 行且退出码为 0,静默生成空文件排查起来很费时间。

真实报错与处理:

  • journalctl: Failed to add match 'nginx': Invalid argument —— 常见于 -u 后面多了一个空格或单位名带了 .service.service,用 systemctl list-units --type=service | grep nginx 核对全名。
  • awk: line 3: function mon2num never defined —— 把函数写在 BEGIN 之后了,awk 要求函数定义与调用在同一个程序块序列里、且不能位于 {...} 内部,把 function 提到最前面即可。
  • date: invalid date '2024-05-20 14:05:00' —— 只出现在 BusyBox 或 macOS 的 BSD date 上:前者需 date -d "2024-05-20 14:05:00" +%s 支持有限,后者要用 date -j -f "%Y-%m-%d %H:%M:%S"。脚本头部用 #!/bin/bash 并注明依赖 GNU coreutils。
  • awk: syntax error at or near && —— 粘贴时 && 被 HTML 实体化成 &amp;&amp;,在网页复制 awk 代码要检查;本文示例在源码中已写成 && 的实体形式,复制到终端前确认是单个 &。

性能方面:48MB / 约 48 万行的日志,gawk 扫一遍约 0.4~0.6 秒,两次 awk 调用完全可以接受。如果日志上 GB,先 journalctl --since/--until 粗筛到分钟级,再用本脚本精修,不要直接对全量历史日志跑。

小结

把「指定时间窗口」拆成两步:先用 awk 扫一遍,把日志行首时间戳转成可比较的键值,记录第一个 ≥ since 的 NR 和最后一个 ≤ until 的 NR;再执行一次 NR>=start && NR<=end 精确切片。这套做法的价值在于:对 Boot 分隔行、内核时间戳行这类无标准时间字段的杂行,天然按「包含在区间内」处理,不会漏;边界行数可以 echo $((END-START+1)) 与 wc -l 交叉验证;跨天跨月靠数字键单调性自动成立,跨年则通过退出码 5 暴露出来。能直接用 --output=short-iso 导出的,优先用 ISO 版本脚本,能省掉月份映射和跨年判断。

FAQ

Q1: 为什么用 NR 而不是直接比较每行的时间字符串?

因为比较字符串要先判断格式,遇到 -- Boot ... --、[ 12.345] 内核行、带 -- Logs begin at 的头部行时,取不到时间字段就没法比较,容易漏掉或误纳。用行号区间把「时间识别」和「内容截取」解耦,识别失败的行仍保留在输出里,反而更贴近原始日志。

Q2: 日志不是严格按时间排序的怎么办?

journalctl 单次导出基本有序,但多文件 cat app.log* 拼接、或日志轮转边界处可能局部乱序。此时 end < start 会触发退出码 5。处理办法是先 sort -s -k1,3 排一次(-s 稳定排序保留同秒内的原始顺序),或改用 ISO 格式后 sort -s -k1,1。

Q3: gawk、mawk、busybox awk 都能跑吗?

本文代码只用到 sprintf、split、substr、NR、用户函数,都是 POSIX 子集,三者均可。唯一差异在正则区间 {4}:BusyBox 的部分构建不支持 ERE 区间量词,若报 awk: bad regex,把 /^[0-9]{4}-... 换成 length(ts)==19 && substr(ts,1,4)+0>=2000 之类的显式判断。

Q4: 只想输出匹配行的行号、不要内容,怎么改?

在切片脚本里加 { print NR },或用一个 awk 同时判断:NR>=s && NR<=e { print NR": "$0 },后者带行号输出,方便回到原始文件核对。

Q5: 时间窗口内包含大量不需显示的行(如 debug),怎么二次过滤?

不要在第一遍定位时混入内容过滤,否则 NR 会跳变。正确做法是切片后再通过管道过滤:./cut_window.sh app.log 1042 3791 | grep -v 'debug' | less,或 awk 里加 && $0 !~ /debug/ 但保留 NR 区间判断不变。