Kubernetes Ingress Split Traffic
Контекст
Ковыряясь с employer api был момент когда мы выдвинули гипотезу о том что было бы стратежнее переключиться на redis бегущий в нашем кластере
Было бы круто, если бы мы могли, точечно, для пары под сделать такое переключение, что бы посмотреть что будет
В текущем сетапе, такое переключение это билет в один конец и переключить можно только все
Эта заметка, будет на это примере описывать как поднять временное решение которое позволит проверить подобную гипотезу
Как это работает
Есть несколько способов провернуть такой трюк, в этой заметке мы пойдем пожалуй самым длинным, но в месте с тем и самым гибким
На деле мы подымем рядом еще одну копию сервиса, назовем ее employer-api-test, подымать будем deployment, service и ingress
В ingress будет внесено всего пара мелко правок которые и сделают всю магию
На выходе в Kubernetes у нас будут крутиться х2 deployment, service, ingress для employer api, при этом на новосозданную копию мы будет отправлять N% трафика
Что важно: ingress нашего клона будет один в один такой же как и оригинальный, НО, в нем будет две дополнительные аннотации, которые и сделают весь трюк:
nginx.ingress.kubernetes.io/canary: "true" # enables traffic split
nginx.ingress.kubernetes.io/canary-weight: "5" # send 5% of traffic to this ingressКлонирование
Тут никакого ракетостроения
делаем, условный kubectl get deployment employer-api -o yaml > deployment.yml, затем из него удаляем все лишнее (то что мы не добавляли бы, создавая yaml руками)
выглядит приблизительно вот так:
За для удобства, меняем все employer-api, на employer-api-test
Перепроверяем spec.selector.matchLabels - там нужно будет убрать автобусную лейбу и добавить нашу app
Удаляем целиком раздел status
Далее нам понадобиться сервис, тут даже не ясно что быстрее вычищать существующий или накалякать свой
apiVersion: v1
kind: Service
metadata:
annotations:
owner: alexandrm@rabota.ua
repository: https://github.com/rabotaua/employerapi
labels:
app: employer-api-test
name: employer-api-test
namespace: production
spec:
ports:
- port: 80
protocol: TCP
targetPort: 80
selector:
app: employer-api-test
type: ClusterIPИ сам ingress
Внимание: тут самый важный сок
apiVersion: v1
kind: Service
metadata:
annotations:
owner: alexandrm@rabota.ua
repository: https://github.com/rabotaua/employerapi
labels:
app: employer-api-test
name: employer-api-test
namespace: production
spec:
ports:
- port: 80
protocol: TCP
targetPort: 80
selector:
app: employer-api-test
type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 10m
owner: alexandrm@rabota.ua
repository: https://github.com/rabotaua/employerapi
# POI: enables traffic split 5%
nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-weight: "5"
labels:
app: employer-api
name: employer-api
namespace: production
spec:
ingressClassName: external
rules:
# POI: DNS is same as in original ingress
- host: employer-api.rabota.ua
http:
paths:
- backend:
service:
name: employer-api-test
port:
number: 80
path: /
pathType: ImplementationSpecific
- host: employer-api.robota.ua
http:
paths:
- backend:
service:
name: employer-api-test
port:
number: 80
path: /
pathType: ImplementationSpecific
tls:
- hosts:
- employer-api.rabota.ua
secretName: wildcard-tls
- hosts:
- employer-api.robota.ua
secretName: robota-wildcard-tlsПримечания:
- само DNS имя осталось прежним, то есть оба, оригинальный и клоннированый ingress обслуживают запросы для employer-api.rabota.ua
- в клоннированом ingress добавлены две аннотации включающие сплит трафика и забирающие на себя 5% всех запросов
Важно: тут очень легко запутаться, условно забыл поменять имя ингресса, или внутри ингресса забыл поменять селектор для сервиса и оно не работает - нужно пять раз перепроверить что бы везде были новые названия
Пример
Прикрепляю готовый к использованию пример, с которым можно играться
---
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-a
data:
index.html: |
<style>html,body{display:flex;align-items:center;justify-content:center;font-size:25vh}</style>
A
---
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-b
data:
index.html: |
<style>html,body{display:flex;align-items:center;justify-content:center;font-size:25vh}</style>
B
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-a
labels:
app: nginx-a
spec:
replicas: 1
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
type: RollingUpdate
selector:
matchLabels:
app: nginx-a
template:
metadata:
labels:
app: nginx-a
annotations:
owner: alexandrm@rabota.ua
repository: http://hg.nginx.org/nginx/
spec:
volumes:
- name: nginx-a
configMap:
name: nginx-a
containers:
- name: nginx-a
image: nginx
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
resources:
limits:
cpu: 500m
memory: 500Mi
requests:
cpu: 50m
memory: 100Mi
readinessProbe:
failureThreshold: 1
periodSeconds: 5
timeoutSeconds: 1
successThreshold: 1
tcpSocket:
port: 80
livenessProbe:
failureThreshold: 3
httpGet:
path: /
port: 80
scheme: HTTP
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 5
startupProbe:
failureThreshold: 10
httpGet:
path: /
port: 80
scheme: HTTP
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 5
volumeMounts:
- name: nginx-a
mountPath: /usr/share/nginx/html
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: poolDestination
operator: In
values:
- app
---
apiVersion: v1
kind: Service
metadata:
name: nginx-a
labels:
app: nginx-a
spec:
type: ClusterIP
selector:
app: nginx-a
ports:
- protocol: TCP
port: 80
targetPort: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-b
labels:
app: nginx-b
spec:
replicas: 1
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
type: RollingUpdate
selector:
matchLabels:
app: nginx-b
template:
metadata:
labels:
app: nginx-b
annotations:
owner: alexandrm@rabota.ua
repository: http://hg.nginx.org/nginx/
spec:
volumes:
- name: nginx-b
configMap:
name: nginx-b
containers:
- name: nginx-b
image: nginx
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
resources:
limits:
cpu: 500m
memory: 500Mi
requests:
cpu: 50m
memory: 100Mi
readinessProbe:
failureThreshold: 1
periodSeconds: 5
timeoutSeconds: 1
successThreshold: 1
tcpSocket:
port: 80
livenessProbe:
failureThreshold: 3
httpGet:
path: /
port: 80
scheme: HTTP
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 5
startupProbe:
failureThreshold: 10
httpGet:
path: /
port: 80
scheme: HTTP
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 5
volumeMounts:
- name: nginx-b
mountPath: /usr/share/nginx/html
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: poolDestination
operator: In
values:
- app
---
apiVersion: v1
kind: Service
metadata:
name: nginx-b
labels:
app: nginx-b
spec:
type: ClusterIP
selector:
app: nginx-b
ports:
- protocol: TCP
port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: nginx-a
labels:
app: nginx-a
spec:
ingressClassName: external
rules:
- host: nginx-ab.rabota.ua
http:
paths:
- backend:
service:
name: nginx-a
port:
number: 80
path: /
pathType: ImplementationSpecific
- host: nginx-ab.robota.ua
http:
paths:
- backend:
service:
name: nginx-a
port:
number: 80
path: /
pathType: ImplementationSpecific
tls:
- hosts:
- nginx-ab.rabota.ua
secretName: wildcard-tls
- hosts:
- nginx-ab.robota.ua
secretName: robota-wildcard-tls
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: nginx-b
labels:
app: nginx-b
annotations:
owner: alexandrm@rabota.ua
repository: http://hg.nginx.org/nginx/
# POI: enables traffic split 50%
nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-weight: "50"
spec:
ingressClassName: external
rules:
# POI: DNS is same as in previous ingress
- host: nginx-ab.rabota.ua
http:
paths:
- backend:
service:
name: nginx-b
port:
number: 80
path: /
pathType: ImplementationSpecific
- host: nginx-ab.robota.ua
http:
paths:
- backend:
service:
name: nginx-b
port:
number: 80
path: /
pathType: ImplementationSpecific
tls:
- hosts:
- nginx-ab.rabota.ua
secretName: wildcard-tls
- hosts:
- nginx-ab.robota.ua
secretName: robota-wildcard-tlsПример запроса:
curl -s --resolve nginx-ab.rabota.ua:443:20.101.14.136 https://nginx-ab.rabota.ua/ | grep -E "A|B"Если его позапускать, через раз в ответе будет то А то B
Очистка
Вся прелесть этого подхода, в том что точно так же как и добавляли достаточно удалить deployment, service и ingress
Или если хотим на время выключить можно удалить только ingress или выставить там 0%
Метрики
Важный момент, по хорошему мы хотим все это провернуть что бы посмотреть что будет, а куда смотреть и что сравнивать?
Тут по хорошему нужно что бы у сервиса был прикручен прометей, тогда можно было бы по подам смотреть прямо точечно
Если метрик никаких нет, то есть вариант посмотреть косвенно через метрики nginx, по типу (тут только кол-во)
sum(rate(nginx_ingress_controller_response_size_count{host="employer-api.rabota.ua",canary=""}[2m]))
sum(rate(nginx_ingress_controller_response_size_count{host="employer-api.rabota.ua",canary!=""}[2m]))Зачем это может быть полезным
По мимо распределения трафика про процентам, эта штука умеет ориентироваться по заголовкам и печенькам
Так например, отталкиваясь от заголовка CF-Country мы знаем с какого региона прилетел запрос и могли бы куда то отдельно отправлять всех foreign клиентов, дабы если и будут мучать сервис, то в своей песочнице
Печенька может быть использована в А/Б тесте, например, мы запускаем новую бета версию и даем пользователям возможность добровольно на нее переключаться, при переключении ему ставиться печенька и все его запросы попадают на новую версию
И так далее и тому подобное, тут уже на сколько фантазии хватит
History: employer-api-test
Делалось впервые для копии employer-api-test, соотв в кубере были развернуты deployment, service и ingress
Отличительная черта копии в том что он ходит в redis внутри кубера, за вместо ажурного
На этот экземпляр отправлено 5% трафика
Преследуемая цель: посмотреть будет ли разница, но в виду отсуствия метрик пока это не представляется возможным, потом это дело остается как есть до момента если снова что грохнеться
Сам yml пока не прикрепляю т.к. там с одной стороны ничего полезного и интересного нет, а с другой, там в env все все секреты продовские
canary-by-cookie
Передумова: ми хочемо паралельно розвернути клон-сервісу або нову версію і перевести трафік щоб протестувати ізольовано.
В нашому випадку це сервіс авторизації і хочемо перевірити флоу як наший фронтовий клієнт буде повноцінно взаємодіяти з ним, а не точково кидати запити на різні ендпоінти
Нам буде корисною наступна анотація (doc):
nginx.ingress.kubernetes.io/canary-by-cookieна прикладі міграції auth-api сервіса з .NET Framework → .NET Core я паралельно задеплоїв новий сервіс з відповідним deployment + service (тут все тривіально і звичайно)
Цікавіше далі, у нашому випадку ingress виглядає так:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: "authapi"
labels:
app: "authapi"
annotations:
nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-by-cookie: "netcore"
owner: "vadimk@rabota.ua"
repository: "https://github.com/rabotaua/auth-api"
spec:
ingressClassName: external
rules:
- host: "auth-api.dev.rabota.ua"
http:
paths:
- backend:
service:
name: "authapi"
port:
number: 80
path: /
pathType: ImplementationSpecific
- host: "auth-api.dev.robota.ua"
http:
paths:
- backend:
service:
name: "authapi"
port:
number: 80
path: /
pathType: ImplementationSpecific
tls:
- hosts:
- auth-api.dev.rabota.ua
secretName: wildcard-tls
- hosts:
- auth-api.dev.robota.ua
secretName: robota-wildcard-tlsв загальному нічим не відрізняється від типового, але тут з‘явилось декілька цікавих для нас анотацій:
перша вмикає власне сам механізм:
nginx.ingress.kubernetes.io/canary: "true"друга визначає куку, на яку ми будемо орієнтуватись, щоб спрямувати трафік на новий сервіс
nginx.ingress.kubernetes.io/canary-by-cookie: "netcore"на практиці нашого цільового сервісу: https://auth-api.dev.rabota.ua при переході без куки ми потрапляємо в старий свагер: https://auth-api.dev.rabota.ua/swagger/ui/index і відповідно старий сервіс обслуговує трафік (по замовченню в нас стара поведінка)
для тесту нового сервісу нам потрібно засетити куку netcore з значенням always
у нашому випадку надійніше сетить куку не на рівні домену auth-api.dev.rabota.ua → а на рівні .dev.rabota.ua && .dev.robota.ua одночасно
валідними значеннями можуть бути лише 2 значення: always і newer
після перезавантаження сторінки потрапимо в новий сервіс, де свагер урла відрізняється: https://auth-api.dev.rabota.ua/swagger/index.html
в нашому випадку навіть візуально видно різницю, якщо не вдаватись в деталі!
canary-by-header
Еще один пример применения на практике распределения трафика между сервисами, но уже по хедеру.
Контекст проблемы, которую нужно было решить asap
Приложение alliance-deskop обслуживает запросы как от пользователей чисто за “статикой” (скрипты, стили, иконки для SPA-части основного продукта), так и запросы за SEO-значимыми страницами (пресловутый server side rendering), за которыми приходят боты (Google, OpenAI, facebook, telegram, viber, etc).
При наплыве запросов за ssr-страницами от ботов периодически ловим скейл в полку с выеданием всех отведенных ресурсов, а следом и увеличение времени отклика.
Поскольку одно приложение обслуживает 2 функции, то такая ситуация цепляет и реальных пользователей - у них также начинает долго грузиться наш сайт, бо запросы за скриптами/стилями идут в “общую очередь”.
Идея: а что если эти 2 функции разделить и положить на плечи двух отдельных сервисов? (чтобы снять негативное влияние на пользователей)
Собственно, идея валидная, квик-вин можно получить, а как сделать?
Вот тут и пригодилась фишка с canary и сплитом трафика.
Шаг 1
Порождаем сервис-копию нашего alliance-desktop-а. Причем буквально - путем ctrl C, ctrl V текущей структуры в kube.
Для иллюстрации yml service-a:
apiVersion: v1
kind: Service
metadata:
name: alliance-desktop-temp
labels:
app: alliance-desktop-temp
spec:
type: ClusterIP
selector:
app: alliance-desktop-temp
ports:
- name: alliance-desktop-temp
port: 80
protocol: TCP
targetPort: httpВажно:
- сделать уникальное имя для копии (иначе просто не сработает kubectl apply)
- пометить уникальной label-ой (нужно ж их как-то различать в метриках / логах / etc)
Шаг 2
Заводим рядом свой отдельный ingress с нужной магией для сплита:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: alliance-desktop-temp
namespace: production
labels:
app: alliance-desktop-temp
annotations:
nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-by-header: "User-Agent"
nginx.ingress.kubernetes.io/canary-by-header-pattern: ".*(mactemp|foo bar|foo-bar|bot|spider|crawl|APIs-Google|AdsBot|Googlebot|mediapartners|Google Favicon|FeedFetcher|Google-Read-Aloud|DuplexWeb-Google|googleweblight|ia_archiver|facebook|instagram|pinterest|reddit|slack|twitter|whatsapp|youtube|telegram|viber|Google-InspectionTool|AhrefsBot|AhrefsSiteAudit).*"
spec:
ingressClassName: external
rules:
- host: robota.ua
http:
paths:
- backend:
service:
name: alliance-desktop-temp
port:
number: 80
path: /
pathType: Prefix
tls:
- hosts:
- robota.ua
secretName: robota-wildcard-tlsВ нем "говорим", что хотим обслуживать host: robota.ua нашим новым приложением-копией alliance-desktop-temp, НО не всё, а по условию.
Здесь важны следующие аннотации:
- опция, которая включает возможность сплитить трафик:
nginx.ingress.kubernetes.io/canary: "true"Примечание: без нее kubectl apply выдаст с ходу ошибку, мол, "точно такой же host уже обслуживается другим приложением"
- указываем, что хотим сплитить по хедеру и какому
nginx.ingress.kubernetes.io/canary-by-header: "User-Agent"Примечание: именно по User-Agent-у умеем отличать ботов, которым нужны ssr-ные страницы
- указываем паттерн значения этого самого хедера
nginx.ingress.kubernetes.io/canary-by-header-pattern: ".\*(foo bar|foo-bar|Google|etc)\*"Примечание: опция позволяет описать регулярным выражением значение хедера.
Также есть опция под точное значение:
nginx.ingress.kubernetes.io/canary-by-header-value: "i_am_batman"Шаг 3. Применяем и молимся проводим такой финт сначала на тестовой среде и проверяем, что ничего не сломали, а потом применяем и молимся.
Когда отпадет нужна в таком финте ушами, будет достаточно удалить добавленный ingress (если быстро и ненадолго) или прям всё (по финализации).
Удобство отдельного как раз в этом - одним движением можно быстро вернуть всё взад.
ПР со всеми изменениями тут
Демонстрация разделения трафика в графиках и цифрах:


Важно: проводя такой эксперимент важно помнить что ингрес нового сервиса, это отдельный ингресс с другим именем. Если по ошибке обозвать его так же само как и оригинальный - он просто перетрет его и все пойдет наперекосяк