AWS CDKで始めるAWS環境構築 ~入門編~

こんにちは、株式会社アドグローブ web&アプリ事業部の富澤です。
前回はAWSの認定試験について記事を執筆させていただきました。 blog.adglobe.co.jp

なんとなくAWSの概要を掴んだところで、今回はAWS CDKを使用して実際に簡単なAWS環境を構築してみたいと思います。
また、AWSの用語が出てきますので想定読者としてAWS用語がある程度わかる人とさせていただきます。

AWS CloudFormationとは

AWSの環境を構築する方法として、AWSコンソール画面から構築する方法のほかにAWS CloudFormationというサービスを使用して環境構築を行うことができます。
CloudFormationはCloudformationテンプレートと呼ばれるでYAMLもしくはJSONといったテキスト形式でAWSの環境を構築・管理できるIaC(Infrastructure as Code)サービスになります。

参照:AWS アプリケーションフレームワーク

例を見ていただければ分かるように、正直読みづらいと感じた人が多いのではないでしょうか。
これを覚えて活用していくのは結構な学習コストがかかることが予想されます。
そこで、今回の題材としたAWS CDKを利用することで可読性の向上だったり、学習コストの低減といったメリットを受けることができます。

AWS CDKとは

CDKはCloud Development Kitの略称になります。
どういったものかを簡単に説明すると、プログラムからCloudFormationテンプレートを作成できるツールになります。
2019年にAWS公式で発表された比較的新しいツールで、2022年4月現時点では、JavaScript、TypeScript、Python、Java、C#が一般公開されて使用できる言語になります。
まだ開発プレビューですがGo言語も使用可能なようです。

CDKにはコンストラクトライブラリというものが含まれていて、これがAWSリソースを表すコンストラクト(単一または複数のAWSリソースのようなもの)を定義しています。
このコンストラクトには3つのレイヤーがあり、L1やL2といった感じで呼ばれています。
レイヤーの違いについて簡単に説明すると以下のようになります。

L1:

CFNリソース(または「レイヤー1」の略)またはCfn(CloudFormationの略)リソースと呼ばれる低レベルの構成から始まる、3つの異なるレベルの構成があります。これらのコンストラクトは、AWSCloudFormationで利用可能なすべてのリソースを直接表します。CFNリソースは、AWSCloudFormationリソース仕様から定期的に生成されます。それらはCfnXyzという名前で、Xyzはリソースの名前です。たとえば、CfnBucketはAWS :: S3::Bucketを表しますAWSCloudFormationリソース。Cfnリソースを使用する場合は、すべてのリソースプロパティを明示的に設定する必要があります。これには、基盤となるAWSCloudFormationリソースモデルの詳細を完全に理解する必要があります。

CloudFormationテンプレートのように宣言したリソースと作成されるリソースが1対1で作成されるような感じです。

L2:

次のレベルのコンストラクトであるL2もAWSリソースを表しますが、より高いレベルのインテントベースのAPIを備えています。これらは同様の機能を提供しますが、CFNリソース構造を使用して自分で作成するデフォルト、ボイラープレート、およびグルーロジックを提供します。AWSコンストラクトは便利なデフォルトを提供し、それらが表すAWSリソースに関するすべての詳細を知る必要性を減らし、リソースの操作を簡単にする便利なメソッドを提供します。たとえば、s3.Bucketクラスは、バケットにライフサイクルルールを追加するbucket.addLifeCycleRule()などの追加のプロパティとメソッドを持つAmazonS3バケットを表します

AWSのベストプラクティスに沿って、いい感じにリソースが作成されるような感じです。(例えばEC2でサーバを立てようとした場合、EC2リソースを作成すると自動でネットワークの構成等もやってくれるようなイメージ)

L3:

AWSで一般的なタスクを完了するのに役立つように設計されており、多くの場合、複数の種類のリソースが関係しています。たとえば、aws-ecs-patterns.ApplicationLoadBalancedFargateServiceコンストラクトは、Application Load Balancer(ALB)を採用したAWSFargateコンテナクラスターを含むアーキテクチャを表します。aws-apigateway.LambdaRestApiコンストラクトは、 AWSLambda関数によってサポートされるAmazonAPIGatewayAPIを表します。

L3についてはまだ触っていないのですが、おそらくAWS側がアーキテクチャのパターンとして持っているテンプレートのようなもので環境を構築してくれるものだと思います。

引用:Constructs - AWS Cloud Development Kit (AWS CDK) v2


今回はまずL1のみで簡単なAWS環境を構築してみようと思います。
言語はpythonを使用しますが、他のCDKの記事ではTypeScriptが多そうでした。
pythonはサンプルが少ないという理由とTypeScriptで書くとコピペする自信があるのでpythonにしました。

作成するAWS構成

今回はEC2インスタンスでwebサーバを立てて、ブラウザ画面からアクセスできるようにしたいと思います。
また、VPC内の通信ログを取ってみようと思います。

作成するAWS構成図

実装

aws-cdkが必要になりますが、ローカルPCにインストールしているものとして進めます(npmからインストール可能)。
aws-cdkのバージョンは2.18.0で言語にpythonを使用します。

まずはcdkの初期化コマンドを実行します。
以下のようなメッセージが出たら成功です。

$ mkdir try-aws-cdk
$ cdk init --language python
Applying project template app for python
# Welcome to your CDK Python project!
This is a blank project for Python development with CDK.
The `cdk.json` file tells the CDK Toolkit how to execute your app.
This project is set up like a standard Python project.  The initialization
process also creates a virtualenv within this project, stored under the `.venv`
directory.  To create the virtualenv it assumes that there is a `python3`
(or `python` for Windows) executable in your path with access to the `venv`
package. If for any reason the automatic creation of the virtualenv fails,
you can create the virtualenv manually.
To manually create a virtualenv on MacOS and Linux:

$ python3 -m venv .venv

After the init process completes and the virtualenv is created, you can use the following
step to activate your virtualenv.

$ source .venv/bin/activate

If you are a Windows platform, you would activate the virtualenv like this:

% .venv\Scripts\activate.bat

Once the virtualenv is activated, you can install the required dependencies.

$ pip install -r requirements.txt

At this point you can now synthesize the CloudFormation template for this code.

$ cdk synth

To add additional dependencies, for example other CDK libraries, just add
them to your `setup.py` file and rerun the `pip install -r requirements.txt`
command.
## Useful commands
 * `cdk ls`          list all stacks in the app
 * `cdk synth`       emits the synthesized CloudFormation template
 * `cdk deploy`      deploy this stack to your default AWS account/region
 * `cdk diff`        compare deployed stack with current state
 * `cdk docs`        open CDK documentation
Enjoy!
Initializing a new git repository...
Please run 'python3 -m venv .venv'!
Executing Creating virtualenv...
:チェックマーク_緑: All done!

以下のようなディレクトリが作成されます。
README.mdの手順に従って初期設定を行いましょう。

.
├── README.md
├── app.py
├── cdk.json
├── requirements-dev.txt
├── requirements.txt
├── source.bat
├── try_aws_cdk
│   ├── _init_.py
│   └── try_aws_cdk_stack.py
└── tests
    ├── _init_.py
    └── unit
        ├── _init_.py
        └── test_try_aws_cdk_stack.py

try_aws_cdk_stack.pyを開くと以下のような記述があるのですが、こちらにリソースを定義していきます。

from aws_cdk import (
    # Duration,
    Stack,
    # aws_sqs as sqs,
)
from constructs import Construct
class TryAwsCdkStack(Stack):
    def _init_(self, scope: Construct, construct_id: str, **kwargs) -> None:
        super()._init_(scope, construct_id, **kwargs)
        # The code that defines your stack goes here
        # example resource
        # queue = sqs.Queue(
        #     self, "TestQueue",
        #     visibility_timeout=Duration.seconds(300),
        # )
        → ここから書きます

VPCの作成

CIDRブロックに192.168.0.0/24を割り当てていますが、ある程度のアドレス数が確保できれば「/24」ではなくても大丈夫です。
「enable_dns_hostnames」と「enable_dns_support」を両方TrueにすることでAWS側が名前解決を行ってくれるらしいので一応設定しました。

属性の両方が true に設定されている場合、次のようになります。
・パブリック IP アドレスを持つインスタンスは、対応するパブリック DNS ホスト名を受け取ります。
・Amazon Route 53 Resolver サーバーは、Amazon が提供するプライベート DNS ホスト名を解決できます。

引用:VPC の DNS 属性 - Amazon Virtual Private Cloud

        # vpc
        cfn_vpc = ec2.CfnVPC(self, 'VPC',
            cidr_block='192.168.0.0/24',
            enable_dns_hostnames=True,
            enable_dns_support=True,
            tags=[cdk.CfnTag(
                key='Name',
                value='My-Vpc',
            )]
        )

サブネットの作成

CIDRブロックは先ほどVPCで定義した「192.168.0.0/24」の範囲内で設定します。
vpc_idにcfn_vpcのrefプロパティを入れることで先ほど作成したVPCを参照できるようになります。
「map_public_ip_on_launch」をtrueにしてサブネットで起動したインスタンスにパブリックIPアドレスの割り当てを許可します。

        # subnet
        cfn_subnet = ec2.CfnSubnet(self, 'MyCfnSubnet',
            cidr_block='192.168.0.1/26',
            vpc_id=cfn_vpc.ref,
            map_public_ip_on_launch=True,
            availability_zone='ap-northeast-1a',
            tags=[cdk.CfnTag(
                key='Name',
                value='My-Public-Subnet',
            )]
        )

ルートテーブルの作成

ルートテーブルを作成します。
サブネットと同じようにvpc_idは作成済みのcfn_vpcを参照します。
ルーティング自体は後ほど別途定義します。

        # public subnet route table
        cfn_route_table = ec2.CfnRouteTable(self, 'MyCfnRouteTable',
            vpc_id=cfn_vpc.ref,
            tags=[cdk.CfnTag(
                key='Name',
                value='My-Public-Route-Table',
            )]
        )

インターネットゲートウェイの作成

インターネットとの接続用にインターネットゲートウェイを作成します。

        # internet getway
        cfn_internet_gateway = ec2.CfnInternetGateway(self, 'MyCfnInternetGateway',
            tags=[cdk.CfnTag(
                key='Name',
                value='My-Internet-Gatway',
            )]
        )

インターネットゲートウェイをVPCにアタッチ

作成したインターネットゲートウェイをVPCにアタッチします。

        # vpc attachement internet getway
        cfn_vPCGateway_attachment = ec2.CfnVPCGatewayAttachment(self, 'MyCfnVPCGatewayAttachment',
            vpc_id=cfn_vpc.ref,
            internet_gateway_id=cfn_internet_gateway.ref,
        )

ルーティング定義

インターネットとの接続用のルート定義を作成します。
「0.0.0.0/0」は全てのIPv4アドレスになります。
ここではインターネットゲートウェイのトラフィックがインターネットに出られるようにしています。

        # route
        cfn_route = ec2.CfnRoute(self, 'MyCfnRoute',
            route_table_id=cfn_route_table.ref,
            destination_cidr_block='0.0.0.0/0',
            gateway_id=cfn_internet_gateway.ref,
        )

サブネットとルートテーブルの関連付け

作成済みのサブネットとルートテーブルを関連付けます。
注意点として、サブネットを明示的にルートテーブルに関連付けを行わない場合はデフォルトで用意されたルートテーブルに自動で関連付けられます。
意図しない挙動になることもあるため気を付けましょう(体験談)。

        # subnet route table associatioin
        ec2.CfnSubnetRouteTableAssociation(self, 'MySubnetRouteTableAssociation',
            route_table_id=cfn_route_table.ref,
            subnet_id=cfn_subnet.ref
        )

セキュリティグループ

EC2インスタンス用にセキュリティグループを作成します。
セキュリティグループはインスタンスに対するファイアウォールのようなのもで、IPアドレスやIPプロトコル、ポート番号を設定することで通信を許可することができます。
ここではIPアドレス「0.0.0.0/0」や「80」番ポートを設定してhttpの通信を許可させます。
必要に応じてSSHも許可したりします。
注意点としてSSHを許可する場合は「0.0.0.0/0」にしないようにIPは絞りましょう。
そうしないとどこからでもこのサーバにログインできてしまうので。

        # security group
        cfn_public_security_group = ec2.CfnSecurityGroup(self, 'My-Security-Group-1',
            group_description='Allow ssh access to ec2 instances',
            security_group_ingress=[
                ec2.CfnSecurityGroup.IngressProperty(
                    ip_protocol='tcp',
                    cidr_ip='0.0.0.0/0',
                    description='open world',
                    from_port=80,
                    to_port=80,
                )
            ],
            tags=[cdk.CfnTag(
                key='Name',
                value='My-Public-Sg',
            )],
            vpc_id=cfn_vpc.ref
        )

EC2インスタンスの作成

EC2インスタンスを作成する前にユーザデータを作成します。
ユーザデータを作成してインスタンスに渡すことで、インスタンスが起動後にスクリプトやコマンドを実行することができます。
今回はphpとapacheをインストールしてapacheを起動しています。
ユーザデータはbase64形式に変換する必要があるようです。

EC2は設定項目が少し多めですが、ポイントはimage_idはAMIのidを設定するところです。
存在しないidを設定しないように注意しましょう(体験談として、idが間違えていたことで通信できずに原因究明で時間かかりました...)。
OSはAmazon Linux2を使用しています。

また、SSH用のキーペアをCDKで作成しようと思ったのですが、公式ではAPIが用意されていませんでした。
そのためサードパーティー製のライブラリを使用してみたのですがキー自体は作成できるもののうまくSSHログインができなかったのでコマンドから作成したキーペアを使用するようにしました(そのため、ここはCDKではなくAWSコンソール画面からキーをアップロードしています)。

        # user data
        commands_user_data = ec2.UserData.for_linux()
        commands_user_data.add_commands('yum -y update')
        commands_user_data.add_commands('amazon-linux-extras install -y php8.0')
        commands_user_data.add_commands('yum install -y httpd')
        commands_user_data.add_commands('systemctl start httpd.service')

        # EC2 instance
        cfn_instance = ec2.CfnInstance(self, 'MyCfnInstance',
            instance_type='t2.nano',
            block_device_mappings=[ec2.CfnInstance.BlockDeviceMappingProperty(
                device_name='/dev/xvda',
                ebs=ec2.CfnInstance.EbsProperty(
                    delete_on_termination=True,
                    encrypted=True,
                    iops=3000,
                    volume_size=8,
                    volume_type='gp3'
                ),
                no_device=ec2.CfnInstance.NoDeviceProperty(), # 多分インスタンスストアをアタッチしない設定
            )],
            subnet_id=cfn_subnet.ref, # ENIが未指定の場合は必須
            security_group_ids=[cfn_public_security_group.ref], # ENIが未指定の場合はデフォルトのSGになるためカスタムSGを指定
            image_id='ami-0ab0bbbd329f565e6', # IDを間違えないように注意
            # key_name=key_pair.key_pair_name, # サードパーティーのキーペアが使用できないのでコメントアウト
            key_name='traning-key', # SSHが必要の場合は設定
            user_data=cdk.Fn.base64(commands_user_data.render()),
            tags=[cdk.CfnTag(
                key='Name',
                value='My-Public-Server',
            )]
        )

ネットワークACLの作成

ネットワークACLはサブネットに対するファイアウォールのようなものでVPCのセキュリティオプションレイヤーです。
この後通信ルールを設定します。

        # networkACL
        cfn_network_acl = ec2.CfnNetworkAcl(self, 'MyCfnNetworkAcl',
            vpc_id=cfn_vpc.ref,
            tags=[cdk.CfnTag(
                key='Name',
                value='My-Network-Acl',
            )]
        )

ネットワークACLのルール作成

ここでは通信を全て許可する設定を行っています。

        # networkACL rule
        cfn_network_acl_entry = ec2.CfnNetworkAclEntry(self, 'MyCfnNetworkAclEntry2',
            network_acl_id=cfn_network_acl.ref,
            protocol=-1,
            rule_action='allow',
            rule_number=100,
            cidr_block='0.0.0.0/0',
            egress=True,
        )

サブネットとネットワークACLの関連付け

作成済みのネットワークACLとVPCを関連付けます。

        # サブネットとネットワークACLを関連付ける
        cfn_subnet_network_acl_association = ec2.CfnSubnetNetworkAclAssociation(self, 'MyCfnSubnetNetworkAclAssociation',
            network_acl_id=cfn_network_acl.ref,
            subnet_id=cfn_subnet.ref,
        )



ここまででEC2インスタンスとネットワーク設定を行いました。
次にVPCフローログを取得して、CloudWatch Logsに送信するまでの設定を行っていきます。

VPCフローログ用のロググループの作成

VPCフローログ用のロググループを作成します。

        # ロググループ(vpcフローログ)
        log_group = logs.CfnLogGroup(self, 'MyLogGroup',
            log_group_name='vpc-flow-logs',
            tags=[cdk.CfnTag(
                key='Name',
                value='My-Vpc-Flow-Log',
            )]
        )

ロールの作成

CloudWatch LogsへVPCフローログを発行するためのポリシーとロールを作成します。
IAMポリシーが一見難しそうですが以下を参照しました。

参照:CloudWatch Logs へのフローログの発行 - Amazon Virtual Private Cloud

        # ロールの作成
        cfn_role = iam.CfnRole(self, 'MyCfnRole',
            assume_role_policy_document={
                'Version': '2012-10-17',
                'Statement': [{
                    'Action': 'sts:AssumeRole',
                    'Effect': 'Allow',
                    'Principal': {'Service': 'vpc-flow-logs.amazonaws.com'}
                }]
            },
            description='vpc flow log role',
            policies=[iam.CfnRole.PolicyProperty(
                policy_document={
                    'Version': '2012-10-17',
                    'Statement': [
                        {
                        'Effect': 'Allow',
                        'Action': [
                            'logs:CreateLogGroup',
                            'logs:CreateLogStream',
                            'logs:PutLogEvents',
                            'logs:DescribeLogGroups',
                            'logs:DescribeLogStreams'
                        ],
                        'Resource': '*'
                        }
                    ]
                },
                policy_name='My-Cloud-Watch-Log-Policy',
            )],
            role_name='My-Vpc-Flow-Log-Role',
            tags=[cdk.CfnTag(
                key='Name',
                value='My-Role',
            )]
        )

VPCフローログの作成

VPCフローログを作成します。
「deliver_logs_permission_arn」はCloudWatch Logsのロググループにフローログを公開するためのIAMロールのARN(Amazon リソースネーム)の設定です。
「log_destination」は先ほど作成したロググループにします。

        # VPCフローログ
        cfn_flow_log = ec2.CfnFlowLog(self, 'MyCfnFlowLog',
            resource_id=cfn_subnet.ref,
            resource_type='Subnet',
            traffic_type='ALL',
            deliver_logs_permission_arn=cfn_role.attr_arn,
            max_aggregation_interval=60,
            log_format='${version} ${vpc-id} ${subnet-id} ${instance-id} ${account-id} ${type} ${interface-id} ${srcaddr} ${dstaddr} ${srcport} ${dstport} ${protocol} ${pkt-src-aws-service} ${pkt-dst-aws-service} ${flow-direction} ${traffic-path} ${action}',
            log_destination=log_group.attr_arn,
            log_destination_type='cloud-watch-logs',
            tags=[cdk.CfnTag(
                key='Name',
                value='My-Flow-Log'
            )]
        )

これで全てのAWSリソースの作成は終わりました。

デプロイ

これまで書いてきたものをAWS環境にデプロイする必要があります。
まずは以下のコマンドを実行してCloudformation用のS3バケットを作成します。
もし、AWS側でアクセスキー等の設定をしていない場合は設定してから実行してください。

$ cdk bootstrap

S3バケットが作成できたら以下コマンドでデプロイします。

$ cdk deploy

AWSリソースが作成されていれば作業完了になります。

まとめ

いかがでしたでしょうか。
今回はL1のみで環境構築を行っているため宣言したAWSリソースと構築されたAWSリソースが1対1の関係で構築されるので分かりやすいと思います。
おそらくCloudformationテンプレートを直書きするよりは、可読性だったり学習しやすさは個人的にはAWS CDKに軍配が上がりそうです。
とはいえ、まだまだAWS CDKの良さは引き出せてはいないと思うので今後も勉強を続けていきます。
「入門編」として記事を執筆していますので今度は「応用編」等書いていこうと思います。