--- title: Spring Boot 2.4で導入されたConfig TreeでKubernetesのSecretの値をSpring Bootアプリのプロパティに設定 tags: ["Spring Boot", "Config Data API", "Kubernetes"] categories: ["Programming", "Java", "org", "springframework", "boot", "context", "config"] date: 2020-10-23T08:29:13Z updated: 2020-11-17T05:21:38Z --- **目次** ### Config Data API Spring Boot 2.4でプロパティを追加でロードするための[Config Data API](https://docs.spring.io/spring-boot/docs/2.4.0-SNAPSHOT/reference/html/spring-boot-features.html#boot-features-external-config-files-importing)が追加され、 `spring.config.import`プロパティで追加のプロパティを指定できます。デフォルトで読み込まれるプロパティよりも優先されます。 この仕組みは`org.springframework.boot.context.config.ConfigDataLocationResolver`と`ConfigDataLoader`インターフェースで実装でき、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](https://docs.spring.io/spring-boot/docs/2.4.0-SNAPSHOT/reference/html/spring-boot-features.html#boot-features-external-config-files-configtree)と呼ばれるファイル階層のロードをサポートし、 `spring.config.import=configtree:/etc/config/myapp/`というような設定ができます。 この記事ではConfig Treesについて説明します。 > その他、Spring Cloudの各プロジェクトでもConfig Data APIが[サポートされています](https://spring.io/blog/2020/10/07/spring-cloud-2020-0-0-m4-aka-ilford-is-available)。 > * 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 ``` `username`と`password`はファイルで、次の内容が記述されているとします。 ``` echo admin > /etc/config/myapp/spring/security/user/name echo pa33w0rd > /etc/config/myapp/spring/security/user/password ``` このようなファイルがある状態で ```properties 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.*`に設定したい場合、 ```yaml apiVersion: v1 kind: Secret metadata: name: admin-user stringData: username: admin password: pa33w0rd ``` 次のように環境変数にマッピングすることが多いのではないでしょうか。自分はそうしています。 ```yaml 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](https://12factor.net/config)では、"設定を環境変数に格納"すべしと言っています。 しかしこの方法は[KubernetesのBest practices](https://kubernetes.io/docs/concepts/configuration/secret/#security-properties)とは異なります。 環境変数に格納された内容はDiskに書き込まれます。(`/proc/{pid}/environ`ファイルに格納されます。) KubernetesのSecretをファイルにマウントした場合は内容がDiskに書かれず、`tmpfs`に格納され、Podが削除とともに削除されます。 そのため、環境変数に格納するよりもセキュアなPracticeとして紹介されています。(環境変数がNGとは言っていません。) ファイルマウントによってSecretの内容を`spring.security.user.*`に設定したい場合、 次のようにpropertiesファイルの内容そのものをSecretに設定し、 ```yaml apiVersion: v1 kind: Secret metadata: name: app-config stringData: application.properties: |- spring.security.user.name=admin spring.security.user.password=pa33w0rd ``` 次のようにマウントする方法が考えられます。 ```yaml 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をロードした場合には次のような設定になります。 ```yaml 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 - ``` ```java cd car-api cat < 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 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 ``` ```sql mkdir -p src/main/resources/db/migration cat < 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 ``` ```properties cat < 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を持っているとします。 ```yaml mkdir -p k8s cat < 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でロードできるように次のようなマニフェストファイルを作成します。 ```yaml cat < 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:///cars | jq . ``` ---- Spring 2.3からKubernetesフレンドリーな機能が追加されていますが、 2.4でもこのような機能が追加されました。 Kubernetesフレンドリーですが、Kubernetes以外でも使える汎用的な機能になっています。そのため、Kubernetesに特化しない"Config Tree"という名前が付けられました。