日志体系:记录、采集、检索与保留
一条订单请求可能留下入口访问记录、应用异常和数据库慢查询。它们来自不同进程,记录时间与字段也各不相同。把这些记录按服务、环境、请求标识和时间关联起来,才能解释请求经过了哪里、在哪一步失败,以及失败影响了哪些操作。
日志体系包含生成、读取、传输、存储和查询。任一环节都可能过滤、延迟或丢弃记录。集中平台查不到事件时,应沿这几个环节逐段确认,而不是立即修改应用的日志级别。
让日志能够解释一次操作
不同来源记录不同事实
| 来源 | 常用内容 | 适合回答的问题 |
|---|---|---|
| 应用 | 业务结果、异常栈、依赖调用、任务进度 | 哪个操作失败,异常在什么位置出现 |
| Nginx 等入口 | 路径、状态、请求与上游耗时 | 请求是否到达入口,哪个上游响应异常 |
| 容器运行时 | 标准输出、标准错误、启动退出信息 | 指定容器实例输出了什么 |
| systemd journal / syslog | 服务、内核、认证与系统组件消息 | 进程为何退出,系统是否出现 OOM 或设备错误 |
| 数据服务 | 慢查询、复制、持久化、连接与权限错误 | 应用依赖是否处于正常运行状态 |
| 审计记录 | 操作主体、目标、动作及结果 | 谁执行了敏感操作,是否需要追责或合规留存 |
业务审计需要约定访问权限、保留和防篡改方式,不能让普通日志采样规则把关键审计事件丢掉。反过来,也不应把每条 DEBUG 消息送进长期审计存储。
先用一组稳定字段统一查询入口:
事件
├── timestamp 事件发生时间,带时区
├── service 稳定服务名,例如 orders
├── environment 环境,例如 staging
├── instance 实例或 Pod 标识
├── level INFO / WARN / ERROR 等
├── event 稳定事件名,例如 order.submit.failed
├── trace_id 分布式调用标识,有追踪上下文时记录
├── request_id 单次入口请求标识
├── message 可读说明
└── error 异常类型、原因及栈,需要时才记录事件时间用于还原业务先后,采集时间用于判断传输延迟。两者都保留时,才能区分“刚刚发生的故障”和“采集器刚补发的旧日志”。跨主机比较耗时还需要时钟同步;单个进程内部的耗时应使用单调时钟计算,不用两次墙上时间相减。
请求标识来自不可信客户端时,应限制长度和字符,并避免原样注入日志。入口可以生成自己的 request_id;分布式 trace_id 则由追踪组件传播。一个异步任务可能延续原调用,也可能建立新的调用并保存关联关系,需要按任务语义设计。
Java 应用怎样记录
使用 SLF4J 的参数化记录,业务分支给出稳定事件名,把异常对象作为最后一个参数传入:
log.info("event=order.accepted orderId={}", orderId);
log.warn("event=inventory.rejected productId={} reason={}", productId, reason);
log.error("event=payment.failed orderId={}", orderId, exception);参数化消息便于统一格式,也避免提前拼接字符串;昂贵的参数计算仍然会在方法调用前发生,应按日志级别或懒求值 API 控制。异常在能够补充处理结果的一层记录即可,底层记录后原样抛出、每层再次打印,容易把一个错误放大成多条告警。
级别应表达操作结果。正常业务拒绝未必是 ERROR;可自动恢复但值得观察的问题适合 WARN;ERROR 表示本次操作失败或组件无法继续完成职责。生产临时开启 DEBUG 时限定包名、实例和持续时间,同时关注输出速率,排查结束恢复原级别。
Spring Boot 支持结构化日志。使用具备该功能的发行线时,可在应用配置中选择 ECS JSON 输出:
spring:
application:
name: orders
logging:
structured:
format:
console: ecs
level:
root: INFO
com.example.orders: INFO字段名和默认字段随格式变化,采集器应按实际输出配置,不能把后面实验的自定义 JSON 字段直接套到 ECS 上。文件输出、滚动策略、结构化格式和包含字段的配置见 Spring Boot 日志参考。
MDC 可把请求标识放进当前线程的日志上下文。在线程池中,任务结束后必须恢复或清除上下文,否则下一个任务可能沿用前一个请求的标识。异步提交也不会仅因使用同一个 Logger 就自动携带 MDC;应由框架的上下文传播设施或受控的任务包装完成复制与恢复。详细语义见 Logback MDC。
敏感信息应在源头收敛:不记录 Authorization、Cookie、密码、完整支付信息或原始请求体。必要的账号、联系方式采用脱敏或受控标识。采集阶段再过滤可以补救个别字段,但原始文件与标准输出可能已经保存了秘密。
JSON、多行与异步输出
“一行一个 JSON 对象”让采集器容易识别事件边界。异常栈作为 JSON 字符串中的换行转义,仍属于一个对象;直接输出多行栈时,需要多行合并规则,并限制最大行数、字节数和等待时间,避免把后续事件吞进同一个栈。
结构化编码器负责 JSON 转义,不要用字符串拼接把任意业务值包成 JSON。输出中引号、反斜线或换行处理错误,会导致后续解析失败。
异步 Appender 把写出工作放到队列后方,代价是队列占用与结束时的排空时间。队列满时阻塞还是丢弃、哪些级别允许丢弃、进程关闭等待多久,必须明确。Logback 的默认行为与配置项见 Appender 手册;对账所需事件应使用可靠业务存储或专门审计通道,不把普通异步日志当作事务提交记录。
接好入口、容器与系统的本地输出
Nginx 访问日志:先解释时间字段
下面配置放在 Nginx 的 http 上下文中,输出路径需要运行身份可写:
log_format access_json escape=json
'{"timestamp":"$time_iso8601","request_id":"$request_id",'
'"method":"$request_method","uri":"$uri","status":"$status",'
'"request_time":"$request_time","upstream_addr":"$upstream_addr",'
'"upstream_status":"$upstream_status",'
'"upstream_response_time":"$upstream_response_time"}';
access_log /var/log/nginx/access.jsonl access_json;$uri 避开了原始查询字符串,但路径本身也可能含敏感数据,应继续按业务约定处理。$request_time 覆盖入口处理请求的总时间;$upstream_response_time 对应上游响应时间,多次尝试可能产生多个值。二者差值不能直接命名为“网络耗时”,它还可能包含客户端收发、缓冲及入口处理。
访问日志中的 502 可以指向上游连接或协议问题,具体原因要接着看同实例的 error log。应用主动返回的 500,则需要关联应用异常。变量及写日志阶段见 Nginx 日志模块 和 上游变量。
在有授权的 Linux 测试主机上,修改后由管理员先检查再重载:
sudo nginx -t
sudo systemctl reload nginx
sudo systemctl is-active nginx语法检查失败时保留现有进程,修正报错文件和行号后再检查;重载后应请求实际业务入口并确认新访问记录出现。容器 Nginx 的配置校验、重载身份和日志路径按 Nginx 部署 处理。
Docker 标准输出与日志驱动
容器应用写标准输出,由 Docker 日志驱动保存或转发。应用写到容器内部普通文件时,docker logs 读不到那个文件;容器删除后,未挂载的数据也会随可写层消失。
先从明确的容器名称检查配置,下面的 CONTAINER 必须替换为待观察实例:
CONTAINER=orders-api
docker inspect --format '{{json .HostConfig.LogConfig}}' "$CONTAINER"
docker logs --tail 100 --since 10m "$CONTAINER"这些命令只读,但日志可能含敏感信息,不要把原样输出贴到公开工单。找不到容器时先核对 Docker context 和实例名,不能据此判断服务已经删除。
为新建服务设置有上限的本地日志驱动:
services:
app:
# image、环境变量、端口等沿用应用本身的配置
logging:
driver: local
options:
max-size: "10m"
max-file: "3"这里的轮转对象是运行时日志文件,不是应用自行写入的文件。local 驱动适合本地轮转与读取;使用 json-file 时也要显式控制容量。更换 Docker daemon 默认驱动不会追溯修改已创建容器,需要按发布流程重建。非阻塞日志模式在缓冲区满时可以丢消息,阻塞模式则可能反过来拖慢应用;取舍见 Docker 日志驱动配置。
不要用 logrotate 直接截断 Docker 管理的内部日志文件,也不要让普通采集器通过挂载 Docker socket 获得远超读日志所需的主机控制权限。
journal、syslog 与文件轮转
systemd 服务的标准输出常进入 journal。普通账号能看到哪些条目取决于系统授权;需要查看完整服务日志时,通过已有提权流程执行:
sudo journalctl -u orders.service -n 100 --no-pager
sudo journalctl -u orders.service --since '10 min ago' -p warning --no-pager
sudo journalctl --disk-usagejournal 同时保存 unit、PID、启动实例等元数据,比只对全系统日志搜索字符串更容易定位。系统可将日志保存在易失目录或持久目录;重启后是否保留,由 journald 的 Storage 和实际目录决定。SystemMaxUse、SystemKeepFree 等控制容量,删除归档日志的 vacuum 操作不保证立刻缩小当前活跃文件。配置和查询分别见 journald.conf 与 journalctl。
syslog 是另一条消息接收和转发路径,rsyslog 可接入本机服务及远程设备。与 journal 同时采集时,先检查是否重复转发同一消息。远程 UDP 传输可能丢包;使用 TCP/TLS 时仍要设计发送队列、磁盘缓存和接收端故障处理。相关队列模型见 rsyslog 队列。
应用文件应由一个明确的轮转者负责:Logback 自身滚动或外部 logrotate,避免两者同时重命名同一个活跃文件。外部轮转通常采用“重命名旧文件、创建新文件、通知进程重新打开”。只改文件名,写入进程可能继续持有旧文件描述符。
copytruncate 在复制和截断之间存在丢失写入的窗口,不能承诺零丢失。轮转后的权限、组、压缩延迟和采集器读取进度也需配合。先用管理员身份对限定配置运行 logrotate --debug /etc/logrotate.d/orders 查看计划;实际触发轮转属于变更,应在测试环境验证新旧文件均能被正确采集。选项见 logrotate 手册。
用 Alloy 和 Loki 完成一次采集与查询
准备独立实验目录
下载日志实验工程。解压后进入 logging-lab,不要放在生产日志目录。需要 Linux、GNU Bash/date、Docker Compose v2、curl 和 jq;普通用户需有 Docker 使用权限,这项权限本身具有主机管理风险。
实验固定 Loki 3.7.2、Alloy 1.18.0。镜像来自对应项目分发,版本选择与安装方式可查 Loki Docker 安装 和 Alloy Docker 安装。先在可联网环境拉取并核对目标 CPU 架构;受限环境将镜像通过企业仓库或 docker image save/load 搬运,保留摘要和来源。文件准备方法见 离线部署。
初始化使用固定项目名 ops8-logs。脚本在生成文件前检查本地状态和同名项目的容器、卷、网络;存在旧资源或 Docker 查询失败就退出。换解压目录不会隔离同一个 Compose 项目,重启旧实验应回原目录直接执行 docker compose up -d,不重新 prepare,也不通过 -p 或 COMPOSE_PROJECT_NAME 改名绕过检查。
docker version
docker compose version
curl --version
jq --version
bash prepare.sh || exit 1
docker compose config
docker compose pull
docker compose up -d
docker compose psprepare.sh 保存当前 UID/GID,创建专用 runtime 目录。两个服务均使用这个非 root 身份;源日志只读挂载给 Alloy。它还为全新 Loki 数据目录生成 schema 起始时间,复用数据时应保留这个文件,不要重新初始化 schema。
主机只在回环地址开放 Loki 的 18310 和 Alloy 的 18345 端口。Loki 未启用认证,只适用于这个隔离实验。容器间使用 loki:3100,不会经过主机的 18310 端口。
若 Compose 报地址池耗尽,先检查已有网络和宿主路由,为实验指定一段不冲突的网段;不要清理其他项目网络。若报目录不可写,检查 .env 身份和 runtime 所有者,不要用 chmod 777 放开整个日志树。
Loki 初次启动可能暂时返回 503。下面最多等待约三分钟,每次请求也有超时上限:
ready=0
for attempt in $(seq 1 60); do
if curl -q --noproxy '*' --fail-with-body -sS --max-time 2 \
http://127.0.0.1:18310/ready; then
ready=1
break
fi
sleep 1
done
test "$ready" -eq 1最后的检查非零退出时,先看 docker compose logs --tail 100 loki;文件权限、schema 或内存限制报错需要修正后重新启动。就绪后再发送第一条事件。
读取位置、解析字段与存储标签
工程中的采集配置分为四段:
local.file_match.app
找到 /logs/*.jsonl
↓
loki.source.file.app
按已保存位置读取完整行
↓
loki.process.app
解析 JSON;level 进入标签;event_id 作为结构化元数据
↓
loki.write.local
批量发送到 Loki关键解析配置如下:
stage.json {
expressions = { level = "level", event_id = "event_id", timestamp = "timestamp" }
}
stage.labels {
values = { level = "" }
}
stage.structured_metadata {
values = { event_id = "" }
}
stage.timestamp {
source = "timestamp"
format = "RFC3339"
}service 和 environment 在文件发现时附加。level 的取值有限,适合作为标签;订单号、请求标识等高基数字段放在消息或结构化元数据中。Loki 按标签组合组织 stream,给每个订单号建一个标签值会产生大量稀疏 stream,增加内存、索引和查询负担。标签选择见 Loki 标签。
Alloy 的读取位置位于 --storage.path 指定的数据目录,工程将其持久化到 runtime/alloy。配置组件名称、文件发现目标及标签变化也可能影响读取状态;删除位置文件后重新采集可能重读,也可能因从末尾开始而跳过历史数据。具体行为见 loki.source.file。
读取位置和服务端持久化确认之间存在传输环节。已读取的数据可能仍在内存批次里,后端确认也可能在网络断开时丢失。因此应分别设计本地缓冲、重试次数与可接受的重复范围,不能把位置文件当作零丢失保证。loki.write 的批处理、重试和可选 WAL 参数见 写入组件参考;开启 WAL 还需预留磁盘并检验故障后重放。
写入、查询,再验证暂停与恢复
事件时间由脚本在运行时生成,每行包含一个自定义事件标识:
bash emit.sh order-first
curl -q --noproxy '*' --fail-with-body -sS --max-time 5 --get \
http://127.0.0.1:18310/loki/api/v1/query_range \
--data-urlencode 'query={service="orders",environment="lab"} | json | event_id="order-first"' \
--data-urlencode 'limit=20' |
jq '{status, events: [.data.result[].values[][1] | fromjson | .event_id]}'查询可能比写入晚几秒看到结果。成功时 status 为 success,events 中出现 order-first。空数组时可在 30 秒内重查;仍为空则先检查源文件,再检查 Alloy 文件 target 和发送日志。查询 API 返回成功但结果为空,表示该条件和时间窗内没有匹配事件。
这个查询先按服务和环境缩小 stream,再解析 JSON 精确过滤事件。筛选错误级别可使用 {service="orders",level="ERROR"},按消息片段使用 |= "timeout"。生产查询先限制时间,再增加解析和聚合,避免扫描全部历史数据;完整语法见 LogQL。
在同一实验目录暂停采集器:
docker compose stop alloy
bash emit.sh order-paused把上面的查询标识换成 order-paused,此时应为空;本地文件已有事件,集中平台尚未接收到它。随后恢复:
docker compose start alloy在 30 秒内重新查询,应能找到 order-paused。这验证的是源文件仍存在、位置目录保留、采集器恢复后的补读。它没有覆盖主机掉电、文件提前删除、缓冲耗尽或重复确认等故障,生产接入时还需分别检验这些情况。
查看 Alloy 的 http://127.0.0.1:18345,可以定位采集组件、目标文件和读取位置。组件显示健康也应继续检查发送结果与实际查询;文件组件的健康状态不能覆盖整个传输路径。
停止实验使用:
docker compose down这会移除本实验容器和默认网络,保留绑定目录中的日志、Loki 数据和读取位置。需要重启时直接 docker compose up -d,不要再运行初始化。只有确认实验数据不再需要时才手动删除这个专用目录。
保留数据并处理丢失、积压和磁盘问题
存储选择与保留不是同一项配置
Loki 使用标签选择数据,再检索日志内容,适合已有稳定服务标签的运行日志。Elasticsearch/OpenSearch 更强调字段检索与全文索引,可承接复杂检索和分析,但字段映射、分片和索引生命周期也需要维护。选择前用自己的典型查询和每日数据量做测量,不能只比较组件数量。
本地轮转负责控制主机文件,集中保留负责删除平台历史记录,归档负责更长期的数据保存。三者需要分别定容量和恢复方式:
源文件剩余保存时间 ≥ 可容忍采集故障时间 + 恢复补读时间
集中存储预算 ≈ 每日实际写入量 × 保留天数 × 副本与索引系数
恢复补读能力 > 正常新增速率系数应由实际压缩、标签和存储布局测量,不能给所有产品套同一个压缩比。若采集器补发速度只等于新增速度,积压会一直存在。
实验的 Loki 使用单机文件系统、单副本和 168 小时保留,Compactor 执行清理。剩余磁盘过低不会自动把保留期缩短到安全值,清理也存在延迟;工作目录和删除标记必须可持久保存。相关配置见 Loki 保留机制。多机部署、对象存储与查询扩展见 Loki 工具主文。
备份至少包括采集规则、索引/schema 配置、必要状态和日志存储。恢复时先隔离查询验证,再决定是否继续写入。已经按新 schema 形成的数据,不能通过把配置文件换回旧版来撤销。
从最早缺失的位置开始排查
| 现象 | 先查哪里 | 确认后怎样处理 |
|---|---|---|
| 源文件没有事件 | 实例、日志级别、过滤与业务分支 | 发起可识别的测试操作,核对是否走到该分支 |
| 文件有事件,采集器读不到 | 路径、目录执行位、文件权限、挂载与 target | 用采集身份验证读取;修正最小目录授权 |
| 读取增长,发送失败 | 目标地址、TLS、认证、状态码与队列 | 400 查字段/时间,401/403 查身份,429 查速率和配额 |
| 发送恢复,延迟继续扩大 | 新增速度、发送速度、重试与积压 | 扩大可持续吞吐或减少低价值日志;保护剩余磁盘 |
| 查询不到已发送事件 | 租户、标签、时间区间、时区、解析规则 | 从窄服务标签和原始行开始,逐步加入过滤 |
| 同一事件多次出现 | 多个采集器、位置重置、重试和轮转 | 先去除双路采集,保留事件标识便于核对重复 |
不要在发现积压后立即删除源日志或位置文件。先保存采集错误、最早未送达事件时间和剩余容量,再决定是否限流、临时扩容或舍弃明确允许丢弃的数据。对审计或交易相关记录,丢弃需要业务授权。
磁盘满:区分文件、inode 与打开的旧文件
以下只读命令在目标 Linux 主机运行,/var/log 应替换成实际日志挂载路径:
df -hT /var/log
df -i /var/log
sudo du -xhd1 /var/log
sudo lsof +L1df 观察文件系统分配,du 统计可遍历目录下的文件占用;两者出现大差异时,还要考虑已删除但仍打开的文件、挂载点遮蔽、权限和文件系统自身占用。lsof +L1 可列出链接数小于 1 的打开文件,按 PID、文件描述符与路径定位写入者。
确认进程持有已删除日志后,按该服务支持的方式重新打开日志,必要时安排受控重启。不要直接杀进程,也不要把截断 /proc/PID/fd/N 作为日常处理;操作错对象会破坏其他数据。
inode 用尽常见于大量小文件,单纯扩容字节配额未必解决。找出制造小文件的组件,修正轮转、压缩与归档策略。释放空间后继续观察一段完整写入周期,确认增长速率、轮转和采集补发均恢复,否则下一次爆盘只是时间问题。
权威资料与规范地址
应用与本地日志
- Spring Boot 日志参考:https://docs.spring.io/spring-boot/reference/features/logging.html
- Logback MDC:https://logback.qos.ch/manual/mdc.html
- Appender 手册:https://logback.qos.ch/manual/appenders.html
- Nginx 日志模块:https://nginx.org/en/docs/http/ngx_http_log_module.html
- 上游变量:https://nginx.org/en/docs/http/ngx_http_upstream_module.html#variables
- Docker 日志驱动配置:https://docs.docker.com/engine/logging/configure/
- journald.conf:https://www.freedesktop.org/software/systemd/man/latest/journald.conf.html
- journalctl:https://www.freedesktop.org/software/systemd/man/latest/journalctl.html
- rsyslog 队列:https://www.rsyslog.com/doc/concepts/queues.html
采集、检索与保留
- logrotate 手册:https://github.com/logrotate/logrotate/blob/main/logrotate.8.in
- Loki Docker 安装:https://grafana.com/docs/loki/latest/setup/install/docker/
- Alloy Docker 安装:https://grafana.com/docs/alloy/latest/set-up/install/docker/
- Loki 标签:https://grafana.com/docs/loki/latest/get-started/labels/
- loki.source.file:https://grafana.com/docs/alloy/latest/reference/components/loki/loki.source.file/
- 写入组件参考:https://grafana.com/docs/alloy/latest/reference/components/loki/loki.write/
- LogQL:https://grafana.com/docs/loki/latest/query/
- Loki 保留机制:https://grafana.com/docs/loki/latest/operations/storage/retention/
