Как открыть сайт из Kubernetes по домену ruslans.site

У каждой команды и каждого файла есть объяснение: что это, зачем нужно и что произойдёт.

Порядок такой: проверяем кластер и сеть, настраиваем автоматический деплой через GitLab CI/CD в три окружения (test, preprod, prod), затем открываем сайт наружу через Ingress, MetalLB и Cloudflare Tunnel.

Браузер: ruslans.siteCloudflareТуннель (WSL, Docker)Ingress 192.168.56.50ServiceПоды nginx

Словарь: что есть что

Kubernetes (k8s)
система, которая запускает и следит за контейнерами на нескольких компьютерах сразу.
Кластер и нода
кластер это группа компьютеров под управлением Kubernetes, нода это один такой компьютер. У нас три ноды: одна главная (control-plane) и две рабочие (worker), все в виртуальных машинах VirtualBox.
Под (pod)
наименьшая единица в Kubernetes, обычно один запущенный контейнер. У нас это контейнер с nginx.
Deployment
объект, который создаёт заданное число копий пода и пересоздаёт их при сбое.
Namespace (пространство имён)
отдельная «папка» для объектов внутри кластера. У нас три окружения (test, preprod, prod) живут в одном кластере, каждое в своём namespace.
Service
постояный адрес внутри кластера для группы подов. Поды приходят и уходят, а адрес Service остаётся.
Ingress
правила вида «запросы для сайта ruslans.site отправь в такой-то Service». Сами правила ничего не делают, их исполняет Ingress-контроллер (в нашем случае ingress-nginx).
ConfigMap
хранилище небольших файлов и настроек внутри Kubernetes.
Helm
менеджер пакетов для Kubernetes: ставит готовые программы одной командой.
Cloudflare Tunneldsds
программа cloudflared сама подключается к Cloudflare и пропускает через это соединение запросы к вашему сайту. Поэтому белый IP и открытые порты на роутере не нужны.
Ansible
программа, которая выполняет команды на нескольких серверах сразу по SSH, без установки агентов на них (раздел 4).
CI/CD, пайплайн, раннер
автоматический запуск действий после git push: пайплайн это цепочка действий, раннер это программа, которая их выполняет (разделы 5 и 7).
kubectl
консольная программа, через которую мы управляем кластером. Все команды ниже выполняются на главной ноде (node1), кроме тех, где указано иное (WSL, GitLab).

1что у меня установлено

  • Работающий кластер Kubernetes из трёх нод (установлен через Kubespray).
  • Helm на node1 (понадобится для установки Ingress-контроллера).
  • Ansible на node1, установленный в виртуальное окружение Python (/opt/ansible/.venv).
  • Домен ruslans.site, добавленный в Cloudflare (статус Active).
  • Windows с WSL и установленным Docker внутри WSL там же gitlab runner.
  • Проект на gitlab.com и его клон в WSL (у нас zeynaloff-project).
  • Файлы index.html (эта документация): мы положим их в папку site репозитория в разделе 9.

2Проверка кластера

kubectl get nodes -o wide
  • kubectl get nodes показывает список нод.
  • -o wide добавляет подробности, дажее IP-адреса.

В колонке STATUS у всех нod должно быть Ready (готова к работе). Если там NotReady, то сидим и исправляем

4Ansible: управление нoдами с node1

Ansible выполняет команды сразу на нескольких серверах по SSH. На самих серверах ничего ставить не нужно. Список серверов хранится в файле inventory (у нас он называется hosts), настройки в файле ansible.cfg. Всё делается на node1 в папке /opt/ansible, где Ansible установлен в виртуальное окружение Python (.venv).

Файл inventory: hosts

/opt/ansible/hosts
[servers]
negga1 ansible_host=192.168.56.103 ansible_user=zeynaloff ansible_port=22
negga2 ansible_host=192.168.56.101 ansible_user=zeynaloff ansible_port=22
  • [servers]: имя группы. Группа нужна, чтобы обращаться ко всем серверам сразу.
  • negga1: короткое имя сервера внутри Ansible, оно может быть любым.
  • ansible_host: реальный IP-адрес сервера.
  • ansible_user: под каким пользователем заходить по SSH.
  • ansible_port: порт SSH

Файл настроек: ansible.cfg

/opt/ansible/ansible.cfg
[defaults]
inventory = /opt/ansible/hosts
remote_tmp = $HOME/.ansible/tmp
forks = 150
sudo_user = zeynaloff

[privilege_escalation]
become = True
become_method = sudo
become_user = root
  • inventory: где лежит список серверов. Благодаря этому не нужно каждый раз писать -i hosts.
  • remote_tmp: временная папка на сервере, куда Ansible кладёт свои файлы на время работы.
  • forks = 150: на скольких серверах работать одновременно.
  • become = True: выполнять команды с правами root через sudo. Работает, если у пользователя настроен sudo без пароля .

Проверка связи

cd /opt/ansible
source .venv/bin/activate
ansible --version | grep "config file"
ansible all -m ping
  • cd /opt/ansible: перейти в папку проекта, чтобы Ansible нашёл ansible.cfg.
  • source .venv/bin/activate: включить виртуальное окружение. В начале строки появится (.venv), и команда ansible станет доступна.
  • ansible --version | grep "config file": показать, какой конфиг реально используется. Значение None означает, что конфиг не найден.
  • ansible all -m ping: формат всегда такой: ansible КОГО -m МОДУЛЬ. all значит все серверы из inventory, -m ping запускает модуль ping. Это не сетевой ping, а проверка, что Ansible смог зайти по SSH и запустить Python на сервере.

Успешный результат выглядит так: negga1 | SUCCESS => {"ping": "pong"} для каждого сервера..

Вход без пароля и sudo без пароля

ssh-copy-id [email protected]
ssh-copy-id [email protected]
echo "zeynaloff ALL=(ALL) NOPASSWD:ALL" | sudo tee /etc/sudoers.d/zeynaloff
  • ssh-copy-id: добавляет ваш публичный SSH-ключ на сервер, после чего вход идёт без пароля. Выполняется один раз для каждого сервера, спросит пароль пользователя.
  • Вторая команда выполняется на каждом сервере. Файл в /etc/sudoers.d/ разрешает пользователю zeynaloff выполнять любые команды через sudo без ввода пароля. Через tee записываем потому, что обычное перенаправление > выполнилось бы без прав root и не сработало бы.

5CI/CD в GitLab: схема и ветки

CI/CD это автоматизация: вы делаете git push, GitLab сам запускает цепочку действий (пайплайн), а в конце сайт обновляется в кластере. Пайплайн описывается файлом .gitlab-ci.yml в корне репозитория.

Пайплайн (pipeline)
вся цепочка действий, которая запускается после push.
Джоба (job)
одно действие в пайплайне, например «выложить сайт в кластер».
Раннер (runner)
программа, которая реально выполняет джобы. GitLab.com сам до вашей сети 192.168.56.x не достаёт, поэтому раннер стоит у нас в WSL и сам подключается к GitLab.
Окружение (environment)
место, куда выкладывается сайт: test, preprod, prod. Для каждого окружения своя ветка git и своё пространство имён (namespace) в кластере.
git pushGitLab.comРаннер (WSL, Docker)kubectlКластер: test / preprod / prod

Три ветки

git checkout -b test
git push -u origin test
git checkout -b preprod
git push -u origin preprod
git checkout -b prod
git push -u origin prod
  • git checkout -b test: создать новую ветку test и сразу перейти в неё.
  • git push -u origin test: отправить ветку на GitLab (origin это адрес репозитория). Ключ -u запоминает связь, и дальше достаточно писать просто git push.
kubectl create namespace test
kubectl create namespace preprod
kubectl create namespace prod

Namespace это отдельная «папка» для объектов в кластере. Три окружения живут в одном кластере, но не мешают друг другу.

Защита веток

В GitLab: Settings → Repository → Protected branches → Add protected branch.

  • preprod: Allowed to merge: Maintainers, Allowed to push: Maintainers.
  • prod: Allowed to merge: Maintainers, Allowed to push: No one. Изменения попадают в prod только через merge request.
  • test не защищаем.

Путь изменений: test → preprod → prod. Каждая ветка хранит свой .gitlab-ci.yml, потому что GitLab читает файл из той ветки, для которой запущен пайплайн.

6SSH-ключ для CI: WSL и три ноды

Ключ состоит из двух файлов: приватный (секретный, остаётся у вас и уходит в переменную GitLab) и публичный (его можно раздавать, он копируется на все ноды). Создаём отдельный ключ именно для CI, чтобы не использовать личный.

в WSL
ssh-keygen -t ed25519 -N "" -C "gitlab-ci" -f ~/.ssh/gitlab_ci
  • -t ed25519: современный тип ключа.
  • -N "": пустая парольная фраза, иначе CI не сможет использовать ключ без человека.
  • -C "gitlab-ci": комментарий внутри ключа, чтобы потом понять, чей он.
  • -f ~/.ssh/gitlab_ci: имя файла. Появятся gitlab_ci (приватный) и gitlab_ci.pub (публичный).
ssh-copy-id -i ~/.ssh/gitlab_ci.pub [email protected]
ssh-copy-id -i ~/.ssh/gitlab_ci.pub [email protected]
ssh-copy-id -i ~/.ssh/gitlab_ci.pub zeynaloff@IP_NODE1
ssh -i ~/.ssh/gitlab_ci [email protected] hostname
  • ssh-copy-id -i ...pub: дописывает указанный публичный ключ в файл ~/.ssh/authorized_keys на ноде. Команда повторяется для каждой из трёх нод.
  • ssh -i ... hostname: проверка. Подключаемся с этим ключом и просим ноду назвать своё имя. Если имя пришло без вопроса о пароле, всё настроено.

Если ssh-copy-id отвечает Permission denied, на ноде отключён вход по паролю. Временно включите PasswordAuthentication yes в /etc/ssh/sshd_config, выполните sudo systemctl restart ssh и после копирования ключа верните значение no.

7GitLab Runner в WSL

Раннер запускаем в Docker внутри WSL: там уже есть Docker, и из WSL видны ноды кластера.

docker volume create gitlab-runner-config

docker run -d --name gitlab-runner --restart always \
  -v gitlab-runner-config:/etc/gitlab-runner \
  -v /var/run/docker.sock:/var/run/docker.sock \
  gitlab/gitlab-runner:latest
  • docker volume create: создать постоянное хранилище для настроек раннера, чтобы они не пропали при пересоздании контейнера.
  • -d: запустить в фоне. --name gitlab-runner: имя контейнера. --restart always: поднимать контейнер заново после сбоя и перезагрузки.
  • -v gitlab-runner-config:/etc/gitlab-runner: подключить хранилище в папку, где раннер держит конфигурацию.
  • -v /var/run/docker.sock:/var/run/docker.sock: дать раннеру доступ к Docker хоста. Без этого он не сможет запускать контейнеры для джоб.

Регистрация

В GitLab откройте Settings → CI/CD → Runners → New project runner, укажите тег wsl и скопируйте токен вида glrt-.... Затем в WSL:

docker exec -it gitlab-runner gitlab-runner register --non-interactive --url https://gitlab.com --token glrt-ВАШ_ТОКЕН --executor docker --docker-image alpine:latest
  • docker exec -it gitlab-runner: выполнить команду внутри запущенного контейнера.
  • --url: адрес GitLab. --token: токен, который связывает раннер с вашим проектом.
  • --executor docker: каждая джоба запускается в отдельном чистом контейнере.
  • --docker-image alpine:latest: образ по умолчанию, если в джобе свой образ не указан.

Команду нужно писать в одну строку. Если строка разорвалась, программа переходит в диалоговый режим и задаёт вопросы по очереди: URL (Enter), токен, имя раннера, executor (docker), образ (alpine:latest). Тег wsl при токене glrt- задаётся только в интерфейсе GitLab.

docker exec -it gitlab-runner gitlab-runner verify

verify проверяет связь с GitLab. В Settings → CI/CD → Runners раннер должен стать зелёным (online).

Если контейнеры джоб не видят кластер

В файле /etc/gitlab-runner/config.toml (внутри контейнера раннера) в секции [runners.docker] добавьте строку network_mode = "host" и выполните docker restart gitlab-runner. Контейнеры джоб тогда используют сеть самого WSL, из которой доступны адреса 192.168.56.x.

8Переменные GitLab: kubeconfig и SSH-ключ

Секреты не хранят в репозитории. Их кладут в Settings → CI/CD → Variables, а джоба получает их при запуске.

Где взять kubeconfig

на главной ноде кластера
sudo cat /etc/kubernetes/admin.conf

Это файл с адресом кластера и ключами доступа. Скопируйте весь вывод. В строке server: должен быть адрес главной ноды, достижимый из WSL, например https://192.168.56.103:6443. Если там 127.0.0.1, замените на IP ноды, иначе контейнер раннера будет стучаться сам в себя.

проверка в WSL
nano /tmp/kc.yaml
kubectl --kubeconfig /tmp/kc.yaml get nodes
rm /tmp/kc.yaml
  • nano /tmp/kc.yaml: вставьте туда kubeconfig с исправленным server:.
  • kubectl --kubeconfig ... get nodes: проверить, что из WSL кластер виден. Должны быть три ноды в статусе Ready.
  • rm: удалить временный файл с секретами.

Какие переменные создать

Для каждого окружения (test, preprod, prod) нужно по две переменные, всего шесть. Поля при создании одинаковые, меняется только Environments и флаг Protect.

Key
KUBECONFIG_FILE (значение: kubeconfig целиком) и SSH_PRIVATE_KEY (значение: содержимое ~/.ssh/gitlab_ci, команда cat ~/.ssh/gitlab_ci).
Type
File. В джобе переменная становится путём к временному файлу с содержимым. Именно так её ждут kubectl --kubeconfig и ssh.
Environments
test, preprod или prod. Переменная будет видна только джобе этого окружения.
Visibility
Visible. Режим Masked принимает только однострочные значения, а kubeconfig и ключ многострочные. Содержимое File-переменной в лог не попадает, пока вы сами его не выведете.
Protect variable
нет для test, да для preprod и prod. Защищённая переменная доступна только защищённым веткам.
Expand variable reference
не отмечать: знак $ внутри ключей и конфигов не должен раскрываться как ссылка на другую переменную.
В конце значения SSH_PRIVATE_KEY должна быть пустая строка после -----END OPENSSH PRIVATE KEY-----, иначе будет ошибка error in libcrypto. Kubeconfig даёт полный доступ к кластеру: никому его не показывайте и не публикуйте скриншоты со значением.

9Файлы проекта и пайплайн деплоя

Объекты в кластере мы вручную не создаём. Мы описываем, что должно существовать, в файлах, кладём их в репозиторий, а пайплайн сам применяет их в нужном окружении. Файлы пишутся на языке YAML: отступы важны (вложенность задаётся двумя пробелами), строки с # это комментарии и ни на что не влияют, три дефиса --- разделяют несколько объектов в одном файле.

В корне клона репозитория должна быть такая структура:

.
├── .gitlab-ci.yml
├── site/
│   ├── index.html
│   └── project.html
└── k8s/
    └── docs.yaml
git checkout test
mkdir -p site k8s
cp index.html project.html site/
nano k8s/docs.yaml
  • git checkout test: перейти в ветку test. Файлы создаём именно в ней.
  • mkdir -p site k8s: создать две папки (-p не ругается, если папка уже есть).
  • cp index.html project.html site/: положить обе страницы документации в папку site.
  • nano k8s/docs.yaml: создать манифест, он описан ниже. Сохранение в nano: Ctrl+O, Enter, Ctrl+X.

k8s/docs.yaml

Это шаблон. Вместо реального домена в нём написано __HOST__, пайплайн подставит нужный домен в зависимости от ветки.

k8s/docs.yaml
apiVersion: apps/v1          # версия API для объектов этого типа
kind: Deployment             # создаёт и поддерживает поды
metadata:
  name: docs                 # имя объекта
spec:
  replicas: 3                # сколько копий держать запущенными
  selector:
    matchLabels:
      app: docs              # управлять подами с меткой app=docs
  template:                  # шаблон, по которому создаются поды
    metadata:
      labels:
        app: docs            # метка на каждом поде
    spec:
      topologySpreadConstraints:   # раскладывать копии по разным нодам
      - maxSkew: 1
        topologyKey: kubernetes.io/hostname
        whenUnsatisfiable: ScheduleAnyway
        labelSelector:
          matchLabels:
            app: docs
      containers:
      - name: nginx
        image: nginx:1.27-alpine   # из какого образа запускать
        ports:
        - containerPort: 80        # порт, который слушает nginx в контейнере
        volumeMounts:              # подключить том в контейнер
        - name: site
          mountPath: /usr/share/nginx/html   # папка, откуда nginx берёт сайт
      volumes:
      - name: site
        configMap:
          name: docs-site          # том заполнен файлами из этого ConfigMap
---
apiVersion: v1
kind: Service                # постоянный внутренний адрес для подов
metadata:
  name: docs
spec:
  selector:
    app: docs                # отправлять трафик на поды с этой меткой
  ports:
  - port: 80                 # порт самого Service
    targetPort: 80           # порт контейнера, куда передавать трафик
---
apiVersion: networking.k8s.io/v1
kind: Ingress                # правила входа с внешней стороны
metadata:
  name: docs
spec:
  ingressClassName: nginx    # правила исполняет контроллер ingress-nginx
  rules:
  - host: __HOST__           # сюда пайплайн подставит домен
    http:
      paths:
      - path: /              # для всех адресов сайта
        pathType: Prefix     # путь считается началом адреса
        backend:
          service:
            name: docs       # отправить в этот Service
            port:
              number: 80
  • Deployment запускает три копии nginx. Страницы он берёт из ConfigMap docs-site, который создаёт пайплайн из папки site.
  • Service даёт этим копиям один постоянный адрес внутри кластера.
  • Ingress говорит: запросы для домена __HOST__ отправлять в этот Service. Само правило ничего не делает, его исполняет Ingress-контроллер, который мы ставим в разделе 10.
  • На главной ноде по умолчанию стоит запрет (taint) на запуск обычных подов, поэтому три копии размещаются на рабочих нодах, а topologySpreadConstraints старается распределить их поровну.

.gitlab-ci.yml для ветки test

.gitlab-ci.yml
stages: [deploy]

variables:
  NAMESPACE: test
  HOST: test.ruslans.site

deploy-docs:
  stage: deploy
  image:
    name: bitnami/kubectl:latest
    entrypoint: [""]
  tags: [wsl]
  environment:
    name: test
    url: https://test.ruslans.site
  script:
    - export KUBECONFIG="$KUBECONFIG_FILE"
    - test -n "$HOST"
    - kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f -
    - kubectl -n $NAMESPACE create configmap docs-site --from-file=site/ --dry-run=client -o yaml | kubectl apply -f -
    - sed "s/__HOST__/$HOST/" k8s/docs.yaml | kubectl -n $NAMESPACE apply -f -
    - kubectl -n $NAMESPACE rollout restart deployment/docs
    - kubectl -n $NAMESPACE rollout status deployment/docs
  rules:
    - if: $CI_COMMIT_BRANCH == "test"
  • stages: [deploy]: у пайплайна один этап.
  • variables: свои переменные для этой ветки: в какой namespace выкладывать и какой домен подставить.
  • deploy-docs: имя джобы.
  • image и entrypoint: [""]: джоба запускается в контейнере с программой kubectl. У этого образа программой по умолчанию является сам kubectl, а CI нужна оболочка для своих команд, поэтому entrypoint сбрасывается.
  • tags: [wsl]: выполнить джобу на раннере с тегом wsl.
  • environment: привязывает джобу к окружению. Благодаря этому подхватываются переменные с нужным Environments, а в GitLab появляется ссылка на сайт.
  • export KUBECONFIG="$KUBECONFIG_FILE": сказать kubectl, какой файл конфигурации использовать (путь к File-переменной).
  • test -n "$HOST": проверка, что домен не пустой. Если пустой, джоба остановится сразу.
  • kubectl create namespace ... --dry-run=client -o yaml | kubectl apply -f -: создать namespace, если его нет. --dry-run=client -o yaml не создаёт объект, а только выводит его описание. Символ | передаёт этот вывод следующей команде, а -f - значит «читать описание из входа, а не из файла». Такая связка безопасна для повторного запуска.
  • create configmap docs-site --from-file=site/: упаковать все файлы папки site в ConfigMap, при повторном запуске он обновляется на месте.
  • sed "s/__HOST__/$HOST/" k8s/docs.yaml | kubectl apply -f -: sed заменяет в шаблоне __HOST__ на домен, и результат сразу применяется. Файл в репозитории не меняется.
  • rollout restart: перезапустить поды, чтобы они взяли свежие страницы.
  • rollout status: ждать, пока обновление завершится. Если поды не поднялись, джоба упадёт и покажет ошибку.
  • rules: запускать джобу только для ветки test.

Что меняется в других ветках

В ветках preprod и prod файл тот же, меняются четыре места, а для prod ещё добавляется ручной запуск:

preprod
NAMESPACE: preprod, HOST: preprod.ruslans.site, environment.name: preprod и url: https://preprod.ruslans.site, в rules $CI_COMMIT_BRANCH == "preprod".
prod
NAMESPACE: prod, HOST: ruslans.site, environment.name: prod и url: https://ruslans.site, в rules $CI_COMMIT_BRANCH == "prod" и строка when: manual под ней. Тогда боевой деплой запускается только кнопкой Run в пайплайне.
Сайт станет доступен снаружи только после разделов 10 и 11 (Ingress-контроллер и туннель). Но то, что пайплайн разложил поды, можно проверить сразу: kubectl -n test get pods -o wide.

10Ingress-контроллер и MetalLB: вход в кластер

Правила Ingress из раздела 9 исполняет Ingress-контроллер: это программа (тот же nginx), которая принимает запросы снаружи и разбирает их по правилам. Чтобы у контроллера был собственный адрес в сети 192.168.56.x, не привязанный к конкретной ноде, используем MetalLB: у нас нет облачного балансировщика, и MetalLB выдаёт такие адреса сам. Сначала ставим MetalLB, затем контроллер. У нас контроллер получил адрес 192.168.56.50.

Шаг 1. Включить strict ARP

на node1
kubectl edit configmap -n kube-system kube-proxy

Откроется редактор. Найдите строку strictARP: false, замените на strictARP: true и сохраните. Это настройка сети, без которой MetalLB может работать неправильно.

Шаг 2. Установить MetalLB

на node1
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.14.9/config/manifests/metallb-native.yaml
kubectl wait -n metallb-system --for=condition=ready pod --selector=app=metallb --timeout=120s

Первая команда применяет готовый файл с адреса в интернете (можно открыть в браузере и посмотреть, что в нём), вторая ждёт готовности подов MetalLB.

Шаг 3. Задать пул адресов

metallb.yaml
apiVersion: metallb.io/v1beta1
kind: IPAddressPool          # набор адресов, которые MetalLB может выдавать
metadata:
  name: pool
  namespace: metallb-system
spec:
  addresses:
  - 192.168.56.50-192.168.56.60   # диапазон: должен быть свободен
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement        # объявлять выданные адреса в локальной сети
metadata:
  name: l2
  namespace: metallb-system

Диапазон не должен пересекаться с адресами нод и с DHCP-диапазоном VirtualBox. Применяется командой kubectl apply -f metallb.yaml.

В настройках каждой из трёх виртуальных машин в VirtualBox (Сеть → адаптер host-only → Дополнительно) поставьте Режим promiscuous: Разрешить все. Иначе адрес MetalLB может не отвечать.

Шаг 4. Установить Ingress-контроллер

на node1
helm upgrade --install ingress-nginx ingress-nginx \
  --repo https://kubernetes.github.io/ingress-nginx \
  --namespace ingress-nginx --create-namespace

kubectl wait --namespace ingress-nginx --for=condition=ready pod \
  --selector=app.kubernetes.io/component=controller --timeout=180s

kubectl get svc -n ingress-nginx ingress-nginx-controller
  • helm upgrade --install: установить программу, а если она уже есть, обновить.
  • ingress-nginx ingress-nginx: первое слово это имя установки, второе это название пакета.
  • --repo ...: адрес каталога, откуда Helm возьмёт пакет.
  • --namespace ingress-nginx --create-namespace: поставить в отдельное пространство имён и создать его, если его нет.
  • Тип сервиса указывать не нужно: по умолчанию контроллер создаёт сервис типа LoadBalancer, и адрес для него выдаёт MetalLB.
  • kubectl wait ...: ждать до 180 секунд, пока под контроллера не станет готовым (--for=condition=ready). --selector выбирает под по его метке. Скачивание образа занимает около двух минут.
  • kubectl get svc ...: в колонке EXTERNAL-IP должен появиться адрес из пула (у нас 192.168.56.50). Это и есть адрес входа в кластер.

Если пайплайн из раздела 9 уже запускался, поле ADDRESS у Ingress (kubectl -n test get ingress) тоже заполнится этим адресом.

11Cloudflare Tunnel: выход в интернет

Туннель работает так: программа cloudflared сама подключается к Cloudflare и пропускает через это соединение запросы к вашему сайту. Поэтому белый IP и открытые порты на роутере не нужны. Для туннеля должна быть настроена сеть WSL (раздел 3) и получен адрес Ingress (раздел 10).

  1. В панели Cloudflare откройте Zero Trust → Networks → Tunnels и создайте туннель типа Cloudflared. Скопируйте токен: это длинный секретный ключ, он связывает ваш контейнер с этим туннелем. Никому его не показывайте.
  2. В настройках туннеля на вкладке Public Hostname добавьте три маршрута, у каждого тип HTTP и один и тот же адрес 192.168.56.50:80 (IP Ingress):
    • test.ruslans.site для окружения test;
    • preprod.ruslans.site для окружения preprod;
    • ruslans.site для окружения prod.

Все три домена приходят на один Ingress, а он по имени сайта выбирает нужный namespace. Cloudflare сам создаёт DNS-записи и бесплатный сертификат для домена и поддоменов первого уровня.

Дальше, в WSL, запустите контейнер туннеля:

docker run -d --name cloudflared --restart unless-stopped \
  cloudflare/cloudflared:latest tunnel --no-autoupdate run --token ВАШ_ТОКЕН
  • docker run -d: запустить контейнер в фоне.
  • --name cloudflared: имя контейнера, чтобы потом им управлять.
  • --restart unless-stopped: автоматически запускать заново после сбоя или перезагрузки Docker.
  • cloudflare/cloudflared:latest: образ программы cloudflared.
  • tunnel --no-autoupdate run --token ...: запустить туннель с вашим токеном и без автообновления программы.

Проверка: docker logs --tail 20 cloudflared (показать 20 последних строк лога). В нём должны быть слова Registered tunnel connection. Менять маршруты в Cloudflare можно в любой момент, контейнер перезапускать не нужно.

12Запуск, проверка и продвижение по веткам

Отправка в test

git branch --show-current
git status
git add site/ k8s/ .gitlab-ci.yml
git commit -m "add docs site and k8s manifests"
git push origin test
  • git branch --show-current: показать текущую ветку, должно быть test.
  • git status: что изменилось и что ещё не добавлено.
  • git add ...: выбрать файлы для коммита.
  • git commit -m "...": сохранить изменения в истории с комментарием.
  • git push origin test: отправить ветку на GitLab. Пайплайн стартует сам.

Откройте Build → Pipelines. Должна появиться джоба deploy-docs. После зелёного статуса проверьте результат:

kubectl -n test get pods -o wide
kubectl -n test get ingress
curl -H "Host: test.ruslans.site" http://192.168.56.50
  • Первая команда показывает три пода и ноды, на которых они запущены.
  • Вторая показывает правило Ingress с заполненным ADDRESS.
  • Третья должна вернуть HTML этой страницы. curl это консольный браузер, а -H "Host: ..." подставляет имя сайта: Ingress выбирает правило по домену, а не по IP. Без этого заголовка вернётся 404 Not Found от nginx.
  • В браузере откройте https://test.ruslans.site и https://test.ruslans.site/project.html.
  • Устойчивость: удалите один под (kubectl -n test delete pod ИМЯ_ПОДА). Сайт продолжает открываться, а Deployment создаёт замену.

Перенос в preprod

git checkout preprod
git merge test
nano .gitlab-ci.yml
git add .gitlab-ci.yml
git commit -m "ci for preprod"
git push origin preprod
  • git checkout preprod: перейти в ветку preprod.
  • git merge test: принести в неё все изменения из test: папки site, k8s и файл .gitlab-ci.yml.
  • nano .gitlab-ci.yml: поправить четыре значения (namespace, домен, имя окружения, ветку в rules), потому что merge принёс файл из test.
  • Остальное как в прошлом блоке: сохранить коммит и отправить.

Чтобы файл .gitlab-ci.yml не перезаписывался при будущих merge, можно добавить в репозиторий файл .gitattributes со строкой .gitlab-ci.yml merge=ours и один раз выполнить git config merge.ours.driver true (настройка локальная, повторите её на каждой машине).

Перенос в prod

Те же шаги с веткой prod (merge лучше делать через merge request, так как ветка защищена). Пайплайн остановится на ручной джобе: откройте его в GitLab и нажмите Run. После этого сайт открывается по адресу https://ruslans.site.

13Если не работает

Ansible и SSH

Ansible: No inventory was parsed, only implicit localhost is available
конфиг не найден, потому что файл назывался .ansible.cfg (с точкой) в /opt/ansible. Переименуйте в ansible.cfg или передайте inventory явно: ansible all -i /opt/ansible/hosts -m ping.
Ansible: Could not match supplied host pattern, ignoring: ping
перепутан порядок слов. Правильно: ansible all -m ping.
Permission denied (publickey)
публичный ключ не добавлен на какую-то ноду или в SSH_PRIVATE_KEY вставлен не тот ключ.
error in libcrypto
в конце значения SSH_PRIVATE_KEY нет пустой строки.

Пайплайн GitLab

Пайплайн упал за 0 секунд, джоба называется Workers Builds, стадия external
это не ваш пайплайн: к репозиторию подключён проект Cloudflare Workers Builds, который пытается собрать каждую ветку. Откройте Workers & Pages → проект → Settings → Builds и отключите репозиторий (Disconnect) или сборку нужных веток.
В пайплайне нет нужной джобы
файл называется не .gitlab-ci.yml, лежит не в корне или не закоммичен в эту ветку. Проверить синтаксис можно в Build → Pipeline editor, вкладка Validate. Нельзя вставлять в файл фрагменты с ...: нужен полный файл.
Джоба висит в pending
раннер выключен или у него нет тега wsl. Проверьте docker ps и статус раннера в Settings → CI/CD → Runners.
Cannot connect to the Docker daemon
в контейнер раннера не подключён /var/run/docker.sock. Пересоздайте контейнер командой из раздела 7.
kubectl: connection refused или timeout
в kubeconfig в server: указан не тот адрес (например 127.0.0.1) или контейнеры джоб не видят сеть 192.168.56.x. Исправьте адрес и добавьте network_mode = "host" (раздел 7).
Пустая переменная в джобе
не совпал Environments или стоит флаг Protect, а ветка не защищена. Для test флаг не ставьте.
Образ bitnami/kubectl не скачивается
возьмите другой образ, в котором есть kubectl и оболочка, например alpine/k8s нужной версии, и оставьте entrypoint: [""].

Кластер и сайт

The Ingress "docs" is invalid: spec.rules[0].host: Invalid value: "__HOST__"
манифест применился без подстановки домена. Либо в .gitlab-ci.yml осталась старая джоба с kubectl apply -f k8s/, либо файл применили вручную. Применяйте только через sed "s/__HOST__/ДОМЕН/" k8s/docs.yaml | kubectl -n NAMESPACE apply -f -.
failed calling webhook ... connection refused
Ingress-контроллер ещё запускается. Подождите минуту (команда kubectl wait из раздела 10) и запустите джобу снова.
404 Not Found от nginx
запрос пришёл на IP без имени сайта. Используйте curl -H "Host: test.ruslans.site" ... или заходите по домену.
Открывается не та страница или два Ingress на один домен
на этот же домен есть другой Ingress (например, остался от ручных экспериментов), и контроллер выбирает его. Найдите его командой kubectl get ingress -A и удалите: kubectl delete ingress ИМЯ -n NAMESPACE.
DNS_PROBE_FINISHED_NXDOMAIN в браузере
DNS не знает домен. Проверьте, что в Cloudflare домен в статусе Active (у регистратора указаны nameserver Cloudflare) и что в DNS есть запись CNAME для домена на ID-ТУННЕЛЯ.cfargotunnel.com. Проверка: nslookup ruslans.site 1.1.1.1.
Ошибка 502 или 1033 на сайте
туннель не достаёт до кластера или не запущен. Проверьте docker logs cloudflared и доступность 192.168.56.50 из WSL (раздел 3).
Поды возвращаются после удаления
ими управляет Deployment. Чтобы убрать их насовсем, удалите сам объект (kubectl -n NAMESPACE delete deployment docs), но следующий запуск пайплайна создаст его снова.