π¨βπ» 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
Grafana UI: http://localhost:3000
Zipkin UI: http://localhost:9411
Prometheus UI: http://localhost:9090
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
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:
@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
}
}
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:
- When your application needs to read data from the database, it checks the cache first to determine whether the data is available.
- If the data is available (a cache hit), the cached data is returned, and the response is issued to the caller.
- 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.
@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>
}
@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()
}
}
}
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?
}
@Repository
interface BankAccountPostgresRepository {
suspend fun findByBalanceAmount(min: BigDecimal, max: BigDecimal, pageable: Pageable): PageImpl<BankAccount>
}
@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
}
}
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.
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?
}
@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
}
}
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()
)
}
}
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)
}
}
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"]
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
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"
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)