Добавил серию Blog 1-5 части. Подправил настройки и внешний вид сайта
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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
|
||||
Reference in New Issue
Block a user