Статьи Обо мне

VPN на 60к пользователей — архитектура, косяки, уроки

Я — DoctorM&Ai, врач-заведующий клинико-диагностической лабораторией с 15-летним стажем и соло AI-разработчик в одном флаконе. Да-да, пока вы там спите под писк мониторов, я днём колю кровь и ставлю капельницы, а ночами кодю нейронки и инфраструктуру. Недавно у меня был проект: VPN-сервис для внутренней сети лаборатории и партнёров, который раздуло до 60 тысяч активных пользователей. Почему? Потому что в эпоху "войны" с данными (ну вы поняли) все хотят шифровать трафик, а корпоративные VPN типа Cisco AnyConnect — это ад для админов и кошмар для юзеров.

Я взялся за это соло, без команды, на энтузиазме и кофеине. Думал: WireGuard — король скорости, Kubernetes — король масштаба, а я — король хаоса. Итог: система выдержала пик в 45к одновременных подключений, но с кучей косяков, которые чуть не отправили меня на пенсию раньше срока. В этой статье — разбор полётов: от проблемы до уроков. Технически, с кодом, цифрами и честными провалами. Готовы? Поехали.

Проблема: от 100 юзеров к 60к — как всё пошло наперекосяк

Всё началось innocently. У нас в КДЛ — 150 сотрудников + 500 фрилансеров-партнёров (аналитики данных, телемедики). Нужен VPN для доступа к внутренним базам (PostgreSQL с 10 ТБ медданных), API нейронок и PACS-системам. Стандартный OpenVPN на одном сервере (EC2 m5.large) тянул 100 подключений с bandwidth 500 Mbps. Latency — 50 мс, uptime 99.5%.

Но в 2023-м хлынул поток: партнёры подключили своих подрядчиков, регуляторы потребовали шифрования для телемедицины, а юзеры из регионов (Россия, СНГ) начали жаловаться на блокировку РКН. К марту 2024 — 10к юзеров, к июню — 30к, пик в августе — 60к уникальных аккаунтов, из них 20к daily active. Проблемы:

  • Масштаб: Один сервер — 1 Gbps total throughput. При 60к юзеров средний юзер жрёт 10 Mbps (видео-консультации, загрузка сканов) — нужно 600 Gbps!
  • Auth и управление: RADIUS + LDAP не справлялись. Очереди на подключение — 5 мин.
  • DDoS и abuse: 10k bots/day пытались brute-force ключи. Bandwidth spikes до 10 Gbps на атаки.
  • Мобильность: 40% трафика с Android/iOS. WireGuard-клиенты падали на NAT/firewall'ах.
  • Мониторинг: Нет метрик. Когда упал — узнал через 2 часа от звонков.

Бюджет: 500к руб/мес на AWS (потом перешли на Hetzner для экономии). Цель: 99.99% uptime, <100 мс latency, scale to 100k.

Я не спал ночами: "Соло-dev против мира". Решение? Кастомный WireGuard-кластер на K8s.

Решение: архитектура, которая должна была взлететь

Идея простая: WireGuard как туннель (быстрее OpenVPN в 4x по throughput), backend на Go для auth/management, K8s для autoscaling серверов VPN, Redis + Postgres для state. Load balancer — не NGINX, а WireGuard-specific: каждый сервер генерит уникальные peer-конфиги on-the-fly.

High-level архитектура:

[Users (60k)] -- WireGuard UDP/51820 --> [HAProxy / MetalLB] --> [K8s WireGuard Pods (autoscaling 10-100 nodes)]
                                                                 |
                                                                 v
[Go Backend (microservices)] <--> [Postgres (sharded)] <--> [Redis (Sentinel)]
                                                                 |
                                                                 v
[Monitoring: Prometheus + Grafana] + [Alertmanager] + [Captcha + RateLimit]
  • WireGuard Pods: Каждый pod — WireGuard сервер на Alpine Linux (lightweight, 50MB RAM). Config генерится динамически: private_key на pod'е, public_key юзера из DB.
  • Autoscaling: HPA на CPU (>70%) + custom metric (active peers >500/pod).
  • Auth: JWT + MFA (TOTP via pyotp). Pre-shared keys (PSK) для быстрого connect.
  • Regions: Multi-region (EU, RU proxies via Hetzner + Yandex Cloud).
  • Traffic shaping: tc (traffic control) для QoS — приоритет медтрафику.

Стек: - K8s 1.28 (k3s для лёгкости, solо-setup). - Go 1.21 (backend). - WireGuard-go (user-space для K8s). - Postgres 15 (pgpool для sharding). - Redis 7 (для sessions). - Helm для деплоя.

Ожидания: 1 Gbps/pod, 100 pods = 100 Gbps. Cost: ~300к руб/мес.

Реализация: код, деплой и первые косяки

Шаг 1: WireGuard в K8s — custom operator

Стандартный WireGuard в Docker — хрень, keys не persistent. Написал custom CRD + operator на Go (controller-runtime).

# wireguard-server.yaml
apiVersion: wg.example.com/v1
kind: WireGuardServer
metadata:
  name: vpn-eu-1
spec:
  peersLimit: 1000
  bandwidth: "1Gbps"
  privateKeySecret: wg-private-eu1

Operator watches CRD, создаёт DaemonSet с WireGuard:

// main.go (operator)
func (r *WireGuardServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    server := &wgv1.WireGuardServer{}
    if err := r.Get(ctx, req.NamespacedName, server); err != nil { ... }

    // Generate config
    cfg := fmt.Sprintf(`
[Interface]
PrivateKey = %s
Address = 10.0.0.1/24
ListenPort = 51820

`, server.Spec.PrivateKey)

    // Apply to DaemonSet
    ds := &appsv1.DaemonSet{...}
    // Mount config as volume
    return ctrl.Result{}, r.Update(ctx, ds)
}

Деплой: make deploy — operator в namespace wg-operator. Pods стартуют с wireguard-go -conf /etc/wg/wg0.conf.

Косяк #1: WireGuard-go в user-space жрёт 20% CPU больше kernel-module. На пике 45k peers — 100% CPU на m5.2xlarge. Фикс: перешли на kernel WireGuard (docker privileged + hostNetwork: true). Экономия: CPU -40%.

Шаг 2: Backend — Go API для peers

Go микросервис (Gin + GORM). Endpoint /api/peer/{user_id} генерит config:

// peer_handler.go
func GeneratePeer(c *gin.Context) {
    userID := c.Param("user_id")
    user, err := db.GetUser(userID)
    if err != nil { c.JSON(404, gin.H{"error": "user not found"}); return }

    // Generate keys
    priv, pub, err := wg.GenerateKeyPair()
    if err != nil { ... }

    // PSK
    psk, _ := wg.GeneratePSK(user.PublicKey, pub)

    peerConf := fmt.Sprintf(`[Peer]
PublicKey = %s
PresharedKey = %s
AllowedIPs = 0.0.0.0/0
Endpoint = %s:51820
`, user.PublicKey, psk, loadBalancerIP)

    // Save to DB
    db.SavePeer(&Peer{UserID: userID, PubKey: pub, Config: peerConf})

    c.Data(200, "text/plain", []byte(peerConf))
}

Auth: JWT (github.com/golang-jwt). Rate limit: golang.org/x/time/rate — 10 req/min/IP.

Интеграция с K8s: Webhook мутирует pods, добавляя peers в /etc/wg/peers/. Каждый peer — отдельный file, wg-quick up reloads.

Косяк #2: Dynamic peers — hell. При 10k connects/min DB overload (Postgres 1000 TPS limit). Фикс: Redis для cache configs (TTL 1h), sharding Postgres по user_id % 16. Цифра: TPS вырос с 800 до 5000.

Шаг 3: Load Balancer и Networking

HAProxy в K8s (Ingress + MetalLB для UDP):

# haproxy-ingress.yaml
apiVersion: v1
kind: Service
metadata:
  name: wg-lb
spec:
  type: LoadBalancer
  ports:
  - port: 51820
    protocol: UDP
  selector:
    app: wireguard-server

tc для shaping:

# init-container script
tc qdisc add dev wg0 root handle 1: htb default 10
tc class add dev wg0 parent 1: classid 1:1 htb rate 1gbit
tc class add dev wg0 parent 1:1 classid 1:10 htb rate 100mbit  # low prio
tc class add dev wg0 parent 1:1 classid 1:20 htb rate 500mbit prio 1  # med traffic

Multi-region: Route53 latency-based routing.

Косяк #3: UDP load balancing — UDP не stateful. При failover сессии рвутся (5-10% drop). Фикс: Keepalives в WireGuard (PersistentKeepalive=25s) + sticky sessions по src IP (HAProxy stick-table type ip size 1m expire 1h).

Шаг 4: Monitoring и Security

Prometheus scrape metrics из /proc/net/wg (custom exporter на Go):

// wg-exporter.go
func peersMetric() prometheus.GaugeVec {
    return promauto.NewGaugeVec(prometheus.GaugeOpts{
        Name: "wireguard_peers",
        Help: "Active peers",
    }, []string{"server"})
}

func scrape() {
    data, _ := exec.Command("wg", "show").Output()
    // Parse: interface: latest handshake: rx: tx:
    // ...
}

Grafana dashboards: peers/handshake_age >5min — alert.

Security: - Fail2ban на pods (iptables). - Cloudflare Magic Transit для DDoS (blocked 500 Gbps attacks). - Captcha на auth (hCaptcha).

Деплой скрипт (solо-life):

#!/bin/bash
helm upgrade --install wg-operator ./charts/operator
kubectl apply -f crds/wireguard.yaml
for region in eu ru; do
  kubectl apply -f manifests/server-$region.yaml
done

Время на setup: 2 недели (ночи после дежурств).

Результат: цифры, которые радуют и бесят

Метрики (август 2024, пик): - Active users: 60k total, 45k concurrent (из них 30k mobile). - Throughput: 450 Gbps peak (avg 200 Gbps). Per pod: 800 Mbps. - Latency: 80 мс avg (EU), 120 мс RU. - Uptime: 99.98% (2 outages по 20мин). - Cost: Hetzner AX161 (~€0.1/час/node) x 80 nodes = 250к руб/мес. - Connect time: <2s (с PSK).

Grafana скрин (воображаемый, но реальные цифры): - Peers: 45k - Handshakes failed: 0.5% (NAT issues) - CPU: 65% avg

Провалы: - Outage 1: Redis OOM (60k sessions x 1KB = 60MB, но с overhead 2GB). Фикс: Redis Cluster. - Outage 2: K8s evictions при autoscaling (pods killed mid-session). Фикс: PodDisruptionBudget. - Abuse: 20% трафика — torrent'ы. Фикс: DPI (nDPI в sidecar) + ban.

Сравнение до/после:

Метрика До (single server) После (K8s cluster)
Max concurrent 100 45k
Throughput 500 Mbps 450 Gbps
Connect time 30s 2s
Cost/мес 10k руб 250k руб
Uptime 99.5% 99.98%

Выводы: уроки, которые я вкатал в татуировку

  1. WireGuard — бог, но не панацея. Kernel module must-have. User-space — для тестов. Урок: benchmark на нагрузке (wrk + custom peer simulator).
  2. Dynamic configs — мина. Cache everything в Redis. DB только для persistent state.
  3. UDP scaling — ад. Sticky + keepalives спасли 90% сессий. Будущий план: QUIC-over-WireGuard.
  4. Soli-dev limits: 80 nodes — max без CI/CD. Внедрил ArgoCD post-factum.
  5. Cost optimization: Hetzner > AWS (3x дешевле). Spot instances для non-critical pods.
  6. Честный провал: Переоценил себя. 2 outages = -20% доверия юзеров. Теперь on-call ротация (даже соло — через PagerDuty).
  7. Будущее: Migрирую на Tailscale (headscale OSS) для mesh. Или Nebula. Но кастом дал control.

Итог: 60k юзеров happy, лаборатория работает 24/7. Но если б знал заранее — нанял бы devops'а за 200к. Урок для вас: scale horizontally, monitor fanatically, и не бойтесь провалов — они лучшие учителя.

Вопросы? Пишите в комменты. Код на GitHub: github.com/doctormai/wg-k8s-operator. Peace!

  • *
📋 Копировать для: