Я продолжаю цикл статей по приручению домашнего сервера разработчика, который хочет уметь в 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-окружению.
eigrad
Для полноценной глубокой статьи слишком opionated, много спорных моментов типа того как сделать чтобы поды на всех нужных нодах поднялись, или про то использовать ли релизный baremetal файл vs разобраться в values... найди в репе с чартом эти values от релизного baremetal файла, возьми их за основу.
Но и статьей "смотрите как все легко, просто и понятно" тут тоже не пахнет. (впрочем тогда нужен бы был не baremetal кластер)
Просто рассказ "я сделал" ... ну норм.