DEV Community

Cover image for Kotlin Spring WebFlux, R2DBC and Redisson microservice in k8s πŸ‘‹βœ¨πŸ’«
Alexander
Alexander

Posted on

Kotlin Spring WebFlux, R2DBC and Redisson microservice in k8s πŸ‘‹βœ¨πŸ’«

πŸ‘¨β€πŸ’» Full list what has been used:

Spring Spring web framework
Spring WebFlux Reactive REST Services
Spring Data R2DBC a specification to integrate SQL databases using reactive drivers
Redisson Redis Java Client
Zipkin open source, end-to-end distributed tracing
Spring Cloud Sleuth auto-configuration for distributed tracing
Prometheus monitoring and alerting
Grafana for to compose observability dashboards with everything from Prometheus
Kubernetes automating deployment, scaling, and management of containerized applications
Docker and docker-compose
Helm The package manager for Kubernetes
Flywaydb for migrations

Source code you can find in the GitHub repository.
he main idea of this project is the implementation of microservice using Kotlin, Spring WebFlux, PostgresSQL, and Redis with metrics and monitoring and deploying it to k8s.
For interacting with PostgresSQL we will use reactive Spring Data R2DBC
and for Redis caching using Redisson.

All UI interfaces will be available on ports:

Swagger UI: http://localhost:8000/webjars/swagger-ui/index.html

Swagger

Grafana UI: http://localhost:3000

Grafana

Zipkin UI: http://localhost:9411

Zipkin

Prometheus UI: http://localhost:9090

Prometheus

Docker-compose file for this project:

version: "3.9"

services:
  microservices_postgresql:
    image: postgres:latest
    container_name: microservices_postgresql
    expose:
      - "5432"
    ports:
      - "5432:5432"
    restart: always
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=bank_accounts
      - POSTGRES_HOST=5432
    command: -p 5432
    volumes:
      - ./docker_data/microservices_pgdata:/var/lib/postgresql/data
    networks: [ "microservices" ]

  redis:
    image: redis:latest
    container_name: microservices_redis
    ports:
      - "6379:6379"
    restart: always
    networks: [ "microservices" ]

  prometheus:
    image: prom/prometheus:latest
    container_name: prometheus
    ports:
      - "9090:9090"
    command:
      - --config.file=/etc/prometheus/prometheus.yml
    volumes:
      - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
    networks: [ "microservices" ]

  node_exporter:
    container_name: microservices_node_exporter
    restart: always
    image: prom/node-exporter
    ports:
      - '9101:9100'
    networks: [ "microservices" ]

  grafana:
    container_name: microservices_grafana
    restart: always
    image: grafana/grafana
    ports:
      - '3000:3000'
    networks: [ "microservices" ]

  zipkin:
    image: openzipkin/zipkin:latest
    restart: always
    container_name: microservices_zipkin
    ports:
      - "9411:9411"
    networks: [ "microservices" ]


networks:
  microservices:
    name: microservices
Enter fullscreen mode Exit fullscreen mode

Spring WebFlux is like the documentation says a non-blocking web stack to handle concurrency with a small number of threads and scale with fewer hardware resources.
In this project, we will use it with Kotlin Coroutines which Spring supports very well at this moment and we can use suspend functions.
In WebFlux we can use standard REST Controllers and Functional Endpoints,
for this microservice chose the first one because everyone is familiar with it. Business domain and logic don't make sense for this example, as the domain model used BankAccount, BankAccountController REST Controller:

Swagger

@RestController
@RequestMapping(path = ["/api/v1/bank"])
@Tag(name = "Bank Account", description = "Bank Account REST Endpoints")
class BankAccountController(private val bankAccountService: BankAccountService) {

    @PutMapping(path = ["{id}"])
    @Operation(method = "depositAmount", summary = "deposit amount", operationId = "depositAmount")
    suspend fun depositAmount(@PathVariable(required = true) id: UUID, @Valid @RequestBody depositAmountRequest: DepositAmountRequest) = withTimeout(httpTimeoutMillis) {
        ResponseEntity.ok(SuccessBankAccountResponse.of(bankAccountService.depositAmount(id, depositAmountRequest))
            .also { log.info("updated account: $it") })
    }

    @PostMapping
    @Operation(method = "createBankAccount", summary = "create new bank account", operationId = "createBankAccount")
    suspend fun createBankAccount(@Valid @RequestBody createBankAccountRequest: CreateBankAccountRequest) = withTimeout(httpTimeoutMillis) {
        bankAccountService.createBankAccount(createBankAccountRequest)
            .let {
                log.info("created bank account: $it")
                ResponseEntity.status(HttpStatus.CREATED).body(SuccessBankAccountResponse.of(it))
            }
    }

    @GetMapping(path = ["{id}"])
    @Operation(method = "getBankAccountById", summary = "get bank account by id", operationId = "getBankAccountById")
    suspend fun getBankAccountById(@PathVariable(required = true) id: UUID) = withTimeout(httpTimeoutMillis) {
        ResponseEntity.ok(SuccessBankAccountResponse.of(bankAccountService.getBankAccountById(id))
            .also { log.info("success get bank account: $it") })
    }

    @GetMapping(path = ["/email/{email}"])
    @Operation(method = "getBankAccountByEmail", summary = "get bank account by email", operationId = "getBankAccountByEmail")
    suspend fun getBankAccountByEmail(@PathVariable(required = true) email: String) = withTimeout(httpTimeoutMillis) {
        ResponseEntity.ok(SuccessBankAccountResponse.of(bankAccountService.getBankAccountByEmail(email))
            .also { log.info("success get bank account bu email: $it") })
    }

    @GetMapping(path = ["all/balance"])
    @Operation(method = "findAllAccounts", summary = "find all bank account with given amount range", operationId = "findAllAccounts")
    suspend fun findAllAccounts(
        @RequestParam(name = "min", defaultValue = "0") min: BigDecimal,
        @RequestParam(name = "max", defaultValue = "500000000") max: BigDecimal,
        @RequestParam(name = "page", defaultValue = "0") page: Int,
        @RequestParam(name = "size", defaultValue = "10") size: Int,
    ) = withTimeout(httpTimeoutMillis) {
        ResponseEntity.ok(bankAccountService.findByBalanceAmount(min, max, PageRequest.of(page, size))
            .map { SuccessBankAccountResponse.of(it) }
            .also { log.info("find by balance amount response: $it") })
    }

    companion object {
        private val log = LoggerFactory.getLogger(BankAccountController::class.java)
        private const val httpTimeoutMillis = 3000L
    }
}
Enter fullscreen mode Exit fullscreen mode

The service layer is simple and has a few methods for creating, deposit amounts, and getting a bank account in different ways, for caching used the Cache-Aside pattern:
A cache-aside cache is the most common caching strategy available. The fundamental data retrieval logic can be summarized as follows:

  1. When your application needs to read data from the database, it checks the cache first to determine whether the data is available.
  2. If the data is available (a cache hit), the cached data is returned, and the response is issued to the caller.
  3. If the data isn't available (a cache miss), the database is queried for the data. The cache is then populated with the data that is retrieved from the database, and the data is returned to the caller.

This approach has a couple of advantages: The cache contains only data that the application actually requests, which helps keep the cache size cost-effective.
Implementing this approach is straightforward and produces immediate performance gains, whether you use an application framework that encapsulates lazy caching or your own custom application logic.
A disadvantage when using cache-aside as the only caching pattern is that because the data is loaded into the cache only after a cache miss,
some overhead is added to the initial response time because additional roundtrips to the cache and database are needed.

Zipkin

@Service
interface BankAccountService {

    suspend fun depositAmount(id: UUID, depositAmountRequest: DepositAmountRequest): BankAccount

    suspend fun createBankAccount(createBankAccountRequest: CreateBankAccountRequest): BankAccount

    suspend fun getBankAccountById(id: UUID): BankAccount

    suspend fun getBankAccountByEmail(email: String): BankAccount

    suspend fun findByBalanceAmount(min: BigDecimal, max: BigDecimal, pageable: Pageable): PageImpl<BankAccount>
}
Enter fullscreen mode Exit fullscreen mode
@Service
class BankAccountServiceImpl(
    private val bankAccountRepository: BankAccountRepository,
    private val redisCacheRepository: RedisCacheRepository,
    private val tracer: Tracer
) : BankAccountService {

    @Transactional
    override suspend fun depositAmount(id: UUID, depositAmountRequest: DepositAmountRequest) =
        withContext(Dispatchers.IO + tracer.asContextElement()) {
            val span = tracer.nextSpan(tracer.currentSpan()).start().name("BankAccountServiceImpl.depositAmount")

            try {
                val bankAccount = bankAccountRepository.findById(id) ?: throw BankAccountNotFoundException(id.toString()).also { span.error(it) }

                bankAccount.depositAmount(depositAmountRequest.amount)
                bankAccountRepository.save(bankAccount).also {
                    redisCacheRepository.setKey(id.toString(), it)
                    span.tag("bankAccount", it.toString())
                }
            } finally {
                span.end()
            }
        }

    override suspend fun createBankAccount(createBankAccountRequest: CreateBankAccountRequest): BankAccount =
        withContext(Dispatchers.IO + tracer.asContextElement()) {
            val span = tracer.nextSpan(tracer.currentSpan()).start().name("BankAccountServiceImpl.createBankAccount")

            try {
                bankAccountRepository.save(BankAccount.fromCreateRequest(createBankAccountRequest))
                    .also { span.tag("bankAccount", it.toString()) }
            } finally {
                span.end()
            }
        }

    override suspend fun getBankAccountById(id: UUID): BankAccount = withContext(Dispatchers.IO + tracer.asContextElement()) {
        val span = tracer.nextSpan(tracer.currentSpan()).start().name("BankAccountServiceImpl.getBankAccountById")

        try {
            val cachedBankAccount = redisCacheRepository.getKey(id.toString(), BankAccount::class.java)
            if (cachedBankAccount != null) return@withContext cachedBankAccount

            val bankAccount = bankAccountRepository.findById(id) ?: throw BankAccountNotFoundException(id.toString())

            redisCacheRepository.setKey(id.toString(), bankAccount)
            bankAccount.also { span.tag("bankAccount", it.toString()) }
        } finally {
            span.end()
        }

    }

    override suspend fun getBankAccountByEmail(email: String): BankAccount = withContext(Dispatchers.IO + tracer.asContextElement()) {
        val span = tracer.nextSpan(tracer.currentSpan()).start().name("BankAccountServiceImpl.getBankAccountByEmail")

        try {
            val cachedBankAccount = redisCacheRepository.getKey(email, BankAccount::class.java)
            if (cachedBankAccount != null) return@withContext cachedBankAccount

            val bankAccount = bankAccountRepository.findByEmail(email)
                ?: throw BankAccountNotFoundException("bank account with email: $email not found").also { span.error(it) }
            redisCacheRepository.setKey(email, bankAccount)
            bankAccount.also { span.tag("bankAccount", it.toString()) }
        } finally {
            span.end()
        }
    }

    override suspend fun findByBalanceAmount(min: BigDecimal, max: BigDecimal, pageable: Pageable): PageImpl<BankAccount> =
        withContext(Dispatchers.IO + tracer.asContextElement()) {
            val span = tracer.nextSpan(tracer.currentSpan()).start().name("BankAccountServiceImpl.findByBalanceAmount")

            try {
                bankAccountRepository.findByBalanceAmount(min, max, pageable)
            } finally {
                span.end()
            }
        }
}
Enter fullscreen mode Exit fullscreen mode

The repository layer is more interesting, first let's implement the Postgres repository.
In Spring Data R2DBC we can use a combination of exists CoroutineSortingRepository and our own custom implementation,
we only have to define our custom repository interface and implement and then simply extends it:

@Repository
interface BankAccountRepository : CoroutineSortingRepository<BankAccount, UUID>, BankAccountPostgresRepository {
    suspend fun findByEmail(email: String): BankAccount?
}
Enter fullscreen mode Exit fullscreen mode
@Repository
interface BankAccountPostgresRepository {
    suspend fun findByBalanceAmount(min: BigDecimal, max: BigDecimal, pageable: Pageable): PageImpl<BankAccount>
}
Enter fullscreen mode Exit fullscreen mode
@Repository
class BankAccountPostgresRepositoryImpl(
    private val template: R2dbcEntityTemplate,
    private val tracer: Tracer
) : BankAccountPostgresRepository {

    override suspend fun findByBalanceAmount(min: BigDecimal, max: BigDecimal, pageable: Pageable): PageImpl<BankAccount> =
        withContext(Dispatchers.IO + tracer.asContextElement()) {
            val span = tracer.nextSpan(tracer.currentSpan()).start().name("BankAccountPostgresRepositoryImpl.findByBalanceAmount")
            val query = Query.query(Criteria.where("balance").between(min, max))

            try {
                val accountsList = async {
                    template.select(query.with(pageable), BankAccount::class.java)
                        .asFlow()
                        .buffer(accountsListBufferSize)
                        .toList()
                }

                val totalCount = async { template.select(query, BankAccount::class.java).count().awaitFirst() }

                PageImpl(accountsList.await(), pageable, totalCount.await()).also { spanTagFindByBalanceAmount(span, it) }
            } finally {
                span.end()
            }
        }


    private fun spanTagFindByBalanceAmount(span: Span, data: PageImpl<BankAccount>) {
        span.tag("accountsList", data.content.size.toString())
            .tag("totalCount", data.totalElements.toString())
            .tag("pagination", data.pageable.toString())
    }

    companion object {
        const val accountsListBufferSize = 100
    }
}
Enter fullscreen mode Exit fullscreen mode

If we want to have pagination like in JPA, we can use PageImpl,
for work with Postgres R2DBC has R2dbcEntityTemplate with many usefully methods,
API is documented at Spring Docs.

Swagger

For caching prefer use Redis, the most powerful and popular library in the java world is Redisson.
Let's implement our Redis repository, for this example will create simple get and set by key methods:

@Repository
interface RedisCacheRepository {

    suspend fun setKey(key: String, value: Any)

    suspend fun setKey(key: String, value: Any, timeToLive: Long, timeUnit: TimeUnit)

    suspend fun <T> getKey(key: String, clazz: Class<T>): T?
}
Enter fullscreen mode Exit fullscreen mode
@Repository
class RedisCacheRepositoryImpl(
    private val redissonClient: RedissonReactiveClient,
    private val mapper: ObjectMapper,
    private val tracer: Tracer
) : RedisCacheRepository {

    override suspend fun setKey(key: String, value: Any): Unit = withContext(Dispatchers.IO + tracer.asContextElement()) {
        val span = tracer.nextSpan(tracer.currentSpan()).start().name("BankAccountCacheRepositoryImpl.setKey")

        try {
            val serializedValue = mapper.writeValueAsString(value)

            redissonClient.getBucket<String>(getKey(key), StringCodec.INSTANCE)
                .set(serializedValue, cacheTimeToLiveSeconds, TimeUnit.SECONDS)
                .awaitSingleOrNull()
                .also {
                    log.info("redis set key: $key, value: $serializedValue")
                    span.tag("key", serializedValue)
                }
        } finally {
            span.end()
        }
    }

    override suspend fun setKey(key: String, value: Any, timeToLive: Long, timeUnit: TimeUnit): Unit = withContext(Dispatchers.IO + tracer.asContextElement()) {
        val span = tracer.nextSpan(tracer.currentSpan()).start().name("BankAccountCacheRepositoryImpl.setKey")

        try {
            val serializedValue = mapper.writeValueAsString(value)

            redissonClient.getBucket<String>(getKey(key), StringCodec.INSTANCE)
                .set(serializedValue, timeToLive, timeUnit)
                .awaitSingleOrNull()
                .also {
                    log.info("redis set key: $key, value: $serializedValue, timeToLive: $timeToLive $timeUnit")
                    span.tag("key", key).tag("value", serializedValue).tag("timeToLive", "$timeToLive $timeUnit")
                }
        } finally {
            span.end()
        }
    }

    override suspend fun <T> getKey(key: String, clazz: Class<T>): T? = withContext(Dispatchers.IO + tracer.asContextElement()) {
        val span = tracer.nextSpan(tracer.currentSpan()).start().name("BankAccountCacheRepositoryImpl.getKey")

        try {
            redissonClient.getBucket<String>(getKey(key), StringCodec.INSTANCE)
                .get()
                .awaitSingleOrNull()?.let {
                    mapper.readValue(it, clazz).also { value ->
                        log.info("redis get key: $key, value: $value")
                        span.tag("key", value.toString())
                    }
                }
                ?: return@withContext null
        } finally {
            span.end()
        }
    }


    private fun getKey(key: String): String = "$prefix:$key"

    companion object {
        private val log = LoggerFactory.getLogger(RedisCacheRepositoryImpl::class.java)
        private const val prefix = "bankAccountMicroservice"
        private const val cacheTimeToLiveSeconds = 250L
    }
}
Enter fullscreen mode Exit fullscreen mode

Zipkin

for serialization used a custom Jackson ObjectMapper bean, but of course, we can use provided by Redisson if we want:

@Configuration
class Serializer {

    @Bean(name = ["mapper"])
    @Primary
    fun getJsonSerializer(): ObjectMapper {
        return jacksonObjectMapper()
            .registerModule(ParameterNamesModule())
            .registerModule(Jdk8Module())
            .registerModule(JavaTimeModule())
            .registerModule(
                KotlinModule.Builder()
                    .withReflectionCacheSize(512)
                    .configure(KotlinFeature.NullToEmptyCollection, false)
                    .configure(KotlinFeature.NullToEmptyMap, false)
                    .configure(KotlinFeature.NullIsSameAsDefault, false)
                    .configure(KotlinFeature.SingletonSupport, false)
                    .configure(KotlinFeature.StrictNullChecks, false)
                    .build()
            )
    }
}
Enter fullscreen mode Exit fullscreen mode

In Spring for error handling we have @ControllerAdvice:

@Order(2)
@ControllerAdvice
class GlobalControllerAdvice {

    @ExceptionHandler(value = [RuntimeException::class])
    fun handleRuntimeException(ex: RuntimeException, request: ServerHttpRequest): ResponseEntity<ErrorHttpResponse> {
        val errorHttpResponse = ErrorHttpResponse(HttpStatus.INTERNAL_SERVER_ERROR.value(), ex.message ?: "", LocalDateTime.now().toString())
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).contentType(MediaType.APPLICATION_JSON).body(errorHttpResponse).also {
            log.error("(GlobalControllerAdvice) INTERNAL_SERVER_ERROR RuntimeException", ex)
        }
    }

    @ExceptionHandler(value = [BankAccountNotFoundException::class])
    fun handleBankAccountNotFoundException(ex: BankAccountNotFoundException, request: ServerHttpRequest): ResponseEntity<ErrorHttpResponse> {
        val errorHttpResponse = ErrorHttpResponse(HttpStatus.NOT_FOUND.value(), ex.message ?: "", LocalDateTime.now().toString())
        return ResponseEntity.status(HttpStatus.NOT_FOUND).contentType(MediaType.APPLICATION_JSON).body(errorHttpResponse)
            .also { log.error("(GlobalControllerAdvice) BankAccountNotFoundException NOT_FOUND", ex) }
    }

    @ExceptionHandler(value = [InvalidAmountException::class])
    fun handleInvalidAmountExceptionException(ex: InvalidAmountException, request: ServerHttpRequest): ResponseEntity<ErrorHttpResponse> {
        val errorHttpResponse = ErrorHttpResponse(HttpStatus.BAD_REQUEST.value(), ex.message ?: "", LocalDateTime.now().toString())
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).contentType(MediaType.APPLICATION_JSON).body(errorHttpResponse)
            .also { log.error("(GlobalControllerAdvice) InvalidAmountException BAD_REQUEST", ex) }
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(value = [MethodArgumentNotValidException::class])
    fun handleInvalidArgument(ex: MethodArgumentNotValidException): ResponseEntity<MutableMap<String, String>> {
        val errorMap: MutableMap<String, String> = HashMap()
        ex.bindingResult.fieldErrors.forEach { error -> error.defaultMessage?.let { errorMap[error.field] = it } }
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).contentType(MediaType.APPLICATION_JSON).body(errorMap)
            .also { log.error("(GlobalControllerAdvice) WebExchangeBindException BAD_REQUEST", ex) }
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(value = [WebExchangeBindException::class])
    fun handleWebExchangeInvalidArgument(ex: WebExchangeBindException): ResponseEntity<MutableMap<String, Any>> {
        val errorMap = mutableMapOf<String, Any>()
        ex.bindingResult.fieldErrors.forEach { error ->
            error.defaultMessage?.let {
                errorMap[error.field] = mapOf(
                    "reason" to it,
                    "rejectedValue" to error.rejectedValue,
                )
            }
        }
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).contentType(MediaType.APPLICATION_JSON).body(errorMap)
            .also { log.error("(GlobalControllerAdvice) WebExchangeBindException BAD_REQUEST", ex) }
    }

    companion object {
        private val log = LoggerFactory.getLogger(GlobalControllerAdvice::class.java)
    }
}
Enter fullscreen mode Exit fullscreen mode

Lens

Next step let's deploy our microservice to k8s,
we can build a docker image of the microservice in different ways, in this example using a simple multistage docker file:

FROM --platform=linux/arm64 azul/zulu-openjdk-alpine:17 as builder
ARG JAR_FILE=target/Kotlin-Spring-Microservice-0.0.1-SNAPSHOT.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=layertools -jar application.jar extract

FROM azul/zulu-openjdk-alpine:17
COPY --from=builder dependencies/ ./
COPY --from=builder snapshot-dependencies/ ./
COPY --from=builder spring-boot-loader/ ./
COPY --from=builder application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher", "-XX:MaxRAMPercentage=75", "-XX:+UseG1GC"]
Enter fullscreen mode Exit fullscreen mode

For working with k8s like to use Helm, deployment for the microservice is simple and has deployment itself, Service, ConfigMap
and ServiceMonitor.
The last one is required because for monitoring use kube-prometheus-stack helm chart

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Values.microservice.name }}
  labels:
    app: {{ .Values.microservice.name }}
spec:
  replicas: {{ .Values.microservice.replicas }}
  template:
    metadata:
      name: {{ .Values.microservice.name }}
      labels:
        app: {{ .Values.microservice.name }}
    spec:
      containers:
        - name: {{ .Values.microservice.name }}
          image: {{ .Values.microservice.image }}
          imagePullPolicy: Always
          resources:
            requests:
              memory: {{ .Values.microservice.resources.requests.memory }}
              cpu: {{ .Values.microservice.resources.requests.cpu }}
            limits:
              memory: {{ .Values.microservice.resources.limits.memory }}
              cpu: {{ .Values.microservice.resources.limits.cpu }}
          livenessProbe:
            httpGet:
              port: {{ .Values.microservice.livenessProbe.httpGet.port }}
              path: {{ .Values.microservice.livenessProbe.httpGet.path }}
            initialDelaySeconds: {{ .Values.microservice.livenessProbe.initialDelaySeconds }}
            periodSeconds: {{ .Values.microservice.livenessProbe.periodSeconds }}
          readinessProbe:
            httpGet:
              port: {{ .Values.microservice.readinessProbe.httpGet.port }}
              path: {{ .Values.microservice.readinessProbe.httpGet.path }}
            initialDelaySeconds: {{ .Values.microservice.readinessProbe.initialDelaySeconds }}
            periodSeconds: {{ .Values.microservice.readinessProbe.periodSeconds }}
          ports:
            - containerPort: {{ .Values.microservice.ports.http.containerPort }}
              name: {{ .Values.microservice.ports.http.name }}
          env:
            - name: SPRING_APPLICATION_NAME
              value: microservice_k8s
            - name: JAVA_OPTS
              value: "-XX:+UseG1GC -XX:MaxRAMPercentage=75"
            - name: SERVER_PORT
              valueFrom:
                configMapKeyRef:
                  key: server_port
                  name: {{ .Values.microservice.name }}-config-map
            - name: SPRING_ZIPKIN_BASE_URL
              valueFrom:
                configMapKeyRef:
                  key: zipkin_base_url
                  name: {{ .Values.microservice.name }}-config-map
            - name: SPRING_R2DBC_URL
              valueFrom:
                configMapKeyRef:
                  key: r2dbc_url
                  name: {{ .Values.microservice.name }}-config-map
            - name: SPRING_FLYWAY_URL
              valueFrom:
                configMapKeyRef:
                  key: flyway_url
                  name: {{ .Values.microservice.name }}-config-map
            - name: SPRING_REDIS_HOST
              valueFrom:
                configMapKeyRef:
                  key: redis_host
                  name: {{ .Values.microservice.name }}-config-map
      restartPolicy: Always
      terminationGracePeriodSeconds: {{ .Values.microservice.terminationGracePeriodSeconds }}
  selector:
    matchLabels:
      app: {{ .Values.microservice.name }}

---

apiVersion: v1
kind: Service
metadata:
  name: {{ .Values.microservice.name }}-service
  labels:
    app: {{ .Values.microservice.name }}
spec:
  selector:
    app: {{ .Values.microservice.name }}
  ports:
    - port: {{ .Values.microservice.service.port }}
      name: http
      protocol: TCP
      targetPort: http
  type: ClusterIP

---

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  labels:
    release: monitoring
  name: {{ .Values.microservice.name }}-service-monitor
  namespace: default
spec:
  selector:
    matchLabels:
      app: {{ .Values.microservice.name }}
  endpoints:
    - interval: 10s
      port: http
      path: /actuator/prometheus
  namespaceSelector:
    matchNames:
      - default

---

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Values.microservice.name }}-config-map
data:
  server_port: "8000"
  zipkin_base_url: zipkin:9411
  r2dbc_url: "r2dbc:postgresql://postgres:5432/bank_accounts"
  flyway_url: "jdbc:postgresql://postgres:5432/bank_accounts"
  redis_host: redis
Enter fullscreen mode Exit fullscreen mode

and values.yaml:

microservice:
  name: kotlin-spring-microservice
  image: alexanderbryksin/kotlin_spring_microservice:latest
  replicas: 1
  livenessProbe:
    httpGet:
      port: 8000
      path: /actuator/health/liveness
    initialDelaySeconds: 60
    periodSeconds: 5
  readinessProbe:
    httpGet:
      port: 8000
      path: /actuator/health/readiness
    initialDelaySeconds: 60
    periodSeconds: 5
  ports:
    http:
      name: http
      containerPort: 8000
  terminationGracePeriodSeconds: 20
  service:
    port: 8000
  resources:
    requests:
      memory: '6000Mi'
      cpu: "3000m"
    limits:
      memory: '6000Mi'
      cpu: "3000m"
Enter fullscreen mode Exit fullscreen mode

Lens

As UI tool for working with k8s like use Lens .

More details and source code of the full project you can find GitHub repository here,
of course always in real-world projects, business logic and infrastructure code is much more complicated, and we have to implement many more necessary features.
I hope this article is usefully and helpfully, and be happy to receive any feedback or questions, feel free to contact me by email or any messengers :)

Top comments (0)