Перейти к основному содержимому

Блог на Hugo в K3s: часть 5 - что делать когда всё внезапно сломалось

·2201 слово·11 минут
Олег Казанин
Автор
Олег Казанин
Строю полезную инфраструктуру на Open Source стеке. Документирую грабли, чтобы вы на них не наступали.
Оглавление
Блог на Hugo в K3s - Эта статья — часть серии.
Часть 5: Ты уже здесь

В части 4 мы разобрались с Git workflow. Всё работает: пушишь в dev - видишь на тестовом окружении, мержишь в main - публикуется на production.

А потом в один прекрасный день открываешь свой сайт и видишь 503 Service Temporarily Unavailable.

Вчера же все работало! Ты ничего не менял. Что произошло?

Добро пожаловать в мир эксплуатации Kubernetes, где проблемы тоже возникают и требуют системного подхода без паники.

Эта статья - алгоритм диагностики от DNS до пода. Проходишь по шагам сверху вниз, находишь проблему за 5 минут. Не гадаешь, не тыкаешь наугад - работаешь по системе.


Анатомия HTTP запроса в K3s
#

Прежде чем искать проблему, нужно понять путь запроса от браузера до nginx:

Браузер
  ↓ DNS запрос
DNS сервер (провайдер или Cloudflare)
  ↓ Возвращает IP адрес
Роутер/Файрвол (OPNsense, MikroTik)
  ↓ Port Forward 443 → K3s node
MetalLB LoadBalancer
  ↓ External IP
Traefik Ingress Controller
  ↓ IngressRoute matching
Kubernetes Service
  ↓ Endpoint selection
Pod (Nginx контейнер)
  ↓ Volume mount
NFS хранилище

Проблема может быть на любом из этих уровней. Секрет эффективной диагностики - проверять снаружи внутрь, последовательно исключая рабочие компоненты.

Когда тыкаешь наугад, проверяя сначала поды, потом DNS, потом снова поды - тратишь время. Когда идёшь по алгоритму - находишь проблему за минуты.


Шаг 1: DNS - доходит ли домен до твоего IP
#

Первым делом проверяем что домен резолвится в правильный IP. Без этого дальше проверять бессмысленно - браузер просто не знает куда направлять запрос.

Проверка
#

# Проверяем DNS резолв (используй свой домен)
dig blog.example.com +short

# Альтернатива если dig не установлен
nslookup blog.example.com

Ожидаемый результат
#

77.37.XXX.XXX

Должен вернуться твой публичный IP адрес (тот который прописан в A-записи у DNS провайдера).

Что может пойти не так
#

СимптомПричинаКак проверитьРешение
Возвращается старый IPDNS кеш не обновилсяdig blog.example.com @8.8.8.8Подожди TTL (обычно 300-3600 сек)
NXDOMAIN ошибкаДомен не делегированПроверь NS записи у регистратораНастрой DNS правильно
Возвращается 127.0.0.1Локальный overridecat /etc/hosts | grep blogУдали строку из /etc/hosts
Возвращается несколько IPRound-robin DNSПроверь все ли IP твоиУдали лишние A-записи

Если DNS правильный - идём дальше.


Шаг 2: Внешний доступ - доходит ли запрос до сервера
#

Теперь проверяем что запрос физически доходит до сервера. DNS может быть правильным, но файрвол может блокировать трафик.

Проверка
#

# Пробуем подключиться извне (важно - НЕ из локальной сети!)
curl -v https://blog.example.com 2>&1 | head -30

Важно: Запускай эту команду с внешнего сервера или используй мобильный интернет. Тест из локальной сети ничего не докажет - можешь обходить файрвол.

Ситуация А: Connection refused или timeout
#

curl: (7) Failed to connect to blog.example.com port 443: Connection refused

Запрос вообще не дошёл до сервера. Проблема на сетевом уровне.

Возможные причины:

1. Порт 443 закрыт на файрволе/роутере

Проверь Port Forward правила на OPNsense/MikroTik. Должно быть:

WAN:443 → 192.168.X.X:443  (IP любой K3s ноды)

2. MetalLB не назначил External IP для Traefik

# Проверяем MetalLB
kubectl get svc -n traefik traefik

# Ожидаемый результат
NAME      TYPE           EXTERNAL-IP      PORT(S)
traefik   LoadBalancer   192.168.X.X      80:30080/TCP,443:30443/TCP

Если EXTERNAL-IP показывает <pending> - MetalLB не работает или пул IP адресов не настроен.

3. Traefik под не запущен

# Проверяем что Traefik работает
kubectl get pods -n traefik

# Должны быть все Running
NAME                       READY   STATUS
traefik-xxxxxxxxxx-xxxxx   1/1     Running

Ситуация Б: TLS handshake прошёл, но 503
#

< HTTP/2 503
< content-type: text/plain; charset=utf-8
< content-length: 20
no available server

Отлично - наша ситуация! Traefik работает, SSL сертификат отдаёт, но дальше запрос упирается в стену.

Сообщение no available server означает что Traefik нашёл роутер, но не нашёл живой бэкенд за ним.

Проблема внутри кластера. Идём глубже.

Ситуация В: SSL certificate problem
#

curl: (60) SSL certificate problem: unable to get local issuer certificate

Сертификат невалидный или не выпущен.

# Проверяем Certificate объект (используй свой namespace)
kubectl get certificate -n blog

# Должно быть READY=True
NAME       READY   SECRET      AGE
blog-tls   True    blog-tls    2d

Если READY=False - cert-manager не смог выпустить сертификат.

# Смотрим что пошло не так
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 получил запрос на твой домен. Что он с ним делает? Смотрим логи.

Проверка логов Traefik
#

# Смотрим последние 50 строк логов Traefik
kubectl logs -n traefik deployment/traefik --tail=50

# Фильтруем только свой домен (убираем шум от других сервисов)
kubectl logs -n traefik deployment/traefik --tail=100 | grep blog.example

Что искать в логах
#

Нормальный запрос:

{
  "request": "GET / HTTP/2.0",
  "status": 200,
  "size": 8994,
  "router": "blog-blog-https-xxxxx@kubernetescrd",
  "service": "blog-nginx-blog@kubernetescrd",
  "backend": "http://10.42.2.40:80",
  "duration": 12
}

Ключевые поля:

  • router: Traefik нашёл нужный IngressRoute (blog-blog-https)
  • backend: IP пода nginx куда проксируется запрос (10.42.2.40:80)
  • status: HTTP код ответа от nginx (200 = всё хорошо)

Проблемный запрос:

{
  "request": "GET / HTTP/2.0",
  "status": 503,
  "router": "blog-blog-https-xxxxx@kubernetescrd",
  "error": "no available server"
}

Traefik нашёл роутер, но поле backend отсутствует - под недоступен или не существует.

Проверяем список IngressRoute
#

# Смотрим все IngressRoute в кластере
kubectl get ingressroute -A

# Фильтруем только свой домен
kubectl get ingressroute -A | grep blog.example

Важный момент: Если один и тот же домен прописан в двух разных IngressRoute из разных namespace - Traefik будет балансировать между ними.

Например:

NAMESPACE    NAME               AGE
blog         blog-https         10d    # СТАРЫЙ namespace
blog-new     blog-https         2d     # НОВЫЙ namespace

Оба IngressRoute имеют match: Host('blog.example.com'). Traefik видит оба, честно балансирует трафик 50/50.

Если один из бэкендов мёртв - половина запросов уходит в пустоту. 503 через раз.

Решение: Удалить старый IngressRoute:

# Удаляем дубль из старого namespace
kubectl delete ingressroute blog-https blog-http -n blog

Проверяем синтаксис match
#

Traefik очень требователен к синтаксису. Частая ошибка - забыть backticks или скобки.

Неправильно:

match: Host(blog.example.com)         # Нет backticks
match: Host `blog.example.com`        # Нет скобок вокруг Host
match: Host("blog.example.com")       # Двойные кавычки вместо backticks

Правильно:

match: Host(`blog.example.com`)

Проверяем:

# Смотрим манифест 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

# Ожидаемый результат - есть строки с "router": "blog-blog-https"
# Если router не найден - проблема в IngressRoute match синтаксисе

Шаг 4: Service - видит ли он поды
#

Traefik нашёл роутер, проксирует трафик на Service. Но Service может не видеть поды если selector неправильный.

Проверка endpoints
#

# Смотрим endpoints для Service (используй своё имя Service)
kubectl get endpoints nginx -n blog

# Ожидаемый результат - НЕ пустой список IP
NAME    ENDPOINTS
nginx   10.42.0.44:80,10.42.2.40:80

Если видишь <none> - Service не нашёл ни одного пода. Две возможные причины.

Причина 1: Selector не совпадает с labels
#

# Смотрим selector у Service
kubectl get svc nginx -n blog -o yaml | grep -A3 "selector:"

# Вывод
selector:
  app: nginx

# Смотрим labels у подов
kubectl get pods -n blog --show-labels | grep nginx

# Вывод
nginx-xxxxxxxxxx-xxxxx   1/1   Running   app=nginx-old

Видишь проблему? Service ищет app: nginx, а под помечен app: nginx-old. Не совпадает.

Решение: Исправить Deployment или Service чтобы labels совпадали.

Причина 2: Поды не Running
#

# Смотрим статус подов
kubectl get pods -n blog

# Видим
NAME                     READY   STATUS
nginx-xxxxxxxxxx-xxxxx   0/1     CreateContainerError

Под существует, но не работает. Service правильно не включает его в endpoints. Идём в следующий шаг - разбираемся почему под не запускается.

Главное: Service видит поды
#

# Проверка
kubectl get endpoints nginx -n blog

# Ожидаемый результат - НЕ пустой
nginx   10.42.0.44:80,10.42.2.40:80

# Если <none> - проблема в селекторах или поды не Running

Шаг 5: Pod - что происходит внутри контейнера
#

Самый глубокий уровень. Под не запускается или падает в цикле перезапусков.

Проверка статуса подов
#

# Смотрим все поды в namespace
kubectl get pods -n blog

# Фильтруем только nginx
kubectl get pods -n blog | grep nginx

Возможные статусы проблем:

CreateContainerError
#

Контейнер вообще не может стартануть. Обычно проблема с volumes или образом.

# Смотрим детали пода (используй своё имя пода)
kubectl describe pod nginx-xxxxxxxxxx-xxxxx -n blog | tail -30

Ищем секцию Events внизу вывода. Там будет описание проблемы:

Пример 1: PVC не примонтировался

Events:
  Warning  FailedMount  MountVolume.SetUp failed for volume "blog-public-pvc": 
  mount failed: mount.nfs: Connection timed out

NFS хранилище недоступно. Возможные причины:

  • NFS сервер выключен или перезагружается
  • Неправильный IP или путь в PersistentVolume
  • Файрвол блокирует NFS трафик (порт 2049)

Пример 2: Образ не скачался

Events:
  Warning  Failed  Failed to pull image "nginx:latest": rpc error: code = Unknown

Контейнер не может скачать образ. Обычно это означает что imagePullPolicy: Never, а образ не импортирован на ноду.

# Проверяем что образ есть на ноде (используй IP своей worker ноды)
ssh user@192.168.X.X "sudo k3s crictl images | grep nginx"

Если образа нет - импортируй его через k3s ctr images import.

Пример 3: ConfigMap не найден

Events:
  Warning  FailedMount  ConfigMap "nginx-config" not found

Deployment ссылается на несуществующий ConfigMap.

# Проверяем что ConfigMap существует
kubectl get configmap -n blog | grep nginx-config

Если нет - создай или исправь имя в Deployment.

CrashLoopBackOff
#

Контейнер запускается, но сразу падает. Смотрим логи предыдущего запуска:

# Логи последнего упавшего контейнера
kubectl logs nginx-xxxxxxxxxx-xxxxx -n blog --previous

Пример: Nginx падает из-за неправильного конфига

nginx: [emerg] unexpected "}" in /etc/nginx/nginx.conf:15
nginx: configuration file /etc/nginx/nginx.conf test failed

Синтаксическая ошибка в nginx.conf. Проверяем ConfigMap:

# Смотрим содержимое конфига
kubectl get configmap nginx-config -n blog -o yaml

Находим ошибку, исправляем, применяем. Под перезапустится автоматически.

Главное: Под работает
#

# Проверка
kubectl get pods -n blog | grep nginx

# Ожидаемый результат - все Running
nginx-xxxxxxxxxx-xxxxx   1/1   Running   0   2d

# Если не Running - смотри troubleshooting выше

Шаг 6: Контент - есть ли файлы для отдачи
#

Под работает, Service видит его, Traefik проксирует трафик. Но сайт отдаёт 404 Not Found или пустую страницу.

Проблема: Hugo Builder не записал файлы на NFS или записал не туда.

Проверка
#

# Заходим в под nginx (используй своё имя пода)
kubectl exec -it nginx-xxxxxxxxxx-xxxxx -n blog -- sh

# Внутри пода смотрим что примонтировалось
ls -la /usr/share/nginx/html/

# Должен быть index.html и папки posts, tags, etc

Если директория пустая - Hugo Builder не сработал. Проверяем его логи:

# Логи Hugo Builder
kubectl logs -n blog deployment/hugo-builder-prod --tail=50

Ищем строку Build successful! и список созданных файлов. Если её нет:

  1. Webhook не сработал - проверь настройки webhook в Gitea
  2. Hugo упал с ошибкой - читай логи выше, смотри на что ругается
  3. Собрал в другую директорию - проверь переменную OUTPUT_DIR в build.sh

Главное: Контент на месте
#

# Проверка (используй своё имя пода)
kubectl exec -it nginx-xxxxxxxxxx-xxxxx -n blog -- ls /usr/share/nginx/html/ | head -5

# Ожидаемый результат
index.html
posts/
tags/
categories/

# Если пусто - Hugo Builder не отработал (см. выше)

Быстрый чеклист для любой проблемы
#

Сохрани эту последовательность - она работает для 95% проблем:

[ ] DNS: dig домен → правильный IP?
[ ] Сеть: curl -v https://домен → доходит до Traefik?
[ ] MetalLB: kubectl get svc -n traefik → External IP назначен?
[ ] Traefik: kubectl get pods -n traefik → Running?
[ ] IngressRoute: kubectl get ingressroute -A | grep домен → нет дублей?
[ ] Match синтаксис: Host(`домен`) со скобками и backticks?
[ ] Endpoints: kubectl get endpoints -n namespace → не пустые?
[ ] Selector: labels подов совпадают с selector Service?
[ ] Pods: kubectl get pods -n namespace → все Running?
[ ] PVC: kubectl get pvc -n namespace → все Bound?
[ ] Контент: kubectl exec ls /usr/share/nginx/html → файлы есть?

Проходишь по списку сверху вниз. Останавливаешься на первом [ ] где что-то не так. Чинишь. Проверяешь снова.

Не прыгай хаотично между уровнями. Алгоритм экономит время.


Реальный пример: 503 через раз
#

Мой сайт отдавал 503 примерно в 50% запросов. Половина запросов работала, половина нет.

Прошёл по алгоритму:

  1. ✅ DNS - правильный IP
  2. ✅ Сеть - Traefik отвечает
  3. ✅ MetalLB - External IP назначен
  4. ✅ Traefik - поды Running
  5. ❌ IngressRoute - два роутера на один домен
kubectl get ingressroute -A | grep oakazanin

NAMESPACE    NAME
blog         blog-https         # СТАРЫЙ, бэкенд в CreateContainerError
oakazanin    blog-https         # НОВЫЙ, работает

Traefik видел два роутера, честно балансировал трафик 50/50. Каждый второй запрос улетал в мёртвый blog/nginx.

Диагноз поставлен за 3 минуты. Лечение - одна команда:

# Удаляем дубль из старого namespace
kubectl delete ingressroute blog-https blog-http -n blog

Мораль: всегда чисти за собой. Старые namespace с нерабочими сервисами - источник неочевидных проблем.


Откат и cleanup
#

Если в процессе диагностики что-то сломал ещё больше - откатываемся:

# Восстанавливаем предыдущую версию манифеста
kubectl apply -f nginx-deployment.yaml

# Перезапускаем поды принудительно
kubectl rollout restart deployment/nginx -n blog

# Смотрим что изменения применились
kubectl rollout status deployment/nginx -n blog

Золотое правило: Перед экспериментами делай бэкапы манифестов:

# Экспортируем текущее состояние с датой
kubectl get deployment,service,ingressroute -n blog -o yaml > backup-$(date +%Y%m%d).yaml

Что дальше
#

Ты умеешь диагностировать проблемы. Но лучше их вообще не создавать.

В следующей части покажу как правильно мигрировать сервисы между namespace - без даунтайма, дублей IngressRoute и других сюрпризов которые приводят к 503.

Разберём реальный пример: переносим Gitea между namespace с NFS данными, получаем новый SSL за 32 секунды, и удаляем старый namespace навсегда.


Стек этой части:

  • Traefik 2.11 IngressRoute
  • Kubernetes 1.30 (K3s)
  • kubectl CLI
  • curl для внешних проверок
  • dig для DNS диагностики
Блог на Hugo в K3s - Эта статья — часть серии.
Часть 5: Ты уже здесь

Статьи по теме