Kubernetes Ingress Split Traffic

Контекст

Ковыряясь с employer api был момент когда мы выдвинули гипотезу о том что было бы стратежнее переключиться на redis бегущий в нашем кластере

Было бы круто, если бы мы могли, точечно, для пары под сделать такое переключение, что бы посмотреть что будет

В текущем сетапе, такое переключение это билет в один конец и переключить можно только все

Эта заметка, будет на это примере описывать как поднять временное решение которое позволит проверить подобную гипотезу

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

Есть несколько способов провернуть такой трюк, в этой заметке мы пойдем пожалуй самым длинным, но в месте с тем и самым гибким

На деле мы подымем рядом еще одну копию сервиса, назовем ее employer-api-test, подымать будем deployment, service и ingress

В ingress будет внесено всего пара мелко правок которые и сделают всю магию

На выходе в Kubernetes у нас будут крутиться х2 deployment, service, ingress для employer api, при этом на новосозданную копию мы будет отправлять N% трафика

image-20240110-144802.png

Что важно: 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 руками)

выглядит приблизительно вот так:

image-20240110-145200.png

За для удобства, меняем все 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 і відповідно старий сервіс обслуговує трафік (по замовченню в нас стара поведінка)

image-20240509-155625.png

для тесту нового сервісу нам потрібно засетити куку netcore з значенням always

у нашому випадку надійніше сетить куку не на рівні домену auth-api.dev.rabota.ua → а на рівні .dev.rabota.ua && .dev.robota.ua одночасно

валідними значеннями можуть бути лише 2 значення: always і newer

image-20240509-155747.png

після перезавантаження сторінки потрапимо в новий сервіс, де свагер урла відрізняється: https://auth-api.dev.rabota.ua/swagger/index.html

в нашому випадку навіть візуально видно різницю, якщо не вдаватись в деталі!

image-20240509-155934.png


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 (если быстро и ненадолго) или прям всё (по финализации).
Удобство отдельного как раз в этом - одним движением можно быстро вернуть всё взад.

ПР со всеми изменениями тут

Демонстрация разделения трафика в графиках и цифрах:

![Screenshot 2025-07-31 at 19.10.00.png](Screenshot 2025-07-31 at 19.10.00.png)

![Screenshot 2025-07-31 at 19.11.11.png](Screenshot 2025-07-31 at 19.11.11.png)


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