Fast Local Development of Spring Boot Apps on k8s With Skaffold & Telepresence

Developing applications on Kubernetes can be challenging, especially when setting up a development environment that mirrors the production environment. This process can be complex and time-consuming, and developers often have to make trade-offs between speed and accuracy. Furthermore, Kubernetes applications are packaged as containers, which adds an extra layer of complexity to the development process — developers now have to create and manage container images in addition to writing code.

The complexity of developing applications on Kubernetes is heightened when debugging an issue in a cluster that runs on multiple nodes. Debugging a distributed system can be a challenging task.

In this article, you will learn how to use Skaffold and Telepresence to achieve blazing-fast local development of Spring Boot applications running on Kubernetes.

Prerequisites

For this tutorial, it is required that the following is available on your local setup:

Anatomy of the Spring Boot application

To illustrate how Skaffold and Telepresence operate with Spring Boot applications, let’s begin by imagining a situation where we have a pair of microservices built with Spring Boot.

  • The Order service is responsible for managing orders.

  • The Payment service is responsible for processing payments.

The following sequence diagram shows the interaction between Order and Payment microservices.

Here’s a brief explanation of what’s happening in the diagram:

  1. First, the Client sends a POST request to the Order Service to create a new order. The Order Service calls the OrderRepository to save the order and then calls the Payment Service through the PaymentServiceProxy to process the payment.

  2. The PaymentServiceProxy sends a POST request to the Payment Service to create a new payment, which is then saved in the PaymentRepository. The Payment Service returns the saved payment to the PaymentServiceProxy, which returns it to the Order Service.

  3. The Order Service updates the order with the payment status received from the Payment Service and saves the updated order in the Order Repository. The Order Service returns the updated order to the Client.

Creating the Order and Payment microservices

To implement this scenario, we need to create two Spring Boot applications, one for each microservice. Here are the steps to create the microservices.

Creating the Order service

You can use your preferred IDE and the Spring Initializr website to generate the projects with the following dependencies: Spring Web, Spring Data JPA, H2 In-Memory Database, and Spring Cloud OpenFeign. After creating the project, create the OrderRepository.java and OrderController.java classes as explained below.

OrderRepository.java: This interface extends the JpaRepository and defines methods to perform CRUD operations on orders.

public interface OrderRepository extends JpaRepository<OrderValueObject, Long> {}

We need to create a Feign client in the Order Service project to call the Payment Service:

@FeignClient(name = "payment-service", url = "http://payment-service:8081")
public interface PaymentServiceFeignClient {
   @PostMapping("/payments")
   PaymentValueObject processPayment(@RequestBody PaymentValueObject paymentValueObject);
}

We will also need to configure the Order Service to use the Payment Service Feign client by creating a PaymentServiceProxy class:

@Service
public class PaymentServiceProxy {
   @Autowired
   private PaymentServiceFeignClient paymentServiceFeignClient;

   public PaymentValueObject processPayment(PaymentValueObject paymentValueObject) {
       return paymentServiceFeignClient.processPayment(paymentValueObject);
   }
}

OrderController.java: This class defines REST endpoints to create and retrieve orders. In the code snippet below, you can see that we are calling the Payment Service from Order Service while creating an order.

@RestController
public class OrderController {
   @Autowired
   private OrderRepository orderRepository;

   @Autowired
   private PaymentServiceProxy paymentServiceProxy;

   @PostMapping("/orders")
   public OrderValueObject createOrder(@RequestBody OrderValueObject orderValueObject) {
       orderValueObject.setPaymentStatus("pending");
       OrderValueObject savedOrderValueObject = orderRepository.save(orderValueObject);

       PaymentValueObject paymentValueObject = new PaymentValueObject();
       paymentValueObject.setOrderId(savedOrderValueObject.getId());
       paymentValueObject.setAmount(savedOrderValueObject.getPrice());
       paymentValueObject.setPaymentStatus("pending");
       PaymentValueObject savedPaymentValueObject = paymentServiceProxy.processPayment(paymentValueObject);

       savedOrderValueObject.setPaymentStatus(savedPaymentValueObject.getPaymentStatus());
       return orderRepository.save(savedOrderValueObject);
   }

   @GetMapping("/orders/{id}")
   public OrderValueObject getOrder(@PathVariable Long id) {
       return orderRepository.findById(id)
               .orElseThrow(() -> new RuntimeException("Order not found"));
   }
}

Creating the Payment service

To create the payment service, create a new Spring Boot project with the following dependencies: Spring Web, Spring Data JPA, and H2 In-Memory Database. Then create the PaymentController.java and PaymentRepository.java classes as explained below.

PaymentController.java: This class defines REST endpoints to process payments.

@RestController("/payments")
public class PaymentController {
   @Autowired
   private PaymentRepository paymentRepository;

   @PostMapping("/payments")
   public PaymentValueObject processPayment(@RequestBody PaymentValueObject paymentValueObject) {
       // perform payment processing logic here
       paymentValueObject.setPaymentStatus("success");
       return paymentRepository.save(paymentValueObject);
   }
}

PaymentRepository.java: This interface extends the JpaRepository and defines methods to perform CRUD operations on payments.

public interface PaymentRepository extends JpaRepository<PaymentValueObject, Long> {}

The entire source code explained above can be found in this GitHub repository.

Containerizing the microservices

The next thing we are going to do is containerize our newly created applications using Docker. To do this, create a Dockerfile in the root directory of each spring boot application (the Order and Payment services), specify the base image, copy the application files, and set the entry point command. Next, find the docker file for each of these microservices below — copy and paste them into the respective docker files.

Dockerfile for the Order microservice:

FROM openjdk:17
COPY target/*.jar app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

Dockerfile for the Payment microservice:

FROM openjdk:17
RUN mkdir /app
COPY target/*SNAPSHOT-exe.jar /app/app.jar
WORKDIR /app
ENTRYPOINT ["java","-jar","/app/app.jar"]

Accelerating inner dev loop with Skaffold

Skaffold is a powerful tool that streamlines the development workflow for Kubernetes-based applications by automating the build, push, and deploy steps of cloud-native applications, simplifying the process of quickly iterating and deploying changes. By utilizing the Dockerfiles provided in the previous section, Skaffold can build and deploy images to the Kubernetes cluster. Refer to the official documentation to download and install Skaffold on your system.

Using the skaffold init command, we can generate a skaffold.yaml file in the project’s root directory to set up Skaffold for building and deploying these microservices.

apiVersion: skaffold/v4beta3
kind: Config
metadata:
 name: microservices
build:
 artifacts:
   - image: order-service
     context: order-service
     docker:
       dockerfile: Dockerfile
   - image: payment-service
     context: payment-service
     docker:
       dockerfile: Dockerfile
manifests:
 rawYaml:
   - k8s/order-manifest.yml
   - k8s/payment-manifest.yml

Utilizing the Kubernetes manifests accessible under the k8s directory, this configuration file instructs Skaffold to build the Docker images for the Order and Payment microservices and deploy them to the Kubernetes cluster. The code below represents the Kubernetes manifests for the Order Microservice.

apiVersion: apps/v1
kind: Deployment
metadata:
 name: order-service
 namespace: default
spec:
 replicas: 1
 selector:
   matchLabels:
     app.kubernetes.io/name: order-service
     app.kubernetes.io/version: 0.0.1-SNAPSHOT
 template:
   metadata:
     labels:
       app.kubernetes.io/name: order-service
       app.kubernetes.io/version: 0.0.1-SNAPSHOT
   spec:
     containers:
       - name: order-service
         image: order-service
         imagePullPolicy: IfNotPresent
---
apiVersion: v1
kind: Service
metadata:
 name: order-service
 namespace: default
spec:
 ports:
   - name: http
     port: 8080
     protocol: TCP
     targetPort: 8080
 selector:
   app.kubernetes.io/name: order-service
   app.kubernetes.io/version: 0.0.1-SNAPSHOT
 type: ClusterIP

The code below represents the Kubernetes manifests for the Payment Microservice.

apiVersion: apps/v1
kind: Deployment
metadata:
 name: payment-service
 namespace: default
spec:
 replicas: 1
 selector:
   matchLabels:
     app.kubernetes.io/name: payment-service
     app.kubernetes.io/version: 0.0.1-SNAPSHOT
 template:
   metadata:
     labels:
       app.kubernetes.io/name: payment-service
       app.kubernetes.io/version: 0.0.1-SNAPSHOT
   spec:
     containers:
       - name: payment-service
         image: payment-service
         imagePullPolicy: IfNotPresent
---
apiVersion: v1
kind: Service
metadata:
 name: payment-service
 namespace: default
spec:
 ports:
   - name: http
     port: 8081
     protocol: TCP
     targetPort: 8081
 selector:
   app.kubernetes.io/name: payment-service
   app.kubernetes.io/version: 0.0.1-SNAPSHOT
 type: ClusterIP

Finally, run the skaffold dev command to deploy the microservices. This command tells Skaffold to continuously watch for changes in the code and automatically rebuild and redeploy the microservices. On running this command, you should see the following output:

$ skaffold dev
Generating tags...
- order-service -> order-service:22abffd
- payment-service -> payment-service:22abffd
Checking cache...
- order-service: Not found. Building
- payment-service: Not found. Building
Starting build...
Found [docker-desktop] context, using local docker daemon.
Building [payment-service]...
Target platforms: [linux/amd64]
.....
Build [payment-service] succeeded
Building [order-service]...
.....
Build [order-service] succeeded
Tags used in deployment:
- order-service -> order-service:f1eeffee29324d21a00ef97830ff36538c5060b69827b3f1365a0d9a8de4ba01
- payment-service -> payment-service:fbf01bb6276294a3f9bad25f1ac2c3fbd43813d7bf7549f05c94bb3c3adda0e1
Starting deploy...
- deployment.apps/order-service created
- service/order-service created
- deployment.apps/payment-service created
- service/payment-service created
Waiting for deployments to stabilize...
- deployment/order-service: unable to determine current service state of pod "order-service-6d78fbcc78-m8ncv"
   - pod/order-service-6d78fbcc78-m8ncv: unable to determine current service state of pod "order-service-6d78fbcc78-m8ncv"
- deployment/payment-service: unable to determine current service state of pod "payment-service-98f85d574-75f8x"
   - pod/payment-service-98f85d574-75f8x: unable to determine current service state of pod "payment-service-98f85d574-75f8x"
- deployment/payment-service is ready. [1/2 deployment(s) still pending]
- deployment/order-service is ready.
Deployments stabilized in 19.534 seconds
Listing files to watch...
- order-service
- payment-service
Press Ctrl+C to exit
Watching for changes...
[payment-service]
[payment-service]   .   ____          _            __ _ _
[payment-service]  /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
[payment-service] ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
[payment-service]  \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
[payment-service]   '  |____| .__|_| |_|_| |_\__, | / / / /
[payment-service]  =========|_|==============|___/=/_/_/_/
[payment-service]  :: Spring Boot ::                (v2.7.9)
[payment-service]
[order-service]
[order-service]   .   ____          _            __ _ _
[order-service]  /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
[order-service] ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
[order-service]  \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
[order-service]   '  |____| .__|_| |_|_| |_\__, | / / / /
[order-service]  =========|_|==============|___/=/_/_/_/
[order-service]  :: Spring Boot ::                (v2.7.9)

Fast local development and debugging with Telepresence

Telepresence is a Cloud Native Computing Foundation project that facilitates local development of Kubernetes applications by providing a “teleportation” of the local development environment into a Kubernetes cluster. Although Telepresence is not a complete solution for Kubernetes application development, it can be seamlessly integrated into the workflow to help developers quickly test and iterate changes locally before deploying them to the cluster.

Compared to Skaffold, Telepresence takes a step further by eliminating the need to build, push, and deploy your application container images. Instead, Telepresence allows developers to run their code directly on a remote Kubernetes cluster without needing a local container registry or deployment process. This approach can save time by speeding up and streamlining development, especially for teams working on complex applications with multiple dependencies.

Installing and configuring Telepresence

If you are using any other operating system other than macOS, then visit Telepresence’s official documentation for installation instructions. If you have a macOS, run this command brew install datawire/blackbird/telepresence to install Telepresence.

Then run the telepresence version command to verify your installation.

$ telepresence version
Client         : v2.11.1
Root Daemon    : v2.11.1
User Daemon    : v2.11.1
Traffic Manager: not connected

Use Telepresence to connect your local development environment to the remote cluster by running the telepresence connect command.

$ telepresence connect
Launching Telepresence Root Daemon
Need root privileges to run: /usr/local/bin/telepresence daemon-foreground /Users/ashish/Library/Logs/telepresence '/Users/ashish/Library/Application Support/telepresence'
Password: <insert your password here>
Launching Telepresence User Daemon
telepresence connect: error: connector.Connect: traffic manager not found, if it is not installed, please run 'telepresence helm install'. If it is installed, try connecting with a --manager-namespace to point telepresence to the namespace it's installed in..

As you can see in the above output, there is an issue. We must also install the Traffic Manager using the telepresence helm install command. Let’s do that now.

$ telepresence helm install
Telepresence Daemons disconnecting…done
Traffic Manager installed successfully

Now, if we run the telepresence connect command again, you’ll see it will connect successfully.

$ telepresence connect
Connected to context docker-desktop (https://kubernetes.docker.internal:6443)

Looks like we are all set. Let’s try to access the Order service remotely deployed to the Kubernetes cluster.

$ curl -H 'Content-Type: application/json' \
     -d '{"customerName": "mike","productName": "apple","price": 400.00}' \
     -X POST \
     order-service.default.svc.cluster.local:8080/orders | jq
{
 "id": 2,
 "customerName": "mike",
 "productName": "apple",
 "price": 400,
 "paymentStatus": "success"
}

We can connect to the remote Kubernetes Service as if our development machine is within the cluster. It is important to remember that the Kubernetes service type is designated as ClusterIP. So it is impossible to access it directly, i.e., outside the cluster. To do so, we require the assistance of Telepresence, which is exactly what we have done in this instance.

Intercepting traffic with Telepresence

In this step, we will establish an intercept, essentially a directive for Telepresence to route all traffic intended for the OrderService to the locally running version of the service.

  • First, start the local instance of OrderService using the mvn spring-boot:run command. In this code version, we will deliberately change paymentStatus to pending for all the orders.’

  • For verification, curl the service running locally to confirm paymentStatus set to pending.

$ curl -H 'Content-Type: application/json' \
     -d '{"customerName": "mike","productName": "apple","price": 400.00}' \
     -X POST \
     localhost:8080/orders | jq
{
 "id": 2,
 "customerName": "mike",
 "productName": "apple",
 "price": 400,
 "paymentStatus": "pending"
}

Great. Now let’s start the intercept using the telepresence intercept command by giving the name of the service and the port.

$ telepresence intercept order-service --port 8080
Using Deployment order-service
intercepted
  Intercept name         : order-service
  State                  : ACTIVE
  Workload kind          : Deployment
  Destination            : 127.0.0.1:8080
  Service Port Identifier: http
  Volume Mount Error     : sshfs is not installed on your local machine
  Intercepting           : all TCP requests

Before hitting the remote order service, note that paymentStatus is always returned as a success in the response in the version deployed to Kubernetes. Now curl the remote service again.

From the following output, it is clear that Telepresence rerouted the traffic to the local running version of the application; hence, the paymentStatus is set as pending.

curl -H 'Content-Type: application/json' \
    -d '{"customerName": "mike","productName": "apple","price": 400.00}' \
    -X POST \
    order-service.default.svc.cluster.local:8080/orders | jq
{
"id": 1,
"customerName": "mike",
"productName": "apple",
"price": 400,
"paymentStatus": "pending"
}

If you want to route a subset of the traffic, then you’d have to utilize personal intercept. You can enable personal intercepts by authenticating to Ambassador Cloud using the telepresence login command.

Our demonstration of Telepresence has now ended. With Telepresence, we don’t have to go through the build, push, deploy, and test cycle. This helps us get feedback faster.

Wrapping Up

The article discusses developing Spring Boot applications quickly and efficiently on Kubernetes using Skaffold and Telepresence. The traditional development workflows can be slow and frustrating, especially when testing and debugging on Kubernetes. Fortunately, many tools and resources are available to help simplify the process.

With the right approach, it is possible to create an efficient and effective local development environment for workloads running on Kubernetes. So don’t let the challenges of developing on Kubernetes hold you back.

Following the steps outlined in the article, developers can streamline their workflow and focus on building and testing their applications without being slowed down by infrastructure concerns.

Did you find this article valuable?

Support Ashish Choudhary's Blog by becoming a sponsor. Any amount is appreciated!