Kubernetes中日志重要性

通常日志最基础的作用是记录程序的运行轨迹,在此之上会衍生出非常多的功能,例如线上监控、告警、运营分析、安全分析等等,这些功能反过来也对日志具备一定的要求,我们需要尽可能的将日志规范化,以减少收集、解析、分析的代价。

在Kubernetes中,环境的动态性很强,日志基本上都是易失的,因此需要实时将日志采集到中心的存储中,为了配合日志采集,对于日志的输出、采集会有更多的要求。

下述我们列举了Kubernetes中,日志输出的常见注意事项(其中标记 (*)的是Kubernetes中特有的项目):

  1. 如何选择日志等级
  2. 日志内容规范
  3. 合理控制日志输出量
  4. 选择多种日志输出目标
  5. 控制日志性能消耗
  6. 如何选择日志库
  7. 日志形态选择(*)
  8. 日志是否落盘以及落盘介质(*)
  9. 如何保证日志存储周期(*)

如何选择日志等级

日志等级是用来区分日志对应事件严重程度的说明,这是所有日志中必须具备的一个选项。通常日志会分为6个不同的等级:

  • FATAL(致命):用来输出非常严重或预期中不会发生的错误,遇到此种错误应当立即报警并人工介入处理。
  • ERROR (错误):非预期中的错误,此种错误可能导致部分系统异常但不会影响核心业务和系统正常运行。
  • WARN(警告):潜在的危险或值得关注的信息(比较核心的路径)。
  • INFO(信息):应用执行过程中的详细信息,一般通过该信息可以看到每个请求的主要执行过程。
  • DEBUG(调试):用于线下调试的日志信息,用于分析应用执行逻辑,线上应用切勿开启。
  • TRACE(跟踪):输出最细致的运行轨迹,可能包含涉及的数据内容。

作为程序员,一定要合理设置日志等级,个人在开发过程中总结以下几点经验:

  1. FATAL类型日志一定是非常严重的错误、需要人工处理的场景打印的。
  2. ERROR和WARNING的区别很多程序员难以选择,可以从告警角度考虑:ERROR一般需要告警,WARNING不需要。
  3. 日志等级一方面是为了能够表示日志的严重程度,另一方面也是为了控制应用程序的日志输出量,通常线上只能打开INFO或WARN的日志。
  4. DEBUG日志可以多打,方便分析问题。
  5. 所有用户请求日志,必须记录。
  6. 对于不确定的外部系统调用,日志需尽可能覆盖周全。
  7. 程序中的日志库需要具备运行期间变更日志等级的能力,方便在遇到问题需要分析时临时更改日志等级。
  8. 通常在新功能上线,涉及的日志可适当提升一个等级,方便实时观察和监控,待稳定后再调整到正常(记得加上注释,方便改回来)。

日志内容规范

通常在没有约束的情况下,程序员的发挥天马行空,各种日志内容都会出现,这些只有开发自己才能看懂的日志很难进行分析和告警。因此我们需要一个日志顶向下的规范来约束项目中的开发人员,让所有的日志看起来是一个人打印的而且是易于分析的。

日志的字段

日志中通常必备的字段有:Time、Level、Location。对于特定模块/流程/业务,还需要有一些Common的字段,例如:

  1. 如果使用Trace系统,可以把TraceID附加到日志中。
  2. 固定的流程需要附加对应的字段,例如订单的生命周期中,一定要有订单号、用户ID等信息,这些信息可以通过Context附加到对应流程的日志实例上。
  3. HTTP请求需要记录:URL、Method、Status、Latency、Inflow、OutFlow、ClientIP、UserAgent等,详情可以参考Nginx日志格式。
  4. 如果多个模块的日志都打印到同一个流/文件中,必须有字段标识模块名。

日志的字段规约最好由运维平台/中间件平台自顶向下推动,约束每个模块/流程的程序员按照规定打印日志。

日志表现形式

通常我们建议使用KeyValue对形式的日志格式,比如我们阿里的飞天日志库采用的就是这种形式:

1
[2019-12-30 21:45:30.611992]    [WARNING]       [958] [block_writer.cpp:671]  path:pangu://localcluster/index/3/prom/7/1577711464522767696_0_1577711517     min_time:1577712000000000       max_time:1577715600000000       normal_count:27595      config:prom     start_line:57315569     end_line:57343195       latency(ms):42  type:AddBlock

KeyValue对的日志可以完全自解析且易于理解,同时便于日志采集时自动解析。

另外推荐的是JSON日志格式,支持以JSON格式输出的日志库很多,而且大部分的日志采集Agent都支持JSON格式的日志收集。

1
{"addr":"tcp://0.0.0.0:10010","caller":"main.go:98","err":"listen tcp: address tcp://0.0.0.0:10010: too many colons in address","level":"error","msg":"Failed to listen","ts":"2019-03-08T10:02:47.469421Z"}

注意:绝大部分场景不建议使用非可读的日志格式(例如ProtoBuf、Binlog等)。

单条日志换行问题

非必要情况下,尽量不要一条日志输出成多行,这种对于采集、解析和索引的代价都比较高。

合理控制日志输出量

日志的输出量直接影响到磁盘使用以及对于应用的性能消耗,日志太多也不利于查看、采集、分析;日志太少不利于监控,同时在出现问题的时候没办法调查。一般线上应用需合理控制日志的数据量:

  1. 服务入口的请求和响应日志没有特殊原因都要输出并采集,采集的字段可以根据需求调整。
  2. 错误日志一般都要打印,如果太多,可以使用采样方式打印。
  3. 减少无效日志输出,尤其是循环中打印日志的情况需尽量减少。
  4. 请求型的日志(比如Ingress、Nginx访问日志)一般不超过5MB/s(500字节每条,不超过1W/s),应用程序日志不超过200KB/s(2KB每条,不超过100条/s)。

选择多种日志输出目标

建议一个应用不同类型的日志输出到不同的目标(文件),这样便于分类采集、查看和监控。例如:

  1. 访问日志单独放到一个文件,如果域名不多,可以按照一个域名一个文件的形式。
  2. 错误类的日志单独放一个文件,单独配置监控告警。
  3. 调用外部系统的日志单独放一个文件,便于后续对账、审计。
  4. 中间件通常都由统一的平台提供,日志一般单独打印一个文件。

控制日志性能消耗

日志作为业务系统的辅助模块,一定不能影响到业务正常的工作,因此日志模块的性能消耗需要单独额外注意,一般在选择/开发日志库时,需要对日志库进行性能测试,确保正常情况下日志的性能消耗不超过整体CPU占用的5%。

注意:一定要确保日志打印是异步的,不能阻塞业务系统运行。

如何选择日志库

开源的日志库非常多,基本每个语言都有数十种,选择一个符合公司/业务需求的日志库需要精挑细选,有一个简单的指导原则是尽可能使用比较流行的日志库的稳定版本,入坑的几率要小一点。例如:

  • Java 使用 Log4J、LogBack。
  • Golang 使用 go-kit。
  • Python默认集成的日志库大部分场景都够用,建议阅读一下CookBook。
  • C++ 推荐使用 spdlog,高性能、跨平台。

日志形态选择

在虚拟机/物理机的场景中,绝大部分应用都以文件的形式输出日志(只有一些系统应用输出到syslog/journal);而在容器场景中,多了一个标准输出的方式,应用把日志打到stdout或stderr上,日志会自动进入到docker的日志模块,可以通过 docker logs 或 kubectl logs 直接查看。

容器的标准输出只适应于比较单一的应用,例如K8s中的一些系统组件,线上的服务类应用通常都会涉及到多个层级(中间件)、和各种服务交互,一般日志都会分为好几类,如果全部打印到容器的标准输出,很难区分处理。

同时容器标准输出对于DockerEngine的性能消耗特别大,实测10W/s的日志量会额外占用DockerEngine 1个核心的CPU(单核100%)。

日志是否落盘以及落盘介质

在Kubernetes中,还可以将日志库直接对接日志系统,日志打印的时候不落盘而直接传输到日志系统后端。这种使用方式免去了日志落盘、Agent采集的过程,整体性能会高很多。这种方式我们一般只建议日志量极大的场景使用,普通情况下还是直接落盘,相比直接发送到后端的方式,落盘增加了一层文件缓存,在网络失败的情况下还能缓存一定的数据,在日志系统不可用的情况下我们的研发运维同学可以直接查看文件的日志,提高整体的可靠性。

Kubernetes提供了多种存储方式,一般在云上,都会提供本地存储、远程文件存储、对象存储等方式。由于日志写入的QPS很高,和应用也直接相关,如果使用远程类型的存储,会额外多2-3次网络通信开销。我们一般建议使用本地存储的方式,可以使用HostVolume或者EmptyDir的方式,这样对于写入和采集的性能影响会尽可能的小。

如何保证日志存储周期

相比传统虚拟机/物理机的场景,Kubernetes对于节点、应用层提供了强大的调度、容错、缩/扩容能力,我们通过Kubernetes很容易就能让应用获得高可靠运行、极致弹性。这些优势带来的一个现象是:节点动态创建/删除、容器动态创建/删除,这样日志也会随时销毁,没办法保证日志的存储周期能够满足DevOps、审计等相关的需求。

在动态的环境下实现日志的长期存储只能通过中心化的日志存储来实现,通过实时的日志采集方式,将各个节点、各个容器的日志在秒级内采集到日志中心系统上,即使节点/容器挂掉也能够通过日志还原当时的现场。

Kubernetes日志采集难点

在Kubernetes中,日志采集相比传统虚拟机、物理机方式要复杂很多,最根本的原因是Kubernetes把底层异常屏蔽,提供更加细粒度的资源调度,向上提供稳定、动态的环境。因此日志采集面对的是更加丰富、动态的环境,需要考虑的点也更加的多。

例如:

  1. 对于运行时间很短的Job类应用,从启动到停止只有几秒的时间,如何保证日志采集的实时性能够跟上而且数据不丢?
  2. K8s一般推荐使用大规格节点,每个节点可以运行10-100+的容器,如何在资源消耗尽可能低的情况下采集100+的容器?
  3. 在K8s中,应用都以yaml的方式部署,而日志采集还是以手工的配置文件形式为主,如何能够让日志采集以K8s的方式进行部署?
Kubernetes 传统方式
日志种类 文件、stdout、宿主机文件、journal 文件、journal
日志源 业务容器、系统组件、宿主机 业务、宿主机
采集方式 Agent(Sidecar、DaemonSet)、直写(DockerEngine、业务) Agent、直写
单机应用数 10-100 1-10
应用动态性
节点动态性
采集部署方式 手动、Yaml 手动、自定义

采集方式:主动 or 被动

日志的采集方式分为被动采集和主动推送两种,在K8s中,被动采集一般分为Sidecar和DaemonSet两种方式,主动推送有DockerEngine推送和业务直写两种方式。

  • DockerEngine本身具有LogDriver功能,可通过配置不同的LogDriver将容器的stdout通过DockerEngine写入到远端存储,以此达到日志采集的目的。这种方式的可定制化、灵活性、资源隔离性都很低,一般不建议在生产环境中使用。
  • 业务直写是在应用中集成日志采集的SDK,通过SDK直接将日志发送到服务端。这种方式省去了落盘采集的逻辑,也不需要额外部署Agent,对于系统的资源消耗最低,但由于业务和日志SDK强绑定,整体灵活性很低,一般只有日志量极大的场景中使用。
  • DaemonSet方式在每个node节点上只运行一个日志agent,采集这个节点上所有的日志。DaemonSet相对资源占用要小很多,但扩展性、租户隔离性受限,比较适用于功能单一或业务不是很多的集群。
  • Sidecar方式为每个POD单独部署日志agent,这个agent只负责一个业务应用的日志采集。Sidecar相对资源占用较多,但灵活性以及多租户隔离性较强,建议大型的K8S集群或作为PAAS平台为多个业务方服务的集群使用该方式。

总结下来:DockerEngine直写一般不推荐;业务直写推荐在日志量极大的场景中使用;DaemonSet一般在中小型集群中使用;Sidecar推荐在超大型的集群中使用。详细的各种采集方式对比如下:

DockerEngine 业务直写 DaemonSet方式 Sidecar方式
采集日志类型 标准输出 业务日志 标准输出+部分文件 文件
部署运维 低,原生支持 低,只需维护好配置文件即可 一般,需维护DaemonSet 较高,每个需要采集日志的POD都需要部署sidecar容器
日志分类存储 无法实现 业务独立配置 一般,可通过容器/路径等映射 每个POD可单独配置,灵活性高
多租户隔离 弱,日志直写会和业务逻辑竞争资源 一般,只能通过配置间隔离 强,通过容器进行隔离,可单独分配资源
支持集群规模 本地存储无限制,若使用syslog、fluentd会有单点限制 无限制 取决于配置数 无限制
资源占用 低,docker
engine提供 整体最低,省去采集开销 较低,每个节点运行一个容器 较高,每个POD运行一个容器 较高,每个POD运行一个容器
查询便捷性 低,只能grep原始日志 高,可根据业务特点进行定制 较高,可进行自定义的查询、统计 高,可根据业务特点进行定制
可定制性 高,可自由扩展 高,每个POD单独配置
耦合度 高,与DockerEngine强绑定,修改需要重启DockerEngine 高,采集模块修改/升级需要重新发布业务 低,Agent可独立升级 一般,默认采集Agent升级对应Sidecar业务也会重启(有一些扩展包可以支持Sidecar热升级)
适用场景 测试、POC等非生产场景 对性能要求极高的场景 日志分类明确、功能较单一的集群 大型、混合型、PAAS型集群

日志输出:Stdout or 文件

和虚拟机/物理机不同,K8s的容器提供标准输出和文件两种方式。在容器中,标准输出将日志直接输出到stdout或stderr,而DockerEngine接管stdout和stderr文件描述符,将日志接收后按照DockerEngine配置的LogDriver规则进行处理;日志打印到文件的方式和虚拟机/物理机基本类似,只是日志可以使用不同的存储方式,例如默认存储、EmptyDir、HostVolume、NFS等。

虽然使用Stdout打印日志是Docker官方推荐的方式,但大家需要注意这个推荐是基于容器只作为简单应用的场景,实际的业务场景中我们还是建议大家尽可能使用文件的方式,主要的原因有以下几点:

  1. Stdout性能问题,从应用输出stdout到服务端,中间会经过好几个流程(例如普遍使用的JSON LogDriver):应用stdout -> DockerEngine -> LogDriver -> 序列化成JSON -> 保存到文件 -> Agent采集文件 -> 解析JSON -> 上传服务端。整个流程相比文件的额外开销要多很多,在压测时,每秒10万行日志输出就会额外占用DockerEngine 1个CPU核。
  2. Stdout不支持分类,即所有的输出都混在一个流中,无法像文件一样分类输出,通常一个应用中有AccessLog、ErrorLog、InterfaceLog(调用外部接口的日志)、TraceLog等,而这些日志的格式、用途不一,如果混在同一个流中将很难采集和分析。
  3. Stdout只支持容器的主程序输出,如果是daemon/fork方式运行的程序将无法使用stdout。
  4. 文件的Dump方式支持各种策略,例如同步/异步写入、缓存大小、文件轮转策略、压缩策略、清除策略等,相对更加灵活。

因此我们建议线上应用使用文件的方式输出日志,Stdout只在功能单一的应用或一些K8s系统/运维组件中使用。

CICD集成:Logging Operator

Kubernetes提供了标准化的业务部署方式,可以通过yaml(K8s API)来声明路由规则、暴露服务、挂载存储、运行业务、定义缩扩容规则等,所以Kubernetes很容易和CICD系统集成。而日志采集也是运维监控过程中的重要部分,业务上线后的所有日志都要进行实时的收集。

原始的方式是在发布之后手动去部署日志采集的逻辑,这种方式需要手工干预,违背CICD自动化的宗旨;为了实现自动化,有人开始基于日志采集的API/SDK包装一个自动部署的服务,在发布后通过CICD的webhook触发调用,但这种方式的开发代价很高。

在Kubernetes中,日志最标准的集成方式是以一个新资源注册到Kubernetes系统中,以Operator(CRD)的方式来进行管理和维护。在这种方式下,CICD系统不需要额外的开发,只需在部署到Kubernetes系统时附加上日志相关的配置即可实现。

Kubernetes日志采集方案

早在Kubernetes出现之前,我们就开始为容器环境开发日志采集方案,随着K8s的逐渐稳定,我们开始将很多业务迁移到K8s平台上,因此也基于之前的基础专门开发了一套K8s上的日志采集方案。主要具备的功能有:

  1. 支持各类数据的实时采集,包括容器文件、容器Stdout、宿主机文件、Journal、Event等;
  2. 支持多种采集部署方式,包括DaemonSet、Sidecar、DockerEngine LogDriver等;
  3. 支持对日志数据进行富化,包括附加Namespace、Pod、Container、Image、Node等信息;
  4. 稳定、高可靠,基于阿里自研的Logtail采集Agent实现,目前全网已有几百万的部署实例;
  5. 基于CRD进行扩展,可使用Kubernetes部署发布的方式来部署日志采集规则,与CICD完美集成。

采集规则配置:环境变量 or CRD

除了在日志服务控制台上手动配置之外,对于Kubernetes还额外支持两种配置方式:环境变量和CRD。

环境变量是自swarm时代一直使用的配置方式,只需要在想要采集的容器环境变量上声明需要采集的数据地址即可,Logtail会自动将这些数据采集到服务端。这种方式部署简单,学习成本低,很容易上手;但能够支持的配置规则很少,很多高级配置(例如解析方式、过滤方式、黑白名单等)都不支持,而且这种声明的方式不支持修改/删除,每次修改其实都是创建1个新的采集配置,历史的采集配置需要手动清理,否则会造成资源浪费。

CRD配置方式是非常符合Kubernetes官方推荐的标准扩展方式,让采集配置以K8s资源的方式进行管理,通过向Kubernetes部署AliyunLogConfig这个特殊的CRD资源来声明需要采集的数据。例如下面的示例就是部署一个容器标准输出的采集,其中定义需要Stdout和Stderr都采集,并且排除环境变量中包含COLLEXT_STDOUT_FLAG:false的容器。

基于CRD的配置方式以Kubernetes标准扩展资源的方式进行管理,支持配置的增删改查完整语义,而且支持各种高级配置,是我们极其推荐的采集配置方式。

采集规则推荐的配置方式

实际应用场景中,一般都是使用DaemonSet或DaemonSet与Sidecar混用方式,DaemonSet的优势是资源利用率高,但有一个问题是DaemonSet的所有Logtail都共享全局配置,而单一的Logtail有配置支撑的上限,因此无法支撑应用数比较多的集群。

上述是我们给出的推荐配置方式,核心的思想是:

  1. 一个配置尽可能多的采集同类数据,减少配置数,降低DaemonSet压力;
  2. 核心的应用采集要给予充分的资源,可以使用Sidecar方式;
  3. 配置方式尽可能使用CRD方式;
  4. Sidecar由于每个Logtail是单独的配置,所以没有配置数的限制,这种比较适合于超大型的集群使用。

实践1-中小型集群

绝大部分Kubernetes集群都属于中小型的,对于中小型没有明确的定义,一般应用数在500以内,节点规模1000以内,没有职能明确的Kubernetes平台运维。这种场景应用数不会特别多,DaemonSet可以支撑所有的采集配置:

  1. 绝大部分业务应用的数据使用DaemonSet采集方式
  2. 核心应用(对于采集可靠性要求比较高,例如订单/交易系统)使用Sidecar方式单独采集

实践2-大型集群

对于一些用作PAAS平台的大型/超大型集群,一般业务在1000以上,节点规模也在1000以上,有专门的Kubernetes平台运维人员。这种场景下应用数没有限制,DaemonSet无法支持,因此必须使用Sidecar方式,整体规划如下:

  1. Kubernetes平台本身的系统组件日志、内核日志相对种类固定,这部分日志使用DaemonSet采集,主要为平台的运维人员提供服务;
  2. 各个业务的日志使用Sidecar方式采集,每个业务可以独立设置Sidecar的采集目的地址,为业务的DevOps人员提供足够的灵活性。

emptyDir收集日志

在阿里云环境中,将pod中的日志推到日志服务SLS中,按照官方文档,他会推荐你使用sidecar方式将logtail跑到pod中,然后业务容器和logtail共享同一个卷,然后logtail实现日志采集,具体配置参考官方文档即可,我这里说一个官方文档比较坑的点,就是在配置过程中,官方文档使用emptyDir 类型的卷来和logtail容器进行共享,我们业务日志打到这个目录中:

默认emptyDir类型的卷是使用tmpfs类型来挂载,而tmpfs占用的是内存,如果日志一大,容器又配置了limit,就会造成 OOM,而且就算没有配置pod的limit,占用内存来存放日志也是很不合理的,所以赶紧将卷类型改为hostPath类型.

hostPath收集日志

还有一种方式,就是将容器内的日志目录挂载在宿主机上,这样我们每个宿主机只需要安装一个日志收集组件,并监控宿主机上的日志文件就可以了。当然,我们可以使用Daemonset 的方式部署日志收集组件,保证集群内每个节点都会有一个组件,并且也不需要额外的监控。

这种方式也不是没有任何问题,这里有个小坑,当节点数量小于副本集数量时,也就是说在同一个节点上会有一个pod 的多个副本在运行,那么此时app 日志会写入到宿主机上同一个文件内。如果需要为不同的pod 建立不同的文件夹要怎么做呢?

这里有一个小技巧,跟大家分享,我们可以使用 软链接 + PODNAME 的方式。使用shell 脚本为每个日志文件目录建立一个以PODNAME命名的软链接目录,宿主机只需要监控这个软链接目录就可以实现不同pod 日志写入不同的目录内。

完整示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
apiVersion: apps/v1
kind: Deployment
metadata:
  name: example
  namespace: mynamespace
  labels:
    app: example
spec:
  replicas: 2
  selector:
    matchLabels:
      app: example
  template:
    metadata:
      labels:
        app: example
    spec:
      containers:
        - name: example
          image: abc
          command:
            [
              "/bin/sh",
              "-c",
              "mkdir -p /data/logs/${MY_POD_NAME} /app/ && ln -s /data/logs/${MY_POD_NAME} /app/logs && /app/example --log=/app/logs/example.log",
            ]
          env:
            - name: MY_POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
      volumes:
        - name: logdata
          hostPath:
            path: /data/logs
            type: DirectoryOrCreate

参考

系列文章:Kubernetes中日志的正确输出姿势

系列文章:Kubernetes日志采集最佳实践

将pod中的日志推到阿里云的SLS中遇到的一些坑

k8s使用hostPath Volume 收集容器app 的日志