はじめに
AWS CDKでプロジェクトを作成するときの手順をまとめておく。
お勉強で終わりたくないので、
まずは基本のEC2から、実用面で踏み台サーバーを使ってインスタンスにアクセスする構成を作る。
作成したデプロイスクリプトは以下にある。
環境
- Windows Subsystem for Linux(ubuntu 20.04)
- 使用言語は Typescript
$ cdk --version
2.55.1 (build 30f1ae4)
セットアップ
CDK CLI はインストールしておく。
$ npm install -g aws-cdk
プロジェクトの作成
# プロジェクトディレクトリを作成
$ 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がそれにあたる。
$ 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ならその設定もかなり簡略化できる。
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の良いところだ。
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を指定していないのに!)
# 環境変数でデプロイ先のアカウントやリージョンを指定
$ 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側)からプライベートサブネットへの通信を許可する設定が必要。
// 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への保存まで全てやってくれる。
// 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インスタンスに渡してあげる。
まとめると以下のようになる。
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でアクセス
まずは、作成したスクリプトを使ってデプロイする。
デプロイ方法を再掲。
# 環境変数でデプロイ先のアカウントやリージョンを指定
$ 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の秘密鍵を取得します。
$ 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にアクセスするのは、以下のコマンドでできる。
$ 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つのサブネットを作った。
// 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自体の設定は以下のようになる。
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アクセスが許可されている。
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を使って取得しておく。
$ 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アクセスする。
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については、スナップショットが残るので、忘れずに手動で消しておく。
cdk destroy
Tips
デプロイ時にパラメータを渡す
デプロイ時にコンテキスト変数として、引数を渡すことができる。
SSHアクセスの際に、自分のPCのIPからのアクセスに制限するようにしてみた。
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を編集すればいい。
#!/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/