390 lines
16 KiB
Markdown
390 lines
16 KiB
Markdown
---
|
||
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 для миграции
|