目次
Config Data API
Spring Boot 2.4でプロパティを追加でロードするためのConfig Data APIが追加され、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.StandardConfigDataLoaderorg.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
usernameとpasswordはファイルで、次の内容が記述されているとします。
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プロパティにadminspring.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"という名前が付けられました。