[{"content":"","date":"17 февраля 2026","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"17 февраля 2026","externalUrl":null,"permalink":"/tags/debugging/","section":"Tags","summary":"","title":"Debugging","type":"tags"},{"content":"","date":"17 февраля 2026","externalUrl":null,"permalink":"/categories/infrastructure/","section":"Categories","summary":"","title":"Infrastructure","type":"categories"},{"content":"","date":"17 февраля 2026","externalUrl":null,"permalink":"/tags/k3s/","section":"Tags","summary":"","title":"K3s","type":"tags"},{"content":"","date":"17 февраля 2026","externalUrl":null,"permalink":"/tags/kubernetes/","section":"Tags","summary":"","title":"Kubernetes","type":"tags"},{"content":"","date":"17 февраля 2026","externalUrl":null,"permalink":"/tags/nginx/","section":"Tags","summary":"","title":"Nginx","type":"tags"},{"content":"","date":"17 февраля 2026","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"17 февраля 2026","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"},{"content":"","date":"17 февраля 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"","date":"17 февраля 2026","externalUrl":null,"permalink":"/tags/traefik/","section":"Tags","summary":"","title":"Traefik","type":"tags"},{"content":"","date":"17 февраля 2026","externalUrl":null,"permalink":"/tags/troubleshooting/","section":"Tags","summary":"","title":"Troubleshooting","type":"tags"},{"content":"","date":"17 февраля 2026","externalUrl":null,"permalink":"/series/%D0%B1%D0%BB%D0%BE%D0%B3-%D0%BD%D0%B0-hugo-%D0%B2-k3s/","section":"Series","summary":"","title":"Блог На Hugo В K3s","type":"series"},{"content":"В части 4 мы разобрались с Git workflow. Всё работает: пушишь в dev - видишь на тестовом окружении, мержишь в main - публикуется на production.\nА потом в один прекрасный день открываешь свой сайт и видишь 503 Service Temporarily Unavailable.\nВчера же все работало! Ты ничего не менял. Что произошло?\nДобро пожаловать в мир эксплуатации Kubernetes, где проблемы тоже возникают и требуют системного подхода без паники.\nЭта статья - алгоритм диагностики от DNS до пода. Проходишь по шагам сверху вниз, находишь проблему за 5 минут. Не гадаешь, не тыкаешь наугад - работаешь по системе.\nАнатомия HTTP запроса в K3s # Прежде чем искать проблему, нужно понять путь запроса от браузера до nginx:\nБраузер ↓ 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 хранилище Проблема может быть на любом из этих уровней. Секрет эффективной диагностики - проверять снаружи внутрь, последовательно исключая рабочие компоненты.\nКогда тыкаешь наугад, проверяя сначала поды, потом DNS, потом снова поды - тратишь время. Когда идёшь по алгоритму - находишь проблему за минуты.\nШаг 1: DNS - доходит ли домен до твоего IP # Первым делом проверяем что домен резолвится в правильный IP. Без этого дальше проверять бессмысленно - браузер просто не знает куда направлять запрос.\nПроверка # # Проверяем DNS резолв (используй свой домен) dig blog.example.com +short # Альтернатива если dig не установлен nslookup blog.example.com Ожидаемый результат # 77.37.XXX.XXX Должен вернуться твой публичный IP адрес (тот который прописан в A-записи у DNS провайдера).\nЧто может пойти не так # Симптом Причина Как проверить Решение Возвращается старый 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 правильный - идём дальше.\nШаг 2: Внешний доступ - доходит ли запрос до сервера # Теперь проверяем что запрос физически доходит до сервера. DNS может быть правильным, но файрвол может блокировать трафик.\nПроверка # # Пробуем подключиться извне (важно - НЕ из локальной сети!) curl -v https://blog.example.com 2\u0026gt;\u0026amp;1 | head -30 Важно: Запускай эту команду с внешнего сервера или используй мобильный интернет. Тест из локальной сети ничего не докажет - можешь обходить файрвол.\nСитуация А: Connection refused или timeout # curl: (7) Failed to connect to blog.example.com port 443: Connection refused Запрос вообще не дошёл до сервера. Проблема на сетевом уровне.\nВозможные причины:\n1. Порт 443 закрыт на файрволе/роутере\nПроверь Port Forward правила на OPNsense/MikroTik. Должно быть:\nWAN:443 → 192.168.X.X:443 (IP любой K3s ноды) 2. MetalLB не назначил External IP для Traefik\n# Проверяем 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 показывает \u0026lt;pending\u0026gt; - MetalLB не работает или пул IP адресов не настроен.\n3. Traefik под не запущен\n# Проверяем что Traefik работает kubectl get pods -n traefik # Должны быть все Running NAME READY STATUS traefik-xxxxxxxxxx-xxxxx 1/1 Running Ситуация Б: TLS handshake прошёл, но 503 # \u0026lt; HTTP/2 503 \u0026lt; content-type: text/plain; charset=utf-8 \u0026lt; content-length: 20 no available server Отлично - наша ситуация! Traefik работает, SSL сертификат отдаёт, но дальше запрос упирается в стену.\nСообщение no available server означает что Traefik нашёл роутер, но не нашёл живой бэкенд за ним.\nПроблема внутри кластера. Идём глубже.\nСитуация В: SSL certificate problem # curl: (60) SSL certificate problem: unable to get local issuer certificate Сертификат невалидный или не выпущен.\n# Проверяем Certificate объект (используй свой namespace) kubectl get certificate -n blog # Должно быть READY=True NAME READY SECRET AGE blog-tls True blog-tls 2d Если READY=False - cert-manager не смог выпустить сертификат.\n# Смотрим что пошло не так kubectl describe certificate blog-tls -n blog # Ищем секцию Events внизу вывода - там описание проблемы Главное: Запрос доходит до Traefik # # Проверка (с ВНЕШНЕГО сервера!) curl -I https://blog.example.com # Ожидаемый результат (любой из двух) HTTP/2 200 # Всё работает HTTP/2 503 # Traefik работает, но бэкенд недоступен # Если connection refused/timeout - проблема в сети (см. выше) Шаг 3: Traefik - правильно ли маршрутизируется трафик # Traefik получил запрос на твой домен. Что он с ним делает? Смотрим логи.\nПроверка логов Traefik # # Смотрим последние 50 строк логов Traefik kubectl logs -n traefik deployment/traefik --tail=50 # Фильтруем только свой домен (убираем шум от других сервисов) kubectl logs -n traefik deployment/traefik --tail=100 | grep blog.example Что искать в логах # Нормальный запрос:\n{ \u0026#34;request\u0026#34;: \u0026#34;GET / HTTP/2.0\u0026#34;, \u0026#34;status\u0026#34;: 200, \u0026#34;size\u0026#34;: 8994, \u0026#34;router\u0026#34;: \u0026#34;blog-blog-https-xxxxx@kubernetescrd\u0026#34;, \u0026#34;service\u0026#34;: \u0026#34;blog-nginx-blog@kubernetescrd\u0026#34;, \u0026#34;backend\u0026#34;: \u0026#34;http://10.42.2.40:80\u0026#34;, \u0026#34;duration\u0026#34;: 12 } Ключевые поля:\nrouter: Traefik нашёл нужный IngressRoute (blog-blog-https) backend: IP пода nginx куда проксируется запрос (10.42.2.40:80) status: HTTP код ответа от nginx (200 = всё хорошо) Проблемный запрос:\n{ \u0026#34;request\u0026#34;: \u0026#34;GET / HTTP/2.0\u0026#34;, \u0026#34;status\u0026#34;: 503, \u0026#34;router\u0026#34;: \u0026#34;blog-blog-https-xxxxx@kubernetescrd\u0026#34;, \u0026#34;error\u0026#34;: \u0026#34;no available server\u0026#34; } Traefik нашёл роутер, но поле backend отсутствует - под недоступен или не существует.\nПроверяем список IngressRoute # # Смотрим все IngressRoute в кластере kubectl get ingressroute -A # Фильтруем только свой домен kubectl get ingressroute -A | grep blog.example Важный момент: Если один и тот же домен прописан в двух разных IngressRoute из разных namespace - Traefik будет балансировать между ними.\nНапример:\nNAMESPACE NAME AGE blog blog-https 10d # СТАРЫЙ namespace blog-new blog-https 2d # НОВЫЙ namespace Оба IngressRoute имеют match: Host('blog.example.com'). Traefik видит оба, честно балансирует трафик 50/50.\nЕсли один из бэкендов мёртв - половина запросов уходит в пустоту. 503 через раз.\nРешение: Удалить старый IngressRoute:\n# Удаляем дубль из старого namespace kubectl delete ingressroute blog-https blog-http -n blog Проверяем синтаксис match # Traefik очень требователен к синтаксису. Частая ошибка - забыть backticks или скобки.\nНеправильно:\nmatch: Host(blog.example.com) # Нет backticks match: Host `blog.example.com` # Нет скобок вокруг Host match: Host(\u0026#34;blog.example.com\u0026#34;) # Двойные кавычки вместо backticks Правильно:\nmatch: Host(`blog.example.com`) Проверяем:\n# Смотрим манифест IngressRoute kubectl get ingressroute blog-https -n blog -o yaml | grep match: # Должно быть со скобками и backticks match: Host(`blog.example.com`) Главное: Traefik нашёл роутер # # Проверка kubectl logs -n traefik deployment/traefik --tail=50 | grep blog.example # Ожидаемый результат - есть строки с \u0026#34;router\u0026#34;: \u0026#34;blog-blog-https\u0026#34; # Если router не найден - проблема в IngressRoute match синтаксисе Шаг 4: Service - видит ли он поды # Traefik нашёл роутер, проксирует трафик на Service. Но Service может не видеть поды если selector неправильный.\nПроверка endpoints # # Смотрим endpoints для Service (используй своё имя Service) kubectl get endpoints nginx -n blog # Ожидаемый результат - НЕ пустой список IP NAME ENDPOINTS nginx 10.42.0.44:80,10.42.2.40:80 Если видишь \u0026lt;none\u0026gt; - Service не нашёл ни одного пода. Две возможные причины.\nПричина 1: Selector не совпадает с labels # # Смотрим selector у Service kubectl get svc nginx -n blog -o yaml | grep -A3 \u0026#34;selector:\u0026#34; # Вывод 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. Не совпадает.\nРешение: Исправить Deployment или Service чтобы labels совпадали.\nПричина 2: Поды не Running # # Смотрим статус подов kubectl get pods -n blog # Видим NAME READY STATUS nginx-xxxxxxxxxx-xxxxx 0/1 CreateContainerError Под существует, но не работает. Service правильно не включает его в endpoints. Идём в следующий шаг - разбираемся почему под не запускается.\nГлавное: Service видит поды # # Проверка kubectl get endpoints nginx -n blog # Ожидаемый результат - НЕ пустой nginx 10.42.0.44:80,10.42.2.40:80 # Если \u0026lt;none\u0026gt; - проблема в селекторах или поды не Running Шаг 5: Pod - что происходит внутри контейнера # Самый глубокий уровень. Под не запускается или падает в цикле перезапусков.\nПроверка статуса подов # # Смотрим все поды в namespace kubectl get pods -n blog # Фильтруем только nginx kubectl get pods -n blog | grep nginx Возможные статусы проблем:\nCreateContainerError # Контейнер вообще не может стартануть. Обычно проблема с volumes или образом.\n# Смотрим детали пода (используй своё имя пода) kubectl describe pod nginx-xxxxxxxxxx-xxxxx -n blog | tail -30 Ищем секцию Events внизу вывода. Там будет описание проблемы:\nПример 1: PVC не примонтировался\nEvents: Warning FailedMount MountVolume.SetUp failed for volume \u0026#34;blog-public-pvc\u0026#34;: mount failed: mount.nfs: Connection timed out NFS хранилище недоступно. Возможные причины:\nNFS сервер выключен или перезагружается Неправильный IP или путь в PersistentVolume Файрвол блокирует NFS трафик (порт 2049) Пример 2: Образ не скачался\nEvents: Warning Failed Failed to pull image \u0026#34;nginx:latest\u0026#34;: rpc error: code = Unknown Контейнер не может скачать образ. Обычно это означает что imagePullPolicy: Never, а образ не импортирован на ноду.\n# Проверяем что образ есть на ноде (используй IP своей worker ноды) ssh user@192.168.X.X \u0026#34;sudo k3s crictl images | grep nginx\u0026#34; Если образа нет - импортируй его через k3s ctr images import.\nПример 3: ConfigMap не найден\nEvents: Warning FailedMount ConfigMap \u0026#34;nginx-config\u0026#34; not found Deployment ссылается на несуществующий ConfigMap.\n# Проверяем что ConfigMap существует kubectl get configmap -n blog | grep nginx-config Если нет - создай или исправь имя в Deployment.\nCrashLoopBackOff # Контейнер запускается, но сразу падает. Смотрим логи предыдущего запуска:\n# Логи последнего упавшего контейнера kubectl logs nginx-xxxxxxxxxx-xxxxx -n blog --previous Пример: Nginx падает из-за неправильного конфига\nnginx: [emerg] unexpected \u0026#34;}\u0026#34; in /etc/nginx/nginx.conf:15 nginx: configuration file /etc/nginx/nginx.conf test failed Синтаксическая ошибка в nginx.conf. Проверяем ConfigMap:\n# Смотрим содержимое конфига kubectl get configmap nginx-config -n blog -o yaml Находим ошибку, исправляем, применяем. Под перезапустится автоматически.\nГлавное: Под работает # # Проверка kubectl get pods -n blog | grep nginx # Ожидаемый результат - все Running nginx-xxxxxxxxxx-xxxxx 1/1 Running 0 2d # Если не Running - смотри troubleshooting выше Шаг 6: Контент - есть ли файлы для отдачи # Под работает, Service видит его, Traefik проксирует трафик. Но сайт отдаёт 404 Not Found или пустую страницу.\nПроблема: Hugo Builder не записал файлы на NFS или записал не туда.\nПроверка # # Заходим в под nginx (используй своё имя пода) kubectl exec -it nginx-xxxxxxxxxx-xxxxx -n blog -- sh # Внутри пода смотрим что примонтировалось ls -la /usr/share/nginx/html/ # Должен быть index.html и папки posts, tags, etc Если директория пустая - Hugo Builder не сработал. Проверяем его логи:\n# Логи Hugo Builder kubectl logs -n blog deployment/hugo-builder-prod --tail=50 Ищем строку Build successful! и список созданных файлов. Если её нет:\nWebhook не сработал - проверь настройки webhook в Gitea Hugo упал с ошибкой - читай логи выше, смотри на что ругается Собрал в другую директорию - проверь переменную OUTPUT_DIR в build.sh Главное: Контент на месте # # Проверка (используй своё имя пода) kubectl exec -it nginx-xxxxxxxxxx-xxxxx -n blog -- ls /usr/share/nginx/html/ | head -5 # Ожидаемый результат index.html posts/ tags/ categories/ # Если пусто - Hugo Builder не отработал (см. выше) Быстрый чеклист для любой проблемы # Сохрани эту последовательность - она работает для 95% проблем:\n[ ] 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 → файлы есть? Проходишь по списку сверху вниз. Останавливаешься на первом [ ] где что-то не так. Чинишь. Проверяешь снова.\nНе прыгай хаотично между уровнями. Алгоритм экономит время.\nРеальный пример: 503 через раз # Мой сайт отдавал 503 примерно в 50% запросов. Половина запросов работала, половина нет.\nПрошёл по алгоритму:\n✅ DNS - правильный IP ✅ Сеть - Traefik отвечает ✅ MetalLB - External IP назначен ✅ Traefik - поды Running ❌ IngressRoute - два роутера на один домен kubectl get ingressroute -A | grep oakazanin NAMESPACE NAME blog blog-https # СТАРЫЙ, бэкенд в CreateContainerError oakazanin blog-https # НОВЫЙ, работает Traefik видел два роутера, честно балансировал трафик 50/50. Каждый второй запрос улетал в мёртвый blog/nginx.\nДиагноз поставлен за 3 минуты. Лечение - одна команда:\n# Удаляем дубль из старого namespace kubectl delete ingressroute blog-https blog-http -n blog Мораль: всегда чисти за собой. Старые namespace с нерабочими сервисами - источник неочевидных проблем.\nОткат и cleanup # Если в процессе диагностики что-то сломал ещё больше - откатываемся:\n# Восстанавливаем предыдущую версию манифеста kubectl apply -f nginx-deployment.yaml # Перезапускаем поды принудительно kubectl rollout restart deployment/nginx -n blog # Смотрим что изменения применились kubectl rollout status deployment/nginx -n blog Золотое правило: Перед экспериментами делай бэкапы манифестов:\n# Экспортируем текущее состояние с датой kubectl get deployment,service,ingressroute -n blog -o yaml \u0026gt; backup-$(date +%Y%m%d).yaml Что дальше # Ты умеешь диагностировать проблемы. Но лучше их вообще не создавать.\nВ следующей части покажу как правильно мигрировать сервисы между namespace - без даунтайма, дублей IngressRoute и других сюрпризов которые приводят к 503.\nРазберём реальный пример: переносим Gitea между namespace с NFS данными, получаем новый SSL за 32 секунды, и удаляем старый namespace навсегда.\nСтек этой части:\nTraefik 2.11 IngressRoute Kubernetes 1.30 (K3s) kubectl CLI curl для внешних проверок dig для DNS диагностики ","date":"17 февраля 2026","externalUrl":null,"permalink":"/posts/blog-part-5-debugging/","section":"Posts","summary":"","title":"Блог на Hugo в K3s: часть 5 - что делать когда всё внезапно сломалось","type":"posts"},{"content":"","date":"17 февраля 2026","externalUrl":null,"permalink":"/","section":"Олег Казанин","summary":"","title":"Олег Казанин","type":"page"},{"content":"","date":"16 февраля 2026","externalUrl":null,"permalink":"/tags/devops/","section":"Tags","summary":"","title":"Devops","type":"tags"},{"content":"","date":"16 февраля 2026","externalUrl":null,"permalink":"/tags/git/","section":"Tags","summary":"","title":"Git","type":"tags"},{"content":"","date":"16 февраля 2026","externalUrl":null,"permalink":"/tags/hugo/","section":"Tags","summary":"","title":"Hugo","type":"tags"},{"content":"","date":"16 февраля 2026","externalUrl":null,"permalink":"/tags/workflow/","section":"Tags","summary":"","title":"Workflow","type":"tags"},{"content":"В части 3 мы развернули два окружения - production и development. Один репозиторий, две ветки (main и dev), два пайплайна.\nТеперь встаёт вопрос: как организовать работу локально?\nПроблема # У нас есть:\nРепозиторий в Gitea Две ветки: main (production) и dev (development) Необходимость постоянно переключаться между ними Как это делать на локальной машине? Два варианта.\nВариант А: Две отдельные папки # Клонируем репозиторий дважды - в разные папки:\n~/projects/ ├── blog/ ← ветка main (production) └── blog-dev/ ← ветка dev (development) Логика: Хочу работать с dev - иду в blog-dev. Хочу что-то проверить в production - иду в blog. Без переключения веток.\nКажущиеся преимущества # Параллельная работа. Можно держать открытыми два терминала - в одном hugo server для dev, в другом смотреть production код.\nИзоляция. Каждая папка - своя песочница. Изменения в одной не влияют на другую.\nПростота навигации. cd blog-dev вместо git checkout dev. Меньше команд.\nПривычный паттерн. Многие админы и разработчики держат несколько клонов для разных задач.\nРеальные проблемы # Проблема 1: Рассинхронизация локальных ветокin # Работаю в blog-dev - пишу статьи, коммичу, пушу в origin/dev. Всё хорошо.\nНо локальная ветка dev в папке blog при этом не обновляется. Она отстаёт от origin/dev.\nПриходишь делать merge:\ncd blog git checkout main git merge dev # Already up to date. ← НО есть НЮАНС! Git говорит \u0026ldquo;всё актуально\u0026rdquo;, имея в виду локальную ветку dev, которая отстала на три коммита. Статьи не попадают в production.\nПриходится помнить делать git pull origin dev перед каждым merge. Забыл - публикуешь устаревшую версию.\nПроблема 2: Конфликты при merge # Редактируешь config/params.toml в обеих папках независимо:\nВ blog-dev добавил Firebase конфиг В blog изменил название сайта При merge Git честно сообщает о конфликте:\nCONFLICT (content): Merge conflict in config/params.toml И это повторяется каждый раз когда трогаешь конфигурацию. Потому что две папки - это две независимые истории изменений одного файла.\nПроблема 3: Работа не в той папке # Несколько раз ловил себя на том что редактирую статьи прямо в blog - папке production. Это нарушает весь смысл раздельных окружений.\nПроблема 4: Умственная нагрузка # Постоянный вопрос \u0026ldquo;в какой папке я сейчас?\u0026rdquo; Для простого блога это лишняя когнитивная нагрузка.\nВариант Б: Одна папка с переключением веток # Один клон репозитория, работа через git checkout:\n~/projects/ └── blog/ ← одна папка, две ветки: main и dev Как это работает # Пишу статью:\ncd ~/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 \u0026#34;feat: новая статья\u0026#34; git push origin dev Публикую:\n# Убеждаюсь что 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 обновляет всё что нужно.\nНет конфликтов из-за независимых изменений. Когда работаешь в одной папке, params.toml существует в одном экземпляре. Все изменения делаются в dev, в main попадают только через merge.\nКонфликт возможен только если кто-то редактирует main напрямую - а это нарушение workflow.\nНевозможно ошибиться с веткой. git branch показывает где ты сейчас. Случайно отредактировать файлы в main - сложнее.\nМеньше места на диске. Один клон вместо двух. Один .git вместо двух.\nСравнение на практических примерах # Пример 1: Обновление темы Blowfish # Две папки:\ncd blog-dev git submodule update --remote themes/blowfish git add themes/blowfish git commit -m \u0026#34;update: Blowfish theme\u0026#34; 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 Одна папка:\ncd blog git checkout dev git pull origin dev git submodule update --remote themes/blowfish git add themes/blowfish git commit -m \u0026#34;update: Blowfish theme\u0026#34; git push origin dev # Проверяешь на dev.blog.ru # Если всё ок - мержишь git checkout main git pull origin main git merge dev git push origin main Меньше команд, меньше переходов между папками, меньше шансов забыть git pull.\nПример 2: Правка опечатки в production # Нашёл опечатку на blog.ru. Нужно исправить быстро.\nДве папки:\nОпасность: хочется исправить прямо в blog (ветка main). Это нарушает workflow - все изменения должны идти через dev.\nПравильно:\ncd blog-dev git checkout dev # Исправляешь git commit -m \u0026#34;fix: опечатка\u0026#34; git push origin dev cd ../blog git checkout main git pull origin dev # ← опять легко забыть git merge dev git push origin main Одна папка:\ncd blog git checkout dev git pull origin dev # Исправляешь git commit -m \u0026#34;fix: опечатка\u0026#34; git push origin dev git checkout main git merge dev git push origin main Проще, меньше команд, понятнее.\nПример 3: Долгая работа над статьёй # Пишешь большую статью несколько дней. Между сеансами работы кто-то (или ты сам) запушил другие изменения в dev.\nДве папки:\ncd blog-dev # День 1: пишешь git add . git commit -m \u0026#34;wip: статья\u0026#34; # День 2: продолжаешь git pull origin dev # Подтягиваешь чужие изменения # Пишешь дальше git add . git commit -m \u0026#34;feat: закончил статью\u0026#34; git push origin dev Всё так же как и с одной папкой. Разницы нет.\nОдна папка:\ncd blog git checkout dev # День 1: пишешь git add . git commit -m \u0026#34;wip: статья\u0026#34; # День 2: продолжаешь git pull origin dev # Подтягиваешь чужие изменения # Пишешь дальше git add . git commit -m \u0026#34;feat: закончил статью\u0026#34; git push origin dev Идентично. Этот пример работает одинаково в обоих вариантах.\nЧто выбрать? # Если ты только начинаешь - сразу делай одну папку.\nДве папки кажутся удобными, но создают проблемы которые регулярно прерывают работу:\nРассинхронизация веток Конфликты при merge Когнитивная нагрузка Одна папка с переключением веток - стандартный Git workflow, проверенный миллионами разработчиков. Требует чуть больше дисциплины (git checkout dev вместо cd blog-dev), но избавляет от всех проблем выше.\nЗолотое правило: Никогда не редактировать файлы находясь на ветке main. Все изменения - через dev. Всегда.\nМиграция: если начал с двух папок # Если уже работаешь в двух папках - переход простой.\nШаг 1: Убеждаемся что всё запушено # # Проверяем обе папки cd ~/projects/blog-dev git status git push origin dev cd ~/projects/blog git status git push origin main Шаг 2: Синхронизируем ветку dev в основной папке # cd ~/projects/blog # Обновляем локальную ветку dev из remote git checkout dev git pull origin dev # Проверяем что всё актуально git log --oneline -5 # Возвращаемся на main git checkout main Шаг 3: Удаляем вторую папку # # Убеждаемся что в blog-dev нет несохранённых изменений cd ~/projects/blog-dev git status # Должно быть: nothing to commit, working tree clean # Удаляем папку cd ~/projects rm -rf blog-dev Шаг 4: Проверяем что всё работает # cd ~/projects/blog # Переключаемся на dev и запускаем сервер git checkout dev hugo server -D --bind 0.0.0.0 # Открываем http://localhost:1313/ # Видим dev версию сайта Новый workflow: шпаргалка # Создаю статью # cd ~/projects/blog git checkout dev hugo new content posts/название/index.md # Пишу, сохраняю, проверяю в hugo server git add . git commit -m \u0026#34;feat: название статьи\u0026#34; git push origin dev # → dev.blog.ru Публикую статью # 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 Меняю конфигурацию # git checkout dev # ВСЕ изменения только через dev! nano config/params.toml git add . git commit -m \u0026#34;feat: изменил конфиг\u0026#34; git push origin dev # Проверяю на dev.blog.ru # Если всё ок git checkout main git merge dev git push origin main Что дальше # Workflow выбран, окружения работают. Можно писать статьи.\nНо есть ещё одна тема которую стоит разобрать - что делать когда что-то сломалось. Как диагностировать проблемы когда сайт вдруг начал отдавать 503, или SSL перестал работать, или webhook не срабатывает.\nВ следующей части покажу процесс диагностики на реальном примере - как я чинил blog.ru когда он внезапно стал недоступен из интернета.\nРекомендация этой части:\nОдна папка ~/projects/blog Переключение веток через git checkout Все изменения через dev → merge в main Никогда не редактировать находясь на main ","date":"16 февраля 2026","externalUrl":null,"permalink":"/posts/blog-part-4-git-workflow/","section":"Posts","summary":"","title":"Блог на Hugo в K3s: часть 4 - выбор Git workflow","type":"posts"},{"content":"","date":"15 января 2026","externalUrl":null,"permalink":"/tags/basic-auth/","section":"Tags","summary":"","title":"Basic-Auth","type":"tags"},{"content":"В части 2 мы развернули production окружение для ветки main. Каждый пуш в main автоматически обновляет публичный сайт.\nПроблема: нельзя проверить как выглядит статья до публикации. Локальный hugo server показывает одно, а production может выглядеть по-другому из-за версий Hugo, конфигов, CSS.\nНужен второй пайплайн - тестовый контур где можно проверить изменения перед мержем в main.\nАрхитектура 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:\nОтдельный Hugo Builder (переменная BRANCH=dev) Отдельный NFS volume (blog-public-dev) Отдельный Nginx (одна реплика вместо двух) Basic Auth - доступ только по логину и паролю Отдельный домен (dev.blog.ru) Всё это живёт в том же namespace что и production. Два независимых пайплайна, нулевое пересечение.\nШаг 1: Hugo Builder для dev # Используем тот же Docker образ что и для production. Разница - в переменной окружения BRANCH.\nФайл: 01-hugo-builder-dev.yaml\napiVersion: 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: \u0026#34;dev\u0026#34; # Главное отличие - используем 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 # Применяем манифест 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 # Одна реплика вместо двух - для тестового окружения высокая доступность не критична.\nФайл: 03-nginx-dev-deployment.yaml\napiVersion: 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 # Применяем манифест 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.\nСоздаём пароль # # Генерируем htpasswd (логин: dev, пароль: ваш пароль) htpasswd -nb dev your-password # dev:$apr1$...хеш... # Кодируем в base64 для Kubernetes Secret echo -n \u0026#34;dev:$apr1$...хеш...\u0026#34; | base64 # ZGV2OiRhcHIxJC4uLg== Secret с паролем # Файл: 07-basic-auth-secret.yaml\n--- apiVersion: v1 kind: Secret metadata: name: dev-basic-auth namespace: blog type: Opaque data: users: ZGV2OiRhcHIxJC4uLg== # ваш base64 хеш # Применяем секрет 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\n--- apiVersion: traefik.io/v1alpha1 kind: Middleware metadata: name: dev-basic-auth namespace: blog spec: basicAuth: secret: dev-basic-auth removeHeader: true # Убираем заголовок Authorization после проверки # Применяем 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.\nФайл: 06-ingressroute-dev.yaml\n--- # 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 # Применяем IngressRoute kubectl apply -f 06-ingressroute-dev.yaml # Проверяем kubectl get ingressroute -n blog | grep dev Шаг 5: SSL сертификат для dev # cert-manager выпустит отдельный сертификат для dev.blog.ru.\nНе забудьте: Добавить A-запись в DNS:\ndev.blog.ru A 77.37.XXX.XXX Файл: 04-certificate-dev.yaml\napiVersion: 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 # Применяем манифест 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.\nGitea → ваш репозиторий → Settings → Webhooks → Add Webhook → Gitea\nURL: http://hugo-builder-dev.blog.svc.cluster.local:8080 HTTP Method: POST Content Type: application/json Trigger On: Push events Branch filter: dev ← Главное отличие от prod Нажимаем \u0026ldquo;Test Delivery\u0026rdquo; → должен вернуть 200 OK.\nПроверяем логи:\n# Смотрим логи Hugo Builder Dev kubectl logs -n blog deployment/hugo-builder-dev -f # Должно появиться: # Webhook triggered, starting build... # Cloning repository (branch: dev)... # Build successful! Проверка работы # # Создаём тестовую статью в dev cd ~/projects/blog git checkout dev # Создаём статью hugo new content posts/test-dev/index.md echo \u0026#34;Тестовая статья в dev окружении\u0026#34; \u0026gt;\u0026gt; content/posts/test-dev/index.md # Коммитим и пушим git add . git commit -m \u0026#34;test: проверка dev окружения\u0026#34; git push origin dev # Следим за логами Hugo Builder Dev kubectl logs -n blog deployment/hugo-builder-dev -f # Через 5-7 секунд сборка завершится Открываем https://dev.blog.ru в браузере:\nБраузер запросит логин и пароль (Basic Auth) Вводим: логин dev, пароль который задали Видим тестовую статью Критично: Статья появилась на dev.blog.ru, но её нет на blog.ru - окружения изолированы.\nWorkflow: dev → prod # Типичный процесс работы:\n1. Пишу статью в dev:\ncd ~/projects/blog git checkout dev # Создаю статью hugo new content posts/kubernetes-intro/index.md # Пишу контент, коммичу git add . git commit -m \u0026#34;feat: статья про Kubernetes\u0026#34; git push origin dev # → автосборка → dev.blog.ru 2. Проверяю на dev.blog.ru:\nОткрываю https://dev.blog.ru (вводя логин/пароль), читаю статью, проверяю форматирование, ссылки, изображения.\nНахожу опечатку - исправляю локально, пушу в dev снова. Повторяю пока не доволен результатом.\n3. Публикую в production:\n# Всё отлично на dev - мержу в main git checkout main git merge dev git push origin main # → автосборка → blog.ru Статья появляется на публичном сайте.\nИтоговая архитектура # Два полностью независимых пайплайна в одном namespace:\nProduction: 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 Общее:\nNamespace: blog Gitea репозиторий Docker образ Hugo Builder Traefik IngressRoute cert-manager Отдельное:\nDeployments Services PersistentVolumes SSL сертификаты Домены Что дальше # Два окружения работают. Можно писать статьи, проверять на dev, публиковать в production.\nНо есть проблема: я долго работал в двух папках - ~/projects/blog (main) и ~/projects/blog-dev (dev). Это создавало конфликты при merge, рассинхронизацию веток и головную боль.\nВ следующей части расскажу как я от этого избавился и почему одна папка с переключением веток лучше чем две отдельные папки.\nСтек этой части:\nHugo Builder Dev (та же версия Hugo) Nginx Dev (одна реплика) Traefik Basic Auth Middleware cert-manager (отдельный сертификат) NFS (отдельный volume) ","date":"15 января 2026","externalUrl":null,"permalink":"/posts/blog-part-3-dev-environment/","section":"Posts","summary":"","title":"Блог на Hugo в K3s: часть 3 - development окружение","type":"posts"},{"content":"","date":"8 января 2026","externalUrl":null,"permalink":"/tags/cert-manager/","section":"Tags","summary":"","title":"Cert-Manager","type":"tags"},{"content":"","date":"8 января 2026","externalUrl":null,"permalink":"/tags/nfs/","section":"Tags","summary":"","title":"Nfs","type":"tags"},{"content":"В первой части мы запустили Hugo локально. Сайт работает пока открыт терминал. Закрыл терминал - сайт умер.\nПора переносить это в K3s.\nАрхитектура деплоя # 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) Пять компонентов:\nNFS - хранилище для статики (OpenMediaVault) Hugo Builder - пересобирает сайт при каждом пуше Nginx - раздаёт статику с NFS cert-manager - автоматический SSL от Let\u0026rsquo;s Encrypt Traefik IngressRoute - маршрутизация с SSL терминацией Шаг 1: NFS хранилище # Hugo собирает статику в HTML/CSS/JS файлы. Nginx раздаёт эти файлы. Значит нужно общее хранилище куда Hugo пишет, а Nginx читает.\nNFS - самый простой вариант для homelab. У меня OpenMediaVault на отдельной машине.\nСоздаём директории на NAS # # Подключаемся к 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 попыток до нуля. Безопасность через скрытность работает для домашних серверов.\nНастраиваем NFS через OMV Web UI # Storage → Shared Folders → Create:\nName: blog-public Device: основной диск Path: /blog/blog-public Services → NFS → Shares → Create:\nShared 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.\nКритично: no_root_squash - без этого контейнеры не смогут записывать файлы (они пишут от root внутри контейнера).\nПроверяем экспорт # # Заходим на 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 ресурсами.\nФайл: 02-pv.yaml\n--- 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 работает стабильно. Не надо усложнять то что работает.\n# Применяем манифест 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.\nЗачем нужен Hugo Builder? # Проблема: Hugo генерирует статику командой hugo. Где её запускать? На локальной машине? Тогда нужно вручную заливать файлы на сервер после каждого изменения. Неудобно и ломает автоматизацию.\nРешение: Контейнер который живёт в K3s, слушает webhook от Gitea и автоматически пересобирает сайт при каждом git push.\nDockerfile # 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 \u0026amp;\u0026amp; \\ tar -xzf hugo_extended_0.155.3_linux-amd64.tar.gz \u0026amp;\u0026amp; \\ cp hugo /usr/bin/hugo \u0026amp;\u0026amp; \\ chmod +x /usr/bin/hugo \u0026amp;\u0026amp; \\ 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 [\u0026#34;/usr/local/bin/webhook-listener.sh\u0026#34;] build.sh - скрипт сборки Hugo # Зачем: Отдельный скрипт сборки нужен чтобы его можно было запускать не только из webhook listener, но и вручную для тестирования. Один скрипт - одна ответственность.\n#!/bin/bash set -e # Остановиться при первой ошибке export GIT_TERMINAL_PROMPT=0 # Не запрашивать пароли интерактивно REPO_URL=\u0026#34;https://git.example.com/user/blog.git\u0026#34; # URL вашего Gitea репозитория BRANCH=\u0026#34;${BRANCH:-main}\u0026#34; # Ветка (передаётся через env) OUTPUT_DIR=\u0026#34;/mnt/blog-public\u0026#34; # Куда складывать собранную статику (NFS) WORK_DIR=\u0026#34;/tmp/build\u0026#34; # Временная папка для клонирования # Чистим рабочую директорию от прошлой сборки rm -rf ${WORK_DIR} mkdir -p ${WORK_DIR} # Клонируем репозиторий (только нужную ветку, без истории) cd ${WORK_DIR} git clone --branch ${BRANCH} --depth 1 ${REPO_URL} site 2\u0026gt;\u0026amp;1 cd site # Подтягиваем тему Blowfish как Git submodule git submodule update --init --recursive --depth 1 2\u0026gt;\u0026amp;1 # Собираем сайт (минифицируем CSS/JS/HTML) hugo --minify --destination ${OUTPUT_DIR} 2\u0026gt;\u0026amp;1 # Проверяем что сборка прошла успешно if [ -f \u0026#34;${OUTPUT_DIR}/index.html\u0026#34; ]; then echo \u0026#34;Build successful!\u0026#34; else echo \u0026#34;Build failed - index.html not found\u0026#34; exit 1 fi # Убираем за собой rm -rf ${WORK_DIR} webhook-listener.sh - слушатель webhook # Зачем: Gitea отправляет HTTP POST запрос при каждом git push. Нужен простой HTTP сервер который принимает этот запрос и запускает сборку. netcat - самый простой способ поднять HTTP listener без зависимостей.\n#!/bin/bash set -e echo \u0026#34;Starting webhook listener on port 8080...\u0026#34; while true; do # Принимаем HTTP запрос через netcat и сразу отвечаем 200 OK echo -e \u0026#34;HTTP/1.1 200 OK\\r\\n\\r\\nWebhook received\u0026#34; | nc -l -p 8080 # Запускаем сборку синхронно (чтобы видеть логи в kubectl logs) echo \u0026#34;$(date): Webhook triggered, starting build...\u0026#34; /usr/local/bin/build.sh echo \u0026#34;$(date): Build completed, waiting for next webhook...\u0026#34; done Сборка и деплой образа # # Собираем 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 \u0026#34;sudo k3s ctr images import /tmp/hugo-builder.tar \u0026amp;\u0026amp; rm /tmp/hugo-builder.tar\u0026#34; done Deployment и Service # --- 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: \u0026#34;main\u0026#34; # Для 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 # Применяем манифест kubectl apply -f 01-hugo-builder-prod.yaml # Проверяем что под запустился kubectl get pods -n blog | grep hugo-builder # Смотрим логи - должна быть строка \u0026#34;Starting webhook listener\u0026#34; kubectl logs -n blog deployment/hugo-builder-prod Шаг 4: Nginx с Prometheus exporter # Nginx раздаёт статику с того же NFS где Hugo её собрал. Две реплики для минимальной доступности при обновлениях.\nБонус: sidecar контейнер с nginx-prometheus-exporter для мониторинга через Grafana.\n--- 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\u0026rsquo;s Encrypt через HTTP-01 challenge.\nВажно: Сначала настрой A-запись у DNS провайдера:\nyour-blog.ru A 77.37.XXX.XXX (ваш внешний IP) www.your-blog.ru A 77.37.XXX.XXX Без этого Let\u0026rsquo;s Encrypt не сможет проверить что домен принадлежит вам.\napiVersion: 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 # Применяем манифест 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 терминацию.\n--- # 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 # Применяем манифест kubectl apply -f 05-ingressroute.yaml # Проверяем что сайт доступен curl -I https://your-blog.ru # HTTP/2 200 Шаг 7: Webhook в Gitea # Последний шаг - связать Gitea с Hugo Builder.\nGitea → ваш репозиторий → Settings → Webhooks → Add Webhook → Gitea\nURL: http://hugo-builder-prod.blog.svc.cluster.local:8080 HTTP Method: POST Content Type: application/json Trigger On: Push events Branch filter: main Нажимаем \u0026ldquo;Test Delivery\u0026rdquo; - должен вернуть 200 OK.\nПроверяем логи Hugo Builder:\n# Следим за логами в реальном времени kubectl logs -n blog deployment/hugo-builder-prod -f # Должно появиться: # Webhook triggered, starting build... # Cloning repository... # Initializing submodules... # Building Hugo site... # Build successful! Проверка работы # # Меняем статью cd ~/hugo-projects/blog git checkout main echo \u0026#34;## Тестовая правка\u0026#34; \u0026gt;\u0026gt; content/posts/hello-world/index.md # Коммитим и пушим git add . git commit -m \u0026#34;test: проверка автосборки\u0026#34; 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 \u0026#34;Тестовая правка\u0026#34; Если видите \u0026ldquo;Тестовая правка\u0026rdquo; - всё работает. Каждый git push автоматически обновляет сайт.\nЧто дальше # Production окружение развёрнуто. Но пока только для ветки main.\nВ следующей части добавим development окружение с отдельным Hugo Builder, Nginx и защитой через Basic Auth. Два независимых пайплайна в одном namespace.\nСтек этой части:\nK3s 1.30 NFS на OpenMediaVault Hugo Builder (Alpine + Hugo v0.155.3) Nginx 1.25 + Prometheus exporter cert-manager + Let\u0026rsquo;s Encrypt Traefik IngressRoute ","date":"8 января 2026","externalUrl":null,"permalink":"/posts/blog-part-2-k8s-deployment/","section":"Posts","summary":"","title":"Блог на Hugo в K3s: часть 2 - деплой в кластер","type":"posts"},{"content":"","date":"3 января 2026","externalUrl":null,"permalink":"/tags/blowfish/","section":"Tags","summary":"","title":"Blowfish","type":"tags"},{"content":"","date":"3 января 2026","externalUrl":null,"permalink":"/tags/gitea/","section":"Tags","summary":"","title":"Gitea","type":"tags"},{"content":"","date":"3 января 2026","externalUrl":null,"permalink":"/tags/homelab/","section":"Tags","summary":"","title":"Homelab","type":"tags"},{"content":"Предыдущий блог жил на Jekyll. Жил - громко сказано. Скорее существовал, периодически ломаясь при обновлениях и требуя ритуальных танцев с Ruby каждый раз когда я садился за новую статью.\nОднажды я решил что хватит.\nПочему не Jekyll # Jekyll - зрелый инструмент с большим сообществом. Но у него есть фундаментальная проблема: он написан на Ruby.\nЗвучит невинно. На практике это означает:\nВерсионный ад. Ruby, Bundler, Gems - у каждого своя версия, и они регулярно конфликтуют друг с другом. Клонируешь репозиторий на новую машину - час уходит на то чтобы собрать рабочее окружение. Обновляешь Jekyll - ломаются плагины. Обновляешь плагины - ломается что-то ещё.\nЗависимости ради зависимостей. Простой блог тянет 1000+ gems, половина из которых устаревшая или находится в состоянии \u0026ldquo;поддерживается постольку-поскольку\u0026rdquo;. Каждый bundle install - это лотерея.\nЛишний мусор. Jekyll генерирует страницы без ссылок, которые непонятно зачем существуют. Приходится явно прописывать что не генерировать.\nHugo решает все эти проблемы радикально: это один бинарник на Go. Никаких зависимостей, никаких конфликтов версий, никакого bundle install. Скачал - работает. На любой машине, всегда.\nБинарник весит ~50MB. Собирает сайт из 40+ страниц за полторы секунды. Jekyll на том же контенте думал заметно дольше.\nПочему Blowfish # Все просто: понравилась.\nСмотрел темы несколько часов. Blowfish выглядела именно так как я хотел - чисто, без лишнего, с хорошей типографикой. Взял её.\nСтавится как Git submodule - обновляется одной командой, не засоряет репозиторий.\nАрхитектура: два окружения # У любого нормального инфраструктурного проекта есть тестовый и production контур. Блог - не исключение: обновления Hugo, изменения темы, новые статьи - всё это нужно проверять до того как это увидят читатели.\n┌────────────────────────────────────┐ │ Gitea (внутренний) │ │ репозиторий: blog │ │ │ │ ветка: main ветка: dev │ └───────┬────────────────────┬───────┘ │ │ ▼ ▼ ┌───────────────┐ ┌───────────────┐ │ hugo-builder │ │ hugo-builder │ │ prod │ │ dev │ └───────┬───────┘ └───────┬───────┘ │ │ ▼ ▼ ┌───────────────┐ ┌───────────────┐ │ nginx │ │ nginx-dev │ │ blog.ru │ │ dev.blog.ru │ │ (публичный) │ │ (Basic Auth) │ └───────────────┘ └───────────────┘ Production (blog.ru) - публичный. Ветка main.\nDevelopment (dev.blog.ru) - тестовый контур. Ветка dev. Закрыт Basic Auth через Traefik Middleware - без логина и пароля не войти.\nОба окружения в одном K3s namespace. Один репозиторий, две ветки, два независимых пайплайна.\nШаг 1: Создаём Hugo проект # # Создаём новый 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/ Структура после инициализации:\nblog/ ├── 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 # baseURL = \u0026#34;https://blog.ru/\u0026#34; theme = \u0026#34;blowfish\u0026#34; defaultContentLanguage = \u0026#34;ru\u0026#34; languages.ru.toml # # Переименовываем файл языка для русского mv config/_default/languages.en.toml config/_default/languages.ru.toml languageCode = \u0026#34;ru\u0026#34; languageName = \u0026#34;Русский\u0026#34; weight = 1 title = \u0026#34;Мой Блог\u0026#34; [params] displayName = \u0026#34;RU\u0026#34; isoCode = \u0026#34;ru\u0026#34; rtl = false dateFormat = \u0026#34;2 January 2006\u0026#34; [params.author] name = \u0026#34;Ваше Имя\u0026#34; headline = \u0026#34;DevOps Engineer | Kubernetes | Homelab\u0026#34; menus.ru.toml # # Переименовываем файл меню mv config/_default/menus.en.toml config/_default/menus.ru.toml [[main]] name = \u0026#34;Блог\u0026#34; pageRef = \u0026#34;posts\u0026#34; weight = 10 [[main]] name = \u0026#34;О сайте\u0026#34; pageRef = \u0026#34;about\u0026#34; weight = 20 Шаг 3: Первая статья # # Создаём новую статью hugo new content posts/hello-world/index.md --- title: \u0026#34;Hello World\u0026#34; date: 2026-02-13 draft: false description: \u0026#34;Первый пост на новом сайте\u0026#34; tags: [\u0026#34;test\u0026#34;] --- Это первый пост на blog.ru. Шаг 4: Проверяем локально # # Запускаем локальный сервер 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 темой и первой статьёй.\nHugo автоматически пересобирает сайт при сохранении файлов - изменения видны сразу после обновления страницы в браузере.\nШаг 5: Пушим в Gitea # # Добавляем все файлы в Git git add . # Создаём первый коммит git commit -m \u0026#34;Initial commit: Hugo + Blowfish v2.98.0\u0026#34; # Переименовываем ветку в 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):\n# Переходим в папку проекта 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 \u0026#34;feat: новая статья про X\u0026#34; # Пушим в dev ветку git push origin dev # → webhook срабатывает → автосборка → dev.blog.ru Проверяю:\nОткрываю https://dev.blog.ru (вводя логин/пароль Basic Auth) - вижу статью как её увидят читатели.\nПубликую:\n# Переключаемся на main git checkout main # Мержим изменения из dev git merge dev # Пушим в production git push origin main # → webhook срабатывает → автосборка → blog.ru Золотое правило: все изменения только через ветку dev. В main - только через merge. Никогда не редактировать файлы находясь на main.\nПочему это важно - расскажу отдельно, когда дойдём до того как я нарушил это правило и что из этого вышло.\nЧто дальше # Локально всё работает. Но hugo server умрёт как только закрою терминал.\nНужно развернуть это в K3s: Hugo Builder который пересобирает сайт при каждом пуше, Nginx который раздаёт статику, SSL сертификаты, NFS хранилище. Об этом - в следующей части.\nСтек этой части:\nHugo v0.155.3 extended Blowfish v2.98.0 Gitea (внутренний) Рабочая станция: Debian/Ubuntu ","date":"3 января 2026","externalUrl":null,"permalink":"/posts/blog-part-1-architecture/","section":"Posts","summary":"","title":"Блог на Hugo в K3s: часть 1 - архитектура и первый запуск","type":"posts"},{"content":"","date":"2 ноября 2025","externalUrl":null,"permalink":"/tags/etcd/","section":"Tags","summary":"","title":"Etcd","type":"tags"},{"content":"","date":"2 ноября 2025","externalUrl":null,"permalink":"/tags/ha/","section":"Tags","summary":"","title":"Ha","type":"tags"},{"content":"","date":"2 ноября 2025","externalUrl":null,"permalink":"/tags/installation/","section":"Tags","summary":"","title":"Installation","type":"tags"},{"content":"Инфраструктура готова: 5 VM работают, ОС настроена, порты открыты. Пора устанавливать K3s. Один curl-скрипт на каждую ноду - и через 15 минут у вас работающий HA-кластер.\nЗвучит слишком просто? Потому что сложная часть уже позади - в предыдущих статьях. Теперь осталось не перепутать флаги и порядок установки.\nРезультат: 5 нод в статусе Ready, etcd кластер 3/3 healthy, kubectl работает с локальной машины.\nДля кого это # Подходит:\nПрошёл статьи 1 и 2 (или имеешь готовые VM с настроенной ОС) Все 5 нод доступны по SSH Понимаешь разницу между master и worker Не подходит:\nVM ещё не созданы → статья 2 Не понимаешь зачем 3 master ноды → статья 1 Что понадобится # Компонент Значение K3s версия v1.31.4+k3s1 (или актуальная stable) Token Сгенерируем на первом шаге SSH доступ Ко всем 5 нодам Время ~15-20 минут Шаг 1: Сгенерировать token # Token - общий секрет для всех нод кластера. Без правильного token нода не присоединится.\nНа локальной машине:\n# Сгенерировать случайный token openssl rand -base64 32 Пример вывода:\nK10f8c9a7b6e5d4c3b2a1f0e9d8c7b6a5e4d3c2b1a0f9e8d7c6b5a4== Сохрани этот token - он понадобится для каждой ноды. Положи в менеджер паролей или временный файл.\n# Для удобства - сохранить в переменную (на время сессии) export K3S_TOKEN=\u0026#34;твой_сгенерированный_token\u0026#34; echo $K3S_TOKEN Шаг 2: Установить K3s на первую master ноду # Первая нода инициализирует etcd кластер. Она особенная - использует флаг --cluster-init.\nSSH на k3s-master-1:\nssh k3s@192.168.11.201 Установка:\n# Задать переменные export K3S_TOKEN=\u0026#34;твой_сгенерированный_token\u0026#34; export INSTALL_K3S_VERSION=\u0026#34;v1.31.4+k3s1\u0026#34; # Установить K3s curl -sfL https://get.k3s.io | sh -s - server \\ --cluster-init \\ --tls-san=192.168.11.201 \\ --disable=traefik \\ --disable=servicelb \\ --write-kubeconfig-mode=644 Разбор флагов:\nФлаг Зачем server Режим control plane (не agent) --cluster-init Ключевой! Инициализирует etcd. Только на первой ноде --tls-san=192.168.11.201 Добавить IP в сертификат API server --disable=traefik Отключить встроенный Traefik (установим свой через Helm) --disable=servicelb Отключить встроенный LB (установим MetalLB) --write-kubeconfig-mode=644 Разрешить чтение kubeconfig без sudo Установка займёт 1-2 минуты. K3s скачает бинарник (~50MB) и запустит все компоненты.\nПроверка # # 1. Статус сервиса sudo systemctl status k3s Ожидаемый результат:\n● k3s.service - Lightweight Kubernetes Loaded: loaded Active: active (running) since ... # 2. Статус ноды sudo k3s kubectl get nodes Ожидаемый результат:\nNAME STATUS ROLES AGE VERSION k3s-master-1 Ready control-plane,etcd,master 45s v1.31.4+k3s1 Checkpoint: Первая master работает # # Быстрая проверка sudo systemctl is-active k3s \u0026amp;\u0026amp; \\ sudo k3s kubectl get nodes | grep -q \u0026#34;Ready\u0026#34; \u0026amp;\u0026amp; \\ echo \u0026#34;✓ Master-1 готов\u0026#34; || echo \u0026#34;✗ Проблема\u0026#34; Если статус NotReady или сервис не запустился:\n# Смотреть логи sudo journalctl -u k3s -f --no-pager | tail -50 Ошибка в логах Причина Решение cgroup v1 is not supported Нужен cgroup v2 Вернись к статье 2, шаг 6.5 port 6443 already in use Что-то занимает порт sudo ss -tlnp | grep 6443 etcd failed to start Мало места на диске df -h, увеличь диск Шаг 3: Добавить вторую master ноду # Теперь присоединяем вторую master. Она подключается к существующему кластеру - без --cluster-init.\nSSH на k3s-master-2:\nssh k3s@192.168.11.202 Установка:\nexport K3S_TOKEN=\u0026#34;твой_сгенерированный_token\u0026#34; export INSTALL_K3S_VERSION=\u0026#34;v1.31.4+k3s1\u0026#34; curl -sfL https://get.k3s.io | sh -s - server \\ --server=https://192.168.11.201:6443 \\ --tls-san=192.168.11.202 \\ --disable=traefik \\ --disable=servicelb \\ --write-kubeconfig-mode=644 Ключевое отличие:\n❌ Нет --cluster-init - кластер уже инициализирован ✅ Есть --server=https://192.168.11.201:6443 - адрес существующего кластера Проверка # # На master-2 sudo k3s kubectl get nodes Ожидаемый результат:\nNAME STATUS ROLES AGE VERSION k3s-master-1 Ready control-plane,etcd,master 3m v1.31.4+k3s1 k3s-master-2 Ready control-plane,etcd,master 30s v1.31.4+k3s1 Две ноды - но кворума ещё нет. etcd требует большинство, а 2 из 3 - это ещё не \u0026ldquo;большинство от трёх\u0026rdquo;.\nШаг 4: Добавить третью master ноду # SSH на k3s-master-3:\nssh k3s@192.168.11.203 Установка (аналогично master-2):\nexport K3S_TOKEN=\u0026#34;твой_сгенерированный_token\u0026#34; export INSTALL_K3S_VERSION=\u0026#34;v1.31.4+k3s1\u0026#34; curl -sfL https://get.k3s.io | sh -s - server \\ --server=https://192.168.11.201:6443 \\ --tls-san=192.168.11.203 \\ --disable=traefik \\ --disable=servicelb \\ --write-kubeconfig-mode=644 Checkpoint: Control Plane HA готов # На любой master ноде:\n# 1. Все 3 master ноды Ready sudo k3s kubectl get nodes Ожидаемый результат:\nNAME STATUS ROLES AGE VERSION k3s-master-1 Ready control-plane,etcd,master 5m v1.31.4+k3s1 k3s-master-2 Ready control-plane,etcd,master 3m v1.31.4+k3s1 k3s-master-3 Ready control-plane,etcd,master 1m v1.31.4+k3s1 # 2. etcd кластер здоров sudo k3s kubectl exec -n kube-system \\ $(sudo k3s kubectl get pods -n kube-system -l component=etcd -o name | head -1) \\ -- etcdctl endpoint health --cluster Ожидаемый результат:\nhttps://192.168.11.201:2379 is healthy: successfully committed proposal: took = 2.1ms https://192.168.11.202:2379 is healthy: successfully committed proposal: took = 1.8ms https://192.168.11.203:2379 is healthy: successfully committed proposal: took = 2.3ms Теперь у вас настоящий HA. Можете остановить любую master ноду - кластер продолжит работать.\nТест отказоустойчивости (опционально) # Хотите убедиться, что HA работает? Проверьте:\n# С локальной машины - остановить master-2 ssh k3s@192.168.11.202 \u0026#34;sudo systemctl stop k3s\u0026#34; # Подождать 30-40 секунд, затем на master-1: ssh k3s@192.168.11.201 \u0026#34;sudo k3s kubectl get nodes\u0026#34; # master-2 станет NotReady, но кластер работает # Проверить etcd - 2/3 кворум есть ssh k3s@192.168.11.201 \u0026#34;sudo k3s kubectl exec -n kube-system \\ \\$(sudo k3s kubectl get pods -n kube-system -l component=etcd -o name | head -1) \\ -- etcdctl endpoint health --cluster\u0026#34; # 2 из 3 healthy - кворум есть # Вернуть master-2 ssh k3s@192.168.11.202 \u0026#34;sudo systemctl start k3s\u0026#34; Шаг 5: Добавить worker ноды # Worker ноды устанавливаются как agent - они не участвуют в etcd и не запускают control plane.\n5.1. Установить K3s agent на worker-1 # SSH на k3s-worker-1:\nssh k3s@192.168.11.210 Установка:\nexport K3S_TOKEN=\u0026#34;твой_сгенерированный_token\u0026#34; export INSTALL_K3S_VERSION=\u0026#34;v1.31.4+k3s1\u0026#34; curl -sfL https://get.k3s.io | sh -s - agent \\ --server=https://192.168.11.201:6443 Отличия от master:\nagent вместо server - режим worker Нет флагов --disable и --tls-san - они не нужны для worker Только --server - куда подключаться 5.2. Установить K3s agent на worker-2 # SSH на k3s-worker-2:\nssh k3s@192.168.11.211 Установка:\nexport K3S_TOKEN=\u0026#34;твой_сгенерированный_token\u0026#34; export INSTALL_K3S_VERSION=\u0026#34;v1.31.4+k3s1\u0026#34; curl -sfL https://get.k3s.io | sh -s - agent \\ --server=https://192.168.11.201:6443 Checkpoint: Все ноды в кластере # На любой master ноде:\nsudo k3s kubectl get nodes -o wide Ожидаемый результат:\nNAME STATUS ROLES AGE VERSION INTERNAL-IP k3s-master-1 Ready control-plane,etcd,master 10m v1.31.4+k3s1 192.168.11.201 k3s-master-2 Ready control-plane,etcd,master 8m v1.31.4+k3s1 192.168.11.202 k3s-master-3 Ready control-plane,etcd,master 6m v1.31.4+k3s1 192.168.11.203 k3s-worker-1 Ready \u0026lt;none\u0026gt; 2m v1.31.4+k3s1 192.168.11.210 k3s-worker-2 Ready \u0026lt;none\u0026gt; 1m v1.31.4+k3s1 192.168.11.211 Обрати внимание:\nMaster: роли control-plane,etcd,master Worker: роли \u0026lt;none\u0026gt; - только выполнение workloads Шаг 6: Настроить kubectl на локальной машине # Сейчас kubectl работает только на master нодах через sudo k3s kubectl. Настроим доступ с вашей рабочей машины.\n6.1. Скопировать kubeconfig # На локальной машине (не на ноде):\n# 1. Создать директорию mkdir -p ~/.kube # 2. Скопировать конфиг с master-1 scp k3s@192.168.11.201:/etc/rancher/k3s/k3s.yaml ~/.kube/config # 3. Заменить localhost на реальный IP sed -i \u0026#39;s/127.0.0.1/192.168.11.201/g\u0026#39; ~/.kube/config # Для macOS: # sed -i \u0026#39;\u0026#39; \u0026#39;s/127.0.0.1/192.168.11.201/g\u0026#39; ~/.kube/config # 4. Права доступа chmod 600 ~/.kube/config 6.2. Установить kubectl (если нет) # Linux:\ncurl -LO \u0026#34;https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl\u0026#34; sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl rm kubectl macOS:\nbrew install kubectl 6.3. Проверить подключение # # Версия kubectl version # Ноды kubectl get nodes # Все поды kubectl get pods -A Ожидаемый результат kubectl get pods -A:\nNAMESPACE NAME READY STATUS RESTARTS AGE kube-system coredns-xxx 1/1 Running 0 10m kube-system local-path-provisioner-xxx 1/1 Running 0 10m kube-system metrics-server-xxx 1/1 Running 0 10m Если kubectl не подключается:\nСимптом Причина Решение connection refused k3s не запущен ssh k3s@192.168.11.201 \u0026quot;sudo systemctl status k3s\u0026quot; connection timeout Firewall блокирует Проверь UFW на master: sudo ufw status | grep 6443 certificate signed by unknown authority Неправильный kubeconfig Скопируй заново с master ноды 6.4. Настроить автодополнение (опционально) # # Bash echo \u0026#39;source \u0026lt;(kubectl completion bash)\u0026#39; \u0026gt;\u0026gt; ~/.bashrc echo \u0026#39;alias k=kubectl\u0026#39; \u0026gt;\u0026gt; ~/.bashrc echo \u0026#39;complete -o default -F __start_kubectl k\u0026#39; \u0026gt;\u0026gt; ~/.bashrc source ~/.bashrc # Zsh echo \u0026#39;source \u0026lt;(kubectl completion zsh)\u0026#39; \u0026gt;\u0026gt; ~/.zshrc source ~/.zshrc Теперь работает k get nodes и Tab-автодополнение.\nФинальная проверка # Полный чеклист работоспособности кластера:\necho \u0026#34;=== Проверка K3s HA кластера ===\u0026#34; echo -n \u0026#34;1. Все ноды Ready: \u0026#34; [ $(kubectl get nodes --no-headers | grep -c \u0026#34;Ready\u0026#34;) -eq 5 ] \u0026amp;\u0026amp; echo \u0026#34;✓ (5/5)\u0026#34; || echo \u0026#34;✗\u0026#34; echo -n \u0026#34;2. Master ноды: \u0026#34; kubectl get nodes --no-headers | grep -c \u0026#34;control-plane\u0026#34; | xargs -I {} echo \u0026#34;✓ ({}/3)\u0026#34; echo -n \u0026#34;3. Worker ноды: \u0026#34; kubectl get nodes --no-headers | grep -c \u0026#34;\u0026lt;none\u0026gt;\u0026#34; | xargs -I {} echo \u0026#34;✓ ({}/2)\u0026#34; echo -n \u0026#34;4. etcd healthy: \u0026#34; kubectl exec -n kube-system \\ $(kubectl get pods -n kube-system -l component=etcd -o name | head -1) \\ -- etcdctl endpoint health --cluster 2\u0026gt;/dev/null | grep -c \u0026#34;is healthy\u0026#34; | xargs -I {} echo \u0026#34;✓ ({}/3)\u0026#34; echo -n \u0026#34;5. CoreDNS Running: \u0026#34; kubectl get pods -n kube-system -l k8s-app=kube-dns --no-headers | grep -q \u0026#34;Running\u0026#34; \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗\u0026#34; echo -n \u0026#34;6. Metrics Server Running: \u0026#34; kubectl get pods -n kube-system -l k8s-app=metrics-server --no-headers | grep -q \u0026#34;Running\u0026#34; \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗\u0026#34; echo \u0026#34;\u0026#34; echo \u0026#34;=== Информация о кластере ===\u0026#34; kubectl cluster-info Ожидаемый результат:\n=== Проверка K3s HA кластера === 1. Все ноды Ready: ✓ (5/5) 2. Master ноды: ✓ (3/3) 3. Worker ноды: ✓ (2/2) 4. etcd healthy: ✓ (3/3) 5. CoreDNS Running: ✓ 6. Metrics Server Running: ✓ === Информация о кластере === Kubernetes control plane is running at https://192.168.11.201:6443 CoreDNS is running at https://192.168.11.201:6443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy Metrics-server is running at https://192.168.11.201:6443/api/v1/namespaces/kube-system/services/https:metrics-server:https/proxy Тестовый деплой # Убедимся, что кластер может запускать приложения:\n# 1. Создать тестовый под kubectl run nginx-test --image=nginx:alpine --port=80 # 2. Подождать запуска kubectl wait --for=condition=Ready pod/nginx-test --timeout=60s # 3. Проверить на какой ноде запустился kubectl get pod nginx-test -o wide Ожидаемый результат:\nNAME READY STATUS RESTARTS AGE IP NODE nginx-test 1/1 Running 0 30s 10.42.1.5 k3s-worker-1 Под запустился на worker ноде - как и должно быть.\n# 4. Проверить доступность изнутри кластера kubectl run -it --rm debug --image=busybox --restart=Never -- wget -qO- nginx-test Ожидаемый результат: HTML-страница nginx.\n# 5. Удалить тестовые ресурсы kubectl delete pod nginx-test Troubleshooting # Нода не присоединяется к кластеру # Симптом: После установки нода не появляется в kubectl get nodes.\nПричина Диагностика Решение Неправильный token Логи: sudo journalctl -u k3s-agent -f Проверь token, переустанови Firewall блокирует curl -k https://192.168.11.201:6443 Открой порт 6443 на masters DNS не резолвит ping k3s-master-1 Проверь /etc/hosts или DNS etcd не формирует кворум # Симптом: etcdctl endpoint health показывает unhealthy.\n# Проверить членов etcd sudo k3s kubectl exec -n kube-system \\ $(sudo k3s kubectl get pods -n kube-system -l component=etcd -o name | head -1) \\ -- etcdctl member list --write-out=table Причина Диагностика Решение Порты 2379-2380 закрыты sudo ufw status sudo ufw allow 2379:2380/tcp Нода недоступна по сети ping 192.168.11.20X Проверь сеть, UFW etcd ещё стартует uptime на ноде Подожди 2-3 минуты Поды не запускаются на workers # Симптом: Все поды на master нодах, workers пустые.\n# Проверить taints kubectl describe node k3s-worker-1 | grep Taints Если есть taints - убрать:\nkubectl taint nodes k3s-worker-1 node.kubernetes.io/not-ready:NoSchedule- Откат: удаление K3s # Если нужно начать заново:\nНа master ноде:\nsudo /usr/local/bin/k3s-uninstall.sh На worker ноде:\nsudo /usr/local/bin/k3s-agent-uninstall.sh Что удаляется:\nБинарники K3s Systemd сервисы Данные из /var/lib/rancher/k3s/ etcd данные (на masters) Контейнеры и образы ⚠️ Внимание: etcd снапшоты тоже удаляются. Если нужен бэкап:\n# Перед удалением - сохранить снапшоты sudo cp -r /var/lib/rancher/k3s/server/db/snapshots/ ~/k3s-backup/ Итог # Что сделано:\n✅ Сгенерирован token для кластера ✅ Установлен K3s на 3 master ноды с embedded etcd ✅ Добавлены 2 worker ноды ✅ Настроен kubectl на локальной машине ✅ Проверена работоспособность кластера Что имеем:\nHA Control Plane - выдерживает падение 1 master ноды 2 worker ноды для приложений kubectl доступ с локальной машины Встроенные компоненты: CoreDNS, metrics-server, local-path storage Что ещё не настроено (следующие статьи):\nLoadBalancer (MetalLB) - для доступа к сервисам извне Ingress Controller (Traefik) - для HTTP/HTTPS routing SSL сертификаты (cert-manager) - для автоматического HTTPS Мониторинг (Prometheus/Grafana) - для наблюдения за кластером Что дальше # Кластер готов, но пока он изолирован от внешнего мира. Чтобы запускать реальные приложения с доступом извне, нужны:\nMetalLB - выдаёт IP-адреса для LoadBalancer сервисов Traefik - маршрутизирует HTTP/HTTPS трафик cert-manager - автоматически получает SSL сертификаты Это темы для следующей серии статей.\nА пока можно:\nПоэкспериментировать с kubectl Задеплоить тестовые приложения Изучить как работает scheduling между нодами ","date":"2 ноября 2025","externalUrl":null,"permalink":"/posts/k3s-part3-installation/","section":"Posts","summary":"","title":"K3s HA для homelab: Ставим K3s HA кластер","type":"posts"},{"content":"","date":"2 ноября 2025","externalUrl":null,"permalink":"/series/k3s-ha-%D0%BA%D0%BB%D0%B0%D1%81%D1%82%D0%B5%D1%80-%D0%B4%D0%BB%D1%8F-homelab/","section":"Series","summary":"","title":"K3s HA Кластер Для Homelab","type":"series"},{"content":"","date":"21 октября 2025","externalUrl":null,"permalink":"/tags/debian/","section":"Tags","summary":"","title":"Debian","type":"tags"},{"content":"","date":"21 октября 2025","externalUrl":null,"permalink":"/tags/infrastructure/","section":"Tags","summary":"","title":"Infrastructure","type":"tags"},{"content":"Архитектура спланирована, ресурсы посчитаны - пора создавать виртуальные машины. В этой статье подготовим 5 VM в Proxmox и настроим ОС так, чтобы K3s установился без сюрпризов.\nЗвучит просто? В теории - да. На практике: забытый swap, cgroup v1 вместо v2, закрытые порты firewall - и вы тратите час на отладку того, что должно было работать \u0026ldquo;из коробки\u0026rdquo;.\nРезультат: 5 VM (3 master + 2 worker) с Debian 12, настроенной сетью, отключённым swap и правильными параметрами ядра. SSH доступ работает, ноды видят друг друга.\nДля кого это # Подходит:\nПрочитал первую статью (или понимаешь архитектуру K3s HA) Есть Proxmox с 14+ vCPU и 56GB+ RAM свободных Умеешь работать в терминале Proxmox (или готов учиться) Не подходит:\nProxmox ещё не установлен - сначала разберись с ним Хочешь использовать LXC вместо VM - K3s в контейнерах работает, но с нюансами (не покрываем) Что понадобится # Компонент Значение Proxmox VE 7.x или 8.x Storage pool local-lvm или другой (минимум 200GB свободно) Сетевой bridge vmbr0 (или ваш) SSH-ключ Публичный ключ для доступа к VM ОС для VM Debian 12 cloud image Шаг 1: Скачать cloud image Debian 12 # Cloud image - готовый образ с поддержкой cloud-init. Не нужно проходить установщик вручную: задаёшь параметры (IP, пользователь, SSH-ключ) - VM стартует уже настроенной.\nНа Proxmox хосте (SSH или Shell в Web UI):\n# Перейти в директорию для образов cd /var/lib/vz/template/iso # Скачать Debian 12 cloud image wget https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2 # Проверить ls -lh debian-12-generic-amd64.qcow2 Ожидаемый результат:\n-rw-r--r-- 1 root root 521M ... debian-12-generic-amd64.qcow2 Если скачивание медленное: образ ~500MB, на слабом канале может занять время. Альтернатива - скачать на локальную машину и загрузить через Proxmox Web UI (Datacenter → Storage → Upload).\nШаг 2: Создать template VM # Template - шаблон VM, из которого будем клонировать все 5 нод. Настраиваем один раз, клонируем пять.\n2.1. Задать переменные # # Настрой под свою конфигурацию TEMPLATE_ID=9000 # ID для template (любой свободный) STORAGE=local-lvm # Твой storage pool BRIDGE=vmbr0 # Сетевой bridge SSH_KEY_PATH=~/.ssh/id_rsa.pub # Путь к публичному SSH-ключу Как узнать имя storage:\npvesm status Ожидаемый результат\nName Type Status Total Used Available % local dir active 229199360 9095552 220103808 3.97% local-lvm pool active 220103964 96 220103868 0.00% 2.2. Создать VM и импортировать диск # # 1. Создать пустую VM qm create $TEMPLATE_ID \\ --name debian-12-template \\ --memory 2048 \\ --cores 2 \\ --net0 virtio,bridge=$BRIDGE # 2. Импортировать скачанный образ как диск qm importdisk $TEMPLATE_ID \\ /var/lib/vz/template/iso/debian-12-generic-amd64.qcow2 \\ $STORAGE Ожидаемый результат:\nimporting disk \u0026#39;/var/lib/vz/template/iso/debian-12-generic-amd64.qcow2\u0026#39; to VM 9000 ... Successfully imported disk as \u0026#39;unused0:local-lvm:vm-9000-disk-0\u0026#39; 2.3. Настроить диск и загрузку # # 3. Подключить диск к VM qm set $TEMPLATE_ID \\ --scsihw virtio-scsi-pci \\ --scsi0 $STORAGE:vm-$TEMPLATE_ID-disk-0 # 4. Настроить загрузку и cloud-init qm set $TEMPLATE_ID \\ --boot c \\ --bootdisk scsi0 \\ --ide2 $STORAGE:cloudinit \\ --serial0 socket \\ --vga serial0 2.4. Настроить cloud-init # # 5. Пользователь, пароль, SSH-ключ qm set $TEMPLATE_ID \\ --ciuser k3s \\ --cipassword \u0026#34;ВашНадёжныйПароль\u0026#34; \\ --sshkeys $SSH_KEY_PATH \\ --ipconfig0 ip=dhcp # 6. Увеличить диск до 32GB (базовый размер для master) qm resize $TEMPLATE_ID scsi0 32G Замени:\nВашНадёжныйПароль - пароль для пользователя k3s (резервный доступ, если SSH не работает) $SSH_KEY_PATH - путь к твоему публичному ключу 2.5. Превратить в template # # 7. Конвертировать VM в template qm template $TEMPLATE_ID После этого VM 9000 станет шаблоном - её нельзя запустить, только клонировать.\nCheckpoint: Template создан # # Проверить что template существует qm list | grep template Ожидаемый результат:\n9000 debian-12-template stopped 2048 32.00 0 Если ошибка \u0026ldquo;disk import failed\u0026rdquo;:\nПроверь свободное место: pvesm status Проверь путь к образу: ls -l /var/lib/vz/template/iso/ Шаг 3: Клонировать master ноды # Теперь создаём 3 master ноды из template. Каждая получит свой IP, имя и ресурсы.\nTEMPLATE_ID=9000 # ───────────────────────────────────────────── # Master 1 # ───────────────────────────────────────────── qm clone $TEMPLATE_ID 201 --name k3s-master-1 --full qm set 201 --cores 2 --memory 8192 qm set 201 --ipconfig0 ip=192.168.11.201/24,gw=192.168.11.1 qm set 201 --nameserver 8.8.8.8 qm resize 201 scsi0 32G # ───────────────────────────────────────────── # Master 2 # ───────────────────────────────────────────── qm clone $TEMPLATE_ID 202 --name k3s-master-2 --full qm set 202 --cores 2 --memory 8192 qm set 202 --ipconfig0 ip=192.168.11.202/24,gw=192.168.11.1 qm set 202 --nameserver 8.8.8.8 qm resize 202 scsi0 32G # ───────────────────────────────────────────── # Master 3 # ───────────────────────────────────────────── qm clone $TEMPLATE_ID 203 --name k3s-master-3 --full qm set 203 --cores 2 --memory 8192 qm set 203 --ipconfig0 ip=192.168.11.203/24,gw=192.168.11.1 qm set 203 --nameserver 8.8.8.8 qm resize 203 scsi0 32G Параметры:\n--full - полное клонирование (не linked clone), VM независима от template --cores 2 --memory 8192 - 2 vCPU, 8GB RAM (как планировали) --ipconfig0 - статический IP через cloud-init --nameserver - DNS сервер (можешь указать свой) Адаптируй под свою сеть:\n192.168.11.0/24 → твоя подсеть 192.168.11.1 → твой gateway Шаг 4: Клонировать worker ноды # Workers получают больше ресурсов - здесь будут работать приложения.\n# ───────────────────────────────────────────── # Worker 1 # ───────────────────────────────────────────── qm clone $TEMPLATE_ID 210 --name k3s-worker-1 --full qm set 210 --cores 4 --memory 16384 qm set 210 --ipconfig0 ip=192.168.11.210/24,gw=192.168.11.1 qm set 210 --nameserver 8.8.8.8 qm resize 210 scsi0 50G # ───────────────────────────────────────────── # Worker 2 # ───────────────────────────────────────────── qm clone $TEMPLATE_ID 211 --name k3s-worker-2 --full qm set 211 --cores 4 --memory 16384 qm set 211 --ipconfig0 ip=192.168.11.211/24,gw=192.168.11.1 qm set 211 --nameserver 8.8.8.8 qm resize 211 scsi0 50G Отличия от master:\n4 vCPU вместо 2 16GB RAM вместо 8GB 50GB диск вместо 32GB Шаг 5: Запустить все VM # # Запустить все 5 VM for vmid in 201 202 203 210 211; do qm start $vmid echo \u0026#34;Запущена VM $vmid\u0026#34; sleep 3 done # Проверить статус qm list | grep k3s Ожидаемый результат:\n201 k3s-master-1 running 8192 32.00 12345 202 k3s-master-2 running 8192 32.00 12346 203 k3s-master-3 running 8192 32.00 12347 210 k3s-worker-1 running 16384 50.00 12348 211 k3s-worker-2 running 16384 50.00 12349 Checkpoint: VM работают # Подожди 1-2 минуты (cloud-init применяет настройки при первом запуске), затем проверь SSH:\n# Проверить доступность всех нод for ip in 192.168.11.201 192.168.11.202 192.168.11.203 192.168.11.210 192.168.11.211; do echo -n \u0026#34;Проверяю $ip... \u0026#34; ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no k3s@$ip \u0026#34;hostname\u0026#34; 2\u0026gt;/dev/null \u0026amp;\u0026amp; echo \u0026#34;OK\u0026#34; || echo \u0026#34;FAIL\u0026#34; done Ожидаемый результат:\nПроверяю 192.168.11.201... k3s-master-1 OK Проверяю 192.168.11.202... k3s-master-2 OK ... Если SSH не работает:\nСимптом Причина Решение Connection refused VM не загрузилась или SSH не запущен Открой консоль в Proxmox, проверь загрузку Connection timeout Неправильный IP или firewall Проверь IP в консоли: ip addr Permission denied Неправильный SSH-ключ Проверь ~/.ssh/authorized_keys на VM Host key verification failed Первое подключение Добавь -o StrictHostKeyChecking=no Шаг 6: Подготовить ОС на всех нодах # Теперь нужно настроить каждую ноду: обновить пакеты, отключить swap, настроить ядро. Команды одинаковые для всех 5 нод.\n6.1. Обновить систему # На каждой ноде (или через цикл):\n# Вариант 1: по одной ssh k3s@192.168.11.201 sudo apt update sudo apt upgrade -y sudo apt install -y curl wget vim htop iptables # Вариант 2: массово (с локальной машины) for ip in 192.168.11.{201..203} 192.168.11.{210..211}; do echo \u0026#34;=== Обновляю $ip ===\u0026#34; ssh k3s@$ip \u0026#34;sudo apt update \u0026amp;\u0026amp; sudo apt upgrade -y \u0026amp;\u0026amp; sudo apt install -y curl wget vim htop iptables\u0026#34; done 6.2. Отключить swap # Kubernetes не любит swap. При включённом swap поды ведут себя непредсказуемо - OOMKiller срабатывает не тогда, когда ожидаешь.\nНа всех нодах:\n# Отключить swap сейчас sudo swapoff -a # Отключить навсегда (закомментировать в fstab) sudo sed -i \u0026#39;/swap/s/^/#/\u0026#39; /etc/fstab # Проверить free -h | grep Swap Ожидаемый результат:\nSwap: 0B 0B 0B 6.3. Загрузить kernel-модули # K3s использует overlay filesystem и bridge netfilter. Без этих модулей - ошибки при старте.\nНа всех нодах:\n# Загрузить модули sudo modprobe overlay sudo modprobe br_netfilter # Настроить автозагрузку cat \u0026lt;\u0026lt;EOF | sudo tee /etc/modules-load.d/k3s.conf overlay br_netfilter EOF # Проверить lsmod | grep -E \u0026#39;overlay|br_netfilter\u0026#39; Ожидаемый результат:\noverlay 151552 0 br_netfilter 32768 0 6.4. Настроить sysctl # Параметры для сетевого взаимодействия между подами.\nНа всех нодах:\n# Создать конфиг cat \u0026lt;\u0026lt;EOF | sudo tee /etc/sysctl.d/k3s.conf net.ipv4.ip_forward = 1 net.bridge.bridge-nf-call-iptables = 1 net.bridge.bridge-nf-call-ip6tables = 1 EOF # Применить sudo sysctl --system # Проверить sysctl net.ipv4.ip_forward net.bridge.bridge-nf-call-iptables Ожидаемый результат:\nnet.ipv4.ip_forward = 1 net.bridge.bridge-nf-call-iptables = 1 6.5. Проверить cgroup v2 # Debian 12 по умолчанию использует cgroup v2 - просто проверим.\nmount | grep cgroup Ожидаемый результат (cgroup v2):\ncgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot) Если видишь tmpfs on /sys/fs/cgroup type tmpfs - это cgroup v1. Нужно включить v2:\n# Добавить параметр ядра sudo sed -i \u0026#39;s|^GRUB_CMDLINE_LINUX_DEFAULT=\u0026#34;\\(.*\\)\u0026#34;|GRUB_CMDLINE_LINUX_DEFAULT=\u0026#34;\\1 systemd.unified_cgroup_hierarchy=1\u0026#34;|\u0026#39; /etc/default/grub # Обновить GRUB sudo update-grub # Перезагрузить sudo reboot # После reboot проверить mount | grep cgroup2 Шаг 7: Настроить firewall # UFW - простой интерфейс к iptables. Откроем только нужные порты.\n7.1. На master нодах (201, 202, 203) # # Установить UFW sudo apt install -y ufw # Базовые правила sudo ufw default deny incoming sudo ufw default allow outgoing # SSH (чтобы не потерять доступ) sudo ufw allow 22/tcp # Kubernetes API sudo ufw allow 6443/tcp # etcd (между masters) sudo ufw allow 2379:2380/tcp # Kubelet sudo ufw allow 10250/tcp # Flannel VXLAN sudo ufw allow 8472/udp # Включить sudo ufw --force enable # Проверить sudo ufw status 7.2. На worker нодах (210, 211) # sudo apt install -y ufw sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw allow 22/tcp # SSH sudo ufw allow 10250/tcp # Kubelet sudo ufw allow 8472/udp # Flannel VXLAN sudo ufw --force enable sudo ufw status Шаг 8: Настроить /etc/hosts # Не обязательно, но удобно - ноды смогут обращаться друг к другу по имени.\nНа всех нодах:\ncat \u0026lt;\u0026lt;EOF | sudo tee -a /etc/hosts # K3s Cluster 192.168.11.201 k3s-master-1 192.168.11.202 k3s-master-2 192.168.11.203 k3s-master-3 192.168.11.210 k3s-worker-1 192.168.11.211 k3s-worker-2 EOF Проверить:\nping -c 1 k3s-master-2 Финальная проверка # Перед переходом к установке K3s убедись, что всё готово. Запусти на любой ноде:\necho \u0026#34;=== Проверка готовности ноды ===\u0026#34; echo -n \u0026#34;1. Swap отключён: \u0026#34; [ $(free | grep Swap | awk \u0026#39;{print $2}\u0026#39;) -eq 0 ] \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗ ОШИБКА\u0026#34; echo -n \u0026#34;2. Модуль overlay: \u0026#34; lsmod | grep -q overlay \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗ ОШИБКА\u0026#34; echo -n \u0026#34;3. Модуль br_netfilter: \u0026#34; lsmod | grep -q br_netfilter \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗ ОШИБКА\u0026#34; echo -n \u0026#34;4. IP forwarding: \u0026#34; [ $(sysctl -n net.ipv4.ip_forward) -eq 1 ] \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗ ОШИБКА\u0026#34; echo -n \u0026#34;5. bridge-nf-call-iptables: \u0026#34; [ $(sysctl -n net.bridge.bridge-nf-call-iptables) -eq 1 ] \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗ ОШИБКА\u0026#34; echo -n \u0026#34;6. cgroup v2: \u0026#34; mount | grep -q \u0026#34;cgroup2\u0026#34; \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗ ОШИБКА\u0026#34; echo -n \u0026#34;7. UFW активен: \u0026#34; sudo ufw status | grep -q \u0026#34;Status: active\u0026#34; \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗ ОШИБКА\u0026#34; echo -n \u0026#34;8. Пинг k3s-master-1: \u0026#34; ping -c 1 -W 1 k3s-master-1 \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗ ОШИБКА\u0026#34; Ожидаемый результат:\n=== Проверка готовности ноды === 1. Swap отключён: ✓ 2. Модуль overlay: ✓ 3. Модуль br_netfilter: ✓ 4. IP forwarding: ✓ 5. bridge-nf-call-iptables: ✓ 6. cgroup v2: ✓ 7. UFW активен: ✓ 8. Пинг k3s-master-1: ✓ Если где-то ✗ - вернись к соответствующему шагу.\nTroubleshooting # Симптом Причина Решение VM не получает IP cloud-init не отработал Проверь консоль, жди 2-3 минуты, перезагрузи VM SSH connection refused sshd не запущен Открой консоль, проверь systemctl status ssh Swap не отключается Строка не закомментирована в fstab cat /etc/fstab, проверь swap строку, reboot cgroup v1 после reboot GRUB не обновился Проверь /proc/cmdline, повтори update-grub UFW блокирует всё Забыл разрешить SSH до включения Через консоль Proxmox: ufw allow 22/tcp Ноды не пингуются UFW или неправильный IP Проверь ip addr, проверь правила UFW Итог # Что сделано:\n✅ Скачан Debian 12 cloud image ✅ Создан template VM с cloud-init ✅ Склонированы 5 VM (3 master + 2 worker) ✅ Настроены статические IP ✅ Подготовлена ОС (swap, modules, sysctl, cgroup v2) ✅ Настроен firewall с нужными портами ✅ SSH работает на все ноды Что дальше:\n👉 Следующая статья: \u0026ldquo;Установить K3s HA кластер\u0026rdquo;\nТам мы:\nСгенерируем token для кластера Установим K3s на первую master ноду Добавим ещё 2 master ноды (HA) Подключим worker ноды Настроим kubectl Проверим работу кластера ","date":"21 октября 2025","externalUrl":null,"permalink":"/posts/k3s-part2-infrastructure/","section":"Posts","summary":"","title":"K3s HA для homelab: Готовим инфраструктуру в Proxmox","type":"posts"},{"content":"","date":"21 октября 2025","externalUrl":null,"permalink":"/tags/proxmox/","section":"Tags","summary":"","title":"Proxmox","type":"tags"},{"content":"","date":"14 октября 2025","externalUrl":null,"permalink":"/tags/architecture/","section":"Tags","summary":"","title":"Architecture","type":"tags"},{"content":"Kubernetes слишком тяжёлый, Docker Swarm мёртв, а хочется нормальный кластер для экспериментов. Знакомо? K3s решает эту проблему - полноценный Kubernetes в бинарнике на 50MB вместо 1.5GB зависимостей. Но без правильного планирования вы получите нестабильную конструкцию, которая падает в самый неподходящий момент.\nВ этой статье разберём архитектуру K3s HA кластера: почему именно 3 master ноды, зачем embedded etcd и сколько ресурсов закладывать. В конце - готовый план для установки.\nРезультат: понимание архитектуры + таблица ресурсов + сетевая схема. Всё, что нужно перед тем, как создавать VM.\nДля кого это # Подходит:\nЗнаком с базовыми концепциями Kubernetes (pod, service, deployment) Есть Proxmox с 14+ vCPU и 56GB+ RAM Хочешь понять что устанавливать, прежде чем устанавливать Не подходит:\nНужна одна нода для экспериментов - достаточно docker-compose или K3s single-node Ищешь managed Kubernetes для бизнеса - смотри в сторону Yandex Cloud или VK Cloud Хочешь сразу команды без теории - переходи к статье 2 K3s vs Kubernetes: в чём разница # Kubernetes (K8s) - оркестратор контейнеров, стандарт индустрии. Добро пожаловать в enterprise, где для запуска трёх контейнеров нужно поддерживать шесть виртуальных машин.\nK3s - тот же Kubernetes, но кто-то в Rancher (теперь SUSE) задумался: \u0026ldquo;А что если выкинуть всё, что нужно только Сберу и Yandex Cloud?\u0026rdquo;\nKubernetes K3s Что выкинули:\nИнтеграции с облачными провайдерами (вы же не в VK Cloud) Legacy API (вы же не мигрируете кластер 2016 года) Встроенные драйверы хранилищ на все случаи жизни (вы же не используете 47 типов СХД) Альфа/бета-функции (нестабильные эксперименты) Что осталось: полноценный Kubernetes, сертифицированный CNCF (Cloud Native Computing Foundation - организация, которая решает, что считать \u0026ldquo;настоящим\u0026rdquo; Kubernetes). Все манифесты работают. Helm работает. kubectl работает. Ответы со StackOverflow работают.\nСравнение в цифрах # Характеристика Kubernetes K3s Размер ~1.5GB образы 50MB бинарник RAM на control plane ~2GB на ноду ~500MB на ноду Установка kubeadm, 10+ шагов один curl-скрипт etcd Отдельный кластер (3+ VM) Встроенный CNI Нужно устанавливать Flannel из коробки Совместимость 100% 100% \u0026ldquo;Но я потеряю гибкость!\u0026rdquo; - скажете вы. Да, вы не сможете заменить сетевой плагин Flannel без пересборки. Это критично примерно для одного проекта из тысячи, и ваш homelab в их число не входит.\nВердикт: для homelab K3s - очевидный выбор. Теряем 5% гибкости, получаем 90% простоты.\nЧто такое High Availability и зачем оно вам # HA (High Availability) - способность системы продолжать работу при отказе компонентов. Звучит как enterprise-термин для больших компаний? На практике это разница между \u0026ldquo;кластер упал в субботу, но я починил в понедельник\u0026rdquo; и \u0026ldquo;кластер сам пережил падение ноды, пока я спал\u0026rdquo;.\nБез HA (single node) # ┌─────────────────┐ │ K3s Master │ ← Единственная точка отказа │ + Worker │ └─────────────────┘ Нода упала → кластер мёртв → ваши сервисы недоступны С HA (3+ master nodes) # ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Master 1 │ │ Master 2 │ │ Master 3 │ │ + etcd │ │ + etcd │ │ + etcd │ └──────────┘ └──────────┘ └──────────┘ ↓ ↓ ↓ ┌──────────┐ ┌──────────┐ │ Worker 1 │ │ Worker 2 │ └──────────┘ └──────────┘ Одна master упала → кластер работает Один worker упал → поды переехали на другой Сколько master нод нужно # Вот тут начинается интересное. Интуиция подсказывает: одна нода - плохо, две - уже лучше. Логично? Логично. И неправильно.\nMaster нод Выдержит отказов Кворум Вердикт 1 0 1/1 Нет HA, но честно 2 0 Ловушка! ⛔ Хуже, чем 1 3 1 2/3 ✅ Минимум для HA 5 2 3/5 Для критичных систем Почему 2 master ноды хуже, чем 1 # etcd (база данных кластера, где хранится вообще всё) работает по принципу голосования. Чтобы записать данные, нужно согласие большинства нод. Не \u0026ldquo;хотя бы одной\u0026rdquo; - именно большинства.\nСчитаем:\n1 нода: большинство = 1. Упала - кластер мёртв. Честная игра, вы знали на что шли. 2 ноды: большинство = 2. Упала одна - кворума нет, кластер мёртв. Сюрприз! 3 ноды: большинство = 2. Одна упала - две оставшиеся продолжают работать. Это как договор, требующий подписи обоих директоров - заболел один, и компания парализована.\nС двумя нодами вы не получили отказоустойчивость. Вы удвоили количество точек отказа и назвали это \u0026ldquo;высокой доступностью\u0026rdquo;.\nПравило: или 1 нода (и честное понимание рисков), или 3+ (и настоящий HA). Двойка - ловушка для тех, кто не дочитал документацию.\nEmbedded etcd vs External etcd # etcd - распределённое key-value хранилище. Единственный источник истины для всего состояния Kubernetes: все объекты (поды, сервисы, секреты), конфигурации, сетевые политики. Без etcd кластер не работает. Точка.\nЕсть два варианта архитектуры:\nExternal etcd (классический Kubernetes) # Control Plane (3 VM) etcd кластер (3 VM) ┌────────────────┐ ┌──────────────┐ │ API Server │ │ etcd-1 │ │ Scheduler │ ──────────────\u0026gt;│ (только etcd)│ │ Controller │ └──────────────┘ └────────────────┘ ┌──────────────┐ ┌────────────────┐ │ etcd-2 │ │ API Server │ ──────────────\u0026gt;│ (только etcd)│ │ Scheduler │ └──────────────┘ │ Controller │ ┌──────────────┐ └────────────────┘ │ etcd-3 │ ┌────────────────┐ │ (только etcd)│ │ API Server │ ──────────────\u0026gt;└──────────────┘ │ Scheduler │ │ Controller │ └────────────────┘ Итого: 6 виртуальных машин Embedded etcd (K3s) # ┌─────────────────────────┐ │ K3s Master 1 │ │ ┌───────────────────┐ │ │ │ API + Scheduler │ │ │ │ + Controller │ │ │ └───────────────────┘ │ │ ┌───────────────────┐ │ │ │ etcd (встроенный) │◄─┼──┐ │ └───────────────────┘ │ │ └─────────────────────────┘ │ Raft protocol ┌─────────────────────────┐ │ (синхронизация) │ K3s Master 2 │ │ │ etcd ◄────────────────────┤ └─────────────────────────┘ │ ┌─────────────────────────┐ │ │ K3s Master 3 │ │ │ etcd ◄────────────────────┘ └─────────────────────────┘ Итого: 3 виртуальные машины Сравнение подходов # Критерий External etcd Embedded etcd Количество VM 6 (3 master + 3 etcd) 3 (всё вместе) Сложность настройки Высокая Один флаг --cluster-init Сложность обновления Отдельно etcd и K8s Одна команда Производительность Чуть лучше Достаточно для homelab Масштаб \u0026gt;500 нод До 100-200 нод Для homelab embedded etcd - очевидный выбор. Теряем 5-10% производительности etcd, экономим 3 VM и часы настройки.\n\u0026ldquo;А если мне понадобится масштаб?\u0026rdquo; - официально embedded etcd поддерживает до 100 нод и 5000 подов. Для homelab это как ограничение скорости 300 км/ч на велосипеде.\nЗачем отдельные worker ноды # Worker ноды - машины для запуска ваших приложений (подов). На них не запускаются компоненты control plane.\n\u0026ldquo;А можно запускать приложения прямо на master нодах?\u0026rdquo;\nТехнически - да. K3s не ставит ограничений на master ноды (в отличие от обычного Kubernetes). Но это плохая идея:\nControl plane должен быть стабильным. Ваше приложение съело всю память → API server упал → кластер недоступен. etcd чувствителен к диску. База данных на той же ноде создаёт I/O нагрузку → etcd тормозит → весь кластер тормозит. Изоляция отказов. Проблема с приложением не должна убивать control plane. 2 worker ноды - минимум для HA приложений:\nМожно запускать 2 реплики (на разных нодах) При падении одного worker\u0026rsquo;а второй держит нагрузку Легко добавить третью, четвёртую ноду потом Архитектура нашего кластера # Вот что мы будем строить:\nКлючевые моменты:\nВсе master ноды равны - нет \u0026ldquo;главной\u0026rdquo;, kubectl подключается к любой. etcd синхронизируется через Raft - алгоритм консенсуса, гарантирует согласованность данных. Workers знают только про API - они не подключаются к etcd напрямую. Flannel создаёт overlay-сеть - все поды получают IP из 10.42.0.0/16, видят друг друга. Планирование ресурсов # Таблица VM # Hostname VM ID IP vCPU RAM Disk Роль k3s-master-1 201 192.168.11.201 2 8GB 32GB Control Plane + etcd k3s-master-2 202 192.168.11.202 2 8GB 32GB Control Plane + etcd k3s-master-3 203 192.168.11.203 2 8GB 32GB Control Plane + etcd k3s-worker-1 210 192.168.11.210 4 16GB 50GB Workloads k3s-worker-2 211 192.168.11.211 4 16GB 50GB Workloads Итого - - 14 56GB 196GB - Почему именно такие ресурсы # Master ноды (2 vCPU / 8GB RAM / 32GB Disk):\nРеальное потребление в idle:\nAPI server: ~200-300MB RAM etcd: ~100-200MB RAM (растёт со временем) Scheduler + Controller: ~150MB RAM Системные поды: ~100-200MB RAM Итого: ~600-900MB используется \u0026ldquo;Зачем тогда 8GB?\u0026rdquo; - запас для burst-нагрузки. Когда вы деплоите 50 подов одновременно, API server временно съедает больше. etcd при большом кластере может вырасти до 1-2GB. Golang GC работает лучше с запасом памяти.\nWorker ноды (4 vCPU / 16GB RAM / 50GB Disk):\nЗдесь будут ваши приложения. При 16GB можно запустить:\n5-10 средних приложений (256MB-2GB каждое) Или 2-3 базы данных (PostgreSQL любит память) Или комбинацию 50GB диска - под образы контейнеров (10-20GB), логи (5-10GB), временные данные.\nМожно ли меньше? # Минимальная конфигурация (для экспериментов):\nMaster: 1 vCPU / 4GB RAM / 20GB Disk Worker: 2 vCPU / 8GB RAM / 30GB Disk Итого: 9 vCPU / 36GB RAM Риски:\nМедленная работа API server OOM killer при нагрузке Нет запаса для burst Для production-like homelab рекомендую таблицу выше. Комфортный запас стоит дешевле, чем отладка странных падений.\nСетевая схема # IP-адреса (адаптируй под свою сеть) # 192.168.11.0/24 - Локальная сеть 192.168.11.1 - Gateway (роутер) 192.168.11.201-203 - Master ноды 192.168.11.210-211 - Worker ноды 192.168.11.220-230 - Резерв для MetalLB (статья 2) Kubernetes внутренние сети (создаются автоматически) # 10.42.0.0/16 - Pod network (Flannel VXLAN overlay) 10.43.0.0/16 - Service network (ClusterIP) 10.43.0.10 - CoreDNS Порты между нодами # Порт Протокол Направление Назначение 6443 TCP Master ← Worker Kubernetes API 2379-2380 TCP Master ↔ Master etcd (client + peer) 10250 TCP Master ↔ All Kubelet API 8472 UDP All ↔ All Flannel VXLAN Требования к железу и софту # Железо (Proxmox хост) # Минимум:\nCPU: 14 vCPU свободных RAM: 56GB свободных Disk: 200GB на SSD Network: 1 Gbit Рекомендуется:\nCPU: 18+ vCPU (запас для приложений) RAM: 64GB+ (базы данных прожорливые) Disk: NVMe для etcd Network: 2.5 Gbit (для NFS, если будете использовать) Софт # Компонент Версия Proxmox VE 7.x или 8.x K3s v1.31+ (stable) ОС на нодах Debian 12 или Ubuntu 22.04+ Kernel 5.15+ (для cgroup v2) Итог # Что мы спроектировали:\n5 VM: 3 master + 2 worker K3s с embedded etcd (HA без лишних VM) Отказоустойчивость: выдерживает падение 1 master и любого worker Ресурсы: 14 vCPU / 56GB RAM / 196GB Disk Что НЕ входит в эту серию (отдельные статьи):\nLoadBalancer (MetalLB) Ingress (Traefik) SSL (cert-manager) Мониторинг (Prometheus/Grafana) Что дальше # 👉 \u0026ldquo;Подготовить инфраструктуру для K3s в Proxmox\u0026rdquo;\nТам мы:\nСоздадим template VM с Debian 12 Склонируем 5 VM с правильными ресурсами Настроим статические IP Подготовим ОС (swap, cgroup v2, firewall) ","date":"14 октября 2025","externalUrl":null,"permalink":"/posts/k3s-part1-architecture/","section":"Posts","summary":"","title":"K3s HA для homelab: архитектура без боли","type":"posts"},{"content":"","externalUrl":null,"permalink":"/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"}]