Terraformでk6 on EKSな負荷試験環境を爆速で構築する

本記事は [オルトプラス Advent Calendar 2023](https://qiita.com/advent-calendar/2023/altplus2023) の12/23の記事です。

# はじめに

こんにちは。オルトプラスSREの高場です。
普段はAWSの運用・構築や負荷試験を主に行っています。
歴史漫画が好きで、最近は『天幕のジャードゥーガル』がイチオシです。

<br>

オルトプラスアドベントカレンダー2023の2本目は、EKS + k6になります。
k6は業務で少し触ったことがありましたが、EKSに触れたのはほぼ初めてです。
この記事も勉強しながら書いていますので、気づいたことがあればご指摘頂けると幸いです。


### この記事はこんな人におすすめ
- 毎回負荷試験のたびに環境を立てるのが面倒なのでIaC化したい
- これまではローカルから負荷試験ツールを動かしていたが、より大量の負荷を掛けるためには分散型にしたい
- 社内で様々なツールを使っているが、あわよくば統一基盤を作成したい
- EKS使ってみたい！


私は上記全てに当てはまっていたので、今回この記事を書かせて頂きました。

# 概要

下記のような構成の負荷試験環境を構築します。

- EKS
- Grafana
- Prometheus
- k6


k6はGolang製の軽量な負荷試験ツールで、Javascriptでシナリオを記述することができます。
オルトプラスではいくつかのプロジェクトで負荷試験に採用しています。

https://k6.io/



### 構成図
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/616788/25ad8e3a-11c6-9fe8-6d6a-525fb0b6ad1f.png)

### ディレクトリ構成
```
├── 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で作成します。

それぞれのメリットに関しては議論もあるようです。

https://www.reddit.com/r/Terraform/comments/m47ljm/eksctl_vs_terraform/


両方使ってみての感想としては下記のようになります。

- eksctlはコマンド一発で面倒な設定なしで作成できる。
    - 一方、内部で何をやっているかとっつきづらい
- Terraformは必要な操作量に対して記述が多くなる傾向がある
    - 作られるリソースを管理している安心感がある


どちらにもメリットがあると思いますが、今回は下記の理由からTerraformで作成します

- 社内でTerraform推進しており、慣れているツールで時間を短縮したい
- 学習中なので、必要なAWSリソースを把握しておきたい


実際の運用だと併用したりすることも必要になってくると思いますが、EKS運用についてはまだまだ社内に知見が溜まっていないので、今後使っていきたいです。


### Terraformの記述


- hashicorp公式のk8sプロバイダーが必要なため、tfファイルにproviderを追加します

```provider.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.67.0"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = ">= 2.23.0"
    }
  }
}
```

TerraformでもAWSの公式モジュールがありいくつかプロパティを指定するだけでEKSクラスターが作成可能です

https://registry.terraform.io/modules/terraform-aws-modules/eks/aws/latest

```eks.tf

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リソースを記述していきます

```lb.tf
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
    ]
}

```

```vpc.tf

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を生成しています

https://developer.hashicorp.com/terraform/language/functions/cidrsubnet


### CSIアドオンの追加
k6のシナリオはEKSのConfigMapに保存しています。
保存したシナリオファイルを永続化するためにEBSが必要なのですが、EKSでEBSを使うためには、CSIアドオンが必要になります


https://www.architecting.it/blog/container-storage-interface/


> CSIは、コンテナ オーケストレーション プラットフォーム (CO) がストレージ プラグインと通信するための標準化された API を開発するコミュニティ主導の取り組みです。理論的には、標準化された通信プロトコルを使用すれば、ストレージ ベンダーは単一の仕様に準拠したプラグインを作成するだけで済みます。CSI は、プラグインと通信する方法の定義を定めます。プラグインがどのように管理または動作するかは、ストレージ プロバイダー次第です。CSI は次の機能を提供します。

CSIはk8sのようなコンテナオーケストレーションツールと各種ストレージとの通信を標準化してくれる考え方です。
AWSではEKSアドオンとして提供されています。

参考：EBS CSI driverをEKSアドオンとして導入してみた - BTC Cloud

https://cloud.bigtreetc.com/column/ebs-csi-driver/



上記の記事を参考にして、EBS CSI dirverを`eks.tf`に追加します

```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ロールは下記を参考にしました。


https://repost.aws/ja/knowledge-center/eks-persistent-storage


```csi_role.json

{
    "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等のパーミッション）に替わる認証方式として提案された認証方式で、すべてのアクセスがロールを通じて行われます。

https://www.okta.com/jp/identity-101/what-is-role-based-access-control-rbac/

https://csrc.nist.gov/Projects/Role-Based-Access-Control/faqs


EKSではk8sのRBAC認証とIAM認証を紐づけているため、IAMロールによってEKSクラスターの操作が可能になります。
具体的には、aws-authという名前のConfigMapによってk8sのRBACロールとAWS側のIAMロールを対応させています。

EKSの認証周りやIAMとの関係については下記の記事も大変参考になりました。ありがとうございます。

https://zenn.dev/nameless_gyoza/articles/eks-authentication-authorization-20210211

### EKSクラスターの作成

- 上記を`terraform apply`すると、20分ほどですべてのリソースが作成されます

AWSコンソールで確認すると、EKSクラスターが立ち上がっています

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/616788/bce77d6a-1171-7a54-b55d-e919a4478134.png)



### k6-operator

k6をDockerfileでビルドしてジョブとして実行することもできますが、
公式ではk6-operatorを使った分散テストの方法が紹介されています

https://k6.io/docs/testing-guides/running-distributed-tests/

k8sにおけるOperatorはCRD（Custom Resource Definition）とCustom Controllerを組み合わせた概念です
Custom Controllerはkube-apiserverを監視して、Custom Resourceを制御します
深くは立ち入りませんが、k8s上でk6を正常に動作させ続けてくれるものと理解して次へ進みます

https://kubernetes.io/ja/docs/concepts/overview/components/

    
### k6-operatorのinstall

k6-operatorをcloneしてきます

```
    $ git clone https://github.com/grafana/k6-operator.git 
```

- kubeconfigを上記で建てたEKSクラスターの名前で更新します

```shell
    $ aws eks update-kubeconfig --region ap-northeast-1 --name loadtest-cluster
```
- k6-operatorをinstallします

```shell
    $ curl https://raw.githubusercontent.com/grafana/k6-operator/main/bundle.yaml | kubectl apply -f -
```
上記で、EKSクラスターにk6-operatorポッドが追加されていればOK

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/616788/974708be-07cf-7f7d-eb23-5c1a7b32e16e.png)



- 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）があり、メトリクスを複数人で確認する必要がある際はこちらの方が使いやすいと思います。

https://aws.amazon.com/jp/grafana/

AMGの利用はSSOによる認証が前提となってくるため、こちらも若干の準備が必要になります。
今回は素早い構築と実行のため、ローカルのみで確認します。

- 下記のコマンドでpodが正常に作成されているか確認します

```shell
$ 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にアクセスできます

```shell
    $ kubectl --namespace default port-forward $GRAFANA_POD_NAME 3000
    $ kubectl --namespace default port-forward $PROMETHEUS_POD_NAME 9090
```



ここまで上手くいっていればローカルでGrafanaが立ち上がっていると思います
- http://localhost:3000
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/616788/2bafb1e3-7071-98e2-8f37-13030f047b4e.png)

- 上記の画面でログインするためには、生成されたPasswordを取得する必要があります
```shell
    $ kubectl get secrets --namespace default grafana -o jsonpath="{.data.admin-password}" | base64 --decode ; echo

```


- ログインしたら、Grafanaがメトリクスを参照するdata sourceを追加します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/616788/8cad597e-b9a3-df00-0b4b-9328554085c8.png)

- Add Data SourceでPrometheusを選択
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/616788/3fd255a9-0355-ce86-3092-c87a49ce4a43.png)




- server urlは`http://<kubectlで指定したdeployment名>-server`を指定します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/616788/e9f4cd6b-3802-9a16-5755-341e9065ae79.png)


- save & testで`Successfully queried the Prometheus API.`が表示されればOKです
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/616788/a4ee3fe2-9c23-6413-8ab2-db998d6c79d2.png)


- ホーム画面 > Dashboards > New > Importを選択します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/616788/af04fe36-a735-c48e-b0ab-5bcf4ee7eb8c.png)



Grafana公式からダッシュボードのテンプレートをダウンロードすることができます

https://grafana.com/grafana/dashboards/

- 3119番（`Kubernetes cluster monitoring (via Prometheus)`）をインポートします
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/616788/7e645ac7-2fd8-72ea-5583-60294bef59d7.png)

https://grafana.com/grafana/dashboards/3119-kubernetes-cluster-monitoring-via-prometheus/

- 先ほど設定したData Sourceを適用して「Import」します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/616788/d8a1d973-36e1-df7e-72ea-954b2b5b71b9.png)


- ダッシュボードが作成されました！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/616788/f593beb2-14d7-4458-a913-fe70b6912dca.png)

### シナリオの作成

続いてシナリオを作成していきます。

k6のシナリオはJavascriptで記述します。
動作確認のため、単純なGETリクエストを行うシナリオを作成します。

k6のシナリオ：`scripts/test.js`を作成し、下記の内容にします。

```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の入ったコンテナを実行するためのシナリオ」になっています。

```test.yaml
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のシナリオをアップロードします。
```shell
	kubectl create configmap scripts --from-file=scripts
```

- 作成したk6のシナリオを格納するための`configMap`を作成し、同時にアップロードしています。
    - `--from-file`オプションでディレクトリを指定することで、作成と同時に配下のファイルをすべてアップロードすることができます


### シナリオの実行

- シナリオをapplyすると、ジョブが実行されます
```shell
	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クラスターを削除しようとすると失敗します）

```shell
    $ kubectl delete -f bundle.yaml # k6-operatorをアンインストール
    $ cd terraform/module
    $ terraform destroy # EKSクラスターを削除
```

# まとめ

EKSで手軽にk6を実行させて、Grafanaでメトリクスを表示することができました！
初回はTerraform等の整備で時間がかかりますが、2回目以降、k6が実行できるようになるまでの時間は30分程度で済むようになりました。
クラスター作成後の手順をMakefile等を作成することで短縮することができたためです。

また、クラスターで実行することで負荷を掛ける側のサーバーの心配をせずにガンガン負荷を上げられるようになりました💪

より実践的な使い方については、今後社内で知見を貯めていきたいと思います。

ここまでご覧いただきありがとうございました。