AWS CDK で Infrastructure as Code する: Session Manager接続するEC2編

こんにちは。@masatomixです。

https://qiita.com/masatomix/items/80385fb3e9f30d85bb15

の記事で AWS Systems Manager Session Manager (以下SSM)経由でアクセスするEC2への接続をおこないましたが、そのEC2を作るCDKのコードです。


![image-20250426001428513](https://imgur.com/QsiFhyQ.png)


流れとしては、

- 接続に必要な経路(VPCエンドポイント)の構築
- 踏み台サーバ側のEC2に設定するEC2ロールの作成
- EC2インスタンスの作成

って感じのコードになります。

## やってみる

### 接続に必要な経路(VPCエンドポイント)の構築

まず、SSMを使用するのに必要な経路を構築します。

参考:[Systems Manager のために VPC エンドポイントを使用して EC2 インスタンスのセキュリティを強化する](https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/setup-create-vpc.html)


```typescript:lib/VPCEndpointStack.ts
import { App, ScopedAws, Stack, StackProps } from 'aws-cdk-lib'
import { CfnSecurityGroup, CfnSecurityGroupIngress, CfnSubnet, CfnVPC, CfnVPCEndpoint } from 'aws-cdk-lib/aws-ec2'
import { Profile, getProfile, toRefs } from './Utils'

type VPCEndpointStackProps = StackProps & {
  vpc: CfnVPC
  subnets: CfnSubnet[]
}

export class VPCEndpointStack extends Stack {
  constructor(scope: App, id: string, props: VPCEndpointStackProps) {
    super(scope, id, props)
    const p = getProfile(this)
    const { accountId, region } = new ScopedAws(this)

    const { vpc, subnets } = props

    const vpcEndpoints = [
      `com.amazonaws.${region}.ec2messages`,
      `com.amazonaws.${region}.ssm`,
      `com.amazonaws.${region}.ssmmessages`
    ]

    const vpcEndpointSecurityGroup = createVPCEndpointSecurityGroup(this, `VPCEndpointSG`, vpc, p)

    vpcEndpoints.forEach((vpcEndpoint, index) => {
      new CfnVPCEndpoint(this, `vpcEndpoint-${index}`, {
        serviceName: vpcEndpoint,
        vpcId: vpc.ref,
        vpcEndpointType: 'Interface',
        subnetIds: toRefs(subnets),
        securityGroupIds: [vpcEndpointSecurityGroup.ref],
        privateDnsEnabled: true,
      })
    })
  }
}

const createVPCEndpointSecurityGroup = (stack: Stack, id: string, vpc: CfnVPC, p: Profile): CfnSecurityGroup => {
  const group = new CfnSecurityGroup(stack, id, {
    groupName: `${p.name}vpcendpoint-sg`,
    groupDescription: 'vpcendpoint-sg',
    vpcId: vpc.attrVpcId,
    tags: [{ key: 'Name', value: `${p.name}VPCEndpointSG` }],
  })

  new CfnSecurityGroupIngress(stack, 'SecurityGroupIngress000', {
    ipProtocol: '-1',
    groupId: group.ref,
    sourceSecurityGroupId: group.ref,
  })

  new CfnSecurityGroupIngress(stack, 'SecurityGroupIngress001', {
    ipProtocol: 'tcp',
    fromPort: 443,
    toPort: 443,
    groupId: group.ref,
    cidrIp: vpc.attrCidrBlock,
  })

  return group
}
```

設定されたVPCエンドポイント(VPC >> PrivateLink と Lattice >> エンドポイント)
![vpcendpoint](https://imgur.com/Jc1DDqG.png)

そのエンドポイントに紐付けられたセキュリティグループ
![sg](https://imgur.com/WfJPcbR.png)

VPCからのポート443への接続を許可しています。


### 踏み台サーバに設定するEC2ロールの作成

踏み台サーバに設定するロールを作成します。SSM接続するEC2には``AmazonSSMManagedInstanceCore``というポリシーが必要なので、それを設定しています。

```typescript:lib/EC2RoleStack.ts
import { App, Stack, StackProps } from 'aws-cdk-lib'
import { CfnRole } from 'aws-cdk-lib/aws-iam'

export class EC2RoleStack extends Stack {
  public readonly role: CfnRole

  constructor(scope: App, id: string, props?: StackProps) {
    super(scope, id, props)

    this.role = new CfnRole(this, 'EC2_SSM_Role', {
      path: '/',
      roleName: 'EC2_SSM_Role',
      assumeRolePolicyDocument: {
        Version: '2012-10-17',
        Statement: [
          {
            Sid: '',
            Effect: 'Allow',
            Principal: {
              Service: 'ec2.amazonaws.com',
            },
            Action: 'sts:AssumeRole',
          },
        ],
      },
      // maxSessionDuration: 3600,
      managedPolicyArns: ['arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore'],
      description: 'EC2_SSM_Role',
    })
  }
}

```

設定されたロールです。(IAM >> ロール)

![role](https://imgur.com/XWiTIAF.png)

たしかに ``AmazonSSMManagedInstanceCore`` ポリシーが設定されています。



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

最後にEC2インスタンスを作成します。

```typescript:lib/BastionStack.ts
import { App, Stack, StackProps } from 'aws-cdk-lib'
import {
  CfnInstance,
  CfnKeyPair,
  CfnSecurityGroup,
  CfnSecurityGroupIngress,
  CfnSubnet,
  CfnVPC,
} from 'aws-cdk-lib/aws-ec2'
import { getProfile } from './Utils'
import { CfnInstanceProfile, CfnRole } from 'aws-cdk-lib/aws-iam'

type BastionStackProps = StackProps & {
  vpc: CfnVPC
  subnet: CfnSubnet
  role: CfnRole
}

export class BastionStack extends Stack {
  constructor(scope: App, id: string, props: BastionStackProps) {
    super(scope, id, props)
    const p = getProfile(this)

    const { vpc, subnet, role } = props

    // SecurityGroupの作成
    const bastionSG = createBastionSG(this, vpc, 'bastion')
    const ec2SG = createEC2SG(this, vpc, bastionSG, 'ec2')

    // キーペアの作成
    const keyPair = new CfnKeyPair(this, 'KeyPair', {
      keyName: `${p.name}-KeyPair`,
    })

    const instanceProfile = new CfnInstanceProfile(this, 'InstanceProfileEc2', {
      roles: [role.ref],
    })

    // EC2インスタンスの作成
    createEC2({
      stack: this, subnet, groupSet: [bastionSG.ref],
      associatePublicIpAddress: false,
      name: 'bastion',
      instanceProfile,
    })
    createEC2({
      stack: this, subnet, groupSet: [ec2SG.ref],
      associatePublicIpAddress: false,
      name: 'dest',
      keyName: keyPair.ref,
    })
  }
}

type FC = (args: {
  stack: Stack,
  subnet: CfnSubnet,
  groupSet: string[],
  keyName?: string,
  associatePublicIpAddress: boolean,
  name: string,
  instanceProfile?: CfnInstanceProfile
}) => CfnInstance

const createEC2: FC = ({
  stack, subnet, groupSet,
  keyName,
  associatePublicIpAddress,
  name,
  instanceProfile
}): CfnInstance => {

  const baseProps = {
    imageId: 'ami-04beabd6a4fb6ab6f',
    // imageId: 'ami-00d101850e971728d',
    instanceType: 't2.micro',
    networkInterfaces: [
      {
        associatePublicIpAddress,
        deviceIndex: '0',
        subnetId: subnet.attrSubnetId,
        groupSet,
      },
    ],
    tags: [{ key: 'Name', value: `${name}-ec2` }],
  }

  const keyNameProps = keyName ? { ...baseProps, keyName } : baseProps
  const props = instanceProfile ? { ...keyNameProps, iamInstanceProfile: instanceProfile.ref } : keyNameProps
  return new CfnInstance(stack, `${name}-BastionEC2`, props)
}

const createBastionSG = (stack: Stack, vpc: CfnVPC, prefix: string): CfnSecurityGroup => {
  const group = new CfnSecurityGroup(stack, `${prefix}SG`, {
    groupName: `${prefix}-sg`,
    groupDescription: `${prefix} SecurityGroup`,
    vpcId: vpc.attrVpcId,
    tags: [{ key: 'Name', value: `${prefix}-sg` }],
  })
  return group
}

const createEC2SG = (stack: Stack, vpc: CfnVPC, bastionSG: CfnSecurityGroup, prefix: string): CfnSecurityGroup => {
  const group = new CfnSecurityGroup(stack, `${prefix}SG`, {
    groupName: `${prefix}-sg`,
    groupDescription: `${prefix} SecurityGroup`,
    vpcId: vpc.attrVpcId,
    tags: [{ key: 'Name', value: `${prefix}-sg` }],
  })

  new CfnSecurityGroupIngress(stack, `${prefix}SGIngress000`, {
    // ipProtocol: '-1',
    ipProtocol: 'tcp',
    fromPort: 22,
    toPort: 22,
    groupId: group.ref,
    sourceSecurityGroupId: bastionSG.ref,
  })
  return group
}
```

一部抜粋すると、下記のコードあたりで、作成したロールを指定してEC2インスタンスを作成していますね。


```typescript
    const instanceProfile = new CfnInstanceProfile(this, 'InstanceProfileEc2', {
      roles: [role.ref],
    })

    // EC2インスタンスの作成
    createEC2({
      stack: this, subnet, groupSet: [bastionSG.ref],
      associatePublicIpAddress: false,
      name: 'bastion',
      instanceProfile,
    })
```



踏み台のインスタンス

![bastion](https://imgur.com/92ZUULd.png)

IAM ロールに、``EC2_SSM_Role``が設定されていますね！

本丸のインスタンス
![dest](https://imgur.com/gOqR1VU.png)

本丸のサーバは、192.168.1.204が割り当てられました(最初のキャプチャと異なりますが、IPは自動採番なので適宜読み替えてください)。

### 全体のCDK

最後にエントリポイントとなるコード。

```typescript:bin/cdk-samples.ts
#!/usr/bin/env node
import * as cdk from "aws-cdk-lib";
import { VPCStack } from "../lib/VPCStack";
import { BastionStack } from "../lib/BastionStack";
import { VPCEndpointStack } from "../lib/VPCEndpointStack";
import { EC2RoleStack } from "../lib/EC2RoleStack";

const main = () => {
    const app = new cdk.App()
    const vpcStack = new VPCStack(app, "VPCStack")
    new VPCEndpointStack(app, "VPCEndpointStack", {
        vpc: vpcStack.vpc,
        subnets: vpcStack.privateSubnets,
    })
    const ec2roleStack = new EC2RoleStack(app, "EC2RoleStack")
    new BastionStack(app, "BastionStack", {
        vpc: vpcStack.vpc, subnet: vpcStack.privateSubnets[0], role: ec2roleStack.role
    })
}


main()
```

CDKのコードの説明は以上です。ということでCDKを実行します。下記サイトを参考にしつつ、コマンドを実行してみてください。
参考: [AWS CDK で Infrastructure as Code する: EC2編](https://qiita.com/masatomix/items/03dd59929ef1869ea027) 

```console
$ yarn cdk deploy --all --profile ${profile}
... しばらくかかります
$
```

### 接続する

さてCDKの実行が終わったので、前の記事にしたがって接続します。

前記事 :[Session Manager経由のEC2アクセスから、SSHポートフォワードまでのメモ](https://qiita.com/masatomix/items/80385fb3e9f30d85bb15)


```console
$ aws ssm start-session --target ${instance_id} --profile ${profile} \
 --document-name AWS-StartPortForwardingSessionToRemoteHost \
 --parameters localPortNumber=2222,portNumber=22,host=192.168.1.204

Starting session with SessionId: masatomix@xxx-orhlql7j9qhcuijuudqiofg34e
Port 2222 opened for sessionId masatomix@xxx-orhlql7j9qhcuijuudqiofg34e.
Waiting for connections...
```

ポートフォワードが出来たので、別プロンプトで、

```console
$ ssh -l ec2-user localhost -p2222 -i ~/.ssh/hoge.pem
   ,     #_
   ~\_  ####_        Amazon Linux 2023
  ~~  \_#####\
  ~~     \###|
  ~~       \#/ ___   https://aws.amazon.com/linux/amazon-linux-2023
   ~~       V~' '->
    ~~~         /
      ~~._.   _/
         _/ _/
       _/m/'
Last login: Tue May  6 12:47:25 2025 from 192.168.1.234
[ec2-user@ip-192-168-1-204 ~]$
```

OKそうです。以上、お疲れさまでした！




## 関連リンク

- https://github.com/masatomix/cdk-samples/releases/tag/ec2 CDKのソースコード
- [Session Manager経由のEC2アクセスから、SSHポートフォワードまでのメモ](https://qiita.com/masatomix/items/80385fb3e9f30d85bb15)
- [Session Managerを使ってEC2に接続するにはどのような経路が必要なのか](https://qiita.com/nori0329/items/43f15603b342b2df8d8b)
- [AWS CDK で Infrastructure as Code する: EC2編](https://qiita.com/masatomix/items/03dd59929ef1869ea027) 
- [Session Manager経由のEC2アクセスから、SSHポートフォワードまでのメモ](https://qiita.com/masatomix/items/80385fb3e9f30d85bb15)