Добавил серию Blog 1-5 части. Подправил настройки и внешний вид сайта

This commit is contained in:
2026-02-19 19:49:44 +00:00
parent 3ecd02c4ab
commit b05195a735
238 changed files with 53664 additions and 1026 deletions
@@ -0,0 +1,593 @@
---
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: ["infrastructure"]
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