Добавил серию Blog 1-5 части. Подправил настройки и внешний вид сайта
|
After Width: | Height: | Size: 1008 KiB |
@@ -0,0 +1,306 @@
|
||||
---
|
||||
title: "Блог на Hugo в K3s: часть 1 - архитектура и первый запуск"
|
||||
date: 2026-01-03
|
||||
draft: false
|
||||
description: "Как я переехал с Jekyll на Hugo, почему выбрал тему Blowfish и как настроил два окружения с нуля. Начало цикла о том как я строил oakazanin.ru."
|
||||
tags: ["hugo", "blowfish", "gitea", "homelab", "devops"]
|
||||
categories: ["infrastructure"]
|
||||
series: ["Блог на Hugo в K3s"]
|
||||
series_order: 1
|
||||
---
|
||||
|
||||
Предыдущий блог жил на Jekyll. Жил - громко сказано. Скорее существовал, периодически ломаясь при обновлениях и требуя ритуальных танцев с Ruby каждый раз когда я садился за новую статью.
|
||||
|
||||
Однажды я решил что хватит.
|
||||
|
||||
---
|
||||
|
||||
## Почему не Jekyll
|
||||
|
||||
Jekyll - зрелый инструмент с большим сообществом. Но у него есть фундаментальная проблема: он написан на Ruby.
|
||||
|
||||
Звучит невинно. На практике это означает:
|
||||
|
||||
**Версионный ад.** Ruby, Bundler, Gems - у каждого своя версия, и они регулярно конфликтуют друг с другом. Клонируешь репозиторий на новую машину - час уходит на то чтобы собрать рабочее окружение. Обновляешь Jekyll - ломаются плагины. Обновляешь плагины - ломается что-то ещё.
|
||||
|
||||
**Зависимости ради зависимостей.** Простой блог тянет 1000+ gems, половина из которых устаревшая или находится в состоянии "поддерживается постольку-поскольку". Каждый `bundle install` - это лотерея.
|
||||
|
||||
**Лишний мусор.** Jekyll генерирует страницы без ссылок, которые непонятно зачем существуют. Приходится явно прописывать что не генерировать.
|
||||
|
||||
Hugo решает все эти проблемы радикально: это один бинарник на Go. Никаких зависимостей, никаких конфликтов версий, никакого `bundle install`. Скачал - работает. На любой машине, всегда.
|
||||
|
||||
Бинарник весит ~50MB. Собирает сайт из 40+ страниц за полторы секунды. Jekyll на том же контенте думал заметно дольше.
|
||||
|
||||
---
|
||||
|
||||
## Почему Blowfish
|
||||
|
||||
Все просто: понравилась.
|
||||
|
||||
Смотрел темы несколько часов. Blowfish выглядела именно так как я хотел - чисто, без лишнего, с хорошей типографикой. Взял её.
|
||||
|
||||
Ставится как Git submodule - обновляется одной командой, не засоряет репозиторий.
|
||||
|
||||
---
|
||||
|
||||
## Архитектура: два окружения
|
||||
|
||||
У любого нормального инфраструктурного проекта есть тестовый и production контур. Блог - не исключение: обновления Hugo, изменения темы, новые статьи - всё это нужно проверять до того как это увидят читатели.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────┐
|
||||
│ Gitea (внутренний) │
|
||||
│ репозиторий: blog │
|
||||
│ │
|
||||
│ ветка: main ветка: dev │
|
||||
└───────┬────────────────────┬───────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────┐ ┌───────────────┐
|
||||
│ hugo-builder │ │ hugo-builder │
|
||||
│ prod │ │ dev │
|
||||
└───────┬───────┘ └───────┬───────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────┐ ┌───────────────┐
|
||||
│ nginx │ │ nginx-dev │
|
||||
│ blog.ru │ │ dev.blog.ru │
|
||||
│ (публичный) │ │ (Basic Auth) │
|
||||
└───────────────┘ └───────────────┘
|
||||
```
|
||||
|
||||
**Production** (`blog.ru`) - публичный. Ветка `main`.
|
||||
|
||||
**Development** (`dev.blog.ru`) - тестовый контур. Ветка `dev`. Закрыт Basic Auth через Traefik Middleware - без логина и пароля не войти.
|
||||
|
||||
Оба окружения в одном K3s namespace. Один репозиторий, две ветки, два независимых пайплайна.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 1: Создаём Hugo проект
|
||||
|
||||
```bash
|
||||
# Создаём новый Hugo сайт
|
||||
cd ~/projects
|
||||
hugo new site blog
|
||||
cd blog
|
||||
|
||||
# Инициализируем Git репозиторий
|
||||
git init
|
||||
git remote add origin https://git.example.com/user/blog.git
|
||||
|
||||
# Добавляем тему Blowfish как Git submodule
|
||||
git submodule add -b main https://github.com/nunocoracao/blowfish.git themes/blowfish
|
||||
|
||||
# Подтягиваем файлы submodule
|
||||
git submodule update --init --recursive
|
||||
|
||||
# Удаляем дефолтный конфиг Hugo
|
||||
rm -f hugo.toml
|
||||
|
||||
# Создаём структуру для конфигов
|
||||
mkdir -p config/_default
|
||||
|
||||
# Копируем примеры конфигов из темы
|
||||
cp themes/blowfish/config/_default/*.toml config/_default/
|
||||
```
|
||||
|
||||
Структура после инициализации:
|
||||
|
||||
```
|
||||
blog/
|
||||
├── content/ ← статьи в Markdown
|
||||
├── static/ ← изображения, favicon
|
||||
├── themes/
|
||||
│ └── blowfish/ ← тема (Git submodule)
|
||||
└── config/
|
||||
└── _default/
|
||||
├── hugo.toml
|
||||
├── languages.ru.toml
|
||||
├── menus.ru.toml
|
||||
├── markup.toml
|
||||
└── params.toml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Шаг 2: Минимальная конфигурация
|
||||
|
||||
### hugo.toml
|
||||
|
||||
```toml
|
||||
baseURL = "https://blog.ru/"
|
||||
theme = "blowfish"
|
||||
defaultContentLanguage = "ru"
|
||||
```
|
||||
|
||||
### languages.ru.toml
|
||||
|
||||
```bash
|
||||
# Переименовываем файл языка для русского
|
||||
mv config/_default/languages.en.toml config/_default/languages.ru.toml
|
||||
```
|
||||
|
||||
```toml
|
||||
languageCode = "ru"
|
||||
languageName = "Русский"
|
||||
weight = 1
|
||||
title = "Мой Блог"
|
||||
|
||||
[params]
|
||||
displayName = "RU"
|
||||
isoCode = "ru"
|
||||
rtl = false
|
||||
dateFormat = "2 January 2006"
|
||||
|
||||
[params.author]
|
||||
name = "Ваше Имя"
|
||||
headline = "DevOps Engineer | Kubernetes | Homelab"
|
||||
```
|
||||
|
||||
### menus.ru.toml
|
||||
|
||||
```bash
|
||||
# Переименовываем файл меню
|
||||
mv config/_default/menus.en.toml config/_default/menus.ru.toml
|
||||
```
|
||||
|
||||
```toml
|
||||
[[main]]
|
||||
name = "Блог"
|
||||
pageRef = "posts"
|
||||
weight = 10
|
||||
|
||||
[[main]]
|
||||
name = "О сайте"
|
||||
pageRef = "about"
|
||||
weight = 20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Шаг 3: Первая статья
|
||||
|
||||
```bash
|
||||
# Создаём новую статью
|
||||
hugo new content posts/hello-world/index.md
|
||||
```
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Hello World"
|
||||
date: 2026-02-13
|
||||
draft: false
|
||||
description: "Первый пост на новом сайте"
|
||||
tags: ["test"]
|
||||
---
|
||||
|
||||
Это первый пост на blog.ru.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Шаг 4: Проверяем локально
|
||||
|
||||
```bash
|
||||
# Запускаем локальный сервер Hugo
|
||||
# -D: показывать черновики (draft: true)
|
||||
# --bind 0.0.0.0: доступен с любого IP в локальной сети
|
||||
hugo server -D --bind 0.0.0.0
|
||||
```
|
||||
|
||||
Открываем `http://192.168.11.10:1313/` (ваш IP) - сайт с Blowfish темой и первой статьёй.
|
||||
|
||||
Hugo автоматически пересобирает сайт при сохранении файлов - изменения видны сразу после обновления страницы в браузере.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 5: Пушим в Gitea
|
||||
|
||||
```bash
|
||||
# Добавляем все файлы в Git
|
||||
git add .
|
||||
|
||||
# Создаём первый коммит
|
||||
git commit -m "Initial commit: Hugo + Blowfish v2.98.0"
|
||||
|
||||
# Переименовываем ветку в main (если по умолчанию master)
|
||||
git branch -M main
|
||||
|
||||
# Пушим в Gitea
|
||||
git push -u origin main
|
||||
|
||||
# Создаём ветку dev для development окружения
|
||||
git checkout -b dev
|
||||
git push -u origin dev
|
||||
|
||||
# Возвращаемся на main
|
||||
git checkout main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workflow: как я пишу статьи
|
||||
|
||||
**Пишу (ветка dev):**
|
||||
|
||||
```bash
|
||||
# Переходим в папку проекта
|
||||
cd ~/projects/blog
|
||||
|
||||
# Переключаемся на ветку dev
|
||||
git checkout dev
|
||||
|
||||
# Запускаем локальный предпросмотр
|
||||
hugo server -D --bind 0.0.0.0
|
||||
|
||||
# Создаём новую статью
|
||||
hugo new content posts/название-статьи/index.md
|
||||
|
||||
# Редактируем в любом редакторе, сохраняем, смотрим в браузере
|
||||
# Когда готово - коммитим
|
||||
git add .
|
||||
git commit -m "feat: новая статья про X"
|
||||
|
||||
# Пушим в dev ветку
|
||||
git push origin dev
|
||||
# → webhook срабатывает → автосборка → dev.blog.ru
|
||||
```
|
||||
|
||||
**Проверяю:**
|
||||
|
||||
Открываю `https://dev.blog.ru` (вводя логин/пароль Basic Auth) - вижу статью как её увидят читатели.
|
||||
|
||||
**Публикую:**
|
||||
|
||||
```bash
|
||||
# Переключаемся на main
|
||||
git checkout main
|
||||
|
||||
# Мержим изменения из dev
|
||||
git merge dev
|
||||
|
||||
# Пушим в production
|
||||
git push origin main
|
||||
# → webhook срабатывает → автосборка → blog.ru
|
||||
```
|
||||
|
||||
Золотое правило: **все изменения только через ветку `dev`**. В `main` - только через merge. Никогда не редактировать файлы находясь на `main`.
|
||||
|
||||
Почему это важно - расскажу отдельно, когда дойдём до того как я нарушил это правило и что из этого вышло.
|
||||
|
||||
---
|
||||
|
||||
## Что дальше
|
||||
|
||||
Локально всё работает. Но `hugo server` умрёт как только закрою терминал.
|
||||
|
||||
Нужно развернуть это в K3s: Hugo Builder который пересобирает сайт при каждом пуше, Nginx который раздаёт статику, SSL сертификаты, NFS хранилище. Об этом - в следующей части.
|
||||
|
||||
---
|
||||
|
||||
**Стек этой части:**
|
||||
- Hugo v0.155.3 extended
|
||||
- Blowfish v2.98.0
|
||||
- Gitea (внутренний)
|
||||
- Рабочая станция: Debian/Ubuntu
|
||||
|
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
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,504 @@
|
||||
---
|
||||
title: "Блог на Hugo в K3s: часть 3 - development окружение"
|
||||
date: 2026-01-15
|
||||
draft: false
|
||||
description: "Второй пайплайн для ветки dev с отдельным Hugo Builder, Nginx и защитой через Basic Auth. Тестируем изменения перед публикацией в production."
|
||||
tags: ["hugo", "k3s", "kubernetes", "traefik", "basic-auth"]
|
||||
categories: ["infrastructure"]
|
||||
series: ["Блог на Hugo в K3s"]
|
||||
series_order: 3
|
||||
---
|
||||
|
||||
В части 2 мы развернули production окружение для ветки `main`. Каждый пуш в `main` автоматически обновляет публичный сайт.
|
||||
|
||||
Проблема: нельзя проверить как выглядит статья до публикации. Локальный `hugo server` показывает одно, а production может выглядеть по-другому из-за версий Hugo, конфигов, CSS.
|
||||
|
||||
Нужен второй пайплайн - тестовый контур где можно проверить изменения перед мержем в `main`.
|
||||
|
||||
---
|
||||
|
||||
## Архитектура dev окружения
|
||||
|
||||
```
|
||||
Git Push (dev branch)
|
||||
↓
|
||||
Gitea
|
||||
↓ webhook
|
||||
Hugo Builder Dev
|
||||
├→ Clone dev branch
|
||||
├→ hugo --minify
|
||||
└→ Output → NFS (dev)
|
||||
↓
|
||||
/export/blog-public-dev/
|
||||
↓
|
||||
Nginx Dev (1 реплика)
|
||||
↓
|
||||
Traefik Ingress
|
||||
├→ Basic Auth Middleware
|
||||
└→ dev.blog.ru (SSL)
|
||||
```
|
||||
|
||||
Отличия от production:
|
||||
- Отдельный Hugo Builder (переменная `BRANCH=dev`)
|
||||
- Отдельный NFS volume (`blog-public-dev`)
|
||||
- Отдельный Nginx (одна реплика вместо двух)
|
||||
- **Basic Auth** - доступ только по логину и паролю
|
||||
- Отдельный домен (`dev.blog.ru`)
|
||||
|
||||
Всё это живёт в том же namespace что и production. Два независимых пайплайна, нулевое пересечение.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 1: Hugo Builder для dev
|
||||
|
||||
Используем тот же Docker образ что и для production. Разница - в переменной окружения `BRANCH`.
|
||||
|
||||
**Файл:** `01-hugo-builder-dev.yaml`
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: hugo-builder-dev
|
||||
namespace: blog
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: hugo-builder-dev
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: hugo-builder-dev
|
||||
spec:
|
||||
containers:
|
||||
- name: hugo-builder
|
||||
image: hugo-builder:latest
|
||||
imagePullPolicy: Never
|
||||
env:
|
||||
- name: BRANCH
|
||||
value: "dev" # Главное отличие - используем dev ветку
|
||||
volumeMounts:
|
||||
- name: public
|
||||
mountPath: /mnt/blog-public
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
volumes:
|
||||
- name: public
|
||||
persistentVolumeClaim:
|
||||
claimName: blog-public-dev-pvc # Отдельный PVC
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: hugo-builder-dev
|
||||
namespace: blog
|
||||
spec:
|
||||
selector:
|
||||
app: hugo-builder-dev
|
||||
ports:
|
||||
- port: 8080
|
||||
targetPort: 8080
|
||||
name: webhook
|
||||
```
|
||||
|
||||
```bash
|
||||
# Применяем манифест
|
||||
kubectl apply -f 01-hugo-builder-dev.yaml
|
||||
|
||||
# Проверяем что под запустился
|
||||
kubectl get pods -n blog | grep hugo-builder-dev
|
||||
|
||||
# Смотрим логи
|
||||
kubectl logs -n blog deployment/hugo-builder-dev
|
||||
# Starting webhook listener on port 8080...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Шаг 2: Nginx для dev
|
||||
|
||||
Одна реплика вместо двух - для тестового окружения высокая доступность не критична.
|
||||
|
||||
**Файл:** `03-nginx-dev-deployment.yaml`
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: nginx-dev
|
||||
namespace: blog
|
||||
spec:
|
||||
replicas: 1 # Тестовому окружению достаточно одной реплики
|
||||
selector:
|
||||
matchLabels:
|
||||
app: nginx-dev
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: nginx-dev
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:1.25-alpine
|
||||
ports:
|
||||
- containerPort: 80
|
||||
volumeMounts:
|
||||
- name: html
|
||||
mountPath: /usr/share/nginx/html
|
||||
readOnly: true
|
||||
- name: config
|
||||
mountPath: /etc/nginx/nginx.conf
|
||||
subPath: nginx.conf
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
volumes:
|
||||
- name: html
|
||||
persistentVolumeClaim:
|
||||
claimName: blog-public-dev-pvc # Отдельный PVC
|
||||
- name: config
|
||||
configMap:
|
||||
name: nginx-dev-config
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: nginx-dev
|
||||
namespace: blog
|
||||
spec:
|
||||
selector:
|
||||
app: nginx-dev
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
name: http
|
||||
```
|
||||
|
||||
```bash
|
||||
# Применяем манифест
|
||||
kubectl apply -f 03-nginx-dev-deployment.yaml
|
||||
|
||||
# Проверяем
|
||||
kubectl get pods -n blog | grep nginx-dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Шаг 3: Basic Auth через Traefik
|
||||
|
||||
Dev окружение должно быть закрыто от посторонних. Traefik поддерживает Basic Auth через Middleware.
|
||||
|
||||
### Создаём пароль
|
||||
|
||||
```bash
|
||||
# Генерируем htpasswd (логин: dev, пароль: ваш пароль)
|
||||
htpasswd -nb dev your-password
|
||||
# dev:$apr1$...хеш...
|
||||
|
||||
# Кодируем в base64 для Kubernetes Secret
|
||||
echo -n "dev:$apr1$...хеш..." | base64
|
||||
# ZGV2OiRhcHIxJC4uLg==
|
||||
```
|
||||
|
||||
### Secret с паролем
|
||||
|
||||
**Файл:** `07-basic-auth-secret.yaml`
|
||||
|
||||
```yaml
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: dev-basic-auth
|
||||
namespace: blog
|
||||
type: Opaque
|
||||
data:
|
||||
users: ZGV2OiRhcHIxJC4uLg== # ваш base64 хеш
|
||||
```
|
||||
|
||||
```bash
|
||||
# Применяем секрет
|
||||
kubectl apply -f 07-basic-auth-secret.yaml
|
||||
|
||||
# Проверяем что секрет создался
|
||||
kubectl get secret -n blog | grep dev-basic-auth
|
||||
```
|
||||
|
||||
### Middleware для Basic Auth
|
||||
|
||||
**Файл:** `08-basic-auth-middleware.yaml`
|
||||
|
||||
```yaml
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: dev-basic-auth
|
||||
namespace: blog
|
||||
spec:
|
||||
basicAuth:
|
||||
secret: dev-basic-auth
|
||||
removeHeader: true # Убираем заголовок Authorization после проверки
|
||||
```
|
||||
|
||||
```bash
|
||||
# Применяем middleware
|
||||
kubectl apply -f 08-basic-auth-middleware.yaml
|
||||
|
||||
# Проверяем
|
||||
kubectl get middleware -n blog
|
||||
# NAME AGE
|
||||
# dev-basic-auth 5s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Шаг 4: IngressRoute с Basic Auth
|
||||
|
||||
Связываем всё вместе: домен → middleware → nginx-dev.
|
||||
|
||||
**Файл:** `06-ingressroute-dev.yaml`
|
||||
|
||||
```yaml
|
||||
---
|
||||
# HTTP (без SSL)
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: blog-dev-http
|
||||
namespace: blog
|
||||
spec:
|
||||
entryPoints:
|
||||
- web
|
||||
routes:
|
||||
- match: Host(`dev.blog.ru`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: nginx-dev
|
||||
port: 80
|
||||
|
||||
---
|
||||
# HTTPS с Basic Auth
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: blog-dev-https
|
||||
namespace: blog
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`dev.blog.ru`)
|
||||
kind: Rule
|
||||
middlewares:
|
||||
- name: dev-basic-auth # Добавляем Basic Auth
|
||||
services:
|
||||
- name: nginx-dev
|
||||
port: 80
|
||||
tls:
|
||||
secretName: blog-dev-tls
|
||||
```
|
||||
|
||||
```bash
|
||||
# Применяем IngressRoute
|
||||
kubectl apply -f 06-ingressroute-dev.yaml
|
||||
|
||||
# Проверяем
|
||||
kubectl get ingressroute -n blog | grep dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Шаг 5: SSL сертификат для dev
|
||||
|
||||
cert-manager выпустит отдельный сертификат для `dev.blog.ru`.
|
||||
|
||||
**Не забудьте:** Добавить A-запись в DNS:
|
||||
```
|
||||
dev.blog.ru A 77.37.XXX.XXX
|
||||
```
|
||||
|
||||
**Файл:** `04-certificate-dev.yaml`
|
||||
|
||||
```yaml
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: blog-dev-tls
|
||||
namespace: blog
|
||||
spec:
|
||||
secretName: blog-dev-tls
|
||||
issuerRef:
|
||||
name: letsencrypt-prod
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- dev.blog.ru
|
||||
```
|
||||
|
||||
```bash
|
||||
# Применяем манифест
|
||||
kubectl apply -f 04-certificate-dev.yaml
|
||||
|
||||
# Ждём получения сертификата (30-60 секунд)
|
||||
kubectl get certificate -n blog
|
||||
|
||||
# Должно быть READY=True
|
||||
# NAME READY SECRET AGE
|
||||
# blog-dev-tls True blog-dev-tls 45s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Шаг 6: Webhook в Gitea для dev
|
||||
|
||||
Создаём второй webhook который триггерится на пуши в ветку `dev`.
|
||||
|
||||
Gitea → ваш репозиторий → Settings → Webhooks → Add Webhook → Gitea
|
||||
|
||||
- **URL:** `http://hugo-builder-dev.blog.svc.cluster.local:8080`
|
||||
- **HTTP Method:** POST
|
||||
- **Content Type:** application/json
|
||||
- **Trigger On:** Push events
|
||||
- **Branch filter:** `dev` ← Главное отличие от prod
|
||||
|
||||
Нажимаем "Test Delivery" → должен вернуть `200 OK`.
|
||||
|
||||
Проверяем логи:
|
||||
|
||||
```bash
|
||||
# Смотрим логи Hugo Builder Dev
|
||||
kubectl logs -n blog deployment/hugo-builder-dev -f
|
||||
|
||||
# Должно появиться:
|
||||
# Webhook triggered, starting build...
|
||||
# Cloning repository (branch: dev)...
|
||||
# Build successful!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Проверка работы
|
||||
|
||||
```bash
|
||||
# Создаём тестовую статью в dev
|
||||
cd ~/projects/blog
|
||||
git checkout dev
|
||||
|
||||
# Создаём статью
|
||||
hugo new content posts/test-dev/index.md
|
||||
echo "Тестовая статья в dev окружении" >> content/posts/test-dev/index.md
|
||||
|
||||
# Коммитим и пушим
|
||||
git add .
|
||||
git commit -m "test: проверка dev окружения"
|
||||
git push origin dev
|
||||
|
||||
# Следим за логами Hugo Builder Dev
|
||||
kubectl logs -n blog deployment/hugo-builder-dev -f
|
||||
|
||||
# Через 5-7 секунд сборка завершится
|
||||
```
|
||||
|
||||
Открываем `https://dev.blog.ru` в браузере:
|
||||
|
||||
1. Браузер запросит логин и пароль (Basic Auth)
|
||||
2. Вводим: логин `dev`, пароль который задали
|
||||
3. Видим тестовую статью
|
||||
|
||||
**Критично:** Статья появилась на `dev.blog.ru`, но её **нет** на `blog.ru` - окружения изолированы.
|
||||
|
||||
---
|
||||
|
||||
## Workflow: dev → prod
|
||||
|
||||
Типичный процесс работы:
|
||||
|
||||
**1. Пишу статью в dev:**
|
||||
|
||||
```bash
|
||||
cd ~/projects/blog
|
||||
git checkout dev
|
||||
|
||||
# Создаю статью
|
||||
hugo new content posts/kubernetes-intro/index.md
|
||||
|
||||
# Пишу контент, коммичу
|
||||
git add .
|
||||
git commit -m "feat: статья про Kubernetes"
|
||||
git push origin dev
|
||||
# → автосборка → dev.blog.ru
|
||||
```
|
||||
|
||||
**2. Проверяю на dev.blog.ru:**
|
||||
|
||||
Открываю `https://dev.blog.ru` (вводя логин/пароль), читаю статью, проверяю форматирование, ссылки, изображения.
|
||||
|
||||
Нахожу опечатку - исправляю локально, пушу в `dev` снова. Повторяю пока не доволен результатом.
|
||||
|
||||
**3. Публикую в production:**
|
||||
|
||||
```bash
|
||||
# Всё отлично на dev - мержу в main
|
||||
git checkout main
|
||||
git merge dev
|
||||
git push origin main
|
||||
# → автосборка → blog.ru
|
||||
```
|
||||
|
||||
Статья появляется на публичном сайте.
|
||||
|
||||
---
|
||||
|
||||
## Итоговая архитектура
|
||||
|
||||
Два полностью независимых пайплайна в одном namespace:
|
||||
|
||||
```
|
||||
Production:
|
||||
main branch → hugo-builder-prod → blog-public-pvc → nginx (x2) → blog.ru
|
||||
|
||||
Development:
|
||||
dev branch → hugo-builder-dev → blog-public-dev-pvc → nginx-dev (x1) → dev.blog.ru + Basic Auth
|
||||
```
|
||||
|
||||
Общее:
|
||||
- Namespace: `blog`
|
||||
- Gitea репозиторий
|
||||
- Docker образ Hugo Builder
|
||||
- Traefik IngressRoute
|
||||
- cert-manager
|
||||
|
||||
Отдельное:
|
||||
- Deployments
|
||||
- Services
|
||||
- PersistentVolumes
|
||||
- SSL сертификаты
|
||||
- Домены
|
||||
|
||||
---
|
||||
|
||||
## Что дальше
|
||||
|
||||
Два окружения работают. Можно писать статьи, проверять на dev, публиковать в production.
|
||||
|
||||
Но есть проблема: я долго работал в двух папках - `~/projects/blog` (main) и `~/projects/blog-dev` (dev). Это создавало конфликты при merge, рассинхронизацию веток и головную боль.
|
||||
|
||||
В следующей части расскажу как я от этого избавился и почему одна папка с переключением веток лучше чем две отдельные папки.
|
||||
|
||||
---
|
||||
|
||||
**Стек этой части:**
|
||||
- Hugo Builder Dev (та же версия Hugo)
|
||||
- Nginx Dev (одна реплика)
|
||||
- Traefik Basic Auth Middleware
|
||||
- cert-manager (отдельный сертификат)
|
||||
- NFS (отдельный volume)
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,417 @@
|
||||
---
|
||||
title: "Блог на Hugo в K3s: часть 4 - выбор Git workflow"
|
||||
date: 2026-02-16
|
||||
draft: false
|
||||
description: "Две папки или одна с переключением веток? Разбираем варианты организации работы с dev и production окружениями на практических примерах."
|
||||
tags: ["git", "workflow", "devops", "hugo"]
|
||||
categories: ["infrastructure"]
|
||||
series: ["Блог на Hugo в K3s"]
|
||||
series_order: 4
|
||||
---
|
||||
|
||||
В части 3 мы развернули два окружения - production и development. Один репозиторий, две ветки (`main` и `dev`), два пайплайна.
|
||||
|
||||
Теперь встаёт вопрос: **как организовать работу локально?**
|
||||
|
||||
---
|
||||
|
||||
## Проблема
|
||||
|
||||
У нас есть:
|
||||
- Репозиторий в Gitea
|
||||
- Две ветки: `main` (production) и `dev` (development)
|
||||
- Необходимость постоянно переключаться между ними
|
||||
|
||||
Как это делать на локальной машине? Два варианта.
|
||||
|
||||
---
|
||||
|
||||
## Вариант А: Две отдельные папки
|
||||
|
||||
Клонируем репозиторий дважды - в разные папки:
|
||||
|
||||
```
|
||||
~/projects/
|
||||
├── blog/ ← ветка main (production)
|
||||
└── blog-dev/ ← ветка dev (development)
|
||||
```
|
||||
|
||||
**Логика:** Хочу работать с dev - иду в `blog-dev`. Хочу что-то проверить в production - иду в `blog`. Без переключения веток.
|
||||
|
||||
### Кажущиеся преимущества
|
||||
|
||||
**Параллельная работа.** Можно держать открытыми два терминала - в одном `hugo server` для dev, в другом смотреть production код.
|
||||
|
||||
**Изоляция.** Каждая папка - своя песочница. Изменения в одной не влияют на другую.
|
||||
|
||||
**Простота навигации.** `cd blog-dev` вместо `git checkout dev`. Меньше команд.
|
||||
|
||||
**Привычный паттерн.** Многие админы и разработчики держат несколько клонов для разных задач.
|
||||
|
||||
### Реальные проблемы
|
||||
|
||||
#### Проблема 1: Рассинхронизация локальных ветокin
|
||||
|
||||
Работаю в `blog-dev` - пишу статьи, коммичу, пушу в `origin/dev`. Всё хорошо.
|
||||
|
||||
Но локальная ветка `dev` в папке `blog` при этом **не обновляется**. Она отстаёт от `origin/dev`.
|
||||
|
||||
Приходишь делать merge:
|
||||
|
||||
```bash
|
||||
cd blog
|
||||
git checkout main
|
||||
git merge dev
|
||||
# Already up to date. ← НО есть НЮАНС!
|
||||
```
|
||||
|
||||
Git говорит "всё актуально", имея в виду **локальную** ветку `dev`, которая отстала на три коммита. Статьи не попадают в production.
|
||||
|
||||
Приходится помнить делать `git pull origin dev` перед каждым merge. Забыл - публикуешь устаревшую версию.
|
||||
|
||||
#### Проблема 2: Конфликты при merge
|
||||
|
||||
Редактируешь `config/params.toml` в обеих папках независимо:
|
||||
- В `blog-dev` добавил Firebase конфиг
|
||||
- В `blog` изменил название сайта
|
||||
|
||||
При merge Git честно сообщает о конфликте:
|
||||
|
||||
```
|
||||
CONFLICT (content): Merge conflict in config/params.toml
|
||||
```
|
||||
|
||||
И это повторяется **каждый раз** когда трогаешь конфигурацию. Потому что две папки - это две независимые истории изменений одного файла.
|
||||
|
||||
#### Проблема 3: Работа не в той папке
|
||||
|
||||
Несколько раз ловил себя на том что редактирую статьи прямо в `blog` - папке production. Это нарушает весь смысл раздельных окружений.
|
||||
|
||||
#### Проблема 4: Умственная нагрузка
|
||||
|
||||
Постоянный вопрос "в какой папке я сейчас?" Для простого блога это лишняя когнитивная нагрузка.
|
||||
|
||||
---
|
||||
|
||||
## Вариант Б: Одна папка с переключением веток
|
||||
|
||||
Один клон репозитория, работа через `git checkout`:
|
||||
|
||||
```
|
||||
~/projects/
|
||||
└── blog/ ← одна папка, две ветки: main и dev
|
||||
```
|
||||
|
||||
### Как это работает
|
||||
|
||||
**Пишу статью:**
|
||||
|
||||
```bash
|
||||
cd ~/projects/blog
|
||||
|
||||
# Переключаюсь на dev
|
||||
git checkout dev
|
||||
|
||||
# Проверяю что dev актуален
|
||||
git pull origin dev
|
||||
|
||||
# Запускаю локальный сервер
|
||||
hugo server -D --bind 0.0.0.0
|
||||
|
||||
# Создаю статью
|
||||
hugo new content posts/название/index.md
|
||||
|
||||
# Коммичу и пушу
|
||||
git add .
|
||||
git commit -m "feat: новая статья"
|
||||
git push origin dev
|
||||
```
|
||||
|
||||
**Публикую:**
|
||||
|
||||
```bash
|
||||
# Убеждаюсь что dev актуален
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
|
||||
# Переключаюсь на main и мержу
|
||||
git checkout main
|
||||
git pull origin main
|
||||
git merge dev
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### Реальные преимущества
|
||||
|
||||
**Никакой рассинхронизации.** Все ветки в одном репозитории. `git pull` обновляет всё что нужно.
|
||||
|
||||
**Нет конфликтов из-за независимых изменений.** Когда работаешь в одной папке, `params.toml` существует в одном экземпляре. Все изменения делаются в `dev`, в `main` попадают только через merge.
|
||||
|
||||
Конфликт возможен только если кто-то редактирует `main` напрямую - а это нарушение workflow.
|
||||
|
||||
**Невозможно ошибиться с веткой.** `git branch` показывает где ты сейчас. Случайно отредактировать файлы в `main` - сложнее.
|
||||
|
||||
**Меньше места на диске.** Один клон вместо двух. Один `.git` вместо двух.
|
||||
|
||||
---
|
||||
|
||||
## Сравнение на практических примерах
|
||||
|
||||
### Пример 1: Обновление темы Blowfish
|
||||
|
||||
**Две папки:**
|
||||
```bash
|
||||
cd blog-dev
|
||||
git submodule update --remote themes/blowfish
|
||||
git add themes/blowfish
|
||||
git commit -m "update: Blowfish theme"
|
||||
git push origin dev
|
||||
|
||||
# Проверяешь на dev.blog.ru
|
||||
# Если всё ок - мержишь
|
||||
cd ../blog
|
||||
git checkout dev
|
||||
git pull origin dev # ← ЛЕГКО ЗАБЫТЬ
|
||||
git checkout main
|
||||
git merge dev
|
||||
git push origin main
|
||||
```
|
||||
|
||||
**Одна папка:**
|
||||
```bash
|
||||
cd blog
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
git submodule update --remote themes/blowfish
|
||||
git add themes/blowfish
|
||||
git commit -m "update: Blowfish theme"
|
||||
git push origin dev
|
||||
|
||||
# Проверяешь на dev.blog.ru
|
||||
# Если всё ок - мержишь
|
||||
git checkout main
|
||||
git pull origin main
|
||||
git merge dev
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Меньше команд, меньше переходов между папками, меньше шансов забыть `git pull`.
|
||||
|
||||
### Пример 2: Правка опечатки в production
|
||||
|
||||
Нашёл опечатку на `blog.ru`. Нужно исправить быстро.
|
||||
|
||||
**Две папки:**
|
||||
|
||||
Опасность: хочется исправить прямо в `blog` (ветка `main`). Это нарушает workflow - все изменения должны идти через `dev`.
|
||||
|
||||
Правильно:
|
||||
```bash
|
||||
cd blog-dev
|
||||
git checkout dev
|
||||
# Исправляешь
|
||||
git commit -m "fix: опечатка"
|
||||
git push origin dev
|
||||
cd ../blog
|
||||
git checkout main
|
||||
git pull origin dev # ← опять легко забыть
|
||||
git merge dev
|
||||
git push origin main
|
||||
```
|
||||
|
||||
**Одна папка:**
|
||||
|
||||
```bash
|
||||
cd blog
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
# Исправляешь
|
||||
git commit -m "fix: опечатка"
|
||||
git push origin dev
|
||||
git checkout main
|
||||
git merge dev
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Проще, меньше команд, понятнее.
|
||||
|
||||
### Пример 3: Долгая работа над статьёй
|
||||
|
||||
Пишешь большую статью несколько дней. Между сеансами работы кто-то (или ты сам) запушил другие изменения в `dev`.
|
||||
|
||||
**Две папки:**
|
||||
|
||||
```bash
|
||||
cd blog-dev
|
||||
# День 1: пишешь
|
||||
git add .
|
||||
git commit -m "wip: статья"
|
||||
|
||||
# День 2: продолжаешь
|
||||
git pull origin dev # Подтягиваешь чужие изменения
|
||||
# Пишешь дальше
|
||||
git add .
|
||||
git commit -m "feat: закончил статью"
|
||||
git push origin dev
|
||||
```
|
||||
|
||||
Всё так же как и с одной папкой. Разницы нет.
|
||||
|
||||
**Одна папка:**
|
||||
|
||||
```bash
|
||||
cd blog
|
||||
git checkout dev
|
||||
|
||||
# День 1: пишешь
|
||||
git add .
|
||||
git commit -m "wip: статья"
|
||||
|
||||
# День 2: продолжаешь
|
||||
git pull origin dev # Подтягиваешь чужие изменения
|
||||
# Пишешь дальше
|
||||
git add .
|
||||
git commit -m "feat: закончил статью"
|
||||
git push origin dev
|
||||
```
|
||||
|
||||
Идентично. Этот пример работает одинаково в обоих вариантах.
|
||||
|
||||
---
|
||||
|
||||
## Что выбрать?
|
||||
|
||||
**Если ты только начинаешь - сразу делай одну папку.**
|
||||
|
||||
Две папки кажутся удобными, но создают проблемы которые регулярно прерывают работу:
|
||||
- Рассинхронизация веток
|
||||
- Конфликты при merge
|
||||
- Когнитивная нагрузка
|
||||
|
||||
Одна папка с переключением веток - стандартный Git workflow, проверенный миллионами разработчиков. Требует чуть больше дисциплины (`git checkout dev` вместо `cd blog-dev`), но избавляет от всех проблем выше.
|
||||
|
||||
**Золотое правило:** Никогда не редактировать файлы находясь на ветке `main`. Все изменения - через `dev`. Всегда.
|
||||
|
||||
---
|
||||
|
||||
## Миграция: если начал с двух папок
|
||||
|
||||
Если уже работаешь в двух папках - переход простой.
|
||||
|
||||
### Шаг 1: Убеждаемся что всё запушено
|
||||
|
||||
```bash
|
||||
# Проверяем обе папки
|
||||
cd ~/projects/blog-dev
|
||||
git status
|
||||
git push origin dev
|
||||
|
||||
cd ~/projects/blog
|
||||
git status
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### Шаг 2: Синхронизируем ветку dev в основной папке
|
||||
|
||||
```bash
|
||||
cd ~/projects/blog
|
||||
|
||||
# Обновляем локальную ветку dev из remote
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
|
||||
# Проверяем что всё актуально
|
||||
git log --oneline -5
|
||||
|
||||
# Возвращаемся на main
|
||||
git checkout main
|
||||
```
|
||||
|
||||
### Шаг 3: Удаляем вторую папку
|
||||
|
||||
```bash
|
||||
# Убеждаемся что в blog-dev нет несохранённых изменений
|
||||
cd ~/projects/blog-dev
|
||||
git status
|
||||
# Должно быть: nothing to commit, working tree clean
|
||||
|
||||
# Удаляем папку
|
||||
cd ~/projects
|
||||
rm -rf blog-dev
|
||||
```
|
||||
|
||||
### Шаг 4: Проверяем что всё работает
|
||||
|
||||
```bash
|
||||
cd ~/projects/blog
|
||||
|
||||
# Переключаемся на dev и запускаем сервер
|
||||
git checkout dev
|
||||
hugo server -D --bind 0.0.0.0
|
||||
|
||||
# Открываем http://localhost:1313/
|
||||
# Видим dev версию сайта
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Новый workflow: шпаргалка
|
||||
|
||||
### Создаю статью
|
||||
|
||||
```bash
|
||||
cd ~/projects/blog
|
||||
|
||||
git checkout dev
|
||||
hugo new content posts/название/index.md
|
||||
|
||||
# Пишу, сохраняю, проверяю в hugo server
|
||||
git add .
|
||||
git commit -m "feat: название статьи"
|
||||
git push origin dev # → dev.blog.ru
|
||||
```
|
||||
|
||||
### Публикую статью
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull origin dev # Убеждаюсь что dev актуален
|
||||
|
||||
git checkout main
|
||||
git pull origin main # Убеждаюсь что main актуален
|
||||
git merge dev
|
||||
git push origin main # → blog.ru
|
||||
```
|
||||
|
||||
### Меняю конфигурацию
|
||||
|
||||
```bash
|
||||
git checkout dev # ВСЕ изменения только через dev!
|
||||
nano config/params.toml
|
||||
git add .
|
||||
git commit -m "feat: изменил конфиг"
|
||||
git push origin dev # Проверяю на dev.blog.ru
|
||||
|
||||
# Если всё ок
|
||||
git checkout main
|
||||
git merge dev
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Что дальше
|
||||
|
||||
Workflow выбран, окружения работают. Можно писать статьи.
|
||||
|
||||
Но есть ещё одна тема которую стоит разобрать - что делать когда что-то сломалось. Как диагностировать проблемы когда сайт вдруг начал отдавать 503, или SSL перестал работать, или webhook не срабатывает.
|
||||
|
||||
В следующей части покажу процесс диагностики на реальном примере - как я чинил `blog.ru` когда он внезапно стал недоступен из интернета.
|
||||
|
||||
---
|
||||
|
||||
**Рекомендация этой части:**
|
||||
- Одна папка `~/projects/blog`
|
||||
- Переключение веток через `git checkout`
|
||||
- Все изменения через `dev` → merge в `main`
|
||||
- Никогда не редактировать находясь на `main`
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,634 @@
|
||||
---
|
||||
title: "Блог на Hugo в K3s: часть 5 - что делать когда всё внезапно сломалось"
|
||||
date: 2026-02-17
|
||||
draft: false
|
||||
description: "Сайт работал вчера, а сегодня 503. Алгоритм диагностики Kubernetes проблем за 5 минут - от DNS до пода, без паники и танцев с бубном."
|
||||
tags: ["kubernetes", "k3s", "traefik", "debugging", "nginx", "troubleshooting"]
|
||||
categories: ["infrastructure"]
|
||||
series: ["Блог на Hugo в K3s"]
|
||||
series_order: 5
|
||||
---
|
||||
|
||||
В части 4 мы разобрались с Git workflow. Всё работает: пушишь в `dev` - видишь на тестовом окружении, мержишь в `main` - публикуется на production.
|
||||
|
||||
А потом в один прекрасный день открываешь свой сайт и видишь `503 Service Temporarily Unavailable`.
|
||||
|
||||
Вчера же все работало! Ты ничего не менял. Что произошло?
|
||||
|
||||
Добро пожаловать в мир эксплуатации Kubernetes, где проблемы тоже возникают и требуют системного подхода без паники.
|
||||
|
||||
Эта статья - алгоритм диагностики от DNS до пода. Проходишь по шагам сверху вниз, находишь проблему за 5 минут. Не гадаешь, не тыкаешь наугад - работаешь по системе.
|
||||
|
||||
---
|
||||
|
||||
## Анатомия HTTP запроса в K3s
|
||||
|
||||
Прежде чем искать проблему, нужно понять путь запроса от браузера до nginx:
|
||||
|
||||
```
|
||||
Браузер
|
||||
↓ DNS запрос
|
||||
DNS сервер (провайдер или Cloudflare)
|
||||
↓ Возвращает IP адрес
|
||||
Роутер/Файрвол (OPNsense, MikroTik)
|
||||
↓ Port Forward 443 → K3s node
|
||||
MetalLB LoadBalancer
|
||||
↓ External IP
|
||||
Traefik Ingress Controller
|
||||
↓ IngressRoute matching
|
||||
Kubernetes Service
|
||||
↓ Endpoint selection
|
||||
Pod (Nginx контейнер)
|
||||
↓ Volume mount
|
||||
NFS хранилище
|
||||
```
|
||||
|
||||
Проблема может быть на любом из этих уровней. Секрет эффективной диагностики - проверять снаружи внутрь, последовательно исключая рабочие компоненты.
|
||||
|
||||
Когда тыкаешь наугад, проверяя сначала поды, потом DNS, потом снова поды - тратишь время. Когда идёшь по алгоритму - находишь проблему за минуты.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 1: DNS - доходит ли домен до твоего IP
|
||||
|
||||
Первым делом проверяем что домен резолвится в правильный IP. Без этого дальше проверять бессмысленно - браузер просто не знает куда направлять запрос.
|
||||
|
||||
### Проверка
|
||||
|
||||
```bash
|
||||
# Проверяем DNS резолв (используй свой домен)
|
||||
dig blog.example.com +short
|
||||
|
||||
# Альтернатива если dig не установлен
|
||||
nslookup blog.example.com
|
||||
```
|
||||
|
||||
### Ожидаемый результат
|
||||
|
||||
```
|
||||
77.37.XXX.XXX
|
||||
```
|
||||
|
||||
Должен вернуться твой **публичный IP адрес** (тот который прописан в A-записи у DNS провайдера).
|
||||
|
||||
### Что может пойти не так
|
||||
|
||||
| Симптом | Причина | Как проверить | Решение |
|
||||
|---------|---------|---------------|---------|
|
||||
| Возвращается старый IP | DNS кеш не обновился | `dig blog.example.com @8.8.8.8` | Подожди TTL (обычно 300-3600 сек) |
|
||||
| `NXDOMAIN` ошибка | Домен не делегирован | Проверь NS записи у регистратора | Настрой DNS правильно |
|
||||
| Возвращается `127.0.0.1` | Локальный override | `cat /etc/hosts \| grep blog` | Удали строку из /etc/hosts |
|
||||
| Возвращается несколько IP | Round-robin DNS | Проверь все ли IP твои | Удали лишние A-записи |
|
||||
|
||||
Если DNS правильный - идём дальше.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 2: Внешний доступ - доходит ли запрос до сервера
|
||||
|
||||
Теперь проверяем что запрос физически доходит до сервера. DNS может быть правильным, но файрвол может блокировать трафик.
|
||||
|
||||
### Проверка
|
||||
|
||||
```bash
|
||||
# Пробуем подключиться извне (важно - НЕ из локальной сети!)
|
||||
curl -v https://blog.example.com 2>&1 | head -30
|
||||
```
|
||||
|
||||
**Важно:** Запускай эту команду с **внешнего** сервера или используй мобильный интернет. Тест из локальной сети ничего не докажет - можешь обходить файрвол.
|
||||
|
||||
### Ситуация А: Connection refused или timeout
|
||||
|
||||
```
|
||||
curl: (7) Failed to connect to blog.example.com port 443: Connection refused
|
||||
```
|
||||
|
||||
Запрос вообще не дошёл до сервера. Проблема на сетевом уровне.
|
||||
|
||||
**Возможные причины:**
|
||||
|
||||
**1. Порт 443 закрыт на файрволе/роутере**
|
||||
|
||||
Проверь Port Forward правила на OPNsense/MikroTik. Должно быть:
|
||||
```
|
||||
WAN:443 → 192.168.X.X:443 (IP любой K3s ноды)
|
||||
```
|
||||
|
||||
**2. MetalLB не назначил External IP для Traefik**
|
||||
|
||||
```bash
|
||||
# Проверяем MetalLB
|
||||
kubectl get svc -n traefik traefik
|
||||
|
||||
# Ожидаемый результат
|
||||
NAME TYPE EXTERNAL-IP PORT(S)
|
||||
traefik LoadBalancer 192.168.X.X 80:30080/TCP,443:30443/TCP
|
||||
```
|
||||
|
||||
Если `EXTERNAL-IP` показывает `<pending>` - MetalLB не работает или пул IP адресов не настроен.
|
||||
|
||||
**3. Traefik под не запущен**
|
||||
|
||||
```bash
|
||||
# Проверяем что Traefik работает
|
||||
kubectl get pods -n traefik
|
||||
|
||||
# Должны быть все Running
|
||||
NAME READY STATUS
|
||||
traefik-xxxxxxxxxx-xxxxx 1/1 Running
|
||||
```
|
||||
|
||||
### Ситуация Б: TLS handshake прошёл, но 503
|
||||
|
||||
```
|
||||
< HTTP/2 503
|
||||
< content-type: text/plain; charset=utf-8
|
||||
< content-length: 20
|
||||
no available server
|
||||
```
|
||||
|
||||
Отлично - наша ситуация! Traefik работает, SSL сертификат отдаёт, но дальше запрос упирается в стену.
|
||||
|
||||
Сообщение `no available server` означает что Traefik **нашёл роутер**, но **не нашёл живой бэкенд** за ним.
|
||||
|
||||
Проблема внутри кластера. Идём глубже.
|
||||
|
||||
### Ситуация В: SSL certificate problem
|
||||
|
||||
```
|
||||
curl: (60) SSL certificate problem: unable to get local issuer certificate
|
||||
```
|
||||
|
||||
Сертификат невалидный или не выпущен.
|
||||
|
||||
```bash
|
||||
# Проверяем Certificate объект (используй свой namespace)
|
||||
kubectl get certificate -n blog
|
||||
|
||||
# Должно быть READY=True
|
||||
NAME READY SECRET AGE
|
||||
blog-tls True blog-tls 2d
|
||||
```
|
||||
|
||||
Если `READY=False` - cert-manager не смог выпустить сертификат.
|
||||
|
||||
```bash
|
||||
# Смотрим что пошло не так
|
||||
kubectl describe certificate blog-tls -n blog
|
||||
|
||||
# Ищем секцию Events внизу вывода - там описание проблемы
|
||||
```
|
||||
|
||||
### Главное: Запрос доходит до Traefik
|
||||
|
||||
```bash
|
||||
# Проверка (с ВНЕШНЕГО сервера!)
|
||||
curl -I https://blog.example.com
|
||||
|
||||
# Ожидаемый результат (любой из двух)
|
||||
HTTP/2 200 # Всё работает
|
||||
HTTP/2 503 # Traefik работает, но бэкенд недоступен
|
||||
|
||||
# Если connection refused/timeout - проблема в сети (см. выше)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Шаг 3: Traefik - правильно ли маршрутизируется трафик
|
||||
|
||||
Traefik получил запрос на твой домен. Что он с ним делает? Смотрим логи.
|
||||
|
||||
### Проверка логов Traefik
|
||||
|
||||
```bash
|
||||
# Смотрим последние 50 строк логов Traefik
|
||||
kubectl logs -n traefik deployment/traefik --tail=50
|
||||
|
||||
# Фильтруем только свой домен (убираем шум от других сервисов)
|
||||
kubectl logs -n traefik deployment/traefik --tail=100 | grep blog.example
|
||||
```
|
||||
|
||||
### Что искать в логах
|
||||
|
||||
**Нормальный запрос:**
|
||||
|
||||
```json
|
||||
{
|
||||
"request": "GET / HTTP/2.0",
|
||||
"status": 200,
|
||||
"size": 8994,
|
||||
"router": "blog-blog-https-xxxxx@kubernetescrd",
|
||||
"service": "blog-nginx-blog@kubernetescrd",
|
||||
"backend": "http://10.42.2.40:80",
|
||||
"duration": 12
|
||||
}
|
||||
```
|
||||
|
||||
Ключевые поля:
|
||||
- **router:** Traefik нашёл нужный IngressRoute (`blog-blog-https`)
|
||||
- **backend:** IP пода nginx куда проксируется запрос (`10.42.2.40:80`)
|
||||
- **status:** HTTP код ответа от nginx (`200` = всё хорошо)
|
||||
|
||||
**Проблемный запрос:**
|
||||
|
||||
```json
|
||||
{
|
||||
"request": "GET / HTTP/2.0",
|
||||
"status": 503,
|
||||
"router": "blog-blog-https-xxxxx@kubernetescrd",
|
||||
"error": "no available server"
|
||||
}
|
||||
```
|
||||
|
||||
Traefik нашёл роутер, но поле `backend` отсутствует - под недоступен или не существует.
|
||||
|
||||
### Проверяем список IngressRoute
|
||||
|
||||
```bash
|
||||
# Смотрим все IngressRoute в кластере
|
||||
kubectl get ingressroute -A
|
||||
|
||||
# Фильтруем только свой домен
|
||||
kubectl get ingressroute -A | grep blog.example
|
||||
```
|
||||
|
||||
**Важный момент:** Если один и тот же домен прописан в **двух разных IngressRoute** из разных namespace - Traefik будет балансировать между ними.
|
||||
|
||||
Например:
|
||||
|
||||
```bash
|
||||
NAMESPACE NAME AGE
|
||||
blog blog-https 10d # СТАРЫЙ namespace
|
||||
blog-new blog-https 2d # НОВЫЙ namespace
|
||||
```
|
||||
|
||||
Оба IngressRoute имеют `match: Host('blog.example.com')`. Traefik видит оба, честно балансирует трафик 50/50.
|
||||
|
||||
Если один из бэкендов мёртв - половина запросов уходит в пустоту. 503 через раз.
|
||||
|
||||
**Решение:** Удалить старый IngressRoute:
|
||||
|
||||
```bash
|
||||
# Удаляем дубль из старого namespace
|
||||
kubectl delete ingressroute blog-https blog-http -n blog
|
||||
```
|
||||
|
||||
### Проверяем синтаксис match
|
||||
|
||||
Traefik очень требователен к синтаксису. Частая ошибка - забыть backticks или скобки.
|
||||
|
||||
**Неправильно:**
|
||||
```yaml
|
||||
match: Host(blog.example.com) # Нет backticks
|
||||
match: Host `blog.example.com` # Нет скобок вокруг Host
|
||||
match: Host("blog.example.com") # Двойные кавычки вместо backticks
|
||||
```
|
||||
|
||||
**Правильно:**
|
||||
```yaml
|
||||
match: Host(`blog.example.com`)
|
||||
```
|
||||
|
||||
Проверяем:
|
||||
|
||||
```bash
|
||||
# Смотрим манифест IngressRoute
|
||||
kubectl get ingressroute blog-https -n blog -o yaml | grep match:
|
||||
|
||||
# Должно быть со скобками и backticks
|
||||
match: Host(`blog.example.com`)
|
||||
```
|
||||
|
||||
### Главное: Traefik нашёл роутер
|
||||
|
||||
```bash
|
||||
# Проверка
|
||||
kubectl logs -n traefik deployment/traefik --tail=50 | grep blog.example
|
||||
|
||||
# Ожидаемый результат - есть строки с "router": "blog-blog-https"
|
||||
# Если router не найден - проблема в IngressRoute match синтаксисе
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Шаг 4: Service - видит ли он поды
|
||||
|
||||
Traefik нашёл роутер, проксирует трафик на Service. Но Service может не видеть поды если selector неправильный.
|
||||
|
||||
### Проверка endpoints
|
||||
|
||||
```bash
|
||||
# Смотрим endpoints для Service (используй своё имя Service)
|
||||
kubectl get endpoints nginx -n blog
|
||||
|
||||
# Ожидаемый результат - НЕ пустой список IP
|
||||
NAME ENDPOINTS
|
||||
nginx 10.42.0.44:80,10.42.2.40:80
|
||||
```
|
||||
|
||||
Если видишь `<none>` - Service не нашёл ни одного пода. Две возможные причины.
|
||||
|
||||
### Причина 1: Selector не совпадает с labels
|
||||
|
||||
```bash
|
||||
# Смотрим selector у Service
|
||||
kubectl get svc nginx -n blog -o yaml | grep -A3 "selector:"
|
||||
|
||||
# Вывод
|
||||
selector:
|
||||
app: nginx
|
||||
|
||||
# Смотрим labels у подов
|
||||
kubectl get pods -n blog --show-labels | grep nginx
|
||||
|
||||
# Вывод
|
||||
nginx-xxxxxxxxxx-xxxxx 1/1 Running app=nginx-old
|
||||
```
|
||||
|
||||
Видишь проблему? Service ищет `app: nginx`, а под помечен `app: nginx-old`. Не совпадает.
|
||||
|
||||
**Решение:** Исправить Deployment или Service чтобы labels совпадали.
|
||||
|
||||
### Причина 2: Поды не Running
|
||||
|
||||
```bash
|
||||
# Смотрим статус подов
|
||||
kubectl get pods -n blog
|
||||
|
||||
# Видим
|
||||
NAME READY STATUS
|
||||
nginx-xxxxxxxxxx-xxxxx 0/1 CreateContainerError
|
||||
```
|
||||
|
||||
Под существует, но не работает. Service правильно не включает его в endpoints. Идём в следующий шаг - разбираемся почему под не запускается.
|
||||
|
||||
### Главное: Service видит поды
|
||||
|
||||
```bash
|
||||
# Проверка
|
||||
kubectl get endpoints nginx -n blog
|
||||
|
||||
# Ожидаемый результат - НЕ пустой
|
||||
nginx 10.42.0.44:80,10.42.2.40:80
|
||||
|
||||
# Если <none> - проблема в селекторах или поды не Running
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Шаг 5: Pod - что происходит внутри контейнера
|
||||
|
||||
Самый глубокий уровень. Под не запускается или падает в цикле перезапусков.
|
||||
|
||||
### Проверка статуса подов
|
||||
|
||||
```bash
|
||||
# Смотрим все поды в namespace
|
||||
kubectl get pods -n blog
|
||||
|
||||
# Фильтруем только nginx
|
||||
kubectl get pods -n blog | grep nginx
|
||||
```
|
||||
|
||||
**Возможные статусы проблем:**
|
||||
|
||||
### CreateContainerError
|
||||
|
||||
Контейнер вообще не может стартануть. Обычно проблема с volumes или образом.
|
||||
|
||||
```bash
|
||||
# Смотрим детали пода (используй своё имя пода)
|
||||
kubectl describe pod nginx-xxxxxxxxxx-xxxxx -n blog | tail -30
|
||||
```
|
||||
|
||||
Ищем секцию `Events` внизу вывода. Там будет описание проблемы:
|
||||
|
||||
**Пример 1: PVC не примонтировался**
|
||||
|
||||
```
|
||||
Events:
|
||||
Warning FailedMount MountVolume.SetUp failed for volume "blog-public-pvc":
|
||||
mount failed: mount.nfs: Connection timed out
|
||||
```
|
||||
|
||||
NFS хранилище недоступно. Возможные причины:
|
||||
- NFS сервер выключен или перезагружается
|
||||
- Неправильный IP или путь в PersistentVolume
|
||||
- Файрвол блокирует NFS трафик (порт 2049)
|
||||
|
||||
**Пример 2: Образ не скачался**
|
||||
|
||||
```
|
||||
Events:
|
||||
Warning Failed Failed to pull image "nginx:latest": rpc error: code = Unknown
|
||||
```
|
||||
|
||||
Контейнер не может скачать образ. Обычно это означает что `imagePullPolicy: Never`, а образ не импортирован на ноду.
|
||||
|
||||
```bash
|
||||
# Проверяем что образ есть на ноде (используй IP своей worker ноды)
|
||||
ssh user@192.168.X.X "sudo k3s crictl images | grep nginx"
|
||||
```
|
||||
|
||||
Если образа нет - импортируй его через `k3s ctr images import`.
|
||||
|
||||
**Пример 3: ConfigMap не найден**
|
||||
|
||||
```
|
||||
Events:
|
||||
Warning FailedMount ConfigMap "nginx-config" not found
|
||||
```
|
||||
|
||||
Deployment ссылается на несуществующий ConfigMap.
|
||||
|
||||
```bash
|
||||
# Проверяем что ConfigMap существует
|
||||
kubectl get configmap -n blog | grep nginx-config
|
||||
```
|
||||
|
||||
Если нет - создай или исправь имя в Deployment.
|
||||
|
||||
### CrashLoopBackOff
|
||||
|
||||
Контейнер запускается, но сразу падает. Смотрим логи **предыдущего** запуска:
|
||||
|
||||
```bash
|
||||
# Логи последнего упавшего контейнера
|
||||
kubectl logs nginx-xxxxxxxxxx-xxxxx -n blog --previous
|
||||
```
|
||||
|
||||
**Пример: Nginx падает из-за неправильного конфига**
|
||||
|
||||
```
|
||||
nginx: [emerg] unexpected "}" in /etc/nginx/nginx.conf:15
|
||||
nginx: configuration file /etc/nginx/nginx.conf test failed
|
||||
```
|
||||
|
||||
Синтаксическая ошибка в `nginx.conf`. Проверяем ConfigMap:
|
||||
|
||||
```bash
|
||||
# Смотрим содержимое конфига
|
||||
kubectl get configmap nginx-config -n blog -o yaml
|
||||
```
|
||||
|
||||
Находим ошибку, исправляем, применяем. Под перезапустится автоматически.
|
||||
|
||||
### Главное: Под работает
|
||||
|
||||
```bash
|
||||
# Проверка
|
||||
kubectl get pods -n blog | grep nginx
|
||||
|
||||
# Ожидаемый результат - все Running
|
||||
nginx-xxxxxxxxxx-xxxxx 1/1 Running 0 2d
|
||||
|
||||
# Если не Running - смотри troubleshooting выше
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Шаг 6: Контент - есть ли файлы для отдачи
|
||||
|
||||
Под работает, Service видит его, Traefik проксирует трафик. Но сайт отдаёт `404 Not Found` или пустую страницу.
|
||||
|
||||
Проблема: Hugo Builder не записал файлы на NFS или записал не туда.
|
||||
|
||||
### Проверка
|
||||
|
||||
```bash
|
||||
# Заходим в под nginx (используй своё имя пода)
|
||||
kubectl exec -it nginx-xxxxxxxxxx-xxxxx -n blog -- sh
|
||||
|
||||
# Внутри пода смотрим что примонтировалось
|
||||
ls -la /usr/share/nginx/html/
|
||||
|
||||
# Должен быть index.html и папки posts, tags, etc
|
||||
```
|
||||
|
||||
**Если директория пустая** - Hugo Builder не сработал. Проверяем его логи:
|
||||
|
||||
```bash
|
||||
# Логи Hugo Builder
|
||||
kubectl logs -n blog deployment/hugo-builder-prod --tail=50
|
||||
```
|
||||
|
||||
Ищем строку `Build successful!` и список созданных файлов. Если её нет:
|
||||
|
||||
1. **Webhook не сработал** - проверь настройки webhook в Gitea
|
||||
2. **Hugo упал с ошибкой** - читай логи выше, смотри на что ругается
|
||||
3. **Собрал в другую директорию** - проверь переменную `OUTPUT_DIR` в build.sh
|
||||
|
||||
### Главное: Контент на месте
|
||||
|
||||
```bash
|
||||
# Проверка (используй своё имя пода)
|
||||
kubectl exec -it nginx-xxxxxxxxxx-xxxxx -n blog -- ls /usr/share/nginx/html/ | head -5
|
||||
|
||||
# Ожидаемый результат
|
||||
index.html
|
||||
posts/
|
||||
tags/
|
||||
categories/
|
||||
|
||||
# Если пусто - Hugo Builder не отработал (см. выше)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Быстрый чеклист для любой проблемы
|
||||
|
||||
Сохрани эту последовательность - она работает для 95% проблем:
|
||||
|
||||
```
|
||||
[ ] DNS: dig домен → правильный IP?
|
||||
[ ] Сеть: curl -v https://домен → доходит до Traefik?
|
||||
[ ] MetalLB: kubectl get svc -n traefik → External IP назначен?
|
||||
[ ] Traefik: kubectl get pods -n traefik → Running?
|
||||
[ ] IngressRoute: kubectl get ingressroute -A | grep домен → нет дублей?
|
||||
[ ] Match синтаксис: Host(`домен`) со скобками и backticks?
|
||||
[ ] Endpoints: kubectl get endpoints -n namespace → не пустые?
|
||||
[ ] Selector: labels подов совпадают с selector Service?
|
||||
[ ] Pods: kubectl get pods -n namespace → все Running?
|
||||
[ ] PVC: kubectl get pvc -n namespace → все Bound?
|
||||
[ ] Контент: kubectl exec ls /usr/share/nginx/html → файлы есть?
|
||||
```
|
||||
|
||||
Проходишь по списку сверху вниз. Останавливаешься на первом `[ ]` где что-то не так. Чинишь. Проверяешь снова.
|
||||
|
||||
Не прыгай хаотично между уровнями. Алгоритм экономит время.
|
||||
|
||||
---
|
||||
|
||||
## Реальный пример: 503 через раз
|
||||
|
||||
Мой сайт отдавал `503` примерно в 50% запросов. Половина запросов работала, половина нет.
|
||||
|
||||
Прошёл по алгоритму:
|
||||
|
||||
1. ✅ DNS - правильный IP
|
||||
2. ✅ Сеть - Traefik отвечает
|
||||
3. ✅ MetalLB - External IP назначен
|
||||
4. ✅ Traefik - поды Running
|
||||
5. ❌ IngressRoute - **два роутера на один домен**
|
||||
|
||||
```bash
|
||||
kubectl get ingressroute -A | grep oakazanin
|
||||
|
||||
NAMESPACE NAME
|
||||
blog blog-https # СТАРЫЙ, бэкенд в CreateContainerError
|
||||
oakazanin blog-https # НОВЫЙ, работает
|
||||
```
|
||||
|
||||
Traefik видел два роутера, честно балансировал трафик 50/50. Каждый второй запрос улетал в мёртвый `blog/nginx`.
|
||||
|
||||
Диагноз поставлен за 3 минуты. Лечение - одна команда:
|
||||
|
||||
```bash
|
||||
# Удаляем дубль из старого namespace
|
||||
kubectl delete ingressroute blog-https blog-http -n blog
|
||||
```
|
||||
|
||||
Мораль: **всегда чисти за собой**. Старые namespace с нерабочими сервисами - источник неочевидных проблем.
|
||||
|
||||
---
|
||||
|
||||
## Откат и cleanup
|
||||
|
||||
Если в процессе диагностики что-то сломал ещё больше - откатываемся:
|
||||
|
||||
```bash
|
||||
# Восстанавливаем предыдущую версию манифеста
|
||||
kubectl apply -f nginx-deployment.yaml
|
||||
|
||||
# Перезапускаем поды принудительно
|
||||
kubectl rollout restart deployment/nginx -n blog
|
||||
|
||||
# Смотрим что изменения применились
|
||||
kubectl rollout status deployment/nginx -n blog
|
||||
```
|
||||
|
||||
**Золотое правило:** Перед экспериментами делай бэкапы манифестов:
|
||||
|
||||
```bash
|
||||
# Экспортируем текущее состояние с датой
|
||||
kubectl get deployment,service,ingressroute -n blog -o yaml > backup-$(date +%Y%m%d).yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Что дальше
|
||||
|
||||
Ты умеешь диагностировать проблемы. Но лучше их вообще не создавать.
|
||||
|
||||
В следующей части покажу как правильно мигрировать сервисы между namespace - без даунтайма, дублей IngressRoute и других сюрпризов которые приводят к 503.
|
||||
|
||||
Разберём реальный пример: переносим Gitea между namespace с NFS данными, получаем новый SSL за 32 секунды, и удаляем старый namespace навсегда.
|
||||
|
||||
---
|
||||
|
||||
**Стек этой части:**
|
||||
- Traefik 2.11 IngressRoute
|
||||
- Kubernetes 1.30 (K3s)
|
||||
- kubectl CLI
|
||||
- curl для внешних проверок
|
||||
- dig для DNS диагностики
|
||||
|
Before Width: | Height: | Size: 663 KiB |
|
Before Width: | Height: | Size: 672 KiB |
|
Before Width: | Height: | Size: 741 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -3,13 +3,9 @@ title: "K3s HA для homelab: архитектура без боли"
|
||||
date: 2025-10-14
|
||||
draft: false
|
||||
description: "Полноценный Kubernetes в бинарнике на 50MB вместо 1.5GB зависимостей. Разбираем архитектуру K3s HA кластера: почему именно 3 master ноды, зачем embedded etcd и сколько ресурсов закладывать."
|
||||
summary: "Kubernetes слишком тяжёлый, Docker Swarm мёртв, а хочется нормальный кластер для экспериментов. K3s решает эту проблему - полноценный Kubernetes в 50MB. Разберём архитектуру HA кластера без боли."
|
||||
tags: ["kubernetes", "k3s", "homelab", "proxmox", "architecture", "ha", "devops"]
|
||||
series: ["K3s HA кластер для homelab"]
|
||||
series_order: 1
|
||||
# seriesOpened: false
|
||||
# showTableOfContents: true
|
||||
showAuthor: true
|
||||
---
|
||||
|
||||
Kubernetes слишком тяжёлый, Docker Swarm мёртв, а хочется нормальный кластер для экспериментов. Знакомо? K3s решает эту проблему - полноценный Kubernetes в бинарнике на 50MB вместо 1.5GB зависимостей. Но без правильного планирования вы получите нестабильную конструкцию, которая падает в самый неподходящий момент.
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -1,14 +1,11 @@
|
||||
---
|
||||
title: "Подготовить инфраструктуру для K3s в Proxmox"
|
||||
title: "K3s HA для homelab: Готовим инфраструктуру в Proxmox"
|
||||
date: 2025-10-21
|
||||
draft: false
|
||||
description: "Создаём 5 VM в Proxmox с Debian 12, настраиваем сеть, отключаем swap и готовим систему для K3s. Пошаговая инструкция без сюрпризов."
|
||||
summary: "Архитектура спланирована, ресурсы посчитаны - пора создавать виртуальные машины. Подготовим 5 VM в Proxmox и настроим ОС так, чтобы K3s установился без сюрпризов."
|
||||
tags: ["kubernetes", "k3s", "homelab", "proxmox", "infrastructure", "debian", "devops"]
|
||||
series: ["K3s HA кластер для homelab"]
|
||||
series_order: 2
|
||||
# seriesOpened: true
|
||||
showTableOfContents: true
|
||||
---
|
||||
|
||||
Архитектура спланирована, ресурсы посчитаны - пора создавать виртуальные машины. В этой статье подготовим 5 VM в Proxmox и настроим ОС так, чтобы K3s установился без сюрпризов.
|
||||
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 3.6 MiB After Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -1,14 +1,11 @@
|
||||
---
|
||||
title: "Установить K3s HA кластер"
|
||||
title: "K3s HA для homelab: Ставим K3s HA кластер"
|
||||
date: 2025-11-02
|
||||
draft: false
|
||||
description: "Один curl-скрипт на каждую ноду - и через 15 минут у вас работающий HA-кластер. Устанавливаем K3s на 3 master и 2 worker ноды, настраиваем kubectl."
|
||||
summary: "Инфраструктура готова: 5 VM работают, ОС настроена, порты открыты. Пора устанавливать K3s. Один curl-скрипт на каждую ноду - и через 15 минут у вас работающий HA-кластер."
|
||||
tags: ["kubernetes", "k3s", "homelab", "installation", "ha", "etcd", "devops"]
|
||||
series: ["K3s HA кластер для homelab"]
|
||||
series_order: 3
|
||||
# seriesOpened: true
|
||||
showTableOfContents: true
|
||||
---
|
||||
|
||||
Инфраструктура готова: 5 VM работают, ОС настроена, порты открыты. Пора устанавливать K3s. Один curl-скрипт на каждую ноду - и через 15 минут у вас работающий HA-кластер.
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |