Kubernetes: нагрузочное тестирование и high-load тюнинг – проблемы и решения

Автор: | 25/08/2020

Вообще, этот пост планировался в виде небольшой заметки о том, как использовать NodeAffinity для Kubernetes Pod:

Но, как это часто бывает – за одним потянулось другое, за другим третье – и в результате вышел очередной длиннопост в свободном стиле.

Итак, собирался я написать про NodeAffinity, как вдруг подумал – а как будет себя вести Kubernetes cluster-autoscaler – будет ли он учитывать NodeAffinity при создании новых нод?

Что бы проверить это – решил провести небольшой нагрузочный тест с помощью Apach Benchmark, что бы затриггерить Kubernetes HorizontalPodAutoscaler, который должен создать поды, а поды – затриггерить cluster-autoscaler на создание новых AWS ЕС2, которые будут подключены к Kubernetes-кластеру в роли WorkerNodes.

Начал самое первое нагрузочное тестирование, и столкнулся с проблемой, когда поды перестали скейлится.

А потом решил, что раз уж тестирую – то можно попробовать разные типы инстансов. Ну и об этом тоже написать.

А потом мы начали уже “полномастабное” нагрузочное тестирование – и проявилось ещё несколько проблем, и, конечно – надо же записать, как их решал.

В результате – получился пост с описанием и процесса нагрузочного тестирования, и про типы инстансов, и про DNS, и про другие нюансы работы с нагруженным приложением в Kubernetes.

kk тут: alias kk="kubectl" > ~/.bashrc

Задача

Итак, у нас имеется приложение, которое прям ну очень любит CPU.

PHP, Laravel. Сейчас крутится в DigitalOcean, на 50-ти одновременно запущенных дроплетах, плюс NFS-шара между ними для кода (отдельный NFS-сервер), плюс memcache, Redis и MySQL.

Хотим вынести приложение в Kubernetes-кластер с автоскейлингом и сэкономить на инфрастуктуре, т.к. за нынешние сервера в DigitalOcean мы платим в районе 4000 долларов/мес, а за один кластер AWS EKS – сейчас уходит порядка 500-600 долларов (сам кластер + по 4 штуки AWS t3.medium EC2 серверов для WorkerNodes в двух независимых AWS AvailabilityZone, итого 8 серверов под весь кластер).

При этом приложение падало на 12.000 одновременных пользователей (48.000/час). Мы хотим добить до 15.000 одновременных (60.000 час, 1.440.000/сутки), при максимуме, который был с момента запуска проекта осенью 2019, в 600.000/сутки. При этом текущий Production раздаётся через Cloudflare CDN, который из кеша отдаёт до 90% запросов.

Проект будем выносить на отдельную группу рабочих нод, что бы не затрагивать работу остальных приложений в кластере. Для того, что бы поды приложения запускались только на этих серверах – используем NodeAffinity.

Кроме того, проведём нагрузочное тестирование с тремя типами AWS EC2 инстансов – t3, m5, c5, что бы определиться с тем, какой тип лучше подходит, а потом ещё одно нагрузочное – что бы проверить работу автоскейлинга и самого приложения.

Выбор EC2 type

Что выбрать?

  • Т3? Burstable процессоры, хорошее соотношение цены/ЦПУ/памяти, подходят для большинства задач:
    T3 instances are the next generation burstable general-purpose instance type that provide a baseline level of CPU performance with the ability to burst CPU usage at any time for as long as required.
  • М5? Заточены под память – меньше ЦПУ/больше памяти:
    M5 instances are the latest generation of General Purpose Instances powered by Intel Xeon® Platinum 8175M processors. This family provides a balance of compute, memory, and network resources, and is a good choice for many applications.
  • С5? Заточены под ЦПУ – больше ядер процессора, сам процессор немного лучше, но памяти, по сравнению с М5, меньше:
    C5 instances are optimized for compute-intensive workloads and deliver cost-effective high performance at a low price per compute ratio.

Давайте начнём с t3a – будет и немного дешевле, чем обычный Т3, и бурст CPU есть, а нагрузки у нас не линейные, а спорадические.

EC2 AMD instances

Имеются так же инстансы на процессорах AMD – t3a, m5a, c5a – при тех же показателях CPU/memory/network они выходят немного дешевле, но доступны не во всех регионах и даже не во всех AvailabilityZone одного и того же региона.

Например, в AWS регионе us-east-2 c5a доступны в зонах доступности us-east-2b и us-east-2c – но недоступны в us-east-2a. Менять сейчас автоматизацию (а AZ выбирается автоматически, см. AWS: CloudFormation — использование lists в Parameters) не хочется, поэтому AMD оставим в стороне.

EC2 Graviton instances

Кроме того, у AWS появились инстансы m6g и c6g с AWS Graviton2 processors, но для их использования есть определённые требования к AWS EKS кластеру, см. тут>>>, а заниматься сейчас апдейтом кластеров возможности нет.

Далее – создадим три WorkerNode Group с типами инстансов t3, m5 и c5, и проверим работу приложения и потребление CPU на каждом из них.

eksctl и Kubernetes WorkerNode Groups

Конфиг для создаваемых нод-групп выглядит так:

---
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig

metadata:
  name: "{{ eks_cluster_name }}"
  region: "{{ region }}"
  version: "{{ k8s_version }}"

nodeGroups:

  - name: "eat-test-t3-{{ item }}"
    instanceType: "t3.xlarge"
    privateNetworking: true
    labels: { role: eat-workers }
    volumeSize: 50
    volumeType: gp2
    desiredCapacity: 1
    minSize: 1
    maxSize: 1
    availabilityZones: ["{{ item }}"]
    ssh:
      publicKeyName: "bttrm-eks-nodegroup-{{ region }}"
    iam:
      withAddonPolicies:
        autoScaler: true
        cloudWatch: true
        albIngress: true
        efs: true
    securityGroups:
      withShared: true
      withLocal: true
      attachIDs: [ {{ worker_nodes_add_sg }} ]

  - name: "eat-test-m5-{{ item }}"
    instanceType: "m5.xlarge"
    privateNetworking: true
    labels: { role: eat-workers }
    volumeSize: 50
    volumeType: gp2
    desiredCapacity: 1
    minSize: 1
    maxSize: 1
    availabilityZones: ["{{ item }}"]
    ssh:
      publicKeyName: "bttrm-eks-nodegroup-{{ region }}"
    iam:
      withAddonPolicies:
        autoScaler: true
        cloudWatch: true
        albIngress: true
        efs: true
    securityGroups:
      withShared: true
      withLocal: true
      attachIDs: [ {{ worker_nodes_add_sg }} ]

  - name: "eat-test-c5-{{ item }}"
    instanceType: "c5.xlarge"
    privateNetworking: true
    labels: { role: eat-workers }
    volumeSize: 50
    volumeType: gp2
    desiredCapacity: 1
    minSize: 1
    maxSize: 1
    availabilityZones: ["{{ item }}"]
    ssh:
      publicKeyName: "bttrm-eks-nodegroup-{{ region }}"
    iam:
      withAddonPolicies:
        autoScaler: true
        cloudWatch: true
        albIngress: true
        efs: true
    securityGroups:
      withShared: true
      withLocal: true
      attachIDs: [ {{ worker_nodes_add_sg }} ]

Тут описываются три Worker Node группы с тремя различными типами инстансов.

Деплоится всё Ансиблом и eksctl, см. AWS Elastic Kubernetes Service: — автоматизация создания кластера, часть 2 — Ansible, eksctl, в двух различных AvailabilityZone.

minSize и maxSize заданы в 1 и 1, что бы наш Cluster AutoScaler не начал их скейлить – в начале тестирования хочется увидеть нагрузку на один сервер одним подом, и посмотреть на результаты kubectl top для подов и нод.

После того, как определимся с типом инстансов – оставим одну нод-группу, и включим скейлинг.

Тестирование – план

Что и как будем тестировать:

  • PHP, Laravel, упакованы в Docker-образы
  • все сервера 4 ядра, 16 гиг памяти
  • в Deployment проекта с помощью requests опишем запуск по одному поду на 1 сервер (будем “просить” чуть больше, чем половина доступных ядер – и Kubernetes Scheduler вынужден будет запускать поды на выделенных нодах)
  • с помощью NodeAffinity опишем запуск подов только на нужных нам серверах
  • автоскейлинг подов и кластера пока отключен

Создадим три WorkerNode Group с тремся различными типами ЕС2 инстансов, и задеплоим четыре неймспейса – один “дефолтный”, и три – под разные нод-группы с разными типами серверов – в каждом таком неймспейсе в Deployment будет прописан свой NodeAffinity, который будет запускать поды только на нужных нам серверах.

Соответственно, у нас будет создано 4 Ingress ресурса с AWS LoadBalancer, см. Kubernetes: ClusterIP vs NodePort vs LoadBalancer, Services и Ingress — обзор, примеры, и будет 4 ендпоинта для тестирования.

Kubernetes NodeAffinity && nodeSelector

Документация – Assigning Pods to Nodes.

Для выбора того, на каких именно WorkerNode запускать поды с приложением можно использовать два варианта – либо собственные лейблы, либо лейблы, добавляемые самим Кубером автоматически.

В нашем конфиге нод групп добавляется такая лейбла:

...
labels: { role: eat-workers }
...

Которая будет прикреплена к каждому ЕС2.

Обновляем кластер:

И все теги инстанса:

Проверим WorkerNode Groups в eksctl:

[simterm]

$ eksctl --profile arseniy --cluster bttrm-eks-dev-1 get nodegroups
CLUSTER         NODEGROUP               CREATED                 MIN SIZE        MAX SIZE        DESIRED CAPACITY        INSTANCE TYPE   IMAGE ID
bttrm-eks-dev-1 eat-test-c5-us-east-2a  2020-08-20T09:29:28Z    1               1               1                       c5.xlarge       ami-0f056ad53eddfda19
bttrm-eks-dev-1 eat-test-c5-us-east-2b  2020-08-20T09:34:54Z    1               1               1                       c5.xlarge       ami-0f056ad53eddfda19
bttrm-eks-dev-1 eat-test-m5-us-east-2a  2020-08-20T09:29:28Z    1               1               1                       m5.xlarge       ami-0f056ad53eddfda19
bttrm-eks-dev-1 eat-test-m5-us-east-2b  2020-08-20T09:34:54Z    1               1               1                       m5.xlarge       ami-0f056ad53eddfda19
bttrm-eks-dev-1 eat-test-t3-us-east-2a  2020-08-20T09:29:27Z    1               1               1                       t3.xlarge       ami-0f056ad53eddfda19
bttrm-eks-dev-1 eat-test-t3-us-east-2b  2020-08-20T09:34:54Z    1               1               1                       t3.xlarge       ami-0f056ad53eddfda19

[/simterm]

Проверяем созданные WorkerNode ЕС2-инстансы, с помощью -l выбрав сервера с нашей кастомной лейблой “role: eat-workers” и отсортировав по типам инстансов:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns get node -l role=eat-workers -o=json | jq -r '[.items | sort_by(.metadata.labels["beta.kubernetes.io/instance-type"])[] | {name:.metadata.name, type:.metadata.labels["beta.kubernetes.io/instance-type"], region:.metadata.labels["failure-domain.beta.kubernetes.io/zone"]}]'
[
  {
    "name": "ip-10-3-47-253.us-east-2.compute.internal",
    "type": "c5.xlarge",
    "region": "us-east-2a"
  },
  {
    "name": "ip-10-3-53-83.us-east-2.compute.internal",
    "type": "c5.xlarge",
    "region": "us-east-2b"
  },
  {
    "name": "ip-10-3-33-222.us-east-2.compute.internal",
    "type": "m5.xlarge",
    "region": "us-east-2a"
  },
  {
    "name": "ip-10-3-61-225.us-east-2.compute.internal",
    "type": "m5.xlarge",
    "region": "us-east-2b"
  },
  {
    "name": "ip-10-3-45-186.us-east-2.compute.internal",
    "type": "t3.xlarge",
    "region": "us-east-2a"
  },
  {
    "name": "ip-10-3-63-119.us-east-2.compute.internal",
    "type": "t3.xlarge",
    "region": "us-east-2b"
  }
]

[/simterm]

См. форматирование аутпута kubectl тут>>>.

Deployment update

nodeSelector by a custom label

Сначала задеплоим на сервера с лейблой labels: { role: eat-workers } – Kubernetes должен будет запустить поды на всех 6-ти серверах – по 2 на каждый тип инстанса.

Обновляем деплоймент, добавляем nodeSelector по лейбле role со значением “eat-workers“:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Chart.Name }}
  annotations:
    reloader.stakater.com/auto: "true"
spec:
  replicas: {{ .Values.replicaCount }}
  strategy:
    type: RollingUpdate
  selector:
    matchLabels:
      application: {{ .Chart.Name }}
  template:
    metadata:
      labels:
        application: {{ .Chart.Name }}
        version: {{ .Chart.Version }}-{{ .Chart.AppVersion }}
        managed-by: {{ .Release.Service }}
    spec:
      containers:
      - name: {{ .Chart.Name }}
        image: {{ .Values.image.registry }}/{{ .Values.image.repository }}/{{ .Values.image.name }}:{{ .Values.image.tag }}
        imagePullPolicy: Always
...
        ports:
          - containerPort: {{ .Values.appConfig.port }}
        livenessProbe:
          httpGet:
            path: {{ .Values.appConfig.healthcheckPath }}
            port: {{ .Values.appConfig.port }}
          initialDelaySeconds: 10
        readinessProbe:
          httpGet:
            path: {{ .Values.appConfig.healthcheckPath }}
            port: {{ .Values.appConfig.port }}
          initialDelaySeconds: 10
        resources:
          requests:
            cpu: {{ .Values.resources.requests.cpu | quote }}
            memory: {{ .Values.resources.requests.memory | quote }}
      nodeSelector:
        role: eat-workers
      volumes:
      imagePullSecrets:
        - name: gitlab-secret

replicaCount задаём в 6, по числу серверов

Деплоим:

[simterm]

$ helm secrets upgrade --install --namespace eks-dev-1-eat-backend-ns --set image.tag=179217391 --set appConfig.appEnv=local  --set appConfig.appUrl=https://dev-eks.eat.example.com/ --atomic eat-backend . -f secrets.dev.yaml --debug

[/simterm]

Проверяем:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns get pod -o=custom-columns=NAME:.metadata.name,STATUS:.status.phase,NODE:.spec.nodeName,TYPE:.spec.nodeSelector
NAME                                   STATUS    NODE                                        TYPE
eat-backend-57b7b54d98-7m27q   Running   ip-10-3-63-119.us-east-2.compute.internal   map[role:eat-workers]
eat-backend-57b7b54d98-7tvtk   Running   ip-10-3-53-83.us-east-2.compute.internal    map[role:eat-workers]
eat-backend-57b7b54d98-8kphq   Running   ip-10-3-47-253.us-east-2.compute.internal   map[role:eat-workers]
eat-backend-57b7b54d98-l24wr   Running   ip-10-3-61-225.us-east-2.compute.internal   map[role:eat-workers]
eat-backend-57b7b54d98-ns4nr   Running   ip-10-3-45-186.us-east-2.compute.internal   map[role:eat-workers]
eat-backend-57b7b54d98-sxzk4   Running   ip-10-3-33-222.us-east-2.compute.internal   map[role:eat-workers]
eat-backend-memcached-0        Running   ip-10-3-63-119.us-east-2.compute.internal   <none>

[/simterm]

Хорошо – 6 подов на 6 серверах.

nodeSelector by Kuber label

Теперь обновим наш деплоймент, и используем лейблы, которые создаются Kubernetes автоматически, например beta.kubernetes.io/instance-type, используя который зададим деплоймент подов только на сервера определённого типа.

replicaCount задаём в 2, по числу серверов каждого типа – получим по одному поду на каждый из двух серверов.

Удаляем текущий деплоймент:

[simterm]

$ helm --namespace eks-dev-1-eat-backend-ns uninstall eat-backend
release "eatt-backend" uninstalled

[/simterm]

Обновляем манифест деплоймента, – добавляем выбор t3, будут работать оба условия – и role, и instance-type:

...
      nodeSelector:
        beta.kubernetes.io/instance-type: t3.xlarge
        role: eat-workers
...

Деплоим в новые неймспейсы – добавим постфикс t3, m5, c5, т.е. для t3 группы имя нейспейса будет “eks-dev-1-eat–backend-ns-t3“, при вызове Helm добавляем --create-namespace:

[simterm]

$ helm secrets upgrade --install --namespace eks-dev-1-eat-backend-ns-t3 --set image.tag=180029557 --set appConfig.appEnv=local  --set appConfig.appUrl=https://t3-dev-eks.eat.example.com/ --atomic eat-backend . -f secrets.dev.yaml --debug --create-namespace

[/simterm]

Повторяем для m5, c5, проверяем.

t3:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns-t3 get pod -o=custom-columns=NAME:.metadata.name,STATUS:.status.phase,NODE:.spec.nodeName,TYPE:.spec.nodeSelector
NAME                                  STATUS    NODE                                        TYPE
eat-backend-cc9b8cdbf-tv9h5   Running   ip-10-3-45-186.us-east-2.compute.internal   map[beta.kubernetes.io/instance-type:t3.xlarge role:eat-workers]
eat-backend-cc9b8cdbf-w7w5w   Running   ip-10-3-63-119.us-east-2.compute.internal   map[beta.kubernetes.io/instance-type:t3.xlarge role:eat-workers]
eat-backend-memcached-0       Running   ip-10-3-53-83.us-east-2.compute.internal    <none>

[/simterm]

m5:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns-m5 get pod -o=custom-columns=NAME:.metadata.name,STATUS:.status.phase,NODE:.spec.nodeName,TYPE:.spec.nodeSelector
NAME                                   STATUS    NODE                                        TYPE
eat-backend-7dfb56b75c-k8gt6   Running   ip-10-3-61-225.us-east-2.compute.internal   map[beta.kubernetes.io/instance-type:m5.xlarge role:eat-workers]
eat-backend-7dfb56b75c-wq9n2   Running   ip-10-3-33-222.us-east-2.compute.internal   map[beta.kubernetes.io/instance-type:m5.xlarge role:eat-workers]
eat-backend-memcached-0        Running   ip-10-3-47-253.us-east-2.compute.internal   <none>

[/simterm]

И c5:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns-c5 get pod -o=custom-columns=NAME:.metadata.name,STATUS:.status.phase,NODE:.spec.nodeName,TYPE:.spec.nodeSelector
NAME                                  STATUS    NODE                                        TYPE
eat-backend-7b6778c5c-9g6st   Running   ip-10-3-47-253.us-east-2.compute.internal   map[beta.kubernetes.io/instance-type:c5.xlarge role:eat-workers]
eat-backend-7b6778c5c-sh5sn   Running   ip-10-3-53-83.us-east-2.compute.internal    map[beta.kubernetes.io/instance-type:c5.xlarge role:eat-workers]
eat-backend-memcached-0       Running   ip-10-3-47-58.us-east-2.compute.internal    <none>

[/simterm]

Всё готово к тестированию.

Тестирование – AWS EC2 t3 vs m5 vs c5

Запускаем тесты, один и тот же набор на все группы.

Следим за потреблением CPU каждым подом.

t3

Поды:

[simterm]

$ kk top nod-n eks-dev-1-eat-backend-ns-t3 top pod
NAME                                   CPU(cores)   MEMORY(bytes)   
eat-backend-79cfc4f9dd-q22rh   1503m        103Mi           
eat-backend-79cfc4f9dd-wv5xv   1062m        106Mi           
eat-backend-memcached-0        1m           2Mi

[/simterm]

Ноды:

[simterm]

$ kk top node -l role=eat-workers,beta.kubernetes.io/instance-type=t3.xlarge
NAME                                        CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%   
ip-10-3-45-186.us-east-2.compute.internal   1034m        26%    1125Mi          8%        
ip-10-3-63-119.us-east-2.compute.internal   1616m        41%    1080Mi          8%

[/simterm]

M5

Поды:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns-m5 top pod
NAME                                   CPU(cores)   MEMORY(bytes)   
eat-backend-6f5d68778d-484lk   1039m        114Mi           
eat-backend-6f5d68778d-lddbw   1207m        105Mi           
eat-backend-memcached-0        1m           2Mi

[/simterm]

Ноды:

[simterm]

$ kk top node -l role=eat-workers,beta.kubernetes.io/instance-type=m5.xlarge
NAME                                        CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%   
ip-10-3-33-222.us-east-2.compute.internal   1550m        39%    1119Mi          8%        
ip-10-3-61-225.us-east-2.compute.internal   891m         22%    1087Mi          8%

[/simterm]

C5

Поды:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns-c5 top pod
NAME                                   CPU(cores)   MEMORY(bytes)   
eat-backend-79b947c74d-mkgm9   941m         103Mi           
eat-backend-79b947c74d-x5qjd   905m         107Mi           
eat-backend-memcached-0        1m           2Mi

[/simterm]

Ноды:

[simterm]

$ kk top node -l role=eat-workers,beta.kubernetes.io/instance-type=c5.xlarge
NAME                                        CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%   
ip-10-3-47-253.us-east-2.compute.internal   704m         17%    1114Mi          19%       
ip-10-3-53-83.us-east-2.compute.internal    1702m        43%    1122Mi          19%

[/simterm]

Собственно – с этим закончили.

Резюмируя:

  • t3: 1000-1500 mCPU, 385 ms responce
  • m5: 1000-1200 mCPU, 371 ms responce
  • c5: 900-1000 mCPU, 370 ms responce

Остановимся пока на С5 – они вроде себя лучше всего показали.

Kubernetes NodeAffinity vs Kubernetes ClusterAutoscaler

Один из вопросов, который волновал с самого начала – а будет ли Cluster AutoScaler учитывать NodeAffinity?

Забегая наперёд – да, учитывает.

Наш HorizontalPodAutoscaler выглядит так:

---         
apiVersion: autoscaling/v2beta2
kind: HorizontalPodAutoscaler
metadata:
  name: {{ .Chart.Name }}-hpa
spec:       
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ .Chart.Name }}
  minReplicas: {{ .Values.hpa.minReplicas }}
  maxReplicas: {{ .Values.hpa.maxReplicas }}
  metrics:    
  - type: Resource
    resource:
      name: cpu
      target: 
        type: Utilization
        averageUtilization: {{ .Values.hpa.cpuUtilLimit }}

cpuUtilLimit задаём в 30%, с той идеей, что как только PHP начнёт активно использовать своих FPM-воркеров – вырастет нагрузка на процессор, а лимит в 30% даст нам время на запуск новых ЕС2 и подов в них, пока текущие поды вытягивают имеющуюся, но растущую нагрузку.

См. Kubernetes: HorizontalPodAutoscaler — обзор и примеры.

nodeSelector описываем уже используя шаблонизатор Helm и его values.yaml, см. Helm: Kubernetes package manager — обзор, начало работы:

...
      nodeSelector:
        beta.kubernetes.io/instance-type: {{ .Values.nodeSelector.instanceType | quote }}
        role: {{ .Values.nodeSelector.role | quote }}
...

Сам values.yaml:

...
nodeSelector: 
  instanceType: "c5.xlarge"
  role: "eat-workers
...

Пересоздаём всё, и начинаем полноценное тестирование.

Во время простоя потребление ресурсов подами было таким:
[simterm]
$ kk -n eks-dev-1-eat-backend-ns top pod
NAME                                 CPU(cores)   MEMORY(bytes)   
eat-backend-b8b79574-8kjl4   50m          55Mi            
eat-backend-b8b79574-8t2pw   39m          55Mi            
eat-backend-b8b79574-bq8nw   52m          68Mi            
eat-backend-b8b79574-swbvq   40m          55Mi            
eat-backend-memcached-0      2m           6Mi
[/simterm]
На 4-х c5.xlarge серверах (4 ядра, 8 ГБ памяти):
[simterm]
$ kk top node -l role=eat-workers
NAME                                        CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%   
ip-10-3-34-151.us-east-2.compute.internal   105m         2%     1033Mi          18%       
ip-10-3-39-132.us-east-2.compute.internal   110m         2%     1081Mi          19%       
ip-10-3-54-32.us-east-2.compute.internal    166m         4%     1002Mi          17%       
ip-10-3-56-98.us-east-2.compute.internal    106m         2%     1010Mi          17%
[/simterm]
HorizontalPodAutoscaler на 30% реквестов CPU:
[simterm]
$ kk -n eks-dev-1-eat-backend-ns get hpa
NAME                      REFERENCE                        TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
eat-backend-hpa   Deployment/eat-backend   1%/30%    4         40        4          6m27s

[/simterm]

Нагрузочное тестирование

День первый

В двух словах – тестирование, день первый. Сам пост начал писать на второй день, так что тут мы немного вернёмся во вчера.

Тестировали на четырёх t3a.medium инстансах, с теми же 1 под на 1 ноду, с включенным HPA и Cluster AutoScaler.

И всё шло хорошо, пока мы не превысили порог в 8000 одновременных юзеров – резко выросло время ответа:

И поды перестали скейлится:

Так как перестали генерить нагрузку выше 30%.

Первое предположение оказалось и правильным: в подах для PHP-FPM был задан OnDemand, и максимум 5 воркеров, см. PHP-FPM: Process Manager — dynamic vs ondemand vs static.

FPM запускал 5 процессов, которые не нагружали ЦПУ выше 30% от реквестов, и поды переставали скейлиться.

На второй день мы переключили их в Dynamic (на третий скорее переключим в Static), и максимум 50 процессов – после этого нагрузка генерировалась постоянно, и поды скейлились.

Хотя, разумеется, можно просто добавить ещё одно условие для HPA, например – кол-во запросов к LoadBalancer в секунду, и позже именно так и сделаем, см. Kubernetes: мониторинг кластера с Prometheus Operator.

День второй

Итак – грузим JMeter, те же тесты, что вчера (и завтра).

Начинаем с 1, разгоняем до 15000 одновременных пользователей.

Инфрастуктура текущего Production в DigitalOcean падала на 12000 – мы хотим вытащить 15000.

Поехали:

На 3300 юзеров – начал триггерится скейлинг подов:

[simterm]

...
0s    Normal   SuccessfulRescale   HorizontalPodAutoscaler   New size: 5; reason: cpu resource utilization (percentage of request) above target
0s    Normal   ScalingReplicaSet   Deployment   Scaled up replica set eat-backend-b8b79574 to 5
0s    Normal   SuccessfulCreate   ReplicaSet   Created pod: eat-backend-b8b79574-l68vq
0s    Warning   FailedScheduling   Pod   0/12 nodes are available: 12 Insufficient cpu, 8 node(s) didn't match node selector.
0s    Warning   FailedScheduling   Pod   0/12 nodes are available: 12 Insufficient cpu, 8 node(s) didn't match node selector.
0s    Normal   TriggeredScaleUp   Pod   pod triggered scale-up: [{eksctl-bttrm-eks-dev-1-nodegroup-eat-us-east-2b-NodeGroup-1N0QUROWQ8K2Q 2->3 (max: 20)}]
...

[/simterm]

И для них начали скейлится новые ЕС2-ноды:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns top pod
NAME                                 CPU(cores)   MEMORY(bytes)   
eat-backend-b8b79574-8kjl4   968m         85Mi            
eat-backend-b8b79574-8t2pw   1386m        85Mi            
eat-backend-b8b79574-bq8nw   737m         71Mi            
eat-backend-b8b79574-l68vq   0m           0Mi             
eat-backend-b8b79574-swbvq   573m         71Mi            
eat-backend-memcached-0      20m          15Mi            

[/simterm]
[simterm]
$ kk -n eks-dev-1-eat-backend-ns get hpa
NAME                      REFERENCE                        TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
eat-backend-hpa   Deployment/eat-backend   36%/30%   4         40        5          37m

[/simterm]
[simterm]
$ kk top node -l role=eat-workers
NAME                                        CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%   
ip-10-3-34-151.us-east-2.compute.internal   662m         16%    1051Mi          18%       
ip-10-3-39-132.us-east-2.compute.internal   811m         20%    1095Mi          19%       
ip-10-3-53-136.us-east-2.compute.internal   2023m        51%    567Mi           9%        
ip-10-3-54-32.us-east-2.compute.internal    1115m        28%    1032Mi          18%       
ip-10-3-56-98.us-east-2.compute.internal    1485m        37%    1040Mi          18%

[/simterm]

5500 – полёт нормальный, скейлимся дальше:

net/http: request canceled (Client.Timeout exceeded while awaiting headers)

Где-то на 7-8 тысячах начались проблемы – поды отваливались по Liveness и Readiness проверкам с ошибкой “Client.Timeout exceeded while awaiting headers“:

[simterm]

0s    Warning   Unhealthy   Pod   Liveness probe failed: Get http://10.3.38.7:80/: net/http: request canceled (Client.Timeout exceeded while awaiting headers)
1s    Warning   Unhealthy   Pod   Readiness probe failed: Get http://10.3.44.96:80/: net/http: request canceled (Client.Timeout exceeded while awaiting headers)
0s    Normal   MODIFY   Ingress   rule 1 modified with conditions [{    Field: "path-pattern",    Values: ["/*"]  }]
0s    Warning   Unhealthy   Pod   Liveness probe failed: Get http://10.3.44.34:80/: net/http: request canceled (Client.Timeout exceeded while awaiting headers)

[/simterm]

И чем дальше – тем хуже, на 10.000:

Поды начали падать вообще, а самое “весёлое”, что у нас даже не было нормального вывода логов приложения – оно по-прежнему писало в лог-файл в контейнерах, поправили это только на третий день.

Нагрузка выглядела так:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns get hpa
NAME                      REFERENCE                        TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
eat-backend-hpa   Deployment/eat-backend   60%/30%   4         40        15         63m

[/simterm]
[simterm]
$ kk top node -l role=eat-workers
NAME                                        CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%   
ip-10-3-33-155.us-east-2.compute.internal   88m          2%     951Mi           16%       
ip-10-3-34-151.us-east-2.compute.internal   1642m        41%    1196Mi          20%       
ip-10-3-39-128.us-east-2.compute.internal   67m          1%     946Mi           16%       
ip-10-3-39-132.us-east-2.compute.internal   73m          1%     1029Mi          18%       
ip-10-3-43-76.us-east-2.compute.internal    185m         4%     1008Mi          17%       
ip-10-3-47-243.us-east-2.compute.internal   71m          1%     959Mi           16%       
ip-10-3-47-61.us-east-2.compute.internal    69m          1%     945Mi           16%       
ip-10-3-53-124.us-east-2.compute.internal   61m          1%     955Mi           16%       
ip-10-3-53-136.us-east-2.compute.internal   75m          1%     946Mi           16%       
ip-10-3-53-143.us-east-2.compute.internal   1262m        32%    1110Mi          19%       
ip-10-3-54-32.us-east-2.compute.internal    117m         2%     985Mi           17%       
ip-10-3-55-140.us-east-2.compute.internal   992m         25%    931Mi           16%       
ip-10-3-55-208.us-east-2.compute.internal   76m          1%     942Mi           16%       
ip-10-3-56-98.us-east-2.compute.internal    1578m        40%    1152Mi          20%       
ip-10-3-59-239.us-east-2.compute.internal   1661m        42%    1175Mi          20%   
 
[/simterm]
[simterm]
   
$ kk -n eks-dev-1-eat-backend-ns top pod
NAME                                 CPU(cores)   MEMORY(bytes)   
eat-backend-b8b79574-5d6zl   0m           0Mi             
eat-backend-b8b79574-7n7pq   986m         184Mi           
eat-backend-b8b79574-8t2pw   709m         135Mi           
eat--backend-b8b79574-bq8nw   0m           0Mi             
eat-backend-b8b79574-ds68n   0m           0Mi             
eat-backend-b8b79574-f4qcm   0m           0Mi             
eat-backend-b8b79574-f6wfj   0m           0Mi             
eat-backend-b8b79574-g7jm7   842m         165Mi           
eat-backend-b8b79574-ggrdg   0m           0Mi             
eat-backend-b8b79574-hjcnh   0m           0Mi             
eat-backend-b8b79574-l68vq   0m           0Mi             
eat-backend-b8b79574-mlpqs   0m           0Mi             
eat-backend-b8b79574-nkwjc   2882m        103Mi           
eat-backend-b8b79574-swbvq   2091m        180Mi           
eat-backend-memcached-0      31m          54Mi

[/simterm]

И рестарты подов:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns get pod
NAME                                 READY   STATUS             RESTARTS   AGE
eat-backend-b8b79574-5d6zl   0/1     CrashLoopBackOff   6          17m
eat-backend-b8b79574-7n7pq   1/1     Running            5          9m13s
eat-backend-b8b79574-8kjl4   0/1     CrashLoopBackOff   7          64m
eat-backend-b8b79574-8t2pw   0/1     CrashLoopBackOff   6          64m
eat-backend-b8b79574-bq8nw   1/1     Running            6          64m
eat-backend-b8b79574-ds68n   0/1     CrashLoopBackOff   7          17m
eat-backend-b8b79574-f4qcm   1/1     Running            6          9m13s
eat-backend-b8b79574-f6wfj   0/1     Running            6          9m13s
eat-backend-b8b79574-g7jm7   0/1     CrashLoopBackOff   5          25m
eat-backend-b8b79574-ggrdg   1/1     Running            6          9m13s
eat-backend-b8b79574-hjcnh   0/1     CrashLoopBackOff   6          25m
eat-backend-b8b79574-l68vq   1/1     Running            7          29m
eat-backend-b8b79574-mlpqs   0/1     CrashLoopBackOff   6          21m
eat-backend-b8b79574-nkwjc   0/1     CrashLoopBackOff   5          9m13s
eat-backend-b8b79574-swbvq   0/1     CrashLoopBackOff   6          64m
eat-backend-memcached-0      1/1     Running            0          64m

[/simterm]

На 12-13 тысячах стало совсем печально – в живых оставался один под:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns top pod
NAME                                 CPU(cores)   MEMORY(bytes)   
eat-backend-b8b79574-7n7pq   0m           0Mi             
eat-backend-b8b79574-8kjl4   0m           0Mi             
eat-backend-b8b79574-8t2pw   0m           0Mi             
eat--backend-b8b79574-bq8nw   0m           0Mi             
eat-backend-b8b79574-ds68n   0m           0Mi             
eat-backend-b8b79574-f4qcm   0m           0Mi             
eat-backend-b8b79574-f6wfj   0m           0Mi             
eat-backend-b8b79574-g7jm7   0m           0Mi             
eat-backend-b8b79574-ggrdg   0m           0Mi             
eat-backend-b8b79574-hjcnh   0m           0Mi             
eat-backend-b8b79574-l68vq   0m           0Mi             
eat-backend-b8b79574-mlpqs   0m           0Mi             
eat-backend-b8b79574-nkwjc   3269m        129Mi           
eat-backend-b8b79574-swbvq   0m           0Mi             
eat-backend-memcached-0      23m          61Mi   

[/simterm]
[simterm]
$ kk -n eks-dev-1-eat-backend-ns get pod
NAME                                 READY   STATUS             RESTARTS   AGE
eat-backend-b8b79574-5d6zl   1/1     Running            7          20m
eat-backend-b8b79574-7n7pq   0/1     CrashLoopBackOff   6          12m
eat-backend-b8b79574-8kjl4   0/1     CrashLoopBackOff   7          67m
eat-backend-b8b79574-8t2pw   0/1     CrashLoopBackOff   7          67m
eat-backend-b8b79574-bq8nw   0/1     CrashLoopBackOff   6          67m
eat-backend-b8b79574-ds68n   0/1     CrashLoopBackOff   8          20m
eat-backend-b8b79574-f4qcm   0/1     CrashLoopBackOff   6          12m
eat-backend-b8b79574-f6wfj   0/1     CrashLoopBackOff   6          12m
eat-backend-b8b79574-g7jm7   0/1     CrashLoopBackOff   6          28m
eat-backend-b8b79574-ggrdg   0/1     Running            7          12m
eat-backend-b8b79574-hjcnh   0/1     CrashLoopBackOff   7          28m
eat-backend-b8b79574-l68vq   0/1     CrashLoopBackOff   7          32m
eat-backend-b8b79574-mlpqs   0/1     CrashLoopBackOff   7          24m
eat-backend-b8b79574-nkwjc   1/1     Running            7          12m
eat-backend-b8b79574-swbvq   0/1     CrashLoopBackOff   7          67m
eat-backend-memcached-0      1/1     Running            0          67m

[/simterm]

И вот только тут я вспомнил про логи Laravel в контейнерах, и полез их глянуть – оказалось, что сервер баз данных начал отклонять новые подключения:

[simterm]

bash-4.4# cat ./new-eat-backend/storage/logs/laravel-2020-08-20.log
[2020-08-20 16:53:25] production.ERROR: SQLSTATE[HY000] [2002] Connection refused {"exception":"[object] (Doctrine\\DBAL\\Driver\\PDOException(code: 2002): SQLSTATE[HY000] [2002] Connection refused at /var/www/new-eat-backend/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/PDOConnection.php:31, PDOException(code: 2002): SQLSTATE[HY000] [2002] Connection refused at /var/www/new-eat-backend/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/PDOConnection.php:27)

[/simterm]

AWS RDS – “Connection refused”

В качестве сервера баз данных используется RDS Aurora MySQL, у которой есть свой скейлинг для слейвов.

Проблема в том, что во-первых – тестирование проводится на Dev-окружении, у которого сервера db.t2.medium с 4 гигами памяти, а во-вторых – снова-таки, из-за спешки и неподготовленности – приложение все запросы слало на Мастер-хост, Слейвы не использовались вообще. На мастер уходило порядка 155 запросов/секунду.

Воообще, главная идея использования Aurora RDS как раз разделение запросов: все запросы, которые модифицируют данные (UPDATE, CREATE, etc) должны идти на master-инстанс, а все SELECT – на его slave.

При этом слейвы могут скейлится по собственным политикам:

Кстати – мы тут неправильно делаем: надо вместо нагрузки на ЦПУ скейлить по кол-ву коннектов к слейву, тогда для слейвов можно вообще какой-то t3.medium использовать. Потом изменим.

AWS RDS max connections

Вообще, судя по документации – лимит должен быть в 90 подключений, а нас начало отбрасывать на 50-60 подключениях:

Говорил потом с ребятами-архитекторами из Амазона, спрашивал – почему так, где наш лимит в 90?

Сказали – хз, посмотрим в документации 🙂 “Может 90 – это up to”.

Ну и в целом картина такая получилась:

52% запросов отвалились с ошибками, что, разумеется, очень плохо:

Но лично мне был важен другой момент – сам кластер, его Control Plane и сеть работали без проблем.

А вопрос с базой будем решать на день третий – увеличим тип инстанса, и включим в приложении работу со слейвами.

День третий

Самый получился интересный день.

Первое – девелоперы включили работу со слейвами, теперь заодно потестируем автоскейлинг Авроры слейвов (забегая наперёд – не потестировали, потому что нагрузка на слейвы была минимальная).

Кстати, вчера вечером общался с архитекторами Амазона – подсказали про RDS Proxy – надо будет потом тоже добавить.

И ещё надо проверить стаус OpCache – он сильно снижает нагрузку на ЦПУ, см. PHP: кеширование PHP-скриптов — настройка и тюнинг OpCache.

Пока девелоперы вносят свои изменения – давайте глянем на наши пробки 😉

Kubernetes Liveness и Readiness probes

Нашёл неплохих пару статей – Kubernetes Liveness and Readiness Probes: How to Avoid Shooting Yourself in the Foot и Liveness and Readiness Probes with Laravel.

Разработчики уже добавили два новых ендпоинта:

...
$router->get('healthz', 'HealthController@phpCheck');
$router->get('readiness', 'HealthController@dbReadCheck');
...

А сам HealthController выглядит так:

<?php

namespace App\Http\Controllers;

class HealthController extends Controller
{
    public function phpCheck()
    {
        return response('ok');
    }

    public function dbReadCheck()
    {
        try {
            $rows = \DB::select('SELECT 1 AS ok');
            if ($rows && $rows[0]->ok == 1) {
                return response('ok');
            }
        } catch (\Throwable $err) {
            // ignore
        }
        return response('err', 500);
    }
}

По /healthz – проверяем, что сам под запустился, РНР в нём работает.

По /readiness – проверяем, что приложение в поде запустилось, и готово принимать трафик от клиентов:

  • livenessProbe: если фейлится – Kubernetes перезапустит под
    • initialDelaySeconds: should be longer than maximum initialization time for the container – сколько там надо контейнеру с Laravel? ну – пусть будет 5 секунд
    • failureThreshold: поставим три попытки, после трёх неудач – под пойдёт в ребут
    • periodSeconds: дефолтные, кажется, 15 секунд – пусть так и остаётся
  • readinessProbe: определяет, когда приложение готово принимать трафик. Если проверка фейлится – Kubernetes отключит этот под от балансировщика/Сервиса
    • initialDelaySeconds: пусть будет 5 секунд – тут запуск РНР, плюс подключение к БД, может занять время
    • periodSeconds: т.к. мы как раз ожидаем проблем с подключением к БД – то поставим пока 5
    • failureThreshold: тоже три, как и в livenessProbe
    • successThreshold: после скольких попыток считать, что под готов принимать трафик – ставим одну
    • timeoutSeconds: дефолт 1, пусть так и остаётся

См. Configure Probes.

Обновим наши пробки в Deployment-е:

...
        livenessProbe:
          httpGet:
            path: {{ .Values.appConfig.healthcheckPath }}
            port: {{ .Values.appConfig.port }}
          initialDelaySeconds: 5
          failureThreshold: 3
          periodSeconds: 15
        readinessProbe:
          httpGet: 
            path: {{ .Values.appConfig.readycheckPath }}
            port: {{ .Values.appConfig.port }}
          initialDelaySeconds: 5
          periodSeconds: 5
          failureThreshold: 3
          successThreshold: 1
          timeoutSeconds: 1
...

Потом вынесем в Helm values.yaml.

И добавим переменную для Слейва БД:

...
        - name: DB_WRITE_HOST
          value: {{ .Values.appConfig.db.writeHost }}              
        - name: DB_READ_HOST
          value: {{ .Values.appConfig.db.readHost }}
...

Kubernetes: PHP logs from Docker

Так – и логи жеж.

Девелоперы переключили логи в /dev/stderr, откуда по идее их должен собирать Docker daemon, и отправялть в Kubernetes – но kubectl logs показывает сообщения только от NGINX.

Смотрим пост Linux: PHP-FPM, Docker, STDOUT и STDERR — нет логов приложения, вспоминаем, как оно работает – проверяем дескрипторы.

В поде находим мастер-процесс РНР:

[simterm]

bash-4.4# ps aux   |grep php-fpm | grep master
root         9  0.0  0.2 171220 20784 ?        S    12:00   0:00 php-fpm: master process (/etc/php/7.1/php-fpm.conf)

[/simterm]

Проверяем его дескрипторы:

[simterm]

bash-4.4# ls -l /proc/9/fd/2
l-wx------    1 root     root            64 Aug 21 12:04 /proc/9/fd/2 -> /var/log/php/7.1/php-fpm.log
bash-4.4# ls -l /proc/9/fd/1
lrwx------    1 root     root            64 Aug 21 12:04 /proc/9/fd/1 -> /dev/null

[/simterm]

fd/2, stderr процесса, направлен на файл /var/log/php/7.1/php-fpm.log вместо /dev/stderr – поэтому в kubectl мы ничего не видим.

Грепаем /var/log/php/7.1/php-fpm.log в каталоге /etc/php/7.1 – находим php-fpm.conf, в котором по дефолту error_log = /var/log/php/7.1/php-fpm.log, меняем на /dev/stderr – готово.

Вроде всё…

Погнали тесты.

Разгоняемся с 1 до 15к за час.

Первое тестирование

3300 пользователей – всё OK:

Поды:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns top pod
NAME                                   CPU(cores)   MEMORY(bytes)   
eat-backend-867b59c4dc-742vf   856m         325Mi           
eat-backend-867b59c4dc-bj74b   623m         316Mi           
eat-backend-867b59c4dc-cq5gd   891m         319Mi           
eat-backend-867b59c4dc-mm2ll   600m         310Mi           
eat-ackend-867b59c4dc-x8b8d   679m         313Mi           
eat-backend-memcached-0        19m          68Mi

[/simterm]

HPA:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns get hpa
NAME                      REFERENCE                        TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
eat-backend-hpa   Deployment/eat-backend   30%/30%   4         40        5          20h

[/simterm]

На 7.000 пользователях пошли ошибки “php_network_getaddresses: getaddrinfo failed” – мои старые знакомые, уже сталкивался, жаль – не сделал пост на эту тему:

[simterm]

[2020-08-21 14:14:59] local.ERROR: SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo failed: Try again (SQL: insert into `order_logs` (`order_id`, `action`, `data`, `updated_at`, `created_at`) values (175951, nav, "Result page: ok", 2020-08-21 14:14:54, 2020-08-21 14:14:54)) {"exception":"[object] (Illuminate\\Database\\QueryException(code: 2002): SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo failed: Try again (SQL: insert into `order_logs` (`order_id`, `action`, `data`, `updated_at`, `created_at`) values (175951, nav, \"Result page: ok\", 2020-08-21 14:14:54, 2020-08-21 14:14:54))

[/simterm]

В двух слова тут: ошибка “php_network_getaddresses: getaddrinfo failed” в AWS может возникать по трём основным причинам:

Чуть ниже рассмотрим причину ошибок в данном случае.

На 9.000+ начали рестартиться поды:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns get pod
NAME                                   READY   STATUS             RESTARTS   AGE
eat-backend-867b59c4dc-2m7fd   0/1     Running            2          4m17s
eat-backend-867b59c4dc-742vf   0/1     CrashLoopBackOff   5          68m
eat-backend-867b59c4dc-bj74b   1/1     Running            5          68m
...
eat-backend-867b59c4dc-w24pz   0/1     CrashLoopBackOff   5          19m
eat-backend-867b59c4dc-x8b8d   0/1     CrashLoopBackOff   5          68m
eat-backend-memcached-0        1/1     Running            0          21h

[/simterm]

Потому как приложение переставало отвечать на Liveness и Readiness проверки:

[simterm]

0s    Warning   Unhealthy   Pod   Readiness probe failed: Get http://10.3.62.195:80/readiness: net/http: request canceled (Client.Timeout exceeded while awaiting headers)                                                                    
0s    Warning   Unhealthy   Pod   Liveness probe failed: Get http://10.3.56.206:80/healthz: net/http: request canceled (Client.Timeout exceeded while awaiting headers)

[/simterm]

На 10.000 начал отбрасывать подключения уже сервер баз данных:

[simterm]

[2020-08-21 13:05:11] production.ERROR: SQLSTATE[HY000] [2002] Connection refused {"exception":"[object] (Doctrine\\DBAL\\Driver\\PDOException(code: 2002): SQLSTATE[HY000] [2002] Connection refused at /var/www/new-eat-backend/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/PDOConnection.php:31, PDOException(code: 2002): SQLSTATE[HY000] [2002] Connection refused

[/simterm]

php_network_getaddresses: getaddrinfo failed и DNS

Итак, с чем мы столкнулись:

  • ERROR: SQLSTATE[HY000] [2002] Connection refused
  • php_network_getaddresses: getaddrinfo failed

С “ERROR: SQLSTATE[HY000] [2002] Connection refused” понятно – сейчас увеличим тип RDS инстанса с t3.medium на r5.large, а с DNS что? Так как из трёх возможных причин – кол-во пакетов на интерфейсе, загруженность канала или AWS VPC DNS – наиболее реальной выглядит именно вариант с DNS: каждый раз, когда приложение хочет подключиться к серверу баз данных – оно выполняет DNS-запрос на получение IP сервера, плюс все остальные запросы – мы легко можем забить лимит 1024/сек.

Кстати, см. Grafana: Loki — Prometheus-like счётчики и функции агрегации в LogQL и графики DNS запросов к dnsmasq.

Проверим, что у нас сейчас настроено для подов:

[simterm]

bash-4.4# cat /etc/resolv.conf 
nameserver 172.20.0.10
search eks-dev-1-eat-backend-ns.svc.cluster.local svc.cluster.local cluster.local us-east-2.compute.internal
options ndots:5

[/simterm]

nameserver 172.20.0.10 – это наверняка наш kube-dns:

[simterm]

bash-4.4# nslookup 172.20.0.10
10.0.20.172.in-addr.arpa        name = kube-dns.kube-system.svc.cluster.local.

[/simterm]

Ага, он.

Который, между прочим, в логах рассказывал, что не может подключиться к АПИ-серверу:

E0805 21:32:40.283128       1 reflector.go:283] pkg/mod/k8s.io/[email protected]/tools/cache/reflector.go:98: Failed to watch *v1.Namespace: Get https://172.20.0.1:443/api/v1/namespaces?resourceVersion=23502628&timeout=9m40s&timeoutSeconds=580&watch=true: dial tcp 172.20.0.1:443: connect: connection refused

И что можно сделать, что бы перестать забивать DNS Амазона нашими запросами?

  • поднимать dnsmasq? В Kubernetes как-то глупо выглядит – во-первых, у него есть свой DNS-сервис, во-вторых – 146%, что мы не первые столкнулись с такой проблемой, и сильно сомневаюсь, что люди решали это через dnsmasq (тем не менее – см. dnsmasq: ошибки в AWS — «Temporary failure in name resolution», логи, дебаг и размер кеша)
  • другой вариант – использовать DNS от Cloudflare (1.1.1.1) или Google (8.8.8.8) – тогда перестанем забивать VPC DNS, но время ответа от DNS будет дольше
Kubernetes dnsPolicy

Хорошо, а как подам вообще настраивается DNS?

Note: You can manage your pod’s DNS configuration with the dnsPolicy field in the pod specification. If this field isn’t populated, then the ClusterFirst DNS policy is used by default.

Значит, по-умолчанию для подов используется ClusterFirst, который:

Any DNS query that does not match the configured cluster domain suffix, such as “www.kubernetes.io“, is forwarded to the upstream nameserver inherited from the node.

Ну и не сложно догадаться, что для AWS EC2 по дефолту будет ходить именно к AWS VPC DNS.

См. по теме – How do I troubleshoot DNS failures with Amazon EKS?

Что может быть переопределено в настройках ClusterAutoScaler:

[simterm]

$ kk -n kube-system get pod cluster-autoscaler-5dddc9c9b-fstft -o yaml
...
spec:
  containers:
  - command:
    - ./cluster-autoscaler
    - --v=4
    - --stderrthreshold=info
    - --cloud-provider=aws
    - --skip-nodes-with-local-storage=false
    - --expander=least-waste
    - --node-group-auto-discovery=asg:tag=k8s.io/cluster-autoscaler/enabled,k8s.io/cluster-autoscaler/bttrm-eks-dev-1
    - --balance-similar-node-groups
    - --skip-nodes-with-system-pods=false
...

[/simterm]

Но в нашем случае тут ничего не менялось, всё по-умолчанию.

Запуск NodeLocal DNS в Kubernetes

А вот идея с dnsmasq в целом была правильной, только для Kubernetes имеется NodeLocal DNS, который по сути является таким же кеширующим сервисом, как и dnsmasq, только за DNS-записями он обращается к kube-dns, а тот в свою очередь – к VPC DNS.

Что надо для запуска:

  • kubedns: получим с помощью kubectl get svc kube-dns -n kube-system -o jsonpath={.spec.clusterIP}
  • domain: наш <cluster-domain>, cluster.local
  • localdns: <node-local-address>, адрес, на котором будет доступен лоrальный DNS-кеш, используем 169.254.20.10

Получаем IP Сервиса для kube-dns:

[simterm]

$ kubectl get svc kube-dns -n kube-system -o jsonpath={.spec.clusterIP}
172.20.0.10

[/simterm]

См. Fixing EKS DNS.

Загружаем файл nodelocaldns.yaml:

[simterm]

$ wget https://raw.githubusercontent.com/kubernetes/kubernetes/master/cluster/addons/dns/nodelocaldns/nodelocaldns.yaml

[/simterm]

Обновляем его:

[simterm]

$ sed -i "s/__PILLAR__LOCAL__DNS__/169.254.20.10/g; s/__PILLAR__DNS__DOMAIN__/cluster.local/g; s/__PILLAR__DNS__SERVER__/172.20.0.10/g" nodelocaldns.yaml

[/simterm]

Проверяем содержимое – что вообще будет выполняться.

Тут Kubernetes DaemonSet, который задеплоит под с NodeLocal DNS на все ноды:

...
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: node-local-dns
...

И ConfigMap для него:

...
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: node-local-dns
  namespace: kube-system
  labels:
    addonmanager.kubernetes.io/mode: Reconcile
data:
  Corefile: |
    cluster.local:53 {
        errors
        cache {
                success 9984 30
                denial 9984 5
        }
        reload
        loop
        bind 169.254.20.10 172.20.0.10
        forward . __PILLAR__CLUSTER__DNS__ {
                force_tcp
        }
        prometheus :9253
        health 169.254.20.10:8080
        }

...

Применяем:

[simterm]

$ kubectl apply -f nodelocaldns.yaml 
serviceaccount/node-local-dns created
service/kube-dns-upstream created
configmap/node-local-dns created
daemonset.apps/node-local-dns created

[/simterm]

Проверяем поды:

[simterm]

$ kk -n kube-system get pod | grep local-dns
node-local-dns-7cndv                      1/1     Running   0          33s
node-local-dns-7hrlc                      1/1     Running   0          33s
node-local-dns-c5bhm                      1/1     Running   0          33s

[/simterm]

Сервис:

[simterm]

$ kk -n kube-system get svc | grep dns
kube-dns                                             ClusterIP   172.20.0.10      <none>        53/UDP,53/TCP                  88d
kube-dns-upstream                                    ClusterIP   172.20.245.211   <none>        53/UDP,53/TCP                  107s

[/simterm]

kube-dns-upstream ClusterIP 172.20.245.21, но с подов должен быть доступен по 169.254.20.10, как мы указали в localdns.

Доступен ли он? Проверяем из пода:

[simterm]

bash-4.4# dig @169.254.20.10 ya.ru +short
87.250.250.242

[/simterm]

Да, доступен.

Теперь надо отконфигурять поды, что бы они использовали 169.254.20.10 вместо kube-dns.

В конфиге eksctl можно добавить так:

...
nodeGroups:
- name: mygroup
    clusterDNS: 169.254.20.10
...

Но для этого надо будет обновлять и наши WorkerNode Groups.

Kubernetes Pod dnsConfig && nameservers

Правим манифест с Deployment – попробуем через dnsConfig и nameservers:

...
        resources:
          requests:
            cpu: 2500m
            memory: 500m
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
      dnsConfig:
        nameservers:
        - 169.254.20.10
      dnsPolicy: None
      imagePullSecrets:
      - name: gitlab-secret
      nodeSelector:
        beta.kubernetes.io/instance-type: c5.xlarge
        role: eat-workers
...

Деплоим, проверяем:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns exec -ti eat-backend-f7b49b4b7-4jtk5 cat /etc/resolv.conf
nameserver 169.254.20.10

[/simterm]

Окей…

А работает?)

Пробуем dig из пода:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns exec -ti eat-backend-f7b49b4b7-4jtk5 dig ya.ru +short
87.250.250.242

[/simterm]

Да, всё нормально.

Хорошо – погнали следуюдий тест.

Первый тест выглядел так:

На 8+ начались ошибки.

Второе тестирование

8500 – нормально:

В прошлый раз на 7.000 уже было 150-200 ошибок, а тут пока только 5 штук проскочили.

Поды:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns get pod
NAME                                  READY   STATUS    RESTARTS   AGE
eat-backend-5d8984656-2ftd6   1/1     Running   0          17m
eat-backend-5d8984656-45xvk   1/1     Running   0          9m11s
eat-backend-5d8984656-6v6zr   1/1     Running   0          5m10s
...
eat-backend-5d8984656-th2h6   1/1     Running   0          37m
eat-backend-memcached-0       1/1     Running   0          24h

[/simterm]

НРА:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns get hpa
NAME                      REFERENCE                        TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
eat-backend-hpa   Deployment/eat-backend   32%/30%   4         40        13         24h

[/simterm]

10.000 – всё хорошо:

НРА:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns get hpa
NAME                      REFERENCE                        TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
eat-backend-hpa   Deployment/eat-backend   30%/30%   4         40        15         24h

[/simterm]

Поды:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns get pod
NAME                                  READY   STATUS    RESTARTS   AGE
eat-backend-5d8984656-2ftd6   1/1     Running   0          28m
eat--backend-5d8984656-45xvk   1/1     Running   0          20m
eat-backend-5d8984656-6v6zr   1/1     Running   0          16m
...
eat-backend-5d8984656-th2h6   1/1     Running   0          48m
eat-backend-5d8984656-z2tpp   1/1     Running   0          3m51s
eat-backend-memcached-0       1/1     Running   0          24h

[/simterm]

Коннекты к серверу баз данных:

Ноды:

[simterm]

$ kk top node -l role=eat-workers
NAME                                        CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%     
ip-10-3-39-145.us-east-2.compute.internal   743m         18%    1418Mi          24%         
ip-10-3-44-14.us-east-2.compute.internal    822m         20%    1327Mi          23%         
...      
ip-10-3-62-143.us-east-2.compute.internal   652m         16%    1259Mi          21%         
ip-10-3-63-96.us-east-2.compute.internal    664m         16%    1266Mi          22%         
ip-10-3-63-186.us-east-2.compute.internal   <unknown>                           <unknown>               <unknown>               <unknown>               
ip-10-3-58-180.us-east-2.compute.internal   <unknown>                           <unknown>               <unknown>               <unknown>               
...             
ip-10-3-51-254.us-east-2.compute.internal   <unknown>                           <unknown>               <unknown>               <unknown>

[/simterm]

Продолжают скейлится, всё работает:

В 17:45 скачёк времени ответа был, и немного ошибок – но всё прошло, хотя успел начал нервничать.

Ни одного рестарта пода:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns get pod
NAME                                  READY   STATUS    RESTARTS   AGE
eat-backend-5d8984656-2ftd6   1/1     Running   0          44m
eat-backend-5d8984656-45xvk   1/1     Running   0          36m
eat-backend-5d8984656-47vp9   1/1     Running   0          6m49s
eat-backend-5d8984656-6v6zr   1/1     Running   0          32m
eat-backend-5d8984656-78tq9   1/1     Running   0          2m45s
...
eat-backend-5d8984656-th2h6   1/1     Running   0          64m
eat-backend-5d8984656-vbzhr   1/1     Running   0          6m49s
eat-backend-5d8984656-xzv6n   1/1     Running   0          6m49s
eat-backend-5d8984656-z2tpp   1/1     Running   0          20m
eat-backend-5d8984656-zfrb7   1/1     Running   0          16m
eat-backend-memcached-0       1/1     Running   0          24h

[/simterm]

Наскейлило 30 реплик:

[simterm]

$ kk -n eks-dev-1-eat-backend-ns get hpa
NAME                      REFERENCE                        TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
eat-backend-hpa   Deployment/eat-backend   1%/30%    4         40        30         24h

[/simterm]

0% ошибок:

Apache JMeter и Grafana

В заключение – очень понравилось, как у ребят реализован вывод результатов тестирования: JMeter записывает результаты в InfluxDB, из которой Grafana потом рисует результаты тестирования: