本記事は オルトプラス Advent Calendar 2023 の12/23の記事です。
はじめに
こんにちは。オルトプラスSREの高場です。
普段はAWSの運用・構築や負荷試験を主に行っています。
歴史漫画が好きで、最近は『天幕のジャードゥーガル』がイチオシです。
オルトプラスアドベントカレンダー2023の2本目は、EKS + k6になります。
k6は業務で少し触ったことがありましたが、EKSに触れたのはほぼ初めてです。
この記事も勉強しながら書いていますので、気づいたことがあればご指摘頂けると幸いです。
この記事はこんな人におすすめ
- 毎回負荷試験のたびに環境を立てるのが面倒なのでIaC化したい
- これまではローカルから負荷試験ツールを動かしていたが、より大量の負荷を掛けるためには分散型にしたい
- 社内で様々なツールを使っているが、あわよくば統一基盤を作成したい
- EKS使ってみたい!
私は上記全てに当てはまっていたので、今回この記事を書かせて頂きました。
概要
下記のような構成の負荷試験環境を構築します。
- EKS
- Grafana
- Prometheus
- k6
k6はGolang製の軽量な負荷試験ツールで、Javascriptでシナリオを記述することができます。
オルトプラスではいくつかのプロジェクトで負荷試験に採用しています。
構成図
ディレクトリ構成
├── README.md
├── files
│ └── csi_role.json
├── scenarios
│ └── test.yaml
├── scripts
│ └── test.js
├── terraform
│ └── modules
│ ├── backend.tf
│ ├── data.tf
│ ├── eks.tf
│ ├── lb.tf
│ ├── locals.tf
│ ├── provider.tf
│ ├── variables.tf
│ └── vpc.tf
└── terraform.tfstate
詳細
AWS CLI、kubectl、helmは設定済みとします
EKSクラスターの作成
まずTerraformでEKSクラスターを建てていきます
EKSクラスターの作成にはeksctlが便利ですが、今回はTerraformで作成します。
それぞれのメリットに関しては議論もあるようです。
両方使ってみての感想としては下記のようになります。
- eksctlはコマンド一発で面倒な設定なしで作成できる。
- 一方、内部で何をやっているかとっつきづらい
- Terraformは必要な操作量に対して記述が多くなる傾向がある
- 作られるリソースを管理している安心感がある
どちらにもメリットがあると思いますが、今回は下記の理由からTerraformで作成します
- 社内でTerraform推進しており、慣れているツールで時間を短縮したい
- 学習中なので、必要なAWSリソースを把握しておきたい
実際の運用だと併用したりすることも必要になってくると思いますが、EKS運用についてはまだまだ社内に知見が溜まっていないので、今後使っていきたいです。
Terraformの記述
- hashicorp公式のk8sプロバイダーが必要なため、tfファイルにproviderを追加します
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 4.67.0"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = ">= 2.23.0"
}
}
}
TerraformでもAWSの公式モジュールがありいくつかプロパティを指定するだけでEKSクラスターが作成可能です
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "19.20.0"
cluster_name = var.cluster_name
cluster_version = "1.28"
cluster_endpoint_public_access = true
subnet_ids = local.public
vpc_id = aws_vpc.this.id
eks_managed_node_groups = {
example = {
target_group_arns = [aws_lb_target_group.this.arn]
subnets = local.public
}
}
create_iam_role = true
}
- 必要なロードバランサーやVPCリソースを記述していきます
resource "aws_lb" "this" {
name = "loadtest-lb"
subnets = [for subnet in aws_subnet.public : subnet.id]
}
resource "aws_lb_target_group" "this" {
name = "loadtest-tg"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.this.id
}
locals {
public = [
aws_subnet.public["a"].id,
aws_subnet.public["b"].id
]
}
resource "aws_vpc" "this" {
tags = {"Name" = "loadtest-vpc"}
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
}
resource "aws_subnet" "public" {
for_each = var.azs
availability_zone = each.value
cidr_block = cidrsubnet(aws_vpc.this.cidr_block, 8, index(values(var.azs), each.value) + 1)
vpc_id = aws_vpc.this.id
tags = {"Name" = "loadtest-public-subnet${each.key}"}
map_public_ip_on_launch = true
}
resource "aws_subnet" "private" {
for_each = var.azs
availability_zone = each.value
cidr_block = cidrsubnet(aws_vpc.this.cidr_block, 8, index(values(var.azs), each.value) + 11)
vpc_id = aws_vpc.this.id
map_public_ip_on_launch = true
tags = {"Name" = "loadtest-private-subnet${each.key}"}
}
variable "azs" {
type = map(string)
default = {
"a" = "ap-northeast-1a",
"b" = "ap-northeast-1c"
}
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = {"Name" = "loadtest-igw"}
}
resource "aws_nat_gateway" "this" {
subnet_id = aws_subnet.public["a"].id
allocation_id = aws_eip.nat.allocation_id
}
resource "aws_eip" "nat" {
tags = { "Name" = "loadtest-nat-eip" }
}
resource "aws_route_table" "public"{
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this.id
}
tags = {
Name = "loadtest-public-rtb"
}
}
resource "aws_route_table" "private"{
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_nat_gateway.this.id
}
tags = {
Name = "loadtest-private-rtb"
}
}
data "aws_subnets" "private" {
filter {
name = "tag:Name"
values = ["loadtest-private-*"]
}
}
resource "aws_route_table_association" "public_a" {
for_each = aws_subnet.public
subnet_id = each.value.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public_b" {
subnet_id = aws_subnet.public["b"].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private_a" {
subnet_id = aws_subnet.private["a"].id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "private_b" {
subnet_id = aws_subnet.private["b"].id
route_table_id = aws_route_table.private.id
}
-
cidrsubnet()
関数を使ってサブネットのCIDRを生成しています
CSIアドオンの追加
k6のシナリオはEKSのConfigMapに保存しています。
保存したシナリオファイルを永続化するためにEBSが必要なのですが、EKSでEBSを使うためには、CSIアドオンが必要になります
CSIは、コンテナ オーケストレーション プラットフォーム (CO) がストレージ プラグインと通信するための標準化された API を開発するコミュニティ主導の取り組みです。理論的には、標準化された通信プロトコルを使用すれば、ストレージ ベンダーは単一の仕様に準拠したプラグインを作成するだけで済みます。CSI は、プラグインと通信する方法の定義を定めます。プラグインがどのように管理または動作するかは、ストレージ プロバイダー次第です。CSI は次の機能を提供します。
CSIはk8sのようなコンテナオーケストレーションツールと各種ストレージとの通信を標準化してくれる考え方です。
AWSではEKSアドオンとして提供されています。
参考:EBS CSI driverをEKSアドオンとして導入してみた - BTC Cloud
上記の記事を参考にして、EBS CSI dirverをeks.tf
に追加します
resource "aws_eks_addon" "ebs_cni" {
cluster_name = var.cluster_name
addon_name = "aws-ebs-csi-driver"
service_account_role_arn = aws_iam_role.ebs_cni.arn
}
resource "aws_iam_role" "ebs_cni" {
name = "eks_ebs_csi_role"
assume_role_policy = templatefile("../../files/csi_role.json", {
oidc_arn = module.eks.oidc_provider_arn,
trimmed_arn = trimprefix("${module.eks.oidc_provider_arn}", "arn:aws:iam::${local.aws_account_id}:oidc-provider/")
})
managed_policy_arns = [
"arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy"
]
}
アドオンに必要なIAMロールは下記を参考にしました。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "${oidc_arn}"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"${trimmed_arn}:aud": "sts.amazonaws.com",
"${trimmed_arn}:sub": "system:serviceaccount:kube-system:ebs-csi-controller-sa"
}
}
}
]
}
-
trimmed_arn
ではOIDCプロバイダーのARNの一部を切り取って、ロールの許可対象のキーとして使用しています - EKSがEBSへ接続時に、stsで一時的な認証情報を発行してアクセスできるようになります
EKSの認証周りについて
k8sではRBAC認証を採用しています。
RBAC認証は、従来のMAC・DAC(Linux等のパーミッション)に替わる認証方式として提案された認証方式で、すべてのアクセスがロールを通じて行われます。
EKSではk8sのRBAC認証とIAM認証を紐づけているため、IAMロールによってEKSクラスターの操作が可能になります。
具体的には、aws-authという名前のConfigMapによってk8sのRBACロールとAWS側のIAMロールを対応させています。
EKSの認証周りやIAMとの関係については下記の記事も大変参考になりました。ありがとうございます。
EKSクラスターの作成
- 上記を
terraform apply
すると、20分ほどですべてのリソースが作成されます
AWSコンソールで確認すると、EKSクラスターが立ち上がっています
k6-operator
k6をDockerfileでビルドしてジョブとして実行することもできますが、
公式ではk6-operatorを使った分散テストの方法が紹介されています
k8sにおけるOperatorはCRD(Custom Resource Definition)とCustom Controllerを組み合わせた概念です
Custom Controllerはkube-apiserverを監視して、Custom Resourceを制御します
深くは立ち入りませんが、k8s上でk6を正常に動作させ続けてくれるものと理解して次へ進みます
k6-operatorのinstall
k6-operatorをcloneしてきます
$ git clone https://github.com/grafana/k6-operator.git
- kubeconfigを上記で建てたEKSクラスターの名前で更新します
$ aws eks update-kubeconfig --region ap-northeast-1 --name loadtest-cluster
- k6-operatorをinstallします
$ curl https://raw.githubusercontent.com/grafana/k6-operator/main/bundle.yaml | kubectl apply -f -
上記で、EKSクラスターにk6-operatorポッドが追加されていればOK
- prometheusとGrafanaをinstallします
$ helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
$ helm install prometheus prometheus-community/prometheus
$ helm install grafana grafana/grafana
GrafanaとPrometheusの初期設定
GrafanaとPrometheusのPodを立ち上げて、ローカルから確認できるようにしていきます。
大規模な負荷試験では、負荷を掛ける側のサーバーのメトリクスも確認したい場面が多くなります。
EC2でもいいのですが、EC2はデフォルトではメモリ情報が取得できなかったりします。
メモリを取得するにはCloudwatch Agentで別途設定が必要になり、若干手間です。
そこで、k6からprometheusにメトリクスを送信し、Grafanaで表示するようにします。
AWSにはAmazon Managed Grafana(AMG)があり、メトリクスを複数人で確認する必要がある際はこちらの方が使いやすいと思います。
AMGの利用はSSOによる認証が前提となってくるため、こちらも若干の準備が必要になります。
今回は素早い構築と実行のため、ローカルのみで確認します。
- 下記のコマンドでpodが正常に作成されているか確認します
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
grafana-6c99868785-7zf52 1/1 Running 0 7m36s
prometheus-alertmanager-0 1/1 Running 0 9m44s
prometheus-kube-state-metrics-74f788c56d-rmbqb 1/1 Running 0 9m44s
prometheus-prometheus-node-exporter-lhwns 1/1 Running 0 9m44s
prometheus-prometheus-pushgateway-f7f8778d7-f425v 1/1 Running 0 9m44s
prometheus-server-56d9c674dd-9mv8p 2/2 Running 0 9m44s
下記のように作成されたPod名を控えておきます
grafana-6c99868785-7zf52
prometheus-server-56d9c674dd-9mv8p
EKS上のPodにポートフォワードすることで、ブラウザからGrafanaとPrometheusにアクセスできます
$ kubectl --namespace default port-forward $GRAFANA_POD_NAME 3000
$ kubectl --namespace default port-forward $PROMETHEUS_POD_NAME 9090
ここまで上手くいっていればローカルでGrafanaが立ち上がっていると思います
-
上記の画面でログインするためには、生成されたPasswordを取得する必要があります
$ kubectl get secrets --namespace default grafana -o jsonpath="{.data.admin-password}" | base64 --decode ; echo
Grafana公式からダッシュボードのテンプレートをダウンロードすることができます
シナリオの作成
続いてシナリオを作成していきます。
k6のシナリオはJavascriptで記述します。
動作確認のため、単純なGETリクエストを行うシナリオを作成します。
k6のシナリオ:scripts/test.js
を作成し、下記の内容にします。
import http from "k6/http";
export const options = {
executor: 'constant-vus',
vus: 50,
duration: '30s',
};
export default function () {
const response = http.get("https://test-api.k6.io/public/crocodiles/");
}
-
executor: 'constant-vus'
で固定の接続数を維持します -
duration
でシナリオの実行時間を指定します -
vus
で接続数を指定します
scenarios/test.yaml
を下記の内容で作成します。
少しややこしいのですが、test.js
がk6側で使うシナリオで、test.yaml
はk8sのOperatorとしてのk6-Operatorが使用するシナリオになります。
違いは、k6の実行時の環境変数を与えられることと、並行実行数を指定できること等があります。
k6のシナリオは「API等の操作」を記述するのに対し、k6-Operatorのシナリオは「k6の入ったコンテナを実行するためのシナリオ」になっています。
apiVersion: k6.io/v1alpha1
kind: K6
metadata:
name: k6-sample
spec:
parallelism: 1
script:
configMap:
name: scripts
file: test.js
separate: false
arguments: -o experimental-prometheus-rw
runner:
env:
- name: K6_PROMETHEUS_RW_SERVER_URL
value: 'http://prometheus-server:9090/api/v1/write'
-
scripts
という名前のconfigMap
を指定し、その中のtest.js
を実行するシナリオです -
runner
のenv
プロパティでkeyvalueを指定することで、実行するシナリオに環境変数を渡すことができます -
parallelism
で並行実行数を指定します- k6のシナリオ内で指定するVUSとは異なり、k6自体を複数起動することでより高負荷をかけることができます
-
-o experimental-prometheus-rw
で、出力先をPrometheusにしています -
K6_PROMETHEUS_RW_SERVER_URL
で出力先のURLを先ほど立ち上げたPrometheusのpodのDNS名に変更しています
シナリオのアップロード
作成したk6のシナリオをアップロードします。
kubectl create configmap scripts --from-file=scripts
- 作成したk6のシナリオを格納するための
configMap
を作成し、同時にアップロードしています。-
--from-file
オプションでディレクトリを指定することで、作成と同時に配下のファイルをすべてアップロードすることができます
-
シナリオの実行
- シナリオをapplyすると、ジョブが実行されます
kubectl apply -f scenarios/test.yaml
- 結果はpodのlogから見れました(もう少し使いやすい方法を検討中です)
$ kubectl logs k6-sample-xxxxx
data_received..................: 5.8 MB 191 kB/s
data_sent......................: 786 kB 26 kB/s
http_req_blocked...............: avg=5.84ms min=1.07µs med=2.81µs max=442.43ms p(90)=4.15µs p(95)=5.04µs
http_req_connecting............: avg=2.67ms min=0s med=0s max=171.68ms p(90)=0s p(95)=0s
http_req_duration..............: avg=234.9ms min=172.87ms med=186.36ms max=996.15ms p(90)=363.4ms p(95)=500.37ms
{ expected_response:true }...: avg=234.9ms min=172.87ms med=186.36ms max=996.15ms p(90)=363.4ms p(95)=500.37ms
http_req_failed................: 0.00% ✓ 0 ✗ 6247
http_req_receiving.............: avg=87.77µs min=18.03µs med=65.19µs max=7.23ms p(90)=105.24µs p(95)=149.46µs
http_req_sending...............: avg=250.3µs min=5.59µs med=16.13µs max=71.04ms p(90)=24.83µs p(95)=42.5µs
http_req_tls_handshaking.......: avg=3.07ms min=0s med=0s max=261.82ms p(90)=0s p(95)=0s
http_req_waiting...............: avg=234.56ms min=172.8ms med=186.2ms max=996.07ms p(90)=363.17ms p(95)=500.3ms
http_reqs......................: 6247 206.777895/s
iteration_duration.............: avg=240.86ms min=173.01ms med=186.58ms max=996.26ms p(90)=399.27ms p(95)=523.98ms
iterations.....................: 6247 206.777895/s
vus............................: 50 min=0 max=50
vus_max........................: 50 min=50 max=50
後始末
- k6-operatorをアンインストールしてから、EKSクラスターを削除します。
- (先にEKSクラスターを削除しようとすると失敗します)
$ kubectl delete -f bundle.yaml # k6-operatorをアンインストール
$ cd terraform/module
$ terraform destroy # EKSクラスターを削除
まとめ
EKSで手軽にk6を実行させて、Grafanaでメトリクスを表示することができました!
初回はTerraform等の整備で時間がかかりますが、2回目以降、k6が実行できるようになるまでの時間は30分程度で済むようになりました。
クラスター作成後の手順をMakefile等を作成することで短縮することができたためです。
また、クラスターで実行することで負荷を掛ける側のサーバーの心配をせずにガンガン負荷を上げられるようになりました💪
より実践的な使い方については、今後社内で知見を貯めていきたいと思います。
ここまでご覧いただきありがとうございました。