Перейти к основному содержимому

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

·7 минут· loading · loading ·
Олег Казанин
Автор
Олег Казанин
Строю полезную инфраструктуру на Open Source стеке. Документирую грабли, чтобы вы на них не наступали.
Оглавление
Блог на Hugo в K3s - Эта статья — часть серии.
Часть 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: Убеждаемся что данные целы
#

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

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

Видим структуру:

/data/git/repositories/
/data/gitea/gitea.db
/data/gitea/conf

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

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

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

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

# Создаём папку для бэкапов
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-pvgitea-pv
  • blog-gitea-pvcgitea-pvc

Собираем всё в один файл gitea.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 сертификат
#

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

Скопировать существующий секрет:

# Копируем секрет из старого 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
#

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

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

# 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 открывается и данные на месте:

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

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

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

# Останавливаем 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 реплик - страховка. Если что-то пошло не так, поднимаем обратно за секунду:

kubectl scale deployment gitea -n blog --replicas=1

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

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

# Сначала удаляем 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 остаются нетронутыми. Это страховка от случайного удаления.


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

# 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 для миграции
Блог на Hugo в K3s - Эта статья — часть серии.
Часть 6: Ты уже здесь

Статьи по теме