【CloudFormation】IPv4/IPv6/Dual-Stackを切り替え可能なネットワークとEC2の作成

初投稿になります。自身の理解をより深めるためのアウトプットをするために投稿しました。  
今月1日からIPv4のパブリックIPアドレスが全て課金対象になったため、これはIPv6が使える環境を用意できるようにならなければという危機感を感じました。(今更感...)  
本稿のテンプレートファイルはそれに対応するために作成しました。  
切り替えについては単純に興味本位で試みたものです。  
本稿のテンプレートファイルで作成する構成はお勉強用のものだと思っています。  
現場ではこんな単純な構成は稀でしょうし、きちんとした要件定義の元にIPプロトコル等の仕様を決定するものと思います。

## 概要
- 本稿のテンプレートファイルを全てデプロイすることで、ネットワークからEC2 Instance Connect Endpointまで手軽に準備できます
  - ご利用は自己責任でお願いいたします
- 本稿のリソースのみ展開する場合は、2024/2/23時点において12ヶ月の無料利用枠内で全て作成可能です
  - 筆者は12ヶ月の無料利用枠がある状態です。2月初旬にリソース作成を行いましたが今のところ請求情報に課金されているものは見当たりません
  - defaultのVPCやらサブネットやらは存在している状態で大丈夫です
  - 一応、ネットワークレイヤーとセキュリティレイヤーは12ヶ月の無料利用枠が切れていても課金は発生しないと思います
- 東京リージョンでの作成を想定しています
- CloudShellもしくはローカルのAWS CLIからコマンドでデプロイすることを前提にしていますが、マネジメントコンソールからの作成も可能です
- EC2 Instance Connect、EC2 Instance Connect Endpointはおまけです
- !Refや!Cidr等の組み込み関数、AWS::StackNameやAWS::NoValue等の疑似パラメータ等は下記の公式ユーザーガイドを参照願います
  - [組み込み関数リファレンス](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/pseudo-parameter-reference.html)

## 想定読者
- CloudFormationの知見を深めたい方
- IPv6やDual-Stackにする方法を知りたい方
- CloudFormationでサクッとEC2を使える環境を作りたい方
- EC2 Instance Connect、EC2 Instance Connect Endpointを試してみたい方

内容の理解には、サーバーやネットワークについてある程度の知識が必要かと思います。

## リソースの全体構成
<img width="800" alt="cfn-ipv46d-diagrams" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3715783/a9f897f3-3151-18a8-9ff7-bd72a1f605e2.png">

## スタックのレイヤー分け
| レイヤー | スタック名 | テンプレートファイル名 |
| :----: | :----: | :----: |
| アプリケーション | ipv46d-eicendpoint-cfn<br>ipv46d-ec2-cfn | ipv46d-eicendpoint-cfn.yml<br>ipv46d-ec2-cfn.yml |
| セキュリティ | ipv46d-securitygroup-cfn | ipv46d-securitygroup-cfn.yml |
| ネットワーク | ipv46d-network-cfn | ipv46d-network-cfn.yml |

スタック作成の際はネットワークレイヤーからアプリケーションレイヤーの順番でデプロイしてください。削除の場合はその逆になります。<br>

ネットワークレイヤーのIPプロトコル切り替えを行う際は、アプリケーションレイヤーを削除してから更新してください。  
あくまで作成時に任意に選択できるという認識をするとよろしいかと思います。<br>

下層レイヤーのリソースの参照にはクロススタック参照を利用しています。  
例えばipv46d-securitygroup-cfn.ymlは、BaseNetworkStackNameのDefaultとして「ipv46d-network-cfn」というスタック名を参照しています。  
スタック作成の際にスタック名を任意のものに設定できますが、参照している上層レイヤーでは任意に設定したスタック名をパラメータ指定する必要があります。  
ipv46d-network-cfn.ymlのスタック名を「exampleNet」とした場合、ipv46d-securitygroup-cfn.ymlのBaseNetworkStackNameを「exampleNet」というパラメータ指定にします。

## ネットワーク作成
<details><summary>ymlファイル</summary>

```yml:ipv46d-network-cfn.yml
AWSTemplateFormatVersion: "2010-09-09"
Description: "Create Network. IPv4, IPv6, or Dual-Stack can be selected"
# -----------------
# Input Parameters
# -----------------
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      -
        Parameters:
          - SystemName
          - CidrBlock
          - IpProtocolEnv

Parameters:
  SystemName:
    Description: Please type the SystemName.
    Type: String
    Default: IPv46D

  CidrBlock:
    Description: Please type the CidrBlock.
    Type: String
    Default: 10.1.0.0/16

  IpProtocolEnv:
    Description: Please type either IPv4-Only, IPv6-Only, or Dual-Stack for IpProtocolEnv.
    Type: String
    Default: IPv6-Only
    AllowedValues:
      - IPv4-Only
      - IPv6-Only
      - Dual-Stack

Conditions:
  CreateIPv4Route: !Not
    - !Equals
      - !Ref IpProtocolEnv
      - IPv6-Only
  CreateIPv6Network: !Not
    - !Equals
      - !Ref IpProtocolEnv
      - IPv4-Only

Resources:
# -----------------
# VPC
# -----------------
  BaseVPC:
    Type: "AWS::EC2::VPC"
    Properties:
      CidrBlock: !Sub ${CidrBlock}
      Tags:
        - Key: Name
          Value: !Sub ${SystemName}-vpc

  BaseVPCCidrBlockIPv6:
    Type: "AWS::EC2::VPCCidrBlock"
    Properties:
      AmazonProvidedIpv6CidrBlock: true
      VpcId: !Ref BaseVPC
# -----------------
# Subnet
# -----------------
  PublicSubnet:
    DependsOn: BaseVPCCidrBlockIPv6
    Type: "AWS::EC2::Subnet"
    Properties:
      VpcId: !Ref BaseVPC
      AvailabilityZone: ap-northeast-1c
      CidrBlock: !Select [ 1, !Cidr [ !GetAtt BaseVPC.CidrBlock, 3, 8 ]]
      Tags:
        - Key: Name
          Value: !Sub ${SystemName}-subnet-public

  PublicSubnetCidrBlockIPv6:
    Type: "AWS::EC2::SubnetCidrBlock"
    Condition: CreateIPv6Network
    Properties:
      Ipv6CidrBlock: !Select [ 1, !Cidr [ !Select [ 0, !GetAtt BaseVPC.Ipv6CidrBlocks ], 3, 64 ]]
      SubnetId: !Ref PublicSubnet


  PrivateSubnet:
    DependsOn: BaseVPCCidrBlockIPv6
    Type: "AWS::EC2::Subnet"
    Properties:
      VpcId: !Ref BaseVPC
      AvailabilityZone: ap-northeast-1c
      CidrBlock: !Select [ 2, !Cidr [ !GetAtt BaseVPC.CidrBlock, 3, 8 ]]
      Tags:
        - Key: Name
          Value: !Sub ${SystemName}-subnet-private

  PrivateSubnetCidrBlockIPv6:
    Type: "AWS::EC2::SubnetCidrBlock"
    Condition: CreateIPv6Network
    Properties:
      Ipv6CidrBlock: !Select [ 2, !Cidr [ !Select [ 0, !GetAtt BaseVPC.Ipv6CidrBlocks ], 3, 64 ]]
      SubnetId: !Ref PrivateSubnet
# -----------------
# InternetGateway
# -----------------
  BaseIGW:
    Type: "AWS::EC2::InternetGateway"
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${SystemName}-igw

  AttachBaseIGW:
    Type: "AWS::EC2::VPCGatewayAttachment"
    Properties:
      VpcId: !Ref BaseVPC
      InternetGatewayId: !Ref BaseIGW
# -----------------
# RouteTable
# -----------------
  PublicRouteTable:
    Type: "AWS::EC2::RouteTable"
    Properties:
      VpcId: !Ref BaseVPC
      Tags:
        - Key: Name
          Value: !Sub ${SystemName}-rt-public

  PublicRouteTableAssociation:
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Properties:
      RouteTableId: !Ref PublicRouteTable
      SubnetId: !Ref PublicSubnet

  PublicRoute:
    Type: "AWS::EC2::Route"
    Condition: CreateIPv4Route
    Properties:
      RouteTableId: !Ref PublicRouteTable
      GatewayId: !Ref BaseIGW
      DestinationCidrBlock: 0.0.0.0/0

  PublicRouteIPv6:
    Type: "AWS::EC2::Route"
    Condition: CreateIPv6Network
    Properties:
      RouteTableId: !Ref PublicRouteTable
      GatewayId: !Ref BaseIGW
      DestinationIpv6CidrBlock: ::/0


  PrivateRouteTable:
    Type: "AWS::EC2::RouteTable"
    Properties:
      VpcId: !Ref BaseVPC
      Tags:
        - Key: Name
          Value: !Sub ${SystemName}-rt-private

  PrivateRouteTableAssociation:
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      SubnetId: !Ref PrivateSubnet

  PrivateRoute:
    Type: "AWS::EC2::Route"
    Condition: CreateIPv4Route
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      GatewayId: !Ref BaseIGW
      DestinationCidrBlock: 0.0.0.0/0

  PrivateRouteIPv6:
    Type: "AWS::EC2::Route"
    Condition: CreateIPv6Network
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      GatewayId: !Ref BaseIGW
      DestinationIpv6CidrBlock: ::/0
# -----------------
# Output
# -----------------
Outputs:
  BaseVPCId:
    Value: !Ref BaseVPC
    Export:
      Name: !Sub "${AWS::StackName}-BaseVPC"

  PublicSubnetId:
    Value: !Ref PublicSubnet
    Export:
      Name: !Sub "${AWS::StackName}-PublicSubnet"

  PrivateSubnetId:
    Value: !Ref PrivateSubnet
    Export:
      Name: !Sub "${AWS::StackName}-PrivateSubnet"
```
</details>

- Conditionsセクションの条件で切り替えを行っています
- VPCでIPv6を有効にするためAWS::EC2::VPCCidrBlockを定義しています
- AWS::EC2::Subnetでは、スタック削除の失敗を防ぐためにDependsOnを指定しています
- 切り替えの際にサブネットの再作成が発生しないようAWS::EC2::SubnetCidrBlockでIPv6を定義しています
  - そのため、サブネットにはIPv6-OnlyのときでもIPv4アドレス範囲を割り当てる形になっています

サブネットについて、AWS::EC2::Subnetのみによる下記のような実装も可能ですが、これだとIPv6←→Dual-Stackの切り替えが失敗します。
```yml
# -----------------
# Subnet
# -----------------
  PublicSubnet:
    Type: "AWS::EC2::Subnet"
    Properties:
      VpcId: !Ref BaseVPC
      AvailabilityZone: ap-northeast-1c
      CidrBlock: !If
        - CreateIPv4Route
        - !Select [ 1, !Cidr [ !GetAtt BaseVPC.CidrBlock, 3, 8 ]]
        - !Ref "AWS::NoValue"
      Ipv6CidrBlock: !If
        - CreateIPv6Network
        - !Select [ 1, !Cidr [ !Select [ 0, !GetAtt BaseVPC.Ipv6CidrBlocks ], 3, 64 ]]
        - !Ref "AWS::NoValue"
      Ipv6Native: !If
        - IsIPv6Native
        - true
        - !Ref "AWS::NoValue"
```
1. スタック更新の際にサブネットの再作成が発生する
1. 同じIPv6アドレス範囲を持ったサブネットの作成が試みられる
1. 古いサブネットのIPv6アドレス範囲が衝突する
1. スタック更新に失敗する

という流れのようです。  
CloudFormationのスタック更新の挙動として、Update requiresがReplacementになっているPropertiesが変更される場合、新しいリソースを作成してから古いリソースを削除するというものが影響しているようです。  
IPv6アドレス範囲の定義をAWS::EC2::SubnetCidrBlockで分けたipv46d-network-cfn.ymlの構成なら、サブネットの再作成が発生しないため切り替えが上手く行きます。

## セキュリティグループ作成
<details><summary>ymlファイル</summary>

```yml:ipv46d-securitygroup-cfn.yml
AWSTemplateFormatVersion: "2010-09-09"
Description: "Create SecurityGroup. IPv4, IPv6, or Dual-Stack can be selected"
# -----------------
# Input Parameters
# -----------------
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      -
        Parameters:
          - SystemName
          - EnvType
          - IpProtocolEnv
          - EC2InstanceConnect
          - IngressSSHCidr
          - IngressSSHCidrIPv6
          - EICEndpoint
          - BaseNetworkStackName

Parameters:
  SystemName:
    Description: Please type the SystemName.
    Type: String
    Default: IPv46D

  EnvType:
    Description: Please type the Environment Type.
    Type: String
    Default: develop
    AllowedValues:
      - develop
      - product

  IpProtocolEnv:
    Description: Please type either IPv4-Only, IPv6-Only, or Dual-Stack for IpProtocolEnv.
    Type: String
    Default: IPv6-Only
    AllowedValues:
      - IPv4-Only
      - IPv6-Only
      - Dual-Stack

  EC2InstanceConnect:
    Description: Please type whether you want to activate EC2InstanceConnect. Only ap-northeast-1 is assumed.
    Type: String
    Default: enable
    AllowedValues:
      - enable
      - disable

  IngressSSHCidr:
    Description: Please type the IngressSSH to IPv4 address range, in CIDR format.
    Type: String
    Default: 0.0.0.0/0

  IngressSSHCidrIPv6:
    Description: Please type the IngressSSH to IPv6 address range, in CIDR format.
    Type: String
    Default: ::/0

  EICEndpoint:
    Description: Please type whether you want to activate EC2InstanceConnectEndpoint.
    Type: String
    Default: enable
    AllowedValues:
      - enable
      - disable

  BaseNetworkStackName:
    Description: Please type the SharedServices stack name.
    Type: String
    Default: ipv46d-network-cfn

Conditions:
  IsProduction: !Equals
    - !Ref EnvType
    - product
  CreateEC2InstanceConnectRule: !Equals
    - !Ref EC2InstanceConnect
    - enable
  CreateIPv4Rule: !Not
    - !Equals
      - !Ref IpProtocolEnv
      - IPv6-Only
  CreateIPv6Rule: !Not
    - !Equals
      - !Ref IpProtocolEnv
      - IPv4-Only
  CreatePrivateInternetPolicy: !And
    - !Not
      - !Condition IsProduction
    - !Condition CreateIPv4Rule
  CreatePrivateInternetIPv6Policy: !And
    - !Not
      - !Condition IsProduction
    - !Condition CreateIPv6Rule
  CreateEICEndpointRule: !Equals
    - !Ref EICEndpoint
    - enable
  DisableEICEndpointRule: !Equals
    - !Ref EICEndpoint
    - disable

Resources:
# -----------------
# SecurityGroup
# -----------------
  PublicSG:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      GroupDescription: SecurityGroup for BastionEC2.
      GroupName: ipv46d-sg-public
      VpcId: !ImportValue
        'Fn::Join':
          - '-'
          - - !Ref BaseNetworkStackName
            - BaseVPC
      Tags:
        - Key: Name
          Value: !Sub ${SystemName}-${EnvType}-sg

  PublicIngressEC2InstanceConnect:
    Type: "AWS::EC2::SecurityGroupIngress"
    Condition: CreateEC2InstanceConnectRule
    Properties:
      IpProtocol: tcp
      FromPort: 22
      ToPort: 22
      GroupId: !Ref PublicSG
      CidrIp: 3.112.23.0/29

  PublicIngressSSH:
    Type: "AWS::EC2::SecurityGroupIngress"
    Condition: CreateIPv4Rule
    Properties:
      IpProtocol: tcp
      FromPort: 22
      ToPort: 22
      GroupId: !Ref PublicSG
      CidrIp: !Ref IngressSSHCidr

  PublicIngressSSHIPv6:
    Type: "AWS::EC2::SecurityGroupIngress"
    Condition: CreateIPv6Rule
    Properties:
      IpProtocol: tcp
      FromPort: 22
      ToPort: 22
      GroupId: !Ref PublicSG
      CidrIpv6: !Ref IngressSSHCidrIPv6

  PublicEgressSSH:
    Type: "AWS::EC2::SecurityGroupEgress"
    Properties:
      IpProtocol: tcp
      FromPort: 22
      ToPort: 22
      GroupId: !Ref PublicSG
      DestinationSecurityGroupId: !Ref PrivateSG

  PublicEgressICMP:
    Type: "AWS::EC2::SecurityGroupEgress"
    Properties:
      IpProtocol: icmp
      FromPort: -1
      ToPort: -1
      GroupId: !Ref PublicSG
      DestinationSecurityGroupId: !Ref PrivateSG

  PublicEgressInternet:
    Type: "AWS::EC2::SecurityGroupEgress"
    Condition: CreateIPv4Rule
    Properties:
      IpProtocol: tcp
      FromPort: 0
      ToPort: 65535
      GroupId: !Ref PublicSG
      CidrIp: 0.0.0.0/0

  PublicEgressInternetIPv6:
    Type: "AWS::EC2::SecurityGroupEgress"
    Condition: CreateIPv6Rule
    Properties:
      IpProtocol: tcp
      FromPort: 0
      ToPort: 65535
      GroupId: !Ref PublicSG
      CidrIpv6: ::/0


  PrivateSG:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      GroupDescription: SecurityGroup for InternalEC2.
      GroupName: ipv46d-sg-private
      VpcId: !ImportValue
        'Fn::Join':
          - '-'
          - - !Ref BaseNetworkStackName
            - BaseVPC
      Tags:
        - Key: Name
          Value: !Sub ${SystemName}-${EnvType}-sg

  PrivateIngressEICEndpoint:
    Type: "AWS::EC2::SecurityGroupIngress"
    Condition: CreateEICEndpointRule
    Properties:
      IpProtocol: tcp
      FromPort: 22
      ToPort: 22
      GroupId: !Ref PrivateSG
      SourceSecurityGroupId: !Ref EICEndpointSG

  PrivateIngressSSH:
    Type: "AWS::EC2::SecurityGroupIngress"
    Properties:
      IpProtocol: tcp
      FromPort: 22
      ToPort: 22
      GroupId: !Ref PrivateSG
      SourceSecurityGroupId: !Ref PublicSG

  PrivateIngressICMP:
    Type: "AWS::EC2::SecurityGroupIngress"
    Properties:
      IpProtocol: icmp
      FromPort: -1
      ToPort: -1
      GroupId: !Ref PrivateSG
      SourceSecurityGroupId: !Ref PublicSG

  PrivateEgressEICEndpoint:
    Type: "AWS::EC2::SecurityGroupEgress"
    Condition: CreateEICEndpointRule
    Properties:
      IpProtocol: tcp
      FromPort: 50001
      ToPort: 50100
      GroupId: !Ref PrivateSG
      DestinationSecurityGroupId: !Ref EICEndpointSG

  PrivateEgressInternet:
    Type: "AWS::EC2::SecurityGroupEgress"
    Condition: CreatePrivateInternetPolicy
    Properties:
      IpProtocol: tcp
      FromPort: 0
      ToPort: 65535
      GroupId: !Ref PrivateSG
      CidrIp: 0.0.0.0/0

  PrivateEgressInternetIPv6:
    Type: "AWS::EC2::SecurityGroupEgress"
    Condition: CreatePrivateInternetIPv6Policy
    Properties:
      IpProtocol: tcp
      FromPort: 0
      ToPort: 65535
      GroupId: !Ref PrivateSG
      CidrIpv6: ::/0

  PrivateEgressDefaultRemove:
    Type: "AWS::EC2::SecurityGroupEgress"
    Condition: IsProduction
    Properties:
      IpProtocol: -1
      GroupId: !Ref PrivateSG
      CidrIp: 127.0.0.1/32


  EICEndpointSG:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      GroupDescription: SecurityGroup for EICEndpoint.
      GroupName: ipv46d-sg-endpoint
      VpcId: !ImportValue
        'Fn::Join':
          - '-'
          - - !Ref BaseNetworkStackName
            - BaseVPC
      Tags:
        - Key: Name
          Value: !Sub ${SystemName}-${EnvType}-sg

  EICEndpointEgressSSH:
    Type: "AWS::EC2::SecurityGroupEgress"
    Condition: CreateEICEndpointRule
    Properties:
      IpProtocol: tcp
      FromPort: 22
      ToPort: 22
      GroupId: !Ref EICEndpointSG
      DestinationSecurityGroupId: !Ref PrivateSG

  EICEndpointEgressDefaultRemove:
    Type: "AWS::EC2::SecurityGroupEgress"
    Condition: DisableEICEndpointRule
    Properties:
      IpProtocol: -1
      GroupId: !Ref EICEndpointSG
      CidrIp: 127.0.0.1/32
# -----------------
# Output
# -----------------
Outputs:
  PublicSGId:
    Value: !Ref PublicSG
    Export:
      Name: !Sub "${AWS::StackName}-PublicSG"

  PrivateSGId:
    Value: !Ref PrivateSG
    Export:
      Name: !Sub "${AWS::StackName}-PrivateSG"

  EICEndpointSGId:
    Value: !Ref EICEndpointSG
    Export:
      Name: !Sub "${AWS::StackName}-EICEndpointSG"
```
</details>

- Conditionsセクションの条件で切り替えを行っています
  - EnvTypeのパラメータをproductにすることでPrivateSGのインターネット接続を制限できます
    - このような設定はほとんど行わないと思いますが、アウトバウンドのデフォルトルールを削除する方法を試したかったので実装してみました
      - もしかしたら、かなり特殊な状況で内部サーバーからの通信を一切インターネット側に出したくないというときに利用できるかもしれません
  - EC2InstanceConnectのパラメータをenableにすることでEC2 Instance Connectを利用できます
    - 公式ユーザーガイドによるとIPv6での接続はサポートされていないようです
  - EICEndpointのパラメータをenableにすることでEC2 Instance Connect Endpointを利用できます
    - 公式ユーザーガイドによるとIPv6での接続はサポートされていないようです
    - ssh接続のみのルールにしています。RDP接続のルールは定義していません
    - PrivateEgressEICEndpointのTCP 50001～50100番というのは、任意のTCPを設定できるそうなので適当に決めました
  - IngressSSHCidrのパラメータ指定で、PublicIngressSSHの許可するIPv4アドレスを任意に設定できます
    - Defaultでは全てのIPv4アドレス範囲としているのでセキュアではありません。自身の利用するIPアドレスをパラメータ指定することをお勧めします
    - ループバックアドレス「127.0.0.1/32」を指定することでssh接続を利用不可にすることもできます
  - IngressSSHCidrIPv6のパラメータ指定で、PublicIngressSSHIPv6の許可するIPv6アドレスを任意に設定できます
    - Defaultでは全てのIPv6アドレス範囲としているのでセキュアではありません。自身の利用するIPアドレスをパラメータ指定することをお勧めします
    - ループバックアドレス「::1/128」を指定することでssh接続を利用不可にすることもできます
- セキュリティグループのルールをAWS::EC2::SecurityGroupとは別に定義することで、ルールの切り替えをしやすくしています
  - この方法だと、スタック更新の際にセキュリティグループ自体のリソース再作成が発生しないのも良い点だと思っています

PublicIngressEC2InstanceConnectでは、ap-northeast-1のEC2 Instance Connect用のIPアドレス範囲をベタで指定しています。  
IPアドレス範囲は公式がip-ranges.jsonというファイルで公開しています。
```json:ip-ranges.json抜粋
    {
      "ip_prefix": "3.112.23.0/29",
      "region": "ap-northeast-1",
      "service": "EC2_INSTANCE_CONNECT",
      "network_border_group": "ap-northeast-1"
    },
```

## EC2作成
<details><summary>ymlファイル</summary>

```yml:ipv46d-ec2-cfn.yml
AWSTemplateFormatVersion: "2010-09-09"
Description: "Create EC2. IPv4, IPv6, or Dual-Stack can be selected"
# -----------------
# Input Parameters
# -----------------
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      -
        Parameters:
          - SystemName
          - EnvType
          - ImageID
          - EC2KeyPairBastion
          - EC2KeyPairInternal
          - IpProtocolEnv
          - BaseNetworkStackName
          - BaseSecurityGroupStackName

Parameters:
  SystemName:
    Description: Please type the SystemName.
    Type: String
    Default: IPv46D

  EnvType:
    Description: Please type the Environment Type.
    Type: String
    Default: develop
    AllowedValues:
      - develop
      - product

  ImageID:
    Description: Please type the EC2 image ID.
    Type: String
    Default: ami-07c589821f2b353aa # Ubuntu Server 22.04 LTS AMI
    #Default: ami-0a1c2ec61571737db # Amazon Linux 2 AMI

  EC2KeyPairBastion:
    Description: Please type BastionEC2 key name.
    Type: AWS::EC2::KeyPair::KeyName

  EC2KeyPairInternal:
    Description: Please type InternalEC2 key name.
    Type: AWS::EC2::KeyPair::KeyName

  IpProtocolEnv:
    Description: Please type either IPv4-Only, IPv6-Only, or Dual-Stack for IpProtocolEnv.
    Type: String
    Default: IPv6-Only
    AllowedValues:
      - IPv4-Only
      - IPv6-Only
      - Dual-Stack

  BaseNetworkStackName:
    Description: Please type the SharedServices stack name.
    Type: String
    Default: ipv46d-network-cfn

  BaseSecurityGroupStackName:
    Description: Please type the SharedServices stack name.
    Type: String
    Default: ipv46d-securitygroup-cfn

Mappings:
  IpProtocolEnvironmentMapping:
    IPv4-Only:
      AssociatePublicIPv4: true
      AddressCountIPv6: 0
    IPv6-Only:
      AssociatePublicIPv4: false
      AddressCountIPv6: 1
    Dual-Stack:
      AssociatePublicIPv4: true
      AddressCountIPv6: 1

Resources:
# -----------------
# EC2
# -----------------
  BastionEC2:
    Type: "AWS::EC2::Instance"
    Properties:
      AvailabilityZone: ap-northeast-1c
      ImageId: !Ref ImageID
      InstanceType: t2.micro
      KeyName: !Ref EC2KeyPairBastion
      BlockDeviceMappings:
        - DeviceName: /dev/sda1
          Ebs:
            VolumeType: gp3
            VolumeSize: 8
      NetworkInterfaces:
        - AssociatePublicIpAddress: !FindInMap [ IpProtocolEnvironmentMapping, !Ref IpProtocolEnv, AssociatePublicIPv4 ]
          DeviceIndex: "0"
          Ipv6AddressCount: !FindInMap [ IpProtocolEnvironmentMapping, !Ref IpProtocolEnv, AddressCountIPv6 ]
          GroupSet:
            - !ImportValue
              'Fn::Join':
                - '-'
                - - !Ref BaseSecurityGroupStackName
                  - PublicSG
          SubnetId: !ImportValue
            'Fn::Join':
              - '-'
              - - !Ref BaseNetworkStackName
                - PublicSubnet
      Tags:
        - Key: Name
          Value: !Sub ${SystemName}-${EnvType}-ec2-bastion

  InternalEC2:
    Type: "AWS::EC2::Instance"
    Properties:
      AvailabilityZone: ap-northeast-1c
      ImageId: !Ref ImageID
      InstanceType: t2.micro
      KeyName: !Ref EC2KeyPairInternal
      BlockDeviceMappings:
        - DeviceName: /dev/sda1
          Ebs:
            VolumeType: gp3
            VolumeSize: 8
      NetworkInterfaces:
        - AssociatePublicIpAddress: !FindInMap [ IpProtocolEnvironmentMapping, !Ref IpProtocolEnv, AssociatePublicIPv4 ]
          DeviceIndex: "0"
          Ipv6AddressCount: !FindInMap [ IpProtocolEnvironmentMapping, !Ref IpProtocolEnv, AddressCountIPv6 ]
          GroupSet:
            - !ImportValue
              'Fn::Join':
                - '-'
                - - !Ref BaseSecurityGroupStackName
                  - PrivateSG
          SubnetId: !ImportValue
            'Fn::Join':
              - '-'
              - - !Ref BaseNetworkStackName
                - PrivateSubnet
      Tags:
        - Key: Name
          Value: !Sub ${SystemName}-${EnvType}-ec2-internal
```
</details>

- こちらはEnvTypeを切り替えても特に変化はありません
  - 本来は開発環境と本番環境で何かを分けることもあるかなと思って記載してあるだけです(インスタンスタイプやEBS容量を分けるとか)
- EC2KeyPairBastionとEC2KeyPairInternalには存在するキーペアをパラメータ指定する必要があることに留意ください
  - 両方同じキーペアを指定しても大丈夫です
- MappingsセクションによるIPアドレスの選択を行っています
  - IPv6アドレスは1つだけ割り当てるようにしています

InternalEC2にssh接続する際は~/.ssh/configに下記のような記載をすると楽です。  
```
Host BastionEC2
    HostName BastionEC2のパブリックIPアドレス
    IdentityFile ~/.ssh/キーペア名.pem
    User ubuntu

Host InternalEC2
    HostName InternalEC2のプライベートIPアドレス
    IdentityFile ~/.ssh/キーペア名.pem
    User ubuntu
    ProxyJump BastionEC2
```
これでPowerShellやターミナル等から`ssh InternalEC2`を使えば接続できます。

## EC2 Instance Connect Endpoint作成
<details><summary>ymlファイル</summary>

```yml:ipv46d-eicendpoint-cfn.yml
AWSTemplateFormatVersion: "2010-09-09"
Description: "Create EICEndpoint. IPv4, IPv6, or Dual-Stack can be selected"
# -----------------
# Input Parameters
# -----------------
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      -
        Parameters:
          - SystemName
          - UsePreserveClientIp
          - BaseNetworkStackName
          - BaseSecurityGroupStackName

Parameters:
  SystemName:
    Description: Please type the SystemName.
    Type: String
    Default: IPv46D

  UsePreserveClientIp:
    Description: Please type use PreserveClientIp or not.
    Type: String
    Default: false
    AllowedValues:
      - true
      - false

  BaseNetworkStackName:
    Description: Please type the SharedServices stack name.
    Type: String
    Default: ipv46d-network-cfn

  BaseSecurityGroupStackName:
    Description: Please type the SharedServices stack name.
    Type: String
    Default: ipv46d-securitygroup-cfn

Resources:
# -----------------
# EICEndpoint
# -----------------
  EICEndpoint:
    Type: "AWS::EC2::InstanceConnectEndpoint"
    Properties:
      PreserveClientIp: !Ref UsePreserveClientIp
      SecurityGroupIds:
        - !ImportValue
          'Fn::Join':
            - '-'
            - - !Ref BaseSecurityGroupStackName
              - EICEndpointSG
      SubnetId: !ImportValue
        'Fn::Join':
          - '-'
          - - !Ref BaseNetworkStackName
            - PrivateSubnet
      Tags:
        - Key: Name
          Value: !Sub ${SystemName}-eic-endpoint
```
</details>

EC2 Instance Connectを試す際はエンドポイントのデプロイ前に行うとよろしいかと思います。  
理由は不明ですが、EC2 Instance ConnectとEC2 Instance Connect Endpointを同時に使えません。  
抜粋ですが、エラーメッセージとして確認したのは以下の2種類になります。上記のスタックで作成したエンドポイントが存在する状況でのものです。  
- The specified Instance Connect Endpoint does not exist or is not in create-complete state.
- Error establishing SSH connection to your instance. Try again later.
<br>

ガチャガチャ試してみると下記の特徴があることがわかりました。
- エンドポイントのスタックを削除してから10数分くらい経過すると改めてEC2 Instance Connectが使えるようになる
- EC2 Instance ConnectでBastionEC2に接続した状態でエンドポイントを作成可能
  - その状態でEC2 Instance Connect EndpointでInternalEC2に接続可能
  - 10分くらい経過してもEC2 Instance Connectのセッションは維持されていた
- 通常のssh接続はできる

何か上手く行かないときは自分がやらかしていることが往々にしてあるので、もし誤りを発見した方はご指摘いただけると幸いです。

## 参考にしたサイト
- [はじめてのCloudFormation](https://www.youtube.com/watch?v=8uvWfCs6orE)
- [【AWS】CloudFormationまとめ](https://zenn.dev/soshimiyamoto/articles/3c624728438902)
- [【AWS】CloudFormationで必要最低限なCLIをまとめてみた](https://qiita.com/hiyanger/items/245d96312248017701b0)
- [[AWS]CloudFormationでVPCのIPv6 CIDRを取得してサブネットに割り当てる](https://zenn.dev/tech_okmn/articles/67d3fe530d6704)
- [【備忘録】CloudFormationで IPv6 対応のVPCとサブネットを作る【CFn】](https://blog.serverworks.co.jp/2021/07/28/155936)
- [パブリック IPv4 の料金の開始に伴い...](https://aws.amazon.com/jp/about-aws/whats-new/2024/02/aws-free-tier-750-hours-free-public-ipv4-addresses/#:~:text=Amazon%20EC2%20%E3%81%AE%20AWS%20%E7%84%A1%E6%96%99,USD%20%E3%81%8C%E8%AA%B2%E9%87%91%E3%81%95%E3%82%8C%E3%81%BE%E3%81%99%E3%80%82)
- [EC2 Instance Connect Endpoint を使用した...](https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/connect-with-ec2-instance-connect-endpoint.html)

## 終わりに
AWS自体に触ってから3ヶ月余りでわからないことがたくさんあります。
試行錯誤を繰り返してより良いものが作れるように、これからも知見を積み上げて行きたいです。
本稿の内容が少しでも誰かのお役に立てば幸いです。