diff --git a/content/posts/blog-part-6-namespace-migration/featured.png b/content/posts/blog-part-6-namespace-migration/featured.png new file mode 100644 index 0000000..f1d2953 Binary files /dev/null and b/content/posts/blog-part-6-namespace-migration/featured.png differ diff --git a/content/posts/blog-part-6-namespace-migration/index.md b/content/posts/blog-part-6-namespace-migration/index.md new file mode 100644 index 0000000..bbe36bf --- /dev/null +++ b/content/posts/blog-part-6-namespace-migration/index.md @@ -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 для миграции diff --git a/public/404.html b/public/404.html index 814e3d3..b6d44bb 100644 --- a/public/404.html +++ b/public/404.html @@ -24,7 +24,7 @@ - + @@ -64,7 +64,7 @@ - + @@ -109,6 +109,8 @@ + + @@ -117,8 +119,8 @@ + href="/css/main.bundle.min.f7f4ea4d52ba0bdbea7d2aa148eb5d7e983ff2cae72189d5c1559a358b6970345d94eaef53a263ed180a9af4efa0eaff8d1be71018580e0826e3cc5959a0a23d.css" + integrity="sha512-9/TqTVK6C9vqfSqhSOtdfpg/8srnIYnVwVWaNYtpcDRdlOrvU6Jj7RgKmvTvoOr/jRvnEBhYDggm48xZWaCiPQ=="> @@ -233,7 +235,7 @@ "headline": "404 Page not found", "inLanguage": "ru", - "url" : "http://localhost:1313/404.html", + "url" : "http://192.168.11.190:1313/404.html", "author" : { "@type": "Person", "name": "Олег Казанин" @@ -548,7 +550,7 @@