Ubuntu服务器上跑容器,最容易被忽略但出问题时最要命的就是日志。很多人以为docker logs或者kubectl logs能看就够了,实际上容器重建、节点重启、日志轮转策略失效这些场景下,日志丢失是常态。真正要解决的是把容器日志当成一条完整的数据管道来管理:从容器标准输出采集,到本地缓冲,再到集中存储和检索,每一层都要有明确的策略和兜底方案。

先搞清楚容器日志默认写到哪里

在Ubuntu上,Docker默认的日志驱动是json-file。容器的stdout和stderr会被Docker捕获,写入/var/lib/docker/containers/<容器ID>/<容器ID>-json.log。这个文件会无限增长,除非你配置了log-opts里的max-size和max-file。很多人不知道的是,即使你设置了max-size,Docker做日志轮转时是直接截断旧内容,而不是像logrotate那样生成.1、.2的归档文件。这意味着如果你没有把日志及时采集走,旧日志就是被直接丢弃的。

对于Kubernetes集群,情况类似但路径不同。kubelet会把容器日志写到/var/log/containers/下的软链接,实际指向/var/log/pods/目录。Kubernetes的日志轮转由kubelet的两个参数控制:container-log-max-size和container-log-max-files。默认值分别是10MiB和5个文件。如果你的应用日志量很大,10MiB可能几分钟就轮转一次,5个文件很快就被覆盖。所以在生产环境,这两个参数必须根据实际日志速率调整,而不是用默认值。

采集方案:DaemonSet节点级采集是首选

容器日志采集有两种思路:应用侧主动推送和节点级被动采集。应用侧推送意味着每个容器里跑一个日志采集进程,这违背了容器单一职责原则,而且会消耗容器自身的资源配额。节点级采集是在每个Ubuntu节点上部署一个采集器,读取/var/log/containers/或/var/lib/docker/containers/下的日志文件,统一推送到集中存储。这种方式对应用无侵入,资源隔离清晰,是目前生产环境的主流做法。

具体实现上,Fluent Bit、Filebeat、Vector都是成熟的节点级采集器。以Fluent Bit为例,在Ubuntu上部署为systemd服务或者DaemonSet,核心配置思路是:使用tail插件监控/var/log/containers/*.log,使用kubernetes过滤器解析文件名中的namespace、pod_name、container_name等元数据,然后输出到Loki、Elasticsearch或Kafka。这里有一个关键细节:tail插件要开启multiline支持,否则Java应用的堆栈日志会被拆成多条独立记录,后续检索时根本拼不回来。

日志格式统一是采集前必须做的事

容器应用输出日志时,格式五花八门。有的用纯文本,有的用JSON,有的带时间戳,有的不带。如果不在采集阶段统一格式,后面的存储和检索会非常痛苦。建议在采集器层面做两件事:第一,强制给每条日志加上采集时间戳,这个时间戳以采集器接收时间为准,而不是应用日志里的时间。第二,把日志内容解析成结构化字段。如果应用输出的是JSON,直接保留;如果是纯文本,至少提取出日志级别(INFO、WARN、ERROR等)和message字段。

对于Fluent Bit,可以用parser过滤器来做这件事。下面是一个实际可用的配置片段,展示如何解析容器日志并添加元数据:

[INPUT]
    Name              tail
    Path              /var/log/containers/*.log
    Parser            docker
    Tag               kube.*
    Refresh_Interval  5
    Mem_Buf_Limit     10MB
    Skip_Long_Lines   On

[FILTER]
    Name                kubernetes
    Match               kube.*
    Kube_URL            https://kubernetes.default.svc:443
    Kube_CA_File        /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
    Kube_Token_File     /var/run/secrets/kubernetes.io/serviceaccount/token
    Merge_Log           On
    Keep_Log            Off

[FILTER]
    Name                parser
    Match               kube.*
    Key_Name            log
    Parser              json_parser
    Reserve_Data        On

[OUTPUT]
    Name                loki
    Match               kube.*
    Host                loki.monitoring.svc
    Port                3100
    Labels              job=fluent-bit
    Label_Keys          $namespace, $pod_name, $container_name

这个配置里,parser docker负责解析Docker的json-file格式,把时间戳、stream、log字段拆出来。kubernetes过滤器通过API Server查询Pod元数据,给每条日志打上namespace、pod_name、container_name等标签。parser过滤器尝试把log字段里的JSON内容进一步解析成结构化字段。最后输出到Loki,标签体系保证了后续查询时可以按命名空间、Pod名、容器名快速过滤。

集中存储选型:Loki、Elasticsearch还是ClickHouse

日志集中存储没有银弹,选型取决于你的查询模式和预算。Elasticsearch功能最强,全文检索、聚合分析、可视化都很好,但资源消耗巨大。一个中等规模的集群,ES节点吃掉的CPU和内存可能比业务容器还多。Loki是Grafana生态的产品,设计思路是只索引标签、不索引日志内容,日志原文压缩存储在对象存储里。查询时通过标签定位到chunk,再解压过滤。这种架构成本低很多,但全文检索速度不如ES。如果你的日志查询模式主要是按时间范围和标签过滤,Loki完全够用。ClickHouse是列式数据库,适合大规模日志分析场景,写入吞吐极高,但运维复杂度也高。

对于大多数Ubuntu服务器上的容器环境,我的建议是:如果日志量每天在100GB以下,且查询需求以故障排查为主,用Loki加Grafana是最省心的方案。如果日志量很大且需要复杂的全文检索和聚合分析,再考虑Elasticsearch。不要一上来就上ES集群,它的运维成本会被很多人低估。

本地缓冲与反压机制不可缺失

集中存储总有不可用的时候:网络抖动、存储集群升级、磁盘写满。如果采集器直接把日志推送到远端,远端不可用时日志就会丢失。所以采集器必须具备本地缓冲能力。Fluent Bit的storage.path配置可以开启文件系统缓冲,当输出端不可达时,日志先写入本地磁盘,等输出恢复后再继续发送。这个缓冲目录要单独挂载数据盘,不要和系统盘共用,否则日志缓冲可能把根分区写满导致节点不可用。

反压机制同样重要。当缓冲达到上限时,采集器应该停止读取新日志,而不是丢弃旧日志或者崩溃。Fluent Bit的Mem_Buf_Limit和storage.max_chunks_up就是控制这个行为的。你需要根据节点磁盘容量和日志产生速率来合理设置这些值。一个常见的错误是缓冲上限设置得太小,导致远端短暂不可用时就开始丢日志;或者设置得太大,把节点磁盘吃满。

日志轮转与采集器的协同问题

这是容器日志采集中最容易踩的坑。Docker或kubelet在做日志轮转时,会重命名当前日志文件然后创建新文件。如果采集器是通过inode跟踪文件,重命名后采集器还持有旧文件的句柄,会继续读旧文件直到EOF,但新文件可能永远不会被读取。所以采集器的tail插件必须支持基于文件名的跟踪,并且能检测到文件轮转事件。

Fluent Bit的tail插件默认使用inode跟踪,但可以通过设置Read_from_Head和Refresh_Interval来处理轮转。更可靠的做法是让采集器直接监控/var/log/pods/目录,因为kubelet轮转时会创建新的日志文件,文件名带有时间戳后缀,采集器可以识别新文件并开始读取。另外,要确保采集器的读取速度足够快,能在日志文件被删除之前把内容读完。如果日志产生速率超过采集器的处理能力,轮转策略再保守也会丢日志。

多行日志处理的实际方案

Java、Python、Node.js应用的异常堆栈往往跨多行。如果采集器逐行读取,堆栈信息会被拆成多条独立日志,检索时根本无法还原完整错误。解决这个问题需要在采集器层面做多行合并。Fluent Bit的multiline插件可以根据正则表达式识别一条日志的开始行,后续不匹配的行都合并到上一条日志。

一个典型的Java堆栈多行配置如下:

[INPUT]
    Name             tail
    Path             /var/log/containers/*.log
    Multiline        On
    Parser_Firstline java_firstline

[PARSER]
    Name        java_firstline
    Format      regex
    Regex       ^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}

这个配置假设Java日志的每一行都以时间戳开头,堆栈行不以时间戳开头,因此会被合并到上一条日志。实际使用时要根据你的日志格式调整正则表达式。多行合并会消耗更多内存,因为采集器需要在内存中缓存不完整的日志,所以要合理设置缓冲上限。

日志安全与合规:敏感信息脱敏

容器日志里经常包含敏感信息:用户手机号、邮箱、Token、密码、身份证号。这些日志如果直接进入集中存储,就变成了合规风险。在采集阶段做脱敏是最有效的,因为日志一旦写入存储,后续再清理就非常困难。Fluent Bit可以用lua过滤器或者自定义插件来做正则替换。下面是一个简单的脱敏配置示例:

[FILTER]
    Name                lua
    Match               kube.*
    Script              /etc/fluent-bit/redact.lua
    Call                redact

-- redact.lua
function redact(tag, timestamp, record)
    if record["log"] ~= nil then
        record["log"] = string.gsub(record["log"], "%d%d%d%d%d%d%d%d%d%d%d", "[PHONE_REDACTED]")
        record["log"] = string.gsub(record["log"], "Bearer%s+%S+", "Bearer [REDACTED]")
    end
    return 1, timestamp, record
end

这个例子把11位手机号和Bearer Token替换成占位符。实际生产环境中,脱敏规则要根据你的业务数据类型来定制,而且要在日志进入网络传输之前完成脱敏,避免敏感信息在网络上明文传输。

监控采集器自身健康状态

日志管道本身也需要监控。采集器进程可能崩溃、缓冲磁盘可能写满、输出端可能持续报错。如果不对采集器做监控,日志管道中断了都没人知道。至少需要监控这几个指标:采集器进程存活状态、日志读取速率(每秒多少条)、输出端错误率、本地缓冲使用量。Fluent Bit暴露了内置的HTTP监控接口,可以配置Prometheus来抓取这些指标。

一个实用的做法是给采集器配置一个心跳日志,每分钟输出一条包含节点名和时间戳的日志。如果集中存储里某个节点的日志突然中断了,你可以快速判断是应用没输出日志,还是采集管道出了问题。这个心跳机制在故障排查时非常有用。

日志保留策略与成本控制

日志不是存得越久越好。存储成本是实打实的,而且大部分日志在几天之后就没有查询价值了。建议根据日志类型设置不同的保留策略:应用访问日志保留7到14天,错误日志保留30天,安全审计日志按合规要求保留90天或更长。Loki支持按租户或标签设置保留期,ES可以用索引生命周期管理来自动删除过期索引。

另一个成本控制手段是对日志做采样。对于访问量极大的服务,全量采集日志可能没有必要。可以在采集器层面做采样,比如只采集错误级别以上的日志,或者按比例采样。但要谨慎使用采样,因为故障排查时缺失的日志可能正好是关键线索。

Ubuntu服务器上的容器日志管理,本质上是一个系统工程。从容器运行时配置、节点级采集、格式统一、多行合并、脱敏、缓冲、集中存储到保留策略,每一层都需要明确的策略和参数调优。没有一套配置能适用所有场景,但理解了这些环节的作用和相互影响,你就能根据实际需求搭建出稳定可靠的日志管道。日志管道的价值不在于平时能存多少日志,而在于出问题时能多快定位到根因。