Перейти к основному содержимому

Блог на Hugo в K3s: часть 2 - деплой в кластер

Олег Казанин
Автор
Олег Казанин
Строю полезную инфраструктуру на Open Source стеке. Документирую грабли, чтобы вы на них не наступали.
Блог на Hugo в K3s - Эта статья — часть серии.
Часть 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
#

# Подключаемся к 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 внутри контейнера).

Проверяем экспорт
#

# Заходим на 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

---
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 работает стабильно. Не надо усложнять то что работает.

# Применяем манифест
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
#

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, но и вручную для тестирования. Один скрипт - одна ответственность.

#!/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 без зависимостей.

#!/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

Сборка и деплой образа
#

# Собираем 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
#

---
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
# Применяем манифест
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.

---
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 не сможет проверить что домен принадлежит вам.

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
# Применяем манифест
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 терминацию.

---
# 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
# Применяем манифест
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:

# Следим за логами в реальном времени
kubectl logs -n blog deployment/hugo-builder-prod -f

# Должно появиться:
# Webhook triggered, starting build...
# Cloning repository...
# Initializing submodules...
# Building Hugo site...
# Build successful!

Проверка работы
#

# Меняем статью
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
Блог на Hugo в K3s - Эта статья — часть серии.
Часть 2: Ты уже здесь

Статьи по теме