CI/CD Conference 2023のKarpenter を活用した GitLab CI/CD ジョブ実行基盤の自動スケールを見て試してみたくなったので、実際に試してみた時のメモ。
N番煎じなメモです。
Karpenterとは
KarpenterとはAWSが開発したKubernetesクラスタのオートスケーラーで、EKSで標準で提供されるAuto Scaling Group(ClusterAPIによるオートスケーラー)よりも高速だったり、インスタンスタイプを選べたり、ちょっと良くなっているらしい。
ちなみに興味深い記事として、MIXIの方のKarpenterを導入した話という記事があり、こちらはKarpenterを入れてみて良くなった点もあったが、デメリットもあって最終的にはManaged Node Groupに戻したというお話だった。必ずしも自環境に使えるものでもない、という点は気をつけた方が良さそうだ。
Karpenterを試す
Archivedにはなってるが公式ハンズオンがあるのでこちらを試して動作確認する。
なお、前提条件としてEKSクラスタが作成済みで、Worker Nodeが2台以上存在しているものとする。
インストール前準備
環境変数を設定する。現在のバージョンは0.27.1に変更し、クラスタ名は自環境のEKSクラスタ名に変更する。
export KARPENTER_VERSION=v0.27.1
export CLUSTER_NAME=imurata-eksctl
最初に、karpenterで使うIAMロールを作成する。この辺の流れはオフィシャルサイトのGetting Startedとも同じである。
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
は未定義なので、定義した上で実行する。
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
に追加されていることを確認する。
kubectl describe configmap -n kube-system aws-auth
次にKarpenterコントローラ用のIAMロールを登録する。最初にIAM OIDC Providerを作成する。
eksctl utils associate-iam-oidc-provider --cluster ${CLUSTER_NAME} --approve
IAMロールを作成し、ServiceAccountと紐付ける。
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"
最後にスポットインスタンス用のロールの紐付けを行う。
aws iam create-service-linked-role --aws-service-name spot.amazonaws.com 2> /dev/null || echo 'Already exist'
Karpenterのインストール
Helmでインストールする。
k8sクラスタのエンドポイントが必要なので、CLUSTER_ENDPOINT
に設定した上でインストールコマンドを実行する。
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を導入した話で紹介されているように、ap-south-1リージョンのpricing:GetProductsの利用が許可されていないとPodが起動していてもエラーを吐き続ける。
(※正確には、apリージョンはap-south-1、cnリージョンはcn-north-1、それ以外はus-east-1を使用)
Pod起動後は念の為ログを確認しておいた方がよい。
APIリージョンの選択はこちらで議論されているが、3月時点ではまだ実装されていないので、AWS側の仕様にあわせるしかない。
Karpenterの設定
次にKarpenterの設定をする。Provisioner
という実際にどのノードをデプロイするかを指定するリソースと、ノードのテンプレートであるAWSNodeTemplate
リソースを作成することで設定する。なお、Provisioner
リソース内に.spec.provider
を作成することでAWSNodeTemplate
を作成せずに進めることも出来る。
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でノードを立ち上げたい時に利用すると便利らしい。
今回はワークショップの設定をそのまま引っ張ってきているが、実際はインスタンスタイプなどを絞ってデプロイすることになる。その辺の設定は公式のこの辺が参考になる。
更に詳細が知りたい場合は公式の説明を参照。
AWSNodeTemplate
の項目の意味は以下となる。
-
subnetSelector
: Subnetを検出するためのセレクタ -
securityGroupSelector
: SecurityGroupを検出するためのセレクタ -
tags
: EC2インスタンスの作成時にノードに設定するタグ
ここでは各リソースにalpha.eksctl.io/cluster-name: ${CLUSTER_NAME}
というタグが付いていることが前提となる。
詳細が知りたい場合は公式の説明を参照。
上記のリソースを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であるため、デプロイ後にレプリカ数を変更して確認していく。
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に変更する。
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
が効くのか確認するために、サンプルを削除して待ってみる。
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のリソースとして管理できるのも個人的には好きな点である。
もうちょっと色々試して他のサイトで記載があったデメリットなどを確認していこうと思う。