由于容器并不能持久的保存数据,而日志又是判断程序运行的状态绝对重要的因素,因此日志收集是 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 的方案,如果能够帮到你,将是这篇文章最大的意义。
参考资料
构建最小 tomcat docker 镜像: https://juejin.im/post/5cf2320af265da1b8b2b462f




