Tags:#java#kubernetes#spring-boot

Kubernetes with Java - Introduction

Salman Malik
By Salman Malik
Spring expert, Kubernetes expert, Borders, ITHAKA/JSTOR.

What are we going to do?

  • Learn how to initialize the k8s api client in a java spring-boot application
  • Extract metadata from deployments in a namespace and transform that metadata into new views
  • Prepare you for more sophisticated problem solving using the k8s API in future articles

Motivation

  • You have a few dozen different applications (as Kubernetes deployment resources) running in your cluster
  • All of those applications use labels to designate the name of the team that manages that application, and the application name
  • You want to provide APIs that:
    • Lists all teams that have applications running in the cluster
    • Lists all apps that belong to a team

Scenario

Assumption: you have minikube locally and you don't already have a namespace called dev. First, let's create the namespace where we will start our sample applications:
kubectl create namespace dev
Now, let's create some deployments to model lots of different teams with lots of apps. This creates 5 teams with 9 applications each (petnames.txt contains 50 petnames).
curl -Lo petnames.txt https://fnjoin.com/data/2021-08-21-show-pods-petnames.txt 
cat petnames.txt | paste - - - - - - - - - - | \
while read line ; do 
  set -- $line
  team=team-$1
  shift
  for app ; do 
    app=app-$app
    kubectl create deployment $app --image=k8s.gcr.io/echoserver:1.4 -n dev
    kubectl label deployment $app team=$team -n dev
  done
done
Once that completes you'll have 45 echoservers running. Running kubectl get deployments -n dev would reveal output like:
NAME                         READY   UP-TO-DATE   AVAILABLE   AGE
app-acaroid-emilee           0/1     1            0           47s
app-ageless-lynna            0/1     1            0           35s
app-airless-jettie           0/1     1            0           12s
app-announceable-yasmin      0/1     1            0           18s
app-apocopic-chanel          0/1     1            0           27s
app-benmost-rudy             0/1     1            0           39s
app-blastular-sparkle        0/1     1            0           29s
app-bromidic-kamden          0/1     1            0           23s
app-buggier-helga            0/1     1            0           32s
app-bullheaded-tran          0/1     1            0           19s
....

If you wanted to get more information about the deployments, you could get the output of the above command in JSON format and transform it with something like jq to get the relevant information out like so:
kubectl get deployments -n dev -o json | jq '[ .items[] | { name: .metadata.labels.app, team: .metadata.labels.team, readyInstances: .status.readyReplicas }]'
This command will transform the information from kubectl to show a list of apps along with their team names, and how many instances of each are ready. This capability to access Kubernetes information from CLI is very powerful on its own but we want to provide this information through an API since most people in our fictional organization won't have access to run the kubectl commands against the cluster for security reasons.
This is a contrived example, as almost all Kubernetes platforms make some kind of dashboard available that let you view resources in the cluster along with their labels. This example mainly shows you how one would go about interacting with the Kubernetes API using Java.

Introducing the Kubernetes Java Client libraries

Kubernetes API clients are published in multiple languages. The Java version of these libraries are published in public maven repositories. As of this writing, version 13.0.0 is the latest version available. Depending on which version of Kubernetes cluster you will be targeting, it is advisable to check the official compatibility matrix. If using gradle, you can add the following dependency to your build.gradle:
implementation 'io.kubernetes:client-java-extended:13.0.0'
If using maven, then add the following dependency to pom.xml:
<dependency>
    <groupId>io.kubernetes</groupId>
    <artifactId>client-java-extended</artifactId>
    <version>13.0.0</version>
</dependency>

Initializing the client

Before using the Kubernetes client, we need to initialize it by letting it know how to connect to the Kubernetes cluster's API server. There are two variations of this. Either we will be running this app inside the Kubernetes cluster as a pod itself or we will be running outside of the cluster we want to target. In either case io.kubernetes.client.openapi.ApiClient encapsulates that connection.
We will leave the details on how to initialize the client when running inside a Kubernetes cluster for a later post. When running this app outside a Kubernetes cluster, we will use the same mechanism kubectl uses to connect to a remote Kubernetes cluster. The following code will instantiate a spring bean of type ApiClient:
import io.kubernetes.client.openapi.ApiClient;
import io.kubernetes.client.util.ClientBuilder;
import io.kubernetes.client.util.KubeConfig;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.io.File;
import java.io.FileReader;
import java.io.IOException;

@Slf4j
@Configuration
public class ApiClientConfig {

    @Bean
    public ApiClient externalApiClient() throws IOException {
        KubeConfig kubeConfig = KubeConfig.loadKubeConfig(new FileReader(getConfigFile()));
        log.info("Current Context: Name={}", kubeConfig.getCurrentContext());
        return ClientBuilder
                .kubeconfig(kubeConfig)
                .build();
    }

    private File getConfigFile() {
        String kubeConfigVar = System.getenv("KUBECONFIG");
        if (StringUtils.isNotBlank(kubeConfigVar)) {
            log.info("Using KUBECONFIG variable: Value='{}'", kubeConfigVar);
            return new File(kubeConfigVar);
        } else {
            File configDir = new File(System.getProperty("user.home"), ".kube");
            File configFile = new File(configDir, "config");
            log.info("Using home file: Path='{}'", configFile.getPath());
            return configFile;
        }
    }
}

Use the client to lookup the deployments

Now that we have the api-client, we are ready to lookup the deployments metadata. Here we will make some assumptions:
  • We are only interested in deployments running in the dev namespace
  • We are only interested in deployments containing app and team labels
  • We are interested in the following attributes of the deployments:
    • App name
    • Team that app belongs to
    • How many instances/replicas of that app are running/ready
We will first declare a Java bean that will hold the information about a running application, lets call it TeamApp. We are using project Lombok to cut down on boiler-plate code here. We will also be using the builder pattern for this bean:
import lombok.Builder;
import lombok.Value;

@Value
@Builder
public class TeamApp {
    String name;
    String team;
    int readyInstances;
}
We will declare an interface that is the contract for our API. One method will provide the names for teams that have applications running in the cluster. The other method will return list of team-app objects for a given team. the interface will look like:
import java.util.List;
import java.util.Set;

public interface TeamAppsService {

    String APP_LABEL = "app";
    String TEAM_LABEL = "team";

    Set<String> listTeams();
    List<TeamApp> listTeamApps(String team);
}
Next we will create an implementation of this interface and expose it as spring bean:
import io.kubernetes.client.openapi.ApiClient;
import io.kubernetes.client.openapi.apis.AppsV1Api;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class SynchronousTeamAppsService implements TeamAppsService {

    private final AppsV1Api appsV1Api;
    private final String namespace;

    public SynchronousTeamAppsService(
            ApiClient client,
            @Value("${namespace}") String namespace) {
        
        log.info("Creating synchronous team-app service, Namespace={}", namespace);
        this.appsV1Api = new AppsV1Api(client);
        this.namespace = namespace;
    }

    ...
}
In the constructor above, api-client bean is injected by Spring, which is used to create an AppsV1Api object - this is the object we will be dealing with to access app level Kubernetes objects like Deployment and StatefulSet among others. There are other API objects in the io.kubernetes.client.openapi.apis package that deal with other kinds of built-in Kubernetes objects. For example, we can use CoreV1Api to deal with Pod, ConfigMap, Secret, and Service.
Next is the namespace variable that is injected into this bean by Spring. The application.properties file in the project sets this value as dev but it can be overridden at runtime through various Spring Framework means.
public Set<String> listTeams() {
    return appsV1Api.listNamespacedDeployment(
                    namespace,
                    null,
                    null,
                    null,
                    null,
                    TEAM_LABEL,
                    null,
                    null,
                    null,
                    null,
                    null)
            .getItems()
            .stream()
            .map(deployment -> deployment.getMetadata().getLabels().get("team"))
            .collect(Collectors.toSet());
}
The star of this functionality is the AppsV1Api.listNamespacedDeployment() call with lots of null values being passed in. To understand what these values are, here is the source code of this method's signature from the library:
public V1DeploymentList listNamespacedDeployment(
        String namespace,
        String pretty,
        Boolean allowWatchBookmarks,
        String _continue,
        String fieldSelector,
        String labelSelector,
        Integer limit,
        String resourceVersion,
        String resourceVersionMatch,
        Integer timeoutSeconds,
        Boolean watch)
        throws ApiException {
    ...
}
This Java API mimics the Kubernetes API very closely. All these arguments are meant for more advanced usages, but for our purposes, supplying namespace and labelSelector parameters are sufficient. The labelselector parameter is a string that can be formatted according to the official documentation. We will supply the label name of team as the value for that parameter. This ensures that returned pods will have that label present no matter what the label values are.
This method returns a V1DeploymentList which has the getItems() method which can give us a list of V1Deployment objects. Once we have that list, we can use the Java stream mechanism to transform each object to a team name, adding them to a Java set which will take care of duplicates. Like the V1Deployment object, most Kubernetes built-in resources have a corresponding class in the io.kubernetes.client.openapi.models package.
There is a pattern at play here. In the api object you'll find methods of the form listNamespacedSomething() that pretty much takes this exact set of arguments. It returns a V1SomethingList object which has a getItems() method that returns a list of V1Something objects.
Finally, we need to implement the TeamAppsService.listTeamApps() method. It will use the same api call but instead of mapping V1Deployment to a team name, it will map them to TeamApp objects. The relevant code will look like:
public List<TeamApp> listTeamApps(String team) {
    return appsV1Api.listNamespacedDeployment(
                    namespace,
                    null,
                    null,
                    null,
                    null,
                    TEAM_LABEL + "=" + team,
                    null,
                    null,
                    null,
                    null,
                    null)
            .getItems()
            .stream()
            .map(this::toTeamApp)
            .collect(Collectors.toList());
}

private TeamApp toTeamApp(V1Deployment v1Deployment) {
    return TeamApp.builder()
            .name(v1Deployment.getMetadata().getLabels().get(APP_LABEL))
            .team(v1Deployment.getMetadata().getLabels().get(TEAM_LABEL))
            .readyInstances(v1Deployment.getStatus().getReadyReplicas())
            .build();
}
Also, notice that the labelSelector argument is of the form team=TEAM_NAME. This will ensure we only get deployment objects belonging to the given team.

Using the service to serve the results

At this point, we can use this service bean to lookup the information. We will introduce a controller to front this service functionality. The controller can look like this:
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.Set;

@RestController
@RequiredArgsConstructor
public class TeamAppsController {

    private final TeamAppsService service;

    @GetMapping("/teams")
    private Set<String> listTeams() {
        return service.listTeams();
    }

    @GetMapping("/teams/{team}/apps")
    private List<TeamApp> listTeamApps(@PathVariable String team) {
        return service.listTeamApps(team);
    }
}
Notice the use of @RequiredArgsConstructor annotation. It is another time saver from project Lombok. It generates a class constructor that accepts parameters for all private final variables in the class and sets them accordingly. Spring then notices this constructor and has no choice but to inject the matching required beans. Even though our service implementation class is SynchronousTeamAppsService, spring will inject it for the service variable since that bean implements the TeamAppsService interface.

Whats next

The APIs we used above make synchronous calls to the Kubernetes API server. In a future post, we will explore how to enable the same functionality using asynchronous mechanisms provided by the Kubernetes API.

Conclusion

In this post we introduced the Kubernetes Java Client. It is relatively easy to use the library to automate, monitor, and/or extend the Kubernetes platform. Most of the community uses Go programming language for these purposes and there are lots of good reasons to do that. Fast startup times, small footprint native executables, efficient threading support, etc.
Java programs can achieve all these qualities as well. Java has a large open-source libraries ecosystem and the collective community has vast experience in designing resilient and scalable systems. The prevalence of spring-boot, reactive programming, and upcoming native-image capabilities with GraalVM are good indicators that Java will stay relevant even in the Kubernetes ecosystem.

Referenced Code

To see working code from this post (with slight modifications), see the Git repository.

© 2021 - 2024 Salman Malik