Инструкции по развёртыванию Kubernetes обычно описывают удачный проход. Автор поднял кластер, записал команды, опубликовал. Читатель повторяет — и упирается в места, которых в инструкции нет, потому что у автора они не сломались или сломались до того, как он начал писать.
Эта статья устроена иначе. Я поднимал тестовый кластер на Talos рядом с работающим боевым — по тем же плейбукам, которыми обслуживается прод. Кластер поднялся, но по дороге сломался четыре раза. И вот что оказалось важным: ни один из четырёх отказов не был виден на боевом кластере, а три из четырёх сломали бы его восстановление, если бы прод понадобилось поднять с нуля.
Это разбор четырёх отказов, а не пошаговая инструкция. Ключевые куски конфигурации и Terraform-ресурсы приведены — без них инциденты не понять, — но они иллюстрируют устройство, а не образуют запускаемый стенд: инфраструктурная часть у каждого своя, начиная с провайдера гипервизора. Если вам нужна воспроизводимая инструкция по Talos, начинать стоит с официальной документации; здесь ценность в другом — в местах, где инструкции заканчиваются.
В статье
- Что строим и почему именно так
- Контекст: как устроено развёртывание
- Инцидент 1: helm ждёт адрес, который никто не выдаст
- Инцидент 2: образ, который не тянется
- Инцидент 3: репозиторий чартов переехал
- Предотвращённый риск: секреты и изоляция среды
- Сертификаты, вход и инцидент 4
- Что общего у четырёх отказов
- Чек-лист перед развёртыванием
- Приложение: Terraform-ресурсы bootstrap
Что строим и почему именно так
Кластер из шести узлов: три control plane и три worker. Виртуалки на гипервизоре, сеть 10.10.0.0/24, доступ только изнутри периметра.
Всё описанное ниже проверено на этих версиях — не «примерно на таких», а именно на них:
| Компонент | Версия |
|---|---|
| Talos Linux | v1.13.6 |
| Kubernetes | v1.36.2 |
| Cilium (чарт) | 1.19.4 |
| cert-manager | v1.17.2 |
| External Secrets | 0.14.3 |
| Sealed Secrets (чарт) | 2.17.1 |
| ArgoCD (чарт) | 9.5.13 |
| Helm | 3.18.4 |
Проверено 19 июля 2026 года. Ветка Talos 1.13 поддерживает Kubernetes 1.36; связку стоит сверять по матрице поддержки Talos, прежде чем брать другие номера. Начиная с Talos 1.11 несовместимую пару вы, скорее всего, увидите сразу: версия Kubernetes из машинной конфигурации проверяется не только при обновлении, но и при первичной установке и при каждом применении конфигурации.
Talos — дистрибутив, у которого нет ни SSH, ни пакетного менеджера, ни shell. Управление идёт по gRPC-API через talosctl, конфигурация узла — один декларативный документ. Это неудобно ровно один раз: когда хочется «зайти и посмотреть». Взамен резко сокращается ручной дрейф конфигурации — вносить изменения в обход декларации попросту нечем. Гарантии совпадения желаемого и фактического это не даёт: неудачно применённая конфигурация или отказ в рантайме разведут их и здесь, просто не руками оператора.
Ответственность разделена так:
- Terraform создаёт виртуальные машины, применяет машинную конфигурацию Talos и делает bootstrap etcd. На выходе — работающий control plane и
kubeconfig. - Ansible ставит всё, что живёт внутри кластера: CNI, cert-manager, external-secrets, метрики, логи, ArgoCD.
Разделение не косметическое. Terraform хорош там, где есть состояние и его надо сверять с реальностью: виртуалки, диски, секреты кластера. Внутри кластера состояние уже держит сам Kubernetes, и в нашей модели второй учёт того же состояния в терраформ-стейте усложняет управление: изменения, сделанные оператором или контроллером, выглядят как дрейф. Поэтому helm- и kubernetes-провайдеры в терраформ-руте отсутствуют намеренно. Цена решения реальная — компоненты кластера не участвуют в terraform plan, их согласованность приходится проверять отдельно, а порядок «сначала Terraform, потом Ansible» держать в голове или в runbook’е. Командам, которые готовы платить дрейфом за единый план, обратный выбор тоже защитим.
Контекст: как устроено развёртывание
Talos грузится с ISO в maintenance-режиме, получает адрес по DHCP и ждёт конфигурацию. Terraform поднимает виртуалки, узнаёт временный адрес через гостевой агент и применяет к каждой машине патч — в нём и статический адрес, и всё остальное.
Что нужно до старта
- Гипервизор с API и учётные данные к нему, плюс Terraform-провайдер под него.
- Schematic ID из Talos Image Factory. Гостевой агент нужен обязательно — без него Terraform не узнает DHCP-адрес узла, — поэтому собирайте schematic с соответствующим расширением.
- ISO этого schematic, загруженный в хранилище гипервизора.
- Свободные адреса: по одному на узел плюс один под VIP. Отсутствие ответа на ping адрес свободным не делает — сверяйтесь с гипервизором.
- DHCP в сети узлов: нужен только на время первой загрузки, дальше адреса статические.
Ключевые куски патча (controlplane.patch.yaml):
cluster:
network:
cni:
name: none # CNI поставим отдельно, Talos его не трогает
proxy:
disabled: true # kube-proxy заменит Cilium
machine:
install:
disk: /dev/sda
image: factory.talos.dev/installer/<schematic-id>:v1.13.6
registries:
mirrors: # зеркала реестров, если контур закрытый
docker.io:
endpoints: ["https://registry.example.internal"]
network:
interfaces:
- interface: ens18
addresses: ["10.10.0.11/24"]
vip:
ip: 10.10.0.10 # только на control plane
routes:
- network: 0.0.0.0/0
gateway: 10.10.0.1
nameservers: ["10.10.0.2"]
Синтаксис здесь — тот, что реально применялся и работает на Talos 1.13.6, но устаревающий. Поля machine.network.interfaces, machine.network.nameservers и machine.registries в 1.13 ещё поддерживаются, а актуальная документация показывает отдельные документы конфигурации: LinkConfig, Layer2VIPConfig, ResolverConfig, RegistryMirrorConfig. Заметнее всего расхождение как раз в VIP — официальный пример для 1.13 построен на Layer2VIPConfig. Новые конфигурации лучше строить на отдельных документах; фрагмент выше показан как есть, потому что именно он породил описанные дальше события.
Три момента, которые стоит отметить.
VIP через выборы лидера в etcd. Адрес 10.10.0.10 — виртуальный адрес control plane. Talos умеет держать его сам: узлы договариваются через etcd, и адрес живёт на текущем лидере. Отдельный keepalived или внешний балансировщик для API-сервера не нужен. Блок vip ставится только на control plane — на worker его быть не должно.
Bootstrap идёт на конкретный узел, а не на VIP. Команда инициализации etcd выполняется один раз и адресуется первому control plane по его собственному адресу. Обращаться к VIP на этом этапе нельзя: выборы лидера ещё не начались, потому что etcd не поднят.
cni: none и отключённый kube-proxy — обязательное условие, если ставите Cilium с kubeProxyReplacement. Иначе получите два датаплейна, спорящих за одни и те же правила.
Файлы лежат в каталоге модуля — сами по себе инструменты их не увидят. talosctl по умолчанию читает ~/.talos/config, kubectl — ~/.kube/config. Без явного подключения обе команды пойдут не в тот кластер или не найдут ничего:
export TALOSCONFIG="$PWD/talosconfig"
export KUBECONFIG="$PWD/kubeconfig"
Экспорт живёт только в текущем терминале — в новом окне всё вернётся к домашним конфигам. Альтернатива для разовых команд — флаги --talosconfig и --kubeconfig в каждом вызове; в нескольких кластерах это надёжнее, потому что не оставляет «залипшего» состояния. Ansible-плейбукам путь тоже надо передать явно: либо тем же окружением, либо переменной, из которой соберётся --kubeconfig.
После применения:
$ terraform apply
Apply complete! Resources: 17 added, 0 changed, 0 destroyed.
$ talosctl --nodes 10.10.0.11 health
waiting for all control plane static pods to be running: OK
waiting for all k8s nodes to report ready: SKIP
waiting for kube-proxy to report ready: SKIP
waiting for coredns to report ready: SKIP
Три SKIP пугают, но они здесь нормальны: узлы не готовы, потому что нет CNI; kube-proxy отключён нами; coredns ждёт сеть. kubectl get nodes покажет шесть узлов в NotReady — так и должно быть до установки CNI.
Мелкие грабли: версии кластера в непрослеживаемом файле
Первое, обо что я споткнулся, — не отказ, а предупреждение:
Warning: Value for undeclared variable
The root module does not declare a variable named "talos_version"
but a value was found in file "terraform.tfvars".
Файл переменных я скопировал из соседнего рута, где эти переменные объявлены. В новом руте версии и адресация зашиты в коде, а не приходят из tfvars, — четыре значения молча игнорировались.
Само по себе безобидно. Опасно другое: в скопированном файле лежали имя и VIP боевого кластера. Если бы рут принимал эти переменные, тестовый кластер поднялся бы с виртуальным адресом прода — с предсказуемыми последствиями для обоих.
Вывод, который я для себя закрепил: версия Talos и версия Kubernetes должны лежать в закоммиченном коде, а не в локальном файле, который не попадает в git. Кластер существует ради обкатки обновлений, и переход с версии на версию обязан быть коммитом с историей, а не правкой файла, о содержимом которого через месяц никто не вспомнит.
Инцидент 1: helm ждёт адрес, который никто не выдаст
Ставим CNI. Ansible запускает helm, и задача встаёт. Через двадцать одну минуту:
{
"delta": "0:21:10.581736",
"stderr": "Error: context deadline exceeded",
"stdout": "Release \"cilium\" does not exist. Installing it now."
}
Смотрим в кластер — и он выглядит совершенно здоровым. Шесть агентов Cilium Running, оператор 2/2, coredns получил адреса, все шесть узлов перешли в Ready:
$ kubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium-dbg status --brief
OK
$ kubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium-dbg status | grep -E 'KubeProxyReplacement|Cluster health'
KubeProxyReplacement: True
Cluster health: 6/6 reachable
cilium-dbg живёт внутри агента, а не на вашей машине. Если установлен Cilium CLI, то же самое доступно снаружи: cilium status --wait.
То есть Cilium установился и работает, а helm об этом не знает и падает по таймауту.
Ложный след
Первая гипотеза выглядела убедительно: helm держит долгоживущее watch-соединение к API-серверу; Cilium в процессе установки забирает датаплейн на себя, соединение рвётся, клиент об этом не узнаёт и ждёт до таймаута. Известный класс проблем при установке CNI, заменяющего kube-proxy.
Проверяется просто — повторить установку, когда Cilium уже стоит и датаплейн стабилен. Повторил. Зависло снова, ровно так же. Гипотеза опровергнута: если бы дело было в разовом обрыве при захвате датаплейна, вторая установка прошла бы.
Настоящая причина
Ответ нашёлся не в логах helm, а в состоянии ресурсов:
$ kubectl get svc -n kube-system
NAME TYPE EXTERNAL-IP PORT(S)
cilium-ingress LoadBalancer <pending> 80:30794/TCP,443:31124/TCP
$ kubectl get ciliumloadbalancerippools
No resources found
Чарт Cilium с включённым ingress-контроллером создаёт сервис типа LoadBalancer. Флаг --wait заставляет helm ждать, пока такой сервис не получит внешний адрес. Адрес выдаёт CiliumLoadBalancerIPPool — ресурс, который плейбук создаёт следующей задачей. А следующая задача не выполняется, потому что текущая заблокирована.
Замкнутый круг. Причём разорвать его перестановкой задач нельзя: сам тип CiliumLoadBalancerIPPool появляется в кластере только вместе с чартом.
Проверка диагноза заняла минуту — создал пул вручную:
apiVersion: cilium.io/v2
kind: CiliumLoadBalancerIPPool
metadata:
name: default-pool
spec:
blocks:
- cidr: "10.10.0.30/32"
Сервис немедленно получил адрес, и та же самая команда helm отработала за 2.8 секунды вместо двадцати одной минуты.
Почему боевой кластер этого не видит
На проде пул существует давно. Каждый запуск плейбука там — это upgrade, при котором у сервиса адрес уже есть, и --wait возвращается сразу. Отказ проявляется исключительно на кластере, разворачиваемом с нуля.
Исправление
Убрать --wait и заменить его явными ожиданиями:
- name: Установить Cilium
ansible.builtin.command:
cmd: helm upgrade --install cilium cilium/cilium ...
# без --wait: см. комментарий про дедлок с LB IP Pool
- name: Дождаться CRD чарта
ansible.builtin.command:
cmd: >
kubectl wait --for=condition=Established
crd/ciliumloadbalancerippools.cilium.io
crd/ciliuml2announcementpolicies.cilium.io --timeout=180s
- name: Создать пул адресов и политику L2
# ... kubectl apply
- name: Дождаться готовности ВСЕХ workload'ов чарта
ansible.builtin.command:
cmd: kubectl -n kube-system rollout status {{ item }} --timeout=300s
loop:
- daemonset/cilium
- daemonset/cilium-envoy
- deployment/cilium-operator
- deployment/hubble-relay
- deployment/hubble-ui
- name: Убедиться, что ingress получил адрес из пула
ansible.builtin.command:
cmd: >
kubectl -n kube-system get svc cilium-ingress
-o jsonpath={.status.loadBalancer.ingress[0].ip}
register: ingress_ip
until: ingress_ip.stdout | length > 0
retries: 30
delay: 5
Список из пяти workload’ов — не универсальный, а следствие моих values. hubble-relay и hubble-ui в чарте 1.19.4 по умолчанию выключены; у меня они включены явно:
helm upgrade --install cilium cilium/cilium --version 1.19.4 \
--namespace kube-system \
--set kubeProxyReplacement=true \
--set k8sServiceHost=10.10.0.10 --set k8sServicePort=6443 \
--set ingressController.enabled=true \
--set ingressController.loadbalancerMode=shared \
--set l2announcements.enabled=true \
--set hubble.enabled=true \
--set hubble.relay.enabled=true \
--set hubble.ui.enabled=true \
--set ipam.mode=kubernetes
Скопируете цикл ожидания (cilium-install.yml) без этих трёх флагов hubble.* — он повиснет на несуществующих деплойментах. Поэтому список надо выводить из своих values, а не переписывать из статьи:
helm template cilium cilium/cilium --version 1.19.4 -f my-values.yaml \
| grep -E '^kind: (Deployment|DaemonSet)' -A2 | grep -E '^kind:| name:'
С дефолтными values эта команда даёт ровно три: daemonset/cilium, daemonset/cilium-envoy, deployment/cilium-operator.
Здесь и кроется ловушка, в которую я сам попал: отказавшись от --wait, вы берёте на себя перечисление того, чего он ждал. В первой версии я оставил ожидание одного daemonset/cilium — неготовый оператор прошёл бы дальше и всплыл позже, в несвязанном месте. И пересверять список надо при каждой смене версии чарта: набор workload’ов — часть его контракта, а не константа.
Если такой ручной учёт кажется хрупким — он и есть хрупкий. Устойчивее не перечислять компоненты самому, а спросить у Cilium: cilium status --wait из CLI проверяет готовность по собственным правилам и не зависит от того, что вы вспомнили включить. Но заменяет он только перечисление workload’ов: проверку, что cilium-ingress получил адрес из пула, надо оставить отдельно — она про интеграцию с вашей сетью, а не про здоровье Cilium, и именно её отсутствие породило этот инцидент.
Взамен ручной список даёт диагностируемость: при отказе имя упавшей задачи прямо называет неготовый компонент. --wait этого не даёт принципиально — он ждёт всё сразу и сообщает только, что не дождался. Но эквивалентную гарантию явные ожидания дают лишь тогда, когда контракт готовности перечислен полностью; неполный список меняет непрозрачное ожидание на прозрачное, но дырявое.
Инцидент 2: образ, который не тянется
Следующий чарт — external-secrets. Падение через пять минут, сообщение то же самое: context deadline exceeded. Смотрим в namespace:
external-secrets-... 1/1 Running (узел 2)
external-secrets-cert-... 0/1 Running (узел 2)
external-secrets-webhook-... 0/1 ImagePullBackOff (узел 3)
Не готовых подов два, но причина одна: cert-controller держит healthz check failed, пока не поднимется вебхук. Как только вебхук заработал, он стал 1/1 сам. Полезная привычка — прежде чем чинить всё сразу, найти, что из симптомов следствие.
Ошибка скачивания:
Failed to pull image "oci.external-secrets.io/external-secrets/external-secrets:v0.14.3":
rpc error: code = Canceled desc = ... context canceled
И тут же наблюдение, которое меняет диагноз: тот же самый образ успешно скачался на другом узле за 16 секунд. Значит, реестр доступен.
Отделяем «недоступен» от «медленный»
Реестра oci.external-secrets.io нет в списке зеркал — образ идёт напрямую через прокси, а не из локального кэша. За восемь минут kubelet сделал всего две попытки, каждая оборвалась с Canceled. Гипотез сразу несколько: дедлайн скачивания, исчерпание пропускной способности прокси, сетевой обрыв. Прежде чем их перебирать, стоит выяснить более простое — доступен ли реестр вообще.
Talos позволяет скачать образ на узел напрямую, вне кубелета:
$ talosctl -n 10.10.0.23 image pull --namespace cri \
oci.external-secrets.io/external-secrets/external-secrets:v0.14.3
pulled image ...@sha256:91a039ca0db52ed1b85b24c3c79f6df19cbb749826f09d1811fad0fce8ed9fd2
real 1m23.721s
Скачалось. Медленно, но успешно. Мелочь на будущее: namespace в Talos называется cri, а не k8s.io — иначе получите unsupported namespace.
Здесь важно не сказать больше, чем показано. Ручное скачивание доказывает две вещи: реестр доступен, и образ выкачивается за полторы минуты по прямому пути. Оно не доказывает, из-за чего именно kubelet отменял свои попытки: я не снял ни значение дедлайна скачивания, ни таймлайн попыток, ни логи среды выполнения. Правдоподобных объяснений минимум два — упёрлись в дедлайн или в пропускную способность прокси при параллельных загрузках с нескольких узлов, — но выбрать между ними по имеющимся данным нельзя.
Честная формулировка результата: причина отмены не изолирована; смена реестра устранила симптом, убрав медленный прямой путь. Для инцидента этого достаточно, для утверждения о механизме — нет.
После предзагрузки под поднялся сам на следующей попытке.
Как чинить: не расширять инфраструктуру, пока не проверил
Очевидное решение — завести проект proxy-cache под этот реестр и добавить его в зеркала. Это правка машинной конфигурации всех узлов всех кластеров, плюс новый объект в реестре, который потом надо сопровождать.
Прежде чем это делать, стоит задать вопрос дешевле: а нет ли того же образа там, где зеркало уже настроено? У external-secrets образы публикуются и в ghcr.io:
oci.external-secrets.io → sha256:91a039ca0db52ed1b85b24c3c79f6df19cbb749826f09d1811fad0fce8ed9fd2
ghcr.io → sha256:91a039ca0db52ed1b85b24c3c79f6df19cbb749826f09d1811fad0fce8ed9fd2
Digest совпадает побайтово — это тот же образ, а не похожий. Значит, достаточно указать чарту другой репозиторий:
--set image.repository=ghcr.io/external-secrets/external-secrets
--set webhook.image.repository=ghcr.io/external-secrets/external-secrets
--set certController.image.repository=ghcr.io/external-secrets/external-secrets
Три ключа, а не один: чарт хранит репозиторий образа в трёх местах. Проверять такие вещи надо через helm show values, а не по памяти — поправить один из трёх и не заметить проще, чем кажется.
Установка после правки заняла 33 секунды вместо падения по таймауту.
Сравнение digest’ов здесь — не педантизм, а способ получить однозначный ответ за две минуты вместо обсуждения «наверное, там то же самое».
Инцидент 3: репозиторий чартов переехал
Третий отказ качественно отличается от первых двух — он не про наше окружение:
Error: looks like "https://bitnami-labs.github.io/sealed-secrets" is not a valid
chart repository or cannot be reached: failed to fetch .../index.yaml : 404 Not Found
Проект sealed-secrets переехал: репозиторий на GitHub отвечает редиректом на новую организацию, а GitHub Pages по старому адресу отдаёт 404. Исправление — одна строка:
# было — отдаёт 404 на index.yaml
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
# стало
helm repo add sealed-secrets https://bitnami.github.io/sealed-secrets
Версию чарта при этом менять не пришлось: в новом репозитории нашлась ровно та, что стояла на проде. Проверяется до правки, одной командой:
helm search repo sealed-secrets/sealed-secrets --version 2.17.1
Важно здесь другое. Боевой кластер работает и ничего не замечает: релиз давно развёрнут, компонент функционирует. Ломается не кластер, а возможность его переустановить. Обнаружилось это только потому, что кто-то запустил тот же плейбук на пустом окружении.
Менять заодно и версию — плохая идея: версии компонентов должны переезжать отдельным решением с отдельной проверкой, а не походя, внутри разбора инцидента.
Отсюда практика, которую стоит завести: прогонять плейбук развёртывания на тестовом кластере регулярно, а не только когда он понадобился. Такой прогон проверяет доступность всех внешних источников, от которых зависит восстановление, — а они меняются без вашего участия.
Предотвращённый риск: секреты и изоляция среды
Кластеру нужны секреты: cert-manager ходит за учётными данными DNS-провайдера, приложения — за своими. Оба кластера смотрят в один Vault, и здесь легко сделать незаметную ошибку.
Плейбук настраивает в Vault метод аутентификации Kubernetes. В исходном виде путь монтирования, имена ролей и политик были записаны константами. Запуск такого плейбука для второго кластера перезаписал бы конфигурацию первого: точка монтирования начала бы указывать на API-сервер тестового кластера, и на проде отвалились бы cert-manager и external-secrets.
Лечится параметризацией: у каждого кластера своя точка монтирования, свои роли, свои политики. Это очевидно, как только произнесено вслух, и совершенно неочевидно, пока плейбук обслуживает один кластер.
Менее очевидная часть — содержимое политики. Первым побуждением было оставить его общим: секреты-то те же самые. Но в том же дереве Vault лежала резервная копия административного kubeconfig боевого кластера. С общей политикой любой, кто в песочнице может создать объект ExternalSecret, прочитал бы её — и получил бы полный доступ к проду.
Политику для тестового кластера сузили до путей, которые ему реально нужны: учётные данные DNS-провайдера и собственное поддерево. Проверять такое стоит не на глаз, а перечитав, какие именно секреты запрашивают развёрнутые объекты.
Вывод, который я считаю главным в этом разделе: изоляция среды — это про учётные данные, а не только про сеть. Тестовый кластер может стоять в отдельном сегменте, не иметь внешнего доступа и всё равно быть мостом к проду, если в нём лежат боевые ключи. Ровно поэтому боевые секреты приложений в песочницу не переносят: тестовым нагрузкам — тестовые креды, даже если это неудобно.
Сертификаты и вход
Кластер внутренний, наружу не публикуется. Это не мешает выдать ему настоящий сертификат: проверка ACME по DNS-01 не требует входящего доступа. cert-manager кладёт TXT-запись в публичную зону через API провайдера и получает wildcard на *.lab.example.com. Снаружи имя не резолвится — записи заведены только во внутренней зоне, — но сертификат валиден, и браузер не ругается.
Пара мелочей, которые экономят время:
- Wildcard одноуровневый:
app.lab.example.comон покрывает,app.beta.lab.example.com— нет. - Сам домен
lab.example.comwildcard’ом не покрывается, его надо добавить вторым именем явно. - Лимит выпуска у Let’s Encrypt считается на зарегистрированный домен. Если тестовый кластер регулярно пересоздаётся, каждый цикл расходует общую с продом квоту. Для часто пересоздаваемых стендов разумнее staging-issuer или внутренний CA.
Инцидент 4: PROXY protocol без того, кто его добавит
Сертификат выпущен, DNS отвечает, порт открыт. И ничего не работает:
* Connected to argocd.lab.example.com (10.10.0.30) port 443
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* Recv failure: Connection reset by peer
Симптом характерный: TCP соединяется, а TLS-рукопожатие обрывается сбросом. У такого сброса несколько типовых причин — ожидание PROXY protocol, требование клиентского сертификата, несовпавшая filter chain у прокси, сетевые политики. Гадать не нужно, состояние проверяется напрямую.
Так и оказалось:
$ kubectl get cm cilium-config -n kube-system \
-o jsonpath='{.data.enable-ingress-proxy-protocol}'
true
Параметр приехал вместе с конфигурацией боевого кластера. И для прода он верен: перед ним стоит L4-балансировщик, который ходит в ingress с send-proxy-v2 и передаёт реальный адрес клиента. Тестовый кластер стоит без балансировщика — заголовок добавлять некому, и ingress отвергает всех.
Исправление тривиально: сделать флаг параметром кластера, true там, где впереди балансировщик, false там, где его нет. Интересен не фикс, а природа ошибки.
enableProxyProtocol=true — это не свойство кластера. Это утверждение о его окружении: «передо мной стоит прокси, который добавит заголовок». Копируя конфигурацию работающей системы, мы переносим и такие утверждения — но проверить их в новой среде некому. Там, где утверждение ложно, тот же самый параметр означает «рвать соединение со всеми».
После правки:
$ curl -o /dev/null -w "HTTP %{http_code}, TLS verify=%{ssl_verify_result}\n" \
https://argocd.lab.example.com/
HTTP 307, TLS verify=0
subject=CN = *.lab.example.com
issuer=C = US, O = Let's Encrypt
Что общего у четырёх отказов
Первое и главное: ни один из них не проявлялся на работающем кластере. Два видны только на пустом окружении, один — только при повторном прогоне, четвёртый — только там, где нет фронтирующего балансировщика. Три из четырёх сломали бы восстановление прода с нуля.
Отсюда практический вывод, который стоит принять как отдельное требование: способность развернуться с нуля — это свойство системы, которое надо проверять специально. Оно не следует из того, что прод работает, и деградирует само по себе — реестры меняют адреса, upstream переезжает, зависимости перестают миррориться. Регулярный прогон развёртывания на тестовом стенде — это не роскошь, а способ узнавать о таких изменениях заранее, а не в аварийной ситуации.
Второе: два из четырёх отказов выглядели одинаково — context deadline exceeded после долгого ожидания. В обоих случаях helm не сообщил, чего именно ждал: в первом это был адрес LoadBalancer, во втором — под с нескачанным образом. Логи helm бесполезны обе минуты, потраченные на их чтение. Ответ был виден в kubectl get svc и kubectl describe pod.
Отсюда: --wait удобен, пока работает, и малодиагностичен, когда нет. Явные ожидания на конкретное условие — rollout status, kubectl wait --for=condition=..., проверка появления адреса — при отказе называют причину, и упавшая задача плейбука сама себя объясняет. Но равную гарантию они дают ровно настолько, насколько полно перечислен контракт готовности: неполный список меняет непрозрачное ожидание на прозрачное, но дырявое.
Третье: скопированный параметр переносит невысказанную предпосылку. Это касается не только PROXY protocol. Любая конфигурация, снятая с работающей системы, содержит утверждения о её окружении — какой прокси стоит впереди, какие реестры зеркалируются, какие адреса уже выданы. В новой среде эти утверждения не проверяются, они просто становятся ложными.
И четвёртое, методическое: прежде чем расширять инфраструктуру, проверьте дешёвую альтернативу. История с реестром решилась сравнением двух digest’ов вместо нового proxy-cache и правки конфигурации всех узлов. Разница в трудозатратах — два порядка.
Чек-лист перед развёртыванием
Что стоит проверить до того, как запускать apply:
- Schematic загрузочного образа и installer’а совпадают. Это требование документации: installer в машинной конфигурации должен использовать тот же schematic, что и ISO, иначе набор расширений разъедется. Версии я держу одинаковыми и того же советую — так проще отвечать на вопрос «что именно установлено», — но как жёсткого требования документация этого не формулирует.
- Адреса и идентификаторы виртуалок реально свободны. Отсутствие ответа на ping ничего не доказывает — выключенная машина тоже не отвечает. Сверяйтесь с гипервизором.
- Версии кластера лежат в git, а не в локальном файле переменных.
- Все внешние источники доступны: реестры образов, репозитории чартов. Именно здесь ломается восстановление, и именно это не проверяется, пока кластер работает.
- Параметры, зависящие от окружения, вынесены в переменные: PROXY protocol, адреса балансировщика, зеркала реестров. Всё, что является утверждением о среде, а не о кластере.
- Точки монтирования и политики секретов разведены по кластерам. Общая конфигурация означает, что второй кластер перезапишет первый.
- Права тестовой среды сужены до необходимых. Боевые ключи в песочнице отменяют её изоляцию.
Что осталось за рамками
Разбор доводит кластер до состояния «узлы Ready, CNI работает, вход опубликован с валидным сертификатом». cert-manager, External Secrets, метрики и ArgoCD появляются ровно в той мере, в какой они порождали инциденты: их настройка — отдельная тема и заслуживает отдельного разбора. Все приведённые фрагменты взяты из рабочего кода и проверены на живом кластере, но, как сказано во вступлении, запускаемым стендом не являются.
Приложение: Terraform-ресурсы bootstrap
Полные ресурсы, о которых шла речь в контексте. Приведены для понимания устройства — это выдержки из рабочего кода, а не готовый к запуску модуль.
Секреты кластера и машинные конфигурации
talos_machine_secrets — корень доверия кластера: CA etcd, CA Kubernetes, ключи сервис-аккаунтов. Ресурс генерирует их один раз и хранит в состоянии Terraform, из-за чего состояние этого рута само становится секретом: шифрование, ограниченный доступ, отдельная резервная копия. Ниже — secrets.tf:
terraform {
required_providers {
talos = {
source = "siderolabs/talos"
version = "0.11.0"
}
# плюс провайдер вашего гипервизора
}
}
resource "talos_machine_secrets" "this" {}
data "talos_machine_configuration" "controlplane" {
cluster_name = "k8s-lab"
cluster_endpoint = "https://10.10.0.10:6443" # VIP
machine_type = "controlplane"
machine_secrets = talos_machine_secrets.this.machine_secrets
kubernetes_version = "v1.36.2"
}
data "talos_machine_configuration" "worker" {
cluster_name = "k8s-lab"
cluster_endpoint = "https://10.10.0.10:6443"
machine_type = "worker"
machine_secrets = talos_machine_secrets.this.machine_secrets
kubernetes_version = "v1.36.2"
}
cluster_endpoint указывает на VIP, которого ещё не существует, — он появится только после того, как поднимется etcd. Это нормально: значение записывается в конфигурацию узлов и понадобится им позже.
Применение конфигурации к узлам
Каждому узлу достаётся общая конфигурация его типа плюс патч под конкретный узел. Применяется всё это на временный DHCP-адрес, который сообщил гостевой агент (machine-config.tf):
resource "talos_machine_configuration_apply" "controlplane" {
for_each = { for n in local.controlplane_nodes : n.name => n }
depends_on = [<ресурс виртуальной машины>]
timeouts = { create = "15m" }
client_configuration = talos_machine_secrets.this.client_configuration
machine_configuration_input = data.talos_machine_configuration.controlplane.machine_configuration
# DHCP-адрес от гостевого агента
node = [
for ip in flatten(<ресурс ВМ>[each.key].ipv4_addresses) :
ip if startswith(ip, "10.10.")
][0]
config_patches = [
yamlencode({ /* патч из следующего блока */ }),
# с Talos 1.12 рекомендуемый формат — отдельный документ HostnameConfig;
# legacy-поле внутри machine deprecated, но пока поддерживается
yamlencode({
apiVersion = "v1alpha1"
kind = "HostnameConfig"
hostname = each.value.name
auto = "off"
})
]
}
Для worker-узлов ресурс такой же, только берёт data.talos_machine_configuration.worker и не содержит блока vip.
Bootstrap и получение доступа
Инициализация etcd выполняется ровно один раз и ровно на одном узле. Дальше из кластера забираются kubeconfig и talosconfig (bootstrap.tf):
resource "talos_machine_bootstrap" "this" {
depends_on = [talos_machine_configuration_apply.controlplane]
client_configuration = talos_machine_secrets.this.client_configuration
# адрес конкретного узла, НЕ VIP: выборов лидера ещё не было
endpoint = local.controlplane_nodes[0].ip
node = local.controlplane_nodes[0].ip
timeouts = { create = "30m" }
}
resource "talos_cluster_kubeconfig" "this" {
depends_on = [talos_machine_bootstrap.this]
client_configuration = talos_machine_secrets.this.client_configuration
endpoint = local.controlplane_nodes[0].ip
node = local.controlplane_nodes[0].ip
timeouts = { create = "30m" }
}
data "talos_client_configuration" "this" {
cluster_name = "k8s-lab"
client_configuration = talos_machine_secrets.this.client_configuration
endpoints = [for n in local.controlplane_nodes : n.ip]
nodes = [for n in local.controlplane_nodes : n.ip]
}
# local_sensitive_file, а не local_file: content помечается sensitive
# и не попадает в вывод plan/apply
resource "local_sensitive_file" "kubeconfig" {
content = talos_cluster_kubeconfig.this.kubeconfig_raw
filename = "${path.module}/kubeconfig"
file_permission = "0600"
}
resource "local_sensitive_file" "talosconfig" {
content = data.talos_client_configuration.this.talos_config
filename = "${path.module}/talosconfig"
file_permission = "0600"
}
depends_on здесь не украшение: без явных зависимостей Terraform вправе запустить bootstrap параллельно с применением конфигураций, а выгрузку kubeconfig — до bootstrap. Порядок «сначала все control plane, потом bootstrap» — свойство этого рута, а не требование Talos: протоколу достаточно одного настроенного узла, остальные присоединяются позже. Просто в коде, который создаёт узлы и кластер одной командой, ждать всех дешевле, чем выяснять, кто уже готов.
Оба файла содержат административные доступы, поэтому им место в .gitignore, а не в репозитории. И записывать их стоит через local_sensitive_file, а не local_file: у первого содержимое помечено как чувствительное и не выводится в plan и apply. На состояние это не влияет — содержимое всё равно лежит в нём открытым текстом, так что защита state остаётся обязательной.
Источники
Поведение инструментов версионно зависимо, поэтому перед тем, как переносить рецепты на другие версии, стоит свериться с первоисточниками:
- Матрица поддержки Talos 1.13 — какая ветка с какими версиями Kubernetes совместима.
- Что нового в Talos 1.11 — с этой версии версия Kubernetes проверяется и при первичной установке.
- Talos Image Factory — требования к совпадению schematic загрузочного образа и installer’а.
- Talos: Virtual IP — почему адрес зависит от выборов лидера в etcd и почему bootstrap идёт не на него.
- Справочник CLI Talos — в том числе где
talosctlищет конфигурацию по умолчанию. - Cilium Ingress и LB IPAM — как сервису выдаётся адрес и что для этого нужно.
values.yamlчарта Cilium 1.19.4 — какие компоненты включены по умолчанию и как на самом деле называются ключи.- Диагностика Cilium —
cilium statusи запускcilium-dbgвнутри агента. - Helm: флаг
--wait— какие ресурсы он считает готовыми.
Все ссылки, кроме документации Helm, закреплены на версиях, о которых идёт речь: поведение Talos и Cilium от версии зависит сильно, и stable через год будет описывать не то, что здесь показано.
И последнее. Ведите журнал по ходу развёртывания: команда, время, что сломалось, что помогло. Задним числом это не восстанавливается — ни тайминги, ни последовательность, ни отвергнутые гипотезы. Эта статья целиком выросла из такого журнала, и самой ценной его частью оказалась запись про ложный след в первом инциденте: она сэкономила бы мне полчаса, если бы я вёл её в предыдущий раз.
Комментарии