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.StandardConfigDataLoader
  • org.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.name property will be set to admin
  • The spring.security.user.password property will be set to pa33w0rd

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.