На уже развёрнутом kubeadm-кластере поднимем Ingress Controller Traefik, дадим ему принимать входящий трафик на портах 80/443, настроим persistent-хранилище для сертификатов и получим рабочий HTTPS через Let's Encrypt встроенным ACME-клиентом Traefik.
Статья продолжает «Разворачиваем кластер Kubernetes (kubeadm) на Ubuntu 22.04» — здесь предполагается, что кластер уже поднят и все ноды в Ready.
Важно: проект ingress-nginx официально архивирован 24 марта 2026 года и больше не развивается — использовать его для новых кластеров не стоит. В качестве альтернативы взят Traefik, который активно поддерживается.
Требования
- Рабочий kubeadm-кластер: control-plane и минимум один worker.
- Доменное имя, A-запись которого указывает на публичный IP одной из worker-нод.
- Открытые порты 80 и 443 на этой ноде — в облачном firewall, если он есть, отдельно от firewall самой ОС.
- Email для регистрации в Let's Encrypt.
1. Установка Helm
curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-4
chmod 700 get_helm.sh
./get_helm.sh
helm version2. Установка Traefik через Helm
Добавляем репозиторий:
helm repo add traefik https://traefik.github.io/charts
helm repo updateСтавим с hostNetwork: true — под будет напрямую биндиться на порты ноды, без облачного LoadBalancer (актуально для self-managed VPS, где его просто нет):
helm install traefik traefik/traefik \
--namespace traefik \
--create-namespace \
--set hostNetwork=true \
--set service.enabled=false \
--set nodeSelector."kubernetes\.io/hostname"=<hostname_worker_ноды>nodeSelector жёстко закрепляет Traefik за конкретной нодой — именно за той, на чей IP указывает ваш домен.
3. Меняем порты на 80/443
По умолчанию чарт слушает 8000/8443 (непривилегированные порты), а не 80/443. Меняем явно:
helm upgrade traefik traefik/traefik \
--namespace traefik \
--reuse-values \
--set ports.web.port=80 \
--set ports.websecure.port=443 \
--set securityContext.runAsUser=0 \
--set securityContext.runAsNonRoot=false \
--set podSecurityContext.runAsUser=0 \
--set podSecurityContext.runAsNonRoot=false \
--set securityContext.capabilities.add[0]=NET_BIND_SERVICERoot здесь не украшение, а вынужденная мера: связка hostNetwork: true и capability NET_BIND_SERVICE (штатный способ разрешить непривилегированному процессу биндиться на порт меньше 1024) в текущей версии чарта не срабатывает сама по себе — под падает с bind: permission denied. Root — задокументированный обходной путь именно для этого сценария.
Важно: runAsUser=0 один, без capabilities.add[0]=NET_BIND_SERVICE, — недостаточен. По умолчанию чарт выставляет capabilities.drop: [ALL], и в современном ядре Linux даже root не может биндиться на привилегированный порт, если capability явно обнулена — это capability-based проверка, а не привилегия по UID. Нужны оба флага одновременно.
Проверяем, что порты реально слушаются на нужной ноде:
ss -tlpn | grep traefikОжидаем *:80 и *:443.
4. Настраиваем DNS для hostNetwork-пода
Под с hostNetwork: true по умолчанию использует DNS-настройки самой ноды (/etc/resolv.conf хоста), а не кластерный CoreDNS — из-за этого под не сможет резолвить имена вида *.svc.cluster.local других сервисов кластера. Для Ingress-роутинга это не критично (Traefik получает адреса подов напрямую из Kubernetes API, а не через DNS), но пригодится для любых будущих проверок и интеграций изнутри самого Traefik:
helm upgrade traefik traefik/traefik \
--namespace traefik \
--reuse-values \
--set deployment.dnsPolicy=ClusterFirstWithHostNetClusterFirstWithHostNet — официальное значение Kubernetes специально для hostNetwork-подов, которым всё равно нужен доступ к кластерному DNS.
5. Открываем порты в облачном firewall
Отдельно от firewall самой ОС: если используете, например, Hetzner Cloud Firewall, добавьте inbound-правила для TCP 80 и 443 на эту ноду. Проверить снаружи:
telnet <публичный_IP> 806. Устанавливаем StorageClass (local-path-provisioner)
Без него любой PersistentVolumeClaim в кластере зависнет в Pending навсегда — в self-managed kubeadm-кластере, в отличие от managed (EKS/GKE), StorageClass по умолчанию не существует.
kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.36/deploy/local-path-storage.yaml
kubectl get pods -n local-path-storageДелаем его default-классом кластера:
kubectl patch storageclass local-path -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'7. Включаем persistence для сертификатов
Без этого шага сертификат будет теряться при каждом перезапуске пода, а частые повторные запросы упрутся в rate limit Let's Encrypt — не больше 5 дубликатов в неделю на домен.
helm upgrade traefik traefik/traefik \
--namespace traefik \
--reuse-values \
--set persistence.enabled=true \
--set persistence.size=128Mi \
--set persistence.storageClass=local-path8. Настраиваем ACME (Let's Encrypt)
helm upgrade traefik traefik/traefik \
--namespace traefik \
--reuse-values \
--set certificatesResolvers.le.acme.email=<ваш_email> \
--set certificatesResolvers.le.acme.storage=/data/acme.json \
--set certificatesResolvers.le.acme.httpChallenge.entryPoint=web9. Добавляем init-container для прав на acme.json
Traefik сам предупреждает об этом при первом упоминании persistence и ACME: без явного init-container права на файл могут теряться при перезапуске.
cat <<EOF > traefik-acme-init.yaml
deployment:
initContainers:
- name: volume-permissions
image: busybox:latest
command: ["sh", "-c", "touch /data/acme.json; chmod -v 600 /data/acme.json"]
volumeMounts:
- mountPath: /data
name: data
EOF
helm upgrade traefik traefik/traefik \
--namespace traefik \
--reuse-values \
-f traefik-acme-init.yamlПроверяем логи — должна появиться строка о старте ACME-провайдера:
kubectl logs -n traefik -l app.kubernetes.io/name=traefik --tail=30 | grep -i acme10. Деплоим тестовое приложение: HTTP и HTTPS независимо, двумя отдельными Ingress
kubectl create deployment demo --image=traefik/whoami --replicas=1
kubectl expose deployment demo --port=80 --target-port=80Важно: Traefik создаёт один роутер на один Ingress-объект. Если добавить аннотацию router.tls.certresolver в общий Ingress, TLS включается для всего роутера целиком — даже если в router.entrypoints явно перечислить и web, и websecure. В результате обычный HTTP-запрос на порт 80 перестаёт обслуживаться (роутер требует TLS-подключение) и Traefik отдаёт свой дефолтный 404 page not found, при этом HTTPS на том же домене продолжает работать нормально — асимметрия, которую легко принять за проблему с DNS или сетью, хотя причина чисто в конфигурации роутинга.
Если нужен редирект HTTP в HTTPS (частый случай) — достаточно одного Ingress с TLS-секцией, роутер сам обработает оба направления. Но если протоколы должны работать независимо, без редиректа — нужны два отдельных Ingress, каждый со своим entrypoints и backend:
cat <<EOF | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: demo-http
annotations:
kubernetes.io/ingress.class: traefik
traefik.ingress.kubernetes.io/router.entrypoints: web
spec:
rules:
- host: <ваш_домен>
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: demo
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: demo-https
annotations:
kubernetes.io/ingress.class: traefik
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls.certresolver: le
spec:
tls:
- hosts:
- <ваш_домен>
rules:
- host: <ваш_домен>
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: demo
port:
number: 80
EOFTraefik автоматически инициирует HTTP-01 challenge (через роутер demo-http на порту 80) и получит сертификат для роутера demo-https — проверяем:
kubectl logs -n traefik -l app.kubernetes.io/name=traefik --tail=30 | grep -i acmeПроверяем оба протокола отдельно:
curl -I http://<ваш_домен>/
curl -I https://<ваш_домен>/Оба должны вернуть 200 OK.
Возможные проблемы
PVC зависает в Pending: unbound immediate PersistentVolumeClaims
Причина — не указан storageClass явно, а в кластере нет default StorageClass. Решение — шаги 6 и 7 выше: поставить local-path-provisioner, пометить его как default либо явно указать persistence.storageClass=local-path.
Если PVC уже создан пустым, без storageClass, — storageClassName неизменяемое поле, апгрейд Helm его не поправит. Нужно удалить и пересоздать:
kubectl delete pvc traefik -n traefik
kubectl delete pod -n traefik -l app.kubernetes.io/name=traefikbind: permission denied даже с runAsUser=0
Root сам по себе не спасает от capability-based проверки ядра. Чарт по умолчанию выставляет securityContext.capabilities.drop: [ALL] — обнуляет всё, включая право биндиться на порт меньше 1024. Добавьте capability явно, вместе с root:
--set securityContext.capabilities.add[0]=NET_BIND_SERVICEHTTP отдаёт 404, хотя HTTPS на том же домене работает нормально
Причина — общий Ingress с аннотацией router.tls.certresolver, которая делает единственный роутер TLS-only для всех перечисленных entrypoints (см. шаг 10 выше). Разделите на два отдельных Ingress-объекта: один чисто HTTP, другой чисто HTTPS.
При каждом helm upgrade новый под зависает в Pending с ошибкой didn't have free ports
Классика для hostNetwork: true — два пода с одинаковыми портами физически не могут одновременно жить на одной ноде. Rolling update не может завершиться сам. Перед апгрейдами, меняющими сетевую конфигурацию, проще сразу остановить старый под:
kubectl scale deployment traefik -n traefik --replicas=0
# helm upgrade ...
kubectl scale deployment traefik -n traefik --replicas=1Traefik слушает 8000/8443 вместо 80/443, хотя ports.web.port=80 указан
Проверьте фактические слушающие порты прямо на ноде:
ss -tlpn | grep traefikЕсли видите старые порты — под не пересоздался после апгрейда (сверьте PID процесса до и после). Удалите под вручную, Deployment пересоздаст его с актуальным конфигом.