nがひとつ多い。

えぬなおの技術的なことを書いていくとこ。

【EKS】【CDK】eksctlからcdkにEKSのデプロイ機構を移した

はじめに

サービスで使用しているEKSのデプロイをクラスターバージョンを変えるのを機に、 デプロイだけCDKでやることにしたのでその話を備忘録としてまとめておく。

eks

EKSはAWSのマネージドKubernetes。この記事を見ている人に対して説明は不要だろうが、 aws authとかiamとかとにかくawsの既存リソースとの兼ね合いや依存が有り、デプロイと把握が結構大変。

eksctl

github.com

eksctlはweaveworksさんが出している、 そんなデプロイと把握が結構大変なEKSをなんか諸々コマンド1発でぶち上げてくれるツールだ。

eksctl create cluster

cdk

aws.amazon.com

最近色々なコンポーネントがGAになったawsのインフラ構成管理ライブラリ. 注目は、 プログラミング言語でawsリソースをコードで管理出来る という点にある

cdkのeksリソースってどうなの?

実はまだdeveloper preview

https://docs.aws.amazon.com/cdk/api/latest/docs/aws-eks-readme.html

This is a developer preview (public beta) module.

でも必要な設定はそろってたし立てる分には問題なく動いたので、次回以降も使っていくと思う。

なんでeksctlからcdkに変えたの?

  • ぶっちゃけeksctlでもいいんだけど、設定の管理がyamlだと、主に変数の扱いや、乱数生成とかちょっとした小技にシェルのお世話になるのがイヤになってきた。
  • 今回cdkはTypeScriptを使っているが、やっぱり変数に型がついていたりEnumが使えるっていう事で、バグを減らす期待が大き過ぎる。
  • terraformやeksctlみたいなランタイムなしに、みんなのPCには入ってるだろうnodejsで全部動かせるので便利。
  • eksの新しいバージョンリリースは3ヶ月に1回と速く、eksctl使って手でクラスター作った後、IAMとかSG入れてーみたいなのを、cdkで一連のコードの流れで作れるって良いよねって話になった

後僕の今いるチームは、ほぼ全員サーバサイド、MLエンジニア、フロントというコードを書く人たちの集まりなので、CDKの方がマッチしやすいという思惑もある。

今回作ったcdkのプロジェクト概要

今回はEKSクラスターを1からデプロイするところまでを記載する

バージョンとか

  • EKS...1.15
  • CDK...1.4.0 (build 175471f)

構成

ディレクトリ構成

.
├── cdk.json
├── node_modules
├── package-lock.json
├── package.json
├── .eslintrc.js
├── .gitignore
├── src
│   ├── config.ts
│   └── index.ts
└── tsconfig.json

特になんの変哲もないTypeScriptのプロジェクトだと思う。 事始めは以下.

npm init -y
npm i @aws-cdk/{core,aws-eks,aws-ec2,aws-iam}
npm i -D aws-cdk @types/node typescript

# eslintしたいなら
npm install --save-dev eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser prettier eslint-config-prettier eslint-plugin-prettier

srcディレクトリにcdkのコードを入れていくイメージ

package.json

気になるpackage.jsonだが、ほとんどscriptの部分しかいじってない

..
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "diff": "tsc && cdk diff",
    "deploy": "tsc && cdk deploy",
    "lint": "eslint --ext .js,.ts --ignore-path .gitignore .",
    "lintfix": "eslint --fix --ext .js,.ts --ignore-path .gitignore .",
    "clean": "rm -rf ./dist && rm -rf ./cdk.out"
  },
..

まぁ特段書くこともない。 npm run deploy でデプロイするってイメージ

cdk.json

{
  "app": "node dist/index",
  "context": {
    "env": "test",
    "cluster_version": "1.15",
    "worker_nodes": 6,
    "worker_node_instance_type": "t3.large"
  }
}

お作法通り。 appフィールドにはコンパイル後のdistのindex.jsを見にいくようになっている。 contextフィールドは後述するが、パラメータをここにいれるといい具合にコード側で扱える。 ここのパラメータは特に指定もないので、好きなものを増やすことも出来るし、何も書かなくてもいい。

cdkのコードを見ていきー

お待ちかねのcdkのコードです。

config.ts

export const myVPC = "vpc-XXXX";
export const mySubnets = {
  private: {
    routeTableId: "rtb-XXXX",
    privateA: { id: "subnet-XXXX", az: "ap-northeast-1a" },
    privateC: { id: "subnet-XXXX", az: "ap-northeast-1c" },
    privateD: { id: "subnet-XXXX", az: "ap-northeast-1d" }
  },
  public: {
    routeTableId: "rtb-XXXX",
    publicA: { id: "subnet-XXXX", az: "ap-northeast-1a" },
    publicC: { id: "subnet-XXXX", az: "ap-northeast-1c" },
    publicD: { id: "subnet-XXXX", az: "ap-northeast-1d" }
  }
};
export const mySecurityGroupId = "sg-XXXX";
export const myEksAdmins = ["XXXX"];
export const myEksWorkerNodeIAMPolicies = [
  "service-role/AmazonEC2RoleforSSM",
  "AmazonEC2ContainerRegistryPowerUser",
  ...
];

※実際の設定値とはだいぶ変えてます
マスクが多いのは仕方ないとして、まぁこんな感じでハードコードしそうな設定値はこのconfig.tsに避けておこうかなという感じです ヘッダーファイル的な。 SSMとか使う人はAmazonEC2RoleforSSMロールを忘れずに。

index.ts

さあさあ本体ですね。

概観

一旦細かなコードを抜くとこんな外観となります

#!/usr/bin/env node
import ...
const app = new cdk.App();
const namedVersion = `v${app.node
  .tryGetContext("cluster_version")
  .replace(".", "")}`;
const clusterName = `${app.node.tryGetContext(
  "env"
)}-XXXX-${namedVersion}`;

export class NewEKS extends cdk.Stack {
...
}

new NewEKS(app, clusterName, {
  env: {
    region: "XXXX",
    account: "XXXX"
  }
});
app.synth();

最初、 new cdk.App(); でcdkで作成するAppクラスを作ります。
次にclusterNameを作ります・・・ここは、 <環境名>-<なんとかクラスターみたいな名前>-<EKSバージョン> のような名前にしました。
さて気になるところが app.node.tryGetContext("env") みたいな関数ですが、これが、 cdk.jsoncontext フィールドで書いたパラメータです。
try...みたいな関数の理由は、もし存在しないフィールド指定したら undefined で戻るからです・・・なのでチェックとかしたい場合はしてから使用するといいです。
元々try...ではなかったみたいですが、これはjsonから持ってくるので色々な型がありえるので、文字列でいう空文字みたいな初期値を持たせるのが無理だったんでしょうね。

注意点が、この cdk.Stack という抽象クラスはsynthメソッドを呼ぶ前に envパラメータを持たないと初期化できないところです。 必ずリージョンとAWSアカウント名を入れて初期化しましょう

NewEKSの中身を順番に

export class NewEKS extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
...

お決まりの呪文を書いた後、いよいよAWSリソースを書いていきます。 おおまかな流れは、この cdk.Stack に継承されたクラスの初期化 = リソース作成となります。 そしてこのクラス内で変数を定義 = AWSリソースを作成すると言った流れとなります。ORMっぽいですね。

VPC

まずはEKSを立てるVPCを記述します

 const vpc = Vpc.fromLookup(this, "vpc", {
      vpcId: myVPC
});

結構世にはVPCを新しく作っちゃう系の記事が多いですが、僕は新しくクラスター作るごとにVPCを作らないので、vpcのidから引いてきてオブジェクト作ります

クラスター IAM ロール

Kubernetes コントロールプレーンがリソースを作成するときに使用するIAMを作ってやる必要があります

const eksRole = new Role(this, `eksRole`, {
      assumedBy: new ServicePrincipal("eks.amazonaws.com"),
      managedPolicies: [
        ManagedPolicy.fromAwsManagedPolicyName("AmazonEKSClusterPolicy"),
        ManagedPolicy.fromAwsManagedPolicyName("AmazonEKSServicePolicy")
      ]
});

まぁこれはみんな設定するでしょう。

管理権限 IAM ロール

これはkubernetesの世界での管理者権限に相当するIAMロールです。 必要な場合は追加します

const clusterAdmin = new Role(this, `clusterAdmin`, {
      assumedBy: new AccountRootPrincipal()
});

あくまで例なわけですが、一旦誰にでもkubeリソースを触れる権限を引き継げるようにするにはこの AccountRootPrincipal() をassumeに設定すればいいです。 とはいえクラスターのブートストラップアクションにはあっていいでしょう。

マスターノード

主人公登場ですね

const cluster = new Cluster(this, "cluster", {
      // デフォルトインスタンスは作らない
      defaultCapacity: 0,
      // EKSクラスターをデプロイするVPC
      vpc: vpc,
      // masterノードがコントロールプレーンで使う用のロール
      role: eksRole,
      // クラスターのマスターロール
      mastersRole: clusterAdmin,
      // クラスターの名前
      clusterName: myClusterName,
      // kubectlを使って操作出来るようにする。
      kubectlEnabled: true,
      // kubernetesのヴァージョン
      version: this.node.tryGetContext("cluster_version"),
      // kubernetesにアサインするsubnet
      vpcSubnets: [
        {
          subnets: [
            Subnet.fromSubnetAttributes(this, "privateA", {
              routeTableId: subnets.private.routeTableId,
              subnetId: subnets.private.privateA.id,
              availabilityZone: subnets.private.privateA.az
            }),
            Subnet.fromSubnetAttributes(this, "privateC", {
              routeTableId: subnets.private.routeTableId,
              subnetId: subnets.private.privateC.id,
              availabilityZone: subnets.private.privateC.az
            }),
            Subnet.fromSubnetAttributes(this, "privateD", {
              routeTableId: subnets.private.routeTableId,
              subnetId: subnets.private.privateD.id,
              availabilityZone: subnets.private.privateD.az
            })
          ]
        },
        {
          subnets: [
            Subnet.fromSubnetAttributes(this, "publicA", {
              routeTableId: subnets.public.routeTableId,
              subnetId: subnets.public.publicA.id,
              availabilityZone: subnets.public.publicA.az
            }),
            Subnet.fromSubnetAttributes(this, "publicC", {
              routeTableId: subnets.public.routeTableId,
              subnetId: subnets.public.publicC.id,
              availabilityZone: subnets.public.publicC.az
            }),
            Subnet.fromSubnetAttributes(this, "publicD", {
              routeTableId: subnets.public.routeTableId,
              subnetId: subnets.public.publicD.id,
              availabilityZone: subnets.public.publicD.az
            })
          ]
        }
      ]
});

ポイントは、

  • なぜかデフォルトでワーカーノードインスタンスが2個ついてくるのでdefaultCapacity: 0に設定する

  • しっかりとクラスターをデプロイするsubnetsを全部設定する必要がある。すごい面倒に感じるが、仕方ない。

ワーカーノード

マスターノードに紐付けるauto scalling group。 マネージドノードグループを紐づけるような設定は探したけどまだないらしい。

const defaultCapacity = +this.node.tryGetContext("worker_nodes");
const ng = cluster.addCapacity(`${myClusterName}-worker-nodes`, {
      desiredCapacity: defaultCapacity,
      maxCapacity: defaultCapacity,
      minCapacity: defaultCapacity,
      instanceType: new InstanceType(
        this.node.tryGetContext("worker_node_instance_type")
      )
});

どういうauto scalling groupにしたいのかは正直好みだとは思う。 後一応 +文字列 で defaultCapacityはstringをnumberにキャストしている。

stackoverflow.com

ワーカーノードに細かなオプションをいれる
    // ワーカノードにSecurityGroupを紐付ける
    ng.addSecurityGroup(
      SecurityGroup.fromSecurityGroupId(this, "sg", mySecurityGroupId)
    );
    // ワーカノードにSSMを入れておく
    ng.addUserData(
      "cd /tmp\n" +
        "yum install -y https://s3.amazonaws.com/ec2-downloads-windows/SSMAgent/latest/linux_amd64/amazon-ssm-agent.rpm\n" +
        "systemctl start amazon-ssm-agent"
    );
    // ワーカノードにデフォルトで必要なIAMをいれる。
    for (const managedPolicyName of myEksWorkerNodeIAMPolicies) {
      ng.role.addManagedPolicy(
        ManagedPolicy.fromAwsManagedPolicyName(managedPolicyName)
      );
    }

まぁこれこそ好みだと思うんですが、SecurityGroupとかSSMとかIAMとかを入れておく。

awsAuth

最後にkubeの権限とawsのIAMのマッピングを司るaws authもクラスターを立ち上げるときにいれる。 cdkの作者がよくわかっているところは、このaws authもちゃんと独自クラスとして用意してくれており、型安全なので設定しやすい。

    // kubectlを叩けるIAMを設定しておく。
    const awsAuth = new AwsAuth(this, "AwsAuth", {
      cluster: cluster
    });
    // クラスター自身のIAMをマップ
    awsAuth.addRoleMapping(ng.role, {
      groups: ["system:bootstrappers", "system:nodes"],
      username: "system:node:{{EC2PrivateDNSName}}"
    });
    // clusterの管理権限をマップ
    awsAuth.addMastersRole(
      Role.fromRoleArn(this, 'clusterAdminAtAwsAuth', clusterAdmin.roleArn),
      clusterAdmin.roleName
    );
    // 追加の管理ユーザをマップ
    for (let i = 0; i < myEksAdmins.length; i++) {
      const userName = myEksAdmins[i];
      awsAuth.addUserMapping(User.fromUserName(this, userName, userName), {
        username: userName,
        groups: ["system:masters"]
      });
    }

IAM型やUser型に縛ってawsAuthのconfigMapに投入出来るのは本当に便利だなあ

完成だー

色々マスクしたり、見にくいかもですが、全体像はこちら・・・!

#!/usr/bin/env node
import ...
const app = new cdk.App();
const namedVersion = `v${app.node
  .tryGetContext("cluster_version")
  .replace(".", "")}`;
const clusterName = `${app.node.tryGetContext(
  "env"
)}-XXXX-${namedVersion}`;

export class NewEKS extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const vpc = Vpc.fromLookup(this, "vpc", {
      vpcId: myVPC
    });

    const eksRole = new Role(this, `eksRole`, {
      assumedBy: new ServicePrincipal("eks.amazonaws.com"),
      managedPolicies: [
        ManagedPolicy.fromAwsManagedPolicyName("AmazonEKSClusterPolicy"),
        ManagedPolicy.fromAwsManagedPolicyName("AmazonEKSServicePolicy")
      ]
    });

    const clusterAdmin = new Role(this, `clusterAdmin`, {
      assumedBy: new AccountRootPrincipal()
    });

    const cluster = new Cluster(this, "cluster", {
      // デフォルトインスタンスは作らない
      defaultCapacity: 0,
      // EKSクラスターをデプロイするVPC
      vpc: vpc,
      // masterノードがコントロールプレーンで使う用のロール
      role: eksRole,
      // クラスターのマスターロール
      mastersRole: clusterAdmin,
      // クラスターの名前
      clusterName: myClusterName,
      // kubectlを使って操作出来るようにする。
      kubectlEnabled: true,
      // kubernetesのヴァージョン
      version: this.node.tryGetContext("cluster_version"),
      // kubernetesにアサインするsubnet
      vpcSubnets: [
        {
          subnets: [
            Subnet.fromSubnetAttributes(this, "privateA", {
              routeTableId: subnets.private.routeTableId,
              subnetId: subnets.private.privateA.id,
              availabilityZone: subnets.private.privateA.az
            }),
            Subnet.fromSubnetAttributes(this, "privateC", {
              routeTableId: subnets.private.routeTableId,
              subnetId: subnets.private.privateC.id,
              availabilityZone: subnets.private.privateC.az
            }),
            Subnet.fromSubnetAttributes(this, "privateD", {
              routeTableId: subnets.private.routeTableId,
              subnetId: subnets.private.privateD.id,
              availabilityZone: subnets.private.privateD.az
            })
          ]
        },
        {
          subnets: [
            Subnet.fromSubnetAttributes(this, "publicA", {
              routeTableId: subnets.public.routeTableId,
              subnetId: subnets.public.publicA.id,
              availabilityZone: subnets.public.publicA.az
            }),
            Subnet.fromSubnetAttributes(this, "publicC", {
              routeTableId: subnets.public.routeTableId,
              subnetId: subnets.public.publicC.id,
              availabilityZone: subnets.public.publicC.az
            }),
            Subnet.fromSubnetAttributes(this, "publicD", {
              routeTableId: subnets.public.routeTableId,
              subnetId: subnets.public.publicD.id,
              availabilityZone: subnets.public.publicD.az
            })
          ]
        }
      ]
    });

    const defaultCapacity = +this.node.tryGetContext("worker_nodes");
    const ng = cluster.addCapacity(`${myClusterName}-worker-nodes`, {
      desiredCapacity: defaultCapacity,
      maxCapacity: defaultCapacity,
      minCapacity: defaultCapacity,
      instanceType: new InstanceType(
        this.node.tryGetContext("worker_node_instance_type")
      )
    });

    // ワーカノードにSecurityGroupを紐付ける
   ng.addSecurityGroup(
     SecurityGroup.fromSecurityGroupId(this, "sg", mySecurityGroupId)
   );
   // ワーカノードにSSMを入れておく
   ng.addUserData(
     "cd /tmp\n" +
       "yum install -y https://s3.amazonaws.com/ec2-downloads-windows/SSMAgent/latest/linux_amd64/amazon-ssm-agent.rpm\n" +
       "systemctl start amazon-ssm-agent"
   );
   // ワーカノードにデフォルトで必要なIAMをいれる。
   for (const managedPolicyName of myEksWorkerNodeIAMPolicies) {
     ng.role.addManagedPolicy(
       ManagedPolicy.fromAwsManagedPolicyName(managedPolicyName)
     );
   }

   // kubectlを叩けるIAMを設定しておく。
    const awsAuth = new AwsAuth(this, "AwsAuth", {
      cluster: cluster
    });
    // クラスター自身のIAMをマップ
    awsAuth.addRoleMapping(ng.role, {
      groups: ["system:bootstrappers", "system:nodes"],
      username: "system:node:{{EC2PrivateDNSName}}"
    });
    // clusterの管理権限をマップ
    awsAuth.addMastersRole(
      Role.fromRoleArn(this, 'clusterAdminAtAwsAuth', clusterAdmin.roleArn),
      clusterAdmin.roleName
    );
    // 追加の管理ユーザをマップ
    for (let i = 0; i < myEksAdmins.length; i++) {
      const userName = myEksAdmins[i];
      awsAuth.addUserMapping(User.fromUserName(this, userName, userName), {
        username: userName,
        groups: ["system:masters"]
      });
    }
}

new NewEKS(app, clusterName, {
  env: {
    region: "XXXX",
    account: "XXXX"
  }
});
app.synth();

デプロイはこちら

npm run deploy

コードとしては、 app.synth() のコールにより、Cloudformationとしてawsにデプロイされ、もし既に存在すれば差分をデプロイする形となります。 しばらく待つと、クラスターができます。

これだけでも、コンパイル時点でかなりのバグを減らすことができますが、チームでいくつもEKSクラスターを作成する場合より一層使うメリットを享受することができます

おわりに

今回はデプロイするだけでしたが、調べていくとCDK、マニフェストの適用やHelmチャートも扱えたりできます。 僕のチームでも、将来的にArgoを導入したりしたときなどはArgoだけCDK経由でデプロイとかも考えていいのかもしれません。

CDK、まだLambdaとEKSくらいでしか使ってないですが、すごいいいですねーという感想でした。

参考リンク