--- 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 для миграции