feat: Блог на Hugo в K3s: часть 6 - миграция между namespace

This commit is contained in:
2026-02-22 17:08:56 +00:00
parent 44d254f1db
commit 11b97341aa
116 changed files with 8784 additions and 887 deletions
@@ -0,0 +1,389 @@
---
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 для миграции