Статья: «Как мы перевели сервисы Java с виртуальных машин в Kubernetes»
Введение
Наша команда занимается разработкой и поддержкой общих сервисов внутри компании. Под «общими» мы подразумеваем сервисы, которыми пользуются как коллеги из смежных проектов, так и внешние пользователи — например, страховые агенты. Все сервисы построены по принципам микросервисной архитектуры: система состояла из 10+ Java 8/11 приложений на базе Spring Boot, и мы планировали дальнейшее расширение числа сервисов. Сейчас система состоит из 28 микросервисов.
Микросервисная архитектура позволяет развивать систему модульно, добавлять новые возможности без влияния на уже работающие компоненты и масштабировать отдельные части системы независимо друг от друга.
В нашем случае главным вызовом стала унификация и упрощение процесса деплоя новых приложений. Мы стремились к тому, чтобы команда могла выпускать новые сервисы быстро, прозрачно — без долгих согласований и ручного вмешательства, несмотря на рост числа приложений. Поддержание стабильности и гибкости инфраструктуры становилось всё сложнее.
Переход на Kubernetes мы рассматривали как способ упростить масштабирование сервисов, унифицировать процессы деплоя, повысить отказоустойчивость системы и эффективнее управлять ресурсами. В итоге это решение не только позволило нам быстрее реагировать на изменения нагрузки, но и принесло ряд дополнительных преимуществ: гибкое управление секретами, автоматизацию фоновых задач и более предсказуемый процесс релизов.
В этой статье я разберу наш путь от VM к Kubernetes: какие задачи решали, как настраивали пайплайны, с какими сложностями столкнулись и какие плюсы в итоге получили.
Лирическое отступление
Рассказывать, как именно Kubernetes работает, создает поды следит чтобы их было заданное кол-во, следит за нагрузкой и производит масштабирование я не буду, цель рассказать с какими трудностями столкнулись и как их решали в процессе перевода приложений Java из VM в Kubernetes.
Задачи и вызовы
С ростом количества микросервисов возникла ключевая проблема: процесс деплоя новых приложений становился слишком сложным, длительным и зависимым от множества ручных операций. Это затрудняло оперативное внедрение новых функций и быструю реакцию на требования бизнеса и изменения нагрузки. Кроме того, поддержание стабильности и масштабируемости сервисов становилось всё более трудоёмкой задачей.
Главные вызовы, с которыми мы столкнулись:
Отсутствие единого стандарта и автоматизации процесса деплоя.
Сложности с масштабированием отдельных сервисов без влияния на другие.
Риски простоев и ошибок при ручном управлении развертываниями.
Ограниченные возможности для управления ресурсами и безопасности.
Почему Kubernetes?
Рассматривая варианты решения, мы увидели в Kubernetes ключевой инструмент, способный упростить и стандартизировать процессы деплоя и масштабирования:
Унификация деплоя: стандартизированные манифесты и автоматизация пайплайнов позволили свести к минимуму ручное вмешательство и сократить время релиза.
Гибкое масштабирование: Kubernetes обеспечивает автоматическое масштабирование сервисов в зависимости от нагрузки, что критично для поддержки стабильности.
Отказоустойчивость: механизм управления состоянием контейнеров позволяет быстро восстанавливаться после сбоев.
Управление секретами и конфигурациями: встроенные инструменты Kubernetes облегчают безопасное хранение и предоставление конфиденциальных данных.
Автоматизация фоновых задач: упрощение задач, связанных с обновлениями, мониторингом и бэкапами.
Экономия ресурсов: Никаких фиксированных «запасов» — ресурсы выделяются по факту.
Централизация управления. Все сервисы видны и управляются из одного места. Не надо «бегать» по виртуальным машинам.
Исходная инфраструктура: 10+ сервисов на VM
Как это было устроено
10+ микросервисов на Java 8/11 Spring Boot 2.
На одной виртуальной машине работало от 3 до 4 сервисов.
Не все сервисы, находящиеся на VM относились к нашей команде.
Память и CPU выделялись фиксированно, «с запасом на пиковые нагрузки». В итоге сервисы потребляли 30–40% выделенного.
Пайплайны в GitLab CI собирали jar, копировали на VM, запускали/перезапускали процесс через systemd.
Для фоновых задач использовались планировщики: отдельные сервисы по cron-расписанию отправляли запросы сервисам-воркерам.
Мониторинг был настроен через Grafana. Метрики для каждого сервиса собирались руками.
Плюсы
Простота и понятность: зашёл на VM — увидел процессы.
Изоляция сервисов на уровне ОС.
Автоматизация релизов через GitLab CI.
Минусы
Избыточное потребление ресурсов. На каждый сервис было выделено больше («с запасом») ресурсов чем ему на самом деле необходимо.
Логирование и мониторинг. Требуется настраивать отдельные решения для сбора логов и метрик с каждой VM.
Медленная реакция на сбои. В случае сбоя требуется ручной перезапуск процесса.
Сложный деплой и масштабирование. Запуск и настройка каждой новой VM требует времени, ручных операций и часто вмешательства системных администраторов.
Архитектура «до»
Каждая VM содержала несколько сервисов. Масштабирование делалось вручную. В целях повышения отказоустойчивости инстансы приложения были размещены на разных VM.

Этапы миграции
1. Контейнеризация.
K8S работает с контейнерами поэтому первым шагом было упаковать приложение в контейнер для этого мы добавили в проект Dockerfile. Базовый Dockerfile:
Dockerfile
FROM eclipse-temurin:17-alpine
ENV TZ="Europe/Moscow"
COPY target/ainsurance.jar .
# В Java 10+ JVM сама понимает лимиты контейнера и автоматически ограничивает heap
ENV JAVA_TOOL_OPTIONS=" \
-XX:MaxRAMPercentage=75.0 \
-XX:+UseParallelGC
"
ENTRYPOINT ["java","-jar","ainsurance.jar"]Подробнее про флаги в ENV расскажу в пункте 6. Сложности и грабли.
2. Инфраструктура в Kubernetes
У нас есть контейнер, но для того, чтобы автоматизировать сборку и установку, обновление и управление приложением нам необходимо использовать пакетный менеджер Helm (тут о нем подробнее - https://helm.sh/ru/docs/intro).
Структура файлов helm’a в проекте выглядит так:

Коротко расскажу про самые важные файлы:
Chart.yaml – это основной файл метаданных любого Helm-чарта. Он обязательно присутствует в корне каждого чарт-пакета и содержит описательную информацию о чарте, необходимую Helm для корректной работы, поиска, версионирования и зависимостей.
Chart.yaml
apiVersion: v2
name: myinsurance
description: A Helm chart for Kubernetes
type: application
version: 0.0.1 # Версия самого чарта (НЕ приложения)
appVersion: 0.0.1 # Версия приложения, которое чарта разворачиваетdeployment.yaml – манифест описывающий объект Deployments который управляет созданием и обновлением определенного количества pod’ов приложения, следит, чтобы всегда работало нужное количество копий (реплик), и может обновлять их без остановки сервиса (rolling updates).
deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "my-chart.fullname" . }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ include "my-chart.name" . }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
app: {{ include "my-chart.name" . }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports:
- containerPort: {{ .Values.service.port }}
livenessProbe:
httpGet:
path: {{ .Values.livenessProbe.path }}
port: http
initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }}
timeoutSeconds: {{ .Values.livenessProbe.timeoutSeconds }}
periodSeconds: {{ .Values.livenessProbe.periodSeconds }}
successThreshold: {{ .Values.livenessProbe.successThreshold }}
failureThreshold: {{ .Values.livenessProbe.failureThreshold }}
readinessProbe:
httpGet:
path: {{ .Values.readinessProbe.path }}
port: http
initialDelaySeconds: {{ .Values.readinessProbe.initialDelaySeconds }}
timeoutSeconds: {{ .Values.readinessProbe.timeoutSeconds }}
periodSeconds: {{ .Values.readinessProbe.periodSeconds }}
successThreshold: {{ .Values.readinessProbe.successThreshold }}
failureThreshold: {{ .Values.readinessProbe.failureThreshold }}
startupProbe:
httpGet:
path: {{ .Values.startupProbe.path }}
port: http
initialDelaySeconds: {{ .Values.startupProbe.initialDelaySeconds }}
periodSeconds: {{ .Values.startupProbe.periodSeconds }}
successThreshold: {{ .Values.startupProbe.successThreshold }}
failureThreshold: {{ .Values.startupProbe.failureThreshold }}
resources:
{{- toYaml .Values.resources | nindent 12 }} service.yaml – манифест описывающий объект Service, который обеспечивает стабильный сетевой доступ (DNS-имя и IP-адрес) к группе pod’ов, обычно выбранных по лейблам. Он выполняет роль "прокси", распределяя трафик между всеми живыми pod’ами, и абстрагирует их внутренние IP, так что приложение всегда доступно по одному адресу даже когда pod'ы пересоздаются.
service.yaml
apiVersion: v1
kind: Service
metadata:
name: {{ include "my-chart.fullname" . }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.targetPort }}
protocol: TCP
selector:
app: {{ include "my-chart.name" . }}Servicemonitor.yaml – манифест описывающий объект ServiceMonitor. ServiceMonitor - это объект из мира Prometheus Operator (специфический CRD), который управляет мониторингом сервисов Kubernetes. Обычный Service лишь открывает сетевой доступ к приложению. А ServiceMonitor сообщает Prometheus, как и где собирать метрики с этого сервиса: по какому адресу, каким путём, с какими параметрами и т.п. Резюмируем, ServiceMonitor определяет, как Prometheus (через Operator) будет собирать метрики с вашего сервиса, автоматически подстраиваясь под смену pod’ов и сервисов в Kubernetes.
Servicemonitor.yaml
{{- if .Values.serviceMonitor.enabled -}}
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "my-chart.fullname" . }}
labels:
release: {{ .Values.serviceMonitor.prometheus.release }}
spec:
selector:
matchLabels:
app: {{ include "my-chart.name" . }}
endpoints:
- port: {{ .Values.serviceMonitor.monitorPort }}
path: {{ .Values.serviceMonitor.metricsPath }}
interval: {{ .Values.serviceMonitor.interval }}
{{- end }} ingress.yaml – манифест описывающий объект Ingress. Ingress — это ресурс, который управляет внешним доступом (HTTP/HTTPS) к сервисам внутри кластера. Он маршрутизирует внешние запросы (например, из интернета) по разным сервисам внутри кластера в зависимости от URL, hostname, или других правил. Ingress работает только если в кластере установлен Ingress Controller (например, nginx-ingress, Traefik, Istio и др.).
ingress.yaml
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "my-chart.fullname" . }}
annotations:
{{- range $key, $value := .Values.ingress.annotations }}
{{ $key }}: {{ $value | quote }}
{{- end }}
spec:
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: {{ .Values.ingress.path }}
pathType: Prefix
backend:
service:
name: {{ include "my-chart.fullname" . }}
port:
number: {{ .Values.service.port }}
{{- end }}hpa.yaml – манифест описывающий объект HorizontalPodAutoscaler(HPA). HPA - это контроллер, который автоматически увеличивает или уменьшает число подов в зависимости от нагрузки (метрики CPU, памяти или других).
hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "my-chart.fullname" . }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "my-chart.fullname" . }}
minReplicas: {{ .Values.hpa.minReplicas }}
maxReplicas: {{ .Values.hpa.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}pdb.yaml – манифест описывающий объект PodDisruptionBudget (PDB). PDB - это специальный ресурс, который определяет, сколько pod’ов вашего приложения может быть одновременно недоступно во время добровольных “нарушений” (disruption) в работе кластера, например, при обновлении нод, rolling update, drain-операциях и т.д. PDB не защищает от внезапных сбоев (например, аварийное выключение ноды), но помогает сохранить доступность сервиса при плановых работах с кластером.
pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "my-chart.fullname" . }}
spec:
minAvailable: {{ .Values.pdb.minAvailable }}
selector:
matchLabels:
app: {{ include "my-chart.name" . }}cronjob.yaml — это YAML-манифест, описывающий объект CronJob в Kubernetes. CronJob — это специализированный ресурс Kubernetes, который запускает задания (Jobs) по расписанию, похожему на cron в Linux. Это удобно для регулярных задач: резервного копирования, рассылок, выгрузок, временной обработки данных и прочих периодических операций.
Скрытый текст
{{- if .Values.cronjob.enabled -}}
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ include "my-chart.fullname" . }}
spec:
schedule: "{{ .Values.cron.schedule }}"
successfulJobsHistoryLimit: {{ .Values.cron.successfulJobsHistoryLimit }}
failedJobsHistoryLimit: {{ .Values.cron.failedJobsHistoryLimit }}
jobTemplate:
spec:
template:
spec:
containers:
- name: cron-job
image: "{{ .Values.cron.image.repository }}:{{ .Values.cron.image.tag }}"
args: {{ toYaml .Values.cron.args | nindent 16 }}
restartPolicy: OnFailure
{{- end }} Теперь у нас есть все для запуска приложения в K8S с помощью Helm. И выполнив команду
- helm upgrade --install myinsurance . мы задеплоим наше приложение в кластер.
Но нам надо автоматизировать процесс. Т.е. написать скрипты для GitLab для сборки приложения, пуша его в репозиторий и выполнения команд Helm.
3. CI/CD в GitLab
Добавим в gitlab-ci.yaml джобы по сборке образа, загрузке в Registry образов и деплою в kubernetes.
Вот основные новые/обновленные шаги пайплайна:
docker_build— собираем контейнерный образ и отправляем в Container Registry.
deploy — тут происходит создание secret в Kubernetes, Helm собирает шаблоны и деплоит приложение в Kubernetes.
gitlab-ci.yaml
stages:
- lint
- build
- build_n_push
- deploy
# Common variables
variables:
MVN_JAVA_BUILD_IMAGE: maven:latest
JAVA_HOME: /opt/java/openjdk
JAVA_HOME_SONAR: /usr/lib/jvm/java-21-openjdk-amd64/
JAVA_VERSION: 21
CHART: .helm
DOCKER_FILE: ./Dockerfile
CHART_VERSION: v0.1.0
SERVICE_IMAGE: kubernetes-helm:latest
# Default runner tag
default:
tags: [runner-tag]
before_script:
- export CHART_NAME=$(echo $CI_PROJECT_NAME)
- echo $CHART_NAME
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH =~ /dev.*/
- if: $CI_COMMIT_BRANCH == "master"
- if: $CI_COMMIT_TAG
# Создание секретов Kubernetes с переменными окружения и сохраняет в файл vars.txt
.secrets: &secrets |
env | sed -n "s/^K8S_SECRET_\(.*\)$/\1/p" > vars.txt
kubectl create secret \
-n "$K8S_NS" generic "$CHART_NAME-secret" \
--from-env-file vars.txt -o yaml --dry-run=client |
kubectl replace -n "$K8S_NS" --force -f -
.PullSecret: &PullSecret |
if [ -n "$CI_REGISTRY" -a -n "$CI_REGISTRY_PASSWORD" -a -n "$CI_REGISTRY_USER" ];then
kubectl -n "$K8S_NS" create secret docker-registry registry-pullsecret \
--docker-server=$CI_REGISTRY \
--docker-username=$CI_REGISTRY_USER \
--docker-password=$CI_REGISTRY_PASSWORD || true
OPT="--set imagePullSecrets=registry-pullsecret"
fi
# Проверка корректности dockerfile
.docker_lint:
image:
name: hadolint:latest
script:
- hadolint $DOCKER_FILE
stage: lint
interruptible: true
lint:docker:backend:
extends: .docker_lint
build:app:
image: $MVN_JAVA_BUILD_IMAGE
script:
- echo $SETTINGS_XML > settings.xml
- mvn -s ./settings.xml -B clean install $MAVEN_PROFILE -Dskip-unit-test=true -Dmaven.test.skip=true -DskipITs -Dspring.cloud.bootstrap.enabled=true
stage: build
interruptible: true
artifacts:
paths:
- "target/*.jar"
.docker_build:
image:
name: kaniko-project-executor:debug
entrypoint: [ "" ]
rules:
- if: $CI_COMMIT_BRANCH == "dev"
- if: $CI_COMMIT_TAG =~ /^v\d+.\d+.\d+/
before_script:
- !reference [ default, before_script ]
- ls -la
- test -z "$CI_REGISTRY_USER" -o -z "$CI_REGISTRY_PASSWORD" && exit 2
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}},\"registry-mirrors\":[\"registry.my-insuracne.ru\"]}" > /kaniko/.docker/config.json
- full_image_name=${CI_REGISTRY}/${CHART_NAME}:${CI_COMMIT_REF_SLUG}-$CI_COMMIT_SHORT_SHA
script:
- cat /kaniko/.docker/config.json
- /kaniko/executor --cache=true --cache-repo ${CI_REGISTRY}/${CHART_NAME} --context $CI_PROJECT_DIR --dockerfile $DOCKER_FILE --destination ${full_image_name} --registry-mirror=my-insurance.ru
stage: build_n_push
interruptible: true
docker:build:backend:
extends: .docker_build
stage: build_n_push
.helm:
image: $SERVICE_IMAGE
before_script:
- !reference [ default, before_script ]
- set -x
- sed -i '\@vault.security.banzaicloud.io/vault-path:@d' .helm/values-dev.yaml || true
- kubectl config set-cluster k8s --insecure-skip-tls-verify=true --server=$K8S_API_URL
- kubectl config set-credentials ci --token=$K8S_API_TOKEN
- kubectl config set-context ci --cluster=k8s --user=ci
- kubectl config use-context ci
.deploy:
extends: [.helm]
stage: deploy
script:
- *secrets
- *PullSecret
# Создаем helm манифесты из Chart'a для установки релиза
- helm template ${CHART}
--set image.repository=${CI_REGISTRY}/${CHART_NAME}
--set image.tag=${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}
--namespace $K8S_NS
--values $CHART_VALUES
--version $CHART_VERSION
$OPT
# Устанавливаем новый или обновлем существующий релиз нашего приложения с помощью helm
- helm upgrade --install ${CHART_NAME} ${CHART}
--set image.repository=${CI_REGISTRY}/${CHART_NAME}
--set image.tag=${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}
--namespace $K8S_NS
--values $CHART_VALUES
--version $CHART_VERSION
--atomic --cleanup-on-fail --timeout 600s --debug ${DEPLOY_EXTRA_ARGS}
$OPT
interruptible: false
deploy:develop:
extends: [.deploy]
rules:
- if: $CI_COMMIT_BRANCH == "dev"
environment:
name: devТеперь мы наладили pipeline и можем автоматически деплоить наши изменения на тестовый/продакшн стенд по определенным нами правилам, например, при мердже в dev ветку.
4. Интеграция с Vault
Для хранения секретов мы использовали HashiCorp Vault. Чтобы получать оттуда секреты и передавать в контейнеры использовали BanzaiCloud Vault Injector – это механизм, который автоматически внедряет секреты из Vault в контейнеры, запущенные в Kubernetes, основываясь на аннотациях подов. Как он это делает? Используется паттерн side-car контейнера:
этот контейнер запускается вместе с нашим приложение внутри пода;
аутентифицируется в Vault;
забирает нужные секреты из Vault;
подставляет секреты в переменные окружения;
Таким образом удобно и безопасно мы получаем секреты прямо из хранилища (Vault) без использования ConfgMap’ов.
Вот пример аннотаций
annotations:
vault.security.banzaicloud.io/vault-addr: "https://vault.address.ru"
vault.security.banzaicloud.io/vault-role: "my-team-role"
vault.security.banzaicloud.io/vault-path: dev-myinsurance
vault.security.banzaicloud.io/vault-env-image: myrepo/vault-env:1.1.1Соответственно:
vault-addr – это url с адресом хранилища секретов
vault-role – это роль с которой происходит аутентификация
vault-path – это путь (namespace / scope) в Vault, к которому будет обращаться под за секретами
vault-env-image – это конкретный образ контейнера который будет работать с секретами, если не указан, то будет использоваться дефолтная (обычная) версия vault-env, встроенная в сам admission controller BanzaiCloud
5. Постепенный rollout
Так как процесс перехода происходил параллельно с основной работой, все новые сервисы мы сразу же выпускали в Kubernetes. А уже работающие сервисы мы переводили постепенно. Мы начали с нескольких некритичных сервисов, проверили пайплайны и мониторинг. Затем постепенно переводили остальные. Это заняло немало времени, но результат того стоил. И в итоге у нас 27+ сервисов на java 17/21 SpringBoot3 в K8s в настоящий момент
Сложности и грабли
Память/Ресурсы
Сделаю небольшое отступление, в Kubernetes приложение находится в отдельном контейнере, контейнер в поде, под на ноде(машине). Нода это виртуальная машина или физический сервер с заданными ресурсами, под минимальная единица развертывания в K8S, контейнер - это изолированная среда, в которой развернуто приложение. В deployment мы задаем request и limits по ресурсам cpu (процессоры), memory – оперативной памяти, доступным контейнерам в каждом поде. Т.е. можно сделать вывод, что все поды ограничены ресурсами ноды, на которой они развернуты и хорошо бы, чтобы поды и соответственно приложения в них не конкурировали за ресурсы и периодически не падали по OOMkill (Killed by Out of Memory) или не получали throttling по cpu.
Чтобы конкуренции не происходило и все Java сервисы на ноде учитывали заданные для подов реквесты и лимиты, а не использовали ресурсы всей ноды, необходимо задать requests и limits для контейнеров в deployment.
С помощью request мы определяем величину минимальных требований нашего пода. С помощью limits мы определяем величину максимальных требований нашего пода, если под будет потреблять больше памяти чем указано с limits он будет остановлено по OOM. С учетом в том числе и этих параметров Kube-scheduler - это компонент управления (Control Plane) , который просматривает неподтверждённые поды (Pending Pods) и выбирает для них подходящие ноды, основываясь на различных критериях, включая доступные ресурсы (CPU, память и др.), указанные в секциях requests (требования) и limits (ограничения) манифеста пода. Если не задать limits, то под будет иметь доступ ко всем свободным ресурсам ноды. Свободные ресурсы это те что не «зарезервированы» request’ом всех подов на ноде. А если не задать request, то под может попасть на ноду, где для старта и корректной работы приложения не будет достаточно ресурсов.
Пример блока resources шаблона deployment.yaml
resources:
limits: <- верхний предел доступных контейнеру ресурсов
memory: 1024Mi
cpu: 1000m
requests: <- нижний предел доступных контейнеру ресурсов
cpu: 1000m
memory: 1024MiЕсть разные практики и мнения по поводу каким образом задавать лимиты и реквесты, я придерживаюсь мнения, что по памяти limit = request. Если не задавать limit, то под будет при необходимости использовать всю доступную память ноды. Если задать limit выше request этот объем памяти будет «зарезервирован» за подом. Надо учитывать HPA, который масштабирует поды на основе потребляемых (!) ресурсов, т.е. на основе request. В нашем случае HPA смотрит на память targetMemoryUtilizationPercentage: 80, т.е. в тот момент, когда поды будут потреблять больше 80% от заданного request(!) будет происходить масштабирование – добавление еще одного пода. И в случае, когда limit больше request мы просто никогда не дойдем до значения limit и будем масштабировать поды раньше, чем хотелось бы.
Пример оптимальных настроек ресурсов и HPA:
1. Memory limit = request позволяет точно и предсказуем управлять ресурсами пода;
2. targetMemoryUtilizationPercentage: 80 позволяет избежать преждевременного масштабирования
3. MaxRAMPercentage=75 позволяет использовать оптимальный размер heap для работы приложения
- использовать ключ -XX:+UseContainerSupport, чтобы JVM учитывал заданные в контейнере requests и limits.
- задать размер heap для приложения, используем ключ -XX:MaxRAMPercentage. Почему можно прочитать вот тут (https://www.baeldung.com/java-jvm-parameters-rampercentage).
Для версий Java 10+ этот ключ -XX:+UseContainerSupport включен по умолчанию. Это был наш случай, т.к. попутно мы переводили проекты с java 8/11на 17/21. Чтобы задать размер heap используем -XX:MaxRAMPercentage, по умолчанию он 25%, мы же задали 75% от request пода
Сравнительная таблица для подбора GC
Factors | SerialGC | ParallelGC | G1GC | ZGC | ShenandoahGC |
Number of cores | 1 | 2 | 2 | 2 | 2 |
Multi-threaded | No | Yes | Yes | Yes | Yes |
Java heap size | <4 GBytes | <4 GBytes | >4 GBytes | >4 GBytes | >4 GBytes |
Pause | Yes | Yes | Yes | Yes (<1 ms) | Yes (<10 ms) |
Overhead | Minimal | Minimal | Moderate | Moderate | Moderate |
Tail-latency Effect | High | High | High | Low | Moderate |
JDK version | All | All | JDK 8+ | JDK 17+ | JDK 11+ |
Best for | Single core small heaps | Multi-core small heaps or batch workloads with any heap size | Responsive in medium to large heaps (request-response/DB interactions) | ||
Источник: https://learn.microsoft.com/en-us/azure/developer/java/containers/overview
Старт приложения и прочие probe.
Для того чтобы мониторить статус контейнера в Kubernetes есть probe. Probe бывают трех типов:
- readiness, чтобы определить, что контейнер готов принимать запросы. Эта проба проверяет готов ли контейнер принимать трафик. Выполняется с заданной периодичность в течении всей жизни контейнера.
- liveness, чтобы определить, что контейнер работает корректно и все в порядке. Если проба прошла неуспешно, то kubelet перезапускает контейнер. Liveness проба не ждет когда успешно завершится readiness проба. Если необходимо чтобы Liveness проба начинала выполнятся после readiness, то надо задать initialDelaySeconds ��ли использовать startup пробу. Выполняется с заданной периодичность в течении всей жизни контейнера.
- stratup, чтобы определить, что контейнер успешно стартовал. Эта проба хорошо подходит для того чтобы избежать неуспешных проверок liveness пробы для медленно стартующих контейнеров и тем самым избежать ненужных перезапусков контейнера. Дело в том, что пока она не выполнилась другие пробы не запускаются. Выполняется только один раз при старте контейнера.
В самом начале мы использовали только две probe readiness и liveness и это было ошибкой. При длительном старте приложения чтобы избежать ненужных перезапусков мы устанавливали большие значения для initialDelaySeconds в эти пробах от 60 до 200 секунд. Т.е. проверка состояния контейнера происходила раз в 1 минут, а то и 3,33 минуты, что никак не соответствует целям стабильности и отказоустойчивости, которых мы хотели достигнуть. Так же часто мы сталкивались с тем что наши приложения просто не могли запуститься из-за долгого старта. Надо заметить, что в официально документации есть рекомендация определить initialDelaySeconds для Liveness probe, чтобы избежать перезапусков, но вряд ли имеются ввиду такие большие временные интервалы.
В итоге мы применили startup probe и проблема с долгими стартами отпала, плюс мы можем быть уверены что Kubernetes пристально следить за нашими контейнерами и при необходимости вовремя перезапустит их.
Вот настройки stratup probe(так же они есть в values.yaml):
Скрытый текст
initialDelaySeconds: 20 – проверка начинается через 20 секунд после создания контейнера
periodSeconds: 10 – проверка происходит раз в 10 секунд
successThreshold: 1 – достаточно одной успешной попытки
failureThreshold: 12 – 12 попыток, раз в 10 секунд – 120 секунд на стартТ.е. у приложения есть 2 минуты на запуск, но если он запустится раньше, за 25 секунд, то на 3й проверке мы получим успешный результат и перейдем к liveness и readinsee probe.
В вот пример настроек Liveness probe (так же они есть в values.yaml):
initialDelaySeconds: 20 – проверка начинается через 20 секунд после создания контейнера
periodSeconds: 30 – проверка происходит раз в 30 секунд
timeoutSeconds: 10 – если за 10 секунд проверка не прошла проба заканчивается timout’ом
failureThreshold: 3 – необходимо три подряд провала проверки, чтобы проба провалилась
successThreshold: 1 – достаточно одной успешной попытки
В качестве метода проверки работоспособности приложения используем http запрос на урл actuator/health/readiness, actuator/health или actuator/health/liveness.
CI/CD
Для новых приложений в Kubernetes были разработаны новые шаблоны деплоя. В Шаблоны были добавлены джобы сборки образа, отправки образа в репозиторий образов и установки/обновления релиза сервиса.
Dockerfile
Необходимо было провести оптимизацию образа для Kubernetes Для сравнения полный образ java 17 (openjdk:17)– 470 MB а облегченный почти в три раза меньше размером. eclipse-temurin:17-alpine – 180 МБ.
Секреты
Vault в связке с Kubernetes потребовал доработок. Подробнее в пункте 4 статьи.
@Scheduled аннотация
Сервисы с планировщиками пришлось разделять, что привело к использованию cronJob –> экономия ресурсов слава Куберу! В некоторых сервисах у нас мы использовали спринговую аннотацию @Scheduled, чтобы запускать методы по расписанию. Некоторые сервисы были развернуты в двух или более экземплярах и в случае с ВМ мы использовали «велосипед» с определением имени хоста на, котором находится приложение и запуском или не запуском в зависимости от хоста. Ну просто нет в Spring решения этой проблемы «из коробки». Нам очень не хотелось выдумывать велосипед для kubernetes. В итоге, мы пришли к двум вариантам решения проблемы:
вынести логику, которая требовала запуска по расписанию в отдельные сервисы
вынести запуск логики в отдельные эндпоинты и осуществлять через API сервиса сторонним сервисом планировщиком.
Потом мы пошли дальше и в итоге стали использовать cronJob вместо сервисов планировщиков для запуска логики через API сервиса. В итоге зоопарк сервисов немного уменьшился.
Обучение команды
Разработчикам было необходимо изучить Helm и kubectl. Для этого мы использовали обучающие курсы, литературу и документацию.
Архитектура «после»

Я не стал отображать на схеме все объекты Kubernetes, т.к. это не пособие по Kubernetes. Как мы видим внешний пользователь обращается к приложению через Ingress, который определяет по label Service в который надо маршрутизировать запрос, а тот в свою очередь распределяет запросы между pod’ами. Deployment следит за состоянием pod’ов при необходимости перезагружает их через ReplicaSet. HPA следит за нагрузкой и при необходимости изменяет кол-во подов через Deployment (а тот через ReplicaSet). Так же стоит отметить что наши приложения расположены в нашем командном namespace для которого выделена отдельная квота ресурсов, что позволяет следить за ресурсами и при необходимости увеличивать квоту, а так же позволяет отделить наши сервисы от сервисов смежных команд.
Результаты и преимущества
Внедрение Kubernetes дало нашей команде возможность действовать быстрее и увереннее, снизив риски и повысив качество релизов. Мы смогли:
Ускорить вывод новых сервисов в продакшен;
Снизить долю ручных операций и ошибок при деплое;
Гибко управлять ресурсами и автоматизировать процессы масштабирования;
Повысить отказоустойчивость и стабильность всей системы;
Автоматизировать управление секретами приложений и сделать его более безопасным;
Стало проще отслеживать необходимые ресурс, что привело к экономии ресурсов;
Унифицировать и упростить сетевую безопасность с помощью NetworkPolicy;
Унифицировали и упростить мониторинг благодаря встроенным инструментам;
Унифицировали и упростить мониторинг благодаря встроенным инструментам;
Заменить некоторое простые сервисы планировщики на нативные cronJob’s;
Сегментировать наши сервисы от других команд в отдельном командном namespce;
