Как устроен этот сайт

Опубликовано: 2026-06-01

Этот персональный сайт — небольшое FastAPI-приложение, работающее внутри однонодового k0s Kubernetes-кластера на VPS в Праге.

Стек

  • FastAPI + шаблоны Jinja2; раздаётся uvicorn за Traefik
  • Docker-образ, пушится в собственный registry
  • k0s — лёгкий дистрибутив Kubernetes, одна нода
  • Traefik как ingress с автоматическим TLS от Let's Encrypt через cert-manager
  • FluxCD используется в рабочих кластерах; здесь деплой — обычные shell-скрипты (06-deploy-site.sh)
  • GitHub для исходного репо; push запускает CI-пайплайн, который собирает и пушит образ

Поддомены

Домен Что
antonnovikov.com главный сайт
cv.antonnovikov.com CV и просмотр резюме
proxy.antonnovikov.com HTTP-, SOCKS5- и Shadowsocks-прокси
vpn.antonnovikov.com VPN: IKEv2 и WireGuard
blog.antonnovikov.com этот блог

Каждому поддомену назначен Traefik middleware addPrefix, добавляющий префикс к пути, — одно FastAPI-приложение обрабатывает всё. Отдельного процесса на поддомен нет.

yaml# middleware для blog-поддомена
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: blog-prefix
  namespace: web
spec:
  addPrefix:
    prefix: /blog

IngressRoute для blog.antonnovikov.com подключает этот middleware, и запрос https://blog.antonnovikov.com/2026-05-01-post внутри становится /blog/2026-05-01-post.

Структура приложения

app/
  main.py          # FastAPI app, CORS, статические файлы
  config.py        # Настройки из env vars
  auth.py          # Простая авторизация по токену для эндпоинтов статуса
  templates.py     # Настройка Jinja2
  routers/
    main.py        # index, health
    blog.py        # список постов + рендеринг
    cv.py          # скачать CV/резюме
    proxy.py       # статус прокси + статистика
    vpn.py         # статус VPN + QR-коды

У каждого поддомена свой роутер. Приложение — осознанно монолит: микросервисы здесь ничего не дают.

Блог

Посты — обычные Markdown-файлы в posts/en/ и posts/ru/. Роутер блога:

  1. Читает posts/index.json для списка постов (заголовок, slug, дата, язык)
  2. Обслуживает GET /blog/{slug} — возвращает страницу с slug поста в переменной шаблона
  3. Шаблон получает сырой Markdown через /blog/raw/{slug} и рендерит его на клиенте через marked.js

Никакой базы данных, никакой CMS, никакого шага сборки. Добавить новый пост:

  1. Написать Markdown-файл в posts/en/ и posts/ru/
  2. Добавить запись в posts/index.json
  3. Закоммитить и запушить
json{
  "slug": "2026-05-30-tor-rotating-proxy",
  "title": "Ротирующий Tor HTTP-прокси в Kubernetes",
  "date": "2026-05-30",
  "lang": "ru"
}

Статус VPN и прокси

systemd-таймер раз в 30 секунд запускает Python-сборщик на хосте. Тот опрашивает:

  • wg show wg0 dump — статус пиров WireGuard
  • swanctl --list-sas — IKEv2-сессии
  • счётчики логов tinyproxy
  • логи подов SOCKS5 и Shadowsocks

Все данные сливаются в /var/lib/vpn-status/status.json. FastAPI читает этот файл на каждый запрос статуса — никакой базы данных, никакого пуша, просто чтение файла.

CI/CD-пайплайн

yamlstages:
  - build
  - deploy

build:
  image: docker:24
  services: [docker:dind]
  script:
    - docker build -t registry.example.com/antonnovikov-com:$CI_COMMIT_SHORT_SHA .
    - docker push registry.example.com/antonnovikov-com:$CI_COMMIT_SHORT_SHA
    - docker tag ... :latest && docker push ... :latest

deploy:
  script:
    - ssh root@vps 'bash -s' < k0s-setup/06-deploy-site.sh

06-deploy-site.sh выполняет kubectl set image deployment/web web=registry.../antonnovikov-com:$SHA и ждёт завершения rollout.

Kubernetes-манифесты

yamlapiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: web
spec:
  replicas: 1
  strategy:
    type: RollingUpdate
  template:
    spec:
      containers:
      - name: web
        image: registry.example.com/antonnovikov-com:latest
        ports:
        - containerPort: 8000
        resources:
          requests:
            memory: "64Mi"
            cpu: "50m"
          limits:
            memory: "256Mi"
            cpu: "200m"
        env:
        - name: AUTH_TOKEN
          valueFrom:
            secretKeyRef:
              name: web-secret
              key: auth-token

Секрет создаётся вручную один раз: kubectl create secret generic web-secret --from-literal=auth-token=....

TLS

Traefik управляет TLS через cert-manager + Let's Encrypt. Ресурс Certificate создаётся один раз на домен. Обновление сертификатов — автоматическое.

yamlapiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: antonnovikov-com
  namespace: web
spec:
  secretName: antonnovikov-com-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
    - antonnovikov.com
    - "*.antonnovikov.com"

Один wildcard-сертификат покрывает все поддомены.

Почему не статический генератор сайтов?

Потому что FastAPI уже был нужен для эндпоинтов статуса прокси и VPN, а добавить блог — буквально 30 строк Python плюс шаблон. Статический генератор потребовал бы шага сборки, CDN или файл-сервера и отдельного хостинга под динамические эндпоинты.

Минус — холодный старт: первый запрос после перезапуска пода занимает ~2 секунды, пока грузится uvicorn. При одной реплике на личном VPS это терпимо.

Стоимость

  • VPS: ~€10/месяц (2 vCPU, 4 ГБ RAM, 50 ГБ SSD)
  • Домен: ~€12/год
  • Всё остальное: бесплатно (k0s, cert-manager, Traefik, Let's Encrypt, собственный registry)

Итого: ~€130/год для сайта с несколькими сотнями посетителей в месяц. По облачным меркам, возможно, неэффективно, но на том же VPS работает ещё дюжина других сервисов.