27 KiB
Перенос контейнера из Docker в k3s (на примере Gitea)
Вот эта самая инструкция, котору вы сейчас читаете размещена на моем персональном сервере Gitea. Раньше она размещалась на домашнем хосте Orange Pi 5 в Docker-контейнере, и проксировалась через nginx c добавлением SSL-сертификата Let's Encrypt. Мне показалось, что зависимость от одного хоста -- опасненько. И я решил перенести Gitea в кластер, в котором у меня несколько узлов, и в случае падения одного из них, Gitea продолжит работать на другом узле. К тому же мне очень хотелось подключить старый Orange Pi 5 тоже к кластеру (ведь для этого нужно установить чистую систему и перекомпилировать ядро ).
Я решил задокументировать процесс переноса контейнера из Docker в k3s, тем более Gitea был не единственный контейнер, который нужно было перенести. Возможно мой опыт вам тоже пригодится.
Перенос данных Docker-контейнера Gitea на узел k3s
Останавливаем докер
Архивируем данные gitea
Переносим данные на узел k3s (в моем случае это opi5plus-1) по SCP:
Подготовка узла k3s
Создадим пространство имен для gitea в k3s (чтобы все было аккуратно):
sudo kubectl create namespace gitea
Создаем папку для хранения манифестов gitea:
mkdir -p ~/k3s/gitea
Перемещаем файлы и базу данных SQLite в блочное хранилище k3s
Теперь нам надо перенести данные gitea в k3s в PersistentVolumeClaim (Longhorn). Longhorn -- это блочное хранилище k3s, которое позволяет создавать и управлять блочными томами в кластере для обеспечения высокой доступности и отказоустойчивости. Если узел, на котором находится том, выходит из строя, Longhorn автоматически перемещает том на другой узел и контейнер продолжает работу с томом, как будто ничего не произошло.
Создадим манифест для PersistentVolumeClaim (PVC) и PersistentVolume (PV):
nano ~/k3s/gitea/longhorn-pvc.yaml
Вставляем в него следующее содержимое:
piVersion: v1
kind: PersistentVolumeClaim
metadata:
name: gitea-pvc # Имя PVC-хранилища
namespace: gitea # Пространство имен gitea
spec:
accessModes:
- ReadWriteOnce # Режим доступа к PVC -- чтение и запись
storageClassName: longhorn # Используем Longhorn как класс хранения
resources:
requests:
storage: 10Gi # Размер под данные Gitea 10 Гб (максимальный объем)
Применим манифест:
sudo kubectl apply -f ~/k3s/gitea/longhorn-pvc.yaml
Проверим, что PVC создан и доступен:
sudo kubectl get pvc -n gitea -o wide
Увидим что-то вроде:
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE VOLUMEMODE
gitea-pvc Bound pvc-5a562c67-89b2-48e2-97a6-25035b90729a 10Gi RWO longhorn <unset> 20h Filesystem
Создадим временный под gitea-init-data
для переноса данных gitea с хост-узла в хранилище Longhorn:
nano ~/k3s/gitea/gitea-init-data.yaml
Манифест временного пода:
apiVersion: v1
kind: Pod
metadata:
name: gitea-init-data
namespace: gitea
spec:
nodeName: opi5plus-1 # Привязка к хосту opi5plus-1 (на этом узле у нас лежит архив с данными gitea из docker)
containers:
- name: init-data
image: alpine:latest
command: ["/bin/sh", "-c", "tar -xzf /mnt/gitea-data.tar.gz -C /data && chmod -R 777 /data && ls -la /data && sleep 3600"]
volumeMounts:
- name: gitea-data
mountPath: /data
- name: tmp-data
mountPath: /mnt
volumes:
- name: gitea-data
persistentVolumeClaim:
claimName: gitea-pvc
- name: tmp-data
hostPath:
path: /home/opi/tmp
type: Directory # Указываем, что это папка
restartPolicy: Never
Что тут происходит:
metadata
— задаёт имя подаgitea-init-data
и привязывает его к пространству именgitea
.nodeName
— гарантирует запуск на нужной ноде (в данном случаеopi5plus-1
).containers
— задаёт контейнер с именемinit-data
, который использует образalpine:latest
.command
— выполняет команду в контейнере:tar -xzf /mnt/gitea-data.tar.gz -C /data
— распаковывает архив в/data
.chmod -R 777 /data
— задаёт права на папку/data
.ls -la /data
— выводит содержимое/data
в логи.sleep 3600
— держит под живым 1 час, чтобы ты можно было зайти в sh и проверить, что всё распаковалось.
volumeMounts
— монтирует два тома:- том и именем
gitea-data
монтируем PVC в/data
. - том и именем
tmp-data
монтирует временную папку на хосте в/mnt
.
- том и именем
volumes
— определяет расположение томов:- том
gitea-data
— размещается в PersistentVolumeClaim (PVC) хранилищаgitea-pvc
, которое мы создали ранее. - том
tmp-data
— размещается в каталоге хоста/home/opi/tmp
(как рам, у нас лежит архив с данными gitea из docker).
- том
restartPolicy: Never
— под не будет перезапускаться, если завершится.
Применим манифест:
sudo kubectl apply -f ~/k3s/gitea/gitea-init-data.yaml
Проверим, что под создан и работает:
sudo kubectl get pod -n gitea -o wide
Увидим что-то вроде:
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
gitea-init-data 1/1 Running 0 4m 10.42.2.64 opi5plus-1 <none> <none>
Проверим логи пода:
sudo kubectl logs -n gitea gitea-init-data
Увидим что-то вроде:
total 36
drwxrwxrwx 6 1000 1000 4096 Apr 16 15:09 .
drwxr-xr-x 1 root root 4096 Apr 17 13:01 ..
drwxrwxrwx 5 root root 4096 Apr 16 15:09 git
drwxrwxrwx 18 root root 4096 Apr 17 13:02 gitea
drwxrwxrwx 2 root root 16384 Apr 16 15:27 lost+found
drwxrwxrwx 2 root root 4096 Apr 17 13:01 ssh
Как видим, данные благополучно распаковались в /data
внутри пода, и это Longhorn PVC gitea-pvc
. Можно также "зайти"
в под и посмотреть, что там внутри:
sudo kubectl exec -it -n gitea gitea-init-data -- /bin/sh
Внутри пода дать команду, например:
ls -l /data/gitea/gitea.db
И убедиться, что данные gitea распакованы. Увидим что-то вроде:
-rwxrwxrwx 1 root root 2555904 Apr 16 15:09 /data/gitea/gitea.db
База SQLite gitea.db на месте. Выходим из пода:
exit
Создание пода gitea и подключение к нему хранилища
Теперь нужно создать под с Gitea, подключить к нему PVC gitea-pvc
и проверить, что данные подцепились.
Настройка Аффинити -- предпочтительные узлы
В моем кластере k3s на OrangePi 5 Plus несколько узлов работают на nVME SSD, а некоторые на eMMC. Накопители
SSD быстрее (если интересно, то вот заметка о тестировании производительности дискового накопителя)
и потому если контейнер с Gitea будет работать на SSD, то он будет работать быстрее. Как настроить предпочтение узлов
описано в заметке о аффинити, поэтому кратко: присваиваем узлам метки, например disk=ssd
:
sudo kubectl label nodes opi5plus-1 disk=ssd
sudo kubectl label nodes opi5plus-3 disk=ssd
Проверяем, что метки добавлены:
sudo kubectl get nodes --show-labels | grep "disk=ssd"
Будут показаны только узлы с меткой disk=ssd
. У каждого узда очень много меток.
Создание манифеста для развертывания Gitea
Создадим манифест для развертывания Gitea (deployment):
nano ~/k3s/gitea/gitea-deployment.yaml
Вставляем в него следующее содержимое:
apiVersion: apps/v1
kind: Deployment # определяем тип ресурса -- Deployment (развертывание)
metadata: # определяем метаданные развертывания
name: gitea # имя развертывания `gitea`
namespace: gitea # в пространстве имен `gitea`
labels:
app: gitea
spec:
replicas: 1 # количество реплик (подов) в кластере -- 1
selector:
matchLabels:
app: gitea
template:
metadata:
labels:
app: gitea
spec:
affinity: # определяем аффинити (предпочтения) для узлов
nodeAffinity: # аффинити для узлов
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100 # вес предпочтения -- 100
preference: # предпочтение
matchExpressions: # выражения для соответствия
- key: disk # метка узла `disk`...
operator: In # ...оператор `In` (входит в множество)...
values: # ...значений...
- ssd # ...значение `ssd` (т.е. узлы с SSD)
containers: # определяем контейнеры, которые будут запущены в поде
- name: gitea # имя контейнера `gitea`
image: gitea/gitea:latest # образ
env: # переменные окружения
- name: USER_UID # идентификатор пользователя
value: "1000"
- name: USER_GID # идентификатор группы
value: "1000"
ports: # определяем порты, которые будут открыты в контейнере
- containerPort: 3000 # порт 3000
name: http # будет именоваться 'http' (используется в Gitea для веб-интерфейса)
- containerPort: 22 # порт 22
name: ssh # будет именоваться 'ssh' (используется в Gitea для SSH-доступа)
volumeMounts: # монтируем тома в контейнер
- name: gitea-data # том с именем 'gitea-data'...
mountPath: /data # в каталог '/data' внутри контейнера
- name: timezone # том 'timezone'...
mountPath: /etc/timezone # в каталог '/etc/timezone' внутри контейнера
readOnly: true # только для чтения
- name: localtime # том 'localtime'...
mountPath: /etc/localtime # в каталог '/etc/localtime' внутри контейнера
readOnly: true # только для чтения
volumes: # определяем тома, которые будут использоваться в поде
- name: gitea-data # том с именем 'gitea-data'...
persistentVolumeClaim: # используем PersistentVolumeClaim (PVC Longhorn)
claimName: gitea-pvc # имя PVC 'gitea-pvc'
- name: timezone # том 'timezone'...
hostPath: # использует каталог (или файл) на хосте узла
path: /etc/timezone # путь к файлу '/etc/timezone' на хосте
- name: localtime # том 'localtime'...
hostPath: # использует каталог (или файл) на хосте узла
path: /etc/localtime # путь к файлу '/etc/localtime' на хосте
Применим манифест:
sudo kubectl apply -f ~/k3s/gitea/gitea-deployment.yaml
Проверим, что под создан и работает:
sudo kubectl get pod -n gitea -o wide
Увидим что-то вроде:
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
gitea-55dfcf4dd9-d99nw 1/1 Running 0 74s 10.42.1.94 opi5plus-3 <none> <none>
gitea-init-data 0/1 Completed 0 2h 10.42.2.64 opi5plus-1 <none> <none>
Как видим:
- под
gitea
работает и запущен на уздеopi5plus-3
(там где у нас SSD). В принципе, аффинити не гарантирует запуск пода на узле с SSD, это только предпочтение. Но в данном случае предпочтение сработало. - под
gitea-init-data
завершился. Это нормально, так как он был создан только для переноса данных gitea в Longhorn PVCgitea-pvc
, и должен быть "жив" только 1 час (времени, которое мы указали вsleep 3600
в манифесте пода).
Проверим логи пода gitea
:
sudo kubectl logs -n gitea deployment/gitea
Увидим что-то вроде:
Server listening on :: port 22.
Server listening on 0.0.0.0 port 22.
2025/04/18 17:42:33 cmd/web.go:253:runWeb() [I] Starting Gitea on PID: 17
2025/04/18 17:42:33 cmd/web.go:112:showWebStartupMessage() [I] Gitea version: 1.23.7 built with GNU Make 4.4.1, go1.23.8 : bindata, timetzdata, sqlite, sqlite_unlock_notify
2025/04/18 17:42:33 cmd/web.go:113:showWebStartupMessage() [I] * RunMode: prod
2025/04/18 17:42:33 cmd/web.go:114:showWebStartupMessage() [I] * AppPath: /usr/local/bin/gitea
2025/04/18 17:42:33 cmd/web.go:115:showWebStartupMessage() [I] * WorkPath: /data/gitea
2025/04/18 17:42:33 cmd/web.go:116:showWebStartupMessage() [I] * CustomPath: /data/gitea
2025/04/18 17:42:33 cmd/web.go:117:showWebStartupMessage() [I] * ConfigFile: /data/gitea/conf/app.ini
...
...
...
2025/04/18 17:42:34 cmd/web.go:205:serveInstalled() [I] PING DATABASE sqlite3
...
...
...
2025/04/18 17:42:39 cmd/web.go:315:listen() [I] Listen: http://0.0.0.0:3000
2025/04/18 17:42:39 cmd/web.go:319:listen() [I] AppURL(ROOT_URL): https://git.cube2.ru/
2025/04/18 17:42:39 cmd/web.go:322:listen() [I] LFS server enabled
2025/04/18 17:42:39 ...s/graceful/server.go:50:NewServer() [I] Starting new Web server: tcp:0.0.0.0:3000 on PID: 17
Как видим, Gitea запустилась, порт 3000 (HTTP) и 22 (SSH) открыты, тома подключены, база данных SQLite на месте, права
на папку /data
установлены. Всё работает.
Можно устроить более глубокую проверку, зайдя внутр пода и посмотреть как там всё устроено:
sudo kubectl exec -n gitea -it deployment/gitea -- /bin/bash
Внутри пода проверим, что PVC gitea-pvc
подключен и данные gitea и база на месте:
ls -la /data
ls -l /data/gitea/gitea.db
Проверим открытые порты в поде:
netstat -tuln
Увидим что порты слушаются:
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN
tcp 0 0 :::3000 :::* LISTEN
tcp 0 0 :::22 :::* LISTEN
Проверим, что web-интерфейс Gitea доступен:
curl -v http://localhost:3000
Увидим что-то вроде:
Host localhost:3000 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:3000...
* Connected to localhost (::1) port 3000
* using HTTP/1.x
> GET / HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/8.12.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 303 See Other
< Cache-Control: max-age=0, private, must-revalidate, no-transform
< Content-Type: text/html; charset=utf-8
...
...
...
Как видим, web-интерфейс Gitea доступен внутри пода по http на порту 3000. Все работает. Можно выходить из пода:
exit
Создание сервиса и IngressRoute для доступа к Gitea снаружи
nano ~/k3s/gitea/gitea-service.yaml
Вставляем в него следующее содержимое:
apiVersion: v1
kind: Service
metadata:
name: gitea
namespace: gitea
spec:
selector:
app: gitea
ports:
- name: http
port: 80
targetPort: 3000
#- name: ssh
# port: 22
# targetPort: 222
Объяснение:
selector: app: gitea — находит поды из Deployment gitea. port: 80 — внешний порт сервиса (Traefik будет слать трафик сюда). targetPort: 3000 — порт контейнера Gitea.
Применим манифест:
sudo kubectl apply -f ~/k3s/gitea/gitea-service.yaml
sudo kubectl get svc -n gitea -o wide NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR gitea ClusterIP 10.43.211.8 80/TCP 115s app=gitea
nano ~/k3s/gitea/https-redirect-middleware.yaml
apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
name: https-redirect
namespace: gitea
spec:
redirectScheme:
scheme: https
permanent: true
Объяснение:
redirectScheme: scheme: https — перенаправляет запросы на HTTPS. permanent: true — возвращает 301 (постоянный редирект) для SEO и кэширования. Размещаем в gitea, чтобы не затрагивать другие сервисы.
Применить:
sudo kubectl apply -f ~/k3s/gitea/https-redirect-middleware.yaml
У нас уже настроена выдача сертификатов Let’s Encrypt в подах cert-managerб cert-manager-cainjector и cert-manager-webhook, в пронстве имен cert-manager. Это нестандартный способ (см. заметку о cert-manager).
Проверим, что cert-manager работает:
sudo kubectl get pods -n cert-manager
Увидим что-то вроде:
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
cert-manager-64478b89d5-p4msl 1/1 Running 2 (5d13h ago) 19d 10.42.1.55 opi5plus-3 <none> <none>
cert-manager-cainjector-65559df4ff-t7rj4 1/1 Running 5 (5d13h ago) 19d 10.42.1.54 opi5plus-3 <none> <none>
cert-manager-webhook-544c988c49-zxdxc 1/1 Running 0 19d 10.42.1.56 opi5plus-3 <none> <none>
Поды cert-manager
, cainjector
, webhook
должны быть Running.
Проверим наличие ClusterIssuer:
sudo kubectl get clusterissuer -A -o wide
Увидим что-то вроде:
NAME READY STATUS AGE
letsencrypt-prod True The ACME account was registered with the ACME server 19d
Проверим, что работает и Let's Encrypt знает о нас:
sudo kubectl describe clusterissuer letsencrypt-prod
Увидим что-то вроде:
Name: letsencrypt-prod
Namespace:
Labels: <none>
Annotations: <none>
API Version: cert-manager.io/v1
Kind: ClusterIssuer
...
...
...
Status:
Acme:
Last Private Key Hash: тут-будет-хэш-вашего-ключа=
Last Registered Email: тут-будет-ваш-email
Uri: https://acme-v02.api.letsencrypt.org/acme/acct/тут-будет-id-вашего-аккаунта
Conditions:
...
...
Status: True
Type: Ready
Events: <none>
Важно чтобы Status: Conditions: Ready:
был True
.
Создадим манифест для получения сертификата Let's Encrypt:
nano ~/k3s/gitea/gitea-certificate.yaml
и вставим в него следующее содержимое:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: gitea-tls
namespace: gitea
spec:
secretName: gitea-tls
dnsNames:
- git.cube2.ru
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
secretName: gitea-tls: Сертификат сохраняется в Secret gitea-tls в gitea. dnsNames: Домен git.cube2.ru. issuerRef: Ссылается на ClusterIssuer letsencrypt-prod.
Применим манифест:
sudo kubectl apply -f ~/k3s/gitea/gitea-certificate.yaml
Проверим, что секрет создан:
sudo kubectl get secret -n gitea gitea-tls -o wide
Увидим что-то вроде:
NAME TYPE DATA AGE
gitea-tls kubernetes.io/tls 2 46s
Проверим, что сертификат выдан:
sudo kubectl describe certificate -n gitea gitea-tls
Увидим что-то вроде:
Name: gitea-tls
Namespace: gitea
...
...
...
Spec:
Dns Names:
тут-будет-ваш-домен
...
...
Status:
Conditions:
Last Transition Time: тут-будет-дата-время-выдачи-сертификата
Message: Certificate is up to date and has not expired
Observed Generation: 1
Reason: Ready
Status: True
Type: Ready
Not After: тут-будет-дата-время-окончания-действия-сертификата
Not Before: тут-будет-дата-время-начала-действия-сертификата
Renewal Time: тут-будет-дата-время-ближайшего-обновления-сертификата
Revision: 1
Events:
...
...
Ожидается Status: True
в Conditions
, свежие даты в Not After
и Not Before
и сообщение Certificate is up to date and has not expired
в Message
.
Создать IngressRoute для HTTPS Настроим IngressRoute для маршрутизации git.cube2.ru через HTTPS с Let’s Encrypt и редиректом HTTP.
nano ~/k3s/gitea/gitea-ingressroute.yaml
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
name: gitea-http
namespace: gitea
spec:
entryPoints:
- web
routes:
- match: Host(`git.cube2.ru`)
kind: Rule
services:
- name: gitea
port: 80
middlewares:
- name: https-redirect
---
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
name: gitea-https
namespace: gitea
spec:
entryPoints:
- websecure
routes:
- match: Host(`git.cube2.ru`)
kind: Rule
services:
- name: gitea
port: 80
tls:
secretName: gitea-tls
# Если у вас стандартная настройка Traefik, то раскомментируйте следующую строку (а строку выше закомментируйте)
# certResolver: letsencrypt
Объяснение:
Первая IngressRoute (gitea-http): entryPoints: web — слушает порт 80 (HTTP). match: Host(git.cube2.ru) — обрабатывает запросы к git.cube2.ru. services: gitea — направляет трафик в Service gitea (порт 80 → 3000). middlewares: https-redirect — редирект на HTTPS. Вторая IngressRoute (gitea-https): entryPoints: websecure — слушает порт 443 (HTTPS). tls: certResolver: letsencrypt — включает Let’s Encrypt для автоматического получения сертификата. Трафик идёт в тот же Service gitea.
Применим манифест:
sudo kubectl apply -f ~/k3s/gitea/gitea-ingressroute.yaml
Можно удалить временный под:
sudo kubectl delete pod gitea-init-data -n gitea