AWS CDKで踏み台サーバーを立てる

## はじめに

AWS CDKでプロジェクトを作成するときの手順をまとめておく。
お勉強で終わりたくないので、
まずは基本のEC2から、実用面で踏み台サーバーを使ってインスタンスにアクセスする構成を作る。

作成したデプロイスクリプトは以下にある。

https://github.com/smallriver0316/awstudy/tree/main/ec2-access-via-bastion

https://github.com/smallriver0316/awstudy/tree/main/rds-access-via-bastion

## 環境

- Windows Subsystem for Linux(ubuntu 20.04)
- 使用言語は Typescript

```bash
$ cdk --version
2.55.1 (build 30f1ae4)
```

## セットアップ

CDK CLI はインストールしておく。

```bash
$ npm install -g aws-cdk 
```

プロジェクトの作成

```bash
# プロジェクトディレクトリを作成
$ mkdir ec2-access-via-bastion
$ cd ec2-access-via-bastion/
# プロジェクト作成 
$ cdk init --language typescript
$ cdk ls
Ec2AccessViaBastionStack
```

## bastion → EC2 instance

以下のlib/以下にひな形のデプロイスタックが作られている。
今回の場合だと、lib/ec2-access-via-bastion.tsがそれにあたる。

```bash
$ tree -L 2
.
├── README.md
├── bin
│   └── ec2-access-via-bastion.ts
├── cdk.context.json
├── cdk.json
├── jest.config.js
├── lib
│   └── ec2-access-via-bastion-stack.ts
├── package-lock.json
├── package.json
├── test
│   └── ec2-access-via-bastion.test.ts
└── tsconfig.json
```

最初に触ったときにはとっつきにくかったが、
作成するサービスを、クラスのコンストラクタに記述していくところ。

ec2-access-via-bastion.tsにEc2AccessViaBastionクラスとして、
デプロイスタックのひな形が実装されている。

このクラスのコンストラクタ内にデプロイするサービスの設定を記述していく。

### VPCの作成

AWSに入門したときに、とにかく苦手だったのがVPC。
しかし、CDKならその設定もかなり簡略化できる。

```typescript
    const vpc = new ec2.Vpc(this, "Vpc", {
      ipAddresses: ec2.IpAddresses.cidr("10.0.0.0/16"),
      maxAzs: 1,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: "public-subnet",
          subnetType: ec2.SubnetType.PUBLIC,
        },
        {
          cidrMask: 24,
          name: "private-subnet",
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
        },
      ],
    });
```

今回は、パブリックサブネットに置いた踏み台サーバーからプライベートサブネットにあるEC2インスタンスにアクセスする構成を作る。
サブネットはそれぞれのインスタンス用に2つ用意した。

通常は、各サブネットのCIDRブロックを予め決めておいて、
それからインフラを構築していく。
しかし、AWS CDKの場合は、CIDRのマスク値を決めておくだけで自動でCIDRブロックを各サブネットに割り当ててくれる。

### EC2インスタンスの作成

本来ならセキュリティグループなど、ネットワーク関連の設定が先だが、
まずは、それぞれのサブネットにEC2インスタンスを立ち上げられるか試してみた。

プログラミングと同じ感覚でインフラの設定を記述できるので、
少しずつサービスを追加していけるのもCDKの良いところだ。

```typescript
    new ec2.Instance(this, "BastionHost", {
      vpc,
      vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC }, // 踏み台サーバー側はパブリックサブネットを指定
      instanceType: ec2.InstanceType.of(
        ec2.InstanceClass.T2,
        ec2.InstanceSize.MICRO
      ),
      machineImage: new ec2.AmazonLinuxImage({
        generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2,
      }),
    });

    new ec2.Instance(this, "PrivateHost", {
      vpc,
      vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED }, // プライベートサブネットを指定
      instanceType: ec2.InstanceType.of(
        ec2.InstanceClass.T2,
        ec2.InstanceSize.MICRO
      ),
      machineImage: new ec2.AmazonLinuxImage({
        generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2,
      }),
    });
```

上記の設定でデプロイすると、
指定したそれぞれのサブネットにEC2インスタンスを作成することができた。
(サブネットIDを指定していないのに!)

```bash
# 環境変数でデプロイ先のアカウントやリージョンを指定
$ export AWS_PROFILE=<Your target profile>
$ export CDK_DEPLOY_ACCOUNT=<Your account id>
$ export CDK_DEPLOY_REGION=<Your target region>

# CDKのスクリプトからCloudformationテンプレートが作成できるか確認
$ cdk synth

# cdk bootstrapは最初だけ必要
$ cdk bootstrap

$ cdk deploy
```

記述していないセキュリティグループやパブリックサブネットに対するネットワークインターフェースなども自動で作成された！

ちなみに、machineImageやinstanceTypeの設定も省くと、
AmazonLinux/t3.nanoのインスタンスが作成された。
無料枠のあるt2.microを使用したかったので、ここでは敢えて指定するようにしている。

### インスタンス間のネットワーク設定

踏み台サーバーからプライベートサブネット内のEC2インスタンスにSSHでアクセスすることを目指す。
そのためには、セキュリティグループを設定して、パブリックサブネット(bastion側)からプライベートサブネットへの通信を許可する設定が必要。

```typescript
    // Security Group
    const bastionSg = new ec2.SecurityGroup(this, "BastionSg", {
      vpc,
      description: "Allow ssh access to bastion instance",
      allowAllOutbound: true,
    });

    bastionSg.addIngressRule(
      ec2.Peer.anyIpv4(),
      ec2.Port.tcp(22),
      "allow ssh access only from me"
    );

    const privateSg = new ec2.SecurityGroup(this, "PrivateSg", {
      vpc,
      description: "Allow ssh access from bastion instance",
      allowAllOutbound: true,
    });

    // set ID of bastion security group as IPeer for access from bastion
    privateSg.addIngressRule(
      ec2.Peer.securityGroupId(bastionSg.securityGroupId),
      ec2.Port.tcp(22),
      "allow ssh access only from bastion instance"
    );
```

上記のように、SSHアクセスのために22番ポートへのアクセスを許可、
さらに、プライベート側のセキュリティグループのイングレスルールに、パブリック側のセキュリティグループのIDを指定する。

### SSH keypairの作成

SSHアクセスのためには、SSH keyを作成する必要がある。
これには、cdk-ec2-key-pairというパッケージを利用すれば、keyの作成からSecretsManagerへの保存まで全てやってくれる。

https://github.com/udondan/cdk-ec2-key-pair

```typescript
    // key pair
    const bastionKey = new KeyPair(this, "BationKey", {
      name: "bastion-keypair",
      description: "ssh key pair for bastion host",
      storePublicKey: true,
    });

    bastionKey.grantReadOnPublicKey;

    const privateKey = new KeyPair(this, "PrivateKey", {
      name: "private-keypair",
      description: "ssh key pair for private host",
      storePublicKey: true,
    });

    privateKey.grantReadOnPublicKey;
```

最後に、ここまで作ってきたセキュリティーグループとSSH keypairをEC2インスタンスに渡してあげる。
まとめると以下のようになる。

```typescript
import * as cdk from "aws-cdk-lib";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import { KeyPair } from "cdk-ec2-key-pair";
import { Construct } from "constructs";

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

    // VPC
    const vpc = new ec2.Vpc(this, "Vpc", {
      ipAddresses: ec2.IpAddresses.cidr("10.0.0.0/16"),
      maxAzs: 1,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: "public-subnet",
          subnetType: ec2.SubnetType.PUBLIC,
        },
        {
          cidrMask: 24,
          name: "private-subnet",
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
        },
      ],
    });

    // Security Group
    const bastionSg = new ec2.SecurityGroup(this, "BastionSg", {
      vpc,
      description: "Allow ssh access to bastion instance",
      allowAllOutbound: true,
    });

    bastionSg.addIngressRule(
      ec2.Peer.anyIpv4(),
      ec2.Port.tcp(22),
      "allow ssh access only from me"
    );

    const privateSg = new ec2.SecurityGroup(this, "PrivateSg", {
      vpc,
      description: "Allow ssh access from bastion instance",
      allowAllOutbound: true,
    });

    // set ID of bastion security group as IPeer for access from bastion
    privateSg.addIngressRule(
      ec2.Peer.securityGroupId(bastionSg.securityGroupId),
      ec2.Port.tcp(22),
      "allow ssh access only from bastion instance"
    );

    // key pair
    const bastionKey = new KeyPair(this, "BationKey", {
      name: "bastion-keypair",
      description: "ssh key pair for bastion host",
      storePublicKey: true,
    });

    bastionKey.grantReadOnPublicKey;

    const privateKey = new KeyPair(this, "PrivateKey", {
      name: "private-keypair",
      description: "ssh key pair for private host",
      storePublicKey: true,
    });

    privateKey.grantReadOnPublicKey;

    // EC2
    new ec2.Instance(this, "BastionHost", {
      vpc,
      vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
      instanceType: ec2.InstanceType.of(
        ec2.InstanceClass.T2,
        ec2.InstanceSize.MICRO
      ),
      machineImage: new ec2.AmazonLinuxImage({
        generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2,
      }),
      securityGroup: bastionSg,
      keyName: bastionKey.keyPairName,
    });

    new ec2.Instance(this, "PrivateHost", {
      vpc,
      vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
      instanceType: ec2.InstanceType.of(
        ec2.InstanceClass.T2,
        ec2.InstanceSize.MICRO
      ),
      machineImage: new ec2.AmazonLinuxImage({
        generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2,
      }),
      securityGroup: privateSg,
      keyName: privateKey.keyPairName,
    });
  }
}
```

### ローカル環境からSSHでアクセス

まずは、作成したスクリプトを使ってデプロイする。
デプロイ方法を再掲。

```bash
# 環境変数でデプロイ先のアカウントやリージョンを指定
$ export AWS_PROFILE=<Your target profile>
$ export CDK_DEPLOY_ACCOUNT=<Your account id>
$ export CDK_DEPLOY_REGION=<Your target region>

# CDKのスクリプトからCloudformationテンプレートが作成できるか確認
$ cdk synth

# cdk bootstrapは最初だけ必要
$ cdk bootstrap

$ cdk deploy
```

次に、SecretsManagerからSSHの秘密鍵を取得します。

```bash
$ aws secretsmanager get-secret-value \
--secret-id ec2-ssh-key/bastion-keypair/private \
--query SecretString \
--output text > bastion-key.pem

$ aws secretsmanager get-secret-value \
--secret-id ec2-ssh-key/private-keypair/private \
--query SecretString \
--output text > private-key.pem

$ chmod 400 bastion-key.pem
$ chmod 400 private-key.pem
```

また、AWSコンソールにアクセスし、
bastionインスタンスのPublic IPv4 DNS名、プライベートEC2インスタンスのPrivate IPv4 DNS名をメモしておく。

ここで確認のために、踏み台サーバーにSSHアクセスしてみるのもあり。
踏み台サーバー経由でプライベートサブネット内のEC2にアクセスするのは、以下のコマンドでできる。

```bash
$ ssh -i private-key.pem \
-o ProxyCommand='ssh -i bastion-key.pem ec2-user@<public IPv4 DNS name of bastion host> -W %h:%p' \
ec2-user@<private IPv4 DNS of private host>

The authenticity of host 'ip-XX-XX-XXX.ap-northeast-1.compute.internal (<no hostip for proxy command>)' can't be established.
ECDSA key fingerprint is SHA256:XXXXXXXXXXXXXXXXXXXXXXXXX.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'ip-XX-X-X-XXX.ap-northeast-1.compute.internal' (ECDSA) to the list of known hosts.

       __|  __|_  )
       _|  (     /   Amazon Linux 2 AMI
      ___|\___|___|

https://aws.amazon.com/amazon-linux-2/
```

プライベートサブネット内のEC2インスタンスにSSHできれば成功。

## bastion → RDS

上記のようにSSHで踏み台経由のアクセスができたが、
SSMアクセスができるようになったことから、AWS公式の見解ではSSHアクセスは推奨されていない。

そこで、bastionサーバーにSSMでアクセスしてRDSにアクセスする構成も作ってみた。

### RDS

ネットワーク構成自体は、ほとんどEC2への踏み台経由のアクセスと同じ。
異なる点は、RDSがシングルサブネットに対応しておらず、最低でも2サブネット構成にする必要があったため、
Multi AZ構成にして、2つのAvailability Zoneでそれぞれ2つのサブネットを作った。

```typescript
    // availability zone x2, subnet type (private/public) => 4 subnets will be created
    const vpc = new ec2.Vpc(this, "Vpc", {
      ipAddresses: ec2.IpAddresses.cidr("10.0.0.0/16"),
      maxAzs: 2,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: "public-subnet",
          subnetType: ec2.SubnetType.PUBLIC,
        },
        {
          cidrMask: 24,
          name: "private-subnet",
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        },
      ],
    });
```

RDS自体の設定は以下のようになる。

```typescript
    new rds.DatabaseCluster(this, "Database", {
      engine: rds.DatabaseClusterEngine.auroraMysql({
        version: rds.AuroraMysqlEngineVersion.VER_2_07_8,
      }),
      credentials: rds.Credentials.fromGeneratedSecret("admin"),
      instanceProps: {
        instanceType: ec2.InstanceType.of(
          ec2.InstanceClass.BURSTABLE2,
          ec2.InstanceSize.SMALL
        ),
        vpcSubnets: {
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        },
        securityGroups: [databaseSg],
        vpc,
      },
    });
  }
```

### BastionHostLinuxの使用

CDKはBastionHostLinuxというモジュールを提供している。
これは踏み台サーバー用のEC2のセットアップ構成で、SSMアクセスが許可されている。

```typescript
    new ec2.BastionHostLinux(this, "BastionHost", {
      vpc,
      subnetSelection: { subnetType: ec2.SubnetType.PUBLIC },
      instanceType: ec2.InstanceType.of(
        ec2.InstanceClass.T2,
        ec2.InstanceSize.MICRO
      ),
      machineImage: new ec2.AmazonLinuxImage({
        generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2,
      }),
      securityGroup: bastionSg,
    });
```

### 踏み台サーバーからRDSへのアクセス

デプロイが成功したら、AWSコンソールから以下の情報をメモしておく。

- SecretsManagerに登録されたDBユーザー情報のsecret ID
- RDSクラスターの書き込みエンドポイント

RDSのデータベースユーザーの情報はSecretsManagerに登録される。
AWSコンソールからSecret IDを確認し、AWS CLIを使って取得しておく。

```bash
$ aws secretsmanager get-secret-value \
--secret-id <RDS user info secret ID> \
--query SecretString \
--output text | jq .
{
  "dbClusterIdentifier": "rdsaccessviabastionstack-dev-databasexxxxxxxxx-xxxxxxxxxx",
  "password": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "engine": "mysql",
  "port": 3306,
  "host": "rdsaccessviabastionstack-dev-databasexxxxxxxxx-xxxxxxxxx.cluster-xxxxxxxxxxxxxxxxxx.us-east-1.rds.amazonaws.com",
  "username": "admin"
}
```

AWSコンソールから踏み台サーバーにSSMアクセスする。

```bash
sudo su - ec2-user

# install mysql CLI
$ sudo yum install -y mysql
$ mysql --version
mysql  Ver 15.1 Distrib 5.5.68-MariaDB, for Linux (x86_64) using readline 5.1

# access RDS cluster
$ mysql -h <writer endpoint of cluster> -u admin -p
Enter password: <Enter password obtained from SecretsManager>
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MySQL connection id is 19
Server version: 5.7.12 MySQL Community Server (GPL)

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

# Create DB user
MySQL [(none)]> SELECT Host, User FROM mysql.user;
+-----------+-----------+
| Host      | User      |
+-----------+-----------+
| %         | admin     |
| localhost | mysql.sys |
| localhost | rdsadmin  |
+-----------+-----------+
3 rows in set (0.01 sec)

MySQL [(none)]> CREATE USER testuser@'%' IDENTIFIED BY 'testEncP';
Query OK, 0 rows affected (0.01 sec)

MySQL [(none)]> GRANT ALL ON testapp.* TO testuser@'%' WITH GRANT OPTION;
Query OK, 0 rows affected (0.00 sec)

MySQL [(none)]> SELECT Host, User FROM mysql.user;
+-----------+-----------+
| Host      | User      |
+-----------+-----------+
| %         | admin     |
| %         | testuser  |
| localhost | mysql.sys |
| localhost | rdsadmin  |
+-----------+-----------+
5 rows in set (0.00 sec)

# Create database
MySQL [(none)]> CREATE DATABASE movies;
Query OK, 1 row affected (0.01 sec)

MySQL [(none)]> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| movies             |
| mysql              |
| performance_schema |
| sys                |
+--------------------+
5 rows in set (0.00 sec)

MySQL [(none)]> USE movies;
Database changed

# Create table
MySQL [movies]> CREATE TABLE movies(
    -> title VARCHAR(50) NOT NULL,
    -> genre VARCHAR(30) NOT NULL,
    -> director VARCHAR(60) NOT NULL,
    -> release_year INT NOT NULL,
    -> PRIMARY KEY(title));
Query OK, 0 rows affected (0.12 sec)

MySQL [movies]> DESCRIBE movies;
+--------------+-------------+------+-----+---------+-------+
| Field        | Type        | Null | Key | Default | Extra |
+--------------+-------------+------+-----+---------+-------+
| title        | varchar(50) | NO   | PRI | NULL    |       |
| genre        | varchar(30) | NO   |     | NULL    |       |
| director     | varchar(60) | NO   |     | NULL    |       |
| release_year | int(11)     | NO   |     | NULL    |       |
+--------------+-------------+------+-----+---------+-------+
4 rows in set (0.00 sec)

MySQL [movies]> INSERT INTO movies VALUE ("Joker", "psychological thriller", "Todd Phillips", 2019);
Query OK, 1 row affected (0.01 sec)

MySQL [movies]> SELECT * FROM movies;
+-------+------------------------+---------------+--------------+
| title | genre                  | director      | release_year |
+-------+------------------------+---------------+--------------+
| Joker | psychological thriller | Todd Phillips |         2019 |
+-------+------------------------+---------------+--------------+
1 row in set (0.00 sec)
```

## インスタンスの削除

RDSについては、スナップショットが残るので、忘れずに手動で消しておく。

```bash
cdk destroy
```

## Tips

### デプロイ時にパラメータを渡す

デプロイ時にコンテキスト変数として、引数を渡すことができる。
SSHアクセスの際に、自分のPCのIPからのアクセスに制限するようにしてみた。

```typescript
const myIp = this.node.tryGetContext("myIp");

...

    const ipv4Peer =
      myIp === undefined ? ec2.Peer.anyIpv4() : ec2.Peer.ipv4(myIp);

    bastionSg.addIngressRule(
      ipv4Peer,
      ec2.Port.tcp(22),
      "allow ssh access only from me"
    );
```

### デプロイスタックにステージ名を追加する

ステージ名もコンテキスト変数として渡すようにした。
基本的には、スタック名にステージ名が含まれるようにすれば、デプロイ時に他のステージ名と見分けることができる。

その場合は、bin/以下にある<プロジェクト名>.tsを編集すればいい。

```typescript
#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import { Ec2AccessViaBastionStack } from "../lib/ec2-access-via-bastion-stack";

const app = new cdk.App();
const stage = app.node.tryGetContext("stage")
  ? app.node.tryGetContext("stage")
  : "dev";

new Ec2AccessViaBastionStack(app, `Ec2AccessViaBastionStack-${stage}`, {
  /* If you don't specify 'env', this stack will be environment-agnostic.
   * Account/Region-dependent features and context lookups will not work,
   * but a single synthesized template can be deployed anywhere. */
  /* Uncomment the next line to specialize this stack for the AWS Account
   * and Region that are implied by the current CLI configuration. */
  // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
  /* Uncomment the next line if you know exactly what Account and Region you
   * want to deploy the stack to. */
  // env: { account: '123456789012', region: 'us-east-1' },
  /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
  env: {
    account: process.env.CDK_DEPLOY_ACCOUNT || process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEPLOY_REGION || process.env.CDK_DEFAULT_REGION,
  },
});
```

問題になるのがS3で、S3は全世界でユニークなバケット名にする必要がある。
そのため、先に述べたIPアドレスのようにして、各サービス名にステージ名を持たせるようにすればいい。

## まとめ

AWS CDKを使えばインフラ構築の設定をかなり簡略化できる。
ただし、その分作りたいインフラのサービス設定をちゃんと理解していないと難易度は高いと感じた。
まずは、AWSコンソールやAWS CLIで作りたいサービス構成と設定を確認することが必要。
また、プログラミング感覚でインフラを構築してトライアンドエラーを積み上げていくアプローチでいくのもあり。

## 参考

AWS公式のクイックスタート
https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/hello_world.html

AWS公式: CDKでFargateを構築
https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/ecs_example.html

CDK Workshop
https://cdkworkshop.com/

CDKでEC2のキーペアを作成
https://dev.classmethod.jp/articles/build-ec2-key-pair-with-aws-cdk/

https://qiita.com/Brutus/items/bba6a49a1a05c3277673