Terraformを使ったEKS上のKarpenter IAM管理手順

Kubernetes の Node をオートスケールするツールとしては ClusterAutoscaler が一般的かと思います。昨今では Karpenter もオートスケーラーとして採用されて実際に利用されているケースもいくつか見られています。

今回は Karpenter の IAM 管理を Terraform で作成する手順をブログに残しておきたいと思います。

本記事では EKS 上で Karpenter を利用しており IRSA (IAM Role for ServiceAccount) を想定して作成します。 EKS には Pod Identity というサービスも登場しており今後はこちらが主流になると思いますが今回は IRSA を利用します。

前提

本記事では以下の前提準備があることを想定しています。

EKS Cluster の作成済み(記事執筆時点では1.27を利用) Terraform のインストール (v1.6.6) eksctl (0.167.0 ※ Terraform でクラスタ管理している場合は不要) helm

EKS クラスタの作成においては Karpenter の Tag が付与されていると後々楽ですので公式ドキュメントの内容を参考にクラスタの作成をすることをオススメします。

karpenter.sh

IAM Role の作成

早速 IAM Role の作成をします。 今回作成する IAM Role は2種類で Node 用の Role と ServiceAccount 用のロールを作成します。

まずは Node 用のロールを作成します。

data "aws_eks_cluster" "cluster" {
  name = "<cluster_name>"
}

data "aws_region" "tokyo" {
  name = "ap-northeast-1"
}

resource "aws_iam_role" "karpenter" {
  name               = "KarpenterNodeRole-${data.aws_eks_cluster.cluster.name}"
  assume_role_policy = data.aws_iam_policy_document.karpenter.json
}

data "aws_iam_policy_document" "karpenter" {
  statement {
    actions = [
      "sts:AssumeRole",
    ]

    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

resource "aws_iam_role_policy_attachment" "karpenter" {
  for_each = toset([
    "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy",
    "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy",
    "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly",
    "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
  ])
  role       = aws_iam_role.karpenter.name
  policy_arn = each.key
}

次に ServiceAccount 用のロールを作成します

data "aws_region" "tokyo" {
  name = "ap-northeast-1"
}

variable "oidc_endpoint" {
  type    = string
  default = "<oide_token_url>"
}

resource "aws_iam_role" "karpenter_cluster_controller" {
  name               = "karpenter-cluster-controller"
  assume_role_policy = data.aws_iam_policy_document.karpenter_cluster_controller_assume.json
}

data "aws_iam_policy_document" "karpenter_cluster_controller_assume" {
  statement {
    effect = "Allow"
    principals {
      type        = "Federated"
      identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/${var.oidc_endpoint}"]
    }

    actions = [
      "sts:AssumeRoleWithWebIdentity",
    ]

    condition {
      test     = "StringEquals"
      values   = ["system:serviceaccount:kube-system:karpenter"]
      variable = "${var.oidc_endpoint}:sub"
    }

    condition {
      test     = "StringEquals"
      values   = ["sts.amazonaws.com"]
      variable = "${var.oidc_endpoint}:aud"
    }


  }
}

resource "aws_iam_role_policy" "karpernter_cluster_controller_policy" {
  name   = "karpenter-cluster-controller-policy"
  policy = data.aws_iam_policy_document.karpenter_cluster_controller_policy.json
  role   = aws_iam_role.karpenter_cluster_controller.name
}

data "aws_iam_policy_document" "karpenter_cluster_controller_policy" {
  statement {
    actions = [
      "ssm:GetParameter",
      "ec2:DescribeImages",
      "ec2:RunInstances",
      "ec2:DescribeSubnets",
      "ec2:DescribeSecurityGroups",
      "ec2:DescribeLaunchTemplates",
      "ec2:DescribeInstances",
      "ec2:DescribeInstanceTypes",
      "ec2:DescribeInstanceTypeOfferings",
      "ec2:DescribeAvailabilityZones",
      "ec2:DeleteLaunchTemplate",
      "ec2:CreateTags",
      "ec2:CreateLaunchTemplate",
      "ec2:CreateFleet",
      "ec2:DescribeSpotPriceHistory",
      "pricing:GetProducts"
    ]
    effect = "Allow"
    resources = [
      "*"
    ]
    sid = "Karpenter"
  }
  statement {
    effect = "Allow"
    actions = [
      "ec2:TerminateInstances"
    ]
    resources = [
      "*"
    ]
    condition {
      test     = "StringLike"
      values   = ["*"]
      variable = "ec2:ResourceTag/karpenter.sh/nodepool"
    }
  }

  statement {
    effect = "Allow"
    actions = [
      "iam:PassRole"
    ]
    resources = [
      aws_iam_role.karpenter.arn
    ]
    sid = "PassNodeIAMRole"
  }

  statement {
    effect = "Allow"
    actions = [
      "eks:DescribeCluster"
    ]
    resources = [
      "arn:aws:eks:${data.aws_region.tokyo.name}:${data.aws_caller_identity.current.account_id}:cluster/${data.aws_eks_cluster.yohira_test_cluster.name}"
    ]
    sid = "EKSClusterEndpointLookup"
  }

  statement {
    effect = "Allow"
    actions = [
      "iam:CreateInstanceProfile",
    ]
    resources = [
      "*"
    ]
    condition {
      test     = "StringEquals"
      values   = [data.aws_region.tokyo.name]
      variable = "aws:RequestTag/topology.kubernetes.io/region"
    }
    condition {
      test     = "StringEquals"
      values   = ["owned"]
      variable = "aws:RequestTag/kubernetes.io/cluster/${data.aws_eks_cluster.cluster.name}"
    }
    condition {
      test     = "StringLike"
      values   = ["*"]
      variable = "aws:RequestTag/karpenter.k8s.aws/ec2nodeclass"
    }
    sid = "AllowScopedInstanceProfileCreationActions"
  }

  statement {
    effect = "Allow"
    actions = [
      "iam:TagInstanceProfile",
    ]
    resources = [
      "*"
    ]
    condition {
      test     = "StringEquals"
      values   = [data.aws_region.tokyo.name]
      variable = "aws:RequestTag/topology.kubernetes.io/region"
    }
    condition {
      test     = "StringEquals"
      values   = ["owned"]
      variable = "aws:RequestTag/kubernetes.io/cluster/${data.aws_eks_cluster.cluster.name}"
    }
    condition {
      test     = "StringLike"
      values   = ["owned"]
      variable = "aws:ResourceTag/kubernetes.io/cluster/${data.aws_eks_cluster.cluster.name}"
    }
    condition {
      test     = "StringLike"
      values   = [data.aws_region.tokyo.name]
      variable = "aws:ResourceTag/topology.kubernetes.io/region"
    }
  }

  statement {
    effect = "Allow"
    resources = [
      "*"
    ]
    actions = [
      "iam:AddRoleToInstanceProfile",
      "iam:RemoveRoleFromInstanceProfile",
      "iam:DeleteInstanceProfil"
    ]
    condition {
      test     = "StringEquals"
      values   = ["owned"]
      variable = "aws:ResourceTag/kubernetes.io/cluster/${data.aws_eks_cluster.cluster.name}"
    }
    condition {
      test     = "StringEquals"
      values   = [data.aws_region.tokyo.name]
      variable = "aws:ResourceTag/topology.kubernetes.io/region"
    }
    condition {
      test     = "StringLike"
      values   = ["*"]
      variable = "aws:ResourceTag/karpenter.k8s.aws/ec2nodeclass"
    }
  }

  statement {
    effect = "Allow"
    resources = [
      "*"
    ]
    actions = [
      "iam:GetInstanceProfile"
    ]
  }
}

もう少し綺麗に書ける箇所はあるのですが一旦このような形にしました。 oidc の URL は aws_eks_cluster の data リソースからも引っ張ってくることが出来るのですが https 形式になっており substr などを使えば上手に使えそうでしたが今回はそのまま直接変数としました。

このまま terraform apply で適用させていきます。 terraform apply が問題なく完了すれば次のステップに進みます。

aws-auth の更新

aws-auth に先程作成した Node 用の IAM Role を適用させます。

#aws-auth.yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: aws-auth
  namespace: kube-system
data:
  mapRoles: |
   .....
   #以下を追加
    - rolearn: <Node 用の Role で作成した IAM Role の arn>
      username: system:node:{{EC2PrivateDNSName}}
      groups:
      - system:bootstrappers
      - system:nodes

Karpenter のデプロイ

Karpenter のデプロイをします。 Helm でも素のマニフェストでも問題有りません。

Helm の場合

# Logout of helm registry to perform an unauthenticated pull against the public ECR
helm registry logout public.ecr.aws

export KARPENTER_VERSION=v0.33.1
export KARPENTER_NAMESPACE=kube-system
export CLUSTER_NAME=<your cluster name>
export AWS_ACCOUNT_ID=<your aws account id>

helm upgrade --install karpenter oci://public.ecr.aws/karpenter/karpenter --version "${KARPENTER_VERSION}" --namespace "${KARPENTER_NAMESPACE}" --create-namespace \
  --set "settings.clusterName=${CLUSTER_NAME}" \
  --set "settings.interruptionQueue=${CLUSTER_NAME}" \
  --set controller.resources.requests.cpu=1 \
  --set controller.resources.requests.memory=1Gi \
  --set controller.resources.limits.cpu=1 \
  --set controller.resources.limits.memory=1Gi \
  --wait

マニフェストの場合

export KARPENTER_VERSION=v0.33.1
export KARPENTER_NAMESPACE=kube-system
export CLUSTER_NAME=<your cluster name>
export AWS_ACCOUNT_ID=<your aws account id>

helm template karpenter oci://public.ecr.aws/karpenter/karpenter --version "${KARPENTER_VERSION}" --namespace "${KARPENTER_NAMESPACE}" \
    --set "settings.clusterName=${CLUSTER_NAME}" \
    --set "serviceAccount.annotations.eks\.amazonaws\.com/role-arn=arn:aws:iam::${AWS_ACCOUNT_ID}:role/KarpenterControllerRole-${CLUSTER_NAME}" \
    --set controller.resources.requests.cpu=1 \
    --set controller.resources.requests.memory=1Gi \
    --set controller.resources.limits.cpu=1 \
    --set controller.resources.limits.memory=1Gi > karpenter.yaml

必要に応じて nodeAffnity を設定する

affinity:
  nodeAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      nodeSelectorTerms:
      - matchExpressions:
        - key: karpenter.sh/nodepool
          operator: DoesNotExist
      - matchExpressions:
        - key: eks.amazonaws.com/nodegroup
          operator: In
          values:
          - <your node group name>
  podAntiAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      - topologyKey: "kubernetes.io/hostname"

Karpenter をデプロイします。

kubectl create -f \
    https://raw.githubusercontent.com/aws/karpenter-provider-aws/${KARPENTER_VERSION}/pkg/apis/crds/karpenter.sh_nodepools.yaml
kubectl create -f \
    https://raw.githubusercontent.com/aws/karpenter-provider-aws/${KARPENTER_VERSION}/pkg/apis/crds/karpenter.k8s.aws_ec2nodeclasses.yaml
kubectl create -f \
    https://raw.githubusercontent.com/aws/karpenter-provider-aws/${KARPENTER_VERSION}/pkg/apis/crds/karpenter.sh_nodeclaims.yaml
kubectl apply -f karpenter.yaml

これで Karpenter のデプロイは完了です。 NodePool や EC2NodeClass のリソースをデプロイして実際に動作を確認して問題なくノードのスケールなどができれば成功です。

NodePool の作成などは以下のドキュメント参考に実施してみてください。

karpenter.sh

終わりに

以上が Karpenter の IAM を Terraform で管理する方法です。 Karpenter 自体も Terraform などで管理することでより簡単に利用ができそうなので機会があれば試してみたいと思います。