本文介绍了 Kubernetes 中应用配置文件管理的最佳实践,并介绍了一些避免开发人员动手的配置文件处理技巧。
本文是作者多个 Kubernetes 改造项目经验的总结。希望通过本文可以让开发了解运维中配置文件管理需要考虑的问题,以及 Kubernetes 的实现方法,也能让运维了解 Java 应用的配置文件处理方式。
12 Factor 指的是部署到 PAAS 的应用应该具备的 12 个要素,最早由 PAAS 的先驱 Heroku 推出,现在已经被奉为云原生应用的经典。如果应用符合这 12 factor 的要求,可以起到非常好的效果。其中有几条是配置文件相关的。
将配置保存到“环境”中,而不是代码、属性文件、构建或应用服务器。
笔者确实遇到了以上几种情况。用代码保存配置显然不合适,不能每次配置变化时都去修改代码吧?确实有人将配置存放到 Jenkins 上,这样做的隐含意思就是如果要更改配置,需要重新构建应用,也不合适;许多 Spring 应用将不同环境的配置保存在不同的配置文件中,姑且不说添加环境可能也需要重新编译,让开发知道生产的配置也不是一个好的实践;将配置存放在应用服务器确实是以前常见的做法,但我的自动化运维经验告诉我,这样并不直观,也不利于自动化。
12 factor 中的环境特指“环境变量”,这在 PAAS 时代是最简单的方式。而在 Kubernetes 中,推荐使用 ConfigMap 来管理配置,此时”环境“就是指的 Kubernetes ,更具体的就是 ConfigMap 。然后 Kubernetes 能够将 ConfigMap 的内容注入到应用的容器中。如果注入的内容比较简单,可以以环境变量的方式注入;如果注入的参数较多,可以将 ConfigMap 的内容变成文件,在应用运行时由 Kubernetes 注入到容器中文件系统中,应用可以按照读普通文件的方式读取,下面是一个 ConfigMap 的例子,我们首先定义一个 ConfigMap:
apiVersion: v1
kind: ConfigMap
metadata:
name: myconfigmap
data:
application.yml: |-
spring:
datasource:
name: test #数据库名
url: jdbc:mysql://localhost:3306/test #url
username: root #用户名
password: 123456 #密码
driver-class-name: com.mysql.jdbc.Driver #数据库链接驱动
可以看到我们定义了一个名为 myconfigmap
的 ConfigMap,其中有一个 key 为 application.yml 的元素,其内容就是一个 Spring Boot 的配置文件。然后,我们可以在 Pod 中引用:
apiVersion: v1
kind: Pod
metadata:
name: mypod
spec:
containers:
- name: mypod
image: openjdk:jre-alpine
volumeMounts:
- name: app-env-config
mountPath: /apps/application.yml
subPath: application.yml
volumes:
- name: app-env-config
configMap:
name: myconfigmap
可以看到上述配置将 ConfigMap 中 application.yml 的内容存放到了容器的 /apps/application.yml
,应用可以按照普通文件方式读取 。
这是 12 factor 中另一项非常重要的因素,含义是基准代码转化为一份部署需要以下三个阶段:
- 构建( Build )阶段 是指将代码仓库转化为可执行包的过程。构建时会使用指定版本的代码,获取和打包 依赖项,编译成二进制文件和资源文件。
- 发布( Release )阶段 会将构建的结果和当前部署所需配置相结合,并能够立刻在运行环境中投入使用。
- 运行( Run )阶段 (或者说“运行时”)是指针对选定的发布版本,在执行环境中启动一系列应用程序 进程。
许多组织在搞CI/CD时会将这三个步骤揉在一起,美其名曰"更加自动化",其实这个因素关注的不是说这三个步骤能不能串在一起,而是说能不能拆分分别执行。如果不能拆分,可能会有几个问题:
- 在不同的环境发布应用时还需要重新构建,耽误时间。
- 不同环境构建的应用可能不同,可能会有未经测试的问题。
- 因为没有对二进制文件与配置进行“发布(确定唯一版本号)”,所以回退或切换版本存在困难。
- 缺少发布阶段,导致我们无法预先对变更进行更清晰的可视化,需要在运行前进行配置修改,一方面增加了版本更新时间,另一方面也会增加出错的可能性。
三个步骤的拆分解决了上述问题,如果使用 Kubernetes 三个步骤可以采用以下方式进行拆分:
- 构建( Build )阶段 – 构建镜像。
- 发布( Release )阶段 – 修订Helm Chart(包含镜像与配置)并打版本。
- 运行( Run )阶段 – 针对 Helm Chart 按照打的版本执行 helm upgrade 。
如果我们采用 ArgoCD 以 GitOps 的方式,那么会变成:
- 发布( Release )阶段 – 修订 ArgoCD 的 Application 定义(包含镜像以及配置)。
- 运行( Run )阶段 – 在 ArgoCD 界面上执行运行。
Helm Chart 和 ArgoCD 的篇幅太长,以后有机会再单独拿出来分享。但无论是哪个工具,一般也是利用 ConfigMap 来实现的配置文件管理。
这是一个比较广泛的原则,即希望我们的开发环境与生产环境要尽可能的一致,从而避免环境差异造成的问题。在配置文件层面,如果不同环境的配置参数的条目相近,但是值差别很大,可以考虑将配置文件的这些差异做成 Helm Chart 的变量。
就像开发领域出现了许多不同编程语言的微服务框架一样,程序员们也创造了许多配置管理工具。例如 Nacos, ZooKeeper, Consul, Apollo 等。这些软件确实解决了大型组织中开发人员的配置管理问题,但是同微服务框架一样,当这些软件与 Kubernetes 配合使用时,可能需要做一些调整。
相对于 Kubernetes 的 ConfigMap 极其衍生工具的方案,这类配置管理工具的有一些不足:
- 本地开发:使用这种配置管理工具时,即使是开发一个简单的应用,也需要提前部署好配置管理服务。如果是 ConfigMap 的方案,程序员本地开发时还可以继续使用文件,而在 Kubernetes 环境中,程序可以读到我们用 ConfigMap 配置的文件。
- 应用发布:通过前面所说的区分“构建、发布和运行三个阶段”,我们可以实现软件版本和配置文件的绑定,从而可以实现更高效的版本切换。如果使用外部的配置管理工具,可能需要设计某个手段实现软件版本更新与配置更新的联动。
- 配置变更生效:如果配置管理工具的配置发生变更,如果应用设置成自动刷新配置,可以实现不停服务的更新。使用配置文件也能达到类似的效果。应用也可以监听配置文件,如果 ConfigMap 的配置变更,会触发这个 ConfigMap 对应文件的变更,从而引发不停机的服务更新。而且,更好的一点是,如果应用做不到自动更新,我们可以通过一些手段,在 ConfigMap 发生变更时自动触发服务的重启,从而使配置自动生效。
因此,如果应用如果还在使用配置文件,这不是坏事,通过 ConfigMap 我们能够实现类似的能力,而且有可能更好用。
又到了开发和运维部门调解时间。前面的探针功能强烈依赖开发的支持,如果开发提供的探针接口不对,会让探测效果大打折扣。不过配置文件相对好一点,即使缺乏开发的配合,运维也能通过一些手段实现许多目标。
在我带过的传统架构转 Kubernetes 的项目中,大多数开发部门的应用还是比较规范的,往往微服务或应用都使用标准的配置文件。而且开发团队的领导也能从整体上分析问题,尝试从框架上做一些统一的调整,所以在 Kubernetes 层面,我们只需要做一些常规的配置即可。
不过,也确实有一些应用团队,可能是缺乏统一的设计,配置文件出现了百花齐放的局面,一个应用有几个、甚至几十个配置文件的情况。然后又可能是因为开发缺乏强有力的技术领导者或动力,所以不能很好的配合配置文件的改造,所以我们便只在 kubernetes 层面进行调整,力争实现前面说的“配置文件的方法论”的需求。以下就是几个案例。
Spring Boot 本身就包含了对配置文件的支持,包括了如何将配置文件外化,如果应用很乖巧的只需要一个配置文件,我们可以使用环境变量 SPRING_CONFIG_LOCATION
来指定配置文件,也可以添加命令行启动参数 --spring.config.location=<configfile_path>
,下面是一个实际的例子:
apiVersion: v1
kind: Pod
metadata:
name: mypod
spec:
containers:
- name: mypod
image: openjdk:jre-alpine
env:
- name: SPRING_CONFIG_LOCATION
value: file:///apps/application.yml
volumeMounts:
- name: app-env-config
mountPath: /apps/application.yml
subPath: application.yml
readOnly: true
volumes:
- name: app-env-config
configMap:
name: myconfigmap
其中 application.yml
的内容,已经通过 myconfigmap
这个 ConfigMap 部署到了 Kubernetes。
Spring Cloud 会根据 bootstrap.yml 的内容加载配置,我们可以通过 --spring.cloud.bootstarp.location=<configfile_path>
指定:
apiVersion: v1
kind: Pod
metadata:
name: mypod
spec:
containers:
- name: mypod
image: openjdk:jre-alpine
command: ["/usr/bin/java","-Xms512M","-Xmx512M","-jar","app.jar","--spring.cloud.bootstrap.location=/apps/bootstrap.yml"]
volumeMounts:
- name: app-env-config
mountPath: /apps/application.yml
subPath: application.yml
readOnly: true
- name: app-env-config
mountPath: /apps/bootstrap.yml
subPath: bootstrap.yml
readOnly: true
volumes:
- name: app-env-config
configMap:
name: myconfigmap
我们遇到的一个部署在 Tomcat 中的应用,它需要从 ClassPath 中读取一些配置,所以我们尝试通过 ConfigMap 中包含一份修改后的 Tomcat 的配置文件,使之能在指定的路径加载我们的应用配置文件,这个应用配置文件也是通过 ConfigMap 注入的。首先,我们需要准备一个修改过的 catalina.properties
配置文件,其中修改的行是:
···
shared.loader=/usr/local/tomcat/addcp/
···
然后,我们使用 ConfigMap 资源包含 catalina.properties
文件:
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ template "log4j2.fullname" . }}-env-config
data:
log4j2.xml: |-
{{ tpl (.Files.Get .Values.log4j2.configFile) . | indent 4 }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}-catalina-properties
data:
catalina.properties: |-
{{ tpl (.Files.Get .Values.tomcat.catalinaFile) . | indent 4 }}
需要注意的是,上面的 ConfigMap 是用 Helm Chart 模板的语法。 通过 .Files.Get
方法,我们可以加载文件系统里的 catalina.properties
文件,使之成为 ConfigMap 的一部分,然后通过 Helm 发布到 Kubernetes 里。
然后,我们在 Pod 模板的定义里,加载这些文件:
volumeMounts:
- name: env-config
mountPath: /apps/application.yml
subPath: application.yml
- name: log4j2-config
mountPath: /apps/log4j2.xml
subPath: log4j2.xml
- name: storage-pvc
mountPath: "/attachment"
- name: catalina-properties
mountPath: /usr/local/tomcat/conf/catalina.properties
subPath: catalina.properties
- name: setting-config
mountPath: /usr/local/tomcat/addcp/config.setting
subPath: config.setting
可以看到我们定义的 catalina.properties
挂载到了 Tomcat 目录 。另外定义了一个名为 setting-config 的 ConfigMap,将 config.setting 挂载到了 /usr/local/tomcat/addcp/
目录。这样,Tomcat 启动时就会将 /usr/local/tomcat/addcp/
视为 ClassPath,从而应用能够读到这个配置文件。
可执行 Jar 包不能指定 ClassPath,所以我们想到的一个办法就是将配置文件动态的保存到 Jar 包里。首先,我们需要准备一个 repackage.sh
脚本:
#!/bin/bash
mkdir -p ./BOOT-INF/classes/properties/
cp lib/config.setting ./BOOT-INF/classes/properties/
jar uf app.jar BOOT-INF/classes/properties/config.setting
然后我们通过 ConfigMap 将 repackage.sh
和 config.setting
挂载到容器中对应的目录,然后我们需要调整容器的启动命令:
apiVersion: v1
kind: Pod
metadata:
name: mypod
labels:
apps: jar
spec:
containers:
- name: mypod
image: openjdk:jre-alpine
command: ["/bin/sh","-c"]
args: ["sh lib/repackage.sh; /usr/bin/java -Xms512M -Xmx512M -jar app.jar --spring.cloud.bootstrap.location=/apps/lib/bootstrap.properties"]
volumeMounts:
- name: setting-config
mountPath: /apps/lib/config.setting
subPath: config.setting
- name: repackage-shell
mountPath: /apps/lib/repackage.sh
subPath: repackage.sh
volumes:
- name: setting-config
configMap:
name: my-pod-setting-config
- name: repackage-shell
configMap:
name: my-pod-repackage-shell
可以看到容易启动时会首先设法将配置文件注入到可执行 Jar 包中,然后再执行 Jar 包,实现了我们运行时指定配置文件的目标。