Введение

Если Вы хоть раз работали с высоконагруженными приложениями, то наверняка знаете, какое количество головной боли возникает в процессе. Как правило, в таких приложениях все направлено на максимальную оптимизацию и увеличение быстродействия, но по достижению критической массы пользователей только на оптимизации уже не уехать. Частично эти проблемы помогает решить контейнеризация, которая сегодня есть уже почти у каждого сервиса. При попытке выложить свое приложение во всеобщий доступ, Вам наверняка придется неоднократно столкнуться с этим термином.

Типичные проблемы, с которыми Вы можете столкнуться при расширении аудитории:

  • Нехватки ресурсов в стрессовых ситуациях.

  • Усложнение развертывания.

  • Ухудшается доступность приложения.

  • Проблемы с расширением инфраструктуры.

Детальнее с Kubernetes Вы можете познакомиться в его документации. Без него сегодня вряд ли обойдется хоть один большой проект.

В этой статье мы познакомимся с базовым функционалом Kubernetes и разберемся на практике как можно его применить, развернув тестовое fullstack приложение.

Повествование будет идти от лица Android разработчика, который попал в мир DevOps практически по случайности и пытается разобраться кто есть кто и зачем.


Реализация приложения

Backend

Для реализации серверной части под руку мне попался новенький Ktor, с которым я уже когда-то имел честь познакомиться, но это было весьма скомкано.

На самом деле, выбор фреймворка абсолютно не важен, так как к сути статьи это не имеет значения. В вашем случае, более очевидным выбором может стать Spring.

В моем же случае, шаги по созданию будут следующими:

  1. Генерация проекта на официальном сайте (особенно если у Вас нет Ultimate версии IntelliJ)

  2. При создании нам нужно будет подключить два плагина - Routing и Kotlinx.Serialization (опционально)

  3. В сгенерированном проекте создадим простейшую модельку пользователя:

    @Serializable
    data class User(
        val id: Long,
        val name: String,
        val age: Int
    )
  4. В качестве хранилища данных будем использовать мутабельный список, чтобы не усложнять и без того не самый простой процесс.

  5. Добавим эндпоинт для просмотра всех пользователей:

    get("/users") {
        call.respond(HttpStatusCode.OK, users)
    }
  6. И эндпоинт для создания нового пользователя с минимальной логикой:

    post("/users") {
        val status = runCatching {
            val user = call.receive<User>()
            if (user.age >= 18) {
                users.add(user.copy(id = users.last().id + 1L))
                HttpStatusCode.Created
            } else HttpStatusCode.BadRequest
        }.getOrDefault(HttpStatusCode.BadRequest)
        call.respond(status)
    }
  7. На этом с реализацией серверной части можно закончить. Остальную магию за нас сделает сам Ktor.

Чуть позже мы еще вернемся к этому проекту.
Более подробно с кодом можно ознакомиться на GitHub


Android

В качестве клиентского приложения выберем Android с Compose. Опять же, в нашем случае, выбор непринципиален.

Этапы создания проекта будут почти что идентичны, поэтому углубляться в реализацию мы не будем:

  1. В Android Studio сгенерируем пустое приложение с Jetpack Compose и подключим все необходимые зависимости

  2. С помощью Retrofit добавим ранее созданные эндпоинты

    interface Api {
        @GET("/users")
        suspend fun getUsers(): Response<List<User>>
    
        @POST("/users")
        suspend fun createUser(
            @Body user: User
        ): Response<Unit>
    }
  3. После этого этапа могут начаться некоторые трудности - с большой вероятностью Ваше Android устройство ничего не знает о localhost. И при создании Retrofit объекта устройство не поймет, куда ему нужно идти за данными. Пути есть два - поместить сервер и клиент в одну локальную сеть, либо работать с эмулятором. Читать подробнее. В моем случае я выбрал вариант с эмулятором, поэтому мой клиент будет ходить на http://10.0.2.2:8080/. Если Вы хоть раз занимались разработкой Android приложения и backend, то, вероятно, уже знаете как Вам будет проще это решить.

  4. Делать запрос на сервер мы, естественно, будем в корутинах и асинхронно отправлять данные в UI:

    private fun getUsers() = viewModelScope.launch {
        val response = api.getUsers()
        response.takeIf { it.isSuccessful }?.run {
            _users.value = this.body() ?: emptyList()
        } ?: run { _error.value = "can't get users: ${response.code()}" }
    }
  5. Итоговый пользовательский интерфейс выглядит следующим образом:

Подробности реализации можно точно так же найти на GitHub


Развертывание с Kubernetes

И вот наконец мы дошли до самой темы этой статьи.

Перед тем, как приступить к развертыванию, необходимо будет провести дополнительные махинации по настройке всех нужных компонентов:

  • Включить виртуализацию на компьютере (если она все еще не включена)

  • Установить WSL (в случае Windows)

  • Установить Docker Desktop

  • Установить Minikube

Так как использовать полноценныйKubernetes будет весьма сложно и затратно, мы выбираем использование Minikube для создания кластера на локальной машине. Как правило, его используют как раз для разработки и тестирования, поэтому для нас это подходит идеально.

Создание контейнера с сервером

Начать, пожалуй, стоит именно с создания образа для контейнера, потому что без контейнеров - оркестрировать Kuberentes будет нечем.

Первое, что нужно будет сделать - это сказать докеру то, как и с помощью чего ему нужно создать контейнер. Указываются все шаги в специальном файле - Dockerfile в корне вашего проекта

FROM openjdk:17-jdk-alpine3.14
RUN mkdir /app
COPY ./build/libs/com.example.ktor-sample-all.jar /app/app.jar
ENTRYPOINT ["java","-jar","/app/app.jar"]
EXPOSE 8080
  1. FROM указывает базовый образ, к которому в дальнейшем мы будем обращаться. В нашем случае мы запрашиваем jdk.

  2. RUN выполняет команду mkdir для создания папки, в которую мы в дальнейшем положим наш файл для исполнения.

  3. COPY, как можно догадаться по названию, скопирует jar файл из базовой директории в ранее созданную.

  4. ENTRYPOINT выполняет команду с указанными аргументами при запуске контейнера. В нашем случае, запустится исполняемый файл с сервером.

  5. EXPOSE указывает на то, какой рабочий порт у контейнера мы желаем открыть.

Несмотря на маленький размер файла, он может вызвать большое количество головной боли, особенно если у Вас еще нет опыта работы с ним. Как правило, в документации к Вашему фреймворку можно найти подробности по созданию такого Dockerfile.

Однако, создавать образ пока еще рано - мы не подготовили тот самый jar файл, с которым он будет работать. Сделать это можно довольно просто:

  • Вызвать сборку руками в консоли - ./gradlew build или ./gradlew assemble (лично у меня этот вариант работает через раз).

  • Или с помощью вашей IDE, где в gradle/tasks можно запустить assemble и всё сразу соберётся.

  • Если Вы решили использовать что-то не на java или kotlin, то опять же эти команды можно найти в документации.

Теперь мы можем со спокойной душой собрать наш образ из корневой директории:
docker build -t ktor-sample .

Для проверки можете выполнить команду docker run -P ktor-sample или docker run -it -p 8080:8080 ktor-sample.

Поздравляю! Вы создали Ваш первый (а может уже далеко и не первый) Docker образ. Большинство разработчиков останавливаются примерно на этом моменте, но мы с Вами метим в более серьезную категорию.

Чтобы избежать проблем в будущей работе с Kubernetes, давайте провернем с Вами еще небольшой трюк, для которого нам понадобится:

  1. Зарегистрироваться на DockerHub.

  2. Теперь можно авторизоваться в него:
    docker login -u your login

  3. Попробуем загрузить наш шикарный образ на всеобщее обозрение:
    docker push your login/your-container-name
    Увы, у нас не получится:
    An image does not exist locally with the tag: ...

  4. Поэтому сначала добавим тег к нашему образу и повторим шаг 3:
    docker tag ktor-sample your login/your-container-name

Делали мы это, так как наш локальный Minikube знать не знает, что это за образ мы создали и где он находится. Решения может быть два:

На этом Docker можно оставить в покое.

Работа с Kubernetes

Теперь, прежде чем запускать наш кластер, создадим манифест, в котором объясним Kubernetes что и как делать с нашим образом.

Объявим Deployment, который будет поддерживать наше желаемое состояние:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: sample-deployment
spec:
  replicas: 2
  selector:
    matchLabels:
      app: sample-deployment
  template:
    metadata:
      labels:
        app: sample-deployment
    spec:
      containers:
        - name: sample-ktor
          image: qveex/ktor-sample:latest
          ports:
            - name: container-port
              containerPort: 8080

Из кода можно понять, что наше желаемое состояние - это:

  • Название Deployment, котором будут наши поды.

  • Использовать наш раннее созданный образ qveex/ktor-sample:latest.

  • Поддерживать две запущенные реплики этого образа.

  • Контейнер будет работать по порту 8080.

К сожалению, одного Deployment для работы будет недостаточно. Дополнительно нам нужно создать Service, который будет предоставлять нам доступ к ранее объявленным репликам:

apiVersion: v1
kind: Service
metadata:
  name: sample-service
spec:
  type: LoadBalancer
  selector:
    app: sample-deployment
  ports:
  - port: 8080
    targetPort: container-port

Здесь все попроще:

  • Сервис с именем sample-service.

  • Тип сервиса будет LoadBalancer.

  • А давать доступ сервис будет к порту 8080

А теперь на русском

Базовая единица Kubernetes - Pod, это обертка над одним контейнером или их группой. Kubernetes даёт нам возможность создавать Pod'ы, однако даже сама документация не особо рекомендует это делать.

Usually you don't need to create Pods directly, even singleton Pods. Instead, create them using workload resources such as Deployment or Job. If your Pods need to track state, consider the StatefulSet resource.

К использованию рекомендуют как раз Deployment, который мы и использовали ранее, так как он помогает нам, например, перезапускать поды, реплицировать их, распределять ресурсы и т.д.

Поскольку по умолчанию все объекты Kubernetes крутятся в нем без выходов в реальный мир, мы вынуждены создать Service, который любезно перенаправит наш сетевой запрос к нужному поду. В нашем примере мы использовал тип сервиса LoadBalancer, что говорит сервису, чтобы он распределял запросы по разным репликам, а не только в одну. Такой подход помогает оптимизировать нагрузку на наши поды. В нашем синтетическом случае, особый пользы это не принесет, но держать это в голове не помешает.

Упрощенная схема организации выглядит следующим образом:

Схема организации
Схема организации

Как видно, у нас есть две реплики нашего образа, за которыми непрерывно следит Deployment. Если пользователь хочет до них достучаться, то сначала его запрос попадет в Service, который решит куда его лучше передать и отдаст дальше в Deployment. После этого запрос попадет в нужный под, а далее в контейнер внутри пода, и только потом в наш написанный код.

Запутаться в этом крайне легко, но именно эти абстракции и надстройки помогают упрощать и автоматизировать весь процесс развертывания и дальнейшей поддержки.

Продолжаем

Ну и наконец пора поднять наш локальный Kubernetes кластер с помощью Minikube.

Для более удобной работы можно сделать alias kubectl="minikube kubectl --

  1. Запустим: minikube start

  2. Теперь мы можем воспользоваться нашим манифестом с Deployment и Service и создать компоненты нашей схемы kubectl apply -f deployment.yaml.

  3. Чтобы проверить результат, мы можем просмотреть работающие на данный момент поды:kubectl get pods
    Подробнее посмотреть состояние пода можно с помощью команды:
    kubectl descripe pod pod-name.
    А увидеть его логи можно так: kubectl logs pod-nape.

  4. Если Вы всё сделали правильно, поды должны работать и ожидать запросов к ним. Поэтому следующим шагом мы начнем перенаправлять запрос к ним с помощью сервиса одной из команд:
    kubectl port-forward service/sample-service 8080:8080
    minikube service sample-service
    minikube tunnel (рекомендует minikube)

  5. Теперь можно отправить тестовый запрос из Postman или протестировать с помощью нашего клиент приложения. В зависимости от того, как мы прокинули порт, доступ можно получить по:
    http://localhost:8080/users (в случае port-forward или tunnel)
    http://127.0.0.1:56735/users (в случае minikube service, откроется само)
    А с Android эмулятором пойдем по адресу http://10.0.2.2:8080/ и получим наш заветный json:

    [
       {
          "id":1,
          "name":"Misha",
          "age":22
       },
       {
          "id":2,
          "name":"Alexandr",
          "age":25
       },
       {
          "id":3,
          "name":"Rakhim",
          "age":22
       }
    ]

Поздравляю! Мы только что разработали fullstack приложение и даже умудрились локально развернуть его в Kubernetes!

Также, можем протестировать работу нашего LoadBalancer, что он пытается выполнять свою ключевую обязанность - распределение нагрузки.

Быстро начнем отправлять запросы с разных клиентов (я пробовал с помощью браузера и postman). Если заглянуть в логи подов, то можно увидеть следующую картину:

Под sample-deployment-7845fc9b8d-ptpkr:

2023-12-14 15:22:47.702 [eventLoopGroupProxy-4-1] TRACE io.ktor.routing.Routing - Trace for [users]
/, segment:0 -> SUCCESS @ /
  /users, segment:1 -> SUCCESS @ /users
    /users/(method:GET), segment:1 -> SUCCESS @ /users/(method:GET)
    /users/(method:POST), segment:1 -> FAILURE "Selector didn't match" @ /users/(method:POST)
Matched routes:
  "" -> "users" -> "(method:GET)"
Route resolve result:
  SUCCESS @ /users/(method:GET)

Под sample-deployment-7845fc9b8d-bz4vq:

2023-12-14 15:22:45.856 [eventLoopGroupProxy-4-1] TRACE io.ktor.routing.Routing - Trace for [users]
/, segment:0 -> SUCCESS @ /
  /users, segment:1 -> SUCCESS @ /users
    /users/(method:GET), segment:1 -> SUCCESS @ /users/(method:GET)
    /users/(method:POST), segment:1 -> FAILURE "Selector didn't match" @ /users/(method:POST)
Matched routes:
  "" -> "users" -> "(method:GET)"
Route resolve result:
  SUCCESS @ /users/(method:GET)

Как можно заметить, запросы действительно, приходят в разные реплики нашего приложения! И всё благодаря LoadBalancer.

А из-за того что мы использовали в качестве хранилища список, то при создании пользователей еще можно заметить несогласованность получаемых данных, так как пользователь создаётся только в одном из подов. Но это уже тема для другой статьи...


Заключение

В результате статьи, мы имеем полноценно работающий проект, который станет отличной отправной точкой в мире DevOps. Такое количество абстракций, виртуализация и т.п., что мы с Вами сделали, помогает максимально отвязаться от реализации наших проектов, что заметно может упростить работу при развёртке и поддержке.

Если Вы дочитали до этого момента, то теперь можете смело добавить навыки DevOps в своё резюме начинать более глубокое изучение Kubernetes на более сложных примерах с участием других компонентов (например, ConfigMap, Secrets, Ingress и т.д.) и детальнее изучить их строение изнутри.

И если Вы тоже только разбираетесь в этой области, как и я, то надеюсь, что это статья была для Вас хоть капельку полезна. ????

Источники

  1. Документация Ktor

  2. Документация Docker

  3. Подробности Dockerfile

  4. Minikube: Quick Start

  5. Kubernetes: Connecting Frontend & Backend

  6. Документация Deployment

  7. Документация Service

Комментарии (0)