Kubernetes: єдиний AWS Load Balancer для різних Kubernetes Ingress

Автор |  19/11/2024

Подивились ми на наші витрати на AWS Load Balancers, і подумали, що треба трохи це діло привести в порядок.

Чого хочеться: мати один LoadBalancer, і через нього роутити запити на різні Kubernetes Ingresses та Services в різних Namespaces.

Перше, що спало на думку – це або додавати в Kubernetes кластер якийсь Service Mesh типу Istio або Linkerd, або додавати Nginx Ingress Controller, а перед ним – AWS ALB.

Але в UkrOps Slack мені нагадали, що AWS Load Balancer Controller, який ми використовуємо в нашому кластері AWS Elastic Kubernetes Service, вже давно вміє таке робити за допомогою IngressGroup.

Тож давайте подивимось як це працює, і як таку схему можна додати на існуючі Ingress ресурси.

Тест Load Balancer Controller IngressGroup

Отже, ідея доволі проста: в маніфесті Kubernetes Ingress ми задаємо ще один атрибут – group.name, і по ньому Load Balancer Controller визначає до якого AWS LoadBalancer цей Ingress належить.

Потім він використовуючи spec.hosts в Ingress визначає hostnames і на LoadBalancer будує роутинг до необхідних Target Groups.

Давайте спробуємо на простому прикладі.

Спочатку створюємо звичайну схему з окремими Ingress/ALB – описуємо маніфест з Namespace, Deployment, Service та Ingress:

apiVersion: v1
kind: Namespace
metadata:
  name: test-app-1-ns
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app-1-deploy
  namespace: test-app-1-ns
spec:
  replicas: 1
  selector:
    matchLabels:
      app: app-1-pod
  template:
    metadata:
      labels:
        app: app-1-pod
    spec:
      containers:
        - name: app-1-container
          image: nginx
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: app-1-service
  namespace: test-app-1-ns
spec:
  selector:
    app: app-1-pod
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
  type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-1-ingress
  namespace: test-app-1-ns
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80}]'
spec:
  ingressClassName: alb
  rules:
    - host: app-1.ops.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: app-1-service
                port:
                  number: 80

І такий же, тільки з app-2.

Деплоїмо:

$ kk apply -f .
namespace/test-app-1-ns created
deployment.apps/app-1-deploy created
service/app-1-service created
ingress.networking.k8s.io/app-1-ingress created
namespace/test-app-2-ns created
deployment.apps/app-2-deploy created
service/app-2-service created
ingress.networking.k8s.io/app-2-ingress created

Перевіряємо Ingress та його LoadBalancer для app-1:

$ kk -n test-app-1-ns get ingress
NAME            CLASS   HOSTS                   ADDRESS                                                                  PORTS   AGE
app-1-ingress   alb     app-1.ops.example.com   k8s-testapp1-app1ingr-9375bc68bc-376038977.us-east-1.elb.amazonaws.com   80      33s

Тут ADDRESS – “k8s-testapp1-app1ingr-9375bc68bc-376038977“.

Перевіряємо для app-2:

$ kk -n test-app-2-ns get ingress
NAME            CLASS   HOSTS                   ADDRESS                                                                   PORTS   AGE
app-2-ingress   alb     app-2.ops.example.com   k8s-testapp2-app2ingr-0277bbb198-1743964934.us-east-1.elb.amazonaws.com   80      64s

Тут ADDRESS – “k8s-testapp2-app2ingr-0277bbb198-1743964934“.

Відповідно, в AWS маємо два Load Balancers:

Тепер до обох Ingress  додаємо анотацію alb.ingress.kubernetes.io/group.name: test-app-alb:

...
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-1-ingress
  namespace: test-app-1-ns
  annotations:
    alb.ingress.kubernetes.io/group.name: test-app-alb
    alb.ingress.kubernetes.io/scheme: internet-facing
...
...
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-2-ingress
  namespace: test-app-2-ns
  annotations:
    alb.ingress.kubernetes.io/group.name: test-app-alb
    alb.ingress.kubernetes.io/scheme: internet-facing
...

Деплоїмо ще раз:

$ kk apply -f .
namespace/test-app-1-ns unchanged
deployment.apps/app-1-deploy unchanged
service/app-1-service unchanged
ingress.networking.k8s.io/app-1-ingress configured
namespace/test-app-2-ns unchanged
deployment.apps/app-2-deploy unchanged
service/app-2-service unchanged
ingress.networking.k8s.io/app-2-ingress configured

Та перевіряємо Ingresses та їхні адреси тепер.

У app-1 – це “k8s-testappalb-95eaaef0c8-2109819642“:

$ kk -n test-app-1-ns get ingress
NAME            CLASS   HOSTS                 ADDRESS                                                            PORTS   AGE
app-1-ingress   alb     app-1.ops.example.com   k8s-testappalb-95eaaef0c8-2109819642.us-east-1.elb.amazonaws.com   80      6m19s

У app-2 – теж “k8s-testappalb-95eaaef0c8-2109819642“:

$ kk -n test-app-2-ns get ingress
NAME            CLASS   HOSTS                 ADDRESS                                                            PORTS   AGE
app-2-ingress   alb     app-2.ops.example.com   k8s-testappalb-95eaaef0c8-2109819642.us-east-1.elb.amazonaws.com   80      6m48s

І в AWS у нас тепер один Load Balancer:

Який має два Listerner Rules, які в залежності від hostname в Ingress будуть редіректити запити до потрібних Target Groups:

IngressGroups – ліміти та реалізація в Production

При використанні такої схеми треба мати на увазі, що деякі параметри LoadBalancer не можуть задаватись в різних Ingress.

Наприклад, якщо один Ingress має анотацію alb.ingress.kubernetes.io/tags: "component=devops", а другий Ingress намагається задати тег component=backend, то Load Balancer Controller не задеплоїть такі зміни, і повідомить про конфлікт, наприклад:

aws-load-balancer-controller-7647c5cbc7-2stvx:aws-load-balancer-controller {"level":"error","ts":"2024-09-25T10:50:23Z","msg":"Reconciler error","controller":"ingress","object":{"name":"ops-1-30-external-alb"},"namespace":"","name":"ops-1-30-external-alb","reconcileID":"1091979f-f349-4b96-850f-9e7203bfb8be","error":"conflicting tag component: devops | backend"}
aws-load-balancer-controller-7647c5cbc7-2stvx:aws-load-balancer-controller {"level":"error","ts":"2024-09-25T10:50:44Z","msg":"Reconciler error","controller":"ingress","object":{"name":"ops-1-30-external-alb"},"namespace":"","name":"ops-1-30-external-alb","reconcileID":"19851b0c-ea82-424c-8534-d3324f4c5e60","error":"conflicting tag environment: ops | prod"}

Аналогічно до параметрів на кшталт alb.ingress.kubernetes.io/load-balancer-attributes: access_logs.s3.enabled=true,access_logs.s3.bucket=some-bucket-name, або параметри SecurityGroups.

А от з TLS все простіше: для кожного Ingress в його annotations з alb.ingress.kubernetes.io/certificate-arn можна передати ARN сертифікату з AWS Certificates Manager, і вони будуть налаштовані у Listener certificates for SNI:

Тому я принаймні поки що зробив так:

  • створив окремий GitHub репозиторій
  • в ньому Helm- чарт
  • в цьому чарті два маніфести для двох Ingress – один з типом internal, другий – internet-facing, і задав там всякі дефолтні параметри

Наприклад:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ops-external-ingress
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/group.name: ops-1-30-external-alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:492***148:certificate/88a5ccc0-e729-4fdb-818c-c41c411a3e3e
    alb.ingress.kubernetes.io/tags: "environment=ops,component=devops,Name=ops-1-30-external-alb"
    alb.ingress.kubernetes.io/actions.ssl-redirect: '{"Type": "redirect", "RedirectConfig": { "Protocol": "HTTPS", "Port": "443", "StatusCode": "HTTP_301"}}'
    alb.ingress.kubernetes.io/load-balancer-attributes: access_logs.s3.enabled=true,access_logs.s3.bucket=ops-1-30-devops-ingress-ops-alb-logs
    alb.ingress.kubernetes.io/actions.default-action: >
      {"Type":"fixed-response","FixedResponseConfig":{"ContentType":"text/plain","StatusCode":"200","MessageBody":"It works!"}}
spec:
  ingressClassName: alb
  defaultBackend:
    service:
      name: default-action
      port: 
        name: use-annotation

В defaultBackend задаємо дію, коли запит приходить на hostname, для якого нема окремого Listener – тут просто відповідаємо “It works!” з кодом 200.

А далі вже в Ingress проектів налаштовуються їхні параметри, наприклад Grafana:

$ kk -n ops-monitoring-ns get ingress atlas-victoriametrics-grafana -o yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    # the Common ALB group name
    alb.ingress.kubernetes.io/group.name: ops-1-30-external-alb
    ## TLS certificate from AWS ACM
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:492***148:certificate/88a5ccc0-e729-4fdb-818c-c41c411a3e3e
    # sucess codes for Target Group health checks
    alb.ingress.kubernetes.io/success-codes: "302"
    alb.ingress.kubernetes.io/target-type: ip
    kubernetes.io/ingress.class: alb
    ...
  name: atlas-victoriametrics-grafana
  namespace: ops-monitoring-ns
  ...
spec:
  rules:
  - host: monitoring.1-30.ops.example.com
    http:
      paths:
      - backend:
          service:
            name: atlas-victoriametrics-grafana
            port:
              number: 80
        path: /
        pathType: Prefix

І через alb.ingress.kubernetes.io/group.name: ops-1-30-external-alb вони “підключаються” до нашого “дефолтного” LoadBalancer.

Працюємо на такій схемі вже кілька тижнів – поки політ нормальний.

Моніторинг

Ще з важливих нюансів – це моніторинг, бо дефолтні метрики CloudWatch, наприклад по помилкам 502/503/504 створюються на весь LoadBalancer.

Але в нашому випадку ми взагалі відмовились від метрик CloudWatch (ще й платити за кожен запит GetData на отримання метрик в CloudWatch Exporter або Yet Another Cloudwatch Exporter).

Натомість ми всі Access логи лоад-балансерів збираємо до Loki, а далі вже з її Recording Rules генеруємо метрики, де в лейблах маємо ім’я домену при запиті на який помилка виникла:

...
        - record: aws:alb:requests:sum_by:elb_http_codes_by_uri_path:5xx:by_pod_ip:rate:1m
          expr: |
            sum by (pod_ip, domain, elb_code, uri_path, user_agent) (
                rate(
                    {logtype="alb"} 
                        | pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`
                        | domain=~"(^.*api.challenge.example.co|lightdash.example.co)"
                        | elb_code=~"50[2-4]"
                        | regexp `.*:443(?P<uri_path>/[^/?]+).* HTTP`
                        [1m] offset 5m
                )
            )
...

Див. Grafana Loki: збираємо логи AWS LoadBalancer з S3 за допомогою Promtail Lambda та Grafana Loki: LogQL та Recoding Rules для метрик з логів AWS Load Balancer.

Ще з цікавого почитати можна тут – A deeper look at Ingress Sharing and Target Group Binding in AWS Load Balancer Controller.