oakazanin/content/posts/blog-part-6-namespace-migra.../index.md

390 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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.

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