Работающий кластер ещё не значит восстановимый: четыре отказа при bootstrap Kubernetes на Talos

Разбор четырёх отказов, случившихся при развёртывании Talos-кластера с нуля: дедлок helm --wait, незеркалированный реестр, переехавший чарт-репозиторий и PROXY protocol без прокси. Ни один не виден на работающем кластере, три из четырёх сломали бы восстановление прода. С разбором ложных гипотез и границей доказанного.

Инструкции по развёртыванию Kubernetes обычно описывают удачный проход. Автор поднял кластер, записал команды, опубликовал. Читатель повторяет — и упирается в места, которых в инструкции нет, потому что у автора они не сломались или сломались до того, как он начал писать.

Эта статья устроена иначе. Я поднимал тестовый кластер на Talos рядом с работающим боевым — по тем же плейбукам, которыми обслуживается прод. Кластер поднялся, но по дороге сломался четыре раза. И вот что оказалось важным: ни один из четырёх отказов не был виден на боевом кластере, а три из четырёх сломали бы его восстановление, если бы прод понадобилось поднять с нуля.

Это разбор четырёх отказов, а не пошаговая инструкция. Ключевые куски конфигурации и Terraform-ресурсы приведены — без них инциденты не понять, — но они иллюстрируют устройство, а не образуют запускаемый стенд: инфраструктурная часть у каждого своя, начиная с провайдера гипервизора. Если вам нужна воспроизводимая инструкция по Talos, начинать стоит с официальной документации; здесь ценность в другом — в местах, где инструкции заканчиваются.

Ретрофутуристическая схема в стиле «Полдень. XXI век»: на верстаке стоят два одинаковых аппарата. Левый собран и работает — подводящая труба присоединена, рычаг на месте, под ним штемпельный узел, стрелка манометра успокоилась. Правый такой же, но недособран, и на нём отмечены четыре точки: пустой кронштейн с пунктирным контуром, где узел не установлен, и стрелка манометра, лежащая на нижнем делении; труба, уходящая вбок мимо стоящего рядом бака; открытый фланец без ответного патрубка, который лежит заглушенным тут же на верстаке; пустое гнездо под оттиск, тогда как штемпельный узел есть только у левого аппарата

В статье

Что строим и почему именно так

Кластер из шести узлов: три 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.com wildcard’ом не покрывается, его надо добавить вторым именем явно.
  • Лимит выпуска у 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:

  1. Schematic загрузочного образа и installer’а совпадают. Это требование документации: installer в машинной конфигурации должен использовать тот же schematic, что и ISO, иначе набор расширений разъедется. Версии я держу одинаковыми и того же советую — так проще отвечать на вопрос «что именно установлено», — но как жёсткого требования документация этого не формулирует.
  2. Адреса и идентификаторы виртуалок реально свободны. Отсутствие ответа на ping ничего не доказывает — выключенная машина тоже не отвечает. Сверяйтесь с гипервизором.
  3. Версии кластера лежат в git, а не в локальном файле переменных.
  4. Все внешние источники доступны: реестры образов, репозитории чартов. Именно здесь ломается восстановление, и именно это не проверяется, пока кластер работает.
  5. Параметры, зависящие от окружения, вынесены в переменные: PROXY protocol, адреса балансировщика, зеркала реестров. Всё, что является утверждением о среде, а не о кластере.
  6. Точки монтирования и политики секретов разведены по кластерам. Общая конфигурация означает, что второй кластер перезапишет первый.
  7. Права тестовой среды сужены до необходимых. Боевые ключи в песочнице отменяют её изоляцию.

Что осталось за рамками

Разбор доводит кластер до состояния «узлы 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 остаётся обязательной.

Источники

Поведение инструментов версионно зависимо, поэтому перед тем, как переносить рецепты на другие версии, стоит свериться с первоисточниками:

Все ссылки, кроме документации Helm, закреплены на версиях, о которых идёт речь: поведение Talos и Cilium от версии зависит сильно, и stable через год будет описывать не то, что здесь показано.

И последнее. Ведите журнал по ходу развёртывания: команда, время, что сломалось, что помогло. Задним числом это не восстанавливается — ни тайминги, ни последовательность, ни отвергнутые гипотезы. Эта статья целиком выросла из такого журнала, и самой ценной его частью оказалась запись про ложный след в первом инциденте: она сэкономила бы мне полчаса, если бы я вёл её в предыдущий раз.

Обсуждение в Telegram

Присоединиться →

Комментарии