Я продолжаю цикл статей по приручению домашнего сервера разработчика, который хочет уметь в DevOps. В первой своей статье я рассказал о развёртывании Xen Project гипервизора и миграции Windows-виртуалок из Hyper-V. Во второй о развёртывании на базе виртуалок этого сервера Kubernetes-кластера. Перед написанием данной я ставил перед собой следующие цели:

  1. Развернуть тестовый сайт, состоящий из статических ресурсов и front-end API в vanila Kubernetes-кластере.

  2. Обеспечить доступ к этому сайту с использованием NGINX Ingress Controller.

  3. Сайт должен быть доступен по HTTPS-протоколу с автоматически обновляемым TLS-сертификатом Let’s Encrypt.

Развёртывание NGINX Ingress-контроллера

Развернуть NGINX ingress Controller довольно просто, однако есть пара моментов. Во-первых, на официальной странице, посвящённой развёртыванию контроллера, в разделе Quick start даётся пример с использованием Helm, пригодный только для облака. Во-вторых, в разделе о развёртывании в bare-metal кластере, сказано, что нужно воспользоваться готовым YAML-файлом, специально предназначенным для такого случая.

На самом деле для развёртывания контроллера и в облаке и на железе можно и нужно использовать helm-chart, но перед этим нужно разобраться с его параметрами (values), коих более 300. Не вполне полная документация по ним дана на странице git-репозитория контроллера. В файле значений по умолчанию также есть масса полезных комментариев.

Добавление helm-репозитория

Первое, что нужно сделать, чтобы получить возможность установить наш ingress-контроллер, это добавить его репозиторий в helm-клиент с помощью следующих команд:

helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update

Настройка параметров развёртывания контроллера

Далее необходимо создать файл nginx-values.yaml. Для моего случая получилось следующее:

controller:
  replicaCount: 2
  service:
    type: NodePort
    externalTrafficPolicy: Local
    nodePorts:
      http: 30100
      https: 30101
  ingressClassResource:
    default: true
  watchIngressWithoutClass: true
  affinity:
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchExpressions:
          - key: app.kubernetes.io/component
            operator: In
            values:
            - controller
          - key: app.kubernetes.io/instance
            operator: In
            values:
            - ingress-nginx
          - key: app.kubernetes.io/name
            operator: In
            values:
            - ingress-nginx
        topologyKey: "kubernetes.io/hostname"

Далее я поясню значение этих настроек.

Способ публикации ingress-контроллера

Так как предполагается использовать в качестве балансировщика нагрузки внешний не управляемый кластером роутер, сервис контроллера нужно публиковать с помощью controller.service.type: NodePort, а не LoadBalancer. Чтобы настроить DNAT на внешнем роутере, необходимо зафиксировать порты для HTTP и HTTPS, прописав их с помощью controller.nodePorts.http: 30100 и controller.nodePorts.https: 30101, соответственно.

Изменение количества реплик

Сервис с типом NodePort по умолчанию выполняет SNAT, что приводит к тому, что исходный IP в запросах будет соответствовать IP узла Kubernetes-кластера, принявшего запрос. Таким образом, в логах web-приложений IP-адрес присутствовать не будет. Рекомендуемым способом сохранения IP-адреса клиента для сервиса с типом NodePort является установка spec.externalTrafficPolicy в Local. Однако, это означает, что пакеты, полученные узлом, не имеющим запущенного экземпляра ingress controller, будут отброшены. Чтобы избежать этого, на каждом узле кластера, указанном в балансировщике нагрузки роутера (в моём случае worker-узлы), необходимо запустить экземпляр контроллера. Это делается с помощью настройки controller.replicaCount: 2 (по числу worker-узлов). Чтобы гарантировать присутствие пода контроллера на каждом узле, настраивается controller.affinity.

Класс ingress-контроллера по умолчанию

В одном кластере одновременно могут использоваться различные продуты, выступающие в качестве ingress-контроллеров. При создании ingress-ресурса можно (и желательно) указать так называемый класс контроллера, соответствующий ingress-продукту (в данном случае nginx == NGINX Ingress Controller). Однако, пользователь может и не указать его. Чтобы кластер работал предсказуемым образом, один из классов ingress-контроллера рекомендуется пометить как используемым по умолчанию. Для этого предназначена настройка controller.ingressClassResource.default: true. Для большей надёжности и для совместимости с ingress-ресурсами, созданными до установки данного ingress-контроллера, также используется возможность NGINX Ingress Controller по отслеживанию ingress-ресурсов без указанного класса ingress-контроллера: controller.watchIngressWithoutClass: true.

Развёртывание ingress-контроллера в кластере

Выполняем следующую команду:

helm install ingress-nginx ingress-nginx/ingress-nginx \
	--namespace ingress-nginx --create-namespace \
	--values nginx-values.yaml

Если вы хотите перед развёртыванием в кластере проанализировать полученные ресурсы, в данную команду можно добавить пару ключей: --dry-run --debug.

Развёртывание занимает некоторое время. Чтобы отследить его окончание можно воспользоваться следующей командой, которая завершится по его окончанию:

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

Развёртывание тестового приложения

В качестве теста и выявления возможных проблем можно развернуть web-приложение, имитирующее широко распространённый случай: статические файлы SPA + front-end web API. В качестве первого я буду использовать nginx, в качестве второго chentex/go-rest-api.

Для начала нам нужно определить необходимые Kubernetes-ресурсы в файле site-sample.yaml:

apiVersion: v1
kind: Namespace
metadata:
  name: test-ingress-app
  labels:
    app.kubernetes.io/name: test-ingress-app
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: spa-deployment
  namespace: test-ingress-app
  labels:
    app.kubernetes.io/name: spa
    app.kubernetes.io/component: spa
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: spa
      app.kubernetes.io/component: spa
  template:
    metadata:
      labels:
        app.kubernetes.io/name: spa
        app.kubernetes.io/component: spa
    spec:
      containers:
      - name: spa
        image: nginx
        ports:
        - containerPort: 80
          name: http
          protocol: TCP
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: app.kubernetes.io/name
                operator: In
                values:
                - spa
              - key: app.kubernetes.io/component
                operator: In
                values:
                - spa
            topologyKey: "kubernetes.io/hostname"
---
apiVersion: v1
kind: Service
metadata:
  name: spa
  namespace: test-ingress-app
  labels:
    app.kubernetes.io/component: spa
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: spa
    app.kubernetes.io/component: spa
  ports:
  - port: 80
    targetPort: 80
    name: http
    protocol: TCP
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: frontend-api
  namespace: test-ingress-app
  labels:
    app.kubernetes.io/name: frontend-api
    app.kubernetes.io/component: frontend-api
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: frontend-api
      app.kubernetes.io/component: frontend-api
  template:
    metadata:
      labels:
        app.kubernetes.io/name: frontend-api
        app.kubernetes.io/component: frontend-api
    spec:
      containers:
      - name: frontend-api
        image: chentex/go-rest-api
        ports:
        - containerPort: 8080
          name: http
          protocol: TCP
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: app.kubernetes.io/name
                operator: In
                values:
                - frontend-api
              - key: app.kubernetes.io/component
                operator: In
                values:
                - frontend-api
            topologyKey: "kubernetes.io/hostname"
---
apiVersion: v1
kind: Service
metadata:
  name: frontend-api
  namespace: test-ingress-app
  labels:
    app.kubernetes.io/name: frontend-api
    app.kubernetes.io/component: frontend-api
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: frontend-api
    app.kubernetes.io/component: frontend-api
  ports:
  - port: 80
    targetPort: 8080
    name: http
    protocol: TCP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ingress-test-site
  namespace: test-ingress-app
spec:
  ingressClassName: nginx
  rules:
  - host: es.moysite.ru
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: spa
            port:
              number: 80
      - path: /test
        pathType: Prefix
        backend:
          service:
            name: frontend-api
            port:
              number: 80

Самым интересным, с точки зрения данной статьи, является последний ресурс, собственно, ingress. В нём я определяю следующее:

  1. Класс используемого ingress-контроллера указывает на только что развёрнутого нами NGINX Ingress Controller: ingressClassName: nginx.

  2. Ingress будет следить за доступом к сайту es.moysite.ru: host: es.moysite.ru. Для этого ingress-контроллер будет анализировать заголовок host: HTTP-запроса.

  3. В рамках этого сайта используются два сервиса: статические файлы SPA, доступные в корневой папке домена (/), и frontend Web API, запросы к которому определяются по префиксу /test.

Более подробно работа с ingress-ресурсами описана в статье про Ingress, а описание API на странице документации.

Тестовый сайт развёртываем командой:

kubectl apply -f site-sample.yaml

Поиск и устранение возможных проблем развёртывания web-приложения

В моём случае я наступил, пожалуй, на большинство «граблей», но для меня это хорошо. Это — опыт.

Далее я буду исходить из того, что у меня есть статический публичный IP-адрес, по которому должен быть доступен мой сайт. Адрес сайта es.moysite.ru, соответствие этого имени статическом адресу прописано в публичном DNS-сервисе. Используется роутер, встроенный firewall которого обеспечивает DNAT и SNAT. Я хочу тестировать развёртывание сайта из своей локальной сети.

Простейший тест доступности развёрнутого сайта делается с помощью навигации по адресу http://es.moysite.ru в браузере. Если всё хорошо, вы увидите страницу по умолчанию nginx, на которой он поприветствует вас: Welcome to nginx!. Чтобы протестировать доступность симулякра frontend Web API, в браузере пытаемся посмотреть страницу по адресу http://es.moysite.ru/test. Ответом должен быть JSON:

{
  "color": "yellow",
  "message": "This is a Test",
  "notify": "false",
  "message_format": "text"
}

Если оба теста прошли удачно, как из локальной сети, так и с устройства, не подключённого к ней, например, мобильника, поздравляю! Однако, как я сказал ранее, у меня так не получилось. Начинаем искать проблему.

Проверка работоспособности pod

К любому pod можно обратиться с worker-узла, на котором запущен pod. Для этого, во-первых, необходимо определить worker-узел и адрес самого pod:

    kubectl get pod --n test-ingress-app -owide

Вы должны получить что-то подобное:

NAME                              READY   STATUS    RESTARTS      AGE     IP              NODE       NOMINATED NODE   READINESS GATES
frontend-api-5b98fc8595-9gflr     1/1     Running   1 (18h ago)   2d18h   192.168.30.85   worker02   <none>           <none>
frontend-api-5b98fc8595-tjkfl     1/1     Running   2 (24h ago)   2d18h   192.168.5.18    worker01   <none>           <none>
spa-deployment-7577c8974d-rh9kw   1/1     Running   2 (24h ago)   2d18h   192.168.5.17    worker01   <none>           <none>
spa-deployment-7577c8974d-w4mrv   1/1     Running   1 (18h ago)   2d18h   192.168.30.84   worker02   <none>           <none>

Для примера, я хочу протестировать работоспособность pod spa-deployment-7577c8974d-rh9kw, расположенного на узле worker01 и доступного по адресу 192.168.5.17. Для этого необходимо подключиться во ssh к узлу worker01 и выполнить следующую команду:

curl 192.168.5.17:80

Если видим кусок HTML с жизнерадостным Welcome to nginx!, идём к следующему шагу. Если нет, начинаем анализировать жизнеспособность pod.

Проверка работоспособности сервиса

Если pod жив и отвечает, тестируем сервис. Сервисы spa и frontend-api имеют тип ClusterIP. Такие сервисы доступны только изнутри кластера. Для проверки их доступности нужно использовать какой-либо pod внутри кластера, например curlimages/curl.

Для начала определяем адрес интересующего нас сервиса:

kubectl get service -n test-ingress-app 

Получим примерно такой вывод:

NAME           TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
frontend-api   ClusterIP   10.26.193.206   <none>        80/TCP    2d21h
spa            ClusterIP   10.26.111.126   <none>        80/TCP    2d21h

Нас интересует IP-адрес сервиса. В данном случае сервис spa внутри кластера доступен по адресу 10.26.111.126.

Развёртываем pod curlimages/curl в кластере и подключаемся к его консоли и выполняем команду curl с адресом нашего сервиса:

kubectl run -i --tty curl --image=curlimages/curl -- sh
/ $ curl 10.26.111.126

Если всё работает нормально, мы должны увидеть всё тот же кусок HTML с приветствием от nginx.

Если что-то пошло не так, начинаем копать под сервис: kubectl describe service spa. Например, в моём случае, я неправильно указал селектор pods в определении сервиса и сервис не был связан ни с одним pod. Заметил я это по отсутствующим IP-адресам в поле Endpoints.

Далее можно проверить доступность сервиса по его имени. Для этого в консоли того же curlimages/curl пытаемся обратиться к сервису по его имени:

/ $ curl spa

В моём случае я неосторожно поменял какие-то настройки сети уже после установки Calico CNI. В результате DNS в кластере не работал. Это также проявлялось в том, что не работали validation webhooks ingress-контроллера и cert-manager. Решить эту проблему помогла переустановка Calico CNI.

В конце не забываем удалить ненужный более pod:

kubectl delete pod curl

Проверка доступности сервиса через Ingress

Если все предыдущие тесты оказались успешными, пришло время проверить доступность сервисов через ingress-контроллер. Делать это нужно с одного из worker-узлов кластера, в моём случае, например, с worker01. Нужно помнить, во-первых, о том, что HTTP выведен на порт 30100, а также о том, что маршрутизация ведётся по HTTP-заголовку запроса host. Поэтому необходимо указать заголовок host:

curl worker01:30100 --header "host: es.moysite.ru"

Как обычно, успешным результатом стоит считать HTML с приветствием nginx. Если есть какие-то проблемы, скорее всего вы что-то напутали с определением ingress-ресурса.

Проверка доступности сервиса из локальной сети

Команда для проверки почти та же, что и в предыдущем случае. Я использую IP-адрес 10.44.55.14 (адрес узла worker01), а не его имя, так как DNAT у меня настроен только для подсети 10.44.55.0/24, которая используется для маршрутизации запросов к публичным серверам, а worker01 разыменовывается в подсети 10.44.44.0/24:

curl 10.44.55.14:30100 --header "host: es.moysite.ru"

Если не получили приветствие nginx, значит у вас не настроен или настроен неправильно DNAT и SNAT на вашем роутере. Как это правильно настроить, можно посмотреть, например, в этой статье: Проброс портов и Hairpin NAT в роутерах Mikrotik. Детали будут зависеть от марки и модели роутера, но общие принципы в статье описаны верно.

Настройка TLS с использованием сертификатов Let’s Encrypt

На данный момент мы развернули наш сайт в кластере и можем открыть его, используя HTTP. На самом деле сайт уже доступен по HTTPS, что легко проверить, открыв его в браузере по ссылке https://es.moysite.ru. Правда, по понятным причинам, браузер ругнётся, что сертификат «не торт», так как он действительно самовыпущенный ingress-контроллером. Наша задача заменить этот сертификат на имеющий признанную цепочку доверия.

Как это работает?

Для того, чтобы использовать HTTPS для вашего сайта, в принципе, достаточно иметь два файла: сертификат и ключ. Содержимое этих файлов сохраняется в кластере в виде secret-ресурса, откуда его использует соответствующий сайту ingress-ресурс. В случае покупных публичных сертификатов вы должны оплатить выпуск этих файлов у сертификационного центра и время от времени обновлять их (обычно раз в год), занося, понятное дело, новую денежку. Let’s Encrypt является центром сертификации, но не берёт с вас денег. Сертификаты Let’s Encrypt нужно обновлять не реже, чем каждые 3 месяца, если я помню правильно. Это тот ещё «геморрой» раз в 3 месяца не забыть запросить новый сертификат и заменить его в кластере. А если сайт не один? Если их несколько десятков? Поэтому появились решения, автоматизирующие этот процесс. Одно из самых известных — cert-manager, cloud native решение для управления сертификатами. cert-manager будет помнить за вас, что сертификаты нужно перевыпустить, обновить соответствующие ресурсы, а всё, что будет нужно от вас, это описать, для каких сайтов нужно выпускать сертификаты, и подключить эти сертификаты, хранящиеся в secret-ресурсах, к соответствующим ingress-ресурсам.

Развёртывание cert-manager в кластере

Я буду использовать helm-способ развёртывания. Для начала добавляем новый репозиторий Helm:

helm repo add jetstack https://charts.jetstack.io
helm repo update

Разворачиваем cert-manager в кластере (installCRDs=true говорит, что нужно автоматически установить дополнительные типы ресурсов в кластер, иначе придётся это делать руками.):

helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager --create-namespace \
  --version v1.8.0 \
  --set installCRDs=true

Настройка cert-manager на использование Let’s Encrypt в качестве эмитента сертификатов

Далее я подразумеваю, что у вас есть доменное имя, которое имеет публичную DNS-запись. В моём случае это будет es.moysite.ru. Также стоит отметить, что cert-manager поддерживает два варианта размещения своих ресурсов эмитента и сертификата: в пространствах имён и вне них (cluster wide). Я разберу только первый вариант. Cluster wide ресурсы нужны в случае, когда ваш сертификат выпускается на несколько доменов, приложения которых расположены в разных пространствах имён. В kubectl командах я подразумеваю, что пространство имён test-ingress-app установлено в качестве пространства имён по умолчанию в вашем контексте.

У Let’s Encrypt есть два окружения: staging и production. Продуктовый имеет очень строгий лимит на количество запросов. Поэтому, пока вы не будете до конца уверены, что всё у вас работает устойчиво, лучше пользоваться staging окружением.

Первое, что нам нужно сделать — это создать ресурс эмитента для staging ресурса в файле lestencrypt-staging.yaml.

apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
  name: letsencrypt-staging
  namespace: test-ingress-app
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: user@example.com # Нужно указать реальный!
    privateKeySecretRef:
      name: letsencrypt-staging
    solvers:
    - http01:
        ingress:
          class: nginx

Важными полями здесь являются: spec.acme.email, в котором нужно указать ваш e-mail адрес, на который будут приходить уведомления об истечении срока действия сертификатов. spc.acme.privateKeySecretRef.name — имя secret-ресурса, в котором будет храниться сертификат, используемый cert-manager. Это не тот же сертификат, который используется нашим сайтом! spec.acme.server указывает на staging окружение.

Применяем этот файл:

kubectl apply -f letsencrypt-staging.yaml

Проверяем, что был создан issuer:

~ kubectl get issuers
NAME                  READY   AGE
letsencrypt-staging   True    138m

Регистрация учётки занимает некоторое время, поэтому поначалу READY будет False. Если False держится более 2 минут, стоит посмотреть, что с ним не так с помощью команды: kubectl describe issuer. В поле Events можно посмотреть в чём проблема.

Также для диагностики проблем полезно посмотреть на ресурсы типов certificaterequests и challenges. В моём случае была проблема с настройкой роутера. Во-первых, нужно пробросить порты 80 и 443 с WAN-интерфейса роутера на порты 30100 и 30101 соответственно, для чего нужно создать DNAT-правила. Во-вторых, разрешить пакеты в цепочке forward к портам 30100 и 30101 worker-узлов кластера с WAN-интерфейса. Более подробно о поиске проблем с cert-manager рассказано в статье Troubleshooting Issuing ACME Certificates.

В финале вы должны иметь возможность открыть стартовую страницу сайта в браузере по протоколу HTTPS. Браузер всё также будет ругаться на сертификат, но это ожидаемо, так как мы используем staging-окружение Let’s Encrypt. В качестве эмитента сертификата (см. иконку замочка рядом с адресной строкой) должен быть указан (STAGING) Artificial Apricot R3, а не Kubernetes Ingress Controller, как это было ранее :

После того, мы добились работоспособности в staging-окружении, пора перейти на production-окружение Let’s Encrypt. Сделать это довольно просто. Во-первых, создаём issuer-ресурс в файле letsencrypt-production.yaml и применяем его командой kubectl apply -f letsencrypt-production.yaml:

apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
  name: letsencrypt-production
  namespace: test-ingress-app
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: user@example.com
    privateKeySecretRef:
      name: letsencrypt-production
    solvers:
    - http01:
        ingress:
          class: nginx

Меняем описание ingress-ресурса:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ingress-test-site
  namespace: test-ingress-app
  annotations:
    cert-manager.io/issuer: letsencrypt-production
spec:
  ingressClassName: nginx
  rules:
  - host: es.moysite.ru
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: spa
            port:
              number: 80
      - path: /test
        pathType: Prefix
        backend:
          service:
            name: frontend-api
            port:
              number: 80
  tls:
  - hosts:
    - es.moysite.ru
    secretName: test-ingress-app-production

Через минуту-две сертификат будет получен и можно будет обновить страницу в браузере. На этот раз никаких предупреждений о плохом сертификате быть на должно, а информация о нём должна выглядеть примерно так:

Теперь можно удалить issuer-ресурс и секрет, относящиеся к staging-окружению.

Комментарии (1)


  1. eigrad
    28.05.2022 14:26

    Для полноценной глубокой статьи слишком opionated, много спорных моментов типа того как сделать чтобы поды на всех нужных нодах поднялись, или про то использовать ли релизный baremetal файл vs разобраться в values... найди в репе с чартом эти values от релизного baremetal файла, возьми их за основу.

    Но и статьей "смотрите как все легко, просто и понятно" тут тоже не пахнет. (впрочем тогда нужен бы был не baremetal кластер)

    Просто рассказ "я сделал" ... ну норм.