admin zEVsdgPRNTqVrJcq
Все примеры — Hetzner Cloud, дистрибутив RKE2, ingress-nginx + Rancher на cert-manager + Let's Encrypt.
kubectlUbuntu / Debian
sudo apt-get update
sudo apt-get install -y apt-transport-https ca-certificates curl gnupg
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.30/deb/Release.key \
| sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.30/deb/ /' \
| sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update
sudo apt-get install -y kubectl
kubectl version --client
CentOS / AlmaLinux / Rocky
sudo tee /etc/yum.repos.d/kubernetes.repo <<EOF
[kubernetes]
name=Kubernetes
baseurl=https://pkgs.k8s.io/core:/stable:/v1.30/rpm/
enabled=1
gpgcheck=1
gpgkey=https://pkgs.k8s.io/core:/stable:/v1.30/rpm/repodata/repomd.xml.key
EOF
sudo dnf install -y kubectl
kubectl version --client
⚠️ v1.30 в URL — не "клиент", а репо канал. Для свежей версии сменить на v1.31, v1.32 и т.д.
k9s (TUI для kubectl)# простой способ через webi
curl -sS https://webi.sh/k9s | sh
# либо ручной (Linux amd64)
wget https://github.com/derailed/k9s/releases/latest/download/k9s_Linux_amd64.tar.gz
tar xzf k9s_Linux_amd64.tar.gz k9s
sudo install k9s /usr/local/bin/k9s
k9s # читает ~/.kube/config по умолчанию
K9s читает KUBECONFIG, навигация стрелками, :pods, :nodes, :svc. Логи — l, exec в контейнер — s.
⚠️ Перед стартом — выключить swap, открыть порты 6443, 9345, 10250, 2379-2380 между нодами, кастомный SSH порт в firewall.
Master нода
# генерим shared token (один раз — на всех нодах должен совпадать)
openssl rand -hex 32
# установка
curl -sfL https://get.rke2.io | INSTALL_RKE2_CHANNEL=stable INSTALL_RKE2_TYPE=server sh -
# конфиг
sudo mkdir -p /etc/rancher/rke2
sudo tee /etc/rancher/rke2/config.yaml <<EOF
token: <SHARED_TOKEN>
node-ip: 10.0.1.10 # private IP мастера
advertise-address: 10.0.1.10
bind-address: 0.0.0.0
write-kubeconfig-mode: "0640"
tls-san: # все адреса, по которым будут ходить в API
- 10.0.1.10
- <PUBLIC_IP_MASTER> # если будешь дёргать API снаружи
EOF
# старт
sudo systemctl enable --now rke2-server.service
# kubectl + KUBECONFIG для root/локального доступа
sudo ln -sf /var/lib/rancher/rke2/bin/kubectl /usr/local/bin/kubectl
echo 'export KUBECONFIG=/etc/rancher/rke2/rke2.yaml' | sudo tee /etc/profile.d/rke2.sh
# проверка
sudo kubectl get nodes
Worker нода
curl -sfL https://get.rke2.io | INSTALL_RKE2_CHANNEL=stable INSTALL_RKE2_TYPE=agent sh -
sudo mkdir -p /etc/rancher/rke2
sudo tee /etc/rancher/rke2/config.yaml <<EOF
token: <SAME_TOKEN_AS_MASTER>
server: https://10.0.1.10:9345 # private IP мастера, порт 9345 (supervisor)
node-ip: 10.0.1.20 # private IP воркера
EOF
sudo systemctl enable --now rke2-agent.service
✅ Через 1-3 минуты kubectl get nodes на мастере покажет воркера со статусом Ready.
RKE2 «из коробки» не несёт CSI-драйвер под Hetzner. Без него у кластера нет default StorageClass, и любой PersistentVolumeClaim (Postgres, Redis, Minio, etc.) висит в Pending с событием:
no persistent volumes available for this claim and no storage class is set
⚠️ Делать сразу после старта кластера, до cert-manager / Rancher / любого stateful воркшоу — иначе их PVC так и не забиндятся, helm-инсталлы зависнут в wait.
Установка
# 1. Hetzner API token (тот же, которым Terraform пользуется)
HCLOUD_TOKEN=<твой_токен> # 64 hex-символа
# 2. Положить токен Secret'ом, CSI его читает на старте
kubectl -n kube-system create secret generic hcloud \
--from-literal=token="$HCLOUD_TOKEN"
# 3. Поставить чарт + создать дефолтный StorageClass `hcloud-volumes`
helm repo add hcloud https://charts.hetzner.cloud
helm repo update hcloud
helm install hcloud-csi hcloud/hcloud-csi -n kube-system \
--set 'storageClasses[0].name=hcloud-volumes' \
--set 'storageClasses[0].defaultStorageClass=true' \
--wait --timeout 5m
Проверка
# default-флаг — звёздочка в выводе
kubectl get storageclass
# NAME PROVISIONER RECLAIMPOLICY ... AGE
# hcloud-volumes (default) csi.hetzner.cloud Delete ... 1m
# pod'ы csi-controller + csi-node на каждой ноде
kubectl -n kube-system get pods -l app.kubernetes.io/name=hcloud-csi
✅ С этого момента любой PVC без storageClassName: автоматически берёт hcloud-volumes, под капотом создаётся Hetzner Cloud Volume и крепится к ноде.
⚠️ Volumes Hetzner — block-storage уровня нода-локально: pod, у которого приехал PV, может рестартовать только на той же ноде, где он был создан (или сначала volume должен detach-attach между нодами, ~15 секунд). Для wiki/forum это не проблема, для критичных stateful — учитывай.
В Ansible-проекте: всё это уже есть в роли roles/hcloud_csi/, подключённой первой в install.yml. Токен берётся из vault_hcloud_token.
⚠️ Сначала: DNS A-запись rancher.example.com → публичный IP мастера / LB. Без этого Let's Encrypt не выдаст серт (HTTP-01 challenge).
⚠️ В RKE2 ingress-nginx по умолчанию через Service: ClusterIP — порты 80/443 не слушаются на хосте, LB будет показывать unhealthy. Сначала переключить ingress на DaemonSet + hostPort:
# на мастере
sudo tee /var/lib/rancher/rke2/server/manifests/rke2-ingress-nginx-config.yaml <<'EOF'
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: rke2-ingress-nginx
namespace: kube-system
spec:
valuesContent: |-
controller:
kind: DaemonSet
hostPort:
enabled: true
ports:
http: 80
https: 443
EOF
# RKE2 сам подхватит за ~30 сек
cert-manager
helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager --create-namespace \
--version v1.16.1 \
--set installCRDs=true
# подождать готовности webhook
kubectl -n cert-manager rollout status deploy/cert-manager-webhook
ClusterIssuer Let's Encrypt
kubectl apply -f - <<EOF
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-production
spec:
acme:
email: admin@example.com
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-production-account-key
solvers:
- http01:
ingress:
class: nginx
EOF
⚠️ На первый прогон лучше acme-staging-v02.api.letsencrypt.org/directory — у production LE лимит 5 серт/неделю/домен.
Rancher
helm repo add rancher-stable https://releases.rancher.com/server-charts/stable
helm repo update
helm install rancher rancher-stable/rancher \
--namespace cattle-system --create-namespace \
--set hostname=rancher.example.com \
--set bootstrapPassword=ChangeMeOnFirstLogin \
--set replicas=1 \
--set ingress.tls.source=secret \
--set ingress.extraAnnotations."cert-manager\.io/cluster-issuer"=letsencrypt-production
# дождаться pods
kubectl -n cattle-system rollout status deploy/rancher
✅ Открыть https://rancher.example.com, логин admin + bootstrapPassword, далее UI попросит сменить.
Конфиги: /mnt/c/ai/claude/infra/terraform/hetzner/k8s/ (Terraform) и /mnt/c/ai/claude/infra/ansible/hetzner/k8s/ (Ansible). Terraform создаёт инфру (сеть, firewall, ingress-LB, ноды с cloud-init установкой RKE2), Ansible ставит cert-manager + Rancher.
Terraform — поднять инфру
cd /mnt/c/ai/claude/infra/terraform/hetzner/k8s
cp terraform.tfvars.example terraform.tfvars
# отредактировать: hcloud_token, ssh_key_ids, provisioning_ssh_public_key
terraform init
terraform plan
terraform apply
# вывести важные адреса
terraform output -raw ingress_lb_ipv4 # для DNS A-записи rancher.example.com
terraform output -json master_ipv4 | jq -r '.[0]'
DNS — у регистратора rancher.example.com A <ingress_lb_ipv4>, подождать минуту, проверить dig +short rancher.example.com.
Ansible — поставить Rancher
cd /mnt/c/ai/claude/infra/ansible/hetzner/k8s
apt-get install -y python3-kubernetes python3-yaml
python3 -c "import kubernetes; print(kubernetes.__version__)"
pip3 install --break-system-packages --upgrade 'kubernetes>=29'
python3 -c "import kubernetes; print(kubernetes.__version__)"
ansible-galaxy collection install -r requirements.yml
# отредактировать group_vars/all.yml: rancher_hostname, acme_email, rancher_bootstrap_password
ansible-playbook install.yml # подхватит ~/.kube/config по умолчанию
Удаление кластера
cd /mnt/c/ai/claude/infra/terraform/hetzner/k8s
terraform destroy
❌ Error: resource not found (firewall_resource_not_found, ...) при destroy Решение Гонка hcloud_firewall_attachment при удалении сервера. Повторить terraform destroy — второй раз пройдёт. Если нет — terraform state rm hcloud_firewall_attachment.workers + ещё раз destroy. В шаблоне исправлено — firewall привязан inline через firewall_ids на серверах.
Получение kubeconfig с мастера
MASTER_IP=$(terraform output -json master_ipv4 | jq -r '.[0]')
mkdir -p ~/.kube # kubectl сам папку не создаёт
ssh -p 10022 -i /mnt/c/Users/sivsoft/Documents/credentials/symfio_SSH_wop_OpenSSH.ppk ansible@$MASTER_IP \
sudo cat /etc/rancher/rke2/rke2.yaml \
| sed "s|127.0.0.1|$MASTER_IP|" > ~/.kube/config
chmod 600 ~/.kube/config
или
LB_IP=$(terraform output -raw lb_ipv4)
mkdir -p ~/.kube
ssh -p 10022 -i /mnt/c/Users/sivsoft/Documents/credentials/symfio_SSH_wop_OpenSSH.ppk ansible@$(terraform output -json master_ipv4 | jq -r '.[0]') sudo cat /etc/rancher/rke2/rke2.yaml | sed "s|127.0.0.1|$LB_IP|" > ~/.kube/config
chmod 600 ~/.kube/config
kubectl get nodes # ~/.kube/config подхватывается kubectl по умолчанию
❌ bash: /root/.kube/config: No such file or directory Решение Это bash, не kubectl — нет родительской папки. mkdir -p ~/.kube перед редиректом.
⚠️ Если в tls-san нет public IP мастера — будет x509: certificate is valid for ..., not <PUBLIC_IP>. Решение На мастере добавить public IP в /etc/rancher/rke2/config.yaml под tls-san:, удалить старые сертификаты и рестарт:
sudo rm -f /var/lib/rancher/rke2/server/tls/serving-kube-apiserver.*
sudo systemctl restart rke2-server
Или временно kubectl с --insecure-skip-tls-verify=true.
Несколько кластеров
Способ 1 — мерж через KUBECONFIG (без правки ~/.kube/config):
# каждый kubeconfig отдельным файлом
~/.kube/config # текущий (hetzner)
~/.kube/config-aws # новый
# виртуальный мерж: kubectl видит контексты из обоих файлов
export KUBECONFIG=~/.kube/config:~/.kube/config-aws
kubectl config get-contexts # звёздочка = активный
kubectl config use-context aws-dev # переключиться
Если хочешь склеить физически в один файл:
KUBECONFIG=~/.kube/config:~/.kube/config-aws \
kubectl config view --flatten > ~/.kube/config.merged
mv ~/.kube/config.merged ~/.kube/config
chmod 600 ~/.kube/config
Способ 2 — kubectx (переключение в одну команду):
sudo apt-get install -y kubectx # Ubuntu / Debian
sudo dnf install -y kubectx # AlmaLinux / Rocky / CentOS
kubectx # список контекстов из ~/.kube/config
kubectx hetzner-prod # переключить активный кластер
kubens cattle-system # сменить дефолтный namespace
Проверка кластера
kubectl get nodes -o wide # все ноды Ready
kubectl get pods -A # системные pod'ы Running
kubectl cluster-info
kubectl top nodes # требует metrics-server, RKE2 ставит из коробки
Проверка Rancher
# pods
kubectl -n cattle-system get pods
# логи
kubectl -n cattle-system logs deploy/rancher --tail=100
# статус TLS-сертификата (должно быть Ready: True)
kubectl -n cattle-system get certificate tls-rancher-ingress
kubectl -n cattle-system describe certificate tls-rancher-ingress
# дёрнуть с проверкой серта
curl -v https://rancher.example.com 2>&1 | grep -E 'subject|issuer|verify'
Bootstrap-пароль и первый вход
Первый вход на https://rancher.example.com — логин admin + bootstrap-пароль, UI сразу попросит его сменить и предложит сгенерировать новый (можно принять, можно ввести свой). Если в Helm bootstrapPassword не передавали, Rancher сгенерил пароль сам — достать его можно из секрета или из логов pod'а:
# Способ 1: из Helm-параметров — если ставили с --set bootstrapPassword=...
# Просто берём то значение, что задавали (для нашей инсталляции — group_vars/all.yml -> rancher_bootstrap_password).
# Способ 2: из Secret (рекомендуется — секрет с реальным паролем после установки)
kubectl get secret --namespace cattle-system bootstrap-secret \
-o go-template='{{ .data.bootstrapPassword | base64decode }}{{ "\n" }}'
# Способ 3: из логов rancher-pod'а (там при первом старте печатается "Bootstrap Password: ...")
kubectl -n cattle-system logs deploy/rancher 2>&1 | grep -i 'Bootstrap Password'
# или, если ходим прямо на ноду docker'ом:
# docker logs <rancher-container-id> 2>&1 | grep "Bootstrap Password:"
⚠️ bootstrap-secret — это seed, Rancher кладёт его при первом старте и никогда не ротирует. После того как admin сменил пароль в UI, значение в секрете остаётся прежним, но как пароль уже не принимается. Реальный пароль admin'а хранится (хешем) в CRD users.management.cattle.io:
# admin-пользователь и его поля
kubectl get users.management.cattle.io -o custom-columns=NAME:.metadata.name,USER:.username
kubectl get user.management.cattle.io <admin-id> -o yaml | grep -E 'password|mustChangePassword'
Забыли пароль admin'а — сбрасывать через под Rancher'a:
kubectl -n cattle-system exec deploy/rancher -- reset-password
# выведет новый одноразовый, дальше UI попросит сменить
❌ Certificate висит в Issuing / False Решение Смотри kubectl -n cattle-system describe challenge — там написано, почему HTTP-01 fail. Чаще всего DNS не указывает на ingress-LB или 80/443 не открыты.
Проверка Load Balancer и ingress
# targets в LB
hcloud load-balancer describe <NAME>
# ingress-nginx как DaemonSet с hostPort
kubectl -n kube-system get daemonset rke2-ingress-nginx-controller
kubectl -n kube-system get pods -l app.kubernetes.io/name=rke2-ingress-nginx -o wide
# на любой ноде: реально ли 80/443 слушают на хосте
ssh -p 10022 ansible@<NODE_IP> sudo ss -tlnp | grep -E ':(80|443) '
# с другой ноды по приватке — тестируем connectivity
nc -zv 10.0.1.10 80
nc -zv 10.0.1.20 443
❌ В Hetzner Console targets LB красные (unhealthy) Решение ingress-nginx в RKE2 по умолчанию Service: ClusterIP, на хосте 80/443 не слушает. Применить HelmChartConfig с kind: DaemonSet + hostPort: enabled: true (см. раздел "Установка Rancher вручную"). В шаблоне cloud-init этот фикс уже есть, для существующего кластера применить руками на мастере.
❌ terraform plan не показывает изменений, но новый apply не подцепляет cloud-init Решение cloud-init выполняется один раз при первом старте VM. Чтобы перезалить bootstrap, нужно пересоздать сервер: terraform taint hcloud_server.master[0] + terraform apply.
❌ PVC висит в Pending бесконечно, в events — no persistent volumes available for this claim and no storage class is set Решение В кластере нет default StorageClass — не установлен Hetzner CSI driver. Поставить разовой командой (см. раздел «Установка Hetzner Cloud CSI» выше). Любой helm-инсталл со stateful (PG/Redis/Minio) до этого момента так и будет висеть в wait, потому что под Pending → deployment не Ready.
❌ SMTP timeout error: Connection timed out при отправке писем из pod'а (vaultwarden / любое приложение с SMTP submit), причём ровно на порт 465 Решение Hetzner Cloud режет outbound TCP на портах 25 и 465 для всех своих cloud-проектов (anti-abuse). Порт 587 (submission, STARTTLS) — открыт.
Симптомы
Connection timed out ровно на 465-T --port 465: трасса дохнет на 2-м хопе, на gateway Hetzner (172.31.1.1 или его аналог в твоей подсети). На 587 та же трасса проходит чисто.Диагностика
# контрольный — 587 на тот же хост проходит, 465 нет → дело в порте
kubectl run nc1 --rm -it --restart=Never --image=busybox -- nc -zv -w 5 <mail-host> 587 # open
kubectl run nc2 --rm -it --restart=Never --image=busybox -- nc -zv -w 5 <mail-host> 465 # timeout
# подтверждение — те же 465 к 2-3 разным SMTP (Gmail/Brevo/Yahoo):
# если timeout везде — это egress-блок, а не sy-mail/удалённой стороны
# трасса покажет хоп, где пакеты дохнут
kubectl run mtr --rm -it --restart=Never --image=nicolaka/netshoot -- \
mtr -T --port 465 -c 5 -r <mail-host>
Лечится
⚠️ Эту грабельку легко спутать с проблемой на стороне mail-сервера. Отличительный признак — tcpdump на удалённом mail-сервере: SYN-пакеты на 587 приходят, на 465 — нет (= блок не на нём, а на твоём egress).
❌ ImagePullBackOff на Bitnami-образах: failed to resolve reference "docker.io/bitnami/postgresql:17.6.0-debian-12-r4": not found (или то же на redis/minio/etc.) Решение С лета 2025 Bitnami вычистил публичные образы из docker.io/bitnami/* и переселил всё в docker.io/bitnamilegacy/*. Helm-чарт продолжает указывать на bitnami/, образ по тегу не существует. В values установить флаг — subchart переключится на legacy-репу:
global:
security:
allowInsecureImages: true
Касается всех Bitnami subchart'ов (PG, Redis, Minio, MariaDB и т.д.). Authentik в своём чарте уже включает этот флаг, поэтому его внутренний PG поднимается без проблем — а отдельно ставимые Bitnami-чарты надо поправлять руками.
❌ cert-manager webhook context deadline exceeded, DNS внутри pod'ов отваливается, cross-node pod-to-pod не ходит (Hetzner Cloud)
Симптом — cert-manager-startupapicheck падает с failed calling webhook ... context deadline exceeded, при этом сам webhook-pod Running. Pod'ы на одной ноде друг друга пингуют, а на разных — нет.
Корень: Flannel по умолчанию выбирает iface по default-route, на Hetzner это публичный eth0. VXLAN-трафик (UDP 8472) идёт через публичную сеть и режется Hetzner Cloud Firewall.
Диагностика
# 1. какой iface выбрал Flannel
kubectl -n kube-system logs ds/rke2-canal -c kube-flannel | grep 'Using interface'
# плохо: "Using interface with name eth0 and address 49.12.X.Y"
# хорошо: "Using interface with name eth1 and address 10.0.1.X"
# 2. cross-node ping между pod'ами (worker-1 → pod на worker-2)
WEBHOOK_IP=$(kubectl -n cert-manager get pod -l app=webhook -o jsonpath='{.items[0].status.podIP}')
kubectl run nettest --rm -it --image=nicolaka/netshoot --restart=Never \
--overrides='{"spec":{"nodeName":"k8s-worker-1"}}' -- ping -c 3 $WEBHOOK_IP
# 3. что приехало в ConfigMap (rke2-canal не имеет CLI args, конфиг — через ConfigMap)
kubectl -n kube-system get cm rke2-canal-config \
-o jsonpath='{.data.canal_iface}{"\n"}{.data.canal_iface_regex}{"\n"}'
Решение
⚠️ rke2-canal chart не понимает flannel-овский iface-can-reach. Из values маппятся только flannel.iface (имя iface) и flannel.regexIface (regex). Имя iface в Hetzner-образе плавает (eth1/enp7s0/ens10), поэтому надёжнее задать regex по IP приватной подсети — flannel матчит regex и по имени, и по адресу:
cat > /var/lib/rancher/rke2/server/manifests/rke2-canal-config.yaml <<'EOF'
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: rke2-canal
namespace: kube-system
spec:
valuesContent: |-
flannel:
regexIface: '^10\.0\.1\.'
EOF
# проверить, что ConfigMap обновился
kubectl -n kube-system get cm rke2-canal-config -o jsonpath='{.data.canal_iface_regex}'; echo
# ожидаем: ^10\.0\.1\.
# рестартнуть DaemonSet, чтобы pod'ы canal перечитали конфиг
kubectl -n kube-system rollout restart daemonset rke2-canal
kubectl -n kube-system rollout status daemonset rke2-canal --timeout=120s
# убедиться, что Flannel выбрал приватный iface
kubectl -n kube-system logs ds/rke2-canal -c kube-flannel | grep 'Using interface'
После рестарта cross-node ping начинает ходить, webhook отвечает, ClusterIssuer применяется. Если зависшие Job'ы уже выбрали backoffLimit — удалить вручную, helm/оператор пересоздаст при следующем sync:
kubectl -n cert-manager delete job cert-manager-startupapicheck
✅ В Terraform-шаблоне infra/terraform/hetzner/k8s/cloud-init-master.yaml этот HelmChartConfig уже раскладывается на первом мастере при инициализации кластера — для свежих кластеров чинить руками не надо. Regex производный от subnet_cidr через local.private_iface_regex в main.tf.
| Объект | Что делает | Пример |
|---|---|---|
Role |
Определяет что можно делать (доступ к API-ресурсам) | "Может читать и создавать Pod'ы" |
RoleBinding |
Определяет кому разрешено использовать эту роль | "Дать эту роль пользователю bob@example.com" |
kind: Role
metadata:
name: pod-reader
namespace: dev
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
kind: RoleBinding
metadata:
name: read-pods-binding
namespace: dev
subjects:
- kind: User
name: bob@example.com
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: pod-reader
apiGroup: rbac.authorization.k8s.io
kubectl get clusterrolebindings -o jsonpath='{.items[*].subjects[*].name}' | tr ' ' '\n' | sort -u
kubectl get rolebindings --all-namespaces -o jsonpath='{.items[*].subjects[*].name}' | tr ' ' '\n' | sort -u
bootstrap-signer
В Hetzner Cloud LB — отдельный ресурс с постоянным публичным IP, в отличие от public IP сервера (тот может меняться при пересоздании). Поэтому весь внешний доступ к кластеру (kube-apiserver, ingress) удобнее заворачивать через LB и держать стабильные A-записи DNS.
⚠️ Архитектурно «правильно» делать два разных LB:
6443 (kube-apiserver) + 9345 (RKE2 supervisor) → только masters80 + 443 → все ноды (через nginx-ingress DaemonSet с hostPort)Зачем два:
Для маленького кластера это избыточно. Hetzner LB поддерживает несколько service с разными targets через label_selector — то есть один LB и €5/мес вместо €10.
DNS:
k8s-api.ivstech.org A → LB IPrancher.ivstech.org A → тот же LB IPМаршрут: DNS → hostname → один и тот же LB IP → LB по listen_port → нода → nginx-ingress (для 80/443) → pod по hostname.
| listen_port | destination | label_selector таргетов |
|---|---|---|
| 6443 | 6443 (kube-apiserver) | cluster=k8s,role=master |
| 9345 | 9345 (RKE2 supervisor) | cluster=k8s,role=master |
| 80 | 80 (nginx-ingress hostPort) | cluster=k8s (все ноды) |
| 443 | 443 (nginx-ingress hostPort) | cluster=k8s (все ноды) |
⚠️ В RKE2 ingress-nginx по умолчанию Service: ClusterIP — порты 80/443 на хосте не слушаются, target LB будет unhealthy. Применить HelmChartConfig с kind: DaemonSet + hostPort (см. раздел «Установка Rancher вручную»).
resource "hcloud_load_balancer_target" "api" {
type = "label_selector"
load_balancer_id = hcloud_load_balancer.k8s.id
label_selector = "cluster=${var.cluster_name},role=master"
use_private_ip = true
}
resource "hcloud_load_balancer_target" "ingress" {
type = "label_selector"
load_balancer_id = hcloud_load_balancer.k8s.id
label_selector = "cluster=${var.cluster_name}"
use_private_ip = true
}
Labels на ноды навешиваются через labels = {...} в hcloud_server (role=master|worker, cluster=k8s).
Подход в Symfio: централизованный Loki на отдельном VM (sy-logs, см. соседнюю заметку «Система мониторинга Grafana Loki»), агенты на k8s-нодах пушат напрямую через https://logs.symfio.net/loki/api/v1/push с basic_auth.
Promtail DaemonSet grafana-promtail/promtail (chart configmap-based, старого образца). Узкий scope — только pod'ы ingress-nginx, остальной k8s этим агентом не собирается. Файловый источник: tail /var/log/pods/<pod_uid>/<container>/*.log через hostPath (/var/log, /var/lib/docker/containers, /var/log/pods).
Метки (relabel_configs)
| Лейбл | Источник | Кардинальность |
|---|---|---|
cluster |
static sy-cloud |
1 |
source |
static kubernetes-pod |
1 |
job |
static nginx-ingress |
1 |
service |
static nginx |
1 |
namespace |
__meta_kubernetes_namespace |
низкая |
hostname |
k8s-<__meta_kubernetes_pod_node_name> |
по числу нод |
ds |
__meta_kubernetes_pod_controller_name |
по числу rollout (ReplicaSet) |
log_type |
pipeline_stages: default/access/error по regex |
3 |
ds для Deployment-овых подов = <deploy>-<replica-hash> — даёт фильтрацию до уровня rollout («логи после деплоя в 14:30»). Pod в индексе не лежит вообще, конкретный pod ищется grep'ом по строке лога.
Полный promtail.yaml (ConfigMap grafana-promtail/promtail-config)
server:
http_listen_port: 3101
log_level: info
positions:
filename: /tmp/positions.yaml
clients:
- url: https://logs.symfio.net/loki/api/v1/push
basic_auth:
username: ingest
password_file: /etc/promtail/ingest.pass
batchwait: 1s
batchsize: 102400
timeout: 10s
scrape_configs:
- job_name: nginx-ingress
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
action: keep
regex: ingress-nginx
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_component]
action: keep
regex: controller
- source_labels: [__meta_kubernetes_pod_uid, __meta_kubernetes_pod_container_name]
separator: /
target_label: __path__
replacement: /var/log/pods/*$1/*.log
- target_label: cluster
replacement: sy-cloud
- target_label: source
replacement: kubernetes-pod
- target_label: job
replacement: nginx-ingress
- source_labels: [__meta_kubernetes_namespace]
target_label: namespace
- source_labels: [__meta_kubernetes_pod_node_name]
target_label: hostname
regex: (.+)
replacement: k8s-$1
- source_labels: [__meta_kubernetes_pod_controller_name]
target_label: ds
- target_label: service
replacement: nginx
pipeline_stages:
- cri: {}
- drop: { expression: '.*kube-probe.*' }
- drop: { expression: '(?i)^(\[[^]]+\]\s*)?health check\s*$' }
- template: { source: log_type, template: 'default' }
- match:
selector: '{job="nginx-ingress"} |~ "GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS"'
stages:
- template: { source: log_type, template: 'access' }
- match:
selector: '{job="nginx-ingress"} |~ "\\[error\\]|\\[warn\\]|\\[emerg\\]|\\[crit\\]"'
stages:
- template: { source: log_type, template: 'error' }
- labels: { log_type: }
Пароль для basic_auth монтируется в /etc/promtail/ingest.pass через k8s Secret + volumeMounts на pod'е. Создание секрета:
kubectl -n grafana-promtail create secret generic loki-ingest \
--from-literal=password='<пароль ingest из Vaultwarden>'
⚠️ Текущий sy-prod (RKE2) поднят на стандартном чарте grafana/alloy: loki.source.kubernetes (через kube-apiserver) + широкий набор меток. Это работает на малом кластере, но при росте — длинный long-poll к apiserver на каждый pod нагружает control-plane, и при перегрузе apiserver первым отваливается логирование. Плюс в наборе меток есть мусор: app и service_name берутся из одного и того же k8s-label, container_runtime всегда containerd.
Ниже — конфиг, скомбинированный из подхода sy-cloud (метки, гранулярность ds, файловый источник) и плюсов alloy (structured_metadata для pod, scope = все pod'ы).
Helm values: alloy.configMap.content
logging {
level = "info"
format = "logfmt"
}
loki.write "default" {
endpoint {
url = "https://logs.symfio.net/loki/api/v1/push"
basic_auth {
username = "ingest"
password = sys.env("LOKI_INGEST_PASSWORD")
}
}
}
discovery.kubernetes "pod" {
role = "pod"
selectors {
role = "pod"
field = "spec.nodeName=" + coalesce(sys.env("HOSTNAME"), constants.hostname)
}
}
discovery.relabel "pod_logs" {
targets = discovery.kubernetes.pod.targets
// Путь к лог-файлу на ноде (файловый источник)
rule {
source_labels = ["__meta_kubernetes_pod_uid", "__meta_kubernetes_pod_container_name"]
separator = "/"
target_label = "__path__"
replacement = "/var/log/pods/*$1/*.log"
}
// Индексируемые метки
rule {
source_labels = ["__meta_kubernetes_namespace"]
target_label = "namespace"
}
rule {
source_labels = ["__meta_kubernetes_pod_container_name"]
target_label = "container"
}
rule {
source_labels = ["__meta_kubernetes_pod_node_name"]
target_label = "hostname"
regex = "(.+)"
replacement = "k8s-$1"
}
rule {
source_labels = ["__meta_kubernetes_pod_controller_name"]
target_label = "ds"
}
rule {
source_labels = ["__meta_kubernetes_pod_label_app_kubernetes_io_name"]
target_label = "service_name"
regex = "(.+)"
replacement = "$1"
}
rule {
source_labels = ["__meta_kubernetes_namespace", "__meta_kubernetes_pod_container_name"]
target_label = "job"
separator = "/"
}
}
local.file_match "pod_logs" {
path_targets = discovery.relabel.pod_logs.output
}
loki.source.file "pod_logs" {
targets = local.file_match.pod_logs.targets
forward_to = [loki.process.pod_logs.receiver]
}
loki.process "pod_logs" {
stage.cri { }
stage.drop {
expression = ".*kube-probe.*"
drop_counter_reason = "kube_probe"
}
stage.drop {
expression = `(?i)^(\[[^]]+\]\s*)?health check\s*$`
drop_counter_reason = "health_check"
}
stage.static_labels {
values = {
cluster = "<имя_кластера>",
source = "kubernetes-pod",
}
}
// log_type: default → access (HTTP методы) / error ([error] и пр.)
stage.template {
source = "log_type"
template = "default"
}
stage.match {
selector = `{source="kubernetes-pod"} |~ "GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS"`
stage.template { source = "log_type"; template = "access" }
}
stage.match {
selector = `{source="kubernetes-pod"} |~ "\\[error\\]|\\[warn\\]|\\[emerg\\]|\\[crit\\]"`
stage.template { source = "log_type"; template = "error" }
}
stage.labels {
values = { log_type = "" }
}
// pod/node — structured_metadata (не индексируется, но фильтруется через `| pod="..."`)
stage.structured_metadata {
values = { pod = "", node = "" }
}
forward_to = [loki.relabel.drop_high_cardinality.receiver]
}
loki.relabel "drop_high_cardinality" {
forward_to = [loki.write.default.receiver]
rule {
action = "labelkeep"
regex = "^(cluster|source|namespace|container|hostname|ds|service_name|job|log_type)$"
}
}
Helm values: остальное
alloy:
enableReporting: false
mounts:
varlog: true # для loki.source.file — монтируем /var/log с хоста
dockercontainers: false # containerd — достаточно /var/log/pods (symlinks → реальные файлы)
extraEnv:
- name: LOKI_INGEST_PASSWORD
valueFrom:
secretKeyRef:
name: loki-ingest
key: password
resources:
limits: { cpu: 200m, memory: 256Mi }
requests: { cpu: 100m, memory: 128Mi }
controller:
type: daemonset
tolerations:
- effect: NoSchedule
operator: Exists
- effect: NoExecute
operator: Exists
crds: { create: false }
networkPolicy: { enabled: false }
service: { enabled: true, type: ClusterIP }
serviceMonitor: { enabled: false }
Secret c паролем basic_auth
kubectl -n observability create secret generic loki-ingest \
--from-literal=password='<пароль ingest из Vaultwarden>'
Почему так
loki.source.file вместо loki.source.kubernetes — читает файлы /var/log/pods/... через hostPath, не нагружает kube-apiserver. Логирование продолжает работать даже при шторме на apiserver. Совместимо с containerd и Docker runtime.app/service_name, без шумного container_runtime, без project/environment/component/team (нашими подами равномерно не выставляются).ds = controller_name — фильтрация по rollout/ReplicaSet, как в sy-cloud. Кардинальность приемлемая: метка на rollout, не на pod.pod и node в structured_metadata — не индексируются (индекс остаётся компактным), но доступны через pipe-filter Loki: {service_name="spnc-website"} | pod="spnc-website-abc12-xyz". Новый механизм Loki 3.0+.log_type=access/error/default — то же разделение, что в sy-cloud, удобно для дашбордов «top errors» отдельно от потока access.❌ Pod'ы не запускаются после последних изменений Решение Смотрим последний рабочий билд в K8S в Deployment, также в Jenkins смотрим Заходим в Deploy проекта и меняем Image билд на последний рабочий. Запуск pod'ов под 