A Kubernetes operator for Spring Boot microservices. If you have a container image springguides/demo with a Spring Boot application running on port 8080, you can deploy it to Kubernetes with just a few lines of YAML:

apiVersion: spring.io/v1
kind: Microservice
metadata:
  name: demo
spec:
  image: springguides/demo

You can try it out in an interactive tutorial in Katakoda.

Installation

The controller is in Dockerhub, so you should be able to deploy it from just the YAML:

$ kubectl apply -f <(kustomize build github.com/dsyer/spring-boot-operator/config/default)

One Service for the controller is installed into the spring-system namespace:

$ kubectl get all -n spring-system
NAME                                             READY   STATUS    RESTARTS   AGE
pod/spring-controller-manager-79c6c95677-8hf89   2/2     Running   0          3m17s

NAME                                                TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
service/spring-controller-manager-metrics-service   ClusterIP   10.111.94.226   <none>        8443/TCP   3m17s

NAME                                        READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/spring-controller-manager   1/1     1            1           3m17s

NAME                                                   DESIRED   CURRENT   READY   AGE
replicaset.apps/spring-controller-manager-79c6c95677   1         1         1       3m17s

Petclinic:

$ kubectl create namespace services
$ kubectl apply -f <(kustomize build github.com/dsyer/spring-boot-operator/config/samples/mysql)
$ kubectl apply -f <(curl https://raw.githubusercontent.com/dsyer/spring-boot-operator/master/config/samples/petclinic.yaml)

Clean up:

$ kubectl delete microservices --all
$ kubectl delete namespace spring-system

Building from Source

If you know how to set up a GO lang development environment, and are building from source you can just do this:

$ make install
$ make run

and then the controller will register with your default cluster.

Tip

You may encounter issues with go modules. If you see this:

# sigs.k8s.io/controller-tools/pkg/webhook
/go/pkg/mod/sigs.k8s.io/controller-tools@v0.2.1/pkg/webhook/parser.go:98:29: undefined: v1beta1.Webhook
/go/pkg/mod/sigs.k8s.io/controller-tools@v0.2.1/pkg/webhook/parser.go:129:9: undefined: v1beta1.Webhook
/go/pkg/mod/sigs.k8s.io/controller-tools@v0.2.1/pkg/webhook/parser.go:161:21: undefined: v1beta1.Webhook
/go/pkg/mod/sigs.k8s.io/controller-tools@v0.2.1/pkg/webhook/parser.go:162:23: undefined: v1beta1.Webhook
make: *** [Makefile:69: controller-gen] Error 2

try this:

$ (cd .. && GO111MODULE=on go get sigs.k8s.io/controller-tools/cmd/controller-gen@v0.2.1)
$ make install run

The source code has a VSCode .devcontainer definition, so if you use the "Remote Container" extension, you should be able to run in a container. The devcontainer.json has comments explaining what to set up on the host (you need to have docker running and set up your ~/.kube/config to talk to your cluster).

Try it Out

Send the sample YAML above to Kubernetes with kubectl or Kapp:

$ kubectl apply -f <(curl https://raw.githubusercontent.com/dsyer/spring-boot-operator/master/config/samples/demo.yaml)

The Microservice generates a Service and a Deployment, similar to what you would get if you used kubectl create to generate them from your image. Example

$ kubectl apply -f config/samples/demo.yaml
$ kubectl get all
NAME                             READY   STATUS    RESTARTS   AGE
pod/mysql-744f7b658d-zt2gx       1/1     Running   0          26h
pod/demo-6b78fb7b85-2snj4        1/1     Running   0          62m

NAME                 TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)    AGE
service/kubernetes   ClusterIP   10.96.0.1        <none>        443/TCP    3d2h
service/demo         ClusterIP   10.104.167.30    <none>        80/TCP     62m

NAME                        READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/demo        1/1     1            1           62m

NAME                                   DESIRED   CURRENT   READY   AGE
replicaset.apps/demo-6b78fb7b85        1         1         1       62m

The Service is listening on port 80, so you can expose it locally using a port forward:

$ kubectl port-forward svc/demo 8080:80
Forwarding from 127.0.0.1:8080 -> 8080
Forwarding from [::1]:8080 -> 8080

and then in another terminal

$ curl localhost:8080/actuator | jq .
{
  "_links": {
    "self": {
      "href": "http://localhost:8080/actuator",
      "templated": false
    },
    "health": {
      "href": "http://localhost:8080/actuator/health",
      "templated": false
    },
    "health-path": {
      "href": "http://localhost:8080/actuator/health/{*path}",
      "templated": true
    },
    "info": {
      "href": "http://localhost:8080/actuator/info",
      "templated": false
    }
  }
}

There are more features, expressing opinions about how pods should be defined when Spring Boot applications are being deployed.

Spring Profiles

Spring Profiles can be activated by putting profiles in the Microservice spec (as an array). Example:

apiVersion: spring.io/v1
kind: Microservice
metadata:
  name: demo
spec:
  image: springguides/demo
  profiles:
  - mysql

The effect is to generate an EnvVar in the Deployment with SPRING_PROFILES_ACTIVE=mysql.

Bindings

If your namespace has backend services, like databases, which can be exposed as CNB Bindings, then you can list them in the Microservice spec. There is a CRD for ServiceBinding which developers (or operators) can use to define the behaviour of the of all Microservice instances in the same namespace. Example:

apiVersion: spring.io/v1
kind: Microservice
metadata:
  name: bindings
spec:
  image: springguides/demo
  bindings:
  - mysql
  profiles:
  - mysql

Each binding is in the form [namespace/]<name> where the name space is optional. It is used to search for a ServiceBinding in the namespace specified (or the same namepsace as the Microservice if not specified, as in this example).

Actuators

If your application container has Spring Boot Actuators then it probably makes sense to use them as Kubernetes probes. You can do that in one line (accepting the default configuration of liveness and readiness probes):

apiVersion: spring.io/v1
kind: Microservice
metadata:
  name: actr
spec:
  image: springguides/demo
  bindings: actuators

The default binding for "actuators" is a liveness probe on /actuator/info and a readiness probe on /actuator/health. You can change the probe configurations if you need to using a custom binding.

Custom Bindings

A binding carries a patch for the PodTemplateSpec in the app Deployment. It can add a restart policy, annotations, volumes, containers, and init containers, or it can modify the "app" container. Containers can be patched using the volume mounts, env vars, image, command, args, or working dir properties. For example:

apiVersion: spring.io/v1
kind: ServiceBinding
metadata:
  name: prometheus
spec:
  template:
    metadata:
      annotations:
        prometheus.io/path: /actuator/prometheus
        prometheus.io/port: "8080"
        prometheus.io/scrape: "true"

This one adds the annotations that are needed by the Prometheus Helm chart installation to pull metrics from the Spring Boot Actuator endpoint.

An additional feature is that a ServiceBinding can separately specify environment variables for the main app container, in a form that helps with some of the naming conventions in Spring Boot. In particular it permits environment variables which bind to a string array in Spring Boot to accumulate additional content in multiple ServiceBindings. For example, these two bindings applied to a single Microservice will expose the metrics and env Actuator endpoints, in addition to the default info and health:

apiVersion: spring.io/v1
kind: ServiceBinding
metadata:
  name: metrics
spec:
  env:
  - name: MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE
    values:
    - info
    - health
    - metrics
---
apiVersion: spring.io/v1
kind: ServiceBinding
metadata:
  name: env
spec:
  env:
  - name: MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE
    values:
    - info
    - health
    - env

EnvVar entries in a ServiceBinding can have a single value or multiple values. In the case of a single value the last one to bind wins. With multiple values they are merged and written into the app container as a comma-separated list.

CNB Bindings

Services are bound to by name (optionally prefixed with <namespace>/). A useful pattern is to implement the CNB Bindings spec, namely that a binding named <binding> creates directories in the Pod via VolumeMounts at ${CNB_BINDINGS}/<binding>/metadata and ${CNB_BINDINGS}/<binding>/secret. A good way to do that is to create a ConfigMap called <binding>-metadata and optionally a Secret called <binding>-secret. The ConfigMap should have at least the kind, provider and tags entries since those are mandatory for CNB Bindings.

There is an init container that you can use to convert CNB bindings to Spring Boot configuration files. It copies the configuration entries from the binding config maps and secrets into /etc/config/application.properties. The SPRING_CONFIG_LOCATION can then also be set to pick up this location so your application will see those properties as higher priority than those on the classpath, but still lower than system properties or environment variables.

For example if there is a ConfigMap and a Secret, the application.properties entries for the MySQL example might come out like this:

cnb.metadata.other.host=mysql
cnb.metadata.other.kind=mysql
cnb.metadata.other.provider=dsyer
cnb.metadata.other.tags=database,sql
cnb.secret.other.password=test
cnb.secret.other.user=test
cnb.secret.other.database=test

The kind of the Binding.Metadata is also used as a key to locate a transformation rule. The rule is expressed as a set of GO templates that can be rendered from the binding. The templates can be customized by developers (or operators) by including them in the config map (or as a separate config map) and mounting them at ${CNB_BINDINGS}/../templates/<binding>.

There is a sample MySQL service in the project which exposes the right config maps and secrets: look in config/samples/mysql. The MySQL example generates these properties in addition to the cnb.* ones:

spring.datasource.url=jdbc:mysql://mysql/test
spring.datasource.username=test
spring.datasource.password=test

A Spring Boot application with mysql-connector will automatically connect because it matches the default naming conventions in spring-boot-autoconfigure.

Default (Non-Actuators) Bindings

Services are bound to by name (optionally prefixed with <namespace>/). If there is no binding at the namespaced location specified, then a default one is created. Any other binding than "actuators" generates a CNB style ServiceBinding, namely it assumes the existence of a ConfigMap called <binding>-metadata and a Secret called <binding>-secret. The ConfigMap should have at least the kind, provider and tags entries since those are mandatory for CNB Bindings.

Pod Specs

The PodTemplateSpec in the Deployment can be supplied directly in the Microservice spec if desired. The Spring Boot application runs in a Container called "app" by convention (or the first container if there is none called "app"), so any configuration of that Pod in the Microservice is applied to the Deployment. For example, to set an environment variable:

apiVersion: spring.io/v1
kind: Microservice
metadata:
  name: env
spec:
  image: springguides/demo
  template:
    spec:
      containers:
      # the "app" container is special - it doesn't need an image
      - name: app
        env:
        - name: EXT_LIBS
          value: /app/ext

You could add your own probes here, volume mounts, whatever you need to customize the application container. The image is always set to the one in the top of the MicroService spec.

Jobs

Instead of a Deployment and a Service, a MicroService can be a short-lived process, implemented as a Job in Kubernetes. Just make sure the app container is short-lived, and set the job flag in the MicroService. Example:

apiVersion: spring.io/v1
kind: Microservice
metadata:
  name: job
spec:
  job: true
  image: busybox
  args:
    - /bin/sh
    - -c
    - env; find /var/run

Because of the way Kubernetes works, you cannot mutate a Job (e.g. change its Pod spec) once it has started. You need a different name, or to delete the old MicroService, or the old Job instance, in order to run another one.