LoginSignup
1
1

More than 1 year has passed since last update.

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

Posted at

はじめに

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/

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1