594 lines
17 KiB
Markdown
594 lines
17 KiB
Markdown
---
|
||
title: "Блог на Hugo в K3s: часть 2 - деплой в кластер"
|
||
date: 2026-01-08
|
||
draft: false
|
||
description: "NFS хранилище, Hugo Builder с webhook listener, Nginx с Prometheus exporter и автоматический SSL от Let's Encrypt. Полный деплой production окружения."
|
||
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
|