---
title: "Блог на Hugo в K3s: часть 6 - миграция между namespace"
date: 2026-02-18
draft: false
description: "Переносим сервисы между namespace в Kubernetes: экспорт манифестов, миграция NFS данных, новый SSL за 32 секунды. Реальный пример с Gitea."
tags: ["kubernetes", "k3s", "gitea", "devops", "homelab", "namespace"]
categories: ["Kubernetes", "DevOps практики"]
series: ["Блог на Hugo в K3s"]
series_order: 6
---

В части 5 выяснили почему сайт отдавал `503` через раз. Виновник - брошенный namespace `blog` с нерабочим nginx, чей IngressRoute всё ещё висел на продакшн домене.

Мораль была проста: **чисти за собой**. Сегодня делаем именно это - переносим Gitea из старого namespace в `oakazanin` и удаляем старый namespace навсегда.

Заодно разберём универсальный алгоритм миграции, который работает для любого сервиса. Показываю на примере Gitea, но всё описанное применимо к любому stateful сервису - PostgreSQL, MySQL, Nextcloud, MinIO. Принципы одинаковые: экспортируй манифесты, поменяй namespace, создай новое перед удалением старого.

---

## Почему вообще нужна миграция между namespace

Namespace в Kubernetes - это логическая изоляция. Со временем они накапливаются: сначала `default`, потом `blog`, потом `public`, потом `oakazanin`. Каждый создавался "быстро, временно, потом разберёмся".

Проблемы начинаются когда:
- Один домен прописан в IngressRoute двух разных namespace
- Старый сервис давно не работает, но его ресурсы висят и путают
- Непонятно где искать логи - в `blog` или `oakazanin`?

Решение - собрать связанные сервисы в один namespace. В нашем случае всё что относится к блогу и его инфраструктуре живёт в `oakazanin`.

---

## Типы сервисов: stateless и stateful

Перед миграцией важно понять с чем имеешь дело.

**Stateless сервисы** - nginx, hugo-builder, любой под без постоянных данных. Миграция тривиальна: меняешь namespace в манифесте, применяешь, удаляешь старое. Данные не теряются потому что их нет.

**Stateful сервисы** - Gitea, базы данных, всё что хранит данные на диске. Здесь нужна осторожность: данные живут на PersistentVolume, который привязан к конкретному namespace через PersistentVolumeClaim.

Наша Gitea - stateful. Репозитории лежат на NFS:

```
192.168.11.30:/export/gitea-data
└── /data/git/repositories/     ← Git репозитории
└── /data/gitea/gitea.db         ← База данных SQLite
```

Данные никуда не переносятся - они остаются на NFS. Мы просто создаём новый PV/PVC в целевом namespace, который указывает на тот же NFS путь.

---

## Шаг 1: Убеждаемся что данные целы

Прежде чем трогать что-либо - проверяем что данные на месте:

```bash
# Заходим в работающий под и проверяем репозитории
kubectl exec -n blog deployment/gitea -- find /data -maxdepth 3 -type d
```

Видим структуру:
```
/data/git/repositories/
/data/gitea/gitea.db
/data/gitea/conf
```

Репозитории есть - можно двигаться дальше. Также фиксируем NFS путь:

```bash
# Смотрим откуда PV берёт данные
kubectl get pv gitea-pv -o yaml | grep -A3 "nfs:"

# Вывод
# nfs:
#   path: /export/gitea-data
#   server: 192.168.11.30
```

---

## Шаг 2: Экспортируем текущие манифесты

```bash
# Создаём папку для бэкапов
mkdir -p ~/k8s-manifests/gitea-migration

# Экспортируем все ресурсы из старого namespace
kubectl get deployment gitea -n blog -o yaml > ~/k8s-manifests/gitea-migration/deployment.yaml
kubectl get service gitea -n blog -o yaml > ~/k8s-manifests/gitea-migration/service.yaml
kubectl get configmap gitea-config -n blog -o yaml > ~/k8s-manifests/gitea-migration/configmap.yaml
kubectl get pvc gitea-pvc -n blog -o yaml > ~/k8s-manifests/gitea-migration/pvc.yaml
kubectl get pv gitea-pv -o yaml > ~/k8s-manifests/gitea-migration/pv.yaml
kubectl get ingressroute gitea -n blog -o yaml > ~/k8s-manifests/gitea-migration/ingressroute.yaml
```

Это страховка - если что-то пойдёт не так, есть откуда восстановиться.

---

## Шаг 3: Готовим чистый манифест для нового namespace

Экспортированные манифесты содержат мусор: `uid`, `resourceVersion`, `creationTimestamp`, `status`. Всё это нужно убрать и поменять namespace.

Для PV и PVC дополнительно меняем имена - убираем префиксы старого namespace:
- `blog-gitea-pv` → `gitea-pv`
- `blog-gitea-pvc` → `gitea-pvc`

Собираем всё в один файл `gitea.yaml`:

```yaml
# PersistentVolume - тот же NFS путь, новое имя
apiVersion: v1
kind: PersistentVolume
metadata:
  name: gitea-pv
spec:
  accessModes:
  - ReadWriteMany
  capacity:
    storage: 10Gi
  mountOptions:
  - nfsvers=3
  - hard
  - timeo=600
  - retrans=2
  nfs:
    path: /export/gitea-data      # ← тот же путь!
    server: 192.168.11.30
  persistentVolumeReclaimPolicy: Retain
  claimRef:
    apiVersion: v1
    kind: PersistentVolumeClaim
    name: gitea-pvc
    namespace: oakazanin            # ← новый namespace

---
# PersistentVolumeClaim - новый namespace, привязан к gitea-pv
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: gitea-pvc
  namespace: oakazanin
spec:
  accessModes:
  - ReadWriteMany
  resources:
    requests:
      storage: 10Gi
  storageClassName: ""
  volumeName: gitea-pv

---
# Certificate - cert-manager выпустит новый
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: gitea-tls
  namespace: oakazanin
spec:
  dnsNames:
  - git.example.com
  issuerRef:
    kind: ClusterIssuer
    name: letsencrypt-prod
  secretName: gitea-tls

# ... остальные ресурсы: ConfigMap, Deployment, Service, IngressRoute
```

### Важный момент про SSL сертификат

Есть два подхода:

**Скопировать существующий секрет:**
```bash
# Копируем секрет из старого namespace в новый
kubectl get secret gitea-tls -n blog -o yaml | \
  sed 's/namespace: blog/namespace: oakazanin/' | \
  kubectl apply -f -
```
Плюс - мгновенно, нет даунтайма по SSL. Минус - тащим старый секрет.

**Дать cert-manager выпустить новый:**
Просто создаём Certificate объект в новом namespace. cert-manager сам выпустит сертификат через Let's Encrypt за 30-60 секунд.

Выбираем второй вариант - чистое решение без наследия.

---

## Шаг 4: Применяем в новом namespace

```bash
# Применяем манифест
kubectl apply -f gitea.yaml
```

Проверяем что всё поднялось:

```bash
# PVC привязался к PV?
kubectl get pv gitea-pv
kubectl get pvc gitea-pvc -n oakazanin

# Pod запустился с данными?
kubectl get pods -n oakazanin | grep gitea

# Сертификат выпущен?
kubectl get certificate gitea-tls -n oakazanin
```

Ожидаемый результат:
```
gitea-pv    Bound   oakazanin/gitea-pvc  
gitea-pvc   Bound   gitea-pv             
gitea-...   1/1 Running                  
gitea-tls   True                         
```

Проверяем что Gitea открывается и данные на месте:

```bash
# Проверяем доступность
curl -s -o /dev/null -w "%{http_code}" https://git.example.com
# 200
```

---

## Шаг 5: Останавливаем старый сервис

Только после того как убедились что новый работает:

```bash
# Останавливаем Gitea в старом namespace (не удаляем - просто 0 реплик)
kubectl scale deployment gitea -n blog --replicas=0

# Ждём несколько минут, проверяем что сайт всё ещё работает
curl -s -o /dev/null -w "%{http_code}" https://git.example.com
# 200 - трафик идёт через новый namespace
```

Масштабирование до 0 реплик - страховка. Если что-то пошло не так, поднимаем обратно за секунду:
```bash
kubectl scale deployment gitea -n blog --replicas=1
```

---

## Шаг 6: Зачищаем старый namespace

Когда убедились что всё работает - удаляем в правильном порядке:

```bash
# Сначала удаляем workloads
kubectl delete deployment gitea -n blog
kubectl delete service gitea -n blog
kubectl delete configmap gitea-config -n blog
kubectl delete secret gitea-tls -n blog
kubectl delete ingressroute gitea gitea-http -n blog

# Потом PVC (он держит PV)
kubectl delete pvc gitea-pvc -n blog

# Потом PV
kubectl delete pv blog-gitea-pv

# Последним - namespace
kubectl delete namespace blog
```

### Почему такой порядок?

PVC нельзя удалить пока его использует Pod - K8s заблокирует операцию через finalizer `kubernetes.io/pvc-protection`. Поэтому сначала удаляем Deployment, ждём пока Pod завершится, потом PVC.

PV с политикой `Retain` после удаления переходит в статус `Released`, но **данные на NFS остаются нетронутыми**. Это страховка от случайного удаления.

---

## Финальная проверка

```bash
# Namespace удалён?
kubectl get namespace | grep blog
# (пусто)

# Старый PV удалён?
kubectl get pv | grep blog
# (пусто)

# Новый PV работает?
kubectl get pv gitea-pv
# gitea-pv   Bound   oakazanin/gitea-pvc

# Все сервисы живые?
curl -s -o /dev/null -w "%{http_code}" https://blog.example.com    # 200
curl -s -o /dev/null -w "%{http_code}" https://git.example.com     # 200
```

---

## Универсальность подхода

Этот алгоритм работает не только для Gitea. Те же шаги применимы к любым stateful сервисам:

**PostgreSQL/MySQL:**
- Экспортируешь Deployment, Service, PVC
- Меняешь namespace
- PV указывает на тот же NFS путь с базой данных
- Данные остаются нетронутыми

**Nextcloud:**
- Аналогично - файлы на NFS не переносятся
- Только манифесты меняют namespace
- Zero downtime если создаёшь новое до удаления старого

**MinIO (S3-хранилище):**
- Stateful, работает через PV/PVC
- Те же принципы - новый namespace, тот же NFS путь

**Stateless сервисы (nginx, API):**
- Ещё проще - нет PV/PVC вообще
- Только Deployment + Service, меняешь namespace, готово

Главный принцип: **данные живут на PV, который привязан к NFS. Namespace меняется, путь на NFS остаётся.**

## Универсальный чеклист миграции

```
Подготовка
[ ] Проверить данные в поде (find /data)
[ ] Зафиксировать NFS путь (kubectl get pv -o yaml)
[ ] Экспортировать манифесты в отдельную папку

Создание в новом namespace
[ ] Убрать служебные поля (uid, resourceVersion, status)
[ ] Поменять namespace во всех манифестах
[ ] Переименовать PV/PVC (убрать старые префиксы)
[ ] Добавить Certificate объект (не копировать секрет)
[ ] Применить манифест
[ ] Проверить PVC Bound, Pod Running, Certificate True

Переключение
[ ] Убедиться что новый сервис работает (curl HTTP 200)
[ ] Остановить старый (scale --replicas=0)
[ ] Подождать 5 минут, проверить снова

Зачистка
[ ] Удалить Deployment, Service, ConfigMap, Secret
[ ] Удалить IngressRoute
[ ] Удалить PVC
[ ] Удалить PV
[ ] Удалить namespace
[ ] Финальная проверка всех сервисов
```

---

## Итог

Вся миграция Gitea заняла около 10 минут. Даунтайм - 0 секунд: новый под поднялся раньше чем остановили старый, сертификат выпустился за ~32 секунды, данные подхватились с NFS автоматически.

Главные принципы которые работают:

**Один namespace - один проект.** Все сервисы блога живут в одном namespace. Никакого разброса по трём разным местам.

**Сначала создай, потом удаляй.** Никогда не удаляй старое до того как убедился что новое работает.

**Retain политика для PV.** Данные на NFS переживут любые эксперименты с namespace.

**Чисти за собой.** Брошенные IngressRoute и namespace - источник неочевидных проблем в самый неподходящий момент.

---

## Что дальше

Блог работает, два окружения настроены, проблемы диагностируются за минуты, namespace чистый.

В следующей части добавим лайки и просмотры через Firebase Realtime Database и Cloud Firestore, исправим все ошибки интеграции с Blowfish, и обновим Hugo до 0.155 чтобы inline partials заработали.

---

**Стек этой части:**
- Kubernetes 1.30 (K3s)
- Gitea 1.21.11
- NFS для статичных данных
- cert-manager + Let's Encrypt
- kubectl для миграции
