oakazanin/public/index.json

2 lines
135 KiB
JSON
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

[{"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"}]