ローカル Kubernetes で Karpenter を試した

イントラネットに構築したローカル Kubernetes クラスター（K3s）で、オープンソースのノードプロビジョニング [Karpenter](https://github.com/aws/karpenter) を試しました。

# 動機
機械学習や最適化、ソフトウェア開発の検証／テスト、使用頻度が低いアプリを動かしたい、などローカル Kubernetes クラスターのコンピューティング資源が一時的に不足したとき AWS で資源不足を補いたい。

![fig1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2629684/d6c595a4-4770-e8a7-ef79-90ff6a8cc80d.png)





# 本記事の前提
イントラのローカル Kubernetes クラスターは構築済みです。

AWS VPC と Subnet、SecurityGroup 等のリソースは作成済みです。

ローカル Kubernetes とAWS VPC は、WireGuard で VPN 接続されています。

Karpenter を動作させるには、Karpenter Controller が EC2インスタンスを作成するための IAM 権限と、作成された Karpenter Node が Kubernetes クラスターに参加するための Kubernetes 権限が必要です。ここでは IRSA（IAM Roles for Service Accounts）で Karpenter Controller に IAM 権限を付与します。IRSA するために [Amazon EKS Pod Identity Webhook](https://github.com/aws/amazon-eks-pod-identity-webhook) をセットアップ済みです。セットアップ手順は [Self-hosted Kubernetes setup](https://github.com/aws/amazon-eks-pod-identity-webhook/blob/master/SELF_HOSTED_SETUP.md) を参考にしました。また、Karpenter Node がクラスターに参加する権限を付与するために [AWS IAM Authenticator for Kubernetes](https://github.com/kubernetes-sigs/aws-iam-authenticator) をセットアップ済みです。


[aws / amazon-vpc-cni-k8s](https://github.com/aws/amazon-vpc-cni-k8s) は使いません。

# Version
- K3s： v1.22.8
- AWS IAM Authenticator for Kubernetes： v0.5.7
- Amazon EKS Pod Identity Webhook： v0.4.0
- Karpenter： v0.10.0


# Karpenter セットアップ手順
手順概略
1. Terraform コード作成
1. 起動テンプレートの user_data
1. シェル変数設定
1. Terraform 適用
1. Terraform output 取得
1. EC2 Spot Service Linked ロール作成
1. Karpenter Helm Chart インストール
1. AWS IAM Authenticator に Karpenter Node ロール設定
1. Subnet にタグ付与
1. Provisioner 作成

Terraform と AWS CLI を使います。

## Terraform コード作成
main.tf

```terraform
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.8"
    }
  }
}

provider "aws" {
  region  = var.aws_region
  default_tags {
    tags = {
      "kubernetes.io/cluster/${var.cluster_name}" = "owned"
    }
  }
}

################################################################################
# KarpenterController Role
################################################################################

resource "aws_iam_role" "controller" {
  name = "${var.controller_role_name}"
  path = "/"

  assume_role_policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Federated": "${var.oidc_provider_arn}"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "StringEquals": {
                    "${var.oidc_issuer_uri}:aud": "${var.oidc_client_id}"
                }
            }
        }
    ]
}
EOF

  managed_policy_arns = [aws_iam_policy.controller.arn]
}

resource "aws_iam_policy" "controller" {
  name        = "${var.controller_policy_name}"
  path        = "/"
  policy = jsonencode({
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "ec2:CreateLaunchTemplate",
                "ec2:CreateFleet",
                "ec2:RunInstances",
                "ec2:CreateTags",
                "iam:PassRole",
                "ec2:TerminateInstances",
                "ec2:DescribeLaunchTemplates",
                "ec2:DescribeInstances",
                "ec2:DescribeSecurityGroups",
                "ec2:DescribeSubnets",
                "ec2:DescribeInstanceTypes",
                "ec2:DescribeInstanceTypeOfferings",
                "ec2:DescribeAvailabilityZones",
                "ssm:GetParameter"
            ],
            "Resource": "*",
            "Effect": "Allow"
        }
    ]
  })
}

################################################################################
# KarpenterNode Role
################################################################################

resource "aws_iam_role" "node" {
  name = var.node_role_name
  path = "/"

  assume_role_policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": "sts:AssumeRole",
            "Principal": {
               "Service": "ec2.amazonaws.com"
            },
            "Effect": "Allow"
        }
    ]
}
EOF

  managed_policy_arns = [
    "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy",
    "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy",
    "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly",
    "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore",
  ]
}

resource "aws_iam_instance_profile" "node" {
  name = "${var.node_instance_profile_name}"
  role = aws_iam_role.node.name
}

################################################################################
# Launch template for K3s-agent
################################################################################

data "aws_ami" "this" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["amzn2-ami-kernel-*-hvm-*-gp2"]
  }

  filter {
    name   = "architecture"
    values = ["x86_64"]
  }

  filter {
    name   = "root-device-type"
    values = ["ebs"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }

  filter {
    name   = "block-device-mapping.volume-type"
    values = ["gp2"]
  }
}

resource "aws_launch_template" "k3s-agent" {
  name = var.launch_template_name

  block_device_mappings {
    device_name = "/dev/xvda"

    ebs {
      volume_size = 20
      volume_type = "gp3"
    }
  }

  update_default_version = true

  iam_instance_profile {
    arn = aws_iam_instance_profile.node.arn
  }

  image_id = data.aws_ami.this.id

  vpc_security_group_ids = ["${var.node_security_group_id}"]

  user_data = base64encode(templatefile("${path.module}/k3s-agent.sh", {
    CLUSTER_ENDPOINT = "${var.cluster_endpoint}",
    K3S_TOKEN        = "${var.k3s_token}"
  }))
}
```

variables.tf
```terraform
variable "cluster_name" {
  default  = "my-cluster"
  nullable = false
}

variable "aws_region" {
  default  = "ap-northeast-1"
  nullable = false
}

variable "cluster_endpoint" {
  type     = string
  nullable = false
}

variable "controller_policy_name" {
  type     = string
  default  = "KarpenterControllerPolicy"
  nullable = false
}

variable "controller_role_name" {
  type     = string
  default  = "KarpenterControllerRole"
  nullable = false
}

variable "k3s_token" {
  type     = string
  nullable = false
}

variable "launch_template_name" {
  type     = string
  default  = "k3s-agent"
  nullable = false
}

variable "node_instance_profile_name" {
  type     = string
  default  = "KarpenterNodeInstanceProfile"
  nullable = false
}

variable "node_role_name" {
  type     = string
  default  = "KarpenterNodeRole"
  nullable = false
}

variable "node_security_group_id" {
  type     = string
}

variable "oidc_provider_arn" {
  type     = string
  nullable = false
}

variable "oidc_issuer_uri" {
  type     = string
  nullable = false
}

variable "oidc_client_id" {
  type     = string
  nullable = false
}
```

outputs.tf
```terraform
output "controller_role_arn" {
  value = aws_iam_role.controller.arn
}

output "node_role_arn" {
  value = aws_iam_role.node.arn
}
```

## 起動テンプレートの user_data
k3s-agent.sh

```bash
#!/usr/bin/env bash

function get_token() {
  # 中略
}

function get_meta_data() {
  # 中略
}

TOKEN=$(get_token)

INTERNAL_IP=$(get_meta_data 'latest/meta-data/local-ipv4')

curl -sfL https://get.k3s.io | K3S_URL="${CLUSTER_ENDPOINT}" K3S_TOKEN="${K3S_TOKEN}" INSTALL_K3S_EXEC="--node-ip=$INTERNAL_IP --node-label=karpenter.sh/capacity-type=spot" sh -s -

```
ここで `TOKEN` は、インスタンスメタデータ取得 `get_meta_data()` で使用します。`get_token()` と `get_meta_data()` は、[amazon-eks-ami/files/bootstrap.sh](https://github.com/awslabs/amazon-eks-ami/blob/master/files/bootstrap.sh) を参考にしました。


## シェル変数設定
OIDC プロバイダーの ARN を取得

```bash
$ IDP_ARN=$(aws iam list-open-id-connect-providers \
  --query 'OpenIDConnectProviderList[0].Arn' --outpu text)
```

OIDC Issuer URL と Client ID を確認

```bash
$ aws iam get-open-id-connect-provider --open-id-connect-provider-arn $IDP_ARN
{
    "Url": "idp-xxxxx.s3.ap-northeast-1.amazonaws.com",
    "ClientIDList": [
        "sts.amazonaws.com"
    ],
    "ThumbprintList": [],
    "CreateDate": "2022-05-12T00:39:35.963000+00:00",
    "Tags": [
        {
            "Key": "kubernetes.io/cluster/my-cluster",
            "Value": "owned"
        },
        {
            "Key": "ydclab.com/cluster-name",
            "Value": "my-cluster"
        }
    ]
}
```

Kubernetes クラスターエンドポイントを確認

```bash
$ kubectl get ep kubernetes
NAME         ENDPOINTS         AGE
kubernetes   172.30.0.2:6443   113m
```

シェル変数を設定

```bash
$ . <(cat <<EOF
ACCOUNT_ID=$(aws sts get-caller-identity --query 'Account' --output text)
AWS_REGION=ap-northeast-1
CLUSTER_ENDPOINT="https://172.30.0.2:6443"
CLUSTER_NAME="my-cluster"
CONTROLLER_POLICY="KarpenterControllerPolicy-${CLUSTER_NAME}"
CONTROLLER_ROLE="${CLUSTER_NAME}-karpenter"
LAUNCH_TEMPLATE="KarpenterLaunchTemplate-${CLUSTER_NAME}"
K3S_TOKEN="**********************"
NODE_INSTANCE_PROFILE="KarpenterNodeInstanceProfile-${CLUSTER_NAME}"
NODE_ROLE="KarpenterNodeRole-${CLUSTER_NAME}"
NODE_SECURITY_GROUP_ID="sg-005c1b51b56e69a94"
OIDC_CLIENT_ID="sts.amazonaws.com"
OIDC_ISSUER_URI="idp-xxxxx.s3.ap-northeast-1.amazonaws.com"
SUBNET_ID="subnet-029e68efe9ca248d6"
EOF
)
```



## Terraform 適用

```bash
$ terraform init

$ terraform apply \
  -var="cluster_name=${CLUSTER_NAME}" \
  -var="aws_region=${AWS_REGION}" \
  -var="cluster_endpoint=${CLUSTER_ENDPOINT}" \
  -var="controller_policy_name=${CONTROLLER_POLICY}" \
  -var="controller_role_name=${CONTROLLER_ROLE}" \
  -var="k3s_token=${K3S_TOKEN}" \
  -var="launch_template_name=${LAUNCH_TEMPLATE}" \
  -var="node_instance_profile_name=${NODE_INSTANCE_PROFILE}" \
  -var="node_role_name=${NODE_ROLE}" \
  -var="node_security_group_id=${NODE_SECURITY_GROUP_ID}" \
  -var="oidc_provider_arn=${IDP_ARN}" \
  -var="oidc_issuer_uri=${OIDC_ISSUER_URI}" \
  -var="oidc_client_id=${OIDC_CLIENT_ID}"
```

## Terraform output 取得

Terraform output をシェル変数に設定

```bash
$ NODE_ROLE_ARN=$(terraform output -no-color -json node_role_arn | sed 's/^"\(.*\)"$/\1/')
$ CONTROLLER_ROLE_ARN=$(terraform output -no-color -json controller_role_arn | sed 's/^"\(.*\)"$/\1/')
$ SERVICE_ACCOUNT_ANNOTATION="eks\.amazonaws\.com/role-arn=${CONTROLLER_ROLE_ARN}"
```

## EC2 Spot Service Linked ロール作成
AWS CLI で EC2 Spot Service Linked ロールを作成
EC2 Spotを初めて使用する場合のみ必要です。

```bash
$ aws iam create-service-linked-role --aws-service-name spot.amazonaws.com
```

## Karpenter Helm Chart インストール

```bash
$ helm repo add karpenter https://charts.karpenter.sh/
$ helm repo update
$ helm upgrade karpenter karpenter/karpenter \
  --install --namespace karpenter \
  --create-namespace \
  --set serviceAccount.annotations."${SERVICE_ACCOUNT_ANNOTATION}" \
  --set clusterName="${CLUSTER_NAME}" \
  --set clusterEndpoint="${CLUSTER_ENDPOINT}" \
  --set aws.defaultInstanceProfile="${NODE_INSTANCE_PROFILE}" \
  --wait
```

## AWS IAM Authenticator に Karpenter Node ロール設定

```bash
$ cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
  namespace: kube-system
  name: aws-iam-authenticator
  labels:
    k8s-app: aws-iam-authenticator
data:
  config.yaml: |
    clusterID: ${CLUSTER_NAME}
    server:
      mapRoles:
      - roleARN: ${NODE_ROLE_ARN}
        username: system:node:{{EC2PrivateDNSName}}
        groups:
        - system:nodes
        - system:bootstrappers
EOF
```

## Subnet にタグ付与
Karpenter Node をアサインする Subnet に discovery タグを付与します。

```bash
$ aws ec2 create-tags --resources ${SUBNET_ID} \
  --tags Key=karpenter.sh/discovery,Value=${CLUSTER_NAME}
```

## Provisioner 作成
Karpenter の Provisioner にプロビジョニングの条件や制約等を指定します。

```bash
$ cat <<EOF | kubectl apply -f -
apiVersion: karpenter.sh/v1alpha5
kind: Provisioner
metadata:
  name: default
spec:
  requirements:
    - key: karpenter.sh/capacity-type
      operator: In
      values: ["spot"]
  taints:
    - key: app-type
      value: "batch"
      effect: NoSchedule
  limits:
    resources:
      cpu: 10
  provider:
    launchTemplate: "${LAUNCH_TEMPLATE}"
    subnetSelector:
      karpenter.sh/discovery: "${CLUSTER_NAME}"
  ttlSecondsAfterEmpty: 300
  ttlSecondsUntilExpired: 43200
EOF
```

# 動作確認
Karpenter Node に Pod がスケジュールされることを確認します。

ノードのキャパシティを確認

```bash
$ kubectl describe nodes k3s-server.my-cluster
```

実行結果（抜粋）

```yaml
Name:               k3s-server.my-cluster

Capacity:
  cpu:                8
  ephemeral-storage:  5765939832Ki
  hugepages-1Gi:      0
  hugepages-2Mi:      0
  memory:             32821752Ki
  pods:               110

Allocated resources:
  Resource           Requests     Limits
  --------           --------     ------
  cpu                1310m (16%)  1200m (15%)
  memory             1234Mi (3%)  1264Mi (3%)
  ephemeral-storage  0 (0%)       0 (0%)
  hugepages-1Gi      0 (0%)       0 (0%)
  hugepages-2Mi      0 (0%)       0 (0%)
```
この場合、ノードの Capacity は cpu 8 でそのうち 16% が要求（Allocated Requests）されているので空き cpu は 6 強。

1 Pod あたり 1 cpu を要求する Deployment を作成します。

deployment.yaml

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: inflate
spec:
  replicas: 0
  selector:
    matchLabels:
      app: inflate
  template:
    metadata:
      labels:
        app: inflate
    spec:
      terminationGracePeriodSeconds: 0
      containers:
        - name: inflate
          image: public.ecr.aws/eks-distro/kubernetes/pause:3.2
          resources:
            requests:
              cpu: 1
      tolerations:
      - key: "app-type"
        operator: "Equal"
        value: "batch"
        effect: "NoSchedule"
```

```bash
$ kubectl apply -f deployment.yaml
```

replicas を増やす。

```bash
$ kubectl scale deployment inflate --replicas 7
```
しばらくすると Kube クラスターに Karpenter Node が追加されます。

```bash
$ kubectl get nodes
k3s-server.my-cluster                            Ready    control-plane,master   3h9m   v1.22.8+k3s1
ip-10-0-101-74.ap-northeast-1.compute.internal   Ready    <none>                 114s   v1.23.6+k3s1
```

Karpenter Node 'ip-10-0-101-74.ap-northeast-1.compute.internal' で Pod が実行されました。

```bash
$ kubectl get po -o wide
NAME                       READY   STATUS    RESTARTS   AGE     IP           NODE                                             NOMINATED NODE   READINESS GATES
inflate-858875b86c-29pxc   1/1     Running   0          3m33s   10.42.0.16   k3s-server.my-cluster                            <none>           <none>
inflate-858875b86c-hrf7s   1/1     Running   0          3m33s   10.42.0.13   k3s-server.my-cluster                            <none>           <none>
inflate-858875b86c-bxbb2   1/1     Running   0          3m33s   10.42.0.11   k3s-server.my-cluster                            <none>           <none>
inflate-858875b86c-jpn85   1/1     Running   0          3m33s   10.42.0.15   k3s-server.my-cluster                            <none>           <none>
inflate-858875b86c-458l7   1/1     Running   0          3m33s   10.42.0.14   k3s-server.my-cluster                            <none>           <none>
inflate-858875b86c-rs6fc   1/1     Running   0          3m33s   10.42.0.12   k3s-server.my-cluster                            <none>           <none>
inflate-858875b86c-nj85x   1/1     Running   0          3m33s   10.42.1.2    ip-10-0-101-74.ap-northeast-1.compute.internal   <none>           <none>
```

replicas をゼロにすると

```bash
$ kubectl scale deployment inflate --replicas 0
```

およそ 300 秒後に Karpenter Node が削除されました。

```bash
$ kubectl get nodes
NAME                    STATUS   ROLES                  AGE     VERSION
k3s-server.my-cluster   Ready    control-plane,master   3h18m   v1.22.8+k3s1
```

素晴らしい！！

# 気になった点
設定を間違えプロビジョンされたインスタンスが Kube クラスターに参加し損ねた際、しばらくすると別の Karpenter Node がプロビジョンされました。それも参加に失敗します。しばらくすると更に別の Karpenter Node がプロビジョンされました。失敗が続くとインスタンスが増え続ける？ 要確認。

# 次の記事を参考にさせていただきました！
- [AWS OSS製の高速Cluster Autoscaler Karpenter](https://blog.recruit.co.jp/rmp/infrastructure/aws-oss-cluster-autoscaler-karpenter/)
- [karpenter/designs/aws/custom-user-data-and-amis.md](https://github.com/aws/karpenter/blob/4e6c86bb9e4dca2b0af27166f28ed3161b37141b/designs/aws/custom-user-data-and-amis.md)


# 2022/5/16 追記
ここでは、K3s 特有の方法で KarpenterNode をクラスターに参加させているため AWS IAM Authenticator for Kubernetes をセットアップしなくても動作しました。