Oct 23, 2020
Nov 17, 2020
N/A Views
MD
warning
この記事は2年以上前に更新されたものです。情報が古くなっている可能性があります。

目次

Config Data API

Spring Boot 2.4でプロパティを追加でロードするためのConfig Data APIが追加され、
spring.config.importプロパティで追加のプロパティを指定できます。デフォルトで読み込まれるプロパティよりも優先されます。

この仕組みはorg.springframework.boot.context.config.ConfigDataLocationResolverConfigDataLoaderインターフェースで実装でき、Spring Boot 2.4では

  • org.springframework.boot.context.config.StandardConfigDataLocationResolver / org.springframework.boot.context.config.StandardConfigDataLoader
  • org.springframework.boot.context.config.ConfigTreeConfigDataLocationResolver / org.springframework.boot.context.config.ConfigTreeConfigDataLoader

が提供されています。

前者は標準的なpropertiesファイルやYAMLファイルをロードするクラスで、spring.config.import=file:/etc/config/foo.propertiesというような設定ができます。

後者はConfiguration Treesと呼ばれるファイル階層のロードをサポートし、
spring.config.import=configtree:/etc/config/myapp/というような設定ができます。

この記事ではConfig Treesについて説明します。

その他、Spring Cloudの各プロジェクトでもConfig Data APIがサポートされています

  • Spring Cloud Consul: spring.config.import=consul:...
  • Spring Cloud Config: spring.config.import=configserver:...
  • Spring Cloud Zookeeper: spring.config.import=zookeeper:...
  • Spring Cloud Vault: spring.config.import=vault:...

Config Tree

Config Treeは次のようなファイル構造を指します。

/etc/config
`-- myapp
    `-- spring
        `-- security
            `-- user
                |-- name
                `-- password

usernamepasswordはファイルで、次の内容が記述されているとします。

echo admin > /etc/config/myapp/spring/security/user/name
echo pa33w0rd > /etc/config/myapp/spring/security/user/password

このようなファイルがある状態で

spring.config.import=configtree:/etc/config/myapp/

を設定した場合、

  • spring.security.user.nameプロパティにadmin
  • spring.security.user.passwordプロパティにpa33w0rd

が設定されます。

なぜConfig Treeがサポートされたのか?

Kubernetesに詳しい人は、この階層がConfigMapやSecretをファイルとしてマウントした時にkeyとvalueがマッピングされる形式だと気づくでしょう。

これまで例えば、次のようなSecretをspring.security.user.*に設定したい場合、

apiVersion: v1
kind: Secret
metadata:
  name: admin-user
stringData:
  username: admin
  password: pa33w0rd

次のように環境変数にマッピングすることが多いのではないでしょうか。自分はそうしています。

env:
- name: SPRING_SECURITY_USER_NAME
  valueFrom:
    secretKeyRef:
      name: admin-user
      key: username
- name: SPRING_SECURITY_USER_PASSWORD
  valueFrom:
    secretKeyRef:
      name: admin-user
      key: password

The Twelve-Factor Appでは、"設定を環境変数に格納"すべしと言っています。

しかしこの方法はKubernetesのBest practicesとは異なります。
環境変数に格納された内容はDiskに書き込まれます。(/proc/{pid}/environファイルに格納されます。)
KubernetesのSecretをファイルにマウントした場合は内容がDiskに書かれず、tmpfsに格納され、Podが削除とともに削除されます。
そのため、環境変数に格納するよりもセキュアなPracticeとして紹介されています。(環境変数がNGとは言っていません。)

ファイルマウントによってSecretの内容をspring.security.user.*に設定したい場合、
次のようにpropertiesファイルの内容そのものをSecretに設定し、

apiVersion: v1
kind: Secret
metadata:
  name: app-config
stringData:
  application.properties: |-
    spring.security.user.name=admin
    spring.security.user.password=pa33w0rd

次のようにマウントする方法が考えられます。

spec:
  containers:
  - name: my-app
    # ...
    volumeMounts:
    - name: app-config
      mountPath: /config
      readOnly: true
  volumes:
  - name: app-config
    secretName:
      name: app-config

これでも問題はありませんが、Secretのフォーマットがアプリ固有のもので汎用的ではないため、他のリソースで使うSecretと共用したい場合に都合が悪いかもしれません。

元のSecretに設定されたプロパティをそのままダイレクトに(nativeに)アプリに設定したい時に使えるのがConfig Treeです。Config Treeをロードした場合には次のような設定になります。

spec:
  containers:
  - name: my-app
    # ...
    env:
    - name: SPRING_CONFIG_IMPORT
      value: configtree:/workspace/config/
    volumeMounts:
    - name: admin-user
      mountPath: /workspace/config/spring/security/user
      readOnly: true
  volumes:
  - name: admin-user
    secret:
      secretName: admin-user

実際に試してみる

PostgreSQLにアクセスするアプリケーションで、接続情報をSecretで渡したいケースを実装してみます。

アプリケーションの作成

簡単なAPIサーバーを作成します。

curl https://start.spring.io/starter.tgz \
    -d artifactId=car-api \
    -d bootVersion=2.4.0-SNAPSHOT \
    -d baseDir=car-api \
    -d dependencies=web,jdbc,flyway,actuator,postgresql \
    -d packageName=com.example \
    -d applicationName=CarApiApplication | tar -xzvf -
cd car-api
cat <<EOF > src/main/java/com/example/CarController.java
package com.example;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import java.sql.PreparedStatement;
import java.util.List;

@RestController
public class CarController {

    private final JdbcTemplate jdbcTemplate;

    public CarController(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @GetMapping(path = "/cars")
    public ResponseEntity<?> getCars() {
        final List<Car> cars = this.jdbcTemplate.query("SELECT id, name FROM car ORDER BY id", (rs, i) -> new Car(rs.getInt("id"), rs.getString("name")));
        return ResponseEntity.ok(cars);
    }

    @PostMapping(path = "/cars")
    public ResponseEntity<?> postCars(@RequestBody Car car) {
        KeyHolder keyHolder = new GeneratedKeyHolder();
        this.jdbcTemplate.update(connection -> {
            final PreparedStatement statement = connection.prepareStatement("INSERT INTO car(name) VALUES (?)", new String[]{"id"});
            statement.setString(1, car.getName());
            return statement;
        }, keyHolder);
        car.setId(keyHolder.getKey().intValue());
        return ResponseEntity.status(HttpStatus.CREATED).body(car);
    }

    @DeleteMapping(path = "/cars/{id}")
    public ResponseEntity<?> deleteCar(@PathVariable("id") Integer id) {
        this.jdbcTemplate.update("DELETE FROM car WHERE id = ?", id);
        return ResponseEntity.noContent().build();
    }

    static class Car {

        public Car(Integer id, String name) {
            this.id = id;
            this.name = name;
        }

        private Integer id;

        private String name;

        public Integer getId() {
            return id;
        }

        public void setId(Integer id) {
            this.id = id;
        }

        public String getName() {
            return name;
        }

        public void setName(String name) {
            this.name = name;
        }
    }
}
EOF
mkdir -p src/main/resources/db/migration
cat <<EOF > src/main/resources/db/migration/V1__init.sql
CREATE TABLE car (
    id   SERIAL PRIMARY KEY,
    name VARCHAR(16)
);

INSERT INTO car(name) VALUES ('Avalon');
INSERT INTO car(name) VALUES ('Corolla');
INSERT INTO car(name) VALUES ('Crown');
INSERT INTO car(name) VALUES ('Levin');
INSERT INTO car(name) VALUES ('Yaris');
INSERT INTO car(name) VALUES ('Vios');
INSERT INTO car(name) VALUES ('Glanza');
INSERT INTO car(name) VALUES ('Aygo');
EOF
cat <<EOF > src/main/resources/application.properties
spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://localhost:5432/car
spring.datasource.username=${USER}
spring.datasource.password=
EOF

ローカルで実行

ビルドします。

./mvnw clean package -Dmaven.test.skip=true

PostgreSQLをHome Brewでインストールします。

brew install postgres 
brew services start postgresql
psql postgres -c 'create database car;'

jarを実行します。

java -jar target/car-api-0.0.1-SNAPSHOT.jar

APIにアクセスします。

curl -s http://localhost:8080/cars -d "{\"name\": \"Lexus\"}" -H "Content-Type: application/json" | jq .
curl -s http://localhost:8080/cars | jq .

Dockerイメージの作成

Cloud Native BuildpacksでDockerイメージを作成し、Dockerイメージへpushします。

./mvnw spring-boot:build-image -Dmaven.test.skip=true -Dspring-boot.build-image.imageName=ghcr.io/making/car-api
docker push ghcr.io/making/car-api

Kubernetesへデプロイ

PostgreSQLへ接続するため、次のようなSecretを持っているとします。

mkdir -p k8s
cat <<EOF > k8s/postgresql-secret.yml
apiVersion: v1
kind: Secret
metadata:
  name: postgresql
stringData:
  url: jdbc:postgresql://lallah.db.elephantsql.com:5432/aokncqqn
  username: aokncqqn
  password: qH-qyBRtkNIc6at26oBYttmgXknUOLDR
EOF

このSecretをConfig Treeでロードできるように次のようなマニフェストファイルを作成します。

cat <<EOF > k8s/deployment.yml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: car-api
spec:
  replicas: 1
  selector:
    matchLabels:
      app: car-api
  template:
    metadata:
      labels:
        app: car-api
    spec:
      containers:
      - image: ghcr.io/making/car-api:latest
        name: car-api
        ports:
        - containerPort: 8080
        env:
        - name: SPRING_CONFIG_IMPORT
          value: configtree:/workspace/config/
        #! Tweak to use less memory 
        - name: JAVA_TOOL_OPTIONS
          value: -XX:ReservedCodeCacheSize=32M -Xss512k -Duser.timezone=Asia/Tokyo
        - name: BPL_JVM_THREAD_COUNT
          value: "20"
        - name: SERVER_TOMCAT_THREADS_MAX
          value: "4"
        resources:
          limits:
            memory: 256Mi
          requests:
            memory: 256Mi
        readinessProbe:
          httpGet:
            path: /actuator/health/readiness
            port: 8080
            scheme: HTTP
          initialDelaySeconds: 5
          timeoutSeconds: 1
          failureThreshold: 1
          periodSeconds: 5
        livenessProbe:
          httpGet:
            path: /actuator/health/liveness
            port: 8080
            scheme: HTTP
          initialDelaySeconds: 10
          timeoutSeconds: 1
          failureThreshold: 1
          periodSeconds: 10
        volumeMounts:
        - name: database
          mountPath: /workspace/config/spring/datasource
          readOnly: true
      volumes:
      - name: database
        secret:
          secretName: postgresql
---
kind: Service
apiVersion: v1
metadata:
  name: car-api
spec:
  type: LoadBalancer
  selector:
    app: car-api
  ports:
  - protocol: TCP
    port: 80
    targetPort: 8080
EOF

デプロイします。

kubectl apply -f k8s/deployment.yml -f k8s/postgresql-secret.yml

次のようにログを取得して、Secretに設定した接続先へアクセスできていることを確認します。

$ kubectl logs -l app=car-api --tail=12
2020-10-23 00:50:21.661  INFO 1 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2020-10-23 00:50:22.046  INFO 1 --- [           main] o.f.c.i.database.base.DatabaseType       : Database: jdbc:postgresql://lallah.db.elephantsql.com:5432/aokncqqn (PostgreSQL 11.9)
2020-10-23 00:50:24.678  INFO 1 --- [           main] o.f.core.internal.command.DbValidate     : Successfully validated 1 migration (execution time 00:00.889s)
2020-10-23 00:50:26.070  INFO 1 --- [           main] o.f.core.internal.command.DbMigrate      : Current version of schema "public": 1
2020-10-23 00:50:26.243  INFO 1 --- [           main] o.f.core.internal.command.DbMigrate      : Schema "public" is up to date. No migration necessary.
2020-10-23 00:50:27.045  INFO 1 --- [           main] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
2020-10-23 00:50:27.623  INFO 1 --- [           main] o.s.b.a.e.web.EndpointLinksResolver      : Exposing 2 endpoint(s) beneath base path '/actuator'
2020-10-23 00:50:27.679  INFO 1 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2020-10-23 00:50:27.700  INFO 1 --- [           main] com.example.CarApiApplication            : Started CarApiApplication in 11.579 seconds (JVM running for 12.107)
2020-10-23 00:50:28.206  INFO 1 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
2020-10-23 00:50:28.210  INFO 1 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
2020-10-23 00:50:28.216  INFO 1 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 6 ms

APIにアクセスします。

curl -s http://<ServiceのIP>/cars | jq .

Spring 2.3からKubernetesフレンドリーな機能が追加されていますが、
2.4でもこのような機能が追加されました。

Kubernetesフレンドリーですが、Kubernetes以外でも使える汎用的な機能になっています。そのため、Kubernetesに特化しない"Config Tree"という名前が付けられました。

Found a mistake? Update the entry.
Share this article: