Предыстория

Когда изучаешь DevOps по курсам — всё выглядит просто. Запустил minikube, поднял pod, посмотрел на kubectl get pods — красота. А потом пытаешься сделать что-то настоящее и понимаешь: между “hello world в Kubernetes” и реальной инфраструктурой — пропасть.

Я решил эту пропасть пройти. Взял 5 виртуальных машин на VMware Workstation и построил на них production-ready кластер с нуля. С CI/CD, GitOps, мониторингом, высокой доступностью и реальным приложением.

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

Окружение: Ubuntu 22.04 LTS (ubuntu-22.04.4-live-server-amd64), VMware Workstation 17, Kubernetes 1.29, Calico 3.29.3


Результат

React + Go + FastAPI + PostgreSQL, всё в Kubernetes

Компонент

Версия / Детали

Kubernetes

1.29, kubeadm, 1 master + 4 workers

CNI

Calico v3.29.3, режим IPIP

Load Balancer

MetalLB v0.14.9, L2 ARP

Ingress

ingress-nginx v1.10

CI/CD

GitHub Actions + ArgoCD v2.10

БД

CloudNativePG v1.23, 1 primary + 2 replica

Мониторинг

kube-prometheus-stack chart v65.1.1

IaC

Terraform + Ansible

Security

Trivy в каждом pipeline


Часть 1 — Установка кластера

5 VM и Ansible вместо ручной настройки

k8s-master   192.168.11.101   control-plane   2 CPU / 4GB RAM
k8s-node-1   192.168.11.102   worker          2 CPU / 4GB RAM
k8s-node-2   192.168.11.103   worker          2 CPU / 4GB RAM
k8s-node-3   192.168.11.104   worker          2 CPU / 4GB RAM
k8s-node-4   192.168.11.105   worker          2 CPU / 4GB RAM

Написал Ansible playbook с тремя ролями. Запуск:

ansible-playbook -i inventory.ini install-k8s.yml

Ключевые задачи k8s-common (выполняется на каждой ноде):

- name: Disable swap permanently
  ansible.builtin.replace:
    path: /etc/fstab
    regexp: '^([^#].*\sswap\s.*)$'
    replace: '# \1'

- name: Load kernel modules
  ansible.builtin.modprobe:
    name: "{{ item }}"
  loop: [overlay, br_netfilter]

- name: Set sysctl for Kubernetes networking
  ansible.posix.sysctl:
    name: "{{ item.key }}"
    value: "{{ item.value }}"
    sysctl_file: /etc/sysctl.d/k8s.conf
    reload: true
  loop:
    - { key: net.bridge.bridge-nf-call-iptables, value: "1" }
    - { key: net.bridge.bridge-nf-call-ip6tables, value: "1" }
    - { key: net.ipv4.ip_forward, value: "1" }

- name: Configure containerd with SystemdCgroup
  # Ubuntu 22.04 использует cgroups v2 — обязательно включить SystemdCgroup
  ansible.builtin.shell: |
    containerd config default > /etc/containerd/config.toml
    sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml

- name: Install Kubernetes 1.29 (hold versions)
  ansible.builtin.apt:
    name: [kubelet=1.29.*, kubeadm=1.29.*, kubectl=1.29.*]
    state: present

Ubuntu 22.04 + cgroups v2: Без SystemdCgroup = true в containerd kubelet не стартует. На Ubuntu 24.04 поведение аналогичное.

ansible-k8s на GitHub


Calico CNI: почему IPIP, а не BGP

Calico поддерживает несколько режимов передачи трафика:

Режим

Как работает

Когда использовать

IPIP

Инкапсулирует pod-трафик в IP

Везде, не требует L2 между нодами

VXLAN

Инкапсуляция в UDP

Когда IPIP блокирован файрволом

Native BGP

Прямая маршрутизация

Физическое железо с BGP-роутером

Я выбрал IPIP — работает в VMware без дополнительных настроек.

Важно понимать: Calico использует BGP в обоих режимах (IPIP и VXLAN) — для обмена маршрутами между нодами (node-to-node mesh, iBGP). Но сам трафик подов при этом идёт через туннели, а не нативно. BGP здесь — не про внешнюю маршрутизацию, а про синхронизацию таблиц маршрутов внутри кластера.

kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.29.3/manifests/calico.yaml

Грабли #1 — MTU который убил TLS

После установки Calico ноды перешли в Ready. Попытался задеплоить приложение — pods ушли в ImagePullBackOff.

kubectl describe pod product-service-xxx | grep -A5 Warning
# Warning  Failed  Failed to pull image "ghcr.io/...": EOF

Начал копать. Токены — в порядке. Права — нормально. Ничего очевидного.

Диагностика MTU:

# Смотрим MTU физического интерфейса
ip link show ens33
# ens33: mtu 1500

# Смотрим MTU IPIP туннеля
ip link show tunl0
# tunl0: mtu 1480

# Проверяем прохождение пакетов разного размера
ping -M do -s 1400 192.168.11.102  # OK
ping -M do -s 1450 192.168.11.102  # message too long ← вот оно

Что происходит:

Физический MTU = 1500
IPIP добавляет заголовок = 20 байт
Эффективный MTU для pod трафика = 1480

TLS Certificate пакет ≈ 1460 байт
С IPIP заголовком: 1460 + 20 = 1480 — на грани
При фрагментации TLS разрывается → EOF

Фикс — MTU 1350 с запасом:

# Постоянно через netplan (Ubuntu 22.04)
nano /etc/netplan/00-installer-config.yaml
network:
  ethernets:
    ens33:
      dhcp4: false
      addresses: [192.168.11.102/24]
      mtu: 1350        # ← добавить эту строку
      routes:
        - to: default
          via: 192.168.11.1
netplan apply

После этого ImagePullBackOff пропал мгновенно. Полдня дебажить, чтобы поменять одно число.


Часть 2 — Сеть и доступ извне

MetalLB: реальный IP для bare-metal

В облаке Service: LoadBalancer получает внешний IP автоматически. В bare-metal кластере он навсегда в <pending>. MetalLB выдаёт IP из указанного пула через L2 ARP:

kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.14.9/config/manifests/metallb-native.yaml
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
spec:
  addresses:
    - 192.168.11.200-192.168.11.210
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: l2adv
  namespace: metallb-system

Ограничение L2 режима: при отказе ноды-лидера ARP переключение занимает до минуты, часть трафика теряется. Для production на физическом железе предпочтительнее режим BGP с поддержкой роутера. В моём окружении VMware L2 достаточно.

Ingress-NGINX получил IP 192.168.11.200. Приложение доступно как обычный сайт.


Часть 3 — GitOps и CI/CD

ArgoCD: от push до Running за 2 минуты

git push
    ↓
GitHub Actions: build → push image → update tag в k8s/base/deployment.yaml → git commit
    ↓
ArgoCD видит изменение манифеста (polling каждые 3 мин или webhook)
    ↓
kubectl apply автоматически
    ↓
Новая версия запущена

Грабли #2 — git push rejected и почему моё решение — костыль

При активном GitOps возникает гонка: GitHub Actions обновляет манифест и пушит коммит. Если в этот момент пушишь ты — rejected.

Моё решение для pet-проекта:

git stash && git pull --rebase && git stash pop && git push

Почему это костыль: в команде git pull --rebase может перезаписать чужие коммиты. Это временное решение, приемлемое когда разработчик один.

Как правильно:

  • ArgoCD Image Updater — следит за новыми тегами в registry и обновляет образ без изменения манифеста в репозитории. Никаких конфликтов.

  • Pull Request + auto-merge — GitHub Actions открывает PR с новым тегом, автоматически мержит после проверок.

Для pet-проекта с одним разработчиком rebase работает. Для команды — используйте Image Updater.


Часть 4 — Observability и база данных

Prometheus + Grafana

helm install kube-prometheus prometheus-community/kube-prometheus-stack \
  --namespace monitoring \
  --create-namespace \
  --version 65.1.1    # фиксируем версию чарта
https://raw.githubusercontent.com/TokarenkoKonstantin/cloud-shop/main/screenshots/grafana-cluster-overview.png
https://raw.githubusercontent.com/TokarenkoKonstantin/cloud-shop/main/screenshots/grafana-cluster-overview.png
https://raw.githubusercontent.com/TokarenkoKonstantin/cloud-shop/main/screenshots/grafana-node-exporter.png
https://raw.githubusercontent.com/TokarenkoKonstantin/cloud-shop/main/screenshots/grafana-node-exporter.png

На дашборде видно CPU/RAM по каждой ноде, запросы к сервисам, состояние подов product-service, order-service.


PostgreSQL HA через CloudNativePG

helm install cnpg cloudnative-pg/cloudnative-pg \
  --namespace cnpg-system \
  --create-namespace \
  --version 0.21.0    # фиксируем версию
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: postgres
spec:
  instances: 3          # 1 primary + 2 replica
  storage:
    size: 10Gi

Потоковая репликация, автоматический failover при падении Primary.


Часть 5 — Проблемы и планы

Грабли #3 — Calico BGP и недоступная нода

В какой-то момент поды на k8s-node-4 стали недоступны с других нод. Поды живые, трафик не доходит.

Диагностика:

# Статус BGP сессий на проблемной ноде
kubectl exec -n calico-system calico-node-XXXXX -- birdcl show protocols | grep BGP
# BGP сессии с другими нодами — Established
# BGP с node-4 — Active (не Established)

# Проверяем запущен ли BGP демон на node-4
ssh ubuntu@192.168.11.105 "ss -tulpn | grep 179"
# tcp LISTEN 0 128 *:179  *:*  users:(("bird",pid=1234))
# Демон запущен и слушает — значит проблема в сети, не в демоне

# Смотрим логи calico-node на проблемной ноде
kubectl logs -n calico-system calico-node-XXXXX | grep -i bgp | tail -20
# ... BGP session with 192.168.11.105 went down: Hold timer expired
# Hold timer expired — keepalive пакеты не доходят

# Проверяем потерю пакетов между нодами
ping -c 100 192.168.11.105 | tail -2
# 8 packets transmitted, 8 received, 0% packet loss ← обычный ping OK

# Но BGP keepalive на порту 179
nc -zv 192.168.11.105 179  # работает
# Проблема именно в нестабильности под нагрузкой на VMware VMNet

Вывод: bird запущен, порт слушает, обычный ping работает. Но BGP keepalive пакеты периодически теряются именно в VMware VMNet под нагрузкой — Hold timer истекает, сессия рвётся.

Временное решение — перенести критичные поды (PostgreSQL) на стабильные ноды:

spec:
  template:
    spec:
      nodeSelector:
        kubernetes.io/hostname: k8s-node-2

Правильное решение — перейти на VXLAN режим Calico, который не зависит от стабильности BGP для маршрутизации трафика. В планах.


Что ещё не сделано

Проблема

Текущее состояние

План

MTU persistence

Только node-1 через netplan, остальные — runtime

Ansible для всех нод

Calico VXLAN

Используется IPIP, BGP нестабилен на VMware

Migrate to VXLAN

Бэкапы

Нет

Velero + MinIO

TLS/HTTPS

HTTP

Cert-Manager + Let’s Encrypt

Secrets

Plaintext в манифестах

Sealed Secrets

GitOps конфликты

git rebase (костыль)

ArgoCD Image Updater

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


Итог

kubectl get nodes
# NAME         STATUS   ROLES           AGE   VERSION
# k8s-master   Ready    control-plane   42d   v1.29.4
# k8s-node-1   Ready    <none>          42d   v1.29.4
# k8s-node-2   Ready    <none>          42d   v1.29.4
# k8s-node-3   Ready    <none>          42d   v1.29.4
# k8s-node-4   Ready    <none>          42d   v1.29.4

kubectl get pods -n ecommerce
# NAME                                READY   STATUS
# frontend-65bb4b9d8d-wlxbk           1/1     Running
# order-service-64d768ddff-2gqf4      1/1     Running
# postgres-0                          1/1     Running
# product-service-67cf48889b-9gmtr    1/1     Running
# user-service-5b7bbf799b-5gvwd       1/1     Running

GitHub репозитории:

  • ?️ cloud-shop — полная инфраструктура + приложение

  • ⚙️ ansible-k8s — Ansible playbook: один запуск → готовый кластер

  • ? github-actions-templates — готовые CI/CD шаблоны

Если сталкивались с похожими проблемами или есть вопросы — пишите в комментариях!

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


  1. it1804
    29.05.2026 22:14

    На dev кластере на proxmox после тестирования что сейчас доступно выбрал cilium. Мне нравится. Анонс ip через gateway api от того же cilium, l2 балансировка из коробки. Пока тоже в режиме vxvlan.

    В целом планирую в недавнем времени прод тоже перевети на cilium