Как устроен этот сайт
Опубликовано: 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/. Роутер блога:
- Читает
posts/index.jsonдля списка постов (заголовок, slug, дата, язык) - Обслуживает
GET /blog/{slug}— возвращает страницу с slug поста в переменной шаблона - Шаблон получает сырой Markdown через
/blog/raw/{slug}и рендерит его на клиенте через marked.js
Никакой базы данных, никакой CMS, никакого шага сборки. Добавить новый пост:
- Написать Markdown-файл в
posts/en/иposts/ru/ - Добавить запись в
posts/index.json - Закоммитить и запушить
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— статус пиров WireGuardswanctl --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 работает ещё дюжина других сервисов.