---
title: "Блог на Hugo в K3s: часть 5 - что делать когда всё внезапно сломалось"
date: 2026-02-17
draft: false
description: "Алгоритм диагностики Hugo блога в K3s за 5 минут: от DNS до пода. Сайт отдаёт 503 — находим причину без паники."
tags: ["kubernetes", "k3s", "traefik", "debugging", "nginx", "troubleshooting"]
categories: ["Мои проекты"]
series: ["Блог на Hugo в K3s"]
series_order: 5
---

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

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

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

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

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

---

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

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

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

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

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

---

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

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

### Проверка

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

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

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

```
77.37.XXX.XXX
```

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

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

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

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

---

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

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

### Проверка

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

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

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

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

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

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

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

Проверь Port Forward правила на OPNsense/MikroTik. Должно быть:
```
WAN:443 → 192.168.X.X:443  (IP любой K3s ноды)
```

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

```bash
# Смотрим что пошло не так
kubectl describe certificate blog-tls -n blog

# Ищем секцию Events внизу вывода - там описание проблемы
```

### Главное: Запрос доходит до Traefik

```bash
# Проверка (с ВНЕШНЕГО сервера!)
curl -I https://blog.example.com

# Ожидаемый результат (любой из двух)
HTTP/2 200  # Всё работает
HTTP/2 503  # Traefik работает, но бэкенд недоступен

# Если connection refused/timeout - проблема в сети (см. выше)
```

---

## Шаг 3: Traefik - правильно ли маршрутизируется трафик

Traefik получил запрос на твой домен. Что он с ним делает? Смотрим логи.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Например:

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

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

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

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

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

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

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

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

**Правильно:**
```yaml
match: Host(`blog.example.com`)
```

Проверяем:

```bash
# Смотрим манифест IngressRoute
kubectl get ingressroute blog-https -n blog -o yaml | grep match:

# Должно быть со скобками и backticks
match: Host(`blog.example.com`)
```

### Главное: Traefik нашёл роутер

```bash
# Проверка
kubectl logs -n traefik deployment/traefik --tail=50 | grep blog.example

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

---

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

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

### Проверка endpoints

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

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

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

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

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

# Вывод
selector:
  app: nginx

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

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

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

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

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

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

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

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

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

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

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

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

---

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

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

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

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

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

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

### CreateContainerError

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

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

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

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

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

NFS хранилище недоступно. Возможные причины:
- NFS сервер выключен или перезагружается
- Неправильный IP или путь в PersistentVolume
- Файрвол блокирует NFS трафик (порт 2049)

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

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

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

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

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

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

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

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

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

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

### CrashLoopBackOff

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

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

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

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

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

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

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

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

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

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

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

---

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

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

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

### Проверка

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

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

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

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

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

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

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

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

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

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

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

---

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

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

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

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

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

---

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

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

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

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

```bash
kubectl get ingressroute -A | grep oakazanin

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

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

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

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

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

---

## Откат и cleanup

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

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

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

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

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

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

---

## Что дальше

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

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

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

---

**Стек этой части:**
- Traefik 2.11 IngressRoute
- Kubernetes 1.30 (K3s)
- kubectl CLI
- curl для внешних проверок
- dig для DNS диагностики
