Spring Boot on Kubernetes Session - January 2021

Building a Spring Boot application

Bootstrapping

Initialize a new demo-service project from Spring Initializer with the following dependencies:

  • Reactive Web (org.springframework.boot:spring-boot-starter-webflux) contributes libraries for building web applications with Spring Flux and Netty.
  • Actuator (org.springframework.boot:spring-boot-starter-actuator) contributes endpoints for monitoring and managing your application, including health information, metrics, and configuration.
  • Spring Configuration Processor (org.springframework.boot:spring-boot-configuration-processor) generates metadata for custom configuration properties.
  • Lombok (org.projectlombok:lombok) helps reduce boilerplate code like getters, setters, and constructors.

Defining a custom property

Create a DemoProperties class to hold the value for a welcome message.

package com.thomasvitale.demoservice;

import lombok.Data;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "demo")
@Data
public class DemoProperties {
	/**
	 * A message to welcome users.
	 */
	private String message;
}

In application.yml, define a default value for the new demo.message property.

demo:
  message: "Welcome to Spring Boot!"

Implementing a REST API

Implement a GET REST endpoint using the functional method.

package com.thomasvitale.demoservice;

import reactor.core.publisher.Mono;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.context.annotation.Bean;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;

import static org.springframework.web.reactive.function.server.RouterFunctions.route;

@SpringBootApplication
@ConfigurationPropertiesScan
public class DemoServiceApplication {

	public static void main(String[] args) {
		SpringApplication.run(DemoServiceApplication.class, args);
	}

	@Bean
	RouterFunction<ServerResponse> routes(DemoProperties properties) {
		return route()
				.GET("/", request ->
						ServerResponse.ok().body(Mono.just(properties.getMessage()), String.class))
				.build();
	}
}

Running the application on the JVM

First, build the application.

./gradlew build

Then, run the JAR artifact.

java -jar build/libs/demo-service-0.0.1-SNAPSHOT.jar

Containerizing a Spring Boot application

Using a layered JAR

When building Docker images, fat-JARs are not the best. Spring provides a layered mode that organizes a JAR into layers.

You can see the list of layers with this command.

java -Djarmode=layertools -jar build/libs/demo-service-0.0.1-SNAPSHOT.jar list

As per the documentation, the following layers are defined by default:

  • dependencies for any non-project dependency whose version does not contain SNAPSHOT.
  • spring-boot-loader for the jar loader classes.
  • snapshot-dependencies for any non-project dependency whose version contains SNAPSHOT.
  • application for project dependencies, application classes, and resources.

Leveraging Cloud Native Buildpacks

Integrated with Spring Boot since version 2.3, Cloud Native Buildpacks can package a Spring Boot application as a Docker image without providing a Dockerfile. It takes care of using a layered JAR, optimizing performance, ensuring reproducibility, and relying on best practices in terms of security.

You can build a Docker image with the default settings:

./gradlew bootBuildImage

You can also add custom settings in build.gradle.

bootBuildImage {
	imageName = "thomasvitale/${project.name}:${project.version}"
	environment = ["BP_JVM_VERSION" : "11.*"]
}

Publishing a Spring Boot image

You can configure the Gradle/Maven Spring Boot plugin to publish your image to a container registry.

bootBuildImage {
	imageName = "thomasvitale/${project.name}:${project.version}"
	environment = ["BP_JVM_VERSION" : "11.*"]
    docker {
		publishRegistry {
			username = project.property("dockerUsername")
			password = project.property("dockerToken")
			url = "https://docker.io"
		}
	}
}

Username and token are defined as Gradle properties. You can use the -publishImage argument whenever you want to publish the image.

./gradlew bootBuildImage -publishImage

You can test the image by running it as a Docker container, for example using Docker Compose. You can even define a new value for the demo.message property.

version: "3.8"
services:
  demo-service:
    image: thomasvitale/demo-service:0.0.1-SNAPSHOT
    container_name: demo-service
    ports:
      - 8080:8080
    environment:
      - DEMO_MESSAGE=Welcome to Spring Boot on Docker!

Deploying Spring Boot on Kubernetes

Starting a local cluster

Start a local Kubernetes cluster with kind.

kind create cluster

Basic deployment

First, in a k8s folder, create a Deployment definition for the application.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: book-deployment
  labels:
    app: book
spec:
  replicas: 1
  selector:
    matchLabels:
      app: book
  template:
    metadata:
      labels:
        app: book
    spec:
      containers:
        - name: book-service
          image: thomasvitale/book-service:0.0.1-SNAPSHOT
          ports:
            - containerPort: 8080

Then, create a Service definition.

apiVersion: v1
kind: Service
metadata:
  name: book-service
  labels:
    app: book
spec:
  type: ClusterIP
  selector:
    app: book
  ports:
    - port: 8080
      targetPort: 8080

Finally, you can deploy the application on your local Kubernetes cluster.

kubectl create -f k8s

You can inspect the resources created on Kubernetes as follows.

kubectl get all -l app=demo 

The result should be similar to the following.

NAME                                   READY   STATUS    RESTARTS   AGE
pod/demo-deployment-57d6944794-qc4ms   1/1     Running   0          44s

NAME                   TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)    AGE
service/demo-service   ClusterIP   10.106.154.110   <none>        8080/TCP   44s

NAME                              READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/demo-deployment   1/1     1            1           44s

NAME                                         DESIRED   CURRENT   READY   AGE
replicaset.apps/demo-deployment-57d6944794   1         1         1       44s

The application is now accessible only within the cluster, but you can forward the traffic to your local machine with this command.

kubectl port-forward service/demo-service 8080:8080

Configuration with ConfigMaps

Since Spring Boot 2.3, you can natively configure your application through ConfigMaps.

Let's define a new value for the demo.message property in a ConfigMap.

apiVersion: v1
kind: ConfigMap
metadata:
  name: demo-config
data:
  application.yml: |
    demo:
      message: Welcome to Spring Boot on Kubernetes!

Then, we can mount the ConfigMap as a volume to the container.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo-deployment
  labels:
    app: demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: demo
  template:
    metadata:
      labels:
        app: demo
    spec:
      containers:
        - name: demo-service
          image: thomasvitale/demo-service:0.0.1-SNAPSHOT
          ports:
            - containerPort: 8080
          volumeMounts:
            - name: config-volume
              mountPath: /workspace/config
      volumes:
        - name: config-volume
          configMap:
            name: demo-config

Graceful shutdown

You can configure the graceful shutdown for the web server and define a shutdown timeout with these properties.

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 10s

You can configure them through the ConfigMap you defined in the previous step.

apiVersion: v1
kind: ConfigMap
metadata:
  name: demo-config
data:
  application.yml: |
    demo:
      message: Welcome to Spring Boot on Kubernetes!
    server:
      shutdown: graceful
    spring:
      lifecycle:
        timeout-per-shutdown-phase: 20s

Liveness and readiness probes

Spring Boot Actuator exposes liveness and readiness probes automatically when it detects a Kubernetes environment. So, you can use them directly in your Deployment file.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo-deployment
  labels:
    app: demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: demo
  template:
    metadata:
      labels:
        app: demo
    spec:
      containers:
        - name: demo-service
          image: thomasvitale/demo-service:0.0.1-SNAPSHOT
          ports:
            - containerPort: 8080
          lifecycle:
            preStop:
              exec:
                command: [ "sh", "-c", "sleep 10" ]
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 5
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
          volumeMounts:
            - name: config-volume
              mountPath: /workspace/config
      volumes:
        - name: config-volume
          configMap:
            name: demo-config

Scaling pods

You can scaling pods by defining a number of replicas in the Deployment file or from kubectl.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo-deployment
  labels:
    app: demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: demo
  template:
    metadata:
      labels:
        app: demo
    spec:
      containers:
        - name: demo-service
          image: thomasvitale/demo-service:0.0.1-SNAPSHOT
          ports:
            - containerPort: 8080
          lifecycle:
            preStop:
              exec:
                command: [ "sh", "-c", "sleep 10" ]
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 5
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
          volumeMounts:
            - name: config-volume
              mountPath: /workspace/config
      volumes:
        - name: config-volume
          configMap:
            name: demo-config

Local development with Kubernetes

Skaffold is a tool that lets you establish a convenient workflow to work with Kubernetes locally.

After installing the tool, create a skaffold.yml file in your project.

apiVersion: skaffold/v2beta8
kind: Config
metadata:
  name: demo-service
build:
  artifacts:
    - image: thomasvitale/demo-service
      custom:
      buildpacks:
        builder: gcr.io/paketo-buildpacks/builder:base-platform-api-0.3
        env:
          - BP_JVM_VERSION=11.*
        dependencies:
          paths:
            - src
            - build.gradle
deploy:
  kubectl:
    manifests:
      - k8s/*

Run the following command and Skaffold will monitor changes in your code, builds an image, and deploys it to your local Kubernetes cluster.

skaffold dev --port-forward

If you need to debug the application, then Skaffold can expose a remote debug port for you.

skaffold debug --port-forward

Service discovery and load balancing

Build a client Spring Boot application

Initialize a new demo-client project from Spring Initializer with the following dependencies:

  • Reactive Web (org.springframework.boot:spring-boot-starter-webflux) contributes libraries for building web applications with Spring Flux and Netty.
  • Actuator (org.springframework.boot:spring-boot-starter-actuator) contributes endpoints for monitoring and managing your application, including health information, metrics, and configuration.

Implementing a REST API

First, we define a property for the service URL.

package com.thomasvitale.democlient;

import java.net.URI;

import lombok.Data;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties("demo")
@Data
public class DemoProperties {
	/**
	 * The URL of the demo service.
	 */
	private URI serviceUrl; 
}

Then, we define a value to use locally.

demo:
  serviceUrl: http://localhost:8080

server:
  port: 8181

And finally the endpoint, which calls the demo service.

package com.thomasvitale.democlient;

import reactor.core.publisher.Mono;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.context.annotation.Bean;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;

import static org.springframework.web.reactive.function.server.RouterFunctions.route;

@SpringBootApplication
@ConfigurationPropertiesScan
public class DemoClientApplication {

	public static void main(String[] args) {
		SpringApplication.run(DemoClientApplication.class, args);
	}
	
	@Autowired
	private DemoProperties demoProperties;

	private final WebClient webClient = WebClient.create();

	@Bean
	RouterFunction<ServerResponse> routes() {
		return route()
				.GET("/", this::getMessage)
				.build();
	}

	public Mono<ServerResponse> getMessage(ServerRequest request) {
		Mono<String> finalMessage =  webClient.get()
				.uri(demoProperties.getServiceUrl())
				.retrieve()
				.bodyToMono(String.class)
				.map(message -> "The service says: " + message);

		return ServerResponse.ok().body(finalMessage, String.class);
	}
}

Backing services on Kubernetes

On Kubernetes, you need to to configure the URL where Demo Client can find Demo Service. Spring Boot can leverage the native Kubernetes capabilities in terms of service discovery and load balancing, so you just need to set the property to the Kubernetes service URL.

First, create a Deployment resource like you did for Demo Service. In this case, let's use an environment variable to pass configuration data to Spring Boot. The DEMO_SERVICE_URL property needs to be set to http://demo-service, the name of the Service object exposing the Demo Service application to the cluster network.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo-client-deployment
  labels:
    app: demo-client
spec:
  replicas: 2
  selector:
    matchLabels:
      app: demo-client
  template:
    metadata:
      labels:
        app: demo-client
    spec:
      containers:
        - name: demo-client
          image: thomasvitale/demo-client:0.0.1-SNAPSHOT
          ports:
            - containerPort: 8181
          env:
            - name: DEMO_SERVICE_URL
              value: http://demo-service
          lifecycle:
            preStop:
              exec:
                command: [ "sh", "-c", "sleep 10" ]
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8181
            initialDelaySeconds: 10
            periodSeconds: 5
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8181
            initialDelaySeconds: 5
            periodSeconds: 5
          volumeMounts:
            - name: client-config-volume
              mountPath: /workspace/config
      volumes:
        - name: client-config-volume
          configMap:
            name: demo-client-config

Then, just like before a Service and ConfigMap objects for Demo Client.

apiVersion: v1
kind: Service
metadata:
  name: demo-client-service
  labels:
    app: demo-client
spec:
  type: ClusterIP
  selector:
    app: demo-client
  ports:
    - port: 8181
      targetPort: 8181
apiVersion: v1
kind: ConfigMap
metadata:
  name: demo-client-config
data:
  application.yml: |
    server:
      shutdown: graceful
    spring:
      lifecycle:
        timeout-per-shutdown-phase: 20s

Now run both the applications on Kubernetes and verify they work correctly.