# Bare-metal Kubernetes на 5 VM: Calico IPIP + MetalLB + GitOps — честный опыт с граблями

от автора

Предыстория

Когда изучаешь 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 RAMk8s-node-1   192.168.11.102   worker          2 CPU / 4GB RAMk8s-node-2   192.168.11.103   worker          2 CPU / 4GB RAMk8s-node-3   192.168.11.104   worker          2 CPU / 4GB RAMk8s-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  # OKping -M do -s 1450 192.168.11.102  # message too long ← вот оно

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

Физический MTU = 1500IPIP добавляет заголовок = 20 байтЭффективный MTU для pod трафика = 1480TLS 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/v1beta1kind: IPAddressPoolspec:  addresses:    - 192.168.11.200-192.168.11.210---apiVersion: metallb.io/v1beta1kind: L2Advertisementmetadata:  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/v1kind: Clustermetadata:  name: postgresspec:  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-4ssh 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 на порту 179nc -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.4kubectl 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 шаблоны

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

ссылка на оригинал статьи https://habr.com/ru/articles/1041356/