Публикация сервисов Kubernetes: двойной HAProxy на Proxmox и Ingress Controller
(Кластер Kubernetes с 64 ГБ ОЗУ за 39 €/месяц — часть 5)
Это пятая часть серии статей о создании домашней/staging-среды Kubernetes на базе Proxmox на выделенном сервере Hetzner EX44 (14 ядер/20 потоков, 64 ГБ ОЗУ).
В четвёртой части кластер был успешно развёрнут и работает полностью за NAT. Это хорошо с точки зрения безопасности, но одновременно означает, что внешние клиенты не имеют естественного способа добраться до сервисов внутри кластера.
В облачных средах эту проблему обычно решает управляемый балансировщик нагрузки (managed load balancer). В самостоятельно развёрнутой среде на Proxmox нужен эквивалентный компонент, который находится на границе между публичной сетью и приватной сетью кластера.
Эту роль здесь выполняет HAProxy.
Он работает на хосте Proxmox (не внутри Kubernetes), принимает входящие подключения на публичный IP-адрес и перенаправляет трафик к нужным узлам за NAT.
Зачем использовать HAProxy как граничный шлюз (edge gateway) Proxmox?
Есть несколько практических причин, делающих такое размещение труднобиваемым:
Единая точка входа. Публичный IP-адрес есть только у хоста; у узлов его нет. Внешний трафик в любом случае приходит на хост.
Распределение нагрузки. Входящие соединения можно раскидывать по нескольким рабочим узлам (worker nodes) вместо того, чтобы зависеть от одного.
Фильтрация на границе. Удобно отклонять нежелательные домены или трафик ещё до того, как он попадёт в кластер.
TLS passthrough. HAProxy может принимать маршрутные решения на основе SNI, не завершая TLS — сертификаты и шифрование остаются полностью под управлением кластера.
Архитектура инфраструктуры: от публичного IP до Ingress в K8s
Как показано на диаграмме выше, экземпляр HAProxy, запущенный прямо на хосте Proxmox, служит точкой входа для всего внешнего трафика.
Запросы на портах 80 и 443 пересылаются на NodePort-сервисы рабочих узлов (30080 и 30443), которые направляют трафик к подам HAProxy Ingress Controller, запущенным как DaemonSet. Ingress Controller применяет правила маршрутизации и перенаправляет запросы к сервисам внутри кластера.
Трафик на порту 6443 маршрутизируется внутренне к узлам управляющей плоскости (control plane) и доступен через проброс портов SSH, что сохраняет Kubernetes API приватным.
Автоматическая генерация конфигурации HAProxy с помощью шаблонов Terraform
В третьей части Terraform уже создал виртуальные машины и назначил внутренние IP-адреса каждому узлу управляющей плоскости и рабочему узлу. Это значит, что у Terraform уже есть всё необходимое для генерации конфигурации HAProxy — и нам не придётся поддерживать её вручную.
По мере развития кластера — добавления, удаления или пересборки узлов — конфигурация HAProxy будет автоматически оставаться в актуальном состоянии.
Всё, что нужно сделать, — добавить следующий фрагмент в terraform/main.tf:
resource "local_file" "haproxy_config" {
depends_on = [
proxmox_virtual_environment_vm.control_planes,
proxmox_virtual_environment_vm.workers
]
filename = "${path.module}/../ansible/playbooks/roles/haproxy/files/haproxy.cfg"
content = templatefile("${path.module}/templates/haproxy.cfg.tftpl", {
control_planes = local.control_planes
workers = local.workers
})
}
Этот блок делает три простые вещи:
-
Ждёт, пока все ВМ будут созданы (чтобы получить реальные IP-адреса).
-
Рендерит конфигурацию HAProxy из шаблона Terraform.
-
Записывает готовый файл прямо в директорию Ansible-роли — он сразу готов к развёртыванию.
Подробный разбор: конфигурация HAProxy для API K8s и трафика
Конфигурация HAProxy состоит из трёх логических частей:
-
Балансировка нагрузки Kubernetes API
-
Перенаправление HTTP-трафика
-
Перенаправление HTTPS-трафика с SSL passthrough
Каждая часть выполняет свою отдельную функцию.
Балансировка нагрузки Kubernetes API
frontend kube_api_frontend
bind 127.0.0.1:6443
mode tcp
default_backend kube_api_backend
backend kube_api_backend
mode tcp
balance leastconn
option tcp-check
%{ for control_plane in control_planes ~}
server ${control_plane.name} ${control_plane.ip}:6443 check
%{ endfor ~}
Несколько пояснений к принятым решениям:
-
bind 127.0.0.1:6443ограничивает доступ к API-эндпойнту уровнем хоста. Он остаётся доступным через проброс портов SSH, но не публикуется в интернет. -
leastconn— разумный выбор по умолчанию для долгоживущих соединений: он не направляет новый трафик на узел, у которого уже больше активных сессий. -
Список бэкендов генерируется Terraform и отражает реальные IP-адреса узлов управляющей плоскости.
HTTP frontend (порт 80): ACME и редирект
Порт 80 существует в основном по двум причинам:
-
HTTP-01-проверки cert-manager (
/.well-known/acme-challenge/) -
Перенаправление всего остального на HTTPS
frontend worker_http_frontend
bind 0.0.0.0:80
mode http
option httplog
# Don't log null connections
option dontlognull
# Block httpoxy attacks; prevents redirecting internal server traffic to an attacker
http-request del-header Proxy
# Forward real client IP to backend (for logging and access control in cluster)
option forwardfor
http-request set-header X-Real-IP %[src]
http-request set-header X-Forwarded-Proto http
# Step 1: Domain filtering - check if domain is in allowed list
# Read allowed domains from file (exact matches)
acl allowed_domain_exact hdr(host) -f /etc/haproxy/allow_domains.txt
# Wildcard domain matching (e.g., *.example.com)
# This matches domains ending with patterns from the file
# Note: For wildcards like *.example.com, the file should contain .example.com
# HAProxy will match any subdomain ending with .example.com
acl allowed_domain_wildcard hdr_end(host) -f /etc/haproxy/allow_domains.txt
# Step 2: Check if request is for Let's Encrypt HTTP-01 challenge
# Let's Encrypt uses /.well-known/acme-challenge/ path for validation
acl is_letsencrypt_challenge path_beg /.well-known/acme-challenge/
# Rule 1: Block requests from domains NOT in allowed list
# This must be checked first - reject unauthorized domains immediately
# Domain is allowed if it matches exactly OR matches wildcard pattern
http-request deny if !allowed_domain_exact !allowed_domain_wildcard
# Rule 2: Redirect allowed to HTTPS (if not ACME)
http-request redirect scheme https code 301 if !is_letsencrypt_challenge
# Rule 3: Route ACME challenge
use_backend worker_http_backend if is_letsencrypt_challenge
# Default backend (should not be reached, but kept for safety)
default_backend worker_http_backend
Дополнительно добавлено несколько базовых HTTP-защит:
-
Запросы с неизвестными или неразрешёнными заголовками
Hostотбрасываются с помощью строгого списка разрешённых доменов (allow_domains.txt). -
Заголовок
Proxyудаляется для предотвращения атак типа httpoxy. -
Реальный IP-адрес клиента сохраняется через
X-Real-IPиX-Forwarded-For, чтобы сервисы внутри кластера видели фактический адрес источника. -
Пути ACME-проверки (
/.well-known/acme-challenge/) пропускаются по HTTP, всё остальное перенаправляется на HTTPS. -
Обслуживаются только явно разрешённые домены, что снижает шум от массовых сканирований интернета и случайного трафика, попадающего на IP сервера.
Такая конфигурация блокирует неизвестные домены и уменьшает шум от случайных сканирований, однако это лишь базовое усиление защиты. Для надёжной защиты от DDoS-атак, фильтрации ботов и терминации TLS по-прежнему нужен полноценный граничный слой наподобие Cloudflare.
HTTPS frontend (порт 443): TLS passthrough с фильтрацией по SNI
Для HTTPS-трафика TLS сохраняется сквозным. HAProxy не завершает TLS — он просто перенаправляет TCP-соединения, тогда как cert-manager и Ingress Controller управляют сертификатами и шифрованием внутри кластера.
frontend worker_https_frontend
bind 0.0.0.0:443
mode tcp
option tcplog
# Wait for SNI extraction from TLS handshake
tcp-request inspect-delay 5s
# Domain filtering via SNI
acl allowed_domain_exact req_ssl_sni -f /etc/haproxy/allow_domains.txt
acl allowed_domain_wildcard req_ssl_sni -m end -f /etc/haproxy/allow_domains.txt
# Reject unauthorized domains
tcp-request content reject if !{ req_ssl_sni -m found } || !allowed_domain_exact !allowed_domain_wildcard
default_backend worker_https_backend
Ключевой элемент здесь — tcp-request inspect-delay. Он даёт HAProxy короткое окно для анализа начального TLS-рукопожатия (handshake) и извлечения SNI перед принятием решения о маршрутизации. На практике SNI приходит мгновенно — значение 5s является лишь верхней границей, а не фактической задержкой.
Соединения без SNI или нацеленные на домены, отсутствующие в списке разрешённых, отбрасываются на границе. Это гарантирует, что HAProxy перенаправляет трафик только для явно разрешённых доменов, игнорируя всё остальное, что попадает на IP сервера.
Бэкенды для рабочих узлов (NodePorts)
Следующая конфигурация бэкендов использует циклы Terraform для динамического сопоставления всех рабочих узлов с конкретными NodePort-портами, которые использует Ingress Controller:
backend worker_http_backend
mode http
balance leastconn
option tcp-check
%{ for worker in workers ~}
server ${worker.name} ${worker.ip}:30080 check
%{ endfor ~}
backend worker_https_backend
mode tcp
balance leastconn
%{ for worker in workers ~}
server ${worker.name} ${worker.ip}:30443
%{ endfor ~}
Эта конфигурация предполагает, что Ingress Controller опубликован через NodePort:
-
HTTP NodePort:
30080 -
HTTPS NodePort:
30443
HAProxy перенаправляет трафик на эти порты рабочих узлов, а Kubernetes берёт управление на себя внутри кластера.
Сохранение IP-адреса клиента: PROXY Protocol и SNI passthrough
Поскольку HAProxy стоит перед кластером, рабочие узлы будут видеть IP-адрес HAProxy как адрес клиента — даже при использовании externalTrafficPolicy: Local. Этот параметр запрещает Kubernetes переписывать исходные IP-адреса, но не может сохранить оригинальный IP клиента после того, как HAProxy открывает новое TCP-соединение к NodePort.
В данной лаборатории TLS передаётся сквозным образом, а для выдачи сертификатов используется HTTP-01. Это упрощает конфигурацию и позволяет cert-manager и Ingress Controller полностью управлять TLS внутри Kubernetes, не добавляя лишних движущихся частей на граничном уровне.
Если в будущем потребуется сохранять реальный IP-адрес клиента для HTTPS, оптимальный путь обновления — переключиться на DNS-01-проверки и включить PROXY Protocol между HAProxy и Ingress Controller. Это позволит сохранять оригинальный адрес клиента, не отказываясь от TLS passthrough.
Даже без терминации TLS HAProxy обнаруживает отказавшие узлы на уровне соединения и автоматически переключает трафик на работоспособные рабочие узлы.
Настройка списков разрешённых доменов для защиты HAProxy
Список разрешённых доменов (domain allowlist) — это простой текстовый файл, управляющий тем, какие домены HAProxy будет принимать. Он применяется как к HTTP-заголовкам Host, так и к HTTPS SNI, поэтому обслуживаются только явно разрешённые домены.
Создайте его из примера:
cd ansible/playbooks/roles/haproxy/files/
cp allow_domains.txt.example allow_domains.txt
Пример содержимого файла:
example.com
www.example.com
api.example.com
.example.com
Последняя строка заслуживает особого внимания. Маски (wildcards) реализованы как совпадение по суффиксу, поэтому .example.com будет соответствовать любому поддомену (api.example.com, app.example.com и т.д.). HAProxy здесь не использует синтаксис *.example.com — начальная точка и есть то, что включает поиск по маске.
Этот список служит простым ограничителем: HAProxy принимает трафик только для явно заданных доменов и игнорирует всё остальное, попадающее на IP сервера.
Генерация конфигурации HAProxy
Когда ресурс Terraform готов, сгенерируйте конфигурацию HAProxy:
cd terraform/
terraform plan
terraform apply
Terraform запишет итоговый файл конфигурации по пути:
ansible/playbooks/roles/haproxy/files/haproxy.cfg
Быстрая проверка:
cat ansible/playbooks/roles/haproxy/files/haproxy.cfg
Как минимум вы должны увидеть:
-
IP-адреса узлов управляющей плоскости в
kube_api_backend -
IP-адреса рабочих узлов в
worker_http_backendиworker_https_backend
Автоматизация развёртывания: Ansible-роль для HAProxy
HAProxy разворачивается на хосте Proxmox через роль haproxy:
cd ansible/
ANSIBLE_CONFIG=./ansible.cfg ansible-playbook -i inventory/hosts.ini playbooks/site.yml --tags haproxy
Что делает роль
-
Устанавливает HAProxy, если он ещё не установлен.
-
Проверяет, что файл
allow_domains.txtсуществует и не пуст. -
Копирует список разрешённых доменов в
/etc/haproxy/allow_domains.txt. -
Загружает сгенерированный
haproxy.cfgво временное место. -
Проверяет конфигурацию перед применением.
-
Заменяет рабочую конфигурацию только после успешной валидации.
-
Перезагружает HAProxy без простоя.
-
Сохраняет резервные копии, что делает откат тривиальным при необходимости.
Валидация конфигурации происходит до того, как что-либо затронет живой сервис:
- name: Validate HAProxy configuration from temporary file
command: haproxy -c -f "{{ haproxy_config_path }}.tmp"
register: haproxy_config_check
failed_when: haproxy_config_check.rc != 0
Это гарантирует, что неверная конфигурация быстро упадёт с ошибкой и никогда не заменит работающий балансировщик нагрузки.
Установка HAProxy Ingress Controller через Helm
На данном этапе экземпляр HAProxy на хосте Proxmox перенаправляет внешний трафик на рабочие узлы через NodePort (30080 для HTTP и 30443 для HTTPS). Но эти порты — лишь точки входа; что-то внутри кластера всё равно должно принять этот трафик и направить его к нужным сервисам.
Это задача HAProxy Ingress Controller.
Он работает на каждом рабочем узле и становится мостом между внешним трафиком и вашими приложениями. Он следит за ресурсами Ingress и автоматически обновляет правила маршрутизации по мере добавления, изменения или удаления сервисов.
Полный путь трафика теперь выглядит так:
Хост Proxmox доставляет трафик в кластер, после чего Ingress Controller берёт управление на себя и обрабатывает всё внутри Kubernetes.
Установка Ingress Controller
Сначала добавьте Helm-репозиторий:
helm repo add haproxytech https://haproxytech.github.io/helm-charts
helm repo update
Затем установите контроллер, используя предоставленный файл values:
cd examples
helm install haproxy-ingress haproxytech/kubernetes-ingress \
--namespace haproxy-controller \
--create-namespace \
-f examples/01-haproxy-ingress/values.yaml
Это развернёт Ingress Controller как DaemonSet, так что каждый рабочий узел будет запускать собственный экземпляр HAProxy, прослушивающий NodePort-сервис.
Проверка работоспособности
Проверьте поды:
kubectl get pods -n haproxy-controller -o wide
Вы должны увидеть по одному поду Ingress Controller на каждом рабочем узле.
Затем проверьте сервис:
kubectl get svc -n haproxy-controller
Вы должны увидеть NodePort-порты 30080 и 30443 — именно те порты, на которые Proxmox HAProxy перенаправляет трафик.
Детали конфигурации
Рассмотрим подробнее файл examples/01-haproxy-ingress/values.yaml:
controller:
# Use DaemonSet to run one pod per node for distributed traffic processing
kind: DaemonSet
# Network service configuration
service:
type: NodePort
# 'Local' preserves the client source IP and avoids extra hops between nodes
externalTrafficPolicy: Local
nodePorts:
http: 30080
https: 30443
# HAProxy internal configuration (via ConfigMap)
config:
use-proxy-protocol: "false"
# SSL redirect is handled by Proxmox HAProxy (not by HAProxy Ingress Controller)
# HAProxy on dedicated server redirects HTTP to HTTPS, but allows /.well-known/acme-challenge/ to pass through
# Disabling ssl-redirect here to prevent double redirect and allow HTTP-01 challenge to work
ssl-redirect: "false"
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
# Enable built-in Prometheus metrics exporter
metrics:
enabled: true
# IngressClass resource configuration
# Use 'ingressClassName: haproxy' in your Ingress manifests
ingressClassResource:
enabled: true
name: haproxy
default: false
Развёртывание как DaemonSet
Контроллер работает как DaemonSet, поэтому каждый рабочий узел запускает собственный экземпляр HAProxy. Трафик, перенаправленный с хоста Proxmox, попадает на рабочий узел и обрабатывается локально, без лишних переходов через кластер.
NodePort-сервис (30080 / 30443)
Ingress Controller опубликован через:
-
30080→ HTTP -
30443→ HTTPS
Эти порты совпадают с портами, настроенными в бэкендах Proxmox HAProxy. Внешний трафик сначала попадает на хост Proxmox, а затем перенаправляется на один из этих портов рабочего узла.
externalTrafficPolicy: Local
Этот параметр удерживает трафик на узле, где он прибыл, и исключает ненужную пересылку между узлами.
PROXY Protocol отключён
PROXY Protocol отключён для сохранения надёжности HTTP-01-валидации сертификатов. Его глобальное включение нарушает ACME HTTP-валидацию. Если в будущем потребуется сохранять реальные IP-адреса клиентов, переключение на DNS-01-проверки позволит безопасно включить PROXY Protocol.
SSL-редирект обрабатывается на граничном уровне
Перенаправление HTTP → HTTPS выполняет Proxmox HAProxy. Ingress Controller занимается только маршрутизацией трафика внутри кластера, что исключает конфликты редиректов и сохраняет работоспособность ACME-валидации.
IngressClass
При развёртывании регистрируется IngressClass с именем haproxy. Используйте ingressClassName: haproxy в манифестах Ingress, чтобы направлять трафик через этот контроллер.
Настройка SSL-сертификатов Let’s Encrypt
Когда Ingress Controller готов, кластер готов автоматически выдавать и управлять TLS-сертификатами с помощью cert-manager и Let’s Encrypt.
cert-manager работает внутри Kubernetes и управляет полным жизненным циклом сертификатов — запрашивает, хранит и обновляет их по мере необходимости. Он напрямую интегрируется с ресурсами Ingress, поэтому сертификаты выдаются автоматически при появлении новых доменов.
Установка cert-manager
Добавьте Helm-репозиторий:
helm repo add jetstack https://charts.jetstack.io
helm repo update
Установите cert-manager:
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--set crds.enabled=true
Проверка cert-manager и настройки ClusterIssuer
Дождитесь запуска всех подов:
kubectl get pods -n cert-manager
Создание ClusterIssuer для Let’s Encrypt
Скопируйте файл-пример и укажите свой email:
cd examples
cp 02-letsencrypt/cluster-issuer.yaml.example 02-letsencrypt/cluster-issuer.yaml
vi 02-letsencrypt/cluster-issuer.yaml
Затем примените его:
kubectl apply -f 02-letsencrypt/cluster-issuer.yaml
Проверьте:
kubectl get clusterissuer
Вы должны увидеть оба:
-
letsencrypt-staging -
letsencrypt-prod
Staging-издатель удобен для тестирования, production-издатель выдаёт доверенные сертификаты.
Автоматизация: процесс HTTP-01-проверки с HAProxy
cert-manager использует процесс HTTP-01-проверки. При запросе сертификата он временно создаёт Ingress, открывающий эндпойнт валидации:
Proxmox HAProxy пропускает этот путь по HTTP и перенаправляет его в кластер, где cert-manager отвечает на проверку. После успешной валидации сертификат выдаётся и сохраняется как Kubernetes Secret.
После этого Ingress Controller автоматически использует сертификат для TLS.
Настройка Ingress в K8s для TLS-сертификатов
Чтобы включить TLS для домена, укажите ClusterIssuer в вашем Ingress:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-staging
spec:
ingressClassName: haproxy
tls:
- hosts:
- example.com
secretName: example-com-tls
cert-manager выполнит следующее:
-
Запросит сертификат.
-
Сохранит его в Secret.
-
Автоматически обновит его до истечения срока действия.
Когда всё заработает, можно переключиться с letsencrypt-staging на letsencrypt-prod, чтобы получить доверенные сертификаты.
Сквозной тест: развёртывание Podinfo с TLS
Когда HAProxy работает на граничном уровне, Ingress Controller запущен, а cert-manager выдаёт сертификаты, остаётся развернуть простое приложение и убедиться, что всё работает от начала до конца.
В качестве лёгкого тестового сервиса используем Podinfo — он позволит проверить полный путь ingress от интернета до пода внутри кластера и убедиться в корректной работе маршрутизации и TLS.
Развёртывание приложения
Примените Deployment и Service:
kubectl apply -f examples/03-podinfo/deployment.yaml
Убедитесь, что поды и сервис запущены:
kubectl get pods -l app=podinfo
kubectl get svc -l app=podinfo
Вы должны увидеть поды в состоянии Running и сервис, опубликованный на порту 9898.
Создание ресурса Ingress
Скопируйте пример манифеста Ingress и укажите свой домен:
cp examples/03-podinfo/ingress.yaml.example examples/03-podinfo/ingress.yaml
vi examples/03-podinfo/ingress.yaml
Задайте свой домен и начните со staging-издателя:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-staging
Примените Ingress:
kubectl apply -f examples/03-podinfo/ingress.yaml
Это запустит запрос сертификата cert-manager и автоматическую настройку TLS.
Проверка выдачи сертификата
Получение сертификата может занять минуту-другую. Наблюдать за процессом можно командой:
kubectl get certificate podinfo-tls -w
Когда сертификат будет готов, он сохранится как Secret и будет автоматически подхвачен Ingress Controller.
На этом этапе TLS полностью активен.
Тест полного пути трафика
Проверьте HTTP:
curl -I http://your-domain.com
Если всё настроено правильно, запрос пройдёт через:
Интернет → Proxmox HAProxy → Рабочий узел → Ingress Controller → Сервис → Под
Переключение на production-сертификаты
Когда staging работает, обновите издатель:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
Примените снова:
kubectl apply -f examples/03-podinfo/ingress.yaml
cert-manager выдаст доверенный сертификат и автоматически заменит им staging-сертификат.
На этом этапе полный путь ingress активен — HAProxy обрабатывает внешний трафик, Kubernetes маршрутизирует его внутри, а cert-manager поддерживает сертификаты в актуальном состоянии.
Что дальше и как это автоматизировать
На данном этапе ingress работает, cert-manager автоматически выдаёт TLS-сертификаты, а HAProxy перенаправляет внешний трафик в кластер.
Приложения доступны по HTTPS, а трафик течёт из интернета в поды без каких-либо ручных шагов. Репозиторий GitHub обновлён — просто склонируйте репозиторий, переключитесь на ветку part_8 и следуйте инструкциям в README.md.
➜ В следующей статье мы познакомимся с Argo CD и перейдём на Git-ориентированный рабочий процесс развёртывания.
Вместо ручного применения манифестов Argo CD будет следить за Git-репозиторием и автоматически синхронизировать с ним состояние кластера. Это упрощает управление развёртываниями и избавляет от необходимости запускать kubectl apply с рабочей станции.