CDK で EKS クラスタに Karpenter をインストールし柔軟で高速なオートスケールを行う

awskubernetes

Karpenter は AWS によって開発されている Kubernetes クラスタのノードを増減させる OSS。 標準の Cluster Autoscaler と比べて Auto Scaling Group を介さない柔軟で高速なプロビジョニングを行うことができる。 現状は EKS にしか対応していないが、他のクラウドについても対応できるようになっているらしい。

(追記: 2023-12-06) AKS にも対応した

今回は CDK で EKS クラスタに Karpenter をインストールし replica 数を増減したときにクラスタがオートスケールすることを確認する。 全体のコードは GitHub にある。

Karpenter が用いる AWS リソースの作成

Karpenter が用いる Role や、スポットインスタンスの中断といった Event を受け取る SQS などのリソースを作成する CFn のテンプレートが 提供されているので、これを NestedStack として含める。

class KapenterResourcesStack extends cdk.NestedStack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps & {karpenterVersion: string, clusterName: string}) {
    super(scope, id, props);

    new cfninc.CfnInclude(this, 'KarpenterResources', {
      templateFile: `karpenter_${props?.karpenterVersion}.yaml`,
      parameters: {
        "ClusterName": props?.clusterName
      }
    });
  }
}

NodeRole の紐付け

上のテンプレートで作られた NodeRole をノードに紐づけるため ConfigMap aws-auth を更新する。

awsAuth.addRoleMapping(nodeRole, {
  groups: ["system:bootstrappers", "system:nodes"],
  username: "system:node:{{EC2PrivateDNSName}}"
})

ちなみに同じ Role に対して複数の mapping を作成すると上書きされてしまうようなので注意が必要。 これによって権限が足りなくなることで kubernetes.io/kubelet-serving の CertificateSigningRequest が Pending になり Kubelet が機能しなくなるといった問題が起きうる。

$ journalctl -u kubelet
handshake error from x.x.x.x:x no serving certificate available for the kubelet

ControllerRole の作成

この後 Helm Chart の方で作られる ServiceAccount karpenter が OIDC による認証で Assume できる IAM Role を作成する。 eksctl create cluster で渡される ClusterConfig の iam の部分に相当する。

const controllerRole = new iam.Role(this, 'KarpenterControllerRole', {
  roleName: `${cluster.clusterName}-karpenter`,
  path: "/",
  assumedBy: new iam.WebIdentityPrincipal(
    cluster.openIdConnectProvider.openIdConnectProviderArn,
    {
      // delay resolution to deployment-time to use tokens in object keys
      "StringEquals": new cdk.CfnJson(this, 'KarpenterControllerRoleStringEquals', { value: {
          [`${cluster.clusterOpenIdConnectIssuer}:aud`]: "sts.amazonaws.com",
          [`${cluster.clusterOpenIdConnectIssuer}:sub`]: "system:serviceaccount:karpenter:karpenter"
        }})
    }
  ),
  managedPolicies: [controllerPolicy]
})

Karpenter のインストール

Helm Chart で Karpenter をインストールする。 ControllerRole の Principal が間違っていて AssumeRole できなかったりすると更新に失敗してロールバックしてしまうので、 最初は wait: false で様子を見てみると良い。

cluster.addHelmChart('karpenter', {
  chart: 'karpenter',
  repository: 'oci://public.ecr.aws/karpenter/karpenter',
  version: karpenterVersion,
  namespace: 'karpenter',
  createNamespace: true,
  values: {
    serviceAccount: {
      name: 'karpenter', // added
      annotations: {
        "eks.amazonaws.com/role-arn": controllerRole.roleArn,
      }
    },
    settings: {
      clusterName: cluster.clusterName,
      interruptionQueue: interruptionQueueName
    },
    controller: {
      resources: {
        requests: {
          cpu: 1,
          memory: "1Gi"
        },
        limits: {
          cpu: 1,
          memory: "1Gi"
        }
      }
    }
  },
  wait: true
})

NodePoolsEC2NodeClasses の設定

プロビジョニングするインスタンスの設定を行う。

(追記: 2023-12-06) v0.32.0 で v1beta1 api が追加され、旧来の Provisioner や AWSNodeTemplate は非推奨になった。 併せて各種 AWS リソースのタグも変更されたため Policy の condition も修正されている。

cluster.addManifest('DefaultProvisionerAndNodeTemplate',  {
  "apiVersion": "karpenter.k8s.aws/v1beta1",
  "kind": "EC2NodeClass",
  "metadata": {
    "name": "default"
  },
  "spec": {
    "amiFamily": "AL2",
    "role": `KarpenterNodeRole-${cluster.clusterName}`,
    "subnetSelectorTerms": [{
      "tags": {
        "Name": cluster.vpc.publicSubnets.join(",")
      }
    }],
    "securityGroupSelectorTerms": [{
      "id": cluster.clusterSecurityGroup.securityGroupId
    }],

    // optional
    "blockDeviceMappings": [{
      "deviceName": "/dev/xvda",
      "ebs": {
        "volumeSize": "100Gi",
        "volumeType": "gp3",
        "deleteOnTermination": true,
      }
    }]
  }
}, {
  "apiVersion": "karpenter.sh/v1beta1",
  "kind": "NodePool",
  "metadata": {
    "name": "default"
  },
  "spec": {
    "template": {
      "spec": {
        "nodeClassRef": {
          "name": "default"
        },
        "requirements": [{
          "key": "karpenter.sh/capacity-type",
          "operator": "In",
          "values": ["spot"]
        }],
      }
    },
    "disruption": {
      "consolidationPolicy": "WhenUnderutilized"
    },
    "limits": {
      "cpu": "1000"
    }
  }
})

動作確認

deployment の replica 数を増やすと NodePool の requirements 通りスポットインスタンスが立ち上がり、削除するとシャットダウンした。

$ kubectl scale deployment inflate --replicas 5
$ kubectl logs -f -n karpenter -l app.kubernetes.io/name=karpenter -c controller
...
...,"message":"found provisionable pod(s)",...
...,"message":"computed new nodeclaim(s) to fit pod(s)",...
...,"message":"created nodeclaim",...
...,"message":"created launch template",...
...,"message":"launched nodeclaim",...
...,"message":"discovered subnets",...
...,"message":"registered nodeclaim",...
...,"message":"initialized nodeclaim",...

ノードには次のような Label が付けられ、nodeSelector でこれらを指定することで条件に合う最も安いノードに Pod をスケジュールできる。

karpenter.k8s.aws/instance-category=r
karpenter.k8s.aws/instance-cpu=16
karpenter.k8s.aws/instance-encryption-in-transit-supported=false
karpenter.k8s.aws/instance-family=r6gd
karpenter.k8s.aws/instance-generation=6
...

Pod の Disruption

karpenter.sh/do-not-disrupt: “true” annotation を付けることで、 実行中の Pod が Consolidation によって Disrupt されて他のノードに移動されることを防ぐことができる。Consolidation は Karpenter のログや kubectl get event で確認できる。

ただし、この設定を行うことでインスタンスの寿命が伸びてスポットインスタンスの中断に遭遇する機会が増える可能性がある。2 分前の中断通知 は Reconcile 時に interruptionQueue から 取得されハンドリングされるわけだが、その際の Disruption は避けられず時間制限もある。もしキャパシティに不安があるなら do-not-disrupt よりは PodDisruptionBudget で minAvailable/maxUnavailable を設定することで Disruption 時にも最低限必要なリソースが担保されるようにするべきだと考える。

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: testapp-pdb
spec:
  minAvailable: 90%
  unhealthyPodEvictionPolicy: AlwaysAllow
  selector:
    matchLabels:
      app: testapp

unhealthyPodEvictionPolicy のデフォルトは IfHealthyBudget で、Healthy でない Pod は PDB の desiredHealthy を満たしている場合のみ Evict される。これを AlwaysAllow にすると無条件に Evict され得るようになり、半永久的に Healthy にならない Pod を無視して Drain することができるようになる。