--- title: "Блог на Hugo в K3s: часть 2 - деплой в кластер" date: 2026-01-08 draft: false description: "Деплой Hugo в K3s: NFS хранилище, Hugo Builder с webhook, Nginx с Prometheus exporter и автоматический SSL от Let's Encrypt." tags: ["hugo", "k3s", "kubernetes", "nfs", "traefik", "cert-manager"] categories: ["Kubernetes", "Веб-разработка"] series: ["Блог на Hugo в K3s"] series_order: 2 --- В первой части мы запустили Hugo локально. Сайт работает пока открыт терминал. Закрыл терминал - сайт умер. Пора переносить это в K3s. --- ## Архитектура деплоя ``` Git Push ↓ Gitea (внутренний) ↓ webhook POST Hugo Builder ├→ git clone + submodule ├→ hugo --minify └→ output → NFS ↓ /export/blog-public/ ↓ Nginx (x2 реплики) ↓ Traefik Ingress ↓ your-blog.ru (SSL) ``` Пять компонентов: 1. **NFS** - хранилище для статики (OpenMediaVault) 2. **Hugo Builder** - пересобирает сайт при каждом пуше 3. **Nginx** - раздаёт статику с NFS 4. **cert-manager** - автоматический SSL от Let's Encrypt 5. **Traefik IngressRoute** - маршрутизация с SSL терминацией --- ## Шаг 1: NFS хранилище Hugo собирает статику в HTML/CSS/JS файлы. Nginx раздаёт эти файлы. Значит нужно общее хранилище куда Hugo пишет, а Nginx читает. NFS - самый простой вариант для homelab. У меня OpenMediaVault на отдельной машине. ### Создаём директории на NAS ```bash # Подключаемся к NAS (SSH на нестандартном порту для безопасности) ssh -p 33322 nasadmin@192.168.11.30 # Создаём папки для production и development окружений sudo mkdir -p /srv/storage/blog/blog-public sudo mkdir -p /srv/storage/blog/blog-public-dev # Выдаём права на запись (контейнеры пишут от root) sudo chmod -R 775 /srv/storage/blog/ ``` **Почему SSH на порту 33322?** Стандартный порт 22 - первая цель сканеров и ботов. Нестандартный порт снижает шум в логах и количество brute-force попыток до нуля. Безопасность через скрытность работает для домашних серверов. ### Настраиваем NFS через OMV Web UI Storage → Shared Folders → Create: - Name: `blog-public` - Device: основной диск - Path: `/blog/blog-public` Services → NFS → Shares → Create: - Shared folder: `blog-public` - Client: `192.168.11.0/24` - Privilege: Read/Write - Extra options: `rw,sync,no_subtree_check,no_root_squash` То же для `blog-public-dev`. **Критично:** `no_root_squash` - без этого контейнеры не смогут записывать файлы (они пишут от root внутри контейнера). ### Проверяем экспорт ```bash # Заходим на NAS ssh -p 33322 nasadmin@192.168.11.30 # Проверяем что NFS экспортирует наши шары sudo exportfs -v | grep blog # Ожидаемый вывод - две строки с настройками экспорта: # /export/blog-public 192.168.11.0/24(rw,sync,no_root_squash,...) # /export/blog-public-dev 192.168.11.0/24(rw,sync,no_root_squash,...) ``` --- ## Шаг 2: PersistentVolumes в K3s K3s нужно сказать где лежат NFS шары. Создаём манифест с PersistentVolume ресурсами. **Файл:** `02-pv.yaml` ```yaml --- apiVersion: v1 kind: PersistentVolume metadata: name: blog-public-pv spec: capacity: storage: 5Gi accessModes: - ReadWriteMany nfs: server: 192.168.11.30 # IP вашего NAS path: /export/blog-public mountOptions: - nfsvers=3 - hard --- apiVersion: v1 kind: PersistentVolume metadata: name: blog-public-dev-pv spec: capacity: storage: 5Gi accessModes: - ReadWriteMany nfs: server: 192.168.11.30 path: /export/blog-public-dev mountOptions: - nfsvers=3 - hard ``` **Почему NFSv3, а не NFSv4?** Потому что NFSv4.2 в K3s не работал - поды виснут в `ContainerCreating` с ошибкой `mount.nfs: No such file or directory`. NFSv3 работает стабильно. Не надо усложнять то что работает. ```bash # Применяем манифест kubectl apply -f 02-pv.yaml # Проверяем что PV создались и привязались kubectl get pv | grep blog # blog-public-pv 5Gi RWX Bound blog/blog-public-pvc ``` --- ## Шаг 3: Hugo Builder Нужен контейнер который слушает webhook от Gitea, клонирует репозиторий и собирает Hugo. ### Зачем нужен Hugo Builder? **Проблема:** Hugo генерирует статику командой `hugo`. Где её запускать? На локальной машине? Тогда нужно вручную заливать файлы на сервер после каждого изменения. Неудобно и ломает автоматизацию. **Решение:** Контейнер который живёт в K3s, слушает webhook от Gitea и автоматически пересобирает сайт при каждом `git push`. ### Dockerfile ```dockerfile FROM alpine:3.19 # Устанавливаем всё что нужно Hugo и Git RUN apk add --no-cache \ git nodejs npm bash curl wget \ libc6-compat libstdc++ ca-certificates # Скачиваем Hugo Extended v0.155.3 WORKDIR /tmp RUN wget https://github.com/gohugoio/hugo/releases/download/v0.155.3/hugo_extended_0.155.3_linux-amd64.tar.gz && \ tar -xzf hugo_extended_0.155.3_linux-amd64.tar.gz && \ cp hugo /usr/bin/hugo && \ chmod +x /usr/bin/hugo && \ rm -rf /tmp/* WORKDIR /workspace # Копируем скрипты COPY webhook-listener.sh /usr/local/bin/ COPY build.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/*.sh EXPOSE 8080 CMD ["/usr/local/bin/webhook-listener.sh"] ``` ### build.sh - скрипт сборки Hugo **Зачем:** Отдельный скрипт сборки нужен чтобы его можно было запускать не только из webhook listener, но и вручную для тестирования. Один скрипт - одна ответственность. ```bash #!/bin/bash set -e # Остановиться при первой ошибке export GIT_TERMINAL_PROMPT=0 # Не запрашивать пароли интерактивно REPO_URL="https://git.example.com/user/blog.git" # URL вашего Gitea репозитория BRANCH="${BRANCH:-main}" # Ветка (передаётся через env) OUTPUT_DIR="/mnt/blog-public" # Куда складывать собранную статику (NFS) WORK_DIR="/tmp/build" # Временная папка для клонирования # Чистим рабочую директорию от прошлой сборки rm -rf ${WORK_DIR} mkdir -p ${WORK_DIR} # Клонируем репозиторий (только нужную ветку, без истории) cd ${WORK_DIR} git clone --branch ${BRANCH} --depth 1 ${REPO_URL} site 2>&1 cd site # Подтягиваем тему Blowfish как Git submodule git submodule update --init --recursive --depth 1 2>&1 # Собираем сайт (минифицируем CSS/JS/HTML) hugo --minify --destination ${OUTPUT_DIR} 2>&1 # Проверяем что сборка прошла успешно if [ -f "${OUTPUT_DIR}/index.html" ]; then echo "Build successful!" else echo "Build failed - index.html not found" exit 1 fi # Убираем за собой rm -rf ${WORK_DIR} ``` ### webhook-listener.sh - слушатель webhook **Зачем:** Gitea отправляет HTTP POST запрос при каждом `git push`. Нужен простой HTTP сервер который принимает этот запрос и запускает сборку. netcat - самый простой способ поднять HTTP listener без зависимостей. ```bash #!/bin/bash set -e echo "Starting webhook listener on port 8080..." while true; do # Принимаем HTTP запрос через netcat и сразу отвечаем 200 OK echo -e "HTTP/1.1 200 OK\r\n\r\nWebhook received" | nc -l -p 8080 # Запускаем сборку синхронно (чтобы видеть логи в kubectl logs) echo "$(date): Webhook triggered, starting build..." /usr/local/bin/build.sh echo "$(date): Build completed, waiting for next webhook..." done ``` ### Сборка и деплой образа ```bash # Собираем Docker образ docker build -t hugo-builder:latest . # Сохраняем в tar файл docker save hugo-builder:latest -o /tmp/hugo-builder.tar # Копируем на все K3s worker ноды for ip in 210 211; do scp /tmp/hugo-builder.tar k3s@192.168.11.$ip:/tmp/ # Импортируем образ в containerd K3s ssh k3s@192.168.11.$ip "sudo k3s ctr images import /tmp/hugo-builder.tar && rm /tmp/hugo-builder.tar" done ``` ### Deployment и Service ```yaml --- apiVersion: apps/v1 kind: Deployment metadata: name: hugo-builder-prod namespace: blog spec: replicas: 1 selector: matchLabels: app: hugo-builder-prod template: metadata: labels: app: hugo-builder-prod spec: containers: - name: hugo-builder image: hugo-builder:latest imagePullPolicy: Never # Образ локальный, не тянуть из registry env: - name: BRANCH value: "main" # Для prod используем main ветку volumeMounts: - name: public mountPath: /mnt/blog-public # NFS хранилище resources: requests: cpu: 200m memory: 256Mi limits: cpu: 500m memory: 512Mi volumes: - name: public persistentVolumeClaim: claimName: blog-public-pvc --- apiVersion: v1 kind: Service metadata: name: hugo-builder-prod namespace: blog spec: selector: app: hugo-builder-prod ports: - port: 8080 targetPort: 8080 name: webhook ``` ```bash # Применяем манифест kubectl apply -f 01-hugo-builder-prod.yaml # Проверяем что под запустился kubectl get pods -n blog | grep hugo-builder # Смотрим логи - должна быть строка "Starting webhook listener" kubectl logs -n blog deployment/hugo-builder-prod ``` --- ## Шаг 4: Nginx с Prometheus exporter Nginx раздаёт статику с того же NFS где Hugo её собрал. Две реплики для минимальной доступности при обновлениях. Бонус: sidecar контейнер с nginx-prometheus-exporter для мониторинга через Grafana. ```yaml --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx namespace: blog spec: replicas: 2 # Две реплики для доступности selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: # Основной контейнер - Nginx - name: nginx image: nginx:1.25-alpine ports: - containerPort: 80 volumeMounts: - name: html mountPath: /usr/share/nginx/html readOnly: true # Nginx только читает, не пишет - name: config mountPath: /etc/nginx/nginx.conf subPath: nginx.conf resources: requests: cpu: 50m memory: 64Mi # Sidecar - экспортер метрик для Prometheus - name: nginx-exporter image: nginx/nginx-prometheus-exporter:1.1.0 args: - -nginx.scrape-uri=http://localhost/nginx_status ports: - containerPort: 9113 name: metrics resources: requests: cpu: 10m memory: 16Mi volumes: - name: html persistentVolumeClaim: claimName: blog-public-pvc # NFS хранилище - name: config configMap: name: nginx-config --- apiVersion: v1 kind: Service metadata: name: nginx namespace: blog spec: selector: app: nginx ports: - port: 80 targetPort: 80 name: http - port: 9113 targetPort: 9113 name: metrics # Для Prometheus ``` --- ## Шаг 5: SSL сертификаты cert-manager автоматически получает сертификаты от Let's Encrypt через HTTP-01 challenge. **Важно:** Сначала настрой A-запись у DNS провайдера: ``` your-blog.ru A 77.37.XXX.XXX (ваш внешний IP) www.your-blog.ru A 77.37.XXX.XXX ``` Без этого Let's Encrypt не сможет проверить что домен принадлежит вам. ```yaml apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: blog-tls namespace: blog spec: secretName: blog-tls issuerRef: name: letsencrypt-prod kind: ClusterIssuer dnsNames: - your-blog.ru - www.your-blog.ru ``` ```bash # Применяем манифест kubectl apply -f 04-certificate.yaml # Ждём 30-60 секунд пока cert-manager получит сертификат kubectl get certificate -n blog # Должно быть READY=True # NAME READY SECRET AGE # blog-tls True blog-tls 45s ``` --- ## Шаг 6: IngressRoute через Traefik Traefik маршрутизирует трафик на Nginx и делает SSL терминацию. ```yaml --- # HTTP → HTTPS редирект (опционально) apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: blog-http namespace: blog spec: entryPoints: - web # Порт 80 routes: - match: Host(`your-blog.ru`) || Host(`www.your-blog.ru`) kind: Rule services: - name: nginx port: 80 --- # HTTPS с SSL apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: blog-https namespace: blog spec: entryPoints: - websecure # Порт 443 routes: - match: Host(`your-blog.ru`) || Host(`www.your-blog.ru`) kind: Rule services: - name: nginx port: 80 tls: secretName: blog-tls # Сертификат от cert-manager ``` ```bash # Применяем манифест kubectl apply -f 05-ingressroute.yaml # Проверяем что сайт доступен curl -I https://your-blog.ru # HTTP/2 200 ``` --- ## Шаг 7: Webhook в Gitea Последний шаг - связать Gitea с Hugo Builder. Gitea → ваш репозиторий → Settings → Webhooks → Add Webhook → Gitea - **URL:** `http://hugo-builder-prod.blog.svc.cluster.local:8080` - **HTTP Method:** POST - **Content Type:** application/json - **Trigger On:** Push events - **Branch filter:** `main` Нажимаем "Test Delivery" - должен вернуть `200 OK`. Проверяем логи Hugo Builder: ```bash # Следим за логами в реальном времени kubectl logs -n blog deployment/hugo-builder-prod -f # Должно появиться: # Webhook triggered, starting build... # Cloning repository... # Initializing submodules... # Building Hugo site... # Build successful! ``` --- ## Проверка работы ```bash # Меняем статью cd ~/hugo-projects/blog git checkout main echo "## Тестовая правка" >> content/posts/hello-world/index.md # Коммитим и пушим git add . git commit -m "test: проверка автосборки" git push origin main # Следим за логами Hugo Builder kubectl logs -n blog deployment/hugo-builder-prod -f # Через 5-7 секунд сборка завершится # Проверяем что изменение попало на сайт curl -s https://your-blog.ru/posts/hello-world/ | grep "Тестовая правка" ``` Если видите "Тестовая правка" - всё работает. Каждый `git push` автоматически обновляет сайт. --- ## Что дальше Production окружение развёрнуто. Но пока только для ветки `main`. В следующей части добавим development окружение с отдельным Hugo Builder, Nginx и защитой через Basic Auth. Два независимых пайплайна в одном namespace. --- **Стек этой части:** - K3s 1.30 - NFS на OpenMediaVault - Hugo Builder (Alpine + Hugo v0.155.3) - Nginx 1.25 + Prometheus exporter - cert-manager + Let's Encrypt - Traefik IngressRoute