Предисловие

За последние несколько месяцев плотной работы над инфраструктурой для проекта я прошёл путь от первых команд в терминале Linux до настройки полностью отказоустойчивого K3s‑кластера с Zero‑Downtime деплоем. Шишек на этом деле я набил огромное количество, и мне определённо есть чем поделиться.

Сразу оговорюсь: в этой статье не будет монотонных гайдов, слепых копипастов YAML‑манифестов и пересказа официальной документации. С базовой настройкой вы отлично справитесь, прочитав мануалы создателей этих инструментов.

Для меня этот материал — способ структурировать собственный опыт. Хотя, не буду скрывать, обсуждения в комментариях мне тоже интересны. Я хочу разобрать реальные ошибки при переходе от простого Docker Compose к Kubernetes, показать процесс траблшутинга и критически взглянуть на проделанную работу, чтобы понять: а всё ли было сделано верно?

Уверен, статья будет интересна не только DevOps‑инженерам, но и backend‑ и frontend‑разработчикам, а также любым техническим специалистам, которые хотят понимать, что на самом деле происходит с их кодом после пуша в репозиторий, почему локальная среда так сильно отличается от реального продакшена и как заставить приложение выживать при сбоях инфраструктуры.

Начало и предыстория (лирическое отступление: почему я полез в DevOps)

Когда‑то я не осознавал, кто он такой, ваш Kubernetes, и зачем он нужен. Слышал только мифы в духе: «его понимают только гении» или «один раз настроил огромную систему — и забыл». Всё начиналось с совета друга @DirtyHornet, порекомендовавшего пощупать Linux и другие технологии, связанные с DevOps.

До этого у меня уже был некоторый технический бэкграунд, который ограничивался переустановкой Windows и попытками собрать собственную игру на Unity — база каждого второго человека, у которого есть компьютер. В то же время назревал вопрос по защите моего проекта, у которого ещё не было цифровой части. Сразу признаюсь: код я почти полностью навайбкодил через Claude. Сама техническая суть проекта не очень важна для этой истории.

Так я начал изучать базу: Linux, Git, сети. Тонны роликов на YouTube, бесплатные курсы на Stepik и немного живой практики. Я был поражён устройством Unix‑систем: оказалось, существуют права доступа, какие‑то inode, а диски не просто сами по себе стыкуются с системой. После этого меня затянуло в Docker и Nginx. И снова — куча видео, чтение документации и фильтрация инфоцыган, пытающихся впарить курсы и продать базу, которая и так лежит на ладони. На протяжении всего этого периода я очень много гуглил и узнавал самые разные вещи: от алгоритмов SHA‑шифрования и внутренностей cert‑manager до принципов работы процессорной памяти.

Дедлайн по проекту всё близился. К тому моменту я уже знал неплохую базу и решил опробовать все знания на практике: арендовал облако (бесплатно, за грант Яндекса) и пошёл настраивать базовые инструменты вроде удалённого Git и Docker на этом сервере. Настроил Compose, Nginx, пробежался по best practices — и с этим вышел на защиту проекта. Тогда для меня это было верхом инженерной мысли, с теплом вспоминаю эти первые шажки.

Наступило лето, и первоначальная эйфория от работающего Docker Compose быстро улетучилась. Я понял, что хочу настоящей отказоустойчивости и бесшовных CI/CD‑пайплайнов, которые до этого видел только в чужих гайдах на Хабре. Инструментов для песочницы стало мало, проекту требовалась взрослая, сложная архитектура. Именно так я и оказался в одной тележке с Kubernetes.


Часть первая. Проектирование архитектуры

Вокруг k8s/k3s, да и вообще вокруг всего IT‑мира, много хайпа в стиле: «развернул кластер для своего нового убийцы Amazon на изи — и всё работает». Реальность, по моим наблюдениям, работает не так.

В этом плане мне очень помогли ролики YouTube‑канала «Девопсим потихоньку» и база по куберу с одноимённого канала Артура Крюкова. Они не просто дают набор шаблонных манифестов со словами «нате, пишите» или «засунем эту коробочку в ядро системы — всё, вот так мы освоили контейнеризацию», а пытаются донести, как это работает под капотом, и объяснить, что если просто сидеть и смотреть ролики со словами «о, под, круто» — ничего не изменится. Артур даёт шикарную базу, но иногда мне не хватало объяснения «зачем мы это делаем» — а я считаю, что это самый важный вопрос, который нужно себе задавать перед тем, как ты начнешь что‑либо делать. Поэтому приходилось много гуглить дополнительно.

Если вы тоже захотите просветиться в области Kubernetes, рекомендую ознакомиться с творчеством этих людей — может пригодиться. Готовьтесь к тому, что телефон с открытым умным поиском будет постоянно под рукой: вопросов по ходу возникает много.

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

  1. Что будет в этом кластере крутиться?

  2. На чём будет развёрнут кластер?

  3. Какие элементы мне потребуются, чтобы это работало без перебоев?

  4. Запустить эту феноменальную разработку и провести её краш‑тесты.

По ходу дела план, конечно, немного изменится — показываю, как это было в начале. Предлагаю начать.

С самого начала мне не хотелось раздувать бюджет и арендоввать несколько стоек серверов под мои скромные требования. Если немного углубиться в документацию по Kubernetes, вы быстро поймёте, что кубер — это не один проект, а целая кладезь инструментов от самых разных разработчиков. О его истории на Хабре написано достаточно, поэтому сразу перейду к моей ситуации.

Оказалось, что классический оркестратор (k8s vanilla) — крайне прожорливый гад, и запускать его на среднестатистическом ПК или виртуалке с небольшим количеством оперативки — затея сомнительная. Да и зачем, если легковесный k3s полностью закрывает мои потребности в построении взрослой и адекватной архитектуры?

По факту, этот «младший брат» почти не отличается от старшего: он компактнее, потребляет гораздо меньше памяти с любыми дополнениями, но имеет точно такой же API. Любые стандартные манифесты заводятся на обеих платформах без единого изменения. И главное — это не какая‑то обрезанная песочница вроде kind (Kubernetes in Docker), а полноценное production‑ready решение.

Мы получаем весь взрослый функционал Kubernetes (Rolling Update, Probes) без лишнего жира. Из коробки идёт лёгкая связка Kine + SQLite, а в базовый бинарник уже вшиты CoreDNS, Traefik в роли ingress‑контроллера и Local Path Provisioner для работы с дисками (PVC). Их можно прикрутить без лишней суеты.

В энтерпрайзе кубер разворачивают сразу на нескольких серверах (например, 3 мастер‑ноды для отказоустойчивости и несколько воркеров). Но чтобы выстроить надёжный CI/CD, мне не нужен огромный кластер. Я выбрал Single‑Node архитектуру: единственная облачная виртуалка становится и Control Plane, и Worker‑узлом.

Что насчёт альтернатив?

Конечно, я рассматривал и другие варианты, но они не очень подходили для моих целей:

  • RKE2 — 2–3 ГБ RAM только под собственные нужды + тяжёлая база etcd.

  • Minikube — шикарно для локальной разработки, но не подходит для хостинга реального приложения.

  • MicroK8s — всерьёз рассматривал, до того как узнал о его потреблении памяти.

Последней каплей для меня стали слова Артура Крюкова, о котором я говорил ранее. Для старта и понимания базы k3s подходит идеально: он избавляет от боли первичной настройки компонентов, позволяя сразу сфокусироваться на деплое приложений.


Часть вторая. Выбираем железо

После того как мы определили архитектурные требования, пришло время выбирать железо. Прошерстив интернет, я нашёл отличный вариант бесплатного хостинга для старта. Им оказался провайдер Cloud.ru (бывший SberCloud), который выдаёт 4000 бонусных рублей на два месяца — при подтверждении, что вы не злой дядя‑майнер.

Я намеренно собирал конфигурацию так, чтобы мне хватило на два месяца. Конечно, можно было бы взять больше CPU или RAM и сжечь грант за неделю, но для образовательных целей жёсткие ограничения — это, как по мне, скорее плюс, чем минус.

В итоге получилась вот такая конфигурация:

  • Оперативная память (4 ГБ): Kubernetes в принципе любит память. Самому k3s хватит и 512 МБ, но нужно оставить пространство для манёвра: PostgreSQL с томом данных, FastAPI‑бэкенд, React‑фронтенд и самое тяжёлое — временные поды GitLab‑раннера во время сборки образов. Минимальный комфортный порог для такой микросервисной связки — от 4 ГБ.

  • CPU (2 vCPU): для старта и отладки хватит 2 ядер. Основная нагрузка в пет‑проекте будет скачкообразной: во время сборки контейнеров в CI/CD‑пайплайне или в моменты, когда k3s будет экстренно поднимать замену убитому поду во время краш‑тестов.

  • Диск (30 ГБ SSD): базе данных жизненно необходим быстрый отклик (IOPS) для Persistent Volumes, а сборка образов требует пространства для временных слоёв. Обычный HDD здесь станет бутылочным горлышком, поэтому я строго ориентировался на SSD.

Позже, когда кластер был запущен, я подшлифовал всё это дело с помощью requests (гарантированные ресурсы) и limits (жёсткие ограничения) в манифестах подов — так мы защищаем систему от OOM Killer, который придёт за GitLab‑раннером, если тот вдруг начнёт задыхаться.


Часть третья. Создание каркаса и хранение данных (StatefulSet + PVC + Secrets)

После того как шикарная инсталляция K3s была запущена, SSH‑ключи для Git прокинуты, а GitLab Runner успешно завёлся внутри кластера (в docker‑контейнере), встал вопрос с архитектурой сервисов.

Мой план выглядел примерно так:

Позже, он был немного скорректирован:

  1. PostgreSQL в StatefulSet.

  2. Deployment для бэкенда и его связь с БД + фронтенд.

  3. Zero‑Downtime, Ingress и Probes.

  4. Сделать так, чтобы всё работало с TLS‑сертификатом в свободном доступе.

  5. GitLab Runner в подах.

Вот выглядит схема моей будущей архитектуры
Вот выглядит схема моей будущей архитектуры

Сначала я думал, что в этом Kubernetes всё просто: берёшь YAML от docker‑compose, чуть переделываешь и применяешь в кубе. Начать решил с хранения данных, так как это казалось самой тривиальной задачей (ровно до первого ощутимого пинка со стороны кластера).

Но сначала — ещё одно небольшое лирическое отступление для тех, кто только освоил азы docker‑compose:

Почему обычный Pod или Deployment — худшее место для базы данных
  1. Поды эфемерны: если контроллер захочет перезагрузить или пересоздать под, база исчезнет, данных там не останется.

  2. Реплики StatefulSet просто не поделят базу без настройки Master‑Slave на уровне БД.

  3. При запуске Deployment поды получают в именах случайный хеш‑суффикс (вот такой: postgres‑db‑ajfdjfчетотам), и при перезапуске он всегда меняется — реплики просто не найдут мастера по сетевому адресу.

  4. Вы не можете просто поставить replicas: 2 для базы данных в надежде на отказоустойчивость. Две независимые реплики PostgreSQL (без настроенной master‑slave репликации) не смогут безопасно писать данные на один диск — postgres поедет кукухой.

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

Здесь я ещё думал, что Kubernetes не плевать на файл.env, и подключил его к StatefulSet во избежание недопониманий между мной и кластером. Я, по‑моему, забыл добавить его в.gitignore для удалённой ветки, каюсь…

apiVersion: v1
kind: Service
metadata:
  name: db
  labels:
    app: pg_app_compose
spec:
  ports:
  - port: 5432
    name: app-network
  clusterIP: 127.0.0.1 # странная сеть, Кубер не пустит внешний трафик
  selector:
    app: pg_app_compose # путаница с именами сущностей и лейблами
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: app-network
spec:
  serviceName: "db"
  replicas: 1
  selector:
    matchLabels:
      app: pg_app_compose
  template:
    metadata:
      labels:
        app: pg_app_compose
    spec:
      containers:
      - name: pg_app_compose
        image: postgres:16-alpine
        # Здесь я наивно ждал, что Кубер сам найдет мой .env файл и подставит пароли
        ports:
        - containerPort: 5432
          name: app-network
        volumeMounts:
        - name: pg_backend_data
          mountPath: /var/lib/postgresql/data
  volumeClaimTemplates:
  - metadata:
      name: pg_backend_data
    spec:
      accessModes: [ "ReadWriteOnce" ]
      resources:
        requests:
          storage: 1Gi


backend‑deployment был написан по старой тактике: берём с доки базовый манифест, подсматриваем в docker‑compose. И тут начались приколы с GitLab Container Registry: он был приватным, поэтому не давал стянуть образ (при первом применении я накосячил, вставив путь с опечаткой).

Скрин ошибки 401 Unauthorized
Скрин ошибки 401 Unauthorized

На этом проблемы с дружбой бэкенда и БД не закончились: снова вижу, что поды после применения не стартуют (Ready 0/1). Важное правило, которое я усвоил по ходу этой движухи: если вы видите ошибку и она вам кажется простой, не пытайтесь исправить её наугад. Не поленитесь прописать kubectl logs / describe pod, даже если самая умная нейронка даёт быстрый совет — она может не знать всю ситуацию в целом, и вы только зря потратите время.

Pods READY 0/1
Pods READY 0/1

Посмотрите, какой кубер трудяга:

Normal  BackOff  3m56s (x18558 over 2d22h)  kubelet
spec.containers(fastapi-backend):

Больше 18 тысяч попыток скачать образ… В итоге я исправил путь к образу, но куб всё равно выдавал ошибку ImagePullBackOff. Здесь я впервые полыхнул и решил переделать часть с секретами полностью по новой — и тут же встретил новые ошибки: CreateContainerConfigError. Kubernetes опять даёт по рукам ошибкой: type: Invalid value: “Opaque”: field is immutable.

Invalid value
Invalid value

Почему? Я случайно дал секрету с паролями для базы и секрету с токенами GitLab (для авторизации) одно и то же имя, а в Kubernetes на лету нельзя изменить тип уже существующего секрета. Он увидел, что я попытался накинуть ещё секретов поверх старых, и заблокировал это.

Удалив конфликтующие секреты, я аккуратно создал пропуск для GitLab через консоль:

kubectl create secret docker-registry gitlab-registry-secret \
  --docker-server=registry.gitlab.com \
  --docker-username='' \
  --docker-password='' \
  --docker-email=''
Образ стянулся
Образ стянулся

Конечно, я не забыл привязать токен авторизации в базе для бэкенда (как это было сделано в compose: чтобы писать данные, бэкенду необходимо знать имя базы, пароль и пользователя) — и не потратил кучу времени на выяснение, в чём дело. После этого оставалось только поменять SQLAlchemy‑драйвер с psycopg2 на asyncpg, так как в docker‑compose переменные передавались по частям, а современным ORM нужен единый URI‑адрес подключения (Universal Resource Identifier, устроен точно так же, как обычный веб‑адрес в браузере). В Kubernetes это решается прописыванием ссылки прямо в YAML:

- name: DATABASE_URL
  value: "postgresql+asyncpg://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)"
METRICS_API_KEY, psycopg2 (немного обрезал, чтобы не растягивать картинку)
METRICS_API_KEY, psycopg2 (немного обрезал, чтобы не растягивать картинку)
В чём фишка префикса postgresql+asyncpg://?
  • postgresql — сообщает приложению, какой диалект SQL использовать (синтаксис Postgres отличается от MySQL).

  • +asyncpg — указывает, какой драйвер использовать под капотом. Мой FastAPI работает асинхронно. Если написать просто postgresql://, Python попытается подтянуть стандартный синхронный драйвер (psycopg2), на отсутствие которого он начнёт ругаться.

Сборка строки через $(...) позволила собрать все секретные кирпичики в один рабочий URI. На этом мои мучения с дружбой бэкенда и базы данных успешно завершились.

Все завелось
Все завелось

Часть четвёртая. Сеть и маршрутизация (Traefik, Ingress + cert‑manager)

Теперь поговорим о том, как объекты связаны между собой в сети Kubernetes. Про фронтенд детально рассказывать не буду — там всё запустилось без особых проблем (скучно). А вот на настройке Ingress‑контроллера (Traefik) и сертификатов стоит остановиться подробнее — подлянок там собралось немало.

Изначально я хотел по привычке из docker‑compose сделать так, чтобы Nginx внутри пода фронтенда проксировал запросы на бэкенд через location /api/. Оказалось, что это полная шляпа: пускать весь трафик через под фронтенда нельзя. При тяжёлых запросах к API бэкенда сетевая нагрузка будет расти и на фронте, поэтому трафик следует пускать напрямую туда, где он будет обрабатываться. Решить это было достаточно просто — запуском трафика напрямую через Ingress. Я вынес настройки Nginx в отдельный ConfigMap и прокинул его в под с фронтендом. Теперь Nginx только раздаёт статику React, не вставая на пути у данных с бэкенда.

Вот такой небольшой ConfigMap получился:

apiVersion: v1
kind: ConfigMap
metadata:
  name: frontend-nginx-config
data:
  default.conf: |
    server {
        listen 80;
        server_name localhost;

        location / {
            root /usr/share/nginx/html;
            index index.html index.htm;
            try_files $uri $uri/ /index.html;
        }
    }

А чтобы выкатывать новые версии без обрыва соединений пользователей (Zero‑Downtime), я добавил readinessProbe и livenessProbe: балансировщик пускает трафик на под фронтенда или бэкенда только тогда, когда приложение внутри реально стартовало и готово отвечать (чтобы запросы от наших пользователей в случае неготовности пода не улетали в пустоту).

Вот так:

readinessProbe:
    httpGet:
    path: /
    port: 80
    initialDelaySeconds: 2 # сколько секунд подождать после старта перед первой проверкой
    periodSeconds: 5       # как часто проверять
livenessProbe:
    httpGet:
    path: /
    port: 80
    initialDelaySeconds: 5
    periodSeconds: 10

Всё это делалось не без грабель.

Первые грабли. Всю маршрутизацию я отдал Traefik. Чтобы запросы вида https://site.com/api уходили в FastAPI как корневые /, нужно было использовать встроенный механизм Traefik — Middleware StripPrefix. Но при добавлении его в аннотации Ingress маршрут наглухо ломался: сервер возвращал ошибку 404 page not found от Traefik (в браузере), хотя поды работали исправно, а логи Nginx были пустыми.

Вот так выглядел Traefik:

apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
  name: strip-api-prefix
spec:
  stripPrefix:
    prefixes:
      - /api
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-ingress
  annotations:
    kubernetes.io/ingress.class: "traefik"
    traefik.ingress.kubernetes.io/router.middlewares: "default-strip-api-prefix@cuberdns" # <-- вот из-за этой фигни Traefik прикрыл маршрут
    # автополучение сертификата
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
  # сертификат
  tls:
  - hosts:
    - hybrid-lab.duckdns.org
    secretName: hybrid-lab-tls-secret # сюда сохраняем выданный сертификат
  # маршрутизация трафика
  rules:
  - host: hybrid-lab.duckdns.org
    http:
      paths:
      - path: /api
        pathType: Prefix
        backend:
          service:
            name: backend-service
            port:
              number: 8000
      - path: /
        pathType: Prefix
        backend:
          service:
            name: frontend-service
            port:
              number: 80

Я ошибочно прописал провайдера как @cuberdns. Traefik видел неизвестного провайдера и в целях безопасности просто отключал весь маршрут. Как только синтаксис был исправлен на правильный @kubernetescrd, префиксы начали отрезаться корректно, и Ingress нашёл сервисы.

Вторые грабли: квест с Let’s Encrypt.

Маршруты заработали, но зелёный замочек не появлялся… Сертификат завис в статусе Pending.

Reason: Pending
Reason: Pending

Пришлось спускаться на уровень ниже и подебажить цепочку kubectl get certificate → describe order → describe certificaterequest.

Выяснились две проблемы:

  1. В настройках ClusterIssuer не хватало email‑адреса, из‑за чего валидация даже не стартовала. ClusterIssuer отдаёт этот email серверу ACME (Let’s Encrypt), и если сертификат не обновляется, а до конца его действия остаётся меньше 20 дней, он высылает вам письмецо

  2. Финальным аккордом стала настройка Security Groups в самом облаке (Cloud.ru). Робот Let’s Encrypt (HTTP-01 challenge) не мог достучаться до кластера, пока я не открыл 80 и 443 TCP‑порты на облачном файрволе для входящего трафика.

Все завелось
Все завелось

Так сеть — не без затыков — но была настроена.


Часть пятая. GitLab Runner в отдельных подах, gitlab‑ci

Завершающей частью моего построения архитектуры стала настройка раннера CI/CD прямо внутри кластера. Раньше всё это дело крутилось в классическом Docker: раннер линтил код, гонял тесты, собирал образы, отправлял их в GitLab Registry и высылал на сервер. Пришло время причесать эту часть и заставить раннер самому применять изменения из манифестов при каждом пуше.

Здесь я всё‑таки решился воспользоваться Helm. Для начала нужно было создать отдельный namespace для раннера и выдать раннеру права администратора (RBAC) в кластере, чтобы он мог деплоить сервисы:

apiVersion: v1
kind: Namespace
metadata:
  name: gitlab-runner
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: gitlab-runner
  namespace: gitlab-runner
---
# даём раннеру права админа в кластере, чтобы он мог делать deploy через CI/CD
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: gitlab-runner-admin
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
  - kind: ServiceAccount
    name: gitlab-runner
    namespace: gitlab-runner

Вообще я не люблю огромные портянки из bash‑команд, но для установки Helm‑чарта это необходимое зло:

helm install --namespace gitlab-runner gitlab-runner gitlab/gitlab-runner \
  --set gitlabUrl=https://gitlab.com/ \
  --set runnerRegistrationToken="..." \
  --set rbac.create=true \
  --set rbac.serviceAccountName=gitlab-runner \
  --set runners.tags="" \
  --set runners.privileged=true

Ну и не забыть прописать в загрузочный файл ~/.bashrc экспорт переменной KUBECONFIG (путь конфига от k3s), чтобы Helm по привычке не искал его по стандартному пути ~/.kube/config (где обычно лежит конфиг k8s) и не выдавал ошибку.

Файл ~/.bashrc
Файл ~/.bashrc
Это он в действии
Это он в действии

Это ещё не всё. Когда раннер показал статус Online, при git push он начинал свою работу и останавливался на этапе скачивания образа для джобы. Делаем kubectl describe pod... ‑n gitlab‑runner. Видим истинную причину: 401 Unauthorized.

Error: ErrImagePull
Error: ErrImagePull

В наши времена бурного развития ИИ‑парсеров GitLab решил закрутить гайки для анонимных скачиваний (а наш раннер таковым и является) без логина и пароля, плюсом накладываются региональные блокировки.

Указываю своему раннеру ходить за образом на глобальный Docker Hub. Заодно (забегая вперёд) я отключил флаг privileged: true, так как для дальнейшей сборки образов он нам больше не понадобится:

helm upgrade --install --namespace gitlab-runner gitlab-runner gitlab/gitlab-runner \
  --set gitlabUrl=https://gitlab.com/ \
  --set runnerRegistrationToken="" \
  --set rbac.create=true \
  --set serviceAccount.name=gitlab-runner \
  --set runners.tags="botan" \
  --set runners.privileged=false \
  --set image.registry="docker.io" \
  --set image.image="gitlab/gitlab-runner"

С сетью разобрались, но после запуска пайплайна раннер споткнулся на этапе сборки — новые образы отказывались собираться. Возникали конфликты в сетях временного пода раннера (скрипт сборки и сервис Docker‑in‑Docker делили общую сеть, что приводило к таймаутам).

GitLab pipeline
GitLab pipeline

Вместо того чтобы костылить проброс сокетов хоста в контейнер, я решил переписать gitlab‑ci.yml и использовать Kaniko. Это официальная утилита от Google, созданная специально для сборки образов внутри Kubernetes. Ей вообще не нужен демон Docker, не нужны root‑права (privileged: true) на сервере, и работает она быстрее, так как собирает образ слой за слоем прямо в оперативной памяти (в user‑space).

build_backend:
  stage: build
  image:
    # образ Kaniko
    name: gcr.io/kaniko-project/executor:debug
    entrypoint: [""]
  script:
    # конфиг для авторизации в GitLab Registry
    - mkdir -p /kaniko/.docker
    - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json
    # собираем и пушим образ одной командой
    - /kaniko/executor
      --context "${CI_PROJECT_DIR}/metrics-backend"
      --dockerfile "${CI_PROJECT_DIR}/metrics-backend/Dockerfile"
      --destination "${BACKEND_IMAGE}"

И, наконец, сам деплой. Runner должен общаться с кластером нативно — напрямую стучаться в API Kubernetes. Вместо громоздких SSH‑подключений и копирования файлов (набор юного автоматизатора) шаг деплоя сократился до одной изящной команды. Раннер просто берёт манифесты прямо из клонированного Git‑репозитория и отдаёт их куберу:

deploy_app:
  stage: deploy
  image: bitnami/kubectl:latest
  script:
    # раннер уже внутри кластера и авторизован через ServiceAccount,
    # ему не нужны SSH-ключи — он просто применяет манифесты из репозитория:
    - kubectl apply -f k3s/ --recursive

Всё! Теперь пайплайн стал по‑настоящему нативным — чистый Push‑based GitOps без единого SSH‑ключа.


Часть шестая. Краш‑тесты

Конечно, можно было поднять модный k8s‑кластер на несколько нод, не доделать его, красиво описать теорию и закончить статью. Но я считаю, что красивые манифесты ничего не стоят, если система падает от первого же чиха.

Теперь я хочу показать, как кластер остаётся живым при любых искусственных катастрофах, а пользователи не увидят ошибку 502 на своём экране.

Тест 1. Выкатка новой версии под нагрузкой (ZDD)

Ранее я упоминал, что настроил Zero‑Downtime Deployment.

Как именно настроен Rolling Update

Чтобы балансировщик не пускал трафик в пустоту, а пользователи не ловили обрывы при выкатке новых версий, в манифестах Deployment прописаны три ключевые вещи:

1. Стратегия RollingUpdate:

  • maxSurge: 1 — разрешает Kubernetes создать один дополнительный под сверх лимита во время обновления.

  • maxUnavailable: 0 — строго запрещает удалять старые поды до тех пор, пока новые полностью не будут готовы принимать трафик.

2. Пробы готовности (readinessProbe):

Kubernetes постоянно стучится на эндпоинт /health. Пока приложение внутри контейнера реально не стартует и не ответит HTTP 200 OK, трафик на этот под не пойдёт.

3. livenessProbe:

Следит за зависаниями. Если приложение намертво зависнет, под автоматически перезагрузится.

Проверим это в бою. Запускаем в отдельном терминале бесконечный цикл curl, который будет опрашивать наш сервер каждую десятую долю секунды и выводить HTTP‑статус:

while true; do
  curl -s -o /dev/null -w "%{http_code}\n" https://hybrid-lab.duckdns.org/
  sleep 0.1
done

Запросы пошли (200 возвращается) — можно запускать пайплайн в GitLab, который собирает образы и применяет манифесты.

Поехали
Поехали

Образ собирается, манифесты летят в кластер. Наблюдаем за происходящим через k9s. На скриншоте ниже видно магию ZDD в действии: новые реплики создаются, но Service не пускает на них трафик (подсвечен на скриншоте красным). Старые реплики продолжают обслуживать пользователей и перейдут в статус Terminating только тогда, когда новые полностью загорятся зелёным.

Логи пошли
Логи пошли

Всё работает: на последнем фото видно, как создаются новые реплики и заменяют старые. Service не пускает трафик на неготовый под (он горит красным):

Трафик идет без перебоев
Трафик идет без перебоев
frontend полностью обновился, у бэка пока идет работа
frontend полностью обновился, у бэка пока идет работа

Ни одного потерянного запроса. Обновление прошло бесшовно.

Тест 2. Убиваем Stateless‑под

Здесь всё предсказуемо, но проверить нужно. Пока имитируется создание метрик, командой kubectl delete pod... (или Ctrl + D в k9s) убиваем под с бэкендом.

Service моментально исключает его из балансировки, а Ingress перекидывает трафик на вторую, живую реплику. Пользователь ничего не замечает. Через пару секунд ReplicaSet видит, что желаемое состояние порушено, и автоматически поднимает новый под.

Под запустился
Под запустился

Тест 3. Смерть базы данных

Самое печальное для любого проекта — потеря пода с PostgreSQL под нагрузкой. Как вы помните, для БД мы использовали StatefulSet (PVC) вместо Deployment. Фишка PersistentVolumeClaim в том, что жёсткий диск существует независимо от пода: в критических ситуациях он «отсоединяется» от умирающего пода и ждёт.

Проверим нашу базу до теста:

Что то есть
Что то есть

Убиваем под postgres-0, Kubernetes замечает потерю и пересоздаёт его. При запуске новый под автоматически цепляет тот же самый PVC. Проверяем данные — всё на месте.

Под с postgress поднялся
Под с postgress поднялся
Все живо
Все живо

Тест 4. Перегрузка каналов трафиком (стресс‑тест)

Ранее в манифесте backend‑deployment.yaml я жёстко ограничил ресурсы контейнеров:

resources:
  requests:
    memory: "128Mi"
    cpu: "100m"
  limits:
    memory: "512Mi"
    cpu: "500m"

Забьём каналы трафиком и направим 400 одновременных соединений на бэкенд:

wrk -t12 -c400 -d60s https://hybrid-lab.duckdns.org/api/health
Warning CPU Level
Warning CPU Level

На скриншоте из k9s видно, как поды поднапряглись (Warning CPU Level) и уперлись в свой процессорный лимит (500m). И вот тут кроется главная прелесть Kubernetes: благодаря жёстким лимитам поды не могут сожрать все ресурсы сервера. Приложение не падает, операционная система хоста не зависает. Бэкенд просто начинает троттлить (искусственно замедлять обработку процессором), но продолжает упрямо отдавать ответы. Инфраструктура в безопасности.


Заключение

Оглядываясь назад, могу сказать, что за эти пять месяцев был проделан колоссальный объём работы. От первых шагов в Linux и локального Docker Compose — до отказоустойчивого кластера, который собирает образы в памяти и выдерживает стресс‑тесты.

В самом начале пути я даже не подозревал, насколько здесь всё элегантно устроено под капотом. Когда начинаешь понимать принципы работы тех же Taints & Tolerations и видишь, как контроллер изящно раскидывает поды по нодам, соблюдая все твои правила — на лице невольно появляется некая довольная улыбка. Это реально круто. Постепенно развеялся страх перед тем, что Kubernetes — это неподъемная глыба информации, которую невозможно усвоить. Не могу сказать, что у меня была какая‑то четкая мечта настраивать кластеры и копаться в архитектуре. Мне просто было в кайф решать эти задачи и заставлять систему работать.

Конечно, на этом останавливаться было бы глупо. Пока не буду загадывать, но в планах на будущее — прикрутить нормальный мониторинг (смотря что по ресурсам хостинга), чтобы не смотреть метрики через терминал, и перевести деплой на ArgoCD для полноценного GitOps. Я уже начал изучать эти темы, но пока до реализации руки не дошли. Не стоит хвататься за всё сразу.

Все манифесты, скрипты и исходный код проекта лежат в моём репозитории на GitHub и GitLab. Буду рад, если кому‑то этот опыт сэкономит время и нервы.

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