feat: Блог на Hugo в K3s: часть 6 - миграция между namespace
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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 для миграции
|
||||
Reference in New Issue
Block a user