Skip to content

第27讲:如何实现应用配置的外部化与管理

在第 03 课时介绍云原生应用的 15 个特征时,提到过配置外部化的重要性。在线服务的流行,对应用的更新提出了更高的要求,开发团队需要以更快的速度部署新的版本,部署的时间间隔从几个月,下降到几天,甚至是几分钟。这就对持续集成和持续部署提出了更高的要求。配置外部化是满足快速部署需求的一个重要前提条件。本课时将介绍如何进行云原生应用的配置外部化和管理配置。

配置即代码

在应用开发中,配置与代码是两种不同的实体,代码中包含的是应用的逻辑实现,而配置只是静态的数据。在运行时,代码需要依赖配置来提供所需的数据值,配置可以改变代码在运行时的行为。比如,应用的代码使用 JDBC 来读取数据库中的记录,运行时的数据库连接信息则是配置。在典型的持续集成和部署实现中,通常有多个作用不同的环境,比如开发、测试、交付准备和生产环境,在这些环境上运行的代码是相同的,只不过配置不一定相同。在云原生应用的部署中,代码构建之后被打包成不可变的容器镜像,与配置结合起来之后,在 Kubernetes 上运行。

配置即代码(Configuration as Code) 是软件开发中的一种实践,其含义并不是把配置和代码等同起来,而是把配置像代码一样由源代码仓库来管理。传统的应用部署,一般把配置保存在独立的文档中,由安装部署团队手工维护,这种做法无法有效地追踪配置的变化。在配置即代码的实践中,配置以纯文本的形式来保存,并通过源代码管理工具来追踪变化。推荐的做法是为配置创建独立的源代码仓库,与代码的源代码仓库相互独立。在持续集成时,配置需要有自己独立的流水线,对应于应用的运行环境。在部署时,应用的代码镜像需要与配置组合起来,形成完整的不可变的部署。

配置外部化

配置外部化指的是把可以配置的数据从代码中分离出来,保存在代码外部。在配置外部化之后,可以在运行时动态改变代码的行为,而不需要重新构建。配置外部化在应用开发中是一种常见的实践,有不同的方式来传递和使用配置项。

传递和使用配置项

在 Java 应用中,最常用的做法是通过系统属性和环境变量来传递配置值。系统属性通过 java 命令的"-D"参数来传递,在 Java 代码中使用 System.getProperty 方法来获取到属性的值。环境变量的设置与操作系统相关,在代码中使用 System.getEnv 方法来获取。

下面的代码使用了系统属性来传递配置值:

java
java -Dapp.limit=10 MyApp

下面的代码使用了环境变量来传递配置值:

java
APP_LIMIT=10 java MyApp

当配置项很多时,更好的做法是把配置数据保存在文件中,比如属性文件、JSON、XML 或 YAML 格式的文件,方便进行管理,还可以把配置项保存在数据库或外部服务中。

Spring Boot 支持

Spring Boot 提供了配置外部化的支持。在代码中,通过 @Value 注解可以直接引用配置项的值。配置项的名称通常由英文句点分隔,形成嵌套的结构,如 a.b.c.d 这样的形式。配置项的值以纯文本的格式来传递,@Value 注解可以添加到不同类型的方法参数或字段上,类型转换由 Spring 框架来完成。@Value 注解的另外一个优势是可以使用 Spring 表达式语言(SpEL)来求值。

在下面的代码中,env 字段上 @Value 注解的含义是引用名称为 app.env 的属性的值,而 limit 上的 @Value 注解使用了表达式,根据 app.env 的值来进行计算。

java
public class ConfigService {
  @Value("${app.env}")
  String env;
  @Value("#{ '${app.env}' == 'dev' ? 1 : 100 }")
  int limit;
}

在使用 @Value 注解时,属性的名称应该使用规范形式,即通过短横线隔开,并且全部是小写字母,如 app.api-key。

@Value 注解使用起来虽然简单,但是存在类型安全的问题。当以字符串的方式来直接引用属性名称时,一个拼写错误就会造成难以调试的错误,更好的做法是使用类型安全的配置类。下面代码中的 AppConfig 是一个配置的 POJO 类,通过 @ConfigurationProperties 注解来声明,该注解的属性 prefix 用来声明对应的配置项名称的前缀。配置类的字段可以自动与配置中名称相同的项进行绑定。比如,配置属性 app.nested.group 会与 AppConfig 的 nested 字段中的 group 字段自动绑定。

java
@ConfigurationProperties(prefix = "app")
@Data
@NoArgsConstructor
public class AppConfig {
  private String name;
  private long value = 10;
  private Nested nested;
  @Data
  @NoArgsConstructor
  static class Nested {
    private boolean enabled;
    private String group;
  }
}

下面的代码是进行配置的 YAML 文件:

yaml
app:
  name: test
  nested:
    enabled: true
    group: demo

配置对象可以当成 bean 来使用,如下面的代码所示。AppConfig 对象的使用是类型安全的,不会出现引用了错误名称的配置的情况。

java
@Service
public class ConfigService {
  @Autowired
  AppConfig appConfig;
}

配置类需要通过 @EnableConfigurationProperties 注解来启用,如下面的代码所示。

java
@Configuration
@EnableConfigurationProperties(AppConfig.class)
public class ApplicationConfig {
}

Spring Boot 的配置文件有两种格式,即属性文件YAML 文件。属性文件的使用由来已久,格式简单,每一行是一个配置项的名值对。当配置项名称的嵌套层次很深时,那么前缀会被重复很多次。YAML 文件的表达能力更强,适合于复杂的配置组织结构,其树形结构与配置对象的结构可以直观的对应。推荐使用 YAML。

如果应用的配置项很多,更直观的做法是让应用加载配置文件。在 Spring Boot 中,通常的做法是把配置项添加在 src/main/resources 目录下的 application.properties 或 application.yml 文件中。这个文件会成为打包的 JAR 文件的一部分,也会成为容器镜像的一部分,这一点并不太符合配置外部化的实践要求,不过也有其现实的意义。这个配置文件作为应用的一部分,其中只应该包含应用及其依赖的第三方库的配置的默认值,该文件中的配置值与环境无关。比如,以 Spring Data JPA 为例,该配置文件中可以包含 JPA 相关的配置值,但是不能包含数据库连接的相关配置,所有环境相关的配置都应该从代码中分离。在本地开发环境上,环境相关的配置应该以环境变量来配置。

下面的代码给出了示例应用中地址管理服务的 application.yml 文件内容,该文件只包含了 Spring Data JPA 和 Flyway 相关的配置。配置 spring.datasource.url 的值中使用了环境变量 DB_HOST、DB_PORT 和 DB_NAME,并提供了默认值。在开发环境中,即便没有配置这 3 个环境变量,使用默认值也可以正常工作。

yaml
spring:
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: validate
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect
        default_schema: happyride
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:8430}/${DB_NAME:happyride-address}
  flyway:
    schemas:
      - happyride

Spring Boot 提供了对预置文件(Profile) 的支持,可以根据预置文件的值来加载不同的配置文件。比如,预置文件 dev 和 prod 分别表示开发和生产环境,通过 application-dev 和 application-prod 来分别包含相应的配置项,再通过 spring.profiles.active 属性来切换预置文件。

通过预置文件来切换不同环境及其配置文件的做法,在实际开发中并不推荐。在不同的环境中,可以配置的内容是支撑服务或第三方服务的连接方式等。这些配置的值很多都是动态产生的,保存在配置文件中的意义不大。

Kubernetes 部署

在云原生应用中,环境变量是常用的进行配置的方式。在 Kubernetes 中,在部署的容器描述中,通过 env 属性可以指定环境变量的值,Spring Boot 支持通过环境变量来传递配置项的值。对于一个属性名,只需要把其中的英文句点替换成下划线,再删除掉"-",最后转换成全部大写的格式,就得到了对应环境变量的名称。比如属性名称 app.service.url 所对应的环境变量是 APP_SERVICE_URL。

有些第三方库只支持使用系统属性来配置。当在容器中运行时,修改 Java 进程的启动参数比较复杂,通常的做法是通过环境变量来设置,比如 JAVA_OPTS 来设置额外的启动选项。很多应用都采用这种做法,比如 Maven 中的 MAVEN_OPTS,以及 Elasticsearch 中的 ES_JAVA_OPTS,应用的容器镜像同样可以提供对 Java 启动选项的支持。run-java.sh 是一个在容器中启动 Java 应用的 Shell 脚本,当使用该脚本来启动 Java 时,环境变量 JAVA_OPTIONS 可以提供额外的启动参数。

在 Kubernetes 中,应用的配置保存在配置表对象(ConfigMap)中,可以把整个 Spring Boot 配置文件的值作为配置表对象中的某个键所对应的值。在运行时,可以从配置表对象中创建卷并与容器进行绑定。

在下面的代码中,在名为 app-config 的 Kubernetes 配置表对象中,spring.yml 和 app.yml 是两个配置文件。卷 config-volume 的文件内容来自 app-config 配置表,并指定了文件的路径,这个卷被绑定到容器的 /etc/config 目录中,环境变量 SPRING_CONFIG_LOCATION 设置了 Spring Boot 加载配置文件时的查找路径。第一个路径 classpath:/application.yml 的作用是加载应用中自带的 application.yml 文件;第二个路径 /etc/config/*/ 的作用是查找 /etc/config 目录下所有的 application.yml 文件。

yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  spring.yml: |-
    server:
      port: 8080
  app.yml: |-
    app:
      env: prod
      name: prod
      nested:
        enabled: false
        group: prod
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ext-config
spec:
  template:
    spec:
      containers:
        - name: ext-config
          image: myapp/spring-boot-ext-config:1.0.0
          env:
            - name: APP_ENV
              value: dev
            - name: SPRING_CONFIG_LOCATION
              value: classpath:/application.yml,/etc/config/*/
          volumeMounts:
            - name: config-volume
              mountPath: /etc/config
      volumes:
        - name: config-volume
          configMap:
            name: app-config
            items:
              - key: spring.yml
                path: spring/application.yml
              - key: app.yml
                path: app/application.yml

敏感数据的管理

有一些配置项的内容属于敏感数据,包括密码、API 密钥、访问令牌和加密密钥等。这些敏感数据不能保存在源代码仓库中,也不能以明文的形式直接存储。Kubernetes 中提供了隐私存储(Secret)来保存这类数据,这些数据以环境变量的方式传入到应用中。

在下面的代码中,使用 kubectl 命令创建一个名为 app 的 Secret 对象,其中,键 api-key 的值是 12345。

java
kubectl create secret generic app --from-literal=api-key=12345

在 Kubernetes 部署的容器声明中,在环境变量 APP_APIKEY 值的 valueFrom 属性中,secretKeyRef 表示从名为 app 的 Secret 对象的键 api-key 中,获取到环境变量的值。

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ext-config
spec:
  template:
    spec:
      containers:
        - name: ext-config
          image: myapp/spring-boot-ext-config:1.0.0
          env:
            - name: APP_APIKEY
              valueFrom:
                secretKeyRef:
                  name: app
                  key: api-key

配置管理

当配置与代码分离了之后,需要对配置进行管理,对于非敏感的配置,可以直接保存在源代码仓库中。当配置项需要更新时,源代码的改动会触发持续集成和部署,从而完成服务的自动更新。当应用的配置复杂时,一般把配置项按照组件划分到不同的文件中,每个文件中的配置项使用相同的前缀。

对于敏感数据,在简单情况下可以手动管理,也就是把这些数据以 Secret 的形式保存在 Kubernetes 中。当需要更新时,通过 Kubernetes 命令行工具来修改,并触发部署的自动更新。当要管理的敏感数据很多时,最好使用专门的工具来进行管理,目前流行的工具是 HashiCorp VaultSpring Cloud Vault 提供了 Spring 框架与 HashiCorp Vault 的集成。

HashiCorp Vault 的优势是免去了对密码的手动管理。应用在启动时,从 Vault 服务器获取到所需的密码,Vault 还可以实现密码的自动轮换,密码可以在指定的时间之后过期,从而降低密码泄露造成的风险。另外一个更加强大的功能是动态生成用户名和密码,对于数据库来说,这个功能非常实用。每个应用实例在启动之后,都可以从 Vault 获取到唯一的访问数据库的用户名和密码。如果数据库出现问题,比如某个用户占用的资源过多,可以根据这个唯一的用户名,快速定位到产生问题的应用示例。

完整示例

下面通过示例应用的地址管理服务来说明在 Kubernetes 上进行配置外部化的实践。

PostgreSQL 的用户名和密码保存为 Kubernetes 中的 Secret,如下面的代码所示。

yaml
apiVersion: v1
kind: Secret
metadata:
  name: address-postgres
type: Opaque
data:
  username: cG9zZ3Jlcw==
  password: cG9zZ3Jlcy1wYXNzd29yZA==

下面的代码给出了配置表对象的内容,包含数据库相关的配置。

yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: address-config
data:
  db_host: address-postgres
  db_port: '5432'
  db_name: happyride-address
  spring.yml: |-
    server:
      port: 8080
  app.yml: |-

在 PostgreSQL 的部署中,环境变量 POSTGRES_DB 的值来自配置表对象 address-config。configMapKeyRef 的作用是从配置表中读取值。

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: address-postgres
spec:
    spec:
      containers:
        - name: postgres
          env:
            - name: POSTGRES_DB
              valueFrom:
                configMapKeyRef:
                  name: address-config
                  key: db_name
            - name: POSTGRES_USER
              valueFrom:
                secretKeyRef:
                  name: address-postgres
                  key: username
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: address-postgres
                  key: password

下面的代码给出了地址管理服务的部署,环境变量的值来自配置表和 Secret,并从配置表中创建了容器所绑定的卷。

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: address
spec:
    spec:
      containers:
        - name: address
          env:
            - name: DB_HOST
              valueFrom:
                configMapKeyRef:
                  name: address-config
                  key: db_host
            - name: DB_PORT
              valueFrom:
                configMapKeyRef:
                  name: address-config
                  key: db_port
            - name: DB_NAME
              valueFrom:
                configMapKeyRef:
                  name: address-config
                  key: db_name
            - name: SPRING_DATASOURCE_USERNAME
              valueFrom:
                secretKeyRef:
                  name: address-postgres
                  key: username
            - name: SPRING_DATASOURCE_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: address-postgres
                  key: password
            - name: SPRING_CONFIG_LOCATION
              value: classpath:/application.yml,/etc/config/*/
          volumeMounts:
            - name: config-volume
              mountPath: /etc/config
      volumes:
        - name: config-volume
          configMap:
            name: address-config
            items:
              - key: spring.yml
                path: spring/application.yml
              - key: app.yml
                path: app/application.yml

总结

配置外部化是云原生应用的一个重要特征,也是持续集成和部署的基础。通过本课时的学习,你应该掌握 Spring Boot 开发的微服务中如何把配置和代码进行分离,以及在 Kubernetes 部署时,如何用配置表和 Secret 来保存配置,并让应用使用这些配置。