AWS CDKでWeb3層アーキテクチャを作成してみた

83件のシェア(ちょっぴり話題の記事)

はじめに

おはようございます、加藤です。これまで、エンジニアとしてのキャリアは全てインフラエンジニアでしたが、今月からプログラマーとしてのロールを希望してゼロから再スタートしております。
今回、AWS CDK(以降、CDK)を使って典型的なWeb3層アーキテクチャを構築してみたので、こだわったポイントや使う過程で理解した事をご紹介します。

リポジトリはこちらです。
kmd2kmd/aws-cdk-ec2_web3tier

ある程度CDKを理解されている方を前提として書いております。下記のブログを読む、Workshopを試した経験がある程度の理解が必要です。

解説

スタックの分割

CDKは App > Stack > Constracts という大きさの順での概念を取り扱います。

Developers can use one of the supported programming languages to define reusable cloud components known as Constructs. You compose these together into Stacks and Apps.
What Is the AWS CDK? - AWS Cloud Development Kit (AWS CDK)

この章で重要なのは、Stack = CloudFormation(以降、CFn)の1スタックという事です。
つまり、CDK上でStackを分割する事によって生成されるCFnテンプレートを分割する事が可能です。この時スタック間の値の参照はクロススタック参照(Fn::ImportValue)によって行われます。(Low-level constructsの場合は値参照になる?詳細まで調査はできておりません...)
スタック分割ができるので、下記のBlack Beltなどで案内されているように役割やライフサイクルで分割を行う事が可能です。CDKに置いても分割を行うべきかについては、議論の余地があると考えていますが、今回は分割を行ってみました。

実際のコードがこちらです。
分割単位毎にStackを継承したクラスからインスタンスを作成し、コンストラクトの中で各リソースの作成を定義しています。
他のStackから値を受け取る必要があるStackStackPropsというインターフェイスを継承したインターフェイスを作成し、必要な値を受け取れるようにしています。

#!/usr/bin/env node
import "source-map-support/register";
import { NetworkStack } from "../lib/network-stack";
import { ComputeStack } from "../lib/compute-stack";
import { IdentityStack } from "../lib/identity-stack";
import { DataStoreStack } from "../lib/datastore-stack";
import { App } from "@aws-cdk/core";

const app = new App();

const identityStack = new IdentityStack(app, "IdentityStack");
const networkStack = new NetworkStack(app, "NetworkStack");
new ComputeStack(app, "ComputeStack", {
  vpc: networkStack.vpc,
  appSg: networkStack.appSg,
  instanceRole: identityStack.instanceRole
});
// @aws-cdk/aws-rds is a developer preview (public beta) module.
// Releases might lack important features and might have future breaking changes.
new DataStoreStack(app, "DataStoreStack", {
  vpc: networkStack.vpc,
  dbSg: networkStack.dbSg
});

環境(dev|stg|prd)差分の制御

例えば、開発と本番でインスタンスサイズや台数を変更したいという要望がよくあります。CFnではParameter:によってデプロイ時に値を注入して設定する事が可能でした。
CDKではContextという仕組みを使って外部から値を注入する事が可能です。
下記の様に-cオプションを使う事で、任意のKey:Valueを与えられます。

cdk deploy "*Stack" -c stage=dev

また、cdk.jsoncontext:に書かれた値も自動で取り込まれています。

{
  "context": {
    "prj": "web-3tier",
    "dev": {
      "nat_gateways": 1
    },
    "stg": {
      "nat_gateways": 2
    },
    "prd": {
      "nat_gateways": 2
    }
  },
  "app": "npx ts-node bin/web-3tier.ts"
}

下記のコードでは、prj,stageを変数に格納し、stageを使って更にparamsを取り出しています。

import { Construct, Stack, StackProps } from "@aws-cdk/core";
import { ManagedPolicy, Role, ServicePrincipal } from "@aws-cdk/aws-iam";

export class IdentityStack extends Stack {
  public readonly instanceRole: Role;

  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const prj: string = this.node.tryGetContext("prj");
    const stage: string = this.node.tryGetContext("stage");
    // const params: object = this.node.tryGetContext(stage);

    this.instanceRole = new Role(this, "IamRole", {
      assumedBy: new ServicePrincipal("ec2.amazonaws.com"),
      roleName: `${prj}-${stage}-app`,
      managedPolicies: [
        ManagedPolicy.fromAwsManagedPolicyName(
          "service-role/AmazonEC2RoleforSSM"
        )
      ]
    });
  }
}

または、より手続き的に書きたいならば、stageだけを実行時に注入して、IF文などで分岐させても良いでしょう。

Get a Value from a Context Variable - AWS Cloud Development Kit (AWS CDK)

抽象化されたAWSリソース

衝撃を受けたポイントです。Constructsには3段階の抽象化レベルがあり、pattern constructsがもっとも抽象化が強く、対してlow-level constructsはCFn constructsとも呼ばれ、CFnと1:1で対応しており抽象化が弱いです

  1. pattern constructs
  2. high-level constructs
  3. low-level constructs

pattern constructsはECSを利用する際に、ALBを合わせて作成しコンテナのビルドまで行ってくれます。
pattern constructsは現状この、aws-ecs-patternsしか存在しません。なので、「AWSはCDKを抽象化する」と言われても、いまいちピンと来ていませんでした。

Constructs - AWS Cloud Development Kit (AWS CDK)

import cdk = require("@aws-cdk/core");
import { AutoScalingGroup } from "@aws-cdk/aws-autoscaling";
import {
  AmazonLinuxImage,
  InstanceClass,
  InstanceSize,
  InstanceType,
  ISecurityGroup,
  IVpc,
  SubnetType
} from "@aws-cdk/aws-ec2";
import { ApplicationLoadBalancer } from "@aws-cdk/aws-elasticloadbalancingv2";
import { Role } from "@aws-cdk/aws-iam";

interface ComputeStackProps extends cdk.StackProps {
  vpc: IVpc;
  instanceRole: Role;
  appSg: ISecurityGroup;
}

export class ComputeStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props: ComputeStackProps) {
    super(scope, id, props);

    const prj: string = this.node.tryGetContext("prj");
    const stage: string = this.node.tryGetContext("stage");
    // const params: any = this.node.tryGetContext(stage);

    const asg = new AutoScalingGroup(this, "ASG", {
      vpc: props.vpc,
      instanceType: InstanceType.of(
        InstanceClass.BURSTABLE2,
        InstanceSize.MICRO
      ),
      machineImage: new AmazonLinuxImage(),
      allowAllOutbound: true,
      role: props.instanceRole
    });
    asg.addUserData(
      "yum -y update",
      "yum -y install nginx",
      "systemctl enable nginx",
      "systemctl start nginx"
    );

    const alb = new ApplicationLoadBalancer(this, "Alb", {
      vpc: props.vpc,
      vpcSubnets: { subnetType: SubnetType.PUBLIC },
      internetFacing: true,
      loadBalancerName: `${prj}-${stage}-alb`
    });

    const listener = alb.addListener("Listener", {
      port: 80
    });
    listener.addTargets("Target", {
      targets: [asg],
      port: 80
    });
  }
}

ハイライトの部分に注目してください。ALBを作成していますが、Security Groupの指定は行われていません。想像は、「VPC内のデフォルトSecurity Groupが割り当てられる」でした。
実際は、「ALBに設定されているリスナーおよび、ターゲットグループから判断して自動生成」でした。

AWS固有値の型

インスタンスタイプ・サイズ、RDS・Auroraのエンジンやバージョン名など、CFnやTerraformを使っている際に、設定したい値は決まっていても、どういう文字列を設定すれば良いか悩むことがありました。

CDK(TypeScript)では、型が定義されているので、ハイライト部分の様にIDEの補完の力を借りつつ適切に設定をする事が容易です。

import cdk = require("@aws-cdk/core");
import { AutoScalingGroup } from "@aws-cdk/aws-autoscaling";
import {
  AmazonLinuxImage,
  InstanceClass,
  InstanceSize,
  InstanceType,
  ISecurityGroup,
  IVpc,
  SubnetType
} from "@aws-cdk/aws-ec2";
import { ApplicationLoadBalancer } from "@aws-cdk/aws-elasticloadbalancingv2";
import { Role } from "@aws-cdk/aws-iam";

interface ComputeStackProps extends cdk.StackProps {
  vpc: IVpc;
  instanceRole: Role;
  appSg: ISecurityGroup;
}

export class ComputeStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props: ComputeStackProps) {
    super(scope, id, props);

    const prj: string = this.node.tryGetContext("prj");
    const stage: string = this.node.tryGetContext("stage");
    // const params: any = this.node.tryGetContext(stage);

    const asg = new AutoScalingGroup(this, "ASG", {
      vpc: props.vpc,
      instanceType: InstanceType.of(
        InstanceClass.BURSTABLE2,
        InstanceSize.MICRO
      ),
      machineImage: new AmazonLinuxImage(),
      allowAllOutbound: true,
      role: props.instanceRole
    });
    asg.addUserData(
      "yum -y update",
      "yum -y install nginx",
      "systemctl enable nginx",
      "systemctl start nginx"
    );

    const alb = new ApplicationLoadBalancer(this, "Alb", {
      vpc: props.vpc,
      vpcSubnets: { subnetType: SubnetType.PUBLIC },
      internetFacing: true,
      loadBalancerName: `${prj}-${stage}-alb`
    });

    const listener = alb.addListener("Listener", {
      port: 80
    });
    listener.addTargets("Target", {
      targets: [asg],
      port: 80
    });
  }
}

まとめ

「CDKでクロススタックしたいけど、サンプルが無いなー」というのが、きっかけで書いてみましたが、色々な事をチュートリアル的に理解でき、やってみて凄く良かったです。
CDKはまだ、GAされたばかりで今回のコードでもRDSの部分など、モジュールレベルではまだパブリックベータの部分もあるようです。私がコードを書くことに慣れていなくて辛い...という事以外は、凄く使いやすいなという印象です。