EKSでKarpenterによるオートスケールを試す



CI/CD Conference 2023の[Karpenter を活用した GitLab CI/CD ジョブ実行基盤の自動スケール](https://event.cloudnativedays.jp/cicd2023/talks/1793)を見て試してみたくなったので、実際に試してみた時のメモ。
N番煎じなメモです。

## Karpenterとは
[Karpenter](https://karpenter.sh/)とはAWSが開発したKubernetesクラスタのオートスケーラーで、EKSで標準で提供されるAuto Scaling Group(ClusterAPIによるオートスケーラー)よりも高速だったり、インスタンスタイプを選べたり、ちょっと良くなっているらしい。
ちなみに興味深い記事として、MIXIの方の[Karpenterを導入した話](https://team-blog.mitene.us/karpenter-b48ca7cdc22a)という記事があり、こちらはKarpenterを入れてみて良くなった点もあったが、デメリットもあって最終的にはManaged Node Groupに戻したというお話だった。必ずしも自環境に使えるものでもない、という点は気をつけた方が良さそうだ。

## Karpenterを試す
Archivedにはなってるが[公式ハンズオン](https://archive.eksworkshop.com/beginner/085_scaling_karpenter/)があるのでこちらを試して動作確認する。
なお、前提条件としてEKSクラスタが作成済みで、Worker Nodeが2台以上存在しているものとする。

### インストール前準備
環境変数を設定する。現在のバージョンは0.27.1に変更し、クラスタ名は自環境のEKSクラスタ名に変更する。
```sh
export KARPENTER_VERSION=v0.27.1
export CLUSTER_NAME=imurata-eksctl
```

最初に、karpenterで使うIAMロールを作成する。この辺の流れはオフィシャルサイトの[Getting Started](https://karpenter.sh/v0.27.1/getting-started/getting-started-with-karpenter/#3-create-a-cluster)とも同じである。
```sh
TEMPOUT=$(mktemp)

curl -fsSL https://karpenter.sh/"${KARPENTER_VERSION}"/getting-started/getting-started-with-karpenter/cloudformation.yaml  > $TEMPOUT \
&& aws cloudformation deploy \
  --stack-name "Karpenter-${CLUSTER_NAME}" \
  --template-file "${TEMPOUT}" \
  --capabilities CAPABILITY_NAMED_IAM \
  --parameter-overrides "ClusterName=${CLUSTER_NAME}"
```
次にクラスタにKarpenterノード用のIAMロールを登録する。手順に出てくる`ACCOUNT_ID`は未定義なので、定義した上で実行する。
```sh
export ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"
eksctl create iamidentitymapping \
  --username system:node:{{EC2PrivateDNSName}} \
  --cluster  ${CLUSTER_NAME} \
  --arn "arn:aws:iam::${ACCOUNT_ID}:role/KarpenterNodeRole-${CLUSTER_NAME}" \
  --group system:bootstrappers \
  --group system:nodes
```
ConfigMapの`aws-auth`に追加されていることを確認する。
```sh
kubectl describe configmap -n kube-system aws-auth
```

次にKarpenterコントローラ用のIAMロールを登録する。最初にIAM OIDC Providerを作成する。
```sh
eksctl utils associate-iam-oidc-provider --cluster ${CLUSTER_NAME} --approve
```
IAMロールを作成し、ServiceAccountと紐付ける。
```sh
eksctl create iamserviceaccount \
  --cluster "${CLUSTER_NAME}" --name karpenter --namespace karpenter \
  --role-name "${CLUSTER_NAME}-karpenter" \
  --attach-policy-arn "arn:aws:iam::${ACCOUNT_ID}:policy/KarpenterControllerPolicy-${CLUSTER_NAME}" \
  --role-only \
  --approve

export KARPENTER_IAM_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/${CLUSTER_NAME}-karpenter"
```
最後にスポットインスタンス用のロールの紐付けを行う。
```sh
aws iam create-service-linked-role --aws-service-name spot.amazonaws.com 2> /dev/null || echo 'Already exist'
```

### Karpenterのインストール
Helmでインストールする。
k8sクラスタのエンドポイントが必要なので、`CLUSTER_ENDPOINT`に設定した上でインストールコマンドを実行する。
```sh
export CLUSTER_ENDPOINT="$(aws eks describe-cluster --name ${CLUSTER_NAME} --query "cluster.endpoint" --output text)"
helm upgrade --install --namespace karpenter --create-namespace \
  karpenter oci://public.ecr.aws/karpenter/karpenter \
  --version ${KARPENTER_VERSION} \
  --set serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=${KARPENTER_IAM_ROLE_ARN} \
  --set settings.aws.clusterName=${CLUSTER_NAME} \
  --set settings.aws.clusterEndpoint=${CLUSTER_ENDPOINT} \
  --set defaultProvisioner.create=false \
  --set settings.aws.defaultInstanceProfile=KarpenterNodeInstanceProfile-${CLUSTER_NAME} \
  --set settings.aws.interruptionQueueName=${CLUSTER_NAME} \
  --wait
```
問題なければPodが以下のような感じで立ち上がる。
```
$ kubectl get pod -n karpenter
NAME                         READY   STATUS    RESTARTS   AGE
karpenter-748bd867b7-5zg2r   1/1     Running   0          3m6s
karpenter-748bd867b7-vfdf6   1/1     Running   0          3m6s
```
なお、ブログ記事[Karpenterを導入した話](https://team-blog.mitene.us/karpenter-b48ca7cdc22a)で紹介されているように、ap-south-1リージョンのpricing:GetProductsの利用が許可されていないとPodが起動していてもエラーを吐き続ける。
（※正確には、apリージョンはap-south-1、cnリージョンはcn-north-1、それ以外はus-east-1を使用）
Pod起動後は念の為ログを確認しておいた方がよい。
APIリージョンの選択は[こちら](https://github.com/aws/karpenter/issues/2604)で議論されているが、3月時点ではまだ実装されていないので、AWS側の仕様にあわせるしかない。



### Karpenterの設定

次にKarpenterの設定をする。`Provisioner`という実際にどのノードをデプロイするかを指定するリソースと、ノードのテンプレートである`AWSNodeTemplate`リソースを作成することで設定する。なお、`Provisioner`リソース内に`.spec.provider`を作成することで`AWSNodeTemplate`を作成せずに進めることも出来る。
```yaml
cat <<EOF | kubectl apply -f -
apiVersion: karpenter.sh/v1alpha5
kind: Provisioner
metadata:
  name: default
spec:
  labels:
    intent: apps
  requirements:
    - key: karpenter.sh/capacity-type
      operator: In
      values: ["spot"]
    - key: karpenter.k8s.aws/instance-size
      operator: NotIn
      values: [nano, micro, small, medium, large]
  limits:
    resources:
      cpu: 1000
      memory: 1000Gi
  ttlSecondsAfterEmpty: 30
  ttlSecondsUntilExpired: 2592000
  providerRef:
    name: default
---
apiVersion: karpenter.k8s.aws/v1alpha1
kind: AWSNodeTemplate
metadata:
  name: default
spec:
  subnetSelector:
    alpha.eksctl.io/cluster-name: ${CLUSTER_NAME}
  securityGroupSelector:
    alpha.eksctl.io/cluster-name: ${CLUSTER_NAME}
  tags:
    KarpenerProvisionerName: "default"
    NodeType: "karpenter-workshop"
    IntentLabel: "apps"
EOF
```
`Provisioners`の項目の意味は以下となる。
- `requirements`： インスタンスタイプやゾーンなど、ノードに関する条件を定義
- `limits`： クラスタに割り当てられるCPUとメモリの上限を定義
- `providerRef`：参照する`AWSNodeTemplate`リソース
- `ttlSecondsAfterEmpty`: 空のノードが削除されるまでの秒数。値を指定しない場合は停止させない。
- `ttlSecondsUntilExpired`: ノードを強制的に削除する秒数。新しいAMIでノードを立ち上げたい時に利用すると便利らしい。

今回はワークショップの設定をそのまま引っ張ってきているが、実際はインスタンスタイプなどを絞ってデプロイすることになる。その辺の設定は[公式のこの辺](https://karpenter.sh/v0.27.1/concepts/provisioners/#instance-types)が参考になる。
更に詳細が知りたい場合は[公式の説明](https://karpenter.sh/preview/concepts/provisioners/)を参照。


`AWSNodeTemplate`の項目の意味は以下となる。
- `subnetSelector`: Subnetを検出するためのセレクタ
- `securityGroupSelector`: SecurityGroupを検出するためのセレクタ
- `tags`: EC2インスタンスの作成時にノードに設定するタグ

ここでは各リソースに`alpha.eksctl.io/cluster-name: ${CLUSTER_NAME}`というタグが付いていることが前提となる。
詳細が知りたい場合は[公式の説明](https://karpenter.sh/preview/concepts/node-templates/)を参照。

上記のリソースをapplyすると準備完了となり、リソース不足になるとノードが追加される。
なお、自環境ではリソース不足だったのか、apply直後にインスタンスが作成された。
```
$ kubectl get node -o wide
NAME                              STATUS     ROLES    AGE     VERSION                INTERNAL-IP       EXTERNAL-IP    OS-IMAGE         KERNEL-VERSION                  CONTAINER-RUNTIME
ip-192-168-1-216.ec2.internal     NotReady   <none>   37s     v1.24.11-eks-a59e1f0   192.168.1.216     18.234.63.89   Amazon Linux 2   5.10.173-154.642.amzn2.x86_64   containerd://1.6.6
ip-192-168-120-145.ec2.internal   Ready      <none>   41m     v1.23.9-eks-ba74326    192.168.120.145   <none>         Amazon Linux 2   5.4.209-116.367.amzn2.x86_64    containerd://1.6.6
ip-192-168-71-122.ec2.internal    Ready      <none>   68m     v1.23.9-eks-ba74326    192.168.71.122    <none>         Amazon Linux 2   5.4.209-116.367.amzn2.x86_64    containerd://1.6.6
ip-192-168-93-205.ec2.internal    Ready      <none>   7h16m   v1.23.9-eks-ba74326    192.168.93.205    <none>         Amazon Linux 2   5.4.209-116.367.amzn2.x86_64    containerd://1.6.6
ip-192-168-98-54.ec2.internal     Ready      <none>   2d10h   v1.23.9-eks-ba74326    192.168.98.54     <none>         Amazon Linux 2   5.4.209-116.367.amzn2.x86_64    containerd://1.6.6
```
karpenterのログにも作成された旨が出力される。
```
$ kubectl logs deployment/karpenter -n karpenter controller
：（省略）
2023-03-30T10:45:51.475Z	INFO	controller.provisioner.cloudprovider	launched new instance	{"commit": "dc3af1a", "provisioner": "default", "id": "i-0d32f5f38717f3d8a", "hostname": "ip-192-168-1-216.ec2.internal", "instance-type": "r5.xlarge", "zone": "us-east-1d", "capacity-type": "spot"}
```

## スケーリングの確認
サンプルアプリをデプロイする。レプリカ数の初期値は0であるため、デプロイ後にレプリカ数を変更して確認していく。
```yaml
cat <<EOF > inflate.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: inflate
spec:
  replicas: 0
  selector:
    matchLabels:
      app: inflate
  template:
    metadata:
      labels:
        app: inflate
    spec:
      nodeSelector:
        intent: apps
      containers:
        - name: inflate
          image: public.ecr.aws/eks-distro/kubernetes/pause:3.2
          resources:
            requests:
              cpu: 1
              memory: 1.5Gi
EOF
kubectl apply -f inflate.yaml
```
現在のリソース使用状況を確認する。
```
$ kubectl top node
NAME                              CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%
ip-192-168-1-216.ec2.internal     70m          1%     3828Mi          12%
ip-192-168-120-145.ec2.internal   228m         5%     5581Mi          38%
ip-192-168-71-122.ec2.internal    567m         14%    9372Mi          62%
ip-192-168-93-205.ec2.internal    1697m        43%    5310Mi          35%
ip-192-168-98-54.ec2.internal     537m         13%    5256Mi          35%
```
サンプルのレプリカ数を10に変更する。
```sh
kubectl scale deployment inflate --replicas 10
```
直ぐにノードが追加された。体感的にはスケールからノードがReadyになるまで1分程度だった。
```
$ kubectl get node
NAME                              STATUS   ROLES    AGE     VERSION
ip-192-168-1-216.ec2.internal     Ready    <none>   13m     v1.24.11-eks-a59e1f0
ip-192-168-120-145.ec2.internal   Ready    <none>   54m     v1.23.9-eks-ba74326
ip-192-168-48-42.ec2.internal     Ready    <none>   52s     v1.24.11-eks-a59e1f0
ip-192-168-71-122.ec2.internal    Ready    <none>   81m     v1.23.9-eks-ba74326
ip-192-168-93-205.ec2.internal    Ready    <none>   7h28m   v1.23.9-eks-ba74326
ip-192-168-98-54.ec2.internal     Ready    <none>   2d10h   v1.23.9-eks-ba74326
```
次に`ttlSecondsAfterEmpty`が効くのか確認するために、サンプルを削除して待ってみる。
```sh
kubectl delete -f inflate.yaml
```
削除すると、karpenterのログに以下のメッセージが表示される。
```
2023-03-30T11:02:27.459Z	INFO	controller.node	added TTL to empty node	{"commit": "dc3af1a", "node": "ip-192-168-48-42.ec2.internal"}
2023-03-30T11:03:04.745Z	INFO	controller.deprovisioning	deprovisioning via emptiness delete, terminating 1 nodes ip-192-168-48-42.ec2.internal/c5.4xlarge/spot	{"commit": "dc3af1a"}
2023-03-30T11:03:04.760Z	INFO	controller.termination	cordoned node	{"commit": "dc3af1a", "node": "ip-192-168-48-42.ec2.internal"}
2023-03-30T11:03:05.093Z	INFO	controller.termination	deleted node	{"commit": "dc3af1a", "node": "ip-192-168-48-42.ec2.internal"}
```
時刻を見てもらえると分かるが、おおよそ30秒で削除が開始されているのが分かる。
deleteが表示されてからノードを確認すると、消えているのも確認できた。
```
$ kubectl get node
NAME                              STATUS   ROLES    AGE     VERSION
ip-192-168-1-216.ec2.internal     Ready    <none>   17m     v1.24.11-eks-a59e1f0
ip-192-168-120-145.ec2.internal   Ready    <none>   59m     v1.23.9-eks-ba74326
ip-192-168-71-122.ec2.internal    Ready    <none>   85m     v1.23.9-eks-ba74326
ip-192-168-93-205.ec2.internal    Ready    <none>   7h33m   v1.23.9-eks-ba74326
ip-192-168-98-54.ec2.internal     Ready    <none>   2d10h   v1.23.9-eks-ba74326
```

## まとめ
思った以上に高速にノードがデプロイされて使いやすいと感じた。
スケールするノードの設定がkubernetesのリソースとして管理できるのも個人的には好きな点である。
もうちょっと色々試して他のサイトで記載があったデメリットなどを確認していこうと思う。