暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

k8s 日志收集

陈顺吉 2020-07-11
494

由于容器并不能持久的保存数据,而日志又是判断程序运行的状态绝对重要的因素,因此日志收集是 k8s 落地必须要解决的问题。官方也考虑到了这一点,于是推荐使用 efk(Elasticsearch、Fluentd、kibana)这样的解决方案。

它要求容器将日志以 stdout 的方式输出,而不输出到文件中,这样日志就会临时保存在宿主机的 /var/lib/containerd/
(我应该没记错吧?)。此时只需要在宿主机上跑一个容器,将这个目录映射进容器中,该容器就可以收集所有容器的日志了。

这个容器就是 Fluentd,它将收集的日志发送到 Elasticsearch,然后使用 kibana 连接 Elasticsearch,并将里面的内容展示出来。当然,无法否认,这是个解决方案。但是玩玩可以,真要使用还是算了,它存在如下问题:

  • 它会将所有容器的日志都发送到一个 Elasticsearch 的索引中,这样无法对日志进行分类;
  • 某一时刻日志量特别大的时候,Elasticsearch 可能会写不过来(当然日志量大也不会使用这种方案);
  • 多种日志格式都混在一起,无法对日志进行多行处理。对于 java 报错一条几十行的日志,你就无法将其合并为一行。

虽然 Elasticsearch 的 ingest 节点可以对日志进行处理,但是无法解决上面的问题。

在讨论解决方案之前,需要说明的是,本文基于 elk。不过说实话,使用 elasticsearch 存日志有点大炮打蚊子,大材小用的感觉,浪费资源的同时搜索也不友好。我其实是想用 Loki 的,但是还没有来得及研究。不过本文讨论的方案并不强依赖 elk。

本文会以 tomcat 日志收集为例,它会输出三种日志:tomcat 自身、应用日志和 gc 日志,很有代表性。我会在每个 tomcat pod 中专门跑一个 filebeat 容器,不然无法准确区分这三种日志。如果不专门跑一个 filebeat 容器,很多动作你都做不了。

前提都已经确定,现在就是要讨论日志如何输出的问题,我有两种方案:

  • 第一种是挂载 emptyDir,让 tomcat 将日志直接写入到内存,然后使用 filebeat 收集这个目录下的日志。它的好处是不会使用任何磁盘,首选方案,它会有一些注意事项,后面会提到;
  • 将宿主机的目录映射到容器中让容器写,然后 filebeat 收集。备选方案,也有注意事项。

当然,也有将应用日志直接写入到 kafka 的,但这不属于常规方案。起码 gc 日志和 tomcat 本身的日志你总不能写入到 kafka 吧,你无法控制;而且写 kafka 延迟是否会有影响也是需要考虑的问题。不过如果可以把控其中的风险的话,我觉得这种方案更好。

当然直接写 kafka 不在本文讨论的范围内,我们先来看看首选方案。

写内存

当你使用 emptyDir 的时候,可以将 emptyDir.medium
设置为 Memory
,它会将宿主机当前的一半内存作为容量挂载到容器中。那是不是意味着容器可能会将这一半的内存直接写满呢?不一定。

因为你写入内存的大小都会计算到容器的 limits 中,超过 limits pod 会被 oomkill。因此你一定要确保你写入的日志不能太大,不然 pod 会被撑爆。

你可以测试一把:

# vim busybox.yml
apiVersion: v1
kind: Pod
metadata:
  name: busybox
spec:
  containers:
    - name: busybox
      image: busybox
      command:
      - cat
      tty: true
      resources:
        limits:
          memory: 100Mi
      volumeMounts:
        - name: log-dir
          mountPath: /logs
  volumes:
    - name: log-dir
      emptyDir:
        medium: Memory

# kubectl apply -f busybox.yml
# kubectl exec -it busybox -- /bin/sh
# 可以看到宿主机一半内存挂载到了 /logs 目录
# df -h
# 写个 200M
# dd if=/dev/urandom of=/logs/largefile bs=10M count=20
Killed

因为当前 pod 中主进程 cat 消耗的内存少于 dd,所以操作系统只是杀掉了 dd 命令。而因为 cat 这个主进程没别杀,所以容器还在运行。

那么现在的问题是如何减少日志的输出量。

减少日志输出

tomcat 三种日志中 gc 和 tomcat 自身的日志输出的最少的。可能很多人会说 catalina.out 是最大的,那是因为应用日志开启了 stdout 输出,并且在容器环境不存在这样的问题,因为 stdout 会被 docker 接管,要使用 docker logs 命令来获取,tomcat 获取不到。

正常情况下,tomcat 输出的日志确实不大,但是不确保因为各种各样的问题导致它输出日志过多。你可以通过修改它的日志配置文件,将其日志最大只保留一天(8.0 版本的 tomcat 支持使用 log4j 来代替默认的日志输出框架,但是 8.5 和 9.0 官方不支持),同时还可以将其多种日志都输出到同一个文件中。

以下截取 conf/logging.properties
部分配置:

# 你甚至可以将下面两个 java.util.logging.ConsoleHandler 都去掉,以防万一,这是是否收集 stdout 的
handlers = 1catalina.org.apache.juli.AsyncFileHandler, 2localhost.org.apache.juli.AsyncFileHandler, 3manager.org.apache.juli.AsyncFileHandler, 4host-manager.org.apache.juli.AsyncFileHandler, java.util.logging.ConsoleHandler

.handlers = 1catalina.org.apache.juli.AsyncFileHandler, java.util.logging.ConsoleHandler

############################################################
# Handler specific properties.
# Describes specific configuration info for Handlers.
############################################################

1catalina.org.apache.juli.AsyncFileHandler.level = FINE
1catalina.org.apache.juli.AsyncFileHandler.directory = ${catalina.base}/logs
# 以下几个 prefix 改为一样就输出到同一个日志文件中了,便于收集
1catalina.org.apache.juli.AsyncFileHandler.prefix = catalina.
# 最大保存期限为一天
1catalina.org.apache.juli.AsyncFileHandler.maxDays = 1
1catalina.org.apache.juli.AsyncFileHandler.encoding = UTF-8

2localhost.org.apache.juli.AsyncFileHandler.level = FINE
2localhost.org.apache.juli.AsyncFileHandler.directory = ${catalina.base}/logs
# 同上
2localhost.org.apache.juli.AsyncFileHandler.prefix = catalina.
# 同上
2localhost.org.apache.juli.AsyncFileHandler.maxDays = 1
2localhost.org.apache.juli.AsyncFileHandler.encoding = UTF-8

3manager.org.apache.juli.AsyncFileHandler.level = FINE
3manager.org.apache.juli.AsyncFileHandler.directory = ${catalina.base}/logs
# 同上
3manager.org.apache.juli.AsyncFileHandler.prefix = catalina.
# 同上
3manager.org.apache.juli.AsyncFileHandler.maxDays = 1
3manager.org.apache.juli.AsyncFileHandler.encoding = UTF-8

4host-manager.org.apache.juli.AsyncFileHandler.level = FINE
4host-manager.org.apache.juli.AsyncFileHandler.directory = ${catalina.base}/logs
# 同上
4host-manager.org.apache.juli.AsyncFileHandler.prefix = catalina.
# 同上
4host-manager.org.apache.juli.AsyncFileHandler.maxDays = 1
4host-manager.org.apache.juli.AsyncFileHandler.encoding = UTF-8

java.util.logging.ConsoleHandler.level = FINE
java.util.logging.ConsoleHandler.formatter = org.apache.juli.OneLineFormatter
java.util.logging.ConsoleHandler.encoding = UTF-8

如果一天的写入量还是非常大的话,那可能要想想其他办法或者使用备用方案了。当然这种可能性比较小,日志输出的大头还是在应用本身。

应用自身的日志输出要看开发使用啥日志框架了。java 的日志框架非常完善,无论是 logback 还是 log4j 都支持设置日志文件大小以及保存日志文件的个数,通过它们可以很容易的控制日志输出的大小。比如 logback 可以这么指定:

      <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${log_dir}/${projectName}.log</file>
        <triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
        <!-- 20M 一个日志文件 -->
          <maxFileSize>20MB</maxFileSize>
        </triggeringPolicy>
        <rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
          <fileNamePattern>${log_dir}/${projectName}.%i.log</fileNamePattern>
          <minIndex>1</minIndex>
          <!-- 同时最多只会存在两个日志文件 -->
          <maxIndex>1</maxIndex>
        </rollingPolicy>
        <encoder>
          <pattern>%-5relative %-5level %logger{35} - %msg%n</pattern>
        </encoder>
      </appender>

如果你觉得 20M 太大,你可以继续调小,问题不大。这样应用日志的输出大小就控制住了,剩下的 gc 日志我认为没什么大问题,总不会能输出几 G 吧。。这样一来,将 tomcat 输出的三种日志都写入到内存问题就不大了。写内存不仅性能好,而且还没有日志清理这样的问题,可以说是很理想的方式了。

我们接下来只需要将它们三者的日志输出目录设为一样,然后就可以将 emptyDir 挂载到这个目录下了,我这里的目录选择为 /usr/local/tomcat/logs
。你不用考虑挂载后 tomcat 有没有权限写入的问题,内存挂载目录的权限的 777,任何用户都能写。

把应用日志输出搞定之后,接下来就需要使用 filebeat 收集了。

filebeat 配置文件

elastic 官方提供了 filebeat 镜像,但是大小感人,足足 460M。filebeat 是 go 写的,对系统可以说几乎没有任何依赖,完全可以将镜像做到和它本体大小(90M)差不多的程度。

这里推荐我之前写的文章:构建最小 tomcat docker 镜像[1]。通过其中的方法构建的镜像大小为 115M,并且该有的命令都有。我已经将它上传到了 dockerhub,你可以通过 docker pull maxadd/filebeat:7.8.0
拿来使用。

然后就是配置文件了,我这里有份大概的配置文件:

filebeat.inputs:
- type: log
  enabled: true
  paths:
    - /logs/app.log
  fields:
    module: ${MODULE}
    ip: ${IP}
    type: app
- type: log
  enabled: true
  paths:
    - /logs/catalina.*.log
  multiline.pattern: '^[0-3]\d-'
  multiline.negate: true
  multiline.match: after
  fields:
    module: ${MODULE}
    ip: ${IP}
    type: tomcat-self
- type: log
  enabled: true
  paths:
    - /logs/gc.log
  multiline.pattern: '^20\d\d-'
  multiline.negate: true
  multiline.match: after
  fields:
    module: ${MODULE}
    ip: ${IP}
    type: tomcat-gc

output.kafka:
  hosts: ["192.168.1.56:9092", "192.168.1.57:9092", "192.168.1.58:9092"]
  topic: "tomcat"
  codec.format:
    string: '%{[fields.ip]} %{[fields.module]} %{[fields.type]} %{[message]}'
  keep_alive: 3

有几点需要说明:

  • 因为我不清楚你的应用日志输出格式,这里就没有做多行处理了;
  • 要将 emptyDir 挂载到 filebeat 容器的 /logs 目录;
  • 通过自定义的 type 来区分日志分类型。如果你有其他的字段需要添加,可以直接加到 fields
    下面;
  • 当日志量很大的时候,还是要写入到 kafka。当然如果你量不大的话,可以不写。

你最好将这个文件做成 configmap,将其挂载到 /filebeat/filebeat.yml
(要使用 subPath)。

资源清单文件

思路捋清楚之后,实现起来就很简单了。这里提供一份大差不差的清单文件,之前所有的描述都体现在该文件中:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: "payment-main"
  labels:
    module: "payment-main"
    jmx: tomcat
  namespace: business
spec:
  replicas: 1
  selector:
    matchLabels:
      module: "payment-main"
  template:
    metadata:
      labels:
        module: "payment-main"
    spec:
      containers:
        - name: "payment-main"
          image: registry-test.x.com/payment-main:1
          resources:
            limits:
              memory: 2000Mi
            requests:
              memory: 1400Mi
          env:
            - name: JAVA_OPTS
              value: "-Xmx1400m -Xms1400m -javaagent:/usr/local/jmx_prometheus_javaagent-0.13.0.jar=12356:/usr/local/config.yml"
          volumeMounts:
            - name: logback
              mountPath: /conf/logback.xml
              subPath: logback.xml
              readOnly: true
            - name: tomcat-log-conf
              mountPath: /usr/local/tomcat/conf/logging.properties
              subPath: logging.properties
              readOnly: true
            - name: log-dir
              mountPath: /usr/local/tomcat/logs
              readOnly: false
          readinessProbe:
            httpGet:
              path: /health-check
              port: 8099
            initialDelaySeconds: 60
            timeoutSeconds: 3
            failureThreshold: 30
            periodSeconds: 3
          securityContext:
            runAsUser: 99
            runAsGroup: 99
        - name: filebeat
          image: maxadd/filebeat:7.8.0
          env:
            - name: MODULE
              value: payment-main
            - name: IP
              valueFrom:
                fieldRef:
                  fieldPath: status.podIP
          resources:
            limits:
              memory: 100Mi
              cpu: 500m
            requests:
              memory: 50Mi
          volumeMounts:
            - name: filebeat
              mountPath: /filebeat/filebeat.yml
              subPath: filebeat.yml
              readOnly: true
            - name: log-dir
              mountPath: /logs
          securityContext:
            runAsUser: 99
            runAsGroup: 99
      volumes:
        - name: tomcat-log-conf
          configMap:
            name: tomcat-log-conf
        - name: logback
          configMap:
            name: logback
        - name: log-dir
          emptyDir:
            medium: Memory
        - name: filebeat
          configMap:
            name: filebeat

第一种方案的内容就是这么多了,细节方面你可以需要打磨打磨,但是整个流程不会有什么问题。你可能还需要使用 logstash 处理下日志内容后输出到 elasticsearch,这里就不多提了。

写宿主机磁盘

写宿主机磁盘是个备选方案,当你无法控制你的日志输出大小的时候可以考虑。如果你使用它,你得确保你宿主机磁盘性能 OK,容量够大。而且你还要定时清理宿主机的日志文件,推荐清理日志文件前使用 lsof
命令判断文件的描述符有没有被进程持有,如果有,清空;没有则删除。

本方案需要 k8s 1.14+,1.15 以上更稳,因为它需要使用 pod.spec.containers.volumeMounts.subPathExpr
这个特性。你可以使用 kubectl explain 看看它的作用。

# kubectl explain pod.spec.containers.volumeMounts.subPathExpr
KIND:     Pod
VERSION:  v1

FIELD:    subPathExpr <string>

DESCRIPTION:
     Expanded path within the volume from which the container's volume should be
     mounted. Behaves similarly to SubPath but environment variable references
     $(VAR_NAME) are expanded using the container'
s environment. Defaults to ""
     (volume's root). SubPathExpr and SubPath are mutually exclusive. This field
     is alpha in 1.14.

简而言之就是使用它挂载之前,它会在挂载的源目录下创建一个目录(这个目录名可以是变量),然后将这个创建后的目录挂载到容器中。它的意思其实和 subPath 一样,只不过多了个可以使用变量的功能。

为什么使用它呢?因为当使用 deployment 多副本部署应用时,如果其中有两个 pod 都部署到同一个宿主机上,当我们挂载宿主机的 /logs 到容器中,就会出现这两个 pod 都会写这个目录下的相同日志文件,这样日志文件就错乱了。

而使用这个挂载参数,我们就可以将 pod 的 uid 作为目录名。那么在挂载 /logs 之前,它会先在该目录下创建一个以 pod uid 为名的目录,然后将这个目录挂载到容器中。这就避免了上面的问题了。

apiVersion: v1
kind: Pod
metadata:
  name: uuid-test
spec:
  containers:
    - name: uuid-tests
      image: busybox
      command:
        - cat
      tty: true
      env:
        # 先定义一个环境变量,环境变量的值就是 pod 的 uid
        - name: UUID
          valueFrom:
            fieldRef:
              apiVersion: v1
              fieldPath: metadata.uid
      volumeMounts:
        - name: log
          mountPath: /logs
          # 引用定义的环境变量
          subPathExpr: $(UUID)
  volumes:
    - name: log
      hostPath:
        path: /opt

创建该 pod 之后,你就可以在它所在宿主机的 /opt 目录下看到一个 uuid 为名的目录。你在容器的 /logs 目录下 touch 一个文件,此文件就会出现在该 uuid 目录下。

需要注意的是,当你使用非 root 运行该 pod 时,由于挂载目录的属主属组是 root,会出现 tomcat 无法写入日志的情况。这种情况可以通过添加 initContainer 来解决:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: uuid-test
spec:
  replicas: 2
  selector:
    matchLabels:
      app: test
  template:
    metadata:
      labels:
        app: test
    spec:
      initContainers:
      - name: volume-mount-hack
        image: busybox
        # 通过 init 容器改变下权限就行
        command: ["sh", "-c", "chown -R 99:99 /logs"]
        env:
        - name: UUID
          valueFrom:
            fieldRef:
              apiVersion: v1
              fieldPath: metadata.uid
        volumeMounts:
        - name: log
          mountPath: /logs
          subPathExpr: $(UUID)
      containers:
      - name: busybox
        image: busybox
        command:
        - cat
        tty: true
        env:
        - name: UUID
          valueFrom:
            fieldRef:
              apiVersion: v1
              fieldPath: metadata.uid
        volumeMounts:
        - name: log
          mountPath: /logs
          subPathExpr: $(UUID)
        securityContext:
          runAsUser: 99
          runAsGroup: 99
      volumes:
        - name: log
          hostPath:
            path: /opt

接下来的配置就和上面的写内存差不多了,这就不重复了。两种方案写下来,我的感受是能使用第一种就使用第一种,毕竟写内存优势太明显。之所以写第二种,是因为之前研究过它的用法,不写下感觉白研究了,有点可惜。

不过呢,因为每个公司 k8s 使用方式不同,应用的运行部署方式也不同,所以很难有统一的方案。我这里也只是提供一种我公司运行还 ok 的方案,如果能够帮到你,将是这篇文章最大的意义。

参考资料

[1]

构建最小 tomcat docker 镜像: https://juejin.im/post/5cf2320af265da1b8b2b462f


文章转载自陈顺吉,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论