Compare commits

...

13 Commits

7 changed files with 1655 additions and 55 deletions

View File

@@ -11,9 +11,11 @@
## Kubernetes (k3s/k8s) ## Kubernetes (k3s/k8s)
* [Установка k3s на Orange Pi 5 Plus](raspberry-and-orange-pi/k3s.md) * [Установка k3s на Orange Pi 5 Plus](raspberry-and-orange-pi/k3s.md)
* [Под с Shadowsocks-клиент](kubernetes/k3s-shadowsocks-client.md) (k3s) * [Под с Shadowsocks-клиент](kubernetes/k3s-shadowsocks-client.md) в k3s
* [Под с 3X-UI](kubernetes/k3s-3xui-pod.md) (k3s) * [Подключение менеджера сертификатов (cert-manager) Let's Encrypt](kubernetes/k3s-lets-encrypt-cert-manager.md) к k3s
* [Под с 3X-UI](kubernetes/k3s-3xui-pod.md) в k3s
* [Проксирование внешнего хоста через Traefik (Ingress-контроллер)](kubernetes/k3s-proxy.md) * [Проксирование внешнего хоста через Traefik (Ingress-контроллер)](kubernetes/k3s-proxy.md)
* [Перенос контейнера Docker в k3s](kubernetes/k3s-migrating-container-from-docker-to-kubernetes.md)
## Python ## Python
* [Устранение проблем при установке Python-коннектора mysqlclient (MySQL/MariaDB)](python/python-mysql.md) * [Устранение проблем при установке Python-коннектора mysqlclient (MySQL/MariaDB)](python/python-mysql.md)

View File

@@ -321,15 +321,13 @@ Keepalived добавляет 50 к базовому приоритету нод
### Доступ через Ingress Controller по имени домена (http). ### Доступ через Ingress Controller по имени домена (http).
Сейчас web-панель 3x-ui доступна через VIP по порту `2053` по http. _В принципе, так можно и оставить_. Но если мы хотим Сейчас web-панель 3x-ui доступна через VIP по порту `2053` по http. _В принципе, так можно и оставить_. Но если мы хотим
иметь доступ по https, да еще чтобы это работало через доменное имя, и чтобы k3s автоматически получал и обновлял иметь доступ по https, да еще чтобы это работало через доменное имя, и чтобы k3s автоматически получал и обновлял
сертификаты, то можно использовать Ingress-контроллер. Он будет брать трафик с порта VIP, по порту `2055`, через сертификаты, то можно использовать Ingress-контроллер. Он будет брать трафик с порта VIP, по порту `2055`, через
балансировщик svclb-traefik направлять его на Ingress-контроллер Traefik и перенаправлять его на под с 3x-ui (тоже балансировщик svclb-traefik направлять его на Ingress-контроллер Traefik и перенаправлять его на под с 3x-ui (тоже
через VIP но уже по порту `2053`). через VIP но уже по порту `2053`). Дополнительно, есть [заметка про настройку Traefik в качестве прокси](k3s-proxy.md).
#### Манифест для Ingress-контроллера Traefik #### Манифест для Ingress-контроллера Traefik

198
kubernetes/k3s-affinate.md Normal file
View File

@@ -0,0 +1,198 @@
# Аффинность узлов в K3s
Аффинность узлов (node affinity) в K3s позволяет управлять размещением подов на определённых узлах кластера.
Это полезно, когда нужно обеспечить запуск приложения на узле с определёнными характеристиками или метками.
?Привязка узлов может быть и жесткой и не жесткой.
## Под с предпочтительной привязкой к узлу
У меня есть [VPN-под на 3x-ui](k3s-3xui-pod.md), и предпочтительно, чтобы он при старте k3s запускался на узле
c наивысшем приоритетом в keepalive (у меня это хост `opi5plus-1`), но если нода "упадет" и под 3x-ui "переедет"
на другой узел, то keepalive повысит приоритет т ноды При этом возвращать под на исходный узел после его восстановления не обязательно.
(например, opi5plus-1), но мог переезжать на другие узлы (например, opi5plus-2 или opi5plus-3), если что-то пойдёт не так с основным узлом. При этом возвращать под на исходный узел после его восстановления не обязательно.
Ответ: Да, это возможно! В Kubernetes для этого используется механизм node affinity (аффинность к узлам). Он позволяет указать "мягкое" предпочтение для запуска пода на определённом узле, но не запрещает поду перемещаться, если нужного узла нет.
Как настроить node affinity
Допустим, ты хочешь, чтобы под запускался на opi5plus-1 по умолчанию, но мог переехать на другие узлы при необходимости.
Шаги:
Проверь или добавь метки узлам: Убедись, что твои узлы имеют метки (labels). Например:
bash
Collapse
Wrap
Copy
kubectl label nodes opi5plus-1 preferred=true
kubectl label nodes opi5plus-2 preferred=false
kubectl label nodes opi5plus-3 preferred=false
Здесь preferred=true — это метка, которая указывает предпочтительный узел.
Обнови манифест деплоя 3x-ui: Добавь секцию affinity в спецификацию пода. Пример:
yaml
Collapse
Wrap
Copy
apiVersion: apps/v1
kind: Deployment
metadata:
name: x-ui
namespace: x-ui
spec:
replicas: 1
selector:
matchLabels:
app: x-ui
template:
metadata:
labels:
app: x-ui
spec:
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 1
preference:
matchExpressions:
- key: preferred
operator: In
values:
- "true"
containers:
- name: x-ui
image: ghcr.io/mhsanaei/3x-ui:latest
# Остальные настройки контейнера...
Что здесь происходит:
preferredDuringSchedulingIgnoredDuringExecution — это "мягкое" правило. Kubernetes постарается запустить под на узле с меткой preferred=true, но если это невозможно (например, узел недоступен), под запустится на любом другом доступном узле.
weight: 1 — вес предпочтения. Если у тебя будет несколько правил, можно задавать приоритеты, но для одного правила это не так важно.
Как это работает:
Если узел opi5plus-1 доступен, под запустится там.
Если opi5plus-1 упадёт, под автоматически переедет на другой узел (например, opi5plus-2).
После восстановления opi5plus-1 под останется на текущем узле и не вернётся автоматически, что соответствует твоему запросу.
Как это сочетается с Keepalived?
Твой скрипт проверки в Keepalived гарантирует, что виртуальный IP будет следовать за подом 3x-ui. Если ты задашь opi5plus-1 как предпочтительный узел через affinity и дашь ему высокий приоритет в Keepalived (например, 100), то в нормальной ситуации под и IP будут на одном узле. А если под переедет из-за сбоя, Keepalived переключит IP на новый узел благодаря скрипту.
Итог
Скрипт проверки: sudo не нужен, так как Keepalived запускает скрипт от root. Если будут проблемы с доступом, добавь --kubeconfig с путём к файлу конфигурации.
Приоритет узла для пода: Используй node affinity с "мягким" предпочтением (preferredDuringSchedulingIgnoredDuringExecution). Это позволит поду запускаться на нужном узле, но свободно перемещаться при сбоях без обязательного возвращения.
Твоя идея с приоритетами и "кочеванием" пода отлично реализуема, и связка Keepalived + affinity обеспечит гибкость и надёжность. Удачи с настройкой, если что — пиши! 😊
Отлично. Ты мне отлично помог и 3x-ui заработал на VIP. Все сработало как ты сказал. Объясни пожалуйста про метки:
```
kubectl label nodes opi5plus-1 preferred=true
kubectl label nodes opi5plus-2 preferred=false
kubectl label nodes opi5plus-3 preferred=false
```
это означает, что все поды будут предпочитать opi5plus-1 или это касается только подов с nodeAffinity?
3 / 3
Completed
42s
Thinking
Исследую использование меток
Thinking
Исследую использование меток
Пользователь интересуется метками в K3s и их применением для привязки узлов. Они пометили один узел как "preferred=true", а другие как "preferred=false".
Метки в K3s — это пары ключ-значение, прикрепленные к объектам, таким как узлы, поды, сервисы. Они помогают организовать и выбрать подмножества объектов.
Привязка узлов позволяет указать, на каких узлах должен размещаться под. Есть два типа: обязательная и предпочтительная.
Метка "preferred=true" влияет только на поды с привязкой узлов, ищущими эту метку. Поды без таких правил размещаются без учета этой метки.
Все три узла имеют метку "preferred", но с разными значениями: opi5plus-1 — true, opi5plus-2 и opi5plus-3 — false.
Если под не имеет привязки узлов, планировщик размещает его на любом узле с нужными ресурсами, игнорируя метку "preferred".
Под без nodeSelector или nodeAffinity размещается на любом узле с необходимыми ресурсами. Метка "preferred" важна только для подов, ссылающихся на нее.
В K8s, метка "preferred=false" добавляет метку "preferred" со значением "false" на узел. Если метки нет, узел ее не имеет.
Под с предпочтительной привязкой сначала пытается разместиться на opi5plus-1, где preferred=true. Если не получится, может быть размещен на opi5plus-2 или opi5plus-3.
Метка "preferred=true" влияет только на поды с правилами, ищущими эту метку. Другие поды размещаются по другим критериям.
В стандартном K3s нет поведения, которое по умолчанию использует метки, такие как "preferred", для влияния на планирование. Метка важна только для подов, явно ссылающихся на нее в правилах привязки.
Другие поды, без ссылок на эту метку, будут размещены без учета "preferred", основываясь на доступности ресурсов.
Пример: под без привязки узлов размещается на любом узле, игнорируя метку "preferred". Это подтверждает, что метка влияет только на поды с соответствующими правилами.
Если под указывает nodeSelector с preferred="false", он размещается только на узлах, где preferred=false, например, opi5plus-2 или opi5plus-3.
Метка "preferred" влияет только на поды с правилами привязки или селектора, ссылающимися на эту метку. Поды без таких правил не затрагиваются.
Метка "preferred" важна только для подов, таких как 3x-ui, с установленной предпочтительной привязкой. Другие поды в кластере размещаются по своим спецификациям, если нет похожих правил.
Метки на узлах важны только тогда, когда правила планирования пода ссылаются на них. Например, под может исключать узлы, где preferred=true, если указано соответствующее правило.
Прямой ответ
Метки, такие как preferred=true или preferred=false, влияют только на поды с указанной аффинностью узлов, которые ссылаются на эту метку.
Другие поды, без аффинности, размещаются без учёта этих меток, основываясь на доступных ресурсах.
Влияние на поды
Метки на узлах, вроде preferred, не имеют значения для подов, если те не имеют правил аффинности, ссылающихся на эту метку. Например, под с аффинностью, предпочитающей preferred=true, будет стараться запускаться на opi5plus-1, но может переместиться на другие узлы, если opi5plus-1 недоступен.
Неожиданный нюанс
Интересно, что метки могут использоваться в других правилах, например, для исключения узлов (NotIn), но это требует явного указания в спецификации пода, что выходит за рамки стандартного поведения.
Отчёт
Введение
Метки (labels) в K3s — это ключевые пары, прикрепляемые к объектам, таким как узлы, поды и сервисы, для организации и выбора подмножества объектов. В контексте планирования подов метки на узлах используются для влияния на их размещение через механизмы, такие как аффинность узлов (node affinity). Пользователь задал вопрос о влиянии меток preferred=true и preferred=false, установленных на узлы, на все поды или только на те, у которых указана аффинность узлов.
Анализ влияния меток
Метки на узлах, такие как preferred=true или preferred=false, добавляются через команды вроде kubectl label nodes opi5plus-1 preferred=true. Эти метки сами по себе не имеют встроенного значения для планировщика K3s, если поды не ссылаются на них в своих правилах размещения.
В K3s, основанном на Kubernetes, планировщик подов (scheduler) размещает поды на узлах, учитывая доступные ресурсы (CPU, память) и любые ограничения, указанные в спецификации пода. Метки становятся значимыми только тогда, когда под использует их в правилах nodeSelector, nodeAffinity или nodeAntiAffinity.
Пользователь настроил метки следующим образом:
opi5plus-1: preferred=true
opi5plus-2: preferred=false
opi5plus-3: preferred=false
И в манифесте пода 3x-ui указал аффинность:
yaml
Collapse
Wrap
Copy
spec:
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 1
preference:
matchExpressions:
- key: preferred
operator: In
values:
- "true"
Это означает, что под предпочтительно будет размещён на узле с меткой preferred=true, то есть на opi5plus-1. Если такой узел недоступен, под может быть размещён на opi5plus-2 или opi5plus-3, где preferred=false.
Влияние на поды без аффинности
Поды, у которых не указаны nodeSelector, nodeAffinity или nodeAntiAffinity, размещаются планировщиком K3s без учёта метки preferred. Планировщик выбирает узел на основе:
Доступных ресурсов.
Отсутствия конфликтов (например, taints).
Других факторов, таких как анти-аффинность подов или балансировка нагрузки.
Таким образом, метки preferred=true и preferred=false не влияют на поды, у которых нет правил, ссылающихся на эту метку. Например, поды Shadowsocks, если они не имеют аффинности, будут размещаться на любом узле без учёта preferred.
Пример с таблицей
Вот таблица, которая иллюстрирует влияние меток на поды:
Под Имеет аффинность к preferred? Узел предпочтения Возможные узлы при сбое
3x-ui Да, preferred=true opi5plus-1 opi5plus-2, opi5plus-3
Shadowsocks (без аффинности) Нет Любой доступный узел Любой доступный узел
Заключение
Метки preferred влияют только на поды с аффинностью, ссылающейся на эту метку, и не затрагивают остальные поды. Это позволяет гибко управлять размещением, сохраняя свободу для других приложений.
Ключевые цитаты
Kubernetes Node Affinity and Anti-Affinity https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#node-affinity
K3s Documentation on Node Labels https://grok.com/chat/6a083b91-ea05-4246-9407-b9b25af4b48f#:~:text=K3s%20Documentation%20on%20Node%20Labels

View File

@@ -0,0 +1,212 @@
# Подключение менеджера сертификатов (cert-manager) Let's Encrypt к k3s
№ Установка cert-manager
Установим `cert-manager` из официального манифеста GitHub. Это автоматически добавит все необходимые компоненты,
включая CRD (Custom Resource Definitions) и контроллеры:
```bash
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.15.3/cert-manager.yaml
```
Это установит cert-manager версии 1.15.3 (предварительно надо проверь актуальную версию на GitHub cert-manager).
Компоненты развернутся в namespace cert-manager.
Проверим установку:
```bash
kubectl get pods --namespace cert-manager
```
Увидим, что все поды в статусе `Running`:
```text
NAME READY STATUS RESTARTS AGE
cert-manager-778cb8f45-gxtpf 1/1 Running 0 22m
cert-manager-cainjector-6d5854cdd8-w4d8x 1/1 Running 0 22m
cert-manager-webhook-78b57fb6bb-jljdx 1/1 Running 0 22m
```
Что это за поды:
- `cert-manager` — основной контроллер cert-manager, который управляет созданием и обновлением сертификатов.
- `cert-manager-cainjector` — контроллер, который автоматически добавляет CA-сертификаты в секреты и ресурсы Kubernetes.
- `cert-manager-webhook` — контроллер, который обрабатывает запросы на валидацию и мутацию ресурсов cert-manager.
Проверим, что Custom Resource Definitions (CRDs) для cert-manager созданы:
```bash
kubectl get crd | grep cert-manager
```
Должны быть `certificates.cert-manager.io`, `issuers.cert-manager.io`, `clusterissuers.cert-manager.io и другие:
```text
certificaterequests.cert-manager.io 2025-04-27T16:29:56Z
certificates.cert-manager.io 2025-04-27T16:29:56Z
challenges.acme.cert-manager.io 2025-04-27T16:29:56Z
clusterissuers.cert-manager.io 2025-04-27T16:29:56Z
issuers.cert-manager.io 2025-04-27T16:29:56Z
orders.acme.cert-manager.io 2025-04-27T16:29:57Z
```
# Создание ClusterIssuer (Эмитент сертификатов для всего кластера)
Создадим `ClusterIssuer` для Let's Encrypt. Это позволит cert-manager автоматически запрашивать и обновлять сертификаты.
Разместим его в отдельном файле `~/k3s/cert-manager/letsencrypt-clusterissuer.yaml`:
```bash
mkdir -p ~/k3s/cert-manager
nano ~/k3s/cert-manager/letsencrypt-clusterissuer.yaml
```
И вставим в него следующий код (не забудьте заменить `<my@email.com>` на свой email):
```yaml
apiVersion: cert-manager.io/v1 # Версия API для cert-manager, v1 — текущая стабильная.
kind: ClusterIssuer # Тип ресурса. ClusterIssuer — глобальный объект для выдачи сертификатов
# всему кластеру (в отличие от Issuer, который ограничен namespace).
metadata:
name: letsencrypt-prod # Имя `ClusterIssuer`, на него будем ссылаться в манифестах (например, в Certificate).
spec:
acme: # Используется ACME-протокол (Automated Certificate Management Environment) Let's Encrypt.
server: https://acme-v02.api.letsencrypt.org/directory # URL сервера Let's Encrypt (боевой).
email: <my@email.com> # Логин-клиент Let's Encrypt (раньше так же использовался для уведомлений).
privateKeySecretRef:
name: letsencrypt-prod # Имя секрета, где будет храниться приватные ключи для ACME-аккаунта.
solvers: # Список способов "решения" ACME-задач для подтверждения владения доменом.
- http01: # Используется HTTP-01 challenge (подтверждение через HTTP-запросы).
ingress:
class: traefik # Указывает, что ingress-контроллер Traefik будет обрабатывать HTTP-01 challenge
# (создает временные маршруты для `/.well-known/acme-challenge`) для подтверждения
# владения доменом.
```
Применим манифест:
```bash
kubectl apply -f ~/k3s/cert-manager/letsencrypt-clusterissuer.yaml
```
Проверим, что `ClusterIssuer` создан:
```bash
kubectl get clusterissuer
```
Увидим что-то вроде:
```text
NAME READY AGE
letsencrypt-prod True 27s
```
Проверим статус:
```bash
kubectl describe clusterissuer letsencrypt-prod
```
Увидим что-то вроде:
```text
...
...
Status:
Acme:
Last Private Key Hash: тут-хранится-хэш-приватного-ключа
Last Registered Email: <my@email.com>
Uri: https://acme-v02.api.letsencrypt.org/acme/acct/2365771147
Conditions:
Last Transition Time: 2025-04-27T17:00:56Z
Message: The ACME account was registered with the ACME server
Observed Generation: 1
Reason: ACMEAccountRegistered
Status: True
Type: Ready
...
```
Должны быть `Status: True` и `Reason: ACMEAccountRegistered`, что означает, что `ClusterIssuer` успешно зарегистрирован
и готов к использованию.
# Создание сертификата
Эта общая часть, объясняющая как создавать манифесты для выпуска сертификатов.
В своем манифесте, например, в манифесте пода, создается блок (данный кусочек взят из манифеста пода
[Gitea](k3s-migrating-container-from-docker-to-kubernetes.md) и он находится в пространстве имен `gitea`):
```yaml
---
# Манифест для получения сертификата от Let's Encrypt
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: gitea-tls # Имя сертификата `gitea-tls`
namespace: gitea # В пространстве имен `gitea`
spec:
secretName: gitea-tls # Имя секрета для сертификата
dnsNames: # Доменные имена для сертификата...
- git.cube2.ru
issuerRef: # Эмитент сертификата (Issuer)
name: letsencrypt-prod # Имя ClusterIssuer, который отвечает за получение сертификата: `letsencrypt-prod`
kind: ClusterIssuer # Тип эмитента
```
После применения манифеста, можно проверить статус сертификата:
```bash
kubectl get certificate -A
```
Увидим что-то вроде:
```text
NAMESPACE NAME READY SECRET AGE
ab-shelf audiobookshelf-tls False audiobookshelf-tls 4h
gitea gitea-tls True gitea-tls 14m
```
Тут можно заметить, что сертификат `audiobookshelf-tls` не готов (False), а `gitea-tls` готов (True).
Как проверить статус сертификата и причину его неготовности (на примере `audiobookshelf-tls`):
```bash
kubectl describe certificate audiobookshelf-tls -n ab-shelf
```
Увидим что-то вроде:
```text
Name: audiobookshelf-tls
Namespace: ab-shelf
...
...
...
Spec:
Dns Names:
<тут-будет-доменное-имя-для-которого-выдается-сертификат>
Issuer Ref:
Kind: ClusterIssuer
Name: letsencrypt-prod
Secret Name: audiobookshelf-tls
Status:
Conditions:
Last Transition Time: 2025-04-27T17:13:32Z
Message: The certificate request has failed to complete and will be retried: Failed to wait for order resource "audiobookshelf-tls-1-722111375" to become ready: order is in "errored" state: Failed to create Order: 429 urn:ietf:params:acme:error:rateLimited: too many certificates (5) already issued for this exact set of domains in the last 168h0m0s, retry after 2025-04-28 19:30:09 UTC: see https://letsencrypt.org/docs/rate-limits/#new-certificates-per-exact-set-of-hostnames
Observed Generation: 1
Reason: Failed
Status: False
Type: Issuing
Last Transition Time: 2025-04-27T17:13:29Z
Message: Issuing certificate as Secret does not exist
Observed Generation: 1
Reason: DoesNotExist
Status: False
Type: Ready
...
...
...
```
Как видно, причина неготовности сертификата `audiobookshelf-tls` в том, что уже превышен лимит на количество сертификатов
(5) для данного доменного имени за последние 168 часов (7 дней). Указано время после которого можно повторить запрос.
По идее запрос на повторный сертификат будет отправлен автоматически, но это может произойти спустя несколько часов
после разрешенного времени (учтите что время указывается в UTC, делайте поправку по своему локальному времени).
Чтобы ускорить процесс, можно удалить сертификат и создать его заново (на примере `audiobookshelf-tls`):
```bash
kubectl delete certificate audiobookshelf-tls -n ab-shelf
```
А затем повторно принять манифест, в котором у вас находится `kind: Certificate`
| Заметка |
|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Пока Let's Encrypt не выдал сертификат, Traefik будет работать по SSL (https) на самоподписанном сертификате. Можно открыть анонимное окно браузера, согласится с предупреждениями безопасности и пользоваться сайтом. |
| |

View File

@@ -0,0 +1,969 @@
# Перенос контейнера из Docker в k3s (на примере Gitea)
Вот эта самая инструкция, котору вы сейчас читаете размещена на моем персональном сервере Gitea. Раньше она размещалась
на домашнем хосте Orange Pi 5 в Docker-контейнере, и проксировалась через nginx c добавлением SSL-сертификата
Let's Encrypt. Мне показалось, что зависимость от одного хоста -- опасненько. И я решил перенести Gitea в кластер,
в котором у меня несколько узлов, и в случае падения одного из них, Gitea продолжит работать на другом узле. К тому
же мне очень хотелось подключить старый Orange Pi 5 тоже к кластеру (ведь для этого нужно установить чистую систему и
[перекомпилировать ядро](../raspberry-and-orange-pi/opi5plus-rebuilding-linux-kernel-for-iscsi.md)
).
Я решил задокументировать процесс переноса контейнера из Docker в k3s, тем более Gitea не единственный контейнер,
который мне нужно было перенести. Возможно мой опыт вам тоже пригодится.
## Перенос данных Docker-контейнера Gitea на узел k3s
На старом хосте Gitea работал в контейнере через docker-compose. Вот её `docker-compose.yaml`:
```yaml
version: '3'
services:
gitea:
image: gitea/gitea:latest
container_name: gitea
restart: always
environment:
- USER_UID=1000
- USER_GID=1000
ports:
- "3000:3000"
- "2222:22"
volumes:
- /home/orangepi/docker-data/gitea-new:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
```
Как видим, в контейнере Gitea (с именем `gitea-new`) в качестве базы используется SQLite. Файл базы, конфиги
и остальные данные хранятся в каталоге `/data` контейнера, смонтированного во внешний том (volume)
`/home/orangepi/docker-data/gitea-new`. Нам нужно будет перенести все эти данные в кластер k3s, в блочное
хранилище Longhorn. Для этого создадим архив с данными gitea и перенесем его на один из узлов k3s.
Чтобы забрать данные из контейнера, нужно остановить его (на хосте с Docker):
```bash
docker stop docker stop gitea-new
```
### Архивируем данные gitea
Находясь на хосте в Docker, cоздадим архив с данными gitea:
```bash
tar -czf /home/orangepi/gitea-data.tar.gz -C /home/orangepi/docker-data/gitea-new .
```
Проверим, что архив создался:
```bash
ls -alh /home/orangepi/gitea-data.tar.gz
```
Увидим что-то вроде:
```text
-rw-r--r-- 1 orangepi orangepi 147M Apr 16 18:10 /home/orangepi/gitea-data.tar.gz
```
### Перенесём данные на узел k3s по SCP:
Находясь на хосте с Docker, перенесем архив с данными gitea на узел k3s (в моем случае это `opi5plus-1`):
```bash
scp /home/orangepi/gitea-data.tar.gz <USER-NAME>@opi5plus-1:/home/<USER-NAME>/
```
Перейдем на узел `opi5plus-1` k3s и проверим, что архив с данными gitea там есть:
```bash
ls -alh ~/gitea-data.tar.gz
```
Увидим что-то вроде:
```text
-rw-r--r-- 1 <USER-NAME> <USER-NAME> 147M Apr 16 18:12 /home/<USER-NAME>/gitea-data.tar.gz
```
Архив с данными gitea на месте. Теперь можно переходить к следующему шагу.
## Подготовка узла k3s
Создадим пространство имен для gitea в k3s (чтобы все было аккуратно):
```bash
sudo kubectl create namespace gitea
```
Создаем папку для хранения манифестов gitea:
```bash
mkdir -p ~/k3s/gitea
```
## Перемещаем файлы и базу данных SQLite в блочное хранилище k3s
Теперь нам надо перенести данные gitea в k3s в PersistentVolumeClaim (Longhorn). Longhorn -- это блочное хранилище k3s,
которое позволяет создавать и управлять блочными томами в кластере для обеспечения высокой доступности
и отказоустойчивости. Если узел, на котором находится том, выходит из строя, Longhorn автоматически перемещает том
на другой узел и контейнер продолжает работу с томом, как будто ничего не произошло.
Создадим манифест для PersistentVolumeClaim (PVC) и PersistentVolume (PV):
```bash
nano ~/k3s/gitea/longhorn-pvc.yaml
```
Вставляем в него следующее содержимое:
```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 Гб (максимальный объем)
```
Применим манифест:
```bash
sudo kubectl apply -f ~/k3s/gitea/longhorn-pvc.yaml
```
Проверим, что PVC создан и доступен:
```bash
sudo kubectl get pvc -n gitea -o wide
```
Увидим что-то вроде:
```text
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:
```bash
nano ~/k3s/gitea/gitea-init-data.yaml
```
Манифест временного пода:
```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/<USER-NAME>
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/<USER-NAME>` (как рам, у нас лежит архив с данными gitea
из docker).
- `restartPolicy: Never` — под не будет перезапускаться, если завершится.
Применим манифест:
```bash
sudo kubectl apply -f ~/k3s/gitea/gitea-init-data.yaml
```
Проверим, что под создан и работает:
```bash
sudo kubectl get pod -n gitea -o wide
```
Увидим что-то вроде:
```text
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>
```
Проверим логи пода:
```bash
sudo kubectl logs -n gitea gitea-init-data
```
Увидим что-то вроде:
```text
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`. Можно также "зайти"
в под и посмотреть, что там внутри:
```bash
sudo kubectl exec -it -n gitea gitea-init-data -- /bin/sh
```
Внутри пода дать команду, например:
```bash
ls -l /data/gitea/gitea.db
```
И убедиться, что данные gitea распакованы. Увидим что-то вроде:
```text
-rwxrwxrwx 1 root root 2555904 Apr 16 15:09 /data/gitea/gitea.db
```
База SQLite gitea.db на месте. Выходим из пода:
```bash
exit
```
## Создание пода gitea и подключение к нему хранилища
Теперь нужно создать под с Gitea, подключить к нему PVC `gitea-pvc` и проверить, что данные подцепились.
### Настройка Аффинити -- предпочтительные узлы
В моем кластере k3s на OrangePi 5 Plus несколько узлов работают на nVME SSD, а некоторые на eMMC. Накопители
SSD быстрее (если интересно, то вот [заметка о тестировании производительности дискового накопителя](../raspberry-and-orange-pi/measuring-performance-storage-devices.md))
и потому если контейнер с Gitea будет работать на SSD, то он будет работать быстрее. Как настроить предпочтение узлов
описано в [заметке о аффинити](k3s-affinate.md), поэтому кратко: присваиваем узлам метки, например `disk=ssd`:
```bash
sudo kubectl label nodes opi5plus-1 disk=ssd
sudo kubectl label nodes opi5plus-3 disk=ssd
```
Проверяем, что метки добавлены:
```bash
sudo kubectl get nodes --show-labels | grep "disk=ssd"
```
Будут показаны только узлы с меткой `disk=ssd`. У каждого узда очень много меток.
### Создание манифеста для развертывания Gitea
Создадим манифест для развертывания Gitea (deployment):
```bash
nano ~/k3s/gitea/gitea-deployment.yaml
```
Вставляем в него следующее содержимое:
```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' на хосте
```
Применим манифест:
```bash
sudo kubectl apply -f ~/k3s/gitea/gitea-deployment.yaml
```
Проверим, что под создан и работает:
```bash
sudo kubectl get pod -n gitea -o wide
```
Увидим что-то вроде:
```text
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
PVC `gitea-pvc`, и должен быть "жив" только 1 час (времени, которое мы указали в `sleep 3600` в манифесте пода).
Проверим логи пода `gitea`:
```bash
sudo kubectl logs -n gitea deployment/gitea
```
Увидим что-то вроде:
```text
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` установлены. Всё работает.
Можно устроить более глубокую проверку, зайдя внутр пода и посмотреть как там всё устроено:
```bash
sudo kubectl exec -n gitea -it deployment/gitea -- /bin/bash
```
Внутри пода проверим, что PVC `gitea-pvc` подключен и данные gitea и база на месте:
```bash
ls -la /data
ls -l /data/gitea/gitea.db
```
Проверим открытые порты в поде:
```bash
netstat -tuln
```
Увидим что порты слушаются:
```text
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 доступен:
```bash
curl -v http://localhost:3000
```
Увидим что-то вроде:
```text
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. Все работает. Можно выходить из пода:
```bash
exit
```
## Создание сервиса и IngressRoute для доступа к Gitea снаружи
```bash
nano ~/k3s/gitea/gitea-service.yaml
```
Вставляем в него следующее содержимое:
```yaml
apiVersion: v1
kind: Service
metadata:
name: gitea
namespace: gitea
spec:
selector:
app: gitea
ports:
- name: http
port: 80
targetPort: 3000
protocol: TCP
#- name: ssh
# port: 22
# targetPort: 22
# protocol: TCP
```
Объяснение:
selector: app: gitea — находит поды из Deployment gitea.
port: 80 — внешний порт сервиса (Traefik будет слать трафик сюда).
targetPort: 3000 — порт контейнера Gitea.
Применим манифест:
```bash
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 <none> 80/TCP 115s app=gitea
nano ~/k3s/gitea/https-redirect-middleware.yaml
```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, чтобы не затрагивать другие сервисы.
Применить:
```bash
sudo kubectl apply -f ~/k3s/gitea/https-redirect-middleware.yaml
```
### Подключаем сертификат Let's Encrypt
У нас уже настроена выдача сертификатов Lets Encrypt в подах cert-managerб cert-manager-cainjector и
cert-manager-webhook, в пронстве имен cert-manager. Это нестандартный способ (см. [заметку о cert-manager](k3s-cert-manager.md)).
Проверим, что cert-manager работает:
```bash
sudo kubectl get pods -n cert-manager
```
Увидим что-то вроде:
```text
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:
```bash
sudo kubectl get clusterissuer -A -o wide
```
Увидим что-то вроде:
```text
NAME READY STATUS AGE
letsencrypt-prod True The ACME account was registered with the ACME server 19d
```
Проверим, что работает и Let's Encrypt знает о нас:
```bash
sudo kubectl describe clusterissuer letsencrypt-prod
```
Увидим что-то вроде:
```text
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:
```bash
nano ~/k3s/gitea/gitea-certificate.yaml
```
и вставим в него следующее содержимое:
```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.
Применим манифест:
```bash
sudo kubectl apply -f ~/k3s/gitea/gitea-certificate.yaml
```
Проверим, что секрет создан:
```bash
sudo kubectl get secret -n gitea gitea-tls -o wide
```
Увидим что-то вроде:
```text
NAME TYPE DATA AGE
gitea-tls kubernetes.io/tls 2 46s
```
Проверим, что сертификат выдан:
```bash
sudo kubectl describe certificate -n gitea gitea-tls
```
Увидим что-то вроде:
```text
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 для доступа к Gitea
Создать IngressRoute для HTTPS
Настроим IngressRoute для маршрутизации git.cube2.ru через HTTPS с Lets Encrypt и редиректом HTTP.
```bash
nano ~/k3s/gitea/gitea-ingressroute.yaml
```
```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: gitea-tls` — включает Lets Encrypt для автоматического получения сертификата через
cert-manager в секрет gitea-tls. Трафик идёт в тот же Service gitea.
Применим манифест:
```bash
sudo kubectl apply -f ~/k3s/gitea/gitea-ingressroute.yaml
```
Все долно работать. Проверим, что IngressRoute создан:
## Подключение SSH к Gitea (опционально)
Кроме http-доступа и идентификации по логину, паролю и токенами, к к Gitea можно подключаться по SSH ([git://]). Выше
мы не настраивали SSH, так как Gitea я использовал как демонстрацию переноса Docker-контейнера в под K3s.
Порт 22 (SSH) на узлах K3s уже занят, поэтому нам нужно перенастроить его на другой порт. Например, 2222.
Для настройки SSH нужно:
- Обновить Service для Gitea, чтобы он включал порт 22 (SSH).
- Настроить Traefik для порта 2222
- Добавить IngressRouteTCP для Gitea (маршрутизация порта 22 изнутри пода а 2222 порт снаружи).
- Изменить Traefik Service для доступа по порту 2222
### Обновим Service для Gitea
Вносим изменения в `~/k3s/gitea/gitea-service.yaml`, добавив в конце (этот блок был закомментирован):
```yaml
- port: 22 # SSH, добавить
targetPort: 22
protocol: TCP
name: ssh
```
Применим изменения:
```bash
kubectl apply -f ~/k3s/gitea/gitea-service.yaml
````
Теперь Traefik сможет маршрутизировать SSH-трафик. Проверим, что сервис обновился:
```bash
sudo kubectl get svc -n gitea -o wide
```
Увидим что теперь в сервисе gitea есть порт 22:
```text
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR
gitea ClusterIP 10.43.211.8 <none> 80/TCP,22/TCP 5h app=gitea
```
### Настроим Traefik для порта 2222
Traefik должен слушать порт 2222. У меня Traefik настроен через `Helm values` в `HelmChartConfig`, через дополнительные
параметры `additionalArguments` . Чтобы Traefik начал слушать порт 2222, добавим новый entryPoint с именем `ssh` в
`~/k3s/traefik/traefik-config.yaml`. Добавим в конец файла: `- --entrypoints.ssh.address=:2222`. Полностью манифест
у меня выглядит так:
```yaml
aapiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
additionalArguments:
- --entrypoints.web-custom.address=:2055 # Слушаем HTTP на 2055 (для web-панели 3x-ui)
- --entrypoints.ssh.address=:2222 # Слушаем TCP на 2222 (для SSH)
- --log.level=DEBUG
```
Применим изменения и перезапустим Traefik чтобы изменения вступили в силу:
```bash
sudo kubectl apply -f ~/k3s/traefik/traefik-config.yaml
sudo kubectl rollout restart deployment -n kube-system traefik
```
Проверим, что Traefik перезапустился:
```bash
sudo kubectl get pod -n kube-system -o wide
```
Увидим что-то вроде (время жизни пода Traefik небольшое, так как он недавно перезапустился):
```text
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
...
...
traefik-6b96fd9d85-8txb9 1/1 Running 0 119s 10.42.0.93 opi5plus-2 <none> <none>
...
```
### Добавим IngressRouteTCP для Gitea
Чтобы маршрутизировать трафик с порта 2222 на Service Gitea (порт 22), создадим манифест IngressRouteTCP:
```bash
nano ~/k3s/gitea/gitea-ingressroute-tcp.yaml
```
И вставим в него следующее содержимое:
```yaml
# IngressRouteTCP для маршрутизации SSH через Traefik
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRouteTCP
metadata:
name: gitea-ssh
namespace: gitea
spec:
entryPoints:
- ssh # Соответствует entryPoint в Traefik
routes:
- match: HostSNI(`*`) # Для TCP без SNI
services:
- name: gitea
port: 22 # Порт Service
```
Применим манифест:
```bash
sudo kubectl apply -f ~/k3s/gitea/gitea-ingressroute-tcp.yaml
```
Проверим, что IngressRouteTCP создан:
```bash
sudo kubectl get ingressroutetcp -n gitea
```
Увидим что-то вроде:
```text
NAME AGE
gitea-ssh 16s
```
### Изменим Traefik Service для доступа к SSH по порту 2222 через балансировщик
Если у вас уже есть манифест Traefik Service (например `~/k3s/traefik/traefik-service.yaml`), то нужно добавить в него
обработку порта:
```yaml
apiVersion: v1
kind: Service
metadata:
name: traefik
namespace: kube-system
...
...
spec:
ports:
...
...
...
- name: ssh # Имя порта (должно соответствовать имени в IngressRouteTCP и entryPoint)
port: 2222 # Внешний порт
targetPort: 2222 # Порт Traefik
protocol: TCP # Протокол
```
И, само собой, применить манифест:
```bash
sudo kubectl apply -f ~/k3s/traefik/traefik-service.yaml
```
Если у вас нет манифеста Traefik Service, то можно отредактировать его непосредственно в кластере через `kubectl edit`
(будьте готовы редактировать в **vim**):
```bash
sudo kubectl edit svc -n kube-system traefik -o yaml
```
Важно: значение `nodePort` для SSH не нужно указывать, так как оно будет сгенерировано автоматически сразу после
сохранения и выхода из редактора (из vim выходите через `<esc:>wq`).
Проверим, что Service обновился:
```bash
sudo kubectl get svc -n kube-system traefik
```
Увидим что-то вроде (орт 2222 добавился в Service Traefik):
```text
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
traefik LoadBalancer 10.43.164.48 192.168.1.26,192.168.1.27,192.168.1.28 80:32171/TCP,443:30329/TCP,9000:31317/TCP,2055:31675/TCP,2222:31963/TCP 6d
```
Проверим, что порт 2222 доступен на VIP-адресе keepalived (не забудьте заменить `<VIP>` на ваш Virtual IP):
```bash
nc -zv <VIP> 2222
```
Увидим, что порт 2222 доступен:
```text
Connection to <VIP> port 2222 [tcp/rockwell-csp2] succeeded!
```
SSH в Gitea по порту 2222 должен работать. Проверим, что SSH доступен. Создадим SSH-ключ:
```bash
ssh-keygen -t ed25519 -C "ваше-имя-в-gitea-или-что-угодно"
```
Возьмём публичный SSH-ключ `cat ~/.ssh/id_ed25519.pub` и добавим через web-интерфейс Gitea (_Settings → SSH / GPG Keys_
или _Настройки → SSH / GPG ключи_). После добавления ключа, можно подключиться к Gitea по SSH:
```bash
ssh -p 2222 git@<VIP>
```
Увидим что-то вроде:
```text
PTY allocation request failed on channel 0
Hi there, ваше-имя-в-gitea-или-что-угодно! You've successfully authenticated with the key named Apple Mac Mini M4, but Gitea does not provide shell access.
If this is unexpected, please log in with password and setup Gitea under another user.
Connection to <VIP> closed.
```
**ВСЕ!!** Можно настроить переадресацию внешнего 22 порта на домашнем роутере на 2222 порт VIP-адреса, и работать
со всеми репозиториями Gitea по SSH как обычно. Для большинства роутеров при обращении по доменому имени gitea тоже
будет работать по 22 порту.
## Удаление временного пода и архива
Можно удалить временный под, который мы использовали для переноса данных gitea в блочное хранилище Longhorn PVC:
```bash
sudo kubectl delete pod gitea-init-data -n gitea
```
И удалить временный архив с данными gitea которые мы перенесли из Docker-контейнера и положили в корень домашнего
каталога. Теперь данные gitea уже в Longhorn PVC и арихив не нужен:
```bash
sudo rm ~/gitea-data.tar.gz
```
## Выводы
### Плюсы
Размещение Gitea в K3s с Longhorn и Traefik повысит отказоустойчивость и доступность. Выход из строя любого узла
не приведёт к потере данных, так как Longhorn хранит данные в репликах на других узлах, а Traefik обеспечивает
автоматическую маршрутизацию трафика.
### Минусы
Сложность и число абстракций при доступе клиентов в Gitea возросло, и Gitea стала работать на 50-100 миллисекунд
медленнее (в зависимости от нагрузки на узлы). Если посмотреть метрики:
```bash
time curl -s -o /dev/null https://git.cube2.ru
```
Увидим что-то вроде:
```text
real 0m0.233s
user 0m0.124s
sys 0m0.015s
```
Где **233 мс** — суммарное время запроса (включая DNS, соединение, TLS и ответ Gitea). Для моей локальной
сети (1 Гбит/с) стало медленнее, чем ожидалось (~50-100 мс для Docker с Nginx). Но для цепочки K3s с Traefik, Longhorn
и keepalived — это разумно.
#### Основные компоненты задержки:
| Причина | Docker + Nginx | K3s + Traefik + Longhorn + Keepalived |
|-----------------------------|-------------------|-------------------------------------------------|
| DNS | 20-50 мс. | 20-50 мс. |
| Web-сервер + TLS | Nginx — 20-40 мс. | Traefik — 30-50 мс. |
| Маршрутизация | 10 мс. | IngressRoute + Service + Keepalived — 20-60 мс. |
| Доступ к базе данных SQLite | 10 мс. | Longhorn PVC — 20-50 мс. |
| | | |
| **Итого:** | **60-110 мс** | **90-210 мс** |

View File

@@ -1,100 +1,263 @@
# Проксирование внешнего хоста через Traefik (Ingress-контроллер) # Проксирование внешнего хоста через Traefik (Ingress-контроллер)
У меня в домашней сети есть хост с сервисом (audiobookshelf), то удобно его прокинуть через Traefik, чтобы не открывать У меня в домашней сети есть хост с web-сервисом (audiobookshelf), а стандартные web-порты (80 для HTTP и 443 для HTTPS)
на внешний IP лишних адресов, портов, использовать домен и включить SSL, и управлять всем этим через из единой на домашнем роутере перенаправлены в кластер k3S (через keepalived). Таким образом, если прокинуть http-трафик этого
точки. хоста через Traefik, то можно будет получить доступ к этому сервису через доменное имя и SSL-сертификат от
Lets Encrypt.
Для удобства я поместил все манифесты в один файл (вы можете оформить из и как отдельные файлы). Так как хотелось бы
описать как делаются универсальные манифесты, которые можно использовать для проксирования любого сервиса, то
я заменил в нем конкретные значения на "заглушки. Можно взять этот манифест и просто заменить в нем значения на
свои:
В моем случае:
* `<PROXIED-HOST>` -- IP-адрес хоста, где работает сервис, который надо проксировать. * `<PROXIED-HOST>` -- IP-адрес хоста, где работает сервис, который надо проксировать.
* `<PROXIED-PORT>` -- порт, с которого отвечает сервис. * `<PROXIED-PORT>` -- порт, с которого отвечает сервис.
* `<YOU-DOMAIN-NAME>` -- доменное имя, на которое будет проксировать сервис. * `<YOU-DOMAIN-NAME>` -- доменное имя, на которое будет проксировать сервис.
* `<NAME-SPACE>` -- пространство имен кластера, в котором будет создан сервис, маршруты, секреты и все необходимое
для проксирования. Пространство имен -- это логическая группа ресурсов в кластере Kubernetes, которая позволяет
организовать и изолировать ресурсы.
* `<SERVICE-NAME>` -- имя сервиса, который будет проксироваться. Это имя, для простоты, будем использоваться
* и в маршрутах, и сертификатах, и в секрете...
## Пространство имен ## Пространство имен
Чтобы все было аккуратно и серисы и поды не путались, создадим пространство имен для проксируемого сервиса. Чтобы все было аккуратно и сервисы и поды не путались, создадим пространство имен для проксирования конкретного хоста.
Например, `ab-shelf`. Мо хост относится к <SERVICE-NAME>, поэтому назову пространство имен `<NAME-SPACE>`
Например, `<NAME-SPACE>`.
```bash ```bash
sudo ubectl create namespace ab-shelf sudo ubectl create namespace <NAME-SPACE>
``` ```
Проверяем, что пространство создано: Проверяем, что пространство создано:
```bash ```bash
sudo kubectl get namespace ab-shelf sudo kubectl get namespace <NAME-SPACE>
``` ```
Увидим, что пространство создано и активно: Увидим, что пространство создано и активно:
```text ```text
NAME STATUS AGE NAME STATUS AGE
ab-shelf Active 54s <NAME-SPACE> Active 54s
``` ```
## Конфигурация IngressRoute, Service и Endpoints ## Конфигурация всего
Для удобства я объединил манифесты в один файл (но можно и по отдельности). Создаем единый манифетст: Для удобства я объединил манифесты в один файл (но можно и по отдельности). Создаем единый манифест:
```bash ```bash
sudo nano ~/k3s/audiobookshelf/audiobookshelf.yaml sudo nano ~/k3s/<SERVICE-NAME>/<SERVICE-NAME>.yaml
``` ```
И вставляем в него следующее содержимое (не забудь заменить `<PROXIED-HOST>`, `<PROXIED-PORT>` и `<YOU-DOMAIN-NAME>` Он состоит из следующих частей:
на свои значения... пространство имен `ab-shelf` и имя сервиса `audiobookshelf` можно тоже поменять на свое, если * `Endpoints` -- указывает на конечную точку, в которую будет проксироваться трафик. В данном случае это
у вас другой сервис): IP-адрес и порт, на который будет проксироваться запрос.
* `Service` -- создает сервис, который будет использоваться для проксирования запросов к `Endpoints`.
* `Certificate` -- создает сертификат для домена `<YOU-DOMAIN-NAME>`, который будет использоваться для
шифрования трафика. Сертификат запрашивается через `cert-manager`, который автоматически обновляет его по мере
необходимости.
* `Middleware` -- создает промежуточное ПО, которое будет использоваться для обработки запросов. В данном случае
это редирект с HTTP на HTTPS и исключение редиректа для ACME challenge (механизма внешней проверки владения доменом
со стороны Lets Encrypt).
* `IngressRoute` -- создает маршрут, который будет использоваться для проксирования запросов к сервису.
В данном случае это маршруты для HTTP и HTTPS, которые будут обрабатывать запросы на домен `<YOU-DOMAIN-NAME>`.
Также создается маршрут для ACME challenge, который позволяет cert-manager пройти проверку через порт 80.
Вставляем в манифест следующее содержимое (не забудьте заменить `<PROXIED-HOST>`, `<PROXIED-PORT>`, `<YOU-DOMAIN-NAME>`,
`<NAME-SPACE>` и `<SERVICE-NAME>` на свои значения):
```yaml ```yaml
# Endpoints для внешнего хоста # Endpoints для внешнего хоста <SERVICE-NAME>
# Задаёт IP и порт внешнего сервера, так как <SERVICE-NAME> внешний хост для k3s
apiVersion: v1 apiVersion: v1
kind: Endpoints kind: Endpoints
metadata: metadata:
name: audiobookshelf name: <SERVICE-NAME>
namespace: ab-shelf # пространство, например namespace: <NAME-SPACE> # Namespace для <SERVICE-NAME>
subsets: subsets: # Прямо в корне, без spec
- addresses: - addresses:
- ip: <PROXIED-HOST> - ip: <PROXIED-HOST> # IP Synology, где работает <SERVICE-NAME>
ports: ports:
- port: <PROXIED-PORT> - port: <PROXIED-PORT> # Порт Synology (HTTP)
protocol: TCP protocol: TCP
---
# Service для проксируемого хоста (<PROXIED-HOST>:<PROXIED-PORT>) ---
# Service для маршрутизации трафика от Traefik к внешнему хосту
# Связывает IngressRoute с Endpoints
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: audiobookshelf name: <SERVICE-NAME>
namespace: ab-shelf # пространство, например namespace: <NAME-SPACE>
spec: spec:
ports: ports:
- port: <PROXIED-PORT> - port: <PROXIED-PORT> # Порт сервиса, на который Traefik отправляет трафик
targetPort: <PROXIED-PORT> targetPort: <PROXIED-PORT> # Порт на Synology
protocol: TCP protocol: TCP
---
# IngressRoute ---
apiVersion: traefik.containo.us/v1alpha1 # Middleware для редиректа HTTP → HTTPS
# Применяется к HTTP-запросам для перенаправления на HTTPS
apiVersion: traefik.io/v1alpha1 # версия Traefik v34.2.1+up34.2.0 (Traefik v3.3.6)
# apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
name: https-redirect
namespace: <NAME-SPACE>
spec:
redirectScheme:
scheme: https # Перенаправлять на HTTPS
permanent: true # Код 301 (постоянный редирект)
---
# Middleware для исключения редиректа на ACME challenge
# Позволяет Lets Encrypt проверять /.well-known/acme-challenge без HTTPS
apiVersion: traefik.io/v1alpha1 # версия Traefik v34.2.1+up34.2.0 (Traefik v3.3.6)
# apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
name: no-https-redirect
namespace: <NAME-SPACE>
spec:
stripPrefix:
prefixes:
- /.well-known/acme-challenge # Убрать префикс для ACME
---
# IngressRoute для HTTP (порт 80) с редиректом на HTTPS
# Обрабатывает HTTP-запросы и перенаправляет их
apiVersion: traefik.io/v1alpha1 # версия Traefik v34.2.1+up34.2.0 (Traefik v3.3.6)
# apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute kind: IngressRoute
metadata: metadata:
name: audiobookshelf name: <SERVICE-NAME>-http
namespace: ab-shelf # пространство, например namespace: <NAME-SPACE>
spec: spec:
entryPoints: entryPoints:
- web-custom # ендпоинт, который "слушает" порт 2055 - web # Порт 80 (стандартный HTTP)
routes:
- match: Host("<YOU-DOMAIN-NAME>") # Запросы к <YOU-DOMAIN-NAME>
kind: Rule
services:
- name: <SERVICE-NAME> # Сервис <SERVICE-NAME>
port: <PROXIED-PORT> # Порт сервиса
middlewares:
- name: https-redirect # Редирект на HTTPS
---
# IngressRoute для HTTPS (порт 443)
# Обрабатывает HTTPS-запросы с TLS
apiVersion: traefik.io/v1alpha1 # версия Traefik v34.2.1+up34.2.0 (Traefik v3.3.6)
# apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
name: <SERVICE-NAME>-https
namespace: <NAME-SPACE>
spec:
entryPoints:
- websecure # Порт 443 (HTTPS)
routes: routes:
- match: Host("<YOU-DOMAIN-NAME>") - match: Host("<YOU-DOMAIN-NAME>")
kind: Rule kind: Rule
services: services:
- name: audiobookshelf - name: <SERVICE-NAME>
port: <PROXIED-PORT> port: <PROXIED-PORT>
tls:
secretName: <SERVICE-NAME>-tls # Сертификат от cert-manager
---
# IngressRoute для HTTP-01 challenge (Lets Encrypt)
# Позволяет cert-manager пройти проверку через порт 80
apiVersion: traefik.io/v1alpha1 # версия Traefik v34.2.1+up34.2.0 (Traefik v3.3.6)
# apiVersion: traefik.containo.us/v1alpha1 # старая версия Traefik
kind: IngressRoute
metadata:
name: <SERVICE-NAME>-acme
namespace: <NAME-SPACE>
spec:
entryPoints:
- web # Порт 80
routes:
- match: Host("<YOU-DOMAIN-NAME>") && PathPrefix("/.well-known/acme-challenge")
kind: Rule
services:
- name: <SERVICE-NAME>
port: <PROXIED-PORT>
middlewares:
- name: no-https-redirect # Не редиректить ACME
---
# Certificate для TLS-сертификата от Lets Encrypt
# Запрашивает сертификат для <YOU-DOMAIN-NAME> через cert-manager
# ВАЖНО: cert-manager должен быть установлен в кластере
# ВАЖНО: если манифесты принимаются (apply) последовательно, то манифест с сертификатом должен быть последним для
# избежания исчерпания лимитов Lets Encrypt (пять запросов в неделю)
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: <SERVICE-NAME>-tls
namespace: <NAME-SPACE>
spec:
secretName: <SERVICE-NAME>-tls # Имя секрета для сертификата
dnsNames:
- <YOU-DOMAIN-NAME> # Домен для сертификата
issuerRef:
name: letsencrypt-prod # ClusterIssuer для Lets Encrypt
kind: ClusterIssuer
``` ```
Что тут происходит: **ВАЖНО**: В манифесте используется letsencrypt-prod для получения сертификата от Lets Encrypt. Это нестандартный
* `Endpoints` -- указываем, что сервис будет проксировать запросы на внешний хост `<PROXIED-HOST>` ClusterIssuer cert-manager, создание которого описано в [документации](https://cert-manager.io/docs/usage/ingress/#tls-termination)
и порт `<PROXIED-PORT>`. Ендпоинт -- это конечная точка, к которой будет проксироваться запрос. В данном случае и [отдельной инструкции](k3s-custom-container-deployment.md#создание-clusterissuer)
это IP-адрес и порт, но обычно это имя пода, на который отправляются и который отвечает на запросы. (возможно, вам нужно будет создать его отдельно). Если вы используете другой ClusterIssuer, то замените letsencrypt-prod
* `Service` -- создаем сервис, который будет использоваться для проксирования запросов к `Endpoints`. Сервис -- это на имя вашего ClusterIssuer в секции `issuerRef` в манифесте.
абстракция, которая позволяет упрощать доступ к подам. Он может использоваться для балансировки нагрузки между
несколькими потоками, которые обрабатывают запросы. В данном случае мы создаем сервис, который будет проксировать
запросы к `Endpoints` (внешнему хосту).
* `IngressRoute` -- создаем маршрут, который будет проксировать запросы на домен `<YOU-DOMAIN-NAME>`
к сервису `audiobookshelf` в пространстве `ab-shelf`. Маршрут -- это правило, которое определяет, как обрабатывать
запросы, поступающие на определенный адрес. В данном случае мы создаем маршрут, который будет направлять запросы
на домен `<YOU-DOMAIN-NAME>` к сервису `audiobookshelf` в пространстве `ab-shelf`. Внутри маршрута мы указываем,
что запросы должны обрабатываться сервисом `audiobookshelf` на порту `<PROXIED-PORT>`.
В данном случае мы используем `web-custom` как точку входа, которая будет слушать порт 2055.
## Применяем манифест
```bash
sudo kubectl apply -f ~/k3s/<SERVICE-NAME>/<SERVICE-NAME>.yaml
```
Проверяем, что все ресурсы создались:
```bash
sudo kubectl get all -n <NAME-SPACE>
sudo kubectl get ingressroute -n <NAME-SPACE>
sudo kubectl get middleware -n <NAME-SPACE>
sudo kubectl get service -n <NAME-SPACE>
sudo kubectl get endpoints -n <NAME-SPACE>
```
Увидим что-то вроде:
```text
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/<SERVICE-NAME> ClusterIP 10.43.152.59 <none> <PROXIED-PORT>/TCP 3h
```
Для IngressRoute:
```text
NAME AGE
<SERVICE-NAME> 3h
<SERVICE-NAME>-acme 1h
<SERVICE-NAME>-http 1h
<SERVICE-NAME>-https 1h
```
Для Middleware:
```text
NAME AGE
https-redirect 1h
no-https-redirect 1h
```
Для Service:
```text
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
<SERVICE-NAME> ClusterIP 10.43.152.59 <none> <PROXIED-PORT>/TCP 3h
```
Для Endpoints:
```text
NAME ENDPOINTS AGE
<SERVICE-NAME> <PROXIED-HOST>:<PROXIED-PORT> 3h
```
Проверяем, что сертификат создан:
```bash
sudo kubectl describe certificate -n <NAME-SPACE> <SERVICE-NAME>-tls
sudo kubectl get secret -n <NAME-SPACE> <SERVICE-NAME>-tls
```

View File

@@ -41,7 +41,7 @@ sudo /usr/local/bin/k3s-agent-uninstall.sh # На воркере
Некоторые требования к узлам: Некоторые требования к узлам:
* На всех Orange Pi установлена одинаковая версия Ubuntu (например, 22.04 или 24.04). * На всех Orange Pi установлена одинаковая версия Ubuntu (например, 22.04 или 24.04).
* Статические IP-адреса узлов (или зрезервированные под MAC-адреса IP в DHCP). * Статические IP-адреса узлов (или зарезервированные под MAC-адреса IP в DHCP).
* На уздах открыты порты 6443 (для API), 2379-2380 (для etcd) и 10250 (для kubelet). * На уздах открыты порты 6443 (для API), 2379-2380 (для etcd) и 10250 (для kubelet).
@@ -664,5 +664,63 @@ SSH-тоннель с помощью `autossh` и упаковкой UDP-тра
-----
Cертификаты
sudo kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.1/cert-manager.yaml
PVC Longhorn
sudo kubectl apply -f https://raw.githubusercontent.com/longhorn/longhorn/v1.5.3/deploy/longhorn.yaml
Установить Helm (менеджер пакетов для Kubernetes):
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
Добавить репозиторий Traefik в Helm:
helm repo add traefik https://helm.traefik.io/traefik
Обновить репозитории:
helm repo update
mkdir ~/.kube/config
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown opi:opi ~/.kube
chmod 755 ~/.kube
sudo chown opi:opi ~/.kube/config
chmod 755 ~/.kube/config
sudo chown opi:opi ~/.kube/config/k3s.yaml
chmod 755 ~/.kube/config/k3s.yaml
или
chmod 755 ~/.kube ~/.kube/config
chmod 755 ~/.kube ~/.kube
chmod 600 ~/.kube/config/k3s.yaml
ls -ld ~/.kube ~/.kube/config ~/.kube/config/k3s.yaml
```text
drwxr-xr-x 4 opi opi 4096 Apr 26 11:32 /home/opi/.kube
drwxr-xr-x 2 opi opi 4096 Apr 26 11:09 /home/opi/.kube/config
-rw------- 1 opi opi 2961 Apr 26 11:09 /home/opi/.kube/config/k3s.yaml
```
Установить переменную KUBECONFIG:
```bash
export KUBECONFIG=~/.kube/config/k3s.yaml
```
И добавь её в `~/.bashrc` для постоянства:
```bash
echo 'export KUBECONFIG=~/.kube/config/k3s.yaml' >> ~/.bashrc
source ~/.bashrc
```
Установить Traefik:
```bash
helm install traefik traefik/traefik -n kube-system --set service.type=LoadBalancer
```