メインコンテンツまでスキップ

HPAによる性能劣化をCronJobを利用して抑える

· 約11分

KubernetesのHPA(Horizontal Pod Autoscaling)を利用する場合、希望する数のPodがデプロイされるまで時間がかかることがあります(クラスタ・オートスケールのスケールアウト時など)。 それまではサービス品質が低下する可能性があり、例えばトラフィック量が急激に増加した場合、応答レイテンシが非常に大きくなる可能性があります。 本記事では、過去のデータなどからリクエスト量の推移を大まかに推測できることを前提に、予想されるトラフィック増加の前にKubernetesのCronJobを使ってHPAの最小レプリカ数を調整することで、サービス低下を緩和する方法を紹介します。 また、CronJobのテンプレートと、CSVファイルの値を使用してテンプレートからCronJobマニフェストを生成するPythonスクリプトを記述します。

背景

ほとんどのウェブアプリケーションは、ランチタイムは1秒あたりのリクエストが多く、オフィスアワーは控えめで、夜間は少ないというような、日々のトラフィックのトレンドを持っているかと思います。 KubernetesのHPA(Horizontal Pod Autoscaling)と呼ばれる機能では、設定したメトリックターゲットに基づいてPodの数を動的に変更することができますが、HPAの仕組み上、Pod数が最適になるまでタイムラグが発生するのは避けられません。 それまでは、例えば急にトラフィックが増えた場合、リクエストのレイテンシが非常に大きくなる可能性があります。 しかし、実際のシナリオでは、統計的な指標値を利用することで、事前にリクエスト量をおおよそ見積もることができるかもしれません。 この記事では、予想されるトラフィック増加の前に、KubernetesのCronJobを使用してHPAメトリクスを調整することで、サービス低下を緩和する方法を紹介します。

ステップ

1. デモアプリのデプロイ

デモ用にシンプルなToDoアプリを使ってみましょう。 このアプリは、NGINXでホストされたフロントエンドのReact、NodeJSで書かれたバックエンドのAPIサーバー、データベース用のMongoDBで構成されています。

リポジトリをクローンしましょう:

# リポジトリをクローンする
git clone https://github.com/ryojp/todo.git

# リポジトリに移動する
cd todo

その後、READMEに従ってアプリをデプロイします:

  1. Create MongoDB-related secret:

    cp .env.dev .env
    vim .env # set secure passwords
    kubectl create ns todo
    kubectl -n todo create secret generic todosecret --from-env-file .env
  2. Install NGINX Ingress Controller

    helm upgrade --install ingress-nginx ingress-nginx --repo https://kubernetes.github.io/ingress-nginx --namespace ingress-nginx --create-namespace
  3. Apply Kubernetes configuration files:

    kubectl apply -f k8s/
  4. Visit https://localhost and you will be redirected to the login page if success.

上記のステップでは、以下のものを名前空間 todo にデプロイしました:

  • frontendapi 用の ClusterIP、Deployment、HPA
  • mongo 用の ClusterIP と StatefulSet
  • /api リクエストを api に、/ リクエストを frontend に(リバース)プロキシするIngress

特に、2つのHPAをデプロイしました:

$ kubectl -n todo get hpa
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
frontend Deployment/frontend 0%/50% 1 10 1 2m13s
api Deployment/api 0%/50% 2 10 2 2m13s

2. 適切なロールを持つServiceAccountの作成

CronJobからHPAにkubectl patchコマンドを実行します。 そこで、KubernetesのAPIサーバーにアクセスするための適切なロールを持つServiceAccountを作成しましょう:

apiVersion: v1
kind: ServiceAccount
metadata:
name: hpa-scheduler
namespace: todo
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: hpa-scheduler
namespace: todo
rules:
- apiGroups:
- autoscaling
resources:
- horizontalpodautoscalers
verbs:
- get
- list
- patch
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: hpa-scheduler
namespace: todo
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: hpa-scheduler
subjects:
- apiGroup: ""
kind: ServiceAccount
name: hpa-scheduler
namespace: todo

上記のマニフェストは自己説明的かと思われます。

なお、apiGroupsresourceskubectl api-resourcesの出力から見つけることができます:

$ kubectl api-resources | grep hpa
horizontalpodautoscalers hpa autoscaling/v2 true HorizontalPodAutoscaler

それではapplyしましょう:

$ kubectl apply -f k8s/hpa-scheduler/sa.yml
serviceaccount/hpa-scheduler created
role.rbac.authorization.k8s.io/hpa-scheduler created
rolebinding.rbac.authorization.k8s.io/hpa-scheduler created

3. 1日のスケジュールをCSVで定義する

ここでは、ランチタイム(12:00-13:00)に突然リクエスト量が増加すると仮定します。 遅延なく低レイテンシーを維持するために、12:00の5分前にHPAのminReplicasフィールドを増やすことにします。 以下はCronJobの1日のスケジュールです。

NAMESPACE,HPA,HOUR,MINUTE,MIN_REPLICAS,MAX_REPLICAS
todo,frontend,11,55,3,10
todo,frontend,13,10,1,10
todo,api,05,55,3,10
todo,api,11,55,6,10
todo,api,13,10,3,10
todo,api,22,10,1,10

4. CronJob テンプレートの作成

CronJobマニフェストを生成するためにCSVの値を使用します。下記はそのテンプレートです。

apiVersion: batch/v1
kind: CronJob
metadata:
name: cj-__HPA__-__HOURMIN__
namespace: __NAMESPACE__
labels:
target: __HPA__
app.kubernetes.io/name: cj-__HPA__-__HOURMIN__
spec:
schedule: __CRON__ # Timezone UTC(+0000)
startingDeadlineSeconds: 120
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 5
failedJobsHistoryLimit: 5
jobTemplate:
spec:
template:
metadata:
labels:
sidecar.istio.io/inject: "false"
spec:
serviceAccountName: hpa-scheduler
containers:
- name: hpa-scheduler
image: bitnami/kubectl:1.26
command:
- /bin/sh
- -c
- |
kubectl -n __NAMESPACE__ patch hpa/__HPA__ -p '{"spec":{"minReplicas":__MIN__, "maxReplicas":__MAX__}}'
restartPolicy: OnFailure

このテンプレートでは、__HPA__のようなダブルアンダースコアで囲まれた変数は、次のステップで書くPythonスクリプトを使ってCSVファイルの値に置き換えられます。

Kubernetes v1.27以降、.spec.timeZonedoc)でタイムゾーンを指定できるようになりました。 v1.27 はこの記事を書いている時点では Azure のようなクラウドプロバイダーで GA されていないため、このテンプレートでは使用していません。 代わりに、Pythonスクリプトでタイムゾーンを変換することにします。

5. CSV から CronJob マニフェストを生成する Python スクリプトの作成

CSVを読み込んでCronJobマニフェストを生成するスクリプトを以下に示します:

#!/usr/bin/env python3

from collections import namedtuple
import csv
import os


BASE_DIR = "k8s/hpa-scheduler/"
OUTPUT_DIR = os.path.join(BASE_DIR, "generated/")
CRONJOB_TEMPLATE_FILE = os.path.join(BASE_DIR, "cronjob-template.yml")
CRONJOB_CSV_FILE = os.path.join(BASE_DIR, "cronjob.csv")
TIMEZONE_DIFF = int(os.environ.get("TIMEZONE_DIFF", +9)) # UTC+0900

Config = namedtuple("Config", "NAMESPACE,HPA,HOUR,MINUTE,MIN_REPLICAS,MAX_REPLICAS")


def substitute_template_yml(
config: Config, template: str = CRONJOB_TEMPLATE_FILE
) -> str:
# read the template file content
with open(template, "r") as f:
content = f.read()

# convert "3" -> "03" etc.
minute, hour = config.MINUTE.rjust(2, "0"), config.HOUR.rjust(2, "0")

# convert timezone to UTC
utc_hour = (int(hour) - TIMEZONE_DIFF) % 24
utc_hour = str(utc_hour).rjust(2, "0")

return (
content.replace("__CRON__", f"{minute} {utc_hour} * * *")
.replace("__NAMESPACE__", config.NAMESPACE)
.replace("__HPA__", config.HPA)
.replace("__HOURMIN__", hour + minute)
.replace("__MIN__", config.MIN_REPLICAS)
.replace("__MAX__", config.MAX_REPLICAS)
)


def read_csv(csv_filename: str) -> list[Config]:
with open(csv_filename) as f:
reader = csv.reader(f)

# make sure the CSV header matches the `Config` type
assert Config._fields == tuple(next(reader)) # RHS: reading the CSV header

return list(map(Config._make, reader))


def write_yml(filename: str, content: str) -> None:
with open(filename, "w") as f:
f.write(content)


def generate_filename(config: Config) -> str:
return (
f'cj-{config.HPA}{config.HOUR.rjust(2, "0")}{config.MINUTE.rjust(2, "0")}.yml'
)


def generate_yml_from_csv(csv_filename: str, outdir: str) -> None:
os.makedirs(outdir, exist_ok=True)
for config in read_csv(csv_filename=csv_filename):
_filename = generate_filename(config)
_content = substitute_template_yml(config=config)
write_yml(os.path.join(outdir, _filename), _content)


if __name__ == "__main__":
generate_yml_from_csv(CRONJOB_CSV_FILE, OUTPUT_DIR)

実行権限を与えましょう:

chmod +x k8s/hpa-scheduler/generate.py

6. 動作確認

それでは動作を確認します。 まずPythonスクリプトを実行してCronJobマニフェストを生成しましょう:

TIMEZONE_DIFF=0 ./k8s/hpa-scheduler/generate.py

ここで、TIMEZONE_DIFFを変更してください。 ほとんどのシステムではCronJobはUTCタイムゾーンを使用しますので、もしあなたのタイムゾーンがUTC+0900であれば、TIMEZONE_DIFF=9を渡すことができます。 しかしMicroK8sでは、Zennの記事によるとローカルのタイムゾーンになります。

次に、生成されたCronJobマニフェストを適用します:

kubectl apply -f k8s/hpa-scheduler/generated/

それではCronJobsとHPAの状態を確認します:

# List CronJobs that target frontend or api
$ kubectl -n todo get cj -l "target in (frontend, api)"
NAME SCHEDULE SUSPEND ACTIVE LAST SCHEDULE AGE
cj-api-0555 55 05 * * * False 0 <none> 13s
cj-api-1155 55 11 * * * False 0 <none> 13s
cj-api-1310 10 13 * * * False 0 <none> 13s
cj-api-2226 26 13 * * * False 0 <none> 13s
cj-frontend-1155 55 11 * * * False 0 <none> 13s
cj-frontend-1310 10 13 * * * False 0 <none> 13s
cj-api-2235 35 22 * * * False 1 4s 13s

# Get the log of the job that ran 4 seconds ago
$ kubectl -n todo logs cj-api-2235-28126895-572c5
horizontalpodautoscaler.autoscaling/api patched

# Verify that the MINPODS for the hpa/api is reduced from 2 to 1
$ kubectl -n todo get hpa
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
frontend Deployment/frontend 0%/50% 1 10 1 120m
api Deployment/api 0%/50% 1 10 1 120m

以上から、22:35にスケジュールされた hpa/api の CronJob cj-api-2235 が正常に完了し、hpa/apiMINPODS2 から 1 に変更されたことが確認できました。

7. クリーンアップ

kubectl delete ns todo ingress-nginx

最後に

この記事では、サービス品質(応答レイテンシなど)を維持しながら、HPAを活用してPodの数を動的に微調整できる、シンプルかつ堅牢なソリューションを紹介しました。 また個人的には、ServiceAccountRoleCronJobを学ぶ機会になりました。

参考