Workloads

В данной практике предлагаю испробовать работу Horizontal Pod Autoscaler в связке с StatefulSet, увеличивая потребляемые ресурсы приложения с помощью Cronjob.

Metrics server

В первую очередь для работы HPA в кластер необходимо установить metrics server, который позволит hpa контроллеру получать информацию о потребляемых ресурсах подами приложения.

$ k apply -f https://github.com/kubernetes-sigs/metrics-server/releases/download/v0.6.3/components.yaml
$ k patch -n kube-system deployment metrics-server --type=json -p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'

Application

Для проверки работы HPA предлагаю использовать масштабирование на основе потребления памяти приложением. Потребуется простое приложение, которое сможет занять память заданного размера, для этого можно использовать вызов mmap, отображая файл в оперативную память. Наше приложение будет отображать файл заданного размера в оперативную память, таким образом можно будет регулировать потребляемую память. Вы можете написать его на своем любимом языке, в качестве примера приведу простую реализацию на C:

#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
  if (argc < 3) {
    printf("too few arguments\n");
    return 1;
  }

  while (1) {
    int fd = open(argv[1], O_RDONLY);
    if (fd < 0) {
      printf("error read file: %s\n", argv[1]);
      sleep(strtol(argv[2], NULL, 10));
      continue;
    }
    struct stat s;
    int status = fstat(fd, &s);
    char *data = mmap(0, s.st_size, PROT_READ, MAP_SHARED, fd, 0);
    for (int i = 0; i < s.st_size; i++) {
      char c;
      c = data[i];
    }
    printf("size: %lld\n", s.st_size);
    sleep(strtol(argv[2], NULL, 10));
    munmap(data, s.st_size);
    close(fd);
  }
}

Данное программа перечитывает файл заданный первым аргументом с периодичностью заданной вторым аргументом и отображает в оперативную память.

Image

Для запуска приложения в kubernetes нам понадобится docker образ, для приведенной выше программы Dockerfile будет выглядеть так:

FROM alpine:3.17 as build

WORKDIR /build

RUN apk add --no-cache clang musl-dev binutils gcc
COPY main.c ./
RUN clang main.c

FROM alpine:3.17

COPY --from=build /build/a.out /app

CMD ["/app", "/tmp/data", "30"]

Таким образом для сборки образа можно выполнить следующие команды:

mkdir testapp;cd testapp
cat << EOF > main.c
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
  if (argc < 3) {
    printf("too few arguments\n");
    return 1;
  }

  while (1) {
    int fd = open(argv[1], O_RDONLY);
    if (fd < 0) {
      printf("error read file: %s\n", argv[1]);
      sleep(strtol(argv[2], NULL, 10));
      continue;
    }
    struct stat s;
    int status = fstat(fd, &s);
    char *data = mmap(0, s.st_size, PROT_READ, MAP_SHARED, fd, 0);
    for (int i = 0; i < s.st_size; i++) {
      char c;
      c = data[i];
    }
    printf("size: %lld\n", s.st_size);
    sleep(strtol(argv[2], NULL, 10));
    munmap(data, s.st_size);
    close(fd);
  }
}
EOF
cat << EOF > Dockerfile
FROM alpine:3.17 as build

WORKDIR /build

RUN apk add --no-cache clang musl-dev binutils gcc
COPY main.c ./
RUN clang main.c

FROM alpine:3.17

COPY --from=build /build/a.out /app

CMD ["/app", "/tmp/data", "30"]
EOF
docker build -t app:test .

Появится образ app:test, который необходимо загрузить в кластер. В утилите kind для этого есть команда kind load docker-image, которая загрузит образ на все ноды кластера.

$ kind load docker-image app:test
Image: "" with ID "sha256:7966712b4b2f550489f136128f48fafd8d0224c85f6770ae4562714ce5a0c202" not yet present on node "kind-worker", loading...
Image: "" with ID "sha256:7966712b4b2f550489f136128f48fafd8d0224c85f6770ae4562714ce5a0c202" not yet present on node "kind-worker2", loading...
Image: "" with ID "sha256:7966712b4b2f550489f136128f48fafd8d0224c85f6770ae4562714ce5a0c202" not yet present on node "kind-control-plane", loading...

StatefulSet

Создадим StatefulSet с собранным образом, указав в нем:

  • resources.requests - необходимые ресурсы для работы пода, также hpa будет учитывать данный параметр при расчете необходимости увеличения количества реплик

  • resources.limits - максимально возможное потребление для пода

  • image: app:test - собранный образ

  • imagePullPolicy: Never - для того, чтобы kubelet не пытался скачать собранный локально образ из внешнего источника

  • volumeClaimTemplates с необходимыми параметрами дискового хранилища, в котором будет лежать файл, загружаемый в память

  • volumeMounts с указанием пути для монтирования хранилища

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: app
spec:
  selector:
    matchLabels:
      app: app
  podManagementPolicy: OrderedReady
  replicas: 1
  template:
    metadata:
      labels:
        app: app
    spec:
      containers:
      - name: app
        image: app:test
        imagePullPolicy: Never
        resources:
          limits:
            memory: 300Mi
          requests:
            memory: 100Mi
        volumeMounts:
        - name: data
          mountPath: /tmp
  updateStrategy:
    type: RollingUpdate
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: [ "ReadWriteOnce" ]
      storageClassName: "standard"
      resources:
        requests:
          storage: 300Mi

Для создания можно воспользоваться командой kubectl apply:

k apply -f - << EOF
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: app
spec:
  selector:
    matchLabels:
      app: app
  podManagementPolicy: OrderedReady
  replicas: 1
  template:
    metadata:
      labels:
        app: app
    spec:
      containers:
      - name: app
        image: app:test
        imagePullPolicy: Never
        resources:
          limits:
            memory: 300Mi
          requests:
            memory: 100Mi
        volumeMounts:
        - name: data
          mountPath: /tmp
  updateStrategy:
    type: RollingUpdate
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: [ "ReadWriteOnce" ]
      storageClassName: "standard"
      resources:
        requests:
          storage: 300Mi
EOF

После создания пода при установленном metrics server потребление ресурсов подами можно посмотреть командой kubectl top pod:

$ k top pod
NAME    CPU(cores)   MEMORY(bytes)
app-0   1m           1Mi

Horizontal Pod Autoscaler

Для конфигурации автоматического масштабирования необходимо создать ресурс HorizontalPodAutoscaler, где необходимо указать:

  • maxReplicas - максимальное количество реплик, до которого можно масштабироваться

  • minReplicas - минимальное количество реплик

  • averageUtilization - средняя утилизация между всеми репликами в процентах от параметра resource.requests

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: app
spec:
  maxReplicas: 5
  minReplicas: 1
  scaleTargetRef:
    apiVersion: apps/v1
    kind: StatefulSet
    name: app
  metrics:
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 60

Примечание

HorizontalPodAutoscaler имеет несколько версий своей спецификации, важно указывать правильную версию в поле apiVersion. Управление на основе ресурса памяти есть в стабильной версии autoscaling/v2. Информацию по параметрам конкретной версии можно посмотреть командой kubectl explain с указанием опции --api-version, например k explain --api-version='autoscaling/v2' hpa.

Для создания можно воспользоваться командой kubectl apply:

k apply -f - << EOF
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: app
spec:
  maxReplicas: 5
  minReplicas: 1
  scaleTargetRef:
    apiVersion: apps/v1
    kind: StatefulSet
    name: app
  metrics:
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 60
EOF

После создания можно посмотреть текущее состояние:

$ k get hpa
NAME   REFERENCE         TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
app    StatefulSet/app   1%/60%    1         5         1          4s

Как видно из вывода - текущее среднее потребление памяти составляет 1% от resource.requests.

Cronjob

Теперь необходимо сделать механизм для постепенного увеличения потребления, для этого можно сделать небольшой скрипт, который будет создавать файл заданного размера внутри каждого пода. Сохранять состояние между запусками скрипта можно в ConfigMap:

apiVersion: v1
kind: ConfigMap
metadata:
  name: app
data:
  max: "500"
  size: "50"

Где size - текущий размер, который необходимо распределить между подами, max - максимальный размер.

Сам скрипт будет выглядеть так:

#!/bin/bash
set -e

NAME="app"
ADD="10"

data="$(kubectl get configmap "$NAME" -o json)"
max="$(echo "$data" | jq -rc '.data.max')"
size="$(echo "$data" | jq -rc '.data.size')"

size="$((size + ADD))"
if [[ "$size" -gt "$max" ]];then
  size="$max"
fi

pods=($(kubectl get pods -l app="$NAME" -o name))
count="${#pods[@]}"
pod_size="$((size/count))"

for pod in "${pods[@]}";do
  echo "$pod - $pod_size"
  kubectl exec "$pod" -- /bin/sh -c \
    "dd if=/dev/zero of=/tmp/data.tmp bs=1M count=$pod_size;mv /tmp/data.tmp /tmp/data"
done

kubectl patch configmap "$NAME" -p "{\"data\":{\"size\":\"$size\"}}"

Сам скрипт можно также сохранить в ConfigMap.

Для работы данного скрипта внутри кластера потребуется дать права сервисному аккаунту default, для этого необходимо создать Role и RoleBinding такого вида:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: app
rules:
- apiGroups:
  - ""
  resources:
  - pods
  - pods/exec
  - configmaps
  verbs:
  - '*'
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: app
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: app
subjects:
- kind: ServiceAccount
  name: default
  namespace: default

Применить данные ресурсы в кластер можно также командой kubectl apply:

k apply -f - << EOF
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: app
rules:
- apiGroups:
  - ""
  resources:
  - pods
  - pods/exec
  - configmaps
  verbs:
  - '*'
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: app
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: app
subjects:
- kind: ServiceAccount
  name: default
  namespace: default
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: app
data:
  max: "500"
  size: "50"
EOF
k apply -f - << 'EOF'
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-job-sh
data:
  job.sh: |
    #!/bin/bash
    set -e

    NAME="app"
    ADD="10"

    data="$(kubectl get configmap "$NAME" -o json)"
    max="$(echo "$data" | jq -rc '.data.max')"
    size="$(echo "$data" | jq -rc '.data.size')"

    size="$((size + ADD))"
    if [[ "$size" -gt "$max" ]];then
      size="$max"
    fi

    pods=($(kubectl get pods -l app="$NAME" -o name))
    count="${#pods[@]}"
    pod_size="$((size/count))"

    for pod in "${pods[@]}";do
      echo "$pod - $pod_size"
      kubectl exec "$pod" -- /bin/sh -c \
        "dd if=/dev/zero of=/tmp/data.tmp bs=1M count=$pod_size;mv /tmp/data.tmp /tmp/data"
    done

    kubectl patch configmap "$NAME" -p "{\"data\":{\"size\":\"$size\"}}"
EOF

Для запуска скрипта с заданной периодичностью можно воспользоваться ресурсом Cronjob:

apiVersion: batch/v1
kind: CronJob
metadata:
  labels:
    app: app
  name: app
spec:
  jobTemplate:
    metadata:
      name: app
    spec:
      template:
        metadata:
          labels:
            app: app-job
        spec:
          containers:
          - image: alpine/k8s:1.25.8
            name: app
            command:
            - /bin/bash
            - /script/job.sh
            resources: {}
            volumeMounts:
            - name: script
              mountPath: /script
          restartPolicy: OnFailure
          volumes:
          - name: script
            configMap:
              name: app-job-sh
  schedule: '*/5 * * * *'

Данный Cronjob будет запускать созданный скрипт каждые 5 минут, создадим также через kubectl apply:

k apply -f - << EOF
apiVersion: batch/v1
kind: CronJob
metadata:
  labels:
    app: app
  name: app
spec:
  jobTemplate:
    metadata:
      name: app
    spec:
      template:
        metadata:
          labels:
            app: app-job
        spec:
          containers:
          - image: alpine/k8s:1.25.8
            name: app
            command:
            - /bin/bash
            - /script/job.sh
            resources: {}
            volumeMounts:
            - name: script
              mountPath: /script
          restartPolicy: OnFailure
          volumes:
          - name: script
            configMap:
              name: app-job-sh
  schedule: '*/5 * * * *'
EOF

Scaling

После запуска скрипта из CronJob с некоторой задержкой будет отображаться новое потребление у пода:

$ k top po
NAME    CPU(cores)   MEMORY(bytes)
app-0   10m          64Mi
$ k get hpa
NAME   REFERENCE         TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
app    StatefulSet/app   64%/60%   1         5         1          52m

Последующие запуски будут увеличивать потребление и разделять между подами, в свою очередь hpa при прохождении порога в 60% средней нагрузки между подами и нахождении за этим порогом достаточное время будет увеличивать количество реплик.

$ k top po
NAME    CPU(cores)   MEMORY(bytes)
app-0   19m          44Mi
app-1   7m           43Mi
$ k get hpa
NAME   REFERENCE         TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
app    StatefulSet/app   44%/60%   1         5         2          61m

По итогу постепенно развернутся все поды до максимального количества, заданного в hpa:

$ k top po
NAME    CPU(cores)   MEMORY(bytes)
app-0   0m           58Mi
app-1   0m           59Mi
app-2   14m          58Mi
app-3   17m          57Mi
app-4   0m           57Mi
$ k get hpa
NAME   REFERENCE         TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
app    StatefulSet/app   58%/60%   1         5         5          159m

Примечание

Чтобы процесс шел быстрее можно увеличить частоту запуска Job в CronJob через параметр schedule.

После можно вернуть количество потребляемых ресурсов в ConfigMap в 0:

$ k patch cm app -p '{"data":{"size": "0"}}'
configmap/app patched

Что через некоторое время приведет к уменьшению количества реплик:

$ k get po -l app=app
NAME                 READY   STATUS        RESTARTS   AGE
app-0                1/1     Running       0          3h48m
app-1                1/1     Running       0          117m
app-2                0/1     Terminating   0          87m
$ k get po -l app=app
NAME    READY   STATUS    RESTARTS   AGE
app-0   1/1     Running   0          3h49m
$ k get hpa
NAME   REFERENCE         TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
app    StatefulSet/app   7%/60%    1         5         1          172m

Все примеры, используемые здесь, можно также посмотреть на github.