はじめに

この記事は『クラウドインテグレーション事業部SRE第三セクションブログリレー企画』の3回目(全6回)の記事です。
前回は大嵩さんの「初めてTerraform importしてみた」が公開されました。

EKS クラスターを構築するには eksctl や Terraform を用いるのが一般的かと思いますが
CDK で EKS クラスターを構築してみて、想像以上に便利だったので構築手順を紹介します。

作成する構成は以下の通りで、Fargate に Nginx のデプロイを行い、
ALB Controller にて、ALB からアクセスできる状態のものを構築します。

CDKプロジェクトの作成

今回は TypeScript を用いて CDK のプロジェクトを作成します。
はじめに、公式チュートリアルの手順を参考にプロジェクトを作成してきます。

$ mkdir hello-eks && cd hello-eks
$ cdk init app --language typescript

EKSクラスター構築について

CDK でのクラスター構築は kubectlLayer というクラスを用いて作成される Lambda が構築を行います。
この Lambda を用いることで、 CDK ではマニフェストの適応や Helm Chart のインストールなどといった EKS の操作が可能になります。

この辺りの経緯は以下の記事の解説がわかりやすかったです。
https://qiita.com/watany/items/daf433338de5b6858ed6

kubectlLayer を使用するには別途ライブラリが必要になるためインストールを行います。
現在の EKS の最新バージョンは 1.31 のため以下のバージョンのものをインストールしましょう。
https://www.npmjs.com/package/@aws-cdk/lambda-layer-kubectl-v31

$ npm i @aws-cdk/lambda-layer-kubectl-v31

構築

作成するCDKプロジェクトのディレクトリ構成は以下のように、各リソースは resources ディレクトリ配下で管理を行い、stack ディレクトリ配下でスタックの作成する構成にしています。

hello-eks/
└─ lib
   ├─ resources
   │  ├─ aws
   │  │  ├─ eks.ts
   │  │  └─ network.ts
   │  └─ k8s
   │     └─ manifest.ts
   └─ stack
      └─ eks-stack.ts

Resource

まずはじめに、作成する AWS リソースと k8s のマニフェストを定義を行います。

VPC

簡単に VPC と 2AZ にパブリックサブネット・プライベートサブネットをそれぞれ1つずつ作成するコードを記述しました。

# lib/resources/aws/network.ts

import { IpAddresses, IVpc, SubnetType, Vpc } from "aws-cdk-lib/aws-ec2";
import { Construct } from "constructs";

export class Network {
  private readonly envName: string;
  private readonly projectName: string;
  private readonly scope: Construct;
  private readonly manageNumber: string;

  public vpc: IVpc;

  constructor(args: {
    scope: Construct;
    manageNumber: string;
    projectName?: string;
    envName?: string;
  }) {
    this.projectName = args.projectName || "hello-eks";
    this.envName = args.envName || "dev";
    this.scope = args.scope;
    this.manageNumber = args.manageNumber;
  }

  public createVpc(args: { networkCidr: string }) {
    const baseName = `${this.projectName}-${this.envName}`;
    const vpcName = `${baseName}-vpc-${this.manageNumber}`;

    this.vpc = new Vpc(this.scope, vpcName, {
      vpcName,
      ipAddresses: IpAddresses.cidr(args.networkCidr),
      createInternetGateway: true,
      enableDnsHostnames: true,
      enableDnsSupport: true,
      maxAzs: 2,
      natGateways: 1,
      restrictDefaultSecurityGroup: true,
      subnetConfiguration: [
        {
          name: `${baseName}-public-subnet`,
          cidrMask: 24,
          subnetType: SubnetType.PUBLIC,
        },
        {
          name: `${baseName}-private-subnet`,
          cidrMask: 24,
          subnetType: SubnetType.PRIVATE_WITH_EGRESS,
        },
      ],
    });
  }
}

EKSクラスター

今回の実装する上で最低限必要となるもののみ定義をしています。
CDK にて EKS クラスターを構築する上でのポイントは以下になります。

  1. 権限まわり
    EKS クラスター構築後は作成者のみが system:masters の権限を持っていますが、CDK で作成する場合はこれは cdk deploy を実行するユーザーではなく、Lambda がこの権限を持ちます。
    そのため、IAM ユーザーに対して権限を付与する場合は別途 AccessEntry クラスを呼び出す必要があります。
  2. ワーカーノードの設定
    CDK では Cluster クラスにて defaultCapacity や defaultCapacityInstance などのプロパティを設定しないと、デフォルトで m5.large の EC2 を2つ持つマネージドノードが作成されます。
    そのため、Fargate を設定する場合や、細かい部分までマネージドノードの設定を行いたい場合などは defaultCapacity を 0 に設定し、別途 ワーカーノードのメソッド等を呼び出す必要があります。
# lib/resources/aws/eks.ts

import { IVpc, SubnetSelection } from "aws-cdk-lib/aws-ec2";
import {
  AccessEntry,
  AccessPolicy,
  AccessScopeType,
  AlbController,
  AlbControllerVersion,
  AuthenticationMode,
  CfnAddon,
  Cluster,
  FargateProfile,
  KubernetesVersion,
  Selector,
} from "aws-cdk-lib/aws-eks";
import { ILayerVersion } from "aws-cdk-lib/aws-lambda";
import { Construct } from "constructs";

export class EKSCluster {
  private readonly envName: string;
  private readonly projectName: string;

  public scope: Construct;
  public cluster: Cluster;
  private manageNumber: string;

  constructor(args: {
    scope: Construct;
    manageNumber: string;
    projectName?: string;
    envName?: string;
  }) {
    this.projectName = args.projectName || "hello-eks";
    this.envName = args.envName || "dev";
    this.scope = args.scope;
    this.manageNumber = args.manageNumber;
  }

  public createCluster(args: {
    vpc: IVpc;
    version: KubernetesVersion;
    kubectlLayer: ILayerVersion;
  }) {
    const clusterName = `${this.projectName}-${this.envName}-cluster-${this.manageNumber}`;

    this.cluster = new Cluster(this.scope, clusterName, {
      clusterName,
      version: args.version,
      kubectlLayer: args.kubectlLayer,
      vpc: args.vpc,
      defaultCapacity: 0,
      authenticationMode: AuthenticationMode.API_AND_CONFIG_MAP,
    });
  }

  public createAddon(args: {
    addonName: string;
    addonVersion: string;
    role?: string;
  }) {
    new CfnAddon(this.scope, args.addonName, {
      clusterName: this.cluster.clusterName,
      addonName: args.addonName,
      addonVersion: args.addonVersion,
      ...(args.role && { serviceAccountRoleArn: args.role }),
    });
  }

  public createAlbController(args: { version: AlbControllerVersion }) {
    new AlbController(this.scope, "alb-controller", {
      cluster: this.cluster,
      version: args.version,
    });
  }

  public createFargateProfile(args: {
    vpc: IVpc;
    subnet: SubnetSelection;
    selectors: Selector[];
  }) {
    new FargateProfile(
      this.scope,
      `${this.projectName}-${this.envName}-common-fargate-profile-${this.manageNumber}`,
      {
        cluster: this.cluster,
        vpc: args.vpc,
        subnetSelection: args.subnet,
        selectors: args.selectors,
      }
    );
  }

  public createAccessEntry(args: {
    iamArn: string;
    accessPolicyName:
      | "AmazonEKSAdminPolicy"
      | "AmazonEKSClusterAdminPolicy"
      | "AmazonEKSAdminViewPolicy"
      | "AmazonEKSEditPolicy"
      | "AmazonEKSViewPolicy";
    accessScopeType: AccessScopeType;
    manageNumber: string;
  }) {
    new AccessEntry(this.scope, `access-policy-${args.manageNumber}`, {
      cluster: this.cluster,
      principal: args.iamArn,
      accessPolicies: [
        AccessPolicy.fromAccessPolicyName(args.accessPolicyName, {
          accessScopeType: args.accessScopeType,
        }),
      ],
    });
  }
}

マニフェスト

仮で作ったもののため、汎用性は高くありませんが、検証のため以下のようなマニフェストを用意しました。
呼び出すことで Namespce, Deployment, Ingress のリソースを作成できます。
今回はこちらを用いて Nginx のリソースを作成します。

# lib/resources/k8s/manifest.ts

import { Cluster, KubernetesManifest } from "aws-cdk-lib/aws-eks";

export class Manifest {
  private readonly cluster: Cluster;
  private appName: string;
  private namespace: KubernetesManifest;
  private deployment: KubernetesManifest;
  private service: KubernetesManifest;
  private ingress: KubernetesManifest;

  constructor(args: { cluster: Cluster; appName: string }) {
    this.cluster = args.cluster;
    this.appName = args.appName;
  }

  public createService(args: {
    namespace: string;
    replicas: number;
    imageRepo: string;
    protocol: "TCP" | "UDP";
    port: number;
    targetPort: number;
    scheme: "internet-facing" | "internal";
    targetType: "ip" | "instance";
    pathType: "Exact" | "Prefix" | "ImplementationSpecific";
  }) {
    const deployName = `${this.appName}-deploy`;
    const serviceName = `${this.appName}-svc`;

    this.namespace = this.cluster.addManifest(`${this.appName}-ns`, {
      apiVersion: "v1",
      kind: "Namespace",
      metadata: {
        name: args.namespace,
      },
    });

    this.deployment = this.cluster.addManifest(deployName, {
      apiVersion: "apps/v1",
      kind: "Deployment",
      metadata: {
        name: deployName,
        namespace: args.namespace,
        labels: {
          app: this.appName,
        },
      },
      spec: {
        selector: {
          matchLabels: {
            app: this.appName,
          },
        },
        replicas: args.replicas,
        template: {
          metadata: {
            labels: {
              app: this.appName,
            },
          },
          spec: {
            containers: [
              {
                name: this.appName,
                image: args.imageRepo,
                ports: [
                  {
                    containerPort: args.targetPort,
                  },
                ],
              },
            ],
          },
        },
      },
    });
    this.deployment.node.addDependency(this.namespace);

    this.service = this.cluster.addManifest(serviceName, {
      apiVersion: "v1",
      kind: "Service",
      metadata: {
        name: serviceName,
        namespace: args.namespace,
        labels: {
          app: this.appName,
        },
      },
      spec: {
        selector: {
          app: this.appName,
        },
        ports: [
          {
            protocol: args.protocol,
            port: args.port,
            targetPort: args.targetPort,
          },
        ],
      },
    });
    this.service.node.addDependency(this.deployment);

    this.ingress = this.cluster.addManifest(`${this.appName}-ingress`, {
      apiVersion: "networking.k8s.io/v1",
      kind: "Ingress",
      metadata: {
        name: this.appName,
        namespace: args.namespace,
        annotations: {
          "alb.ingress.kubernetes.io/scheme": args.scheme,
          "alb.ingress.kubernetes.io/target-type": args.targetType,
        },
      },
      spec: {
        ingressClassName: "alb",
        rules: [
          {
            http: {
              paths: [
                {
                  path: "/",
                  pathType: args.pathType,
                  backend: {
                    service: {
                      name: serviceName,
                      port: {
                        number: args.port,
                      },
                    },
                  },
                },
              ],
            },
          },
        ],
      },
    });
    this.ingress.node.addDependency(this.service);
  }
}

Stack

Resource で定義したものを Stack で呼び出し、CDK にてデプロイを行う準備をします。
EKS クラスターのところで先述しましたが、kubectl を実行できる IAM ユーザーを環境変数に定義をし、Stack で呼び出しを行なっています。
他は必要に応じてカスタマイズしてみてください。

# lib/resources/stack/eks-stack.ts

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { Network } from "../resources/aws/network";
import { EKSCluster } from "../resources/aws/eks";
import { SubnetSelection, SubnetType } from "aws-cdk-lib/aws-ec2";
import {
  AccessScopeType,
  AlbControllerVersion,
  KubernetesVersion,
  Selector,
} from "aws-cdk-lib/aws-eks";
import { KubectlV31Layer } from "@aws-cdk/lambda-layer-kubectl-v31";
import { Manifest } from "../resources/k8s/manifest";

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

    const projectName = "s-tanaka";
    const envName = "dev";

    //! VPC
    const vpc_01 = new Network({
      scope: this,
      manageNumber: "01",
      projectName,
      envName,
    });
    vpc_01.createVpc({
      networkCidr: "10.0.0.0/16",
    });

    //! EKS Cluster
    const eksVersion = KubernetesVersion.V1_31;
    const kubectlLayer = new KubectlV31Layer(this, "kubectl");
    const eks_01 = new EKSCluster({
      scope: this,
      manageNumber: "01",
      projectName,
      envName,
    });
    eks_01.createCluster({
      vpc: vpc_01.vpc,
      version: eksVersion,
      kubectlLayer,
    });

    //! Fargate Profile
    const fargate_01_selectors: Selector[] = [
      {
        namespace: "kube-system",
      },
      {
        namespace: "default",
      },
      {
        namespace: "app",
      },
    ];
    const fargate_01_subnet: SubnetSelection = {
      subnetType: SubnetType.PRIVATE_WITH_EGRESS,
    };

    eks_01.createFargateProfile({
      vpc: vpc_01.vpc,
      subnet: fargate_01_subnet,
      selectors: fargate_01_selectors,
    });

    eks_01.createAlbController({
      version: AlbControllerVersion.V2_8_2,
    });

    //! EKS Addon
    eks_01.createAddon({
      addonName: "vpc-cni",
      addonVersion: "v1.18.3-eksbuild.2",
    });
    eks_01.createAddon({
      addonName: "coredns",
      addonVersion: "v1.11.3-eksbuild.1",
    });
    eks_01.createAddon({
      addonName: "kube-proxy",
      addonVersion: "v1.31.0-eksbuild.2",
    });

    //! EKS Access Entry
    eks_01.createAccessEntry({
      iamArn: process.env.MY_IAM_USER!,
      accessPolicyName: "AmazonEKSClusterAdminPolicy",
      accessScopeType: AccessScopeType.CLUSTER,
      manageNumber: "01",
    });

    //! Nginx
    const nginx = new Manifest({
      cluster: eks_01.cluster,
      appName: "nginx",
    });
    nginx.createService({
      namespace: "app",
      replicas: 3,
      imageRepo: "public.ecr.aws/docker/library/nginx:latest",
      protocol: "TCP",
      port: 80,
      targetPort: 80,
      scheme: "internet-facing",
      targetType: "ip",
      pathType: "Prefix",
    });
  }
}

デプロイ

Stack への記述が完了後、以下のコマンドを実施し構築を行いましょう。

$ cdk deploy

問題なくリソースが作成ができた場合、CFn のコンソールから以下の3つのスタックが作成されていることが確認できます。

構築確認

VPC, EKS クラスター等のリソースの作成が確認できたら kubectl コマンドを用いて、作成されたリソースの詳細を確認します。
kubectl コマンドの導入については以下のドキュメントを参考にしてください。
https://docs.aws.amazon.com/ja_jp/eks/latest/userguide/install-kubectl.html

kubeconfig 作成

作成した EKS クラスターへ接続するために認証情報を取得する必要があります。
そのため、以下のコマンドを実行し認証情報の取得を行いましょう。

$ aws eks update-kubeconfig --name { ClusterName } --region $AWS_REGION

各種リソース確認

Node

kubectl get node -o wide                                                                               
NAME                                                    STATUS   ROLES    AGE    VERSION               INTERNAL-IP   EXTERNAL-IP   OS-IMAGE         KERNEL-VERSION                  CONTAINER-RUNTIME
fargate-ip-10-0-2-163.ap-northeast-1.compute.internal   Ready    <none>   17m    v1.31.1-eks-ce1d5eb   10.0.2.163    <none>        Amazon Linux 2   5.10.226-214.879.amzn2.x86_64   containerd://1.7.22
fargate-ip-10-0-2-218.ap-northeast-1.compute.internal   Ready    <none>   115m   v1.31.1-eks-ce1d5eb   10.0.2.218    <none>        Amazon Linux 2   5.10.226-214.879.amzn2.x86_64   containerd://1.7.22
fargate-ip-10-0-2-62.ap-northeast-1.compute.internal    Ready    <none>   115m   v1.31.1-eks-ce1d5eb   10.0.2.62     <none>        Amazon Linux 2   5.10.226-214.879.amzn2.x86_64   containerd://1.7.22
fargate-ip-10-0-3-162.ap-northeast-1.compute.internal   Ready    <none>   116m   v1.31.1-eks-ce1d5eb   10.0.3.162    <none>        Amazon Linux 2   5.10.226-214.879.amzn2.x86_64   containerd://1.7.22
fargate-ip-10-0-3-190.ap-northeast-1.compute.internal   Ready    <none>   116m   v1.31.1-eks-ce1d5eb   10.0.3.190    <none>        Amazon Linux 2   5.10.226-214.879.amzn2.x86_64   containerd://1.7.22
fargate-ip-10-0-3-24.ap-northeast-1.compute.internal    Ready    <none>   17m    v1.31.1-eks-ce1d5eb   10.0.3.24     <none>        Amazon Linux 2   5.10.226-214.879.amzn2.x86_64   containerd://1.7.22
fargate-ip-10-0-3-83.ap-northeast-1.compute.internal    Ready    <none>   17m    v1.31.1-eks-ce1d5eb   10.0.3.83     <none>        Amazon Linux 2   5.10.226-214.879.amzn2.x86_64   containerd://1.7.22

~/work/hello-eks main !3 ?6 ❯

Deployment

kubectl get deploy -A -o wide                                                                         
NAMESPACE     NAME                           READY   UP-TO-DATE   AVAILABLE   AGE   CONTAINERS                     IMAGES                                                                                    SELECTOR
app           nginx-deploy                   3/3     3            3           19m   nginx                          public.ecr.aws/docker/library/nginx:latest                                                app=nginx
kube-system   aws-load-balancer-controller   2/2     2            2           9h    aws-load-balancer-controller   602401143452.dkr.ecr.us-west-2.amazonaws.com/amazon/aws-load-balancer-controller:v2.8.2   app.kubernetes.io/instance=aws-load-balancer-controller,app.kubernetes.io/name=aws-load-balancer-controller
kube-system   coredns                        2/2     2            2           9h    coredns                        602401143452.dkr.ecr.ap-northeast-1.amazonaws.com/eks/coredns:v1.11.3-eksbuild.1          eks.amazonaws.com/component=coredns,k8s-app=kube-dns

~/work/hello-eks main !3 ?6 ❯

Service

kubectl get svc -A -o wide
NAMESPACE     NAME                                TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)                  AGE   SELECTOR
app           nginx-svc                           ClusterIP   172.20.143.219   <none>        80/TCP                   13m   app=nginx
default       kubernetes                          ClusterIP   172.20.0.1       <none>        443/TCP                  9h    <none>
kube-system   aws-load-balancer-webhook-service   ClusterIP   172.20.63.42     <none>        443/TCP                  9h    app.kubernetes.io/instance=aws-load-balancer-controller,app.kubernetes.io/name=aws-load-balancer-controller
kube-system   kube-dns                            ClusterIP   172.20.0.10      <none>        53/UDP,53/TCP,9153/TCP   9h    k8s-app=kube-dns

~/work/hello-eks main !3 ?6 ❯

Ingress

kubectl get ingress -A -o wide
NAMESPACE   NAME    CLASS   HOSTS   ADDRESS                                                                PORTS   AGE
app         nginx   alb     *       k8s-app-nginx-37fa3fa42c-1318496877.ap-northeast-1.elb.amazonaws.com   80      20m

~/work/hello-eks main !3 ?6 ❯

アクセス確認

Ingress に定義されている URL にアクセスすると Nginx の初期ページが出力されていたら問題なく構築が完了できています。

まとめ

いかがでしたでしょうか。
個人的にはマニフェストは TypeScript より Yaml 形式で記述した方が便利だと感じているため、今回のように TypeScript でマニフェストを書く機会は少ないと思いますが、TypeScript でここまでできるのは何かと便利かと思いました。

これを機に 1人でも CDK や EKS を触るきっかけを作れたら幸いです。
次回は竹内さんの「Powershell5系で発生するCLIXML問題を回避してAnsibleを実行する方法」が公開される予定です!お楽しみに!

参考ドキュメント

aws-cdk-lib.aws_ec2 module
https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2-readme.html
aws-cdk-lib.aws_eks module
https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_eks-readme.html
kube-proxy
https://docs.aws.amazon.com/ja_jp/eks/latest/userguide/managing-kube-proxy.html
coredns
https://docs.aws.amazon.com/ja_jp/eks/latest/userguide/managing-coredns.html
vpc-cni
https://docs.aws.amazon.com/eks/latest/userguide/managing-vpc-cni.html
Deployment
https://kubernetes.io/ja/docs/concepts/workloads/controllers/deployment/
ALB Controller
https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/