系统与运维

日志分析实战:journald、logrotate 与多行日志的排查思路

磁盘被日志占满、systemd 服务起不来、昨天的日志突然「不见了」——这三类问题背后是同一条链路:写入、轮转、查询。本文用 journalctl 的结构化字段做过滤,讲清紧急清理的正确顺序与 journal 真空后已写入文件的处置,以及 logrotate 的 copytruncate 陷阱与多行日志在 journald / logrotate / 采集端三处不同的合并规则。

作者:巧匠团队·12 分钟阅读·更新于 2026-10-02

排查思路:先定位是写入、轮转还是查询环节的问题

日志相关的故障几乎都能归到三个环节。写入环节:服务没把日志写出去、日志级别设错、磁盘写满导致写入失败。轮转环节:文件被切走但没有压缩、旧文件没被删除、轮转时的 rename 让写句柄指向已删除文件导致空间不释放。查询环节:日志其实还在,只是你用了错误的过滤条件或查错了时间范围。

区分它们的方法是先看「空间」再看「内容」。用 `df -h` 和 `du` 找到到底是哪个目录吃掉了磁盘;如果磁盘有空间但日志里查不到东西,多半是查询问题;如果磁盘满了,那就是写入或轮转问题。这个顺序能省掉大量无效的过滤条件调试。

journald 与传统文件日志的排查入口不同。journald 是二进制索引格式,支持按 `_SYSTEMD_UNIT=`、`_PID=`、`PRIORITY=` 等结构化字段精确过滤,不需要 grep 正则;而 `/var/log/*.log` 是纯文本,只能靠 grep。任何时候不确定,就先用 `journalctl` 把「服务在这段时间内到底有没有产生日志」这个最基础的问题回答掉。

# 空间侧:谁吃掉了磁盘
df -h
du -xh --max-depth=2 /var/log 2>/dev/null | sort -rh | head -20
du -xh --max-depth=1 /var/lib/docker 2>/dev/null | sort -rh | head

# journal 自身占用
journalctl --disk-usage

# 目录清单:有没有被轮转后没删掉的旧文件
ls -lhtr /var/log/ | tail -30

journalctl 常用查询:把时间单位和过滤字段用对

journalctl 的默认输出是「本次启动以来」的日志,也就是 `-b` 的隐含含义。查历史启动必须显式给 `journalctl -b -1`,或者用 `--list-boots` 先看有哪些次启动及其时间范围。这是「日志不见了」最常见的原因之一:服务在两天前重启过,你想看的日志在上一轮启动里。

时间过滤的参数是 `--since` 和 `--until`,它们接受相对表达式(`-1h`、`-30min`、`today`、`yesterday`)和绝对时间戳。`-p` 过滤优先级,`-p err` 等价于只显示 err 及以上,因为 journald 的优先级数字越小越严重,err 是 3。筛选指定字段用 `字段名=值`,例如 `_SYSTEMD_UNIT=nginx.service`,多个条件是「与」的关系。

输出格式上,`-o short-precise` 保留毫秒时间戳,排查毫秒级竞态时非常有用;`-o json` 输出结构化 JSON,可以配 `jq` 做聚合,例如统计某个 unit 每小时打多少次 ERROR;`-f` 是实时跟随,等价于 `tail -f` 但作用在 journal 上。此外 `-n`、`-p`、`-u` 可以组合使用,这三者的组合能覆盖大部分日常场景。

journald 还会把启动时的控制台输出单独收录进 `-b -k`(内核日志),排查网络、挂载、OOM 都需要它。一个实用的组合是 `_SYSTEMD_UNIT=cron.service _PID=1234` 这类双字段过滤,能精确定位到某次具体任务,而不是把这个 service 所有的输出都拉出来。

# 先看有哪些次启动,再逐次排查
journalctl --list-boots | tail -10
journalctl -b -1 -p err --no-pager | tail -50

# 时间范围 + 优先级 + unit 三条件组合
journalctl -u nginx.service --since "2 hours ago" -p warning --no-pager

# 精确到某次任务
journalctl _SYSTEMD_UNIT=cron.service _PID=23117 --since "today" --no-pager

# 聚合统计:每小时 ERROR 数
journalctl -u api.service --since today -p err -o json --no-pager | jq -r '_TIME
    | (.[11:13])' | sort | uniq -c

# 实时跟随 / 内核 / 导出归档
journalctl -f -u api.service
journalctl -b -k --since "-30min" --no-pager
journalctl --since "-7d" -o export > /tmp/journal-export.txt

磁盘被日志占满:紧急处置的正确顺序

磁盘 100% 时服务会出现各种诡异失败,先别急着删文件。最有效的第一步是让 journald 自己收缩:`journalctl --vacuum-size=500M` 会删除最旧的日志直到总量低于 500M,`--vacuum-time=3d` 则按时间保留。两者都可加 `--vacuum-size` 与 `--vacuum-time` 组合使用,先看 `--disk-usage` 确认当前占用。

第二步处理传统文件日志。用 `du` 按修改时间排序找到最大的几个文件;如果它们属于仍在写入的服务,直接 `rm` 会留下一个已删除但仍被进程持有的 inode,磁盘空间不会立刻释放(`df` 仍显示满)。正确做法是 truncate 而不是 delete:`truncate -s 0 /var/log/app.log`,空间立刻回来且写句柄仍然有效。

第三步才是清理归档与容器。`/var/log/*.gz` 里的历史压缩包可以直接删除;Docker 与 containerd 的日志同样可能撑爆磁盘(`/var/lib/docker/containers` 下每个容器一个 json 日志),需要检查 daemon 配置是否开启了日志轮转上限。注意不要删 `*_journal` 之外的 journald 文件,也不要动正在被审计工具读取的目录。

第四步恢复轮转配置并确认。执行 `logrotate -f /etc/logrotate.conf` 强制轮转一次,或对 journald 执行 `systemctl restart systemd-journald`。最后用 `df -h` 确认空间确实回来了——如果空间没回来,说明还有人持有已删除的文件,用 `lsof +L1` 可以列出所有这类「已删除但仍打开」的文件句柄。

整个过程中要避免的动作包括:不要用 `kill -9` 去杀写日志的服务(可能留下更多未落盘状态),不要 `rm -rf` 日志目录,不要只删 `.gz` 而留着巨大的当前文件。磁盘满的根因永远是轮转策略缺失或上限过大,紧急处置之后必须补上 `maxsize` 与保留份数,否则三天后原样复发。

# 1) 现状
df -h /
journalctl --disk-usage
du -xh --max-depth=1 /var/log | sort -rh | head

# 2) 收缩 journal
journalctl --vacuum-time=3d --vacuum-size=500M

# 3) truncate 而非 delete(写句柄仍有效,空间立即回收)
truncate -s 0 /var/log/app.log

# 4) 空间没回来?查被持有的已删除文件
lsof +L1 2>/dev/null | head -20

# 5) 恢复轮转并复核
logrotate -f /etc/logrotate.conf
df -h /

# 6) 防止复发:给容器日志加上限
# /etc/docker/daemon.json  ->  {"log-driver":"json-file","log-opts":{"max-size":"100m","max-file":"3"}}

「昨天的日志不见了」:logrotate 的 copytruncate 与命名映射

传统轮转有两种实现。`create` 模式先 `mv` 再新建,应用持有的写句柄仍指向已被改名的文件,于是新旧内容分处两个文件;`copytruncate` 则先复制再把原文件清零,句柄不变。对直接写文件的程序(很多日志库用 O_APPEND 打开),rename 模式会让进程继续往老文件写,导致新文件永远空着——直到进程重启。这就是配了轮转却「日志没进去」的根因。

copytruncate 的代价是在复制与截断之间的窗口内会丢失一部分写入,而且复制大文件时会有瞬时 IO 峰值。但它是唯一不要求应用重新打开文件的方案,所以对「不支持 reopen 的第三方库」仍然是唯一选择——除非你把应用改成支持 SIGHUP 重开日志。

真正导致「历史文件消失」的是没有设置 `rotate N`。rotate 之后的历史轮次才会被 `n` 限制的保留份数清理;如果省略了 `rotate` 或写了过小的 `n`,昨天甚至前天的日志可能已经被删除。另外 `dateext` 决定是否用日期后缀命名(`app.log-20261002`),不加它就是纯数字后缀,跨天时你会看到 `app.log.1` 既是「昨天」又是「今天之前的最后一份」。

排查顺序:先 `ls -lhtr /var/log/` 看实际存在哪些文件与后缀格式,再去 `/etc/logrotate.d/` 里找对应的规则确认 `rotate`、`daily/weekly`、`compress`、`delaycompress` 四个字段。特别注意 `compress` 打开后未压缩的那一份叫 `.1`,`.1.gz` 才是上一轮,用 `zcat` 读;`delaycompress` 会让最近一份保持未压缩,减少 IO。

日志被 journald 收集时还有一个同类问题:`/var/log/journal` 下的文件一旦通过 `journalctl --vacuum-*` 被清理,那些已经写进 journal 的日志还在,因此有人会看到 journal 明明有记录而文件没了。此时正确的判断依据是 journal,而不是文件。反过来,采集端(Filebeat、Fluent Bit)若按文件 inode 跟踪,copytruncate 与 rename 两种模式需要不同的采集策略,否则会重复采集或漏采。

# /etc/logrotate.d/app
/var/log/app/*.log {
    daily
    rotate 14            # 保留 14 份;省略或写 1 就会丢历史
    missingok
    notifempty
    compress              # 历史轮次压缩
    delaycompress         # 最近一份不压缩,减少 IO
    copytruncate          # 不要求应用 reopen 文件
    dateext
    dateformat -%Y%m%d
    su root adm
    create 0640 root adm
}

# 排查三连
ls -lhtr /var/log/app/
logrotate -d /etc/logrotate.d/app     # -d 仅模拟,不实际执行
logrotate -v /etc/logrotate.d/app     # -v 输出处理了哪些文件

多行日志:三处规则各不相同

异常堆栈天然跨多行。第一行是 `java.lang.NullPointerException`,后面跟着十几行 `at com.example...`。如果采集器按行处理,堆栈会被切成十几条独立记录,告警去重、错误计数、traceId 关联全部失真。所以多行合并规则必须在日志产生端就定好,并保持一致。

在 systemd unit 里,`StandardOutput=` 支持值 `journal`(默认)与 `journal+console`,但真正控制行解析的是 `SyslogIdentifier=` 与日志本身的格式;更实用的做法是让应用直接输出单行(把换行转义成 `\n`),这是最省事的方案。如果必须处理多行,可以在应用侧改,或在采集端配置 multiline 正则。

在采集端,Filebeat 与 Fluent Bit 都提供 multiline 支持,通常以「本行以异常类名或时间戳开头」为续行判定,或者反过来「以下一行不匹配起始模式则并入上一行」。Filebeat 的对应配置块是 `multiline.pattern`,Fluent Bit 是 `[MULTILINE] parser`。两者的正则方言不同,写法不能直接互换,迁移时需要逐条重测。

在 shell 排查侧,grep 本身不支持跨行合并。一个实用的替代方案是用 `pcregrep -M` 之类的多行模式匹配工具,或者先用 `awk` 把文件规范化成单行 JSON 再处理。journald 侧则不需要额外配置——如果应用已经把多行合并成一条记录,journald 原样存储;这也是推荐让应用输出单行日志的原因:它一次性解决了 journald、logrotate 与采集端三处的问题。

# Filebeat:把堆栈合并成一条记录
filebeat.inputs:
  - type: filestream
    id: app-log
    paths:
      - /var/log/app/*.log
    parsers:
      - multiline:
          type: pattern
          pattern: '(^[0-9]{4}-[0-9]{2}-[0-9]{2}.*|Exception in thread|Caused by:|^\\s+at .*)$'
          negate: false
          match: after
          timeout: 5s

# 读压缩的历史轮次
zcat -f /var/log/app/app.log.* 2>/dev/null | grep -n "ERROR" | tail -50

# 侧栏:合并后先看堆栈头部数量,确认有没有被切开
grep -c "Exception in thread" /var/log/app/app.log

官方参考来源

下方为命令对应的官方权威文档,供你核对最新用法与深入查阅。