はじめに
サービスで使用している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だが、ほとんど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
でデプロイするってイメージ
{
"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
さあさあ本体ですね。
概観
一旦細かなコードを抜くとこんな外観となります
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.json
のcontext
フィールドで書いたパラメータです。
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っぽいですね。
まずは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,
vpc: vpc,
role: eksRole,
mastersRole: clusterAdmin,
clusterName: myClusterName,
kubectlEnabled: true,
version: this.node.tryGetContext("cluster_version"),
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
})
]
}
]
});
ポイントは、
ワーカーノード
マスターノードに紐付ける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
ワーカーノードに細かなオプションをいれる
ng.addSecurityGroup(
SecurityGroup.fromSecurityGroupId(this, "sg", mySecurityGroupId)
);
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"
);
for (const managedPolicyName of myEksWorkerNodeIAMPolicies) {
ng.role.addManagedPolicy(
ManagedPolicy.fromAwsManagedPolicyName(managedPolicyName)
);
}
まぁこれこそ好みだと思うんですが、SecurityGroupとかSSMとかIAMとかを入れておく。
awsAuth
最後にkubeの権限とawsのIAMのマッピングを司るaws authもクラスターを立ち上げるときにいれる。
cdkの作者がよくわかっているところは、このaws authもちゃんと独自クラスとして用意してくれており、型安全なので設定しやすい。
const awsAuth = new AwsAuth(this, "AwsAuth", {
cluster: cluster
});
awsAuth.addRoleMapping(ng.role, {
groups: ["system:bootstrappers", "system:nodes"],
username: "system:node:{{EC2PrivateDNSName}}"
});
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に投入出来るのは本当に便利だなあ
完成だー
色々マスクしたり、見にくいかもですが、全体像はこちら・・・!
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,
vpc: vpc,
role: eksRole,
mastersRole: clusterAdmin,
clusterName: myClusterName,
kubectlEnabled: true,
version: this.node.tryGetContext("cluster_version"),
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")
)
});
ng.addSecurityGroup(
SecurityGroup.fromSecurityGroupId(this, "sg", mySecurityGroupId)
);
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"
);
for (const managedPolicyName of myEksWorkerNodeIAMPolicies) {
ng.role.addManagedPolicy(
ManagedPolicy.fromAwsManagedPolicyName(managedPolicyName)
);
}
const awsAuth = new AwsAuth(this, "AwsAuth", {
cluster: cluster
});
awsAuth.addRoleMapping(ng.role, {
groups: ["system:bootstrappers", "system:nodes"],
username: "system:node:{{EC2PrivateDNSName}}"
});
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くらいでしか使ってないですが、すごいいいですねーという感想でした。
参考リンク