Я продолжаю цикл статей по приручению домашнего сервера разработчика, который хочет уметь в DevOps. В первой своей статье я рассказал о развёртывании Xen Project гипервизора и миграции Windows-виртуалок из Hyper-V. Во второй о развёртывании на базе виртуалок этого сервера Kubernetes-кластера. Перед написанием данной я ставил перед собой следующие цели:
Развернуть тестовый сайт, состоящий из статических ресурсов и front-end API в vanila Kubernetes-кластере.
Обеспечить доступ к этому сайту с использованием NGINX Ingress Controller.
Сайт должен быть доступен по 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. В нём я определяю следующее:
Класс используемого ingress-контроллера указывает на только что развёрнутого нами NGINX Ingress Controller:
ingressClassName: nginx
.Ingress будет следить за доступом к сайту es.moysite.ru:
host: es.moysite.ru
. Для этого ingress-контроллер будет анализировать заголовокhost:
HTTP-запроса.В рамках этого сайта используются два сервиса: статические файлы 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-окружению.