Setting Kubernetes Secret Values as Spring Boot Application Properties with Config Tree Introduced in Spring Boot 2.4
⚠️ This article was automatically translated by OpenAI (gpt-4o). It may be edited eventually, but please be aware that it may contain incorrect information at this time.
Table of Contents
Config Data API
Spring Boot 2.4 introduced the Config Data API for loading additional properties, allowing you to specify additional properties with the spring.config.import property. These properties take precedence over the default ones.
This mechanism can be implemented with the org.springframework.boot.context.config.ConfigDataLocationResolver and ConfigDataLoader interfaces. In Spring Boot 2.4, the following are provided:
org.springframework.boot.context.config.StandardConfigDataLocationResolver/org.springframework.boot.context.config.StandardConfigDataLoaderorg.springframework.boot.context.config.ConfigTreeConfigDataLocationResolver/org.springframework.boot.context.config.ConfigTreeConfigDataLoader
The former is a class for loading standard properties files or YAML files, allowing settings like spring.config.import=file:/etc/config/foo.properties.
The latter supports loading file hierarchies called Configuration Trees, allowing settings like spring.config.import=configtree:/etc/config/myapp/.
This article explains Config Trees.
Additionally, various Spring Cloud projects also support the 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
A Config Tree refers to a file structure like the following:
/etc/config
`-- myapp
`-- spring
`-- security
`-- user
|-- name
`-- password
Assume the username and password files contain the following:
echo admin > /etc/config/myapp/spring/security/user/name
echo pa33w0rd > /etc/config/myapp/spring/security/user/password
With these files in place, if you set:
spring.config.import=configtree:/etc/config/myapp/
then,
- The
spring.security.user.nameproperty will be set toadmin - The
spring.security.user.passwordproperty will be set topa33w0rd
Why was Config Tree supported?
Those familiar with Kubernetes will recognize that this hierarchy maps keys and values when ConfigMaps or Secrets are mounted as files.
Previously, for example, if you wanted to set a Secret to spring.security.user.*, you might map it to environment variables like this:
apiVersion: v1
kind: Secret
metadata:
name: admin-user
stringData:
username: admin
password: pa33w0rd
You might often map it to environment variables like this. I do it this way:
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 recommends storing configuration in environment variables.
However, this method differs from Kubernetes Best Practices. Contents stored in environment variables are written to disk (stored in the /proc/{pid}/environ file). When Kubernetes Secrets are mounted as files, the contents are not written to disk but stored in tmpfs, and are deleted along with the Pod. Therefore, it is introduced as a more secure practice than storing in environment variables (not saying environment variables are bad).
If you want to set the contents of a Secret to spring.security.user.* via file mount, you could set the contents of the properties file itself to the Secret like this:
apiVersion: v1
kind: Secret
metadata:
name: app-config
stringData:
application.properties: |-
spring.security.user.name=admin
spring.security.user.password=pa33w0rd
And mount it like this:
spec:
containers:
- name: my-app
# ...
volumeMounts:
- name: app-config
mountPath: /config
readOnly: true
volumes:
- name: app-config
secretName:
name: app-config
This works, but the Secret format is app-specific and not generic, which might be inconvenient if you want to share the Secret with other resources.
Config Tree is useful when you want to directly (natively) set the properties configured in the original Secret to the app. When loading a Config Tree, the settings would be as follows:
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
Trying it out
Let's implement a case where we want to pass connection information to an application accessing PostgreSQL via Secret.
Creating the application
Create a simple API server.
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
Running locally
Build the application.
./mvnw clean package -Dmaven.test.skip=true
Install PostgreSQL using Home Brew.
brew install postgres
brew services start postgresql
psql postgres -c 'create database car;'
Run the jar file.
java -jar target/car-api-0.0.1-SNAPSHOT.jar
Access the API.
curl -s http://localhost:8080/cars -d "{\"name\": \"Lexus\"}" -H "Content-Type: application/json" | jq .
curl -s http://localhost:8080/cars | jq .
Creating a Docker image
Create a Docker image using Cloud Native Buildpacks and push it to a Docker registry.
./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
Deploying to Kubernetes
Assume you have a Secret for connecting to PostgreSQL like this:
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
Create a manifest file to load this Secret with 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
Deploy it.
kubectl apply -f k8s/deployment.yml -f k8s/postgresql-secret.yml
Check the logs to confirm that the application is accessing the connection specified in the 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
Access the API.
curl -s http://<Service IP>/cars | jq .
Spring 2.3 introduced Kubernetes-friendly features, and 2.4 added this feature as well.
While Kubernetes-friendly, it is a generic feature that can be used outside of Kubernetes. Therefore, it was named "Config Tree" instead of something Kubernetes-specific.