Summary
Startups stecken viel Geld in unnötige Cloud-Infrastruktur, bevor sie Gewinne abwerfen. Das Beispiel zeigt ein voll-automatisiertes IaC-Deployment auf einem Kubernetes-Cluster, und das zu nur 15 % der Kosten eines AWS-Kubernetes-Deployments.
Projekt auf Github: https://github.com/trettstadt/spring-boot-microservice
Architektur
Microservices lassen sich auf unzählige Arten entwickeln, aber wegen der Stabilität und der hohen Verfügbarkeit von Entwicklern hat sich im Enterprise-Umfeld Java mit Spring-Boot bewährt. Für den Service wird außerdem die Ports-and-Adapters-Architektur verwendet, die eine strenge Trennung zwischen Geschäftslogik und Technik sicherstellt und damit eine sehr gute Testbarkeit durch Unit- und Integrationstests ermöglicht. Alle Architekturentscheidungen sind hier als ADR (architecture decision records) festgehalten: https://github.com/trettstadt/spring-boot-microservice/tree/main/docs/adr

Entwicklung
Ein wesentlicher Punkt ist die korrekte Konfiguration der Web-Security, wobei ich standardmäßig einen Audience-Validator hinzufüge, um sicherzustellen, dass jeder Service nur mit einem Token aufgerufen werden kann, das auch für ihn bestimmt ist.
/**
* Spring security configuration for REST.
*/
@Configuration
@EnableWebSecurity
public class WebSecurityConfiguration {
/**
* Forcing to use separate tokens for each target service improves security.
*/
@Bean
OAuth2TokenValidator<Jwt> audienceValidator() {
return new JwtClaimValidator<List<String>>(AUD,
aud -> aud.contains("spring-boot-microservice"));
}
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/actuator/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(withDefaults())
)
.oauth2Client(withDefaults());
return http.build();
}
}
Bei Ports and adapters entsteht ein recht hoher Overhead, weil man Models zwischen Adaptern, Ports und Domain mappen muss. Hier hilft Mapstruct, indem es automatisch Mapper aus einem Interface erzeugt.
/**
* MapStruct mapper for converting between BookingOutPort and BookingDomain.
*/
@Mapper(componentModel = "spring", injectionStrategy = InjectionStrategy.CONSTRUCTOR)
public interface FindBookingsMapper {
/**
* Converts a booking output port to a domain booking.
*
* @param bookingOutPort the booking output port
* @return the domain booking
*/
BookingDomain fromPort(BookingOutPort bookingOutPort);
/**
* Converts a list of booking output ports to a list of domain bookings.
*
* @param bookingOutPorts the list of booking output ports
* @return the list of domain bookings
*/
List<BookingDomain> fromPort(List<BookingOutPort> bookingOutPorts);
}
Die injectorStrategy wurde wegen der besseren Testbarkeit in Unit-Tests gewählt und durch das componentModel spring steht jeder Mapper direkt als Bean zur Verfügung.
Es wird ein API first-Ansatz verwendet, d. h. die API wird in einer OpenAPI-Spec definiert und die Klassen mit dem OpenAPI generator erzeugt.
openapi: "3.0.1"
info:
title: Example API for bookings
version: "1.0.0"
paths:
/bookings:
get:
description: Returns a list of all bookings.
operationId: getBookings
responses:
200:
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/BookingList'
components:
schemas:
Booking:
type: object
properties:
id:
type: number
format: int64
description:
type: string
BookingList:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Booking'
Das Datenbankschema wird mit Liquibase verwaltet. Dabei kann Liquibase Changelogs aus den JPA-Entities generieren. Dazu wird das Liquibase-Maven-Plugin verwendet.
databaseChangeLog:
- changeSet:
id: 1776502256728-1
author: trettstadt (generated)
changes:
- createTable:
columns:
- column:
constraints:
nullable: false
primaryKey: true
primaryKeyName: booking_entryPK
name: id
type: BIGINT
- column:
name: description
type: VARCHAR(255)
tableName: booking_entry
Lokales Deployment
Für die Entwicklung ist es unheimlich wichtig, den Service lokal laufen lassen und debuggen zu können. Für diesen Fall werden alle notwendigen Komponenten als Docker-Container zur Verfügung gestellt, die mit Docker-Compose schnell gestartet werden können. Es wird außerdem ein Spring-Profil localdev eingerichtet, in dem bereits alles für die lokale Entwicklung konfiguriert ist.
Um Fehler nachstellen zu können, die nur in der Kubernetes-Umgebung auftreten, kann der Service
auch per Helm in einem lokalen Cluster installiert werden. Hierfür wird das Helm-Chart aus dem
gitops-Verzeichnis mit der values-local.yaml verwendet.
Deployment im produktiven Kubernetes-Cluster
Voraussetzung für das Deployment ist, dass ein Docker-Image für den Service gebaut wurde. Das passiert automatisch in einem Github-Workflow (https://github.com/trettstadt/spring-boot-microservice/actions/workflows/maven.yml), der das Image in die Github-Container-Registry (GHCR) pushed.
Das Deployment besteht aus den Komponenten Server, Kubernetes und Deployment mit Helm, die hier
alle im gleichen Repo liegen, während Server und Kubernetes in der Praxis getrennt davon
konfiguriert werden. Die grundlegende Infrastruktur (Cloud-Server, Firewall, etc.) wird mit Pulumi
deployed, d. h. es reicht der Befehl pulumi up, um die Server zu erstellen oder Änderungen daran
zu deployen. Die Einrichtung von Kubernetes erfolgt im nächsten Schritt mit Ansible. Hier wird
einfach das Ansible-Playbook ausgeführt, damit Kubernetes auf den beiden Servern eingerichtet wird.
Danach steht direkt zwei Kubernetes-Nodes zur Verfügung.
Im letzten Schritt werden per Helmfile alle benötigten Komponenten installiert:
- Cert-Manager für die Bereitstellung von Let’s-Encrypt-TLS-Zertifikaten
- Traefik-Ingress-Controller, um die Kubernetes-Services erreichbar zu machen
- PostgreSQL als Datenbank, die Hetzner-Cloud-Volumes aus Datenspeicher verwendet
- Keycloak als OIDC-Provider
- External-Secrets für die sichere Bereitstellung von Passwörtern
- der Service selbst
Wenn die Infrastruktur wächst, wird das Helmfile zu unübersichtlich und man würde dann auf ArgoCD wechseln, welches die Synchronisation von Helm-Charts aus dem Git-Branch automatisieren kann und damit echtes GitOps umsetzt.
Observability
Es ist essenziell, den Service im laufenden Betrieb überwachen zu können, damit man im Fehlerfall oder bei Performanceproblemen schnell sehen kann, wo das Problem liegt. Dazu sendet der Service Metriken und Traces an Grafana Cloud und ein öffentliches Dashboard ist hier zu finden: https://lavendertoast2486.grafana.net/public-dashboards/6353c61287bf461f9b25450a7a56c8d3