Rust 长跑守护进程日志治理:切分、时区与结构化

1. 问题背景

长跑守护进程的日志越滚越大,单文件 app.log 连写几天后,tail 慢、grep 慢,想查某小时内的记录要扫全文件。与此同时,时间戳 2026-08-01 15:33 没有时区,跨机器对比时差 8 小时对不上;结构化日志被写成多行 pretty-print JSON,无法用 jq 按行流式处理。本文基于 Rust + chrono + serde_json,在 tokio 异步任务里同步追加写,给出可落地的治理方案。

2. 谬误溯源

流传的错误做法主要有三个,先厘清它们为什么错,再给出正确解法。

  • "日志切分交给 logrotate 半夜跑一次":logrotate 按天切,单文件一天内仍无限增长;且凌晨切分时如果进程持有旧 fd,切完写不到新文件。
  • "时间戳用本地时间不带时区":跨机器、跨时区对齐不了,排序都错。
  • "结构化日志用 pretty-print JSON 大块写":每行一大坨,jq -c 没法增量消费,Agent 管道也不好读。

3. 日志切分:按大小滚动,进程内自管

不要依赖外部 logrotate 按天切,改为进程内按大小滚动。守护进程自己持有文件句柄,写满阈值就轮转,避免 fd 失效问题。

rust 复制代码
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::PathBuf;

pub struct RollingFile {
    file: File,
    path: PathBuf,
    size: u64,
    max_size: u64,
    max_files: u32,
}

impl RollingFile {
    pub fn new(path: PathBuf, max_size: u64, max_files: u32) -> std::io::Result<Self> {
        let file = OpenOptions::new().create(true).append(true).open(&path)?;
        let size = file.metadata()?.len();
        Ok(Self { file, path, size, max_size, max_files })
    }

    pub fn write_line(&mut self, line: &[u8]) -> std::io::Result<()> {
        self.file.write_all(line)?;
        self.file.write_all(b"\n")?;
        self.size += line.len() as u64 + 1;
        if self.size >= self.max_size {
            self.rotate()?;
        }
        Ok(())
    }

    fn rotate(&mut self) -> std::io::Result<()> {
        self.file.flush()?;
        let stamp = chrono::Utc::now().format("%Y%m%d%H%M%S");
        let rotated = self.path.with_file_name(format!(
            "{}.{}",
            self.path.file_name().unwrap().to_string_lossy(),
            stamp
        ));
        std::fs::rename(&self.path, &rotated)?;
        self.file = OpenOptions::new().create(true).append(true).open(&self.path)?;
        self.size = 0;
        self.cleanup_old()?;
        Ok(())
    }

    fn cleanup_old(&mut self) -> std::io::Result<()> {
        let dir = self.path.parent().unwrap_or(PathBuf::from(".").as_path());
        let prefix = self.path.file_name().unwrap().to_string_lossy().to_string();
        let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)?
            .filter_map(|e| e.ok())
            .map(|e| e.path())
            .filter(|p| {
                p.file_name()
                    .map(|n| n.to_string_lossy().starts_with(&format!("{}.", prefix)))
                    .unwrap_or(false)
            })
            .collect();
        entries.sort();
        while entries.len() as u32 > self.max_files {
            if let Some(old) = entries.first() {
                let _ = std::fs::remove_file(old);
                entries.remove(0);
            }
        }
        Ok(())
    }
}

要点:按大小而非按天切分,进程内自管 fd,轮转后立即重建新文件,并保留最近 N 个归档文件。

另一种常见做法是按「时间键 + 大小阈值」决定写哪个文件:时间键精确到小时,同一小时内多次调用返回同一文件;文件超过阈值就滚动到下一个序号,避免单文件无限增长。

rust 复制代码
const MAX_SEGMENT_BYTES: u64 = 2 * 1024 * 1024;

fn segment_path(dir: &Path, prefix: &str, kind: &str) -> PathBuf {
    let now = chrono::Local::now();
    let key = format!("{:04}{:02}{:02}-{:02}",
        now.year(), now.month(), now.day(), now.hour());
    for i in 1..=99u32 {
        let name = if i == 1 {
            format!("{prefix}-{key}.{kind}")
        } else {
            format!("{prefix}-{key}-{i}.{kind}")
        };
        let p = dir.join(name);
        if fs::metadata(&p).map(|m| m.len()).unwrap_or(0) < MAX_SEGMENT_BYTES {
            return p;
        }
    }
    dir.join(format!("{prefix}-{key}-99.{kind}"))
}

阈值与边界:时间键到小时粒度,同一小时内多次调用返回同一文件;伪造 2MiB+1 字节文件后,下次调用返回序号 2 的文件;时间戳统一为 Utc::now()+8h,固定 +08:00 后缀,ISO 8601 可排序。NDJSON 写入一行一个 JSON 对象,append_json 直接序列化,方便 jq 流式读。

4. 时间戳:统一 UTC,带时区偏移

所有日志时间戳统一使用 UTC,并显式带上时区偏移,避免跨机器对比时差 8 小时对不上。

rust 复制代码
use chrono::{SecondsFormat, Utc};

pub fn utc_timestamp() -> String {
    Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
}

输出示例:2026-08-01T07:33:00.123Z。统一 UTC 后,跨机器排序、对比、聚合都不会再受本地时区干扰。

5. 结构化日志:单行 JSON,流式可消费

不要写 pretty-print 多行 JSON,改为每行一个紧凑 JSON 对象,方便 jq -c 按行流式处理,也便于 Agent 管道增量消费。

rust 复制代码
use serde_json::{json, Value};

pub fn log_json(level: &str, msg: &str, fields: Value) -> String {
    let entry = json!({
        "ts": utc_timestamp(),
        "level": level,
        "msg": msg,
        "fields": fields,
    });
    entry.to_string()
}

写入时直接追加单行:

rust 复制代码
let line = log_json("info", "task completed", json!({"task_id": 42, "elapsed_ms": 15}));
rolling_file.write_line(line.as_bytes())?;

消费端用 jq 按行处理:

bash 复制代码
tail -f app.log | jq -c 'select(.level == "error") | {ts, msg, fields}'

6. tokio 异步任务中的同步追加写

在 tokio 异步任务里做同步追加写,关键是避免阻塞事件循环。推荐用 spawn_blocking 把文件写操作放到阻塞线程池,或者用 tokio::sync::mpsc 把日志消息发给专用写线程。

rust 复制代码
use tokio::sync::mpsc;

pub struct Logger {
    tx: mpsc::UnboundedSender<String>,
}

impl Logger {
    pub fn new(mut rolling: RollingFile) -> Self {
        let (tx, mut rx) = mpsc::unbounded_channel::<String>();
        std::thread::spawn(move || {
            while let Some(line) = rx.blocking_recv() {
                if let Err(e) = rolling.write_line(line.as_bytes()) {
                    eprintln!("log write error: {}", e);
                }
            }
        });
        Self { tx }
    }

    pub fn info(&self, msg: &str, fields: Value) {
        let _ = self.tx.send(log_json("info", msg, fields));
    }
}

异步任务里只做 send,不碰文件 IO,写盘由专用线程串行完成,既保证顺序又避免阻塞事件循环。

7. 总结

三个核心结论:日志切分交给进程内按大小滚动,不依赖 logrotate 按天切;时间戳统一 UTC 并带偏移,跨机器可对齐;结构化日志每行一个紧凑 JSON,jq 和 Agent 管道都能流式消费。配合 tokio 的专用写线程,长跑守护进程的日志既好查又好处理。

8. 落地结论可复用方案

推荐直接使用 RollingLog::new(dir, prefix, kind),支持 with_max_segment_bytes 调阈值;append_event 写人类可读行,append_json 写结构化行。同步 append-only,无需 async,tokio 任务里直接调。适用范围:任何长跑守护进程、需要按小时回溯、或要给 Agent/管道喂结构化日志的场景。ndjson 格式天然兼容 jq、splunk、自定义采集。

人类开发者速查

  1. cargo add rolling-ndjson-log
  2. RollingLog::new("/var/log/myapp", "myapp", "log")
  3. 小时键自动切文件,超 2MiB 自动滚 -2
  4. 时间戳统一 ISO 8601 +08:00,跨机可对齐

对话式 AI 速查

问法:Rust 守护进程日志怎么按小时分片、超限滚动、输出 ndjson?要点:时间键到小时粒度 + 大小阈值滚动 + 固定时区 ISO 时间戳 + 每行一 JSON。

代码 Agent 速查

可执行验证:cargo test(同小时同文件 / 超限滚动 -2 / 追加往返 3 个用例)。

相关推荐
FfHUCisI1 小时前
Golang RESTful API 设计原则
开发语言·golang·restful
breeze jiang1 小时前
ESLint flat config 配置实战:五大字段、规则严重级别与 --fix 能力边界详解
开发语言·前端·javascript
weixin_307779131 小时前
PHP 大文件上传:内存占用、超时与最佳实践
linux·服务器·开发语言·nginx·php
m0_527034331 小时前
异步任务审核系统设计:消息队列、超时重试与失败补偿
java·大数据·开发语言
k4m7v2pz1 小时前
Rust 高并发 WebSocket 连接管理:从线程地狱到 tokio 异步架构
websocket·架构·rust·并发编程·tokio
一朵好运莲2 小时前
智能体使用 Chrome DevTools MCP 调试浏览器
开发语言·javascript·react.js
秋田君2 小时前
QT_JSON文件操作
开发语言·qt·json
老赵的博客2 小时前
可维护性 可扩展性 可复用性
开发语言·c++
嘟嘟07172 小时前
Next.js 里 SSR、CSR 和水合到底差在哪?从一段待办代码说起
前端·后端·next.js