---
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: ["Мои проекты"]
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
