CDKでEC2 Instance Connect EndpointなEC2インスタンスの構築

株式会社システムアイの川合です。

前回、[CDKのことはじめ](https://qiita.com/ryohk/items/ff7c56ebc2e159d1165c)を投稿しました。あの記事では準備段階までの内容でしたが、今回は実際にコードを書いて、AWSリソースをデプロイするところまで進めたいと思います。

## 今回構築する構成

今回は、EC2インスタンスをデプロイするコードを作成します。ただし、単純な構成だと面白くないので、EC2 Instance Connect Endpointを活用した構成にします。以下のようなシンプルなアーキテクチャを構築します。

![qiita-20241208-thread-caseEIC.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1425487/58c47c28-fbd0-65f3-df50-9f9ef56033f1.png)

### EC2 Instance Connect Endpointとは？

**EC2 Instance Connect Endpoint**は以下の利点を持つサービスです：

- **SSHキー管理の簡略化**: 事前に公開鍵を配布せずにインスタンスへ接続可能
- **セキュリティ向上**: IAMでアクセス制御が可能
- **ブラウザ接続の利便性**: AWSマネジメントコンソールから直接接続可能

特に、インターネットにインスタンスを公開せずにプライベートネットワーク内のインスタンスに接続できるため、セキュリティ要件が厳しい環境でも有用です。

![qiita-20241208-thread-casePublic.drawio (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1425487/b723b08f-3edf-8dd2-e1e7-6ca303f1bcbd.png)


この仕組みを使うことで、踏み台サーバーを公開せずに安全にサーバー作業が可能となり、**IAMによるアクセス制御**や**CloudTrailでのログ記録**も行えます。
ただし、通常のSSHとは接続方法が異なるため、少し慣れが必要です。

## CDKを使ったコード作成

それでは、**AWS CDK**を使ってコードを書いていきます。前回の記事のコードを基に、`src/main.ts` を編集します。

元からあるコードの ```MyStack``` の ```constructor``` にコードを追記していきます。

<details><summary>コード全容はこちらになります。</summary>

```typescript
import { App, CfnOutput, Stack, StackProps } from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import { Construct } from 'constructs';

export class MyStack extends Stack {
  constructor(scope: Construct, id: string, props: StackProps = {}) {
    super(scope, id, props);

    /**
     * VPC
     */
    const vpc = new ec2.Vpc(this, 'Vpc', {
      ipAddresses: ec2.IpAddresses.cidr('10.0.0.100/16'),
      maxAzs: 2,
      flowLogs: {},
      subnetConfiguration: [
        {
          name: 'Public',
          cidrMask: 24,
          subnetType: ec2.SubnetType.PUBLIC,
        },
        {
          name: 'Private',
          cidrMask: 22,
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        },
        {
          name: 'Protected',
          cidrMask: 22,
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
        },
      ],
    });

    /**
     * EC2 Instance Connect Endpoint
     */
    // Security Group
    const eiceSecurityGroup = new ec2.SecurityGroup(this, 'EiceSecurityGroup', {
      vpc,
      allowAllOutbound: false,
      securityGroupName: 'EiceSecurityGroup',
    });

    // EC2 Instance Connect Endpoint
    const instanceConnectEndpoint = new ec2.CfnInstanceConnectEndpoint(
      this,
      'InstanceConnectEndpoint',
      {
        subnetId: vpc.privateSubnets[0].subnetId,
        securityGroupIds: [eiceSecurityGroup.securityGroupId],
      },
    );

    /**
     * EC2 Instance
     */
    // Security Group
    const instanceSecurityGroup = new ec2.SecurityGroup(
      this,
      'InstanceSecurityGroup',
      {
        vpc,
        allowAllOutbound: true,
      },
    );

    // EC2
    const instance = new ec2.Instance(this, 'Instance', {
      vpc,
      instanceType: new ec2.InstanceType('t3.micro'),
      machineImage: ec2.MachineImage.latestAmazonLinux2(),
      securityGroup: instanceSecurityGroup,
      vpcSubnets: {
        subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
      },
    });

    // Security Group of EC2 Instance Connect
    instanceSecurityGroup.addIngressRule(eiceSecurityGroup, ec2.Port.SSH);
    eiceSecurityGroup.addEgressRule(instanceSecurityGroup, ec2.Port.SSH);

    instance.node.addDependency(instanceConnectEndpoint);

    new CfnOutput(this, 'InstanceId', { value: instance.instanceId });
    new CfnOutput(this, 'EIC Command', { value: `aws ec2-instance-connect ssh --instance-id ${instance.instanceId} --connect-type eice` });
  }
}

// for development, use account/region from cdk cli
const devEnv = {
  account: process.env.CDK_DEFAULT_ACCOUNT,
  region: process.env.CDK_DEFAULT_REGION,
};

const app = new App();

new MyStack(app, 'cdk-sample-app-dev', { env: devEnv });
// new MyStack(app, 'cdk-sample-app-prod', { env: prodEnv });

app.synth();

```

</details>

### VPCの作成

まず、VPCを作成するコードを追加します。以下のコードで、VPCとその中のサブネットを定義します。

```typescript:VPCコード部分
/**
 * VPC
 */
const vpc = new ec2.Vpc(this, 'Vpc', {
  ipAddresses: ec2.IpAddresses.cidr('10.0.0.100/16'),
  maxAzs: 2,
  flowLogs: {},
  subnetConfiguration: [
    {
      name: 'Public',
      cidrMask: 24,
      subnetType: ec2.SubnetType.PUBLIC,
    },
    {
      name: 'Private',
      cidrMask: 22,
      subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
    },
    {
      name: 'Protected',
      cidrMask: 22,
      subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
    },
  ],
});
```

このコードで、サブネットやルートテーブル、NATゲートウェイなどが自動的にデプロイされます。

### EC2 Instance Connect Endpointの作成

次に、Instance Connect Endpointを作成します。セキュリティグループも設定します。

```typescript:EC2 Instace Connect Endpoint部分
/**
 * EC2 Instance Connect Endpoint
 */
// Security Group
const eiceSecurityGroup = new ec2.SecurityGroup(this, 'EiceSecurityGroup', {
  vpc,
  allowAllOutbound: false,
  securityGroupName: 'EiceSecurityGroup',
});

// EC2 Instance Connect Endpoint
const instanceConnectEndpoint = new ec2.CfnInstanceConnectEndpoint(
  this,
  'InstanceConnectEndpoint',
  {
    subnetId: vpc.privateSubnets[0].subnetId,
    securityGroupIds: [eiceSecurityGroup.securityGroupId],
  },
);
```

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

最後に、Amazon Linux 2023を使ったEC2インスタンスをデプロイします。

```typescript:EC2部分
/**
 * EC2 Instance
 */
// Security Group
const instanceSecurityGroup = new ec2.SecurityGroup(
  this,
  'InstanceSecurityGroup',
  {
    vpc,
    allowAllOutbound: true,
  },
);

// EC2
const instance = new ec2.Instance(this, 'Instance', {
  vpc,
  instanceType: new ec2.InstanceType('t3.micro'),
  machineImage: ec2.MachineImage.latestAmazonLinux2(),
  securityGroup: instanceSecurityGroup,
  vpcSubnets: {
    subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
  },
});

// Security Group of EC2 Instance Connect
instanceSecurityGroup.addIngressRule(eiceSecurityGroup, ec2.Port.SSH);
eiceSecurityGroup.addEgressRule(instanceSecurityGroup, ec2.Port.SSH);

instance.node.addDependency(instanceConnectEndpoint);
```

以下のコード部分でInstance Connect EndpointとEC2インスタンスのアクセス制御を設定しています。

```typescript
    // Security Group of EC2 Instance Connect
    instanceSecurityGroup.addIngressRule(eiceSecurityGroup, ec2.Port.SSH);
    eiceSecurityGroup.addEgressRule(instanceSecurityGroup, ec2.Port.SSH);
```

これらのコードで、Instance Connect EndpointとEC2の接続に必要なセキュリティグループが設定されます。

## デプロイ

コードが完成したら、デプロイを実行します。
その前に、buildタスクでコードの構文やテストを確認しましょう。

### ビルドとテスト

buildタスクを実行するとCloudformationのコード生成とテストの実行がされます。
以下にタスクの実行例を記述します。

```bash:buildタスクの事項
npx projen build
Enter MFA code for arn:aws:iam::744425351436:mfa/ubikey: 289723
👾 build » default | ts-node --project tsconfig.dev.json .projenrc.ts
👾 Installing dependencies...
👾 install | yarn install --check-files
! The local project doesn't define a 'packageManager' field. Corepack will now add one referencing yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e.
! For more details about this field, consult the documentation at https://nodejs.org/api/packages.html#packagemanager

yarn install v1.22.22
[1/4] 🔍  Resolving packages...
success Already up-to-date.
✨  Done in 0.51s.
👾 build » post-compile » synth:silent | cdk synth -q
****************************************************
*** Newer version of CDK is available [2.172.0]  ***
*** Upgrade recommended (npm install -g aws-cdk) ***
****************************************************
👾 build » test | jest --passWithNoTests --updateSnapshot
 PASS  test/main.test.ts (9.44 s)
  ✓ Snapshot (46 ms)

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
----------|---------|----------|---------|---------|-------------------
All files |     100 |      100 |     100 |     100 |                   
 main.ts  |     100 |      100 |     100 |     100 |                   
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   1 passed, 1 total
Time:        9.496 s
Ran all test suites.
👾 build » test » eslint | eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern  src test build-tools projenrc .projenrc.ts
(node:20379) ESLintRCWarning: You are using an eslintrc configuration file, which is deprecated and support will be removed in v10.0.0. Please migrate to an eslint.config.js file. See https://eslint.org/docs/latest/use/configure/migration-guide for details. An eslintrc configuration file is used because you have the ESLINT_USE_FLAT_CONFIG environment variable set to false. If you want to use an eslint.config.js file, remove the environment variable. If you want to find the location of the eslintrc configuration file, use the --debug flag.
(Use node --trace-warnings ... to show where the warning was created)
```

buildタスクの最後にjestでのテストが実行されています。
今回は説明しませんでしたが、projenでは初期状態でテスト用のコードやタスクも用意されていますので準備の手間がないです。

テスト用のコードは ```test/main.test.ts``` にファイルがあるので興味があれば確認してください。
（最初の状態でスナップショットテストが定義されています。）

### デプロイ

buildタスクが正常終了を確認したら、デプロイを行います。
デプロイは前回の記事で記載しましたが以下のコマンドで実行できます。

```bash:デプロイコマンド
npx projen deploy
```

デプロイが完了するまでコーヒーでも飲んで待ちましょう。

## EC2への接続方法

最後にEC2への接続についてです。
今回EC2にはSSHを設定していないので通常のSSHコマンドでの接続はできません。
接続には EC2 Instance Connect Endpointを使用します。
コマンドは以下のようにawcコマンドを使用します。

```bash
aws ec2-instance-connect ssh --instance-id <EC2インスタンスのID> --connection-type eice
```

接続に成功するとEC2のシェル画面が表示されていると思います。
あとはSSHでログインしたときと同様にインスタンス内で操作ができます。

## おわりに

簡単でしたがCDKを使ったコードの書き方やprojenの内容について記載しました。
今回はコードの書き方だけでしたが、ファイルの分割方法や整理の仕方などもかけていけたらと思います。

CDKだと型がしっかりしているため、値の間違いや引数の誤りが少ないのでデプロイ時の心配が軽減できているなと感じます。
AWSだけを使う場合はCDKも選択肢の一つかなと思いました。

今回のコードは下記にあるのでもしご興味があればご覧になってください。

https://github.com/ryoh/cdk-sample-app