Введение в разработку приложений под Kubernetes
Сам заголовок статьи может показаться странным. Ведь, разместить в Kubernetes можно любое приложение (ну, или почти любое), упаковав его в контейнер типа Docker-а. Поэтому сразу необходимо сделать два существенных уточнения:
— мы будем говорить не просто о заливке какого-то сервиса в Kubernetes, а о разработке нового приложения таким образом, чтобы оно могло в полной мере использовать все возможности этой распределенной среды, при этом под разработкой приложения подразумевается не только разработка программного кода, но и необходимых манифестов для Kubernetes
— мы будем говорить о построении высоконагруженных, масштабируемых, отказоустойчивых, наблюдаемых приложений корпоративного уровня, использующих при своей работе не только возможности самого Kubernetes, но и другие сопутствующие технологии
Стоит сделать и еще одну оговорку. Большая часть пунктов статьи не относится именно к Kubernetes, а может быть применена и в других аналогичных распределенных средах или, вообще, самостоятельно
Как размещаются экземпляры сервисов в Kubernetes
Для сервиса, упакованного в контейнер (это необязательно должен быть Docker), выделяется один или несколько подов (под — это набор из одного или нескольких контейнеров Linux и наименьшая единица приложения Kubernetes). Вместе с контейнером сервиса на этом же поде могут размещаться другие контейнеры, как правило, выполняющие служебные функции. Отдельно для каждого контейнера задаются лимиты оперативной памяти и процессора. Поды располагаются на нодах (нода — это виртуальная или физическая машина в кластере Kubernetes).

Проверки живучести
Для проверки работоспособности пода Kubernetes использует тесты работоспособности, называемые livenessprobe и readinessprobe. Первый говорит о том, что под жив, второй о том, что под готов принимать запросы. В случае отрицательного livenessprobe, Kubernetes пересоздает под, так как считает его неработоспособным, в случае непрохождения readinessprobe на под не будут приходить запросы.
Приложение должно обеспечить корректное информирование Kubernetes о своем состоянии и это можно сделать несколькими способами, но мы остановимся на способе проверки через REST-запрос. Необходимо предусмотреть два URL-а, вызов которых методом GET должен возвращать HTTP код 200(OK), если соответствующая проверка пройдена. Далее, мы будем рассматривать разработку с использованием Spring Boot, который предоставляет эту возможность автоматически, с помощью spring-boot-starter-actuator:
implementation "org.springframework.boot:spring-boot-starter-actuator:${springBootVersion}"
Для включения доступности конечных точек нужно в application.yaml включить следующие настройки:
management:
server:
port: 8081
endpoint:
prometheus:
enabled: true
health:
probes:
enabled: true
endpoints:
web:
exposure:
include: "health"
Конфигурационные файлы приложения
Большинство приложений выносят свои настройки в конфигурационные файлы. В случае Spring Boot основной конфигурационный файл это application.yaml. Этот файл может хранится как внутри приложения, так и в файловой системе. Второй подход более гибкий, поскольку позволяет менять настройки без пересборки приложения. Kubernetes позволяет решить этот вопрос с помощью манифестов ConfigMap.
apiVersion: v1
kind: ConfigMap
metadata:
name: service-back-cm-0.01
namespace: myproject
labels:
version: "0.01"
app: service-back
group: service-back
data:
application.yml: |-
server:
port: 8080
shutdown: graceful
http2:
enabled: true
Пароли и другие секретные данные
Если приложение взаимодействует с базами данных, брокерами сообщений итд, то для авторизации может использовать пароли и файлы хранилищ ключей. Очевидно, что хранить их в самой сборке нельзя. Манифесты Secret позволяют хранить пары ключ-значение, которые могут быть при размещении приложения в Kubernetes импортированы либо в переменные окружения (это небезопасный подход), либо в конфигурационные файлы, используемые приложением
apiVersion: v1
kind: Secret
metadata:
name: service-back-db-secret
namespace: myproject
data:
db_pass: cXdlcnR5MTIz
type: Opaque
Доступ к файловой системе
Для того, чтобы приложение могло использовать временную файловую систему пода или внешнюю файловую систему, в основном конфигурационном манифесте Deployment нужно смонтировать тома (фактически, папки) разного типа — временные (emptyDir), хранящиеся на ноде (hostPath) или постоянные. В эти тома можно загрузить конфигурационный файлы из ConfigMap и файлы из секретов (например, хранилища ключей)
volumeMounts:
- name: config
mountPath: /var/app/config/application.yml
subPath: application.yml
- name: log
mountPath: /opt/app/log
volumes:
- name: config
configMap:
name: service-back-cm-0.01
- name: log
emptyDir: {}
Логирование
Приложение обязательно должно писать логи. Это единственный способ проанализировать конкретную возникшую ошибку или внештатную ситуацию. С точки зрения разработчика ничего особенного, кроме стандартного логирования в консоль, не требуется. Однако, если требуется сквозной поиск по нескольким взаимодействующим микросервисам, то нужно в лог выводить идентификатор сквозного взаимодействия (в любом виде). Далее, Kubernetes собирает логи со всех подов в файлы на ноде. Для сбора и отправки этих файлов в базу данных, просмотра и поиска в них используются такие инструменты как logstash, promtail, fluentd, grafana, kibana.
Метрики
Метрики, фактически, основной способ получить статистику и определить узкие места работающих сервисов продуктовой среде. Остальные способы — подключение в debug режиме, снятие дампов памяти и потоков, скорее всего, будут доступны только на тестовых средах. Кроме того, на основании метрик можно реализовывать алерты (сигналы о критическом состоянии приложения или его частей). Поэтому, отброс метрик приложением обязателен. Наиболее известная библиотека, используемая для отброса метрик, это Micrometer. Отбрасываемые приложением метрики умеет собирать Prometheus, а к нему может обращаться Grafana для построения дашбордов. Базовая библиотека метрик
io.micrometer:micrometer-core
входит в состав
org.springframework.boot:spring-boot-starter-actuator
но для экспорта метрик в Prometheus нужно еще подключить:
implementation "io.micrometer:micrometer-registry-prometheus:${micrometerVersion}"
в состав которой также входит
io.micrometer:micrometer-core
Пример дашборда

Трассировка
Трассировка позволяет отследить на графиках время выполнения запросов между взаимодействующими сервисами. Строго говоря, трассировка может быть заменена метриками, если вызываюший сервис будет всегда фиксировать время выполнения запроса к вызываемому (это возможно и при асинхронном взаимодействии), но по метрикам нельзя найти информацию по определенному идентификатору взаимодействия. Трассировка же, как и логи, предназначена, в первую очередь, для анализа конкретной ситуации. В случае использования трассировки будет отдельный дашборд, на котором по сквозному идентификатору взаимодействия можно получить информацию о времени выполнения запросов и ответов. С точки зрения разработчика для реализации трассировки нужно организовать прокидывание трассировочных заголовков в вызываемые сервисы, чтобы потом такие системы как Zipkin могли восстановить цепочку
Пример трассировочного дашборда

Сборка образа и заливка в реестр образов
После того как реализован код приложения и написаны тесты, нужно упаковать приложение в контейнер и залить в какой-либо реестр образов, откуда его сможет получить Kubernetes. Разумеется, для java-приложения, базовый контейнер должен содержать jre, но неплохо бы позаботиться о добавлении в контейнер различного рода диагностических утилит. Например:
jstack – для снятия дампов потоков
jmap – для снятия дампов памяти
top – для просмотра процессов и ресурсов
curl – для отправки запросов из консоли
После подготовки и заливки в реестр базового контейнера (в простейшем случае, в качестве базового можно взять и существующий, например, openjdk:17.0.1-jdk-slim) можно применить плагин jib для создания контейнера с приложением на основе базового и заливки его в реестр:
task prepareDockerImageBuild {
doFirst {
jib {
from {
image = 'openjdk:17.0.1-jdk-slim'
auth {
username = findProperty('DOCKER_USER')
password = findProperty('DOCKER_PASSWORD')
}
}
container {
jvmFlags = ['-Dfile.encoding=UTF-8', '-Dspring.config.location=file:/var/app/config/application.yml']
format = 'Docker'
mainClass = project.ext.applicationMainClass
appRoot = '/opt/app'
}
to {
image = "lokrusta/lokrusta-repo:${project.name}-${applicationVersion}"
auth {
username = findProperty('DOCKER_USER')
password = findProperty('DOCKER_PASSWORD')
}
}
}
}
}
Deployment — главный конфигурационный файл приложения
Главный конфигурационный файл приложения в Kubernetes обычно имеет тип Deployment. В нем в числе прочего указываем:
Метки
template:
metadata:
labels:
app: service-back
version: "0.01"
Метки используются в конфигах других ресурсов, чтоб указать применяется ли данная конфигурация или ее часть к ресурсу помеченному соответствующей меткой
Образ
spec:
containers:
- name: service-back
image: "lokrusta/lokrusta-repo:service-back-0.01-SNAPSHOT"
workingDir: /opt/app
Образ это ссылка на контейнер с приложением в репозитории образов, который будет установлен в под
Ресурсы памяти и процессора
resources:
limits:
cpu: 1000m
memory: 1Gi
requests:
cpu: 1000m
memory: 1Gi
Настройки livenessprobe и readnessprobe
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8081
initialDelaySeconds: 10
timeoutSeconds: 5
periodSeconds: 10
successThreshold: 1
failureThreshold: 6
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8081
initialDelaySeconds: 10
timeoutSeconds: 5
periodSeconds: 5
successThreshold: 1
failureThreshold: 6
Монтируем тома и указываем их содержимое
volumeMounts:
- name: config
mountPath: /var/app/config/application.yml
subPath: application.yml
- name: log
mountPath: /opt/app/log
- name: truststore-secret
mountPath: /etc/dbkey/truststore.jks
subPath: truststore.jks
- name: keystore-secret
mountPath: /etc/dbkey/keystore.jks
subPath: keystore.jks
volumes:
- name: config
configMap:
name: service-back-cm-0.01
- name: log
emptyDir: {}
- name: truststore-secret
secret:
items:
- key: truststore.jks
path: truststore.jks
secretName: service-back-dbkey-files-secret
- name: keystore-secret
secret:
items:
- key: keystore.jks
path: keystore.jks
secretName: service-back-dbkey-files-secret
Загружаем секреты
envFrom:
- secretRef:
name: service-back-secrets
Это пример загрузки секретов в переменные окружения (что, как говорилось выше, небезопасно). Пример загрузки файлов секретов приведен ранее в коде монтирования томов.
Подключаем вспомогательные образы (Istio, fluentbit и др)
template:
metadata:
annotations:
sidecar.istio.io/inject: "true"
sidecar.istio.io/proxyCPU: "200m"
sidecar.istio.io/proxyMemoryLimit: "300M"
sidecar.istio.io/logLevel: "debug"
sidecar.istio.io/excludeOutboundPorts: "9092"
В данном примере вспомогательный образ Istio (вспомогательные образы принято называть Sidecar) подключается неявно, с помощью технологии webhook, при которой API Kubernetes
сигнализирует о записи какого-либо ресурса и при нотификации проверяется наличие определенных меток или аннотаций и при их наличии код ресурса модифицируется — в данном случае добавляется подключение контейнера Istio.
Сайдкары также можно подключать обычным способом, указывая дополнительные элементы в списке containers
Сетевой доступ
В предыдущих пунктах не оговаривалась функциональность приложения, этапы построения которого мы описали, но наверняка наше приложение будет кто-то вызывать. Наиболее простой способ открыть приложение для внешних (из-за пределов кластера) или внутренних (внутри кластера) вызовов, это использование манифеста Service. В случае внутренних вызовов используется Service типа ClusterIP (это тип по-умолчанию)
apiVersion: v1
kind: Service
metadata:
name: service-back
labels:
app: service-back
service: service-back
namespace: myproject
spec:
ports:
- port: 8080
name: http-8080
- port: 8081
name: http-8081
selector:
app: service-back
Для внешних вызовов используются типы NodePort и LoadBalancer. Но, обычно, в том числе и с точки зрения безопасности, используются более сложные механизмы внешнего вызова, с использованием манифеста Ingress или Istio Gateway и Virtual Service
Автоматизация установки в Kubernetes и шаблонизатор
Ну и наконец приложение надо каким-то образом установить в Kubernetes. Ручная установка производится накатом манифестов командой:
kubectl apply -f <Имя_Файла_Манифеста>
Но, до этого нам надо еще создать и залить образ, создание которого описывалось ранее
В любом случае, заливка вручную подходит только для небольших тестовых проектов. В промышленной разработке используется 2 варианта:
1) Через Devops-конвейер, который запускает необходимые скрипты, с помощью джобов Jenkins
2) Локально из среды с помощью запуска, соответствующих задач сборщика (например, gradle)
Локальный вариант подходит для установки приложения разработчиками на dev стенды
В обоих случаях надо учесть, что содержимое манифестов Kubernetes должно быть параметризовано. В зависимости от стенда установки могут быть разные настройки соединения с базой данных, брокером сообщений, разное количество реплик итд.
Параметризацию можно производить с использованием шаблонизатора, например jinja:
app:
db:
url: {{ db_conn }}
Соответственно, скрипт установки должен подставлять вместо меток шаблонизатора (плэйсхолдеров) конкретные значения для данного стенда, а брать сами значения можно из Git репозитория, где в разных папках хранятся файлы со значениями плэйсхолдеров. Каждой папке соответствует свой стенд.