[AWS] 踏み台サーバ不要！ローカルPCから、Private Subnet上のEC2インスタンスにssh/scpする

# はじめに
この記事では「[[AWS] Private Subnet内のEC2インスタンスにローカルPCのターミナルからSession Managerでアクセスする](https://qiita.com/herohit-tool/items/85ccfd7799474c81fb6a)」の内容を改善し、もっと簡単に環境を構築できる手順を解説します。

そして、何がセキュアかというと、Private Subnetに配置したEC2インスタンスに直接接続できる、という点に注目したいと思います。そう、踏み台サーバが不要なのです。


# 事前準備
まず、最初にクライアント環境に以下のツールが必要ですので、必要に応じてご用意ください。

- [AWS Cli](https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/install-cliv2.html)
- [Session Manager Plugin for the AWS CLI](https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html)

また、AWSのクレデンシャル情報も事前に設定してくおいてください。
環境変数

```bash
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
```

もし必要であれば、

```bash
AWS_DEFAULT_REGION
```

も設定してください。
もしくは、

```bash
$ aws configure
```

で設定しても、どちらでも構いません。

# キーペアの作成
クライアントからssh/scpでアクセスするためのキーペアを作成します。
この手順は、既にキーペアが存在し、ローカル環境に.pemファイルをダウンロード済みの場合はスキップして構いません。

```bash
$ aws ec2 create-key-pair --key-name [キーペア名] --query 'KeyMaterial' --output text > [ローカル保存する.pemファイル名]
```

例

```bash
$ aws ec2 create-key-pair --key-name ssm-key --query 'KeyMaterial' --output text > ssm-key.pem
```

作成したファイルは、アクセス権を変更しておいてください。

```bash
$ chmode 400 ssm-key.pem
```

# CloudFormationの実行

## テンプレート

### 構成概説
以下テンプレートは、以下の環境を構築します。

- VPC
- Subnet（Private Subnetとして作成）
- ルートテーブル（EndPointにのみルーティング）
- セキュリティグループ（443のみ許可）
- エンドポイント（ssm、ssmmessages、ec2messages、s3）
- EC2インスタンス
- EBS

```yml
AWSTemplateFormatVersion: "2010-09-09"
Description:
  Create Session Manager Environment

Metadata:
  "AWS::CloudFormation::Interface":
    ParameterGroups:
      - Label:
          default: "Project Name Prefix"
        Parameters:
          - ProjectPrefix
      - Label:
          default: "Network Configuration"
        Parameters:
          - VPCCIDR
          - PrivateSubnetCIDR
      - Label:
          default: "EC2"
        Parameters:
          - AmiID
          - InstanceType
          - KeyPair

    ParameterLabels: 
      VPCCIDR:
        default: "VPC CIDR"
      PrivateSubnetCIDR: 
        default: "PrivateSubnet CIDR"

Parameters:
  ProjectPrefix:
    Type: String
    Default: "SSM-ACCESS-ENV"

  VPCCIDR:
    Type: String
    Default: "10.1.0.0/16"

  PrivateSubnetCIDR:
    Type: String
    Default: "10.1.100.0/24"

  AmiID:
    Type: String
    Default: "ami-0053d11f74e9e7f52"

  InstanceType:
    Type: String
    Default: "t3.xlarge"

  KeyPair:
    Type: String
    Default: "ssm-key"

Resources:
  VPC:
    Type: "AWS::EC2::VPC"
    Properties:
      CidrBlock: !Ref VPCCIDR
      EnableDnsSupport: "true"
      EnableDnsHostnames: "true"
      InstanceTenancy: default
      Tags:
        - Key: Name
          Value: !Sub "${ProjectPrefix}-vpc"

  PrivateSubnet:
    Type: "AWS::EC2::Subnet"
    Properties:
      AvailabilityZone: !Sub "${AWS::Region}a"
      CidrBlock: !Ref PrivateSubnetCIDR
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub "${ProjectPrefix}-private-subnet"

  PrivateRouteTable:
    Type: "AWS::EC2::RouteTable"
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub "${ProjectPrefix}-private-route"

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

  PrivateSecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      GroupDescription : "Private SecurityGroup"
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub "${ProjectPrefix}-security-group"
      SecurityGroupIngress:
        - IpProtocol: "tcp"
          FromPort: "443"
          ToPort: "443"
          CidrIp: !Ref VPCCIDR
      SecurityGroupEgress:
        - CidrIp: "127.0.0.1/32"
          IpProtocol: "-1"

  EndPointSSM:
    Type: "AWS::EC2::VPCEndpoint"
    Properties:
      PrivateDnsEnabled: true
      SecurityGroupIds:
        - !Ref PrivateSecurityGroup
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.ssm"
      SubnetIds:
        - !Ref PrivateSubnet
      VpcEndpointType: "Interface"
      VpcId: !Ref VPC

  EndPointSSMMessages:
    Type: "AWS::EC2::VPCEndpoint"
    Properties:
      PrivateDnsEnabled: true
      SecurityGroupIds:
        - !Ref PrivateSecurityGroup
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.ssmmessages"
      SubnetIds:
        - !Ref PrivateSubnet
      VpcEndpointType: "Interface"
      VpcId: !Ref VPC

  EndPointEC2Messages:
    Type: "AWS::EC2::VPCEndpoint"
    Properties:
      PrivateDnsEnabled: true
      SecurityGroupIds:
        - !Ref PrivateSecurityGroup
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.ec2messages"
      SubnetIds:
        - !Ref PrivateSubnet
      VpcEndpointType: "Interface"
      VpcId: !Ref VPC

  EndPointS3:
    Type: "AWS::EC2::VPCEndpoint"
    Properties:
      RouteTableIds:
        - !Ref PrivateRouteTable
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.s3"
      VpcEndpointType: "Gateway"
      VpcId: !Ref VPC

  EC2IAMRole:
    Type: "AWS::IAM::Role"
    Properties:
      RoleName: !Sub "${ProjectPrefix}-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - "ec2.amazonaws.com"
            Action:
              - "sts:AssumeRole"
      Path: "/"
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"

  EC2InstanceProfile:
    Type: "AWS::IAM::InstanceProfile"
    Properties:
      Path: "/"
      Roles:
        - Ref: EC2IAMRole
      InstanceProfileName: !Sub "${ProjectPrefix}-PROFILE"

  EC2SecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      GroupDescription : "EC2 SecurityGroup"
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub "${ProjectPrefix}-ec2-security-group"
      SecurityGroupIngress:
        - IpProtocol: "tcp"
          FromPort: "22"
          ToPort: "22"
          CidrIp: "0.0.0.0/0"

  EC2Instance:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: !Ref InstanceType
      SubnetId: !Ref PrivateSubnet
      ImageId: !Ref AmiID
      SecurityGroupIds:
        - !Ref EC2SecurityGroup
      IamInstanceProfile: !Ref EC2InstanceProfile
      BlockDeviceMappings:
        - DeviceName: /dev/xvda
          Ebs:
            VolumeSize: 100
            VolumeType: gp2
      EbsOptimized: true
      SourceDestCheck: true
      KeyName: !Ref KeyPair
      Tags:
        - Key: Name
          Value: !Sub "${ProjectPrefix}-ec2"

Outputs:
  VPC:
    Value: !Ref VPC
    Export:
      Name: !Sub "${ProjectPrefix}-vpc"

  VPCCIDR:
    Value: !Ref VPCCIDR
    Export:
      Name: !Sub "${ProjectPrefix}-vpc-cidr"

  PrivateSubnet:
    Value: !Ref PrivateSubnet
    Export:
      Name: !Sub "${ProjectPrefix}-private-subnet"

  PrivateRouteTable:
    Value: !Ref PrivateRouteTable
    Export:
      Name: !Sub "${ProjectPrefix}-private-route"

  PrivateSecurityGroup:
    Value: !Ref PrivateSecurityGroup
    Export:
      Name: !Sub "${ProjectPrefix}-private-securitygroup"

  EndPointSSM:
    Value: !Ref EndPointSSM
    Export:
      Name: !Sub "${ProjectPrefix}-endpoint-ssm"

  EndPointSSMMessages:
    Value: !Ref EndPointSSMMessages
    Export:
      Name: !Sub "${ProjectPrefix}-endpoint-ssmmessages"

  EndPointEC2Messages:
    Value: !Ref EndPointEC2Messages
    Export:
      Name: !Sub "${ProjectPrefix}-endpoint-ec2messages"

  EndPointS3:
    Value: !Ref EndPointS3
    Export:
      Name: !Sub "${ProjectPrefix}-endpoint-s3"

  EC2SecurityGroup:
    Value: !Ref EC2SecurityGroup
    Export:
      Name: !Sub "${ProjectPrefix}-ec2-security-group"

  EC2Instance:
    Value: !Ref EC2Instance
    Export:
      Name: !Sub "${ProjectPrefix}-ec2"
```

以下の内容をファイルに保存してください。以降、テンプレートファイル名を「ssmenv.yml」という前提で記述します。

## スタックの作成

```bash
$ aws cloudformation create-stack --stack-name SSMEnvStack --template-body file://ssmenv.yml --capabilities CAPABILITY_NAMED_IAM
{
    "StackId": "arn:aws:cloudformation:ap-northeast-1:767054379442:stack/DPM-DATA-MIGRATION-ENV-Stack/0e0b65d0-f268-11ea-be88-06e06928d272"
}
```

もし、KeyPareを、上記で作成したものと違うものを指定する場合は、コマンドの最後に

```bash
--parameters ParameterKey=KeyPair,ParameterValue="キーペア名"
```

のようにしてデフォルトのパラメータ名を上書きしてください。
他に上書き可能なパラメータは、以下の通りです。

| パラメータ名 | 設定値 | デフォルト値 |
|---|---|---|
| ProjectPrefix | 各リソースに設定する名称の接頭子 | SSM-ACCESS-ENV |
| VPCCIDR | VPCのCIDR | 10.1.0.0/16 |
| PrivateSubnetCIDR | SubnetのCIDR | 10.1.100.0/24 |
| AmiID | EC2のAMI ID | ami-0053d11f74e9e7f52 |
| InstanceType | EC2のインスタンスタイプ | t3.xlarge |
| KeyPair | EC2のキーペア名 | ssm-key |

なお、AMIは、Amazon Linux 2 64bitを前提にしていますが、リージョンによってはIDが異なります。上記は、ap-northeast-1が前提です。

また、上記以外のEBSのボリュームサイズなどは、適宜テンプレートを修正してください。

# sshの準備
sshコマンドでSession Manager経由でEC2インスタンスに接続するためには  
`ホームディレクトリ/.ssh/config`  
に、以下の記述を行います。

```text:config
host i-* mi-*
    ProxyCommand sh -c "aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p'"
```

# ssh実行
sshでEC2インスタンスに接続するには、以下の構文で行います。

```bash
$ ssh -i [pemファイル] ec2-user@[EC2インスタンスのインスタンスID]
```

です。

例

```bash
$ ssh -i ssm-key.pem ec2-user@i-0af4afe4df0b3900b

       __|  __|_  )
       _|  (     /   Amazon Linux 2 AMI
      ___|\___|___|

https://aws.amazon.com/amazon-linux-2/
[ec2-user@ip-10-1-100-148 ~]$
```

## EC2インスタンスのインスタンスID

以下のいずれかの方法で取得できます。

- マネジメントコンソールのEC2のインスタンスの一覧から取得する
- AWS Cliでインスタンス一覧にフィルターをかけて取得する

    ```bash
    $ aws ec2 describe-instances --filters "Name=tag:Name,Values={ProjectPrefixの値}-ec2" --query "Reservations[].Instances[].InstanceId"
    ```

    例

    ```bash
    $ aws ec2 describe-instances --filters "Name=tag:Name,Values=SSM-ACCESS-ENV-ec2" --query "Reservations[].Instances[].InstanceId"
[
        "i-0af4afe4df0b3900b"
]
    ```

# まとめ
CloudFormationで構成しているため、スタックを削除すると、全て綺麗に消えてくれます。
今回はVPCごと作成するようになっていますが、少しテンプレートをいじることで、既存のVPCに接続することももちろんできます。
その場合は、EndPointの接続先になるサブネットなどにご注意ください。