HTTPS для сайта в Kubernetes-кластере с помощью NGINX Ingress Controller, cert-manager и Let’s Encrypt

от автора

Я продолжаю цикл статей по приручению домашнего сервера разработчика, который хочет уметь в 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-окружению.


ссылка на оригинал статьи https://habr.com/ru/post/668098/