見出し画像

Karpenter への期待

この記事は、Showcase Gig Advent Calendar 2021 11日目の記事です。

AWS re:Invent 2021 で発表のあった open-source Kubernetes cluster autoscaling project である Karpenter の紹介となります。

また、一部 HCL や yaml で書かれたコードが記載されていますが、動作を補償するものではないので、あしからず。


Showcase Gig SRE チームの 内海 (Chiguhagu) です。

主に O:der Platform (こちらの詳細は 2日目の記事 にて)の新規開発に携わっています。

「会社で Advent Calendar やるんだぁ。すごいなぁ、じゃっ、頑張って!」と静観を決め込もうとしたら、巻き込まれました。

「内海さんはk8sとかでネタありませんか?(ありますよね」 ~ 社内Slack からの引用 ~

「はい、書きます...」

前書き

突然ですが、みなさんは Managed Kubernetes Cluster のオートスケール、どうやって実現していますか?

そうですよね、やはり有名どころでは Cluster Autoscaler が挙げられますね。

弊社でも前述の Cluster Autoscaler を使用し、クラスタのスケーリングをコントロールしています。

これはクラスタに対し Pod を作成したものの「リソース不足で Pod がスケジュールされない!」といった際、

自動で Node をスケールアウトしてくれる Addon となっています。(もちろん、逆のスケールインも勝手に実行してくれます。)

Karpenter の登場

ところが先日、 AWS re:Invent にて Karpenter という同じくクラスタのスケーリングをコントロールしてくれる Addon が GA しました。

「わざわざ Cluster Autoscaler があるのに発表されたという事は、何かしら優位性があるに違いない...」

と、ドキュメントを読もうとしたところ、開発者の方が比較動画をアップロードしていたので、そちらを先行して視聴。

ザックリと以下のようなことを言っているようでした。

  • ワークロードに適したインスタンスを追加、スケジューリングしてくれる。

  • 必要に応じて、Node のサイズ、個数を最適化(さらには、Podの再スケジュールまで)してくれる。

うん、楽しそう。ドキュメント読んで、実際に試してみよう!

ちなみに、こちら が公式のドキュメント

Just-in-time Nodes for Any Kubernetes Cluster

だそうです。Just-in-time って表現が気になりますね。


Karpenter を試してみよう (準備編)

ちゃんと 実行編 もあります。

公式のドキュメントに Getting Started があったので、そちらを見つつ Karpenter を調理していきます。

ドキュメント上では cloudformation や eksctl コマンドを使用し環境の初期構築をしています。

が、私自身、上記のサービスやツールを使用することを禁じられた家系の出身のため、今回はそれらを Terraform と manifest に置き換えて、環境を構築していきます。

ここは自己満なところがあるので、読み飛ばしてかまいません。

EKS Cluster の準備

大部分は割愛します。

OIDC 周りの設定は、主題からそれてしまいますが、書いておきます。

あとあと使うので。

data "tls_certificate" "main" {
  url = aws_eks_cluster.eks_cluster.identity[0].oidc[0].issuer
}

resource "aws_iam_openid_connect_provider" "main" {
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = [data.tls_certificate.main.certificates[0].sha1_fingerprint]
  url             = aws_eks_cluster.eks_cluster.identity[0].oidc[0].issuer
}

cloudformation の分解

次に、ドキュメントに書かれている cloudformation を分解し、Terraform で書き直します。

ここでは3つのリソースを作っているようです。

  1. Karpenter が作成した EC2 リソースの使用する Instance Profile (KarpenterNodeInstanceProfile)

  2. 1 の Profile で使用する IAM Policy

  3. EKS 上に展開された Karpenter が AWS の操作をする際に必要な Policy

はい、これなら簡単ですね。

data "aws_iam_policy_document" "karpenter" {
  statement {
    actions = [
      # Write Operations
      "ec2:CreateLaunchTemplate",
      "ec2:CreateFleet",
      "ec2:RunInstances",
      "ec2:CreateTags",
      "iam:PassRole",
      "ec2:TerminateInstances",
      # Read Operations
      "ec2:DescribeLaunchTemplates",
      "ec2:DescribeInstances",
      "ec2:DescribeSecurityGroups",
      "ec2:DescribeSubnets",
      "ec2:DescribeInstanceTypes",
      "ec2:DescribeInstanceTypeOfferings",
      "ec2:DescribeAvailabilityZones",
      "ssm:GetParameter",
    ]
    resources = ["*"]
    effect = "Allow"
  }
}

resource "aws_iam_policy" "karpenter" {
  name   = "KarpenterControllerPolicy"
  policy = data.aws_iam_policy_document.karpenter.json
}

data "aws_iam_policy_document" "karpenter_node_assume" {
  statement {
    actions = ["sts:AssumeRole"]
    effect  = "Allow"
    principals {
      identifiers = ["ec2.amazonaws.com"]
      type        = "Service"
    }
  }
}

resource "aws_iam_role" "karpenter_node" {
  name               = "KarpenterNodeRole"
  assume_role_policy = data.aws_iam_policy_document.karpenter_node_assume.json
}

resource "aws_iam_role_policy_attachment" "karpenter_node_eks_worker_node" {
  role       = aws_iam_role.karpenter_node.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
}

resource "aws_iam_role_policy_attachment" "karpenter_node_eks_cni" {
  role       = aws_iam_role.karpenter_node.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
}

resource "aws_iam_role_policy_attachment" "karpenter_node_ec2_container_registry_read_only" {
  role       = aws_iam_role.karpenter_node.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
}

resource "aws_iam_role_policy_attachment" "karpenter_node_ssm_managed_instance_core" {
  role       = aws_iam_role.karpenter_node.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "karpenter_node" {
  name = "KarpenterNodeInstanceProfile"
  role = aws_iam_role.karpenter_node.name
}

aws-auth ファイルへの追記

こちらは ↑ のドキュメント節の後半部分ですね。

Karpenter が作成した Node がクラスタに参加できるように Role レベルで許可を出してあげます。

最終的には下記のような manifest がクラスタに適用されれば OK です。

apiVersion: v1
kind: ConfigMap
metadata:
  name: aws-auth
  namespace: kube-system
data:
  mapRoles: |
    - rolearn: arn:aws:iam::xxxxxxxxxxxx:role/${EKS_YOUR_WORKER_ROLE}
      username: system:node:{{EC2PrivateDNSName}}
      groups:
        - system:bootstrappers
        - system:nodes
    - rolearn: arn:aws:iam::xxxxxxxxxxxx:role/KarpenterNodeRole
      username: system:node:{{EC2PrivateDNSName}}
      groups:
        - system:bootstrappers
        - system:nodes

今回でいう 2 つ目の role の権限ですね。

OIDC Role の作成と Service Account の作成

ドキュメント的には こちら

AWS の IAM Role リソースと kubernetes クラスタ上の Service Account リソースがお互い依存関係にあるため、ちょっと面倒なところです。

事前にルールを決めておけば、別に困る事はないんですけどね。

こちらが Terraform に変換したもの。

data "aws_iam_policy_document" "karpenter_trust_relationships" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    effect  = "Allow"

    condition {
      test     = "StringEquals"
      variable = "${replace(aws_iam_openid_connect_provider.main.url, "https://", "")}:sub"
      values   = ["system:serviceaccount:karpenter:karpenter"]
    }

    principals {
      identifiers = [aws_iam_openid_connect_provider.main.arn]
      type        = "Federated"
    }
  }
}

resource "aws_iam_role" "karpenter" {
  name               = "OidcKarpenterRole"
  assume_role_policy = data.aws_iam_policy_document.karpenter_trust_relationships.json
}

resource "aws_iam_role_policy_attachment" "karpenter" {
  role       = aws_iam_role.karpenter.name
  policy_arn = aws_iam_policy.karpenter.arn
}

EKS クラスタを作成するところで書いた OIDC を有効にするリソースの情報を使用しています。

  • aws_iam_openid_connect_provider.main.url

  • aws_iam_openid_connect_provider.main.arn

そして、今度は Service Account のマニフェスト。namespace と service account 名はドキュメントから拝借しちゃいましょ。

apiVersion: v1
kind: Namespace
metadata:
  name: karpenter
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: karpenter
  namespace: karpenter
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::xxxxxxxxxxxx:role/OidcKarpenterRole

spot インスタンスの有効化

割愛!すでにやってあったので!

Karpenter のインストール

普段は Helm とか使わないのですが、禁じられているわけではないので、今回は使います。

Service Account の準備は終わっているので、ドキュメント 通りにコマンドを実行し、マニフェストを適用しましょう。

そうして、できあがったリソースがこちらです。

% k get po -n karpenter
NAME                                    READY   STATUS    RESTARTS   AGE
karpenter-controller-59f6dcdfdb-dkqsq   1/1     Running   0          30s
karpenter-webhook-6c8bdff8bf-nmv4l      1/1     Running   0          30s

生まれたての Pod です。かわいいですね。

ちなみに Node はこんな感じ、Karpenter とデフォルトで展開される Addon 達が動いています。

% k get no
NAME                                               STATUS    ROLES    AGE   VERSION
ip-172-31-43-248.ap-northeast-1.compute.internal   Ready     <none>   2m   v1.21.5-eks-bc4871b

Karpenter のログレベルの変更

ドキュメント ではログレベルの変更の仕方も書いてありました。

変更前に kubectl describe cm で Config Map の中身をのぞいて見ましょう!

Data
====
zap-logger-config:
----
{
  "level": "info",
  "development": false,
  "disableStacktrace": true,
  "disableCaller": true,
  "sampling": {
    "initial": 100,
    "thereafter": 100
  },
  "outputPaths": ["stdout"],
  "errorOutputPaths": ["stderr"],
  "encoding": "console",
  "encoderConfig": {
    "timeKey": "time",
    "levelKey": "level",
    "nameKey": "logger",
    "callerKey": "caller",
    "messageKey": "message",
    "stacktraceKey": "stacktrace",
    "levelEncoder": "capital",
    "timeEncoder": "iso8601"
  }
}

へぇ、zap 使ってんだ。

(zap = Go 言語のメジャーな構造化ログライブラリ。弊社ではまだ logrus が優勢です。)

debug に変更することで詳細のログが確認可能となります。

Karpenter の Custom Resource を準備

ドキュメントでは ここ

spec.provider.instanceProfile パラメータだけ、先程 Terraform で作成したものに置き換えるのを忘れないでください。

apiVersion: karpenter.sh/v1alpha5
kind: Provisioner
metadata:
  name: default
spec:
  requirements:
    - key: karpenter.sh/capacity-type
      operator: In
      values: ["spot"]
  limits:
    resources:
      cpu: 1000
  provider:
    instanceProfile: KarpenterNodeInstanceProfile
  ttlSecondsAfterEmpty: 30

こちらを適用すれば、環境構築は完了です。

Karpenter を試してみよう (実行編)

ここまでで、最低限 Karpenter へ入門できる環境の構築が完了しました。

あとは 残りのドキュメント通り に進めて、最後に terraform destroy するだけです。

deployment リソースの適用

サンプルとして何もしない Pod を管理する Deployment リソースを追加します。

そして、replica 数を 0 -> 5 に変更。 手順は ドキュメント通り ですので割愛します。

コア数を 1 消費する Pod ですので、ドキュメント通りに準備していれば、リソース不足になるはず。

クラスタへの適用後、Node を確認すると...

% k get no
NAME                                               STATUS    ROLES    AGE   VERSION
ip-172-31-23-133.ap-northeast-1.compute.internal   Unknown   <none>   20s   
ip-172-31-43-248.ap-northeast-1.compute.internal   Ready     <none>   38m   v1.21.5-eks-bc4871b

ip-172-31-23-133.ap-northeast-1.compute.internal Unknown '<none'> 20s

えっ、誰? 知らない人(Node)がいるよぉ。

先程、 kubectl get node で確認した時には存在していない Node が新規作成されていました。

Pod のログも見てみましょう。

% kubectl logs -f -n karpenter $(kubectl get pods -n karpenter -l karpenter=controller -o name)
2021-12-09T09:33:24.688Z        INFO    Successfully created the logger.
2021-12-09T09:33:24.688Z        INFO    Logging level set to: info
{"level":"info","ts":1639042404.797704,"logger":"fallback","caller":"injection/injection.go:61","msg":"Starting informers..."}
2021-12-09T09:33:24.820Z        INFO    controller      starting metrics server {"commit": "6984094", "path": "/metrics"}
I1209 09:33:24.819887       1 leaderelection.go:243] attempting to acquire leader lease karpenter/karpenter-leader-election...
I1209 09:33:24.861045       1 leaderelection.go:253] successfully acquired lease karpenter/karpenter-leader-election
2021-12-09T09:33:24.920Z        INFO    controller.controller.provisioning      Starting EventSource    {"commit": "6984094", "reconciler group": "karpenter.sh", "reconciler kind": "Provisioner", "source": "kind source: /, Kind="}
2021-12-09T09:33:24.920Z        INFO    controller.controller.provisioning      Starting Controller     {"commit": "6984094", "reconciler group": "karpenter.sh", "reconciler kind": "Provisioner"}
2021-12-09T09:33:24.921Z        INFO    controller.controller.termination       Starting EventSource    {"commit": "6984094", "reconciler group": "", "reconciler kind": "Node", "source": "kind source: /, Kind="}
2021-12-09T09:33:24.921Z        INFO    controller.controller.termination       Starting Controller     {"commit": "6984094", "reconciler group": "", "reconciler kind": "Node"}
2021-12-09T09:33:24.921Z        INFO    controller.controller.node      Starting EventSource    {"commit": "6984094", "reconciler group": "", "reconciler kind": "Node", "source": "kind source: /, Kind="}
2021-12-09T09:33:24.921Z        INFO    controller.controller.node      Starting EventSource    {"commit": "6984094", "reconciler group": "", "reconciler kind": "Node", "source": "kind source: /, Kind="}
2021-12-09T09:33:24.921Z        INFO    controller.controller.node      Starting EventSource    {"commit": "6984094", "reconciler group": "", "reconciler kind": "Node", "source": "kind source: /, Kind="}
2021-12-09T09:33:24.921Z        INFO    controller.controller.node      Starting Controller     {"commit": "6984094", "reconciler group": "", "reconciler kind": "Node"}
2021-12-09T09:33:24.921Z        INFO    controller.controller.metrics   Starting EventSource    {"commit": "6984094", "reconciler group": "karpenter.sh", "reconciler kind": "Provisioner", "source": "kind source: /, Kind="}
2021-12-09T09:33:24.921Z        INFO    controller.controller.metrics   Starting Controller     {"commit": "6984094", "reconciler group": "karpenter.sh", "reconciler kind": "Provisioner"}
2021-12-09T09:33:24.922Z        INFO    controller.controller.counter   Starting EventSource    {"commit": "6984094", "reconciler group": "karpenter.sh", "reconciler kind": "Provisioner", "source": "kind source: /, Kind="}
2021-12-09T09:33:24.922Z        INFO    controller.controller.counter   Starting EventSource    {"commit": "6984094", "reconciler group": "karpenter.sh", "reconciler kind": "Provisioner", "source": "kind source: /, Kind="}
2021-12-09T09:33:24.922Z        INFO    controller.controller.counter   Starting Controller     {"commit": "6984094", "reconciler group": "karpenter.sh", "reconciler kind": "Provisioner"}
2021-12-09T09:33:25.041Z        INFO    controller.controller.metrics   Starting workers        {"commit": "6984094", "reconciler group": "karpenter.sh", "reconciler kind": "Provisioner", "worker count": 10}
2021-12-09T09:33:25.041Z        INFO    controller.controller.termination       Starting workers        {"commit": "6984094", "reconciler group": "", "reconciler kind": "Node", "worker count": 10}
2021-12-09T09:33:25.041Z        INFO    controller.controller.node      Starting workers        {"commit": "6984094", "reconciler group": "", "reconciler kind": "Node", "worker count": 10}
2021-12-09T09:33:25.041Z        INFO    controller.controller.counter   Starting workers        {"commit": "6984094", "reconciler group": "karpenter.sh", "reconciler kind": "Provisioner", "worker count": 10}
2021-12-09T09:33:25.041Z        INFO    controller.controller.provisioning      Starting workers        {"commit": "6984094", "reconciler group": "karpenter.sh", "reconciler kind": "Provisioner", "worker count": 10}
2021-12-09T09:38:09.855Z        INFO    controller.provisioning Starting provisioner    {"commit": "6984094", "provisioner": "default"}
2021-12-09T09:38:09.855Z        INFO    controller.provisioning Waiting for unschedulable pods  {"commit": "6984094", "provisioner": "default"}
2021-12-09T09:41:18.608Z        INFO    controller.provisioning Batched 5 pods in 1.076234859s  {"commit": "6984094", "provisioner": "default"}
2021-12-09T09:41:18.728Z        INFO    controller.provisioning Computed packing of 1 node(s) for 5 pod(s) with instance type option(s) [c1.xlarge c3.2xlarge c4.2xlarge c5d.2xlarge c5.2xlarge c5a.2xlarge c5n.2xlarge m3.2xlarge t3.2xlarge m4.2xlarge m5.2xlarge m5zn.2xlarge t3a.2xlarge m5d.2xlarge m5a.2xlarge m6i.2xlarge m5n.2xlarge m5dn.2xlarge m5ad.2xlarge c3.4xlarge]    {"commit": "6984094", "provisioner": "default"}
2021-12-09T09:41:21.608Z        INFO    controller.provisioning Launched instance: i-0f7a1b39577ba24d5, hostname: ip-172-31-23-133.ap-northeast-1.compute.internal, type: t3a.2xlarge, zone: ap-northeast-1d, capacityType: spot   {"commit": "6984094", "provisioner": "default"}
2021-12-09T09:41:21.639Z        INFO    controller.provisioning Bound 5 pod(s) to node ip-172-31-23-133.ap-northeast-1.compute.internal {"commit": "6984094", "provisioner": "default"}
2021-12-09T09:41:21.640Z        INFO    controller.provisioning Waiting for unschedulable pods  {"commit": "6984094", "provisioner": "default"}

ふむふむ、最後のこのあたりでスケジュールされていない Pod を確認し、新規で Node (EC2) インスタンスを構築しているみたいですね。

2021-12-09T09:38:09.855Z        INFO    controller.provisioning Waiting for unschedulable pods  {"commit": "6984094", "provisioner": "default"}  
2021-12-09T09:41:18.608Z        INFO    controller.provisioning Batched 5 pods in 1.076234859s  {"commit": "6984094", "provisioner": "default"}  
2021-12-09T09:41:18.728Z        INFO    controller.provisioning Computed packing of 1 node(s) for 5 pod(s) with instance type option(s) [c1.xlarge c3.2xlarge c4.2xlarge c5d.2xlarge c5.2xlarge c5a.2xlarge c5n.2xlarge m3.2xlarge t3.2xlarge m4.2xlarge m5.2xlarge m5zn.2xlarge t3a.2xlarge m5d.2xlarge m5a.2xlarge m6i.2xlarge m5n.2xlarge m5dn.2xlarge m5ad.2xlarge c3.4xlarge]    {"commit": "6984094", "provisioner": "default"}  
2021-12-09T09:41:21.608Z        INFO    controller.provisioning Launched instance: i-0f7a1b39577ba24d5, hostname: ip-172-31-23-133.ap-northeast-1.compute.internal, type: t3a.2xlarge, zone: ap-northeast-1d, capacityType: spot   {"commit": "6984094", "provisioner": "default"}

AWS Management Console から確認したところ、Managed Node 外でインスタンスが作成されたようでした。

(とても残念な感じの node group 名が表示されていますが、気にしないでください。)

deployment リソースの削除

次に deployment を削除してみたところ、Cluster Autoscaler と同様に 不要な Node を判別して削除してくれました。

当時のログを下記に抜粋しました。

2021-12-09T09:45:51.738Z        INFO    controller.node Added TTL to empty node     {"commit": "6984094", "node": "ip-172-31-23-133.ap-northeast-1.compute.internal"}
2021-12-09T09:46:21.757Z        INFO    controller.node Triggering termination after 30s for empty node     {"commit": "6984094", "node": "ip-172-31-23-133.ap-northeast-1.compute.internal"}
2021-12-09T09:46:21.787Z        INFO    controller.termination  Cordoned node       {"commit": "6984094", "node": "ip-172-31-23-133.ap-northeast-1.compute.internal"}
2021-12-09T09:46:21.951Z        INFO    controller.termination  Deleted node{"commit": "6984094", "node": "ip-172-31-23-133.ap-northeast-1.compute.internal"}

しっかりと Cordoned してから Node の削除をしているようです。

お掃除

最後はしっかりとリソースの削除をしておきましょう。

EKS は Master 部分の時間課金 + Node リソースの課金が発生します。

今回は Terraform でリソースを作成したので terraform destroy で終わりです。

入門して感じた事

動画だけではイメージが湧かなかったので、公式ドキュメントにのっとって(?)Karpenter へ入門してみました。

入門することで EKS 環境における Cluster Autoscaler との差分について見えてきました気がします。

0 -> 1 のスケールが必要な Node の準備が manifest で完結する。

EKS 環境で Cluster Autoscaler を使用する場合、AGS (Auto Scaling Group) が必要となります。

通常の 1 以上の インスタンス数からのスケールを行うだけならば、Cluster Autoscaler 側の設定だけで済むのですが、 0 -> 1 へのスケールを行うためには AGS 、つまりは AWS リソース側への追加設定が必要になります。

Terraform + Managed Node Group の場合はさらに面倒になります。 Terraform リソース間に依存関係ができてしまい。適用する順番をコントロールする必要が出てきてしまうのです。

このように管理に手間が増えてしまうのが難点だったのですが、 今回の入門のユースケースのように、マニフェスト1つで 0 -> 1 スケーリングをコントロールできるのは便利だと思いました。

同期的な Pod のスケジューリング

Cluster Autoscaler を使用した場合、「Node のスケールアウト」と「Pod のスケジューリング」は非同期で行われます。

(前者は Cluster Autoscaler に、後者は kube-scheduler により行われる。)

しかし、Karpenter ではスケーリングだけではなく、スケジュールされなかった Pod のスケジュールまで行ってくれるようです。

これにより、スケールアウト時に発生する非同期処理のオーバーヘッドを削減し、より柔軟なスケールが実現できそうです。

(といっても通常のオートスケールではギリギリ間に合わなかったこともないので、恩恵を感じづらい気もしています。)

冒頭でも取り上げた、

Just-in-time って表現が気になりますね。

というのは、この機能を指しているんですね。

Node Group を使用したスケーリングからの開放

最初に Karpenter をプロビジョニングするためのコンピューティングリソースは必要となりますが、それ以降のスケールアウトでは、適切なサイズで常に最適化を行いながらリソースの調整してくれます。

これは従来の管理に比べ、より無駄なリソースの使用を抑え、インフラコストの削減が期待できます。

気を付けること

ただし、Cluster Autoscaler を使用する場合に比べ、Node のコントロールがマニフェスト側に寄ってしまう点は気を付けなければならないと思っています。

単一のチームでソフトウェア開発からリソースの調整、インフラコストの管理までを行っている場合は大きく問題にならないはずですが、

そうでない場合は、Audit や Policy をしっかり効かせないと、予想だにしないリソース増強などが行われ、コスト管理者の頭を悩ませることになりそうです。

まとめ

GA したと言っても v0.5 の OSS です。

実際に本番環境で使用するには、まだまだ知見やレポートが足りないため、しばらくは検証しつつ、様子見しようと思っています。

ただし、↑で上げたようなメリットは無視できないため、いつでも Cluster Autoscaler から乗り換えられるように準備を進めておこうとも思っています。

(個人的には EKS Addons のパーティメンバーに入ってくれると、クラスタのバージョンアップ作業がさらに楽になってうれしいなぁ。CRD があるからキビしいかなぁ。)

以上、Showcase Gig Advent Calendar 2021 11日目 の記事でした!

明日もお楽しみに!

この記事が気に入ったらサポートをしてみませんか?