Kubernetes Testing with CircleCI, Kind, and Skaffold
In this article, you will learn how to use tools like Kind or Skaffold to build integration tests on CircleCI for apps running on Kubernetes. Our main goal in this exercise is to build the app image and verify the Deployment
on Kubernetes in the CircleCI pipeline. Skaffold and Jib Maven plugin build the image from the source and deploy it on Kind using YAML manifests. Finally, we will run some load tests on the deployed app using the Grafana k6 tool and its integration with CircleCI.
If you want to build and run tests against Kubernetes, you can read my article about integration tests with JUnit. On the other hand, if you are looking for other testing tools for testing in a Kubernetes-native environment you can refer to that article about Testkube.
Introduction
Before we start, let’s do a brief introduction. There are three simple Spring Boot apps that communicate with each other. The first-service
app calls the endpoint exposed by the caller-service
app, and then the caller-service
app calls the endpoint exposed by the callme-service
app. The diagram visible below illustrates that architecture.
So in short, our goal is to deploy all the sample apps on Kind during the CircleCI build and then test the communication by calling the endpoint exposed by the first-service
through the Kubernetes Service
.
Source Code
If you would like to try it by yourself, you may always take a look at my source code. In order to do that you need to clone my GitHub repository. It contains three apps: first-service
, caller-service
, and callme-service
. The main Skaffold config manifest is available in the project root directory. Required Kubernetes YAML manifests are always placed inside the k8s
directory. Once you take a look at the source code, you should just follow my instructions. Let’s begin.
Our sample Spring Boot apps are very simple. They are exposing a single “ping” endpoint over HTTP and call “ping” endpoints exposed by other apps. Here’s the @RestController
in the first-service
app:
@RestController
@RequestMapping("/first")
public class FirstController {
private static final Logger LOGGER = LoggerFactory
.getLogger(FirstController.class);
@Autowired
Optional<BuildProperties> buildProperties;
@Autowired
RestTemplate restTemplate;
@Value("${VERSION}")
private String version;
@GetMapping("/ping")
public String ping() {
LOGGER.info("Ping: name={}, version={}", buildProperties.isPresent()
? buildProperties.get().getName() : "first-service", version);
String response = restTemplate.getForObject(
"http://caller-service:8080/caller/ping", String.class);
LOGGER.info("Calling: response={}", response);
return "I'm first-service " + version + ". Calling... " + response;
}
}
Here’s the @RestController
inside the caller-service
app. The endpoint is called by the first-service
app through the RestTemplate
bean.
@RestController
@RequestMapping("/caller")
public class CallerController {
private static final Logger LOGGER = LoggerFactory
.getLogger(CallerController.class);
@Autowired
Optional<BuildProperties> buildProperties;
@Autowired
RestTemplate restTemplate;
@Value("${VERSION}")
private String version;
@GetMapping("/ping")
public String ping() {
LOGGER.info("Ping: name={}, version={}",
buildProperties.or(Optional::empty), version);
String response = restTemplate.getForObject(
"http://callme-service:8080/callme/ping", String.class);
LOGGER.info("Calling: response={}", response);
return "I'm caller-service " + version + ". Calling... " + response;
}
}
Finally, here’s the @RestController
inside the callme-service
app. It also exposes a single GET /callme/ping
endpoint called by the caller-service
app:
@RestController
@RequestMapping("/callme")
public class CallmeController {
private static final Logger LOGGER = LoggerFactory
.getLogger(CallmeController.class);
private static final String INSTANCE_ID = UUID.randomUUID().toString();
private Random random = new Random();
@Autowired
Optional<BuildProperties> buildProperties;
@Value("${VERSION}")
private String version;
@GetMapping("/ping")
public String ping() {
LOGGER.info("Ping: name={}, version={}", buildProperties.isPresent()
? buildProperties.get().getName() : "callme-service", version);
return "I'm callme-service " + version;
}
}
Build and Deploy Images with Skaffold and Jib
Firstly, let’s take a look at the main Maven pom.xml
in the project root directory. We use the latest version of Spring Boot and the latest LTS version of Java for compilation. All three app modules inherit settings from the parent pom.xml
. In order to build the image with Maven we are including jib-maven-plugin
. Since it is still using Java 17 in the default base image, we need to override this behavior with the <from>.<image>
tag. We will declare eclipse-temurin:21-jdk-ubi9-minimal
as the base image. Note that jib-maven-plugin
is activated only if we enable the jib
Maven profile during the build.
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath />
</parent>
<groupId>pl.piomin.services</groupId>
<artifactId>sample-istio-services</artifactId>
<version>1.1.0</version>
<packaging>pom</packaging>
<properties>
<java.version>21</java.version>
</properties>
<modules>
<module>caller-service</module>
<module>callme-service</module>
<module>first-service</module>
</modules>
<profiles>
<profile>
<id>jib</id>
<build>
<plugins>
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>3.4.0</version>
<configuration>
<from>
<image>eclipse-temurin:21-jdk-ubi9-minimal</image>
</from>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
Now, let’s take a look at the main skaffold.yaml
file. Skaffold builds the image using Jib support and deploys all three apps on Kubernetes using manifests available in the k8s/deployment.yaml
file inside each app module. Skaffold disables JUnit tests for Maven and activates the jib
profile. It is also able to deploy Istio objects after activating the istio
Skaffold profile. However, we won’t use it today.
apiVersion: skaffold/v4beta5
kind: Config
metadata:
name: simple-istio-services
build:
artifacts:
- image: piomin/first-service
jib:
project: first-service
args:
- -Pjib
- -DskipTests
- image: piomin/caller-service
jib:
project: caller-service
args:
- -Pjib
- -DskipTests
- image: piomin/callme-service
jib:
project: callme-service
args:
- -Pjib
- -DskipTests
tagPolicy:
gitCommit: {}
manifests:
rawYaml:
- '*/k8s/deployment.yaml'
deploy:
kubectl: {}
profiles:
- name: istio
manifests:
rawYaml:
- k8s/istio-*.yaml
- '*/k8s/deployment-versions.yaml'
- '*/k8s/istio-*.yaml'
Here’s the typical Deployment
for our apps. The app is running on the 8080 port.
apiVersion: apps/v1
kind: Deployment
metadata:
name: first-service
spec:
replicas: 1
selector:
matchLabels:
app: first-service
template:
metadata:
labels:
app: first-service
spec:
containers:
- name: first-service
image: piomin/first-service
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
env:
- name: VERSION
value: "v1"
For testing purposes, we need to expose the first-service
outside of the Kind cluster. In order to do that, we will use the Kubernetes NodePort
Service
. Our app will be available under the 30000
port.
apiVersion: v1
kind: Service
metadata:
name: first-service
labels:
app: first-service
spec:
type: NodePort
ports:
- port: 8080
name: http
nodePort: 30000
selector:
app: first-service
Note that all other Kubernetes services (“caller-service” and “callme-service”) are exposed only internally using a default ClusterIP type.
How It Works
In this section, we will discuss how we would run the whole process locally. Of course, our goal is to configure it as the CircleCI pipeline. In order to expose the Kubernetes Service
outside Kind we need to define the externalPortMappings
section in the configuration manifest. As you probably remember, we are exposing our app under the 30000
port. The following file is available in the repository under the k8s/kind-cluster-test.yaml
path:
apiVersion: kind.x-k8s.io/v1alpha4
kind: Cluster
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 30000
hostPort: 30000
listenAddress: "0.0.0.0"
protocol: tcp
Assuming we already installed kind
CLI on our machine, we need to execute the following command to create a new cluster:
$ kind create cluster --name c1 --config k8s/kind-cluster-test.yaml
You should have the same result as visible on my screen:
We have a single-node Kind cluster ready. There is a single c1-control-plane container running on Docker. As you see, it exposes 30000
port outside of the cluster:
The Kubernetes context is automatically switched to kind-c1
. So now, we just need to run the following command from the repository root directory to build and deploy the apps:
$ skaffold run
If you see a similar output in the skaffold run
logs, it means that everything works fine.
We can verify a list of Kubernetes services. The first-service
is exposed under the 30000
port as expected.
$ kubectl get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
caller-service ClusterIP 10.96.47.193 <none> 8080/TCP 2m24s
callme-service ClusterIP 10.96.98.53 <none> 8080/TCP 2m24s
first-service NodePort 10.96.241.11 <none> 8080:30000/TCP 2m24s
Assuming you have already installed the Grafana k6 tool locally, you may run load tests using the following command:
$ k6 run first-service/src/test/resources/k6/load-test.js
That’s all. Now, let’s define the same actions with the CircleCI workflow.
Test Kubernetes Deployment with the CircleCI Workflow
The CircleCI config.yml
file should be placed in the .circle
directory. We are doing two things in our pipeline. In the first step, we are executing Maven unit tests without the Kubernetes cluster. That’s why we need a standard executor with OpenJDK 21 and the maven ORB. In order to run Kind during the CircleCI build, we need to have access to the Docker daemon. Therefore, we use the latest version of the ubuntu-2204
machine.
version: 2.1
orbs:
maven: circleci/maven@1.4.1
executors:
jdk:
docker:
- image: 'cimg/openjdk:21.0'
machine_executor_amd64:
machine:
image: ubuntu-2204:2023.10.1
environment:
architecture: "amd64"
platform: "linux/amd64"
After that, we can proceed to the job declaration. The name of our job is deploy-k8s. It uses the already-defined machine executor. Let’s discuss the required steps after running a standard checkout command:
- We need to install the
kubectl
CLI and copy it to the/usr/local/bin
directory. Skaffold useskubectl
to interact with the Kubernetes cluster. - After that, we have to install the
skaffold
CLI - Our job also requires the
kind
CLI to be able to create or delete Kind clusters on Docker… - … and the Grafana
k6
CLI to run load tests against the app deployed on the cluster - There is a good chance that this step won’t required once CircleCI releases a new version of ubuntu-2204 machine (probably 2024.1.1 according to the release strategy). For now, ubuntu-2204 provides OpenJDK 17, so we need to install OpenJDK 17 to successfully build the app from the source code
- After installing all the required tools we can create a new Kubernetes with the
kind create cluster
command. - Once a cluster is ready, we can deploy our apps using the
skaffold run
command. - Once the apps are running on the cluster, we can proceed to the tests phase. We are running the test defined inside the
first-service/src/test/resources/k6/load-test.js
file. - After doing all the required steps, it is important to remove the Kind cluster
jobs:
deploy-k8s:
executor: machine_executor_amd64
steps:
- checkout
- run: # (1)
name: Install Kubectl
command: |
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
sudo mv ./kubectl /usr/local/bin/kubectl
- run: # (2)
name: Install Skaffold
command: |
curl -Lo skaffold https://storage.googleapis.com/skaffold/releases/latest/skaffold-linux-amd64
chmod +x skaffold
sudo mv skaffold /usr/local/bin
- run: # (3)
name: Install Kind
command: |
[ $(uname -m) = x86_64 ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.20.0/kind-linux-amd64
chmod +x ./kind
sudo mv ./kind /usr/local/bin/kind
- run: # (4)
name: Install Grafana K6
command: |
sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update
sudo apt-get install k6
- run: # (5)
name: Install OpenJDK 21
command: |
java -version
sudo apt-get update && sudo apt-get install openjdk-21-jdk
sudo update-alternatives --set java /usr/lib/jvm/java-21-openjdk-amd64/bin/java
sudo update-alternatives --set javac /usr/lib/jvm/java-21-openjdk-amd64/bin/javac
java -version
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
- run: # (6)
name: Create Kind Cluster
command: |
kind create cluster --name c1 --config k8s/kind-cluster-test.yaml
- run: # (7)
name: Deploy to K8s
command: |
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
skaffold run
- run: # (8)
name: Run K6 Test
command: |
kubectl get svc
k6 run first-service/src/test/resources/k6/load-test.js
- run: # (9)
name: Delete Kind Cluster
command: |
kind delete cluster --name c1
Here’s the definition of our load test. It has to be written in JavaScript. It defines some thresholds like a % of maximum failed requests or maximum response time for 95% of requests. As you see, we are testing the http://localhost:30000/first/ping
endpoint:
import { sleep } from 'k6';
import http from 'k6/http';
export const options = {
duration: '60s',
vus: 10,
thresholds: {
http_req_failed: ['rate<0.25'],
http_req_duration: ['p(95)<1000'],
},
};
export default function () {
http.get('http://localhost:30000/first/ping');
sleep(2);
}
Finally, the last part of the CircleCI config file. It defines pipeline workflow. In the first step, we are running tests with Maven. After that, we proceeded to the deploy-k8s
job.
workflows:
build-and-deploy:
jobs:
- maven/test:
name: test
executor: jdk
- deploy-k8s:
requires:
- test
Once we push a change to the sample Git repository we trigger a new CircleCI build. You can verify it by yourself here in my CircleCI project page.
As you see all the pipeline steps have been finished successfully.
We can display logs for every single step. Here are the logs from the k6 load test step.
There were some errors during the warm-up. However, the test shows that our scenario works on the Kubernetes cluster.
Final Thoughts
CircleCI is one of the most popular CI/CD platforms. Personally, I’m using it for running builds and tests for all my demo repositories on GitHub. For the sample projects dedicated to the Kubernetes cluster, I want to verify such steps as building images with Jib, Kubernetes deployment scripts, or Skaffold configuration. This article shows how to easily perform such tests with CircleCI and Kubernetes cluster running on Kind. Hope it helps 🙂
Leave a Reply