Приватный EKS с OpenVPN и Grafana на Terraform

Проблема публичных кластеров Kubernetes

Каждый раз, когда я встречаю кластер EKS с публичным эндпоинтом API, меня не покидает ощущение, что это мина замедленного действия.

Да, так удобнее. Но это означает, что API-сервер Kubernetes — мозговой центр всего кластера — доступен из любой точки интернета. Достаточно одной неверно настроенной политики IAM, одного утёкшего токена — и день безвозвратно испорчен.

В этом руководстве я покажу, как построить полностью приватный кластер EKS, в котором:

  • API Kubernetes не имеет никакого выхода в интернет

  • Доступ осуществляется через самостоятельно развёрнутый сервер OpenVPN

  • Prometheus + Grafana следят за всем и всея, открываясь только через внутренний балансировщик нагрузки

  • Route 53 Private DNS даёт нам адрес grafana.devops.private

  • Всё описано в Terraform — один terraform apply, чтобы управлять всем этим

Приступим.

Архитектура

Что внутри

Компонент Роль

VPC (10.0.0.0/16)

Изолированная сеть — 2 публичные подсети, 2 приватные подсети в 2 зонах доступности

EKS (приватный API)

Управляющий уровень Kubernetes — эндпоинт API доступен только внутри VPC

OpenVPN EC2

Самостоятельно развёрнутый VPN в публичной подсети — единственная точка входа

NAT Gateway

Исходящий интернет для приватных подсетей (загрузка образов контейнеров)

kube-prometheus-stack

Prometheus (метрики) + Grafana (дашборды) + Alertmanager

Internal Load Balancer

Открывает Grafana только внутри VPC

Route 53 Private Zone

grafana.devops.private → CNAME внутреннего балансировщика

Схема потока трафика

Your Laptop
│
│ OpenVPN (UDP:1194)
▼
┌──────────────┐ ┌─────────────────────────────┐
│ OpenVPN EC2 │──────▶ │ EKS Private API (HTTPS:443)│
│ Public │ NAT │ Worker Node 1 │
│ Subnet │Masq. │ Worker Node 2 │
│ │ │ ┌─────────────────────┐ │
│ │───────▶│ │ Grafana (Internal LB)│ │
│ │ │ │ grafana.devops.private│ │
│ │ │ └─────────────────────┘ │
└──────────────┘ └─────────────────────────────┘
Public Subnet Private Subnets

VPN-сервер использует NAT masquerade через iptables, подменяя IP-адреса клиентов VPN (10.8.0.0/24) собственным адресом в VPC. В результате все сервисы внутри VPC видят трафик с легитимного IP-адреса VPC, а не с неизвестного внешнего диапазона.

Структура проекта

eks-private-vpn/
├── main.tf                # VPC, EKS, OpenVPN EC2, Security Groups
├── monitoring.tf          # kube-prometheus-stack (Prometheus + Grafana)
├── dns.tf                 # Route 53 private zone + Grafana CNAME
├── providers.tf           # AWS, Helm, Kubernetes providers
├── variables.tf           # Input variables
├── outputs.tf             # Useful outputs
├── openvpn_userdata.sh    # OpenVPN server bootstrap script
└── terraform.tfvars       # Your configuration values

Шаг 1 — Настройка провайдеров

Нам понадобятся четыре провайдера. Провайдеры Helm и Kubernetes используют аутентификацию на основе exec для обращения к приватному кластеру EKS:

# providers.tf
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws        = { source = "hashicorp/aws",        version = "~> 5.0" }
    tls        = { source = "hashicorp/tls",        version = "~> 4.0" }
    helm       = { source = "hashicorp/helm",       version = "~> 2.0" }
    kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.0" }
  }
}

provider "aws" {
  region = var.aws_region
}

provider "helm" {
  kubernetes {
    host                   = module.eks.cluster_endpoint
    cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)
    exec {
      api_version = "client.authentication.k8s.io/v1beta1"
      command     = "aws"
      args        = ["eks", "get-token", "--cluster-name",
                     module.eks.cluster_name, "--region", var.aws_region]
    }
  }
}

provider "kubernetes" {
  host                   = module.eks.cluster_endpoint
  cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)
  exec {
    api_version = "client.authentication.k8s.io/v1beta1"
    command     = "aws"
    args        = ["eks", "get-token", "--cluster-name",
                   module.eks.cluster_name, "--region", var.aws_region]
  }
}

Почему аутентификация через exec? Команда aws eks get-token генерирует короткоживущие токены через IAM. Это надёжнее, чем статические токены в kubeconfig, и прозрачно работает с приватным эндпоинтом.

Шаг 2 — VPC и сеть

# main.tf
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name = "${var.project_name}-vpc"
  cidr = var.vpc_cidr                   # 10.0.0.0/16

  azs             = var.availability_zones    # ["us-east-1a", "us-east-1b"]
  private_subnets = var.private_subnet_cidrs  # ["10.0.1.0/24", "10.0.2.0/24"]
  public_subnets  = var.public_subnet_cidrs   # ["10.0.101.0/24", "10.0.102.0/24"]

  enable_nat_gateway   = true
  single_nat_gateway   = true
  enable_dns_hostnames = true
  enable_dns_support   = true

  # Обязательные теги для обнаружения балансировщиков нагрузки EKS
  public_subnet_tags = {
    "kubernetes.io/role/elb" = "1"
  }
  private_subnet_tags = {
    "kubernetes.io/role/internal-elb" = "1"
  }
}

Тег kubernetes.io/role/internal-elb на приватных подсетях сообщает AWS Load Balancer Controller, куда помещать внутренние балансировщики нагрузки — именно так балансировщик Grafana оказывается в нужной подсети.

Шаг 3 — Приватный кластер EKS

Здесь и происходит вся магия. Два параметра меняют всё:

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 20.0"

  cluster_name    = "${var.project_name}-cluster"
  cluster_version = var.kubernetes_version  # "1.31"

  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnets

  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  # ДВА ПАРАМЕТРА, ДЕЛАЮЩИХ КЛАСТЕР ПРИВАТНЫМ
  cluster_endpoint_public_access  = false
  cluster_endpoint_private_access = true
  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  # Обязательно в модуле EKS v20+ — без этого kubectl не работает
  enable_cluster_creator_admin_permissions = true

  eks_managed_node_groups = {
    default = {
      instance_types = var.node_instance_types  # ["t3.medium"]
      min_size       = var.node_min_size         # 1
      max_size       = var.node_max_size         # 3
      desired_size   = var.node_desired_size     # 2
    }
  }
}

Также нужно разрешить VPN-серверу обращаться к API EKS на порту 443:

resource "aws_security_group_rule" "vpn_to_eks_api" {
  description               = "Allow VPN server to access EKS API"
  type                      = "ingress"
  from_port                 = 443
  to_port                   = 443
  protocol                  = "tcp"
  security_group_id         = module.eks.cluster_security_group_id
  source_security_group_id  = aws_security_group.vpn.id
}

Важное изменение в модуле EKS v20: флаг enable_cluster_creator_admin_permissions появился только в v20. В старых версиях создатель кластера автоматически получал права администратора через ConfigMap aws-auth. В v20+ это заменили механизмом EKS Access Entries (записи доступа EKS) — более безопасным, нативным для IAM способом управления RBAC. Без этого флага вы получите загадочную ошибку: "You must be logged in to the server".

Шаг 4 — Сервер OpenVPN

VPN-сервер размещается в публичной подсети — он служит мостом между интернетом и приватной инфраструктурой:

data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"] # Canonical
  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }
  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

resource "aws_security_group" "vpn" {
  name_prefix = "${var.project_name}-vpn-"
  vpc_id      = module.vpc.vpc_id
  description = "OpenVPN server security group"

  # OpenVPN — открыт для всего мира (аутентификация по сертификатам)
  ingress {
    description = "OpenVPN"
    from_port   = 1194
    to_port     = 1194
    protocol    = "udp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  # SSH — только с вашего IP
  ingress {
    description = "SSH"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.admin_ingress_cidr]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  lifecycle { create_before_destroy = true }
}

resource "aws_instance" "vpn" {
  ami                         = data.aws_ami.ubuntu.id
  instance_type               = var.openvpn_instance_type  # t3.small
  key_name                    = var.ssh_key_name
  subnet_id                   = module.vpc.public_subnets[0]
  vpc_security_group_ids      = [aws_security_group.vpn.id]
  associate_public_ip_address = true
  source_dest_check           = false  # ← Критически важно для маршрутизации VPN!

  root_block_device {
    volume_size = 20
    volume_type = "gp3"
  }

  user_data = templatefile("${path.module}/openvpn_userdata.sh", {
    vpc_cidr        = var.vpc_cidr
    vpn_client_cidr = var.vpn_client_cidr
  })

  tags = { Name = "${var.project_name}-openvpn" }
}

resource "aws_eip" "vpn" {
  instance = aws_instance.vpn.id
  domain   = "vpc"
  tags     = { Name = "${var.project_name}-vpn-eip" }
}

Зачем source_dest_check = false? По умолчанию AWS отбрасывает трафик, в котором экземпляр EC2 не является ни источником, ни получателем. Поскольку VPN-сервер пересылает трафик между клиентами VPN (10.8.0.0/24) и ресурсами VPC (10.0.0.0/16), эту проверку необходимо отключить. Без этого все пересылаемые пакеты молча уничтожаются — VPN подключается, но ничего не работает.

Шаг 5 — Скрипт начальной настройки OpenVPN

Этот скрипт user data выполняет всё автоматически при первой загрузке: устанавливает OpenVPN, генерирует полную инфраструктуру PKI, настраивает сервер, задаёт правила NAT и создаёт готовый к использованию профиль клиента .ovpn:

#!/bin/bash
set -euo pipefail
exec > /var/log/openvpn-setup.log 2>&1

export DEBIAN_FRONTEND=noninteractive
apt-get update -y && apt-get upgrade -y

# Предзаполнение ответов для iptables-persistent, чтобы избежать интерактивных запросов
echo iptables-persistent iptables-persistent/autosave_v4 boolean true | debconf-set-selections
echo iptables-persistent iptables-persistent/autosave_v6 boolean true | debconf-set-selections

apt-get install -y -o Dpkg::Options::='--force-confdef' \
  -o Dpkg::Options::='--force-confold' openvpn easy-rsa iptables-persistent

# Включение IP-форвардинга
echo 'net.ipv4.ip_forward = 1' >> /etc/sysctl.conf
sysctl -p

# ── Настройка PKI ──────────────────────────────────────────────────────
EASY_RSA="/etc/openvpn/easy-rsa"
mkdir -p "$EASY_RSA"
cp -r /usr/share/easy-rsa/* "$EASY_RSA/"
cd "$EASY_RSA"

./easyrsa init-pki
EASYRSA_BATCH=1 ./easyrsa build-ca nopass
EASYRSA_BATCH=1 ./easyrsa build-server-full server nopass
EASYRSA_BATCH=1 ./easyrsa build-client-full client1 nopass
./easyrsa gen-dh
openvpn --genkey secret /etc/openvpn/ta.key
cp pki/ca.crt pki/issued/server.crt pki/private/server.key pki/dh.pem /etc/openvpn/

# ── Конфигурация сервера ─────────────────────────────────────────────
cat > /etc/openvpn/server.conf <<'EOF'
port 1194
proto udp
dev tun

ca   /etc/openvpn/ca.crt
cert /etc/openvpn/server.crt
key  /etc/openvpn/server.key
dh   /etc/openvpn/dh.pem
tls-auth /etc/openvpn/ta.key 0

server ${vpn_client_cidr} 255.255.255.0
topology subnet

push "route ${vpc_cidr} 255.255.0.0"
push "dhcp-option DNS 10.0.0.2"

keepalive 10 120
cipher AES-256-GCM
auth SHA256
user nobody
group nogroup
persist-key
persist-tun

status    /var/log/openvpn-status.log
log-append /var/log/openvpn.log
verb 3
EOF

# ── Правила NAT и форвардинга ─────────────────────────────────────
PRIMARY_IF=$(ip route | grep default | awk '{print $5}')
iptables -t nat -A POSTROUTING -s ${vpn_client_cidr}/24 \
  -o "$PRIMARY_IF" -j MASQUERADE
iptables -A FORWARD -i tun0 -o "$PRIMARY_IF" -j ACCEPT
iptables -A FORWARD -i "$PRIMARY_IF" -o tun0 \
  -m state --state RELATED,ESTABLISHED -j ACCEPT
netfilter-persistent save

systemctl enable openvpn@server
systemctl start  openvpn@server

# ── Генерация клиентского профиля .ovpn ───────────────────────────
PUBLIC_IP=$(curl -s http://169.254.169.254/latest/meta-data/public-ipv4 \
  || curl -s http://checkip.amazonaws.com)

mkdir -p /home/ubuntu/client-configs
cat > /home/ubuntu/client-configs/client1.ovpn <<CLIENTCONF
client
dev tun
proto udp
remote $PUBLIC_IP 1194
resolv-retry infinite
nobind
persist-key
persist-tun
remote-cert-tls server
cipher AES-256-GCM
auth SHA256
key-direction 1
verb 3

<ca>
$(cat /etc/openvpn/ca.crt)
</ca>
<cert>
$(openssl x509 -in "$EASY_RSA/pki/issued/client1.crt")
</cert>
<key>
$(cat "$EASY_RSA/pki/private/client1.key")
</key>
<tls-auth>
$(cat /etc/openvpn/ta.key)
</tls-auth>
CLIENTCONF

chown -R ubuntu:ubuntu /home/ubuntu/client-configs
chmod 600 /home/ubuntu/client-configs/client1.ovpn

echo "=== OpenVPN setup complete ==="

Ключевые детали:

  • push "route ${vpc_cidr} 255.255.0.0" — указывает клиентам VPN маршрутизировать весь трафик к VPC (10.0.0.0/16) через туннель

  • push "dhcp-option DNS 10.0.0.2" — передаёт клиентам адрес DNS-резолвера VPC. 10.0.0.2 — это DNS, предоставляемый Amazon (всегда: базовый адрес CIDR VPC + 2). Именно так разрешается grafana.devops.private!

  • MASQUERADE — подменяет исходные IP-адреса клиентов VPN на IP-адрес сервера в VPC, чтобы EKS и внутренние сервисы принимали трафик

  • DEBIAN_FRONTEND=noninteractive в связке с debconf-set-selections — предотвращает зависание iptables-persistent на интерактивных запросах при выполнении user data

Шаг 6 — Стек мониторинга (Prometheus + Grafana)

Разворачиваем Helm-чарт kube-prometheus-stack — стандартный в отрасли пакет для мониторинга:

# monitoring.tf
resource "kubernetes_namespace" "monitoring" {
  metadata {
    name = "monitoring"
  }
  depends_on = [module.eks]
}

resource "helm_release" "kube_prometheus_stack" {
  name       = "kube-prometheus-stack"
  namespace  = kubernetes_namespace.monitoring.metadata[0].name
  repository = "https://prometheus-community.github.io/helm-charts"
  chart      = "kube-prometheus-stack"
  version    = "65.1.0"

  # ── Grafana ─────────────────────────────────────────────────────
  set {
    name  = "grafana.enabled"
    value = "true"
  }
  set {
    name  = "grafana.adminPassword"
    value = var.grafana_admin_password
  }
  set {
    name  = "grafana.service.type"
    value = "LoadBalancer"
  }
  # Аннотации для внутреннего LB — type = "string" обязателен!
  set {
    name  = "grafana.service.annotations.service\\.beta\\.kubernetes\\.io/aws-load-balancer-internal"
    value = "true"
    type  = "string"
  }
  set {
    name  = "grafana.service.annotations.service\\.beta\\.kubernetes\\.io/aws-load-balancer-scheme"
    value = "internal"
    type  = "string"
  }
  set {
    name  = "grafana.service.port"
    value = "80"
  }

  # ── Prometheus ──────────────────────────────────────────────────
  set {
    name  = "prometheus.prometheusSpec.retention"
    value = "7d"
  }
  set {
    name  = "prometheus.prometheusSpec.storageSpec.volumeClaimTemplate.spec.accessModes[0]"
    value = "ReadWriteOnce"
  }
  set {
    name  = "prometheus.prometheusSpec.storageSpec.volumeClaimTemplate.spec.resources.requests.storage"
    value = "20Gi"
  }

  depends_on = [module.eks]
}

Важный нюанс: type = "string" в блоках set с аннотациями обязателен. Без него Terraform передаёт "true" как булево значение. Но аннотации Kubernetes имеют тип map[string]string — в итоге вы получите ошибку json: cannot unmarshal bool into Go struct field ObjectMeta.metadata.annotations of type string. На отладку этого у меня ушло 30 минут.

Шаг 7 — Приватный DNS (Route 53)

Финальный элемент — приватная зона DNS, которая сопоставляет удобный домен с внутренним балансировщиком нагрузки Grafana:

# dns.tf
resource "aws_route53_zone" "private" {
  name = "devops.private"
  vpc {
    vpc_id = module.vpc.vpc_id
  }
  tags = { Name = "${var.project_name}-private-zone" }
}

# Читаем hostname балансировщика после деплоя Grafana через Helm
data "kubernetes_service" "grafana" {
  metadata {
    name      = "kube-prometheus-stack-grafana"
    namespace = "monitoring"
  }
  depends_on = [helm_release.kube_prometheus_stack]
}

# CNAME: grafana.devops.private → internal-xxx.elb.amazonaws.com
resource "aws_route53_record" "grafana" {
  zone_id = aws_route53_zone.private.zone_id
  name    = "grafana.devops.private"
  type    = "CNAME"
  ttl     = 300
  records = [
    data.kubernetes_service.grafana.status[0].load_balancer[0].ingress[0].hostname
  ]
}

Блок data "kubernetes_service" считывает hostname, который AWS назначает внутреннему балансировщику нагрузки Grafana после деплоя Helm-чарта. Этот hostname становится целью CNAME-записи.

Шаг 8 — Переменные и выходные данные

# variables.tf
variable "aws_region"            { default = "us-east-1" }
variable "project_name"          { default = "eks-private" }
variable "vpc_cidr"              { default = "10.0.0.0/16" }
variable "availability_zones"    { default = ["us-east-1a", "us-east-1b"] }
variable "private_subnet_cidrs"  { default = ["10.0.1.0/24",   "10.0.2.0/24"] }
variable "public_subnet_cidrs"   { default = ["10.0.101.0/24", "10.0.102.0/24"] }
variable "kubernetes_version"    { default = "1.31" }
variable "node_instance_types"   { default = ["t3.medium"] }
variable "node_desired_size"     { default = 2 }
variable "node_min_size"         { default = 1 }
variable "node_max_size"         { default = 3 }
variable "openvpn_instance_type" { default = "t3.small" }
variable "vpn_client_cidr"       { default = "10.8.0.0/24" }

variable "ssh_key_name" {
  description = "Name of an existing EC2 key pair"
  type        = string
}
variable "admin_ingress_cidr" {
  description = "Your public IP/32 for SSH access"
  type        = string
}
variable "grafana_admin_password" {
  description = "Admin password for Grafana"
  type        = string
  sensitive   = true
  default     = "admin"
}
# outputs.tf
output "openvpn_public_ip" {
  value = aws_eip.vpn.public_ip
}
output "ssh_to_vpn" {
  value = "ssh -i <your-key.pem> ubuntu@${aws_eip.vpn.public_ip}"
}
output "configure_kubectl" {
  description = "Run after connecting to VPN"
  value       = "aws eks update-kubeconfig --region ${var.aws_region} --name ${module.eks.cluster_name}"
}
output "grafana_url" {
  description = "Grafana URL (accessible only through VPN)"
  value       = "http://grafana.devops.private"
}
output "grafana_lb_hostname" {
  value = data.kubernetes_service.grafana.status[0].load_balancer[0].ingress[0].hostname
}

Деплой и подключение

1. Инициализация и применение

# Создаём terraform.tfvars
cat > terraform.tfvars <<EOF
aws_region             = "us-east-1"
project_name           = "eks-private"
ssh_key_name           = "your-keypair-name"
admin_ingress_cidr     = "YOUR_PUBLIC_IP/32"
grafana_admin_password = "YourSecurePassword"
EOF

terraform init
terraform apply

В результате создаётся около 60 ресурсов — VPC, подсети, NAT-шлюз, кластер EKS, управляемая группа узлов, EC2 с OpenVPN, группы безопасности, Elastic IP, Helm-релизы, зона Route 53 и DNS-записи.

2. Загрузка профиля VPN и подключение

# Получаем публичный IP VPN из выходных данных
VPN_IP=$(terraform output -raw openvpn_public_ip)

# Скачиваем автоматически сгенерированный профиль клиента
scp -i your-key.pem ubuntu@${VPN_IP}:/home/ubuntu/client-configs/client1.ovpn .

# Подключаемся к VPN
sudo openvpn --config client1.ovpn

Ждём сообщения Initialization Sequence Completed. После этого у вас есть туннель внутрь VPC.

3. Настройка разрешения DNS

Ваш локальный DNS-резолвер ничего не знает о приватных зонах Route 53. Исправим это:

# Направляем DNS-запросы через туннель VPN к DNS VPC
sudo resolvectl dns tun0 10.0.0.2
sudo resolvectl domain tun0 "~."

4. Подключение к кластеру

# Настраиваем kubectl
aws eks update-kubeconfig --region us-east-1 --name eks-private-cluster

# Проверяем
kubectl get nodes
NAME                          STATUS   ROLES    AGE   VERSION
ip-10-0-1-xxx.ec2.internal    Ready    <none>   15m   v1.31.x
ip-10-0-2-xxx.ec2.internal    Ready    <none>   15m   v1.31.x

5. Открываем Grafana

Перейдите в браузере по адресу http://grafana.devops.private.

Логин: admin / ваш настроенный пароль.

Вас встретят преднастроенные дашборды для:

  • Состояния и использования ресурсов кластера Kubernetes

  • Метрик CPU, памяти, диска и сети узлов

  • Потребления ресурсов на уровне подов

  • Самомониторинга Prometheus

Подводные камни, о которых лучше знать заранее

1. "You must be logged in to the server"

Причина: В модуле EKS v20+ создатель кластера больше не получает права администратора автоматически.

Решение: Добавьте enable_cluster_creator_admin_permissions = true в модуль EKS.

2. Ошибка маршалинга булевого значения в аннотации Helm

json: cannot unmarshal bool into Go struct field
ObjectMeta.metadata.annotations of type string

Причина: Terraform передаёт "true" как булево значение, но аннотации Kubernetes должны быть строками.

Решение: Добавьте type = "string" в блоки set с аннотациями.

3. iptables-persistent зависает при выполнении user data

Причина: Пакет запрашивает подтверждение в интерактивном режиме — даже в автоматизированных скриптах.

Решение: Предварительно заполните ответы через debconf-set-selections и установите DEBIAN_FRONTEND=noninteractive.

4. Приватный DNS не разрешается на вашей машине

Причина: Локальный DNS-резолвер (127.0.0.53) ничего не знает о приватных зонах VPC.

Решение: sudo resolvectl dns tun0 10.0.0.2 && sudo resolvectl domain tun0 "~." — направляет DNS-запросы через VPN к DNS-серверу VPC.

Анализ поверхности атаки

Вектор атаки Статус

Kubernetes API

Не доступен из интернета — только приватный эндпоинт

Grafana / Prometheus

Не доступны из интернета — только внутренний балансировщик нагрузки

OpenVPN

UDP:1194 открыт, но с аутентификацией по сертификатам (PKI + TLS-auth HMAC)

SSH на VPN-сервер

Ограничен IP администратора (admin_ingress_cidr)

DNS-записи

Приватная зона — не разрешается за пределами VPC

IAM / RBAC

EKS Access Entries — нативная для IAM, поддаётся аудиту

Единственный ресурс, доступный из интернета — VPN-сервер OpenVPN на UDP:1194, защищённый взаимной TLS-аутентификацией с предварительно общим HMAC-ключом.

Итог

Мы построили production-ready, полностью приватный кластер EKS со следующими характеристиками:

  • Нулевой публичный доступ к API Kubernetes

  • Самостоятельно развёрнутый OpenVPN с автоматической генерацией PKI и профилей клиентов

  • Мониторинг Prometheus + Grafana через внутренний балансировщик нагрузки

  • Route 53 Private DNSgrafana.devops.private

  • 100% Terraform — воспроизводимо, под контролем версий, поддаётся аудиту

Эта архитектура полностью устраняет целый класс векторов атак, делая API-сервер Kubernetes недостижимым из интернета. В сочетании с аутентификацией VPN по сертификатам и приватным DNS она обеспечивает безопасную и практичную среду для команд, которые всерьёз относятся к безопасности инфраструктуры.

Полный исходный код доступен на GitHub.

Логотип сообщества DEV
© 2026 meganuke