Cloud9からAnsibleとCloudFormationでAWSのインフラを管理する環境を構築する

# 動機
企業ネットワークで
 * 普段遣いのPCからはサーバーシステム環境に直接接続できない
 * システム毎に管理用端末は分離すべし、というルールがある
 * AWSコンソールに接続する端末は複数システムで共通化可能

みたいな制約がある場合に、インフラ管理用端末をシステム毎に用意していくのは手間がかかって管理も煩雑なので、インフラ管理用端末をCloud9で構築しようと考えた。

# 前提条件
* 対象業務はインフラ構築・運営
* 管理対象インフラはすべてAWS上にある
* 管理対象インフラのAWSリソースは基本的にCloudFormationで管理する
* 管理対象環境にはEC2で立てたLinuxサーバーがあり、設定はAnsibleで管理する
* LinuxのディストリビューションはRHEL
* 環境は東京リージョンに構築する
* 管理用端末は管理対象環境となるべく分離する

# 構成
PoC用の最小構成(AZなどの表現は省略)

![Cloud9構成図.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/693903/0aeaa577-447e-170d-8ed3-92a05b2aa8fc.png)

構成の意図としては
* 管理用端末は管理対象環境とVPCを分離してネットワーク的に独立させる
* 管理端末からはAWSの任意のAPIを叩くために、管理端末はパブリックネットワークに配置する
* Linuxサーバーを管理するため、管理端末のAnsibleからSSM経由のSSHで接続する
* プライベートネットワークのLinuxサーバーにSSMでセッションを張るために、SSMのVPCエンドポイントを作成する
* Linuxサーバーのセットアップ等に必要なファイルをS3から取得できるようにするため、S3のVPCエンドポイントを作成する

# やってみて分かったこと
　AnsibleからSession ManagerでLinuxサーバーに接続できる状態を実現するまでいくつかはまるところがあった。解決しなければならなかったポイントとしては
* AnsibleでLinuxサーバーを設定するのにセッションマネージャーで接続するためには、Ansibleで接続する前に(すなわちAnsibleを使わないで)SSM Agentをセットアップしないといけない → Amazon Linuxなどと違ってデフォルトでSSM Agentが入っていない
* Cloud9環境でデフォルトで設定されるAWSマネージド一時認証情報では、セッションマネージャー接続ができないという制約がある
* Ansibleからセッションマネージャー経由でSSHする場合には、SSH公開鍵認証の秘密鍵が必要 → 通常 aws ssm start-session でSSH接続する際に秘密鍵は必要ない

といったところ。Ansibleからのセッションマネージャー接続に秘密鍵が必要というのは何か回避策がありそうな気がするけど、直でSSH接続する場合には用意しているものなので、今回は素直に秘密鍵を用意することにした。

# 構築手順
AWSコンソールは、東京リージョンになっていることを確認して作業を進める。

1. 管理用のVPCを作成する → デフォルトのVPCを流用する場合には省略可
1. 管理端末をCloud9で構築する
1. 管理対象のVPC、サーバーを構築する 
1. 作成した環境の動作確認 (このとき期待する動作をしなかったところがあるため追加作業を実施した)

# 1. 管理用のVPCを作成する
CloudFormationで、以下のテンプレートでmgmt-vpcという名前のスタックを作成する。

```MgmtVpc.yml
---
AWSTemplateFormatVersion: 2010-09-09
Resources:
# Network
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 192.168.0.0/23
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-vpc
  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
      - Key: Name
        Value: !Sub ${AWS::StackName}-igw
  InternetGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      InternetGatewayId: !Ref InternetGateway
      VpcId: !Ref Vpc
  PublicSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      CidrBlock: 192.168.0.0/25
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-public-subnet
  PublicRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet
      RouteTableId: !Ref PublicRouteTable

# Route Table
  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref Vpc
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-public-rtb

# Routing
  PublicDefaultRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: '0.0.0.0/0'
      GatewayId: !Ref InternetGateway
```

このファイルをCloudShellの[アクション] > [ファイルのアップロード]でアップロードし、CloudShellから```aws cloudformation deploy```コマンドを実行してをスタック作成する。

```shell:管理用VPCスタック作成コマンド
aws cloudformation deploy --stack-name mgmt-vpc --template-file MgmtVpc.yml --capabilities CAPABILITY_NAMED_IAM
```


# 2. 管理端末をCloud9で構築する
AWSコンソールからインフラ管理用のCloud9環境を作成する。
1. Cloud9の画面に遷移し、[環境を作成]をクリック
1. パラメータを指定する(指定内容は例)
    1. 名前：mgmt-c9-MyCloud9
    1. 環境タイプ：新しいEC2インスタンス
    1. インスタンスタイプ：t2.micro
    1. プラットフォーム：Amazon Linux 2
    1. タイムアウト：30分
    1. 接続：AWS Systems Manager (SSM)
    1. VPC設定を展開
    1. Amazon 仮想プライベートクラウド (VPC)：さっき作成したVPC(名前:mgmt-vpc-vpc)
    1. サブネット：さっき作成したサブネット(名前:mgmt-vpc-public-subnet)
1. [作成]をクリック

これで環境が作成されるとともに、初回実行時はIAMリソースとして以下が自動的に作成される。
* AWSServiceRoleForAWSCloud9
* AWSCloud9SSMAccessRole
* AWSCloud9SSMInstanceProfile

環境の作成はCloudShellからも実行できるが、そのアカウントで１度もCloud9環境を作成していないとこれらのIAMリソースがないために失敗する。初回作成以降は、以下のようなコマンドで同様の環境が作成可能。

```shell:Cloud9環境作成コマンド
subnet_id=$(aws ec2 describe-subnets --filters Name=tag:Name,Values=mgmt-vpc-public-subnet --query 'Subnets[0].SubnetId' --output text)
aws cloud9 create-environment-ec2 \
  --name mgmt-c9-MyCloud9 \
  --instance-type t2.micro \
  --image-id amazonlinux-2-x86_64 \
  --automatic-stop-time-minutes 30 \
  --connection-type CONNECT_SSM \
  --subnet-id "${subnet_id}"
```

Cloud9環境が作成できたら、AWSコンソールのCloud9の画面でその環境を選択し[Cloud9を開く]でCloud9 IDEの画面を開く

IDEのBashターミナルから必要なツールをインストール・アップデートする

```shell
sudo yum -y install jq git
pip install git-remote-codecommit ansible cfn-lint
curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/linux_64bit/session-manager-plugin.rpm" -o "/tmp/session-manager-plugin.rpm"
sudo yum install -y /tmp/session-manager-plugin.rpm
```
インフラコード管理用のリポジトリをCodeCommitに作成する。
```shell
aws codecommit create-repository --repository-name AnsibleCfnDemoRepo
git clone codecommit://AnsibleCfnDemoRepo
```

# 3. 管理対象環境を構築する

管理対象環境を構築するためのAnsibleコードを用意する。
CodeCommitで管理するために、git cloneで作成した場所に、ローカル(PC)からアップロードする。

```shell:ディレクトリ、ファイルの配置想定
AnsibleCfnDemoRepo/
├── cfn_templates.yml # CloudFormation テンプレート生成などのためのPlaybook
├── group_vars
│   ├── env_dev.yml # 環境がprod, dev, ...などとあると想定し、環境依存する変数を定義するファイル
│   ├── env_prod.yml
│   └── system_demo.yml # 各環境共通の変数を定義するファイル
├── inventory
│   ├── dev # 環境ごとにインベントリをディレクトリで分ける
│   │   ├── hosts_aws_ec2.yml # EC2用の動的インベントリ
│   │   └── hosts_static.yml # 静的インベントリ
│   └── prod
│       ├── hosts_aws_ec2.yml
│       └── hosts_static.yml
└── roles
    └── cfn_templates
        ├── tasks
        │   └── main.yml
        └── templates
            ├── CfnS3.yml # CloudFormationテンプレートをアップロードする用のS3を作成する用のテンプレート
            └── SystemResource.yml # 管理対象環境のリソースを定義したテンプレート
```

今回はDemoシステムのdev環境を構築するという体で、それに必要なファイルを準備する。

```yaml:inventory/dev/hosts_static.yml
all:
  children:
    aws_ec2: # 動的インベントリのホストで使用する変数をここで定義
      vars:
        ansible_ssh_common_args: -o StrictHostKeyChecking=no -o ProxyCommand="sh -c \"aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p'\""
        ansible_user: ec2-user
        ansible_ssh_private_key_file: '~/.ssh/demo-{{ env_stage }}.key'
        ansible_become: true
    system_demo:
      children:
        env_dev:
          children:
            ansible_server: # このインベントリ使用時はenv_devのgroup_varsを参照する
              hosts:
                localhost:
                  ansible_connection: local
              vars:
                ansible_become: false
```
```yaml:inventory/dev/hosts_aws_ec2.yml
plugin: aws_ec2
regions:
  - ap-northeast-1
filters:
  # 起動しているEC2インスタンスのみを対象とする
  instance-state-name: running
  # 対象のシステム、環境を特定する
  tag:environment:
    - 'dev'
  tag:system-code:
    - 'demo'
exclude_filters:
  - tag:Name:
      - 'aws-cloud9-*' # Cloud9のインスタンスは除外する
keyed_groups:
  - key: tags.ServerType
    separator: ""
    parent_group: "env_dev"
hostnames:
  - tag:Name
compose:
  # ansible_host: private_ip_address
  ansible_host: instance_id # SSM Session Managerでホストに接続するためEC2のインスタンスIDをansible_hostに
```
```yaml:group_vars/system_demo.yml
system_code: demo
cfn_templates_path: "~/environment/cfn" # envirmentディレクトリはCloud9 IDEに合わせた設定
stack_name_dict:
  cfn_s3: "{{ system_code }}-{{ env_stage }}-cfn-s3"
  system_resource: "{{ system_code }}-{{ env_stage }}-system-resource"
```
```yaml:group_vars/env_dev.yml
env_stage: dev
main_vpc:
  cidr: 10.0.0.0/23
  subnet_dict:
    PrivateMain:
      - { az_num: 1,  cidr: 10.0.0.0/25  }
      - { az_num: 2,  cidr: 10.0.0.128/25 }
interface_endpoint_list: # Systems Managerに必要なエンドポイント
  - ec2messages
  - ssm
  - ssmmessages
demo_sv:
  hostname: DEMOSV01
  type: demo_servers
  subnet: PrivateMainAz1Subnet
  instance_type: t2.micro
  image_id: ami-04fdeff70b7359022 # 東京リージョンのRHEL-9.2.0_HVM-20230905-x86_64-38-Hourly2-GP2
```
```yaml:roles/cfn_templates/tasks/main.yml
---
- name: CloudFormationテンプレート置き場作成
  file:
    state: directory
    path: "{{ cfn_templates_path }}"

- name: CloudFormation用S3 CFnテンプレート生成
  template:
    src: CfnS3.yml
    dest: "{{ cfn_templates_path }}/{{ stack_name_dict.cfn_s3 }}.yml"
    validate: '/usr/local/bin/aws cloudformation validate-template --template-body=file://%s'

- name: 管理対象環境CFnテンプレート生成
  template:
    src: SystemResource.yml
    dest: "{{ cfn_templates_path }}/{{ stack_name_dict.system_resource }}.yml"
```
```yaml:roles/cfn_templates/templates/CfnS3.yml
#jinja2: lstrip_blocks: "True", trim_blocks: "True"
---
AWSTemplateFormatVersion: 2010-09-09
Resources:
  S3Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub '${AWS::StackName}-${AWS::Region}'
      AccessControl: Private
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      VersioningConfiguration:
        Status: Suspended
      BucketEncryption:
        ServerSideEncryptionConfiguration: 
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      LifecycleConfiguration:
        Rules:
          - Id: CFn-Templates-Expiration-Rule
            Status: Enabled
            ExpirationInDays: 30
          - Id: Delete-NonCurrentVersion-Rule
            Status: Enabled
            NoncurrentVersionExpiration:
              NoncurrentDays: 7
            ExpiredObjectDeleteMarker: true
          - Id: AbortIncompleteMultipartUpload
            Status: Enabled
            AbortIncompleteMultipartUpload:
              DaysAfterInitiation: 7
```
```yaml:roles/cfn_templates/templates/SystemResource.yml
#jinja2: lstrip_blocks: "True", trim_blocks: "True"
---
AWSTemplateFormatVersion: 2010-09-09
Resources:
# VPC
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: {{ main_vpc.cidr }}
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-vpc
# Subnets
{% for subnet_list in main_vpc.subnet_dict | dict2items | sort(attribute='key') %}
  {% for subnet in subnet_list.value %}
  {{ subnet_list.key }}Az{{ subnet.az_num }}Subnet:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZoneId: !FindInMap
                            - RegionAzIdMap
                            - !Ref 'AWS::Region'
                            - az{{ subnet.az_num }}
      VpcId: !Ref Vpc
      CidrBlock: {{ subnet.cidr }}
      MapPublicIpOnLaunch: {% if subnet_list.key.startswith('Public') %}true{% else %}false{% endif +%}
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-{{ subnet_list.key | lower }}-az{{ subnet.az_num }}-subnet
  {{ subnet_list.key }}RouteTableAssociationAz{{ subnet.az_num }}Subnet:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref {{ subnet_list.key }}Az{{ subnet.az_num }}Subnet
      RouteTableId: !Ref {{ subnet_list.key }}RouteTable
  {% endfor %}
{% endfor %}
# Route Table
{% for subnet_name in main_vpc.subnet_dict.keys() | sort %}
  {{ subnet_name }}RouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref Vpc
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-{{ subnet_name | lower }}-rtb
{% endfor %}
# IAM
  LinuxSvIamRole:
    Type: AWS::IAM::Role
    Properties: 
      RoleName: !Sub '${AWS::StackName}-LinuxSvRole'
      AssumeRolePolicyDocument: 
        Version: "2012-10-17"
        Statement: 
          - 
            Effect: "Allow"
            Principal: 
              Service: "ec2.amazonaws.com"
            Action: 
              - "sts:AssumeRole"
      Path: "/"
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
  LinuxSvIamInstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties: 
      InstanceProfileName: !Sub '${AWS::StackName}-LinuxSvInstanceProfile'
      Path: "/"
      Roles: 
        - !Ref LinuxSvIamRole
# Security Group
  LinuxSvSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Web Server Security Group
      GroupName: !Sub ${AWS::StackName}-web-sv-sg
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - IpProtocol: icmp
          FromPort: -1
          ToPort: -1
          CidrIp: 0.0.0.0/0
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-linux-sv-sg
  AwsPrivateLinkSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: AWS service private link security group
      GroupName: !Sub ${AWS::StackName}-private-link-sg
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: {{ main_vpc.cidr }}
          Description: allow use private link
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-aws-private-link-sg
# KeyPair
  KeyPair:
    Type: AWS::EC2::KeyPair
    Properties:
      KeyName: !Sub ${AWS::StackName}-keypair
# EC2 Instance
  DemoSvInstance:
    DependsOn: S3Endpoint # SSMエージェントダウンロード時にS3にアクセスするため
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: {{ demo_sv.instance_type }}
      ImageId: {{ demo_sv.image_id }}
      SubnetId: !Ref {{ demo_sv.subnet }}
      SecurityGroupIds:
        - !Ref LinuxSvSecurityGroup
      IamInstanceProfile: !Ref LinuxSvIamInstanceProfile
      KeyName: !Ref KeyPair
      UserData: # RHELのAMIにはSSMエージェントが入っていないので、インスタンス作成時にインストールする
        Fn::Base64: !Sub |
          #!/bin/bash
          dnf --disablerepo=* -y install https://s3.${AWS::Region}.amazonaws.com/amazon-ssm-${AWS::Region}/latest/linux_amd64/amazon-ssm-agent.rpm
      Tags:
        - Key: Name
          Value: {{ demo_sv.hostname }}
        - Key: ServerType
          Value: {{ demo_sv.type }}
# VPC Endpoint
  S3Endpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - 
            Effect: "Allow"
            Principal: '*'
            Action: 's3:ListAllMyBuckets'
            Resource: 'arn:aws:s3:::*'
          -
            Effect: "Allow"
            Principal: '*'
            Action: '*'
            Resource:
              - !Sub "arn:aws:s3:::amazon-ssm-${AWS::Region}/*" # for SSM Agent RPM Download
      RouteTableIds:
      {% for subnet_name in main_vpc.subnet_dict.keys() | sort %}
        - !Ref {{ subnet_name }}RouteTable
      {% endfor %}
      ServiceName: !Sub 'com.amazonaws.${AWS::Region}.s3'
      VpcId: !Ref Vpc
{% for name in interface_endpoint_list %}
  {{ name | capitalize }}PrivateLink:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      PrivateDnsEnabled: true
      ServiceName: !Sub 'com.amazonaws.${AWS::Region}.{{ name }}'
      VpcEndpointType: Interface
      VpcId: !Ref Vpc
      SubnetIds:
        - !Ref PrivateMainAz1Subnet # デモのためシングルAZ
      SecurityGroupIds:
        - !Ref AwsPrivateLinkSecurityGroup
{% endfor %}

# --------------------------------------------------------------
Mappings:
  RegionAzIdMap:
    ap-northeast-1:
      az1: apne1-az4
      az2: apne1-az2
      az3: apne1-az1
# --------------------------------------------------------------
```
```yaml:cfn_templates.yml
- hosts: [ansible_server]
  roles:
    - cfn_templates
```
ファイルをこれだけそろえて、cfn_templates.ymlをプレイブックとして実行すると、CloudFormationテンプレートアップロード用のS3、管理対象環境を作成するためのCloudFormationテンプレートが作成される。
```shell:プレイブック実行コマンド
cd ~/environment/AnsibleCfnDemoRepo
ansible-playbook cfn_templates.yml -vD -i inventory/dev/
```
作成されたテンプレートをCloudFormationスタックとして作成すると、管理対象環境が構築される。
```shell:スタック作成実行コマンド
aws cloudformation deploy --stack-name demo-dev-cfn-s3 \
  --template-file ~/environment/cfn/demo-dev-cfn-s3.yml
aws cloudformation deploy --stack-name demo-dev-system-resource \
  --template-file ~/environment/cfn/demo-dev-system-resource.yml \
  --s3-bucket demo-dev-cfn-s3-ap-northeast-1 \
  --s3-prefix cfn-templates \
  --tags system-code=demo environment=dev \
  --capabilities CAPABILITY_NAMED_IAM
```

# 4. 構築した環境の動作確認

この環境はプライベートネットワークに素のRHEL9のサーバーを建てただけなのでサービスとしては何も動いていないが、AnsibleでLinuxサーバーを管理できる状態であることが期待値なので、AnsibleでLinuxサーバーに何かタスクを実行させられることを確認する。
まず、動的インベントリの確認をするため、Linuxサーバーが起動した状態でansible-inventoryコマンドを実行して結果を確認する。
```shell-session
$ ansible-inventory -i inventory/dev/ --graph
@all:
  |--@aws_ec2:
  |  |--DEMOSV01
  |--@system_demo:
  |  |--@env_dev:
  |  |  |--@ansible_server:
  |  |  |  |--localhost
  |  |  |--@demo_servers:
  |  |  |  |--DEMOSV01
  |--@ungrouped:
```
インベントリは期待通りの状態だったので、次は動作確認用のプレイブックを用意して実行する。
```yaml:demo_servers.yml
- hosts: [ansible_server]
  tasks:
    - name: EC2接続用キーファイル存在確認
      stat:
        path: ~/.ssh/{{ system_code }}-{{ env_stage }}.key
      register: keyfile

    - name: EC2接続用キー取得および保存
      when: not keyfile.stat.exists
      shell: aws ssm get-parameter --name /ec2/keypair/$(aws ec2 describe-key-pairs --filters Name=key-name,Values={{ stack_name_dict.system_resource }}-keypair --query KeyPairs[*].KeyPairId --output text) --with-decryption --query Parameter.Value --output text > ~/.ssh/{{ system_code }}-{{ env_stage }}.key
      check_mode: no

    - name: EC2接続用キーファイルパーミッション変更
      file:
        path: ~/.ssh/{{ system_code }}-{{ env_stage }}.key
        mode: "0400"

- hosts: [demo_servers]
  tasks:
    - name: hellow world
      debug:
        msg: "Hello World!"
```
「EC2接続用キー」は、EC2インスタンス作成時に指定したキーペアの秘密鍵のこと。キーペアはSystemResource.ymlのスタックで作成しており、CloudFormationで作成したキーペアの情報はパラメータストアに保存されるので、パラメータストアから取得してCloud9上に保存し、AnsibleからのSSH接続時に使用する。

動作確認のためプレイブックを実行する。

```shell-session:プレイブック実行結果
$ ansible-playbook demo_servers.yml -vD -i inventory/dev/
No config file found; using defaults
(中略)
PLAY [demo_servers] *******************************************************************************************************************************************************************************************************************

TASK [Gathering Facts] ****************************************************************************************************************************************************************************************************************
fatal: [DEMOSV01]: UNREACHABLE! => {"changed": false, "msg": "Failed to connect to the host via ssh: \nAn error occurred (403) when calling the StartSession operation: Server authentication failed: <UnauthorizedRequest><message>Forbidden.</message></UnauthorizedRequest>\n\nssh_exchange_identification: Connection closed by remote host", "unreachable": true}

PLAY RECAP ****************************************************************************************************************************************************************************************************************************
DEMOSV01                   : ok=0    changed=0    unreachable=1    failed=0    skipped=0    rescued=0    ignored=0   
localhost                  : ok=4    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

```
…うまくいかない。

セッションマネージャー経由のSSHが失敗しているようなので切り分けをすると、以下の通りだった。
* AWSコンソールのEC2画面から[接続] > [セッションマネージャー]での接続は成功
* AWSコンソールのCloudShellから `aws ssm start-session --target <LinuxサーバーのインスタンスID>`を実行すると、接続は成功
* Cloud9のbashターミナルから`aws ssm start-session --target <LinuxサーバーのインスタンスID>`を実行すると失敗

```shell-session:失敗時のエラーメッセージ
$ aws ssm start-session --target i-xxxxxxxxxxxxx

An error occurred (403) when calling the StartSession operation: Server authentication failed: <UnauthorizedRequest><message>Forbidden.</message></UnauthorizedRequest>
```
原因がよくわからなかったので結局サポートに問い合わせた結果、以下のことが分かった。
* Cloud9環境でAWSマネージド一時認証情報を使ってAWS CLIを実行する(デフォルトの動作)場合、セッションマネージャーは動作しないという制約がある
* アクセスキーを使用した認証情報をaws configureで設定して使用するか、インスタンスプロファイルの権限を使用するかでこの制約を回避できる
* アクセスキーやインスタンスプロファイルを使うためにはCloud9のAWS Settings設定でAWSマネージド一時認証情報を無効化する必要がある

今回はインスタンスプロファイルを使う方式を採用することにした。
(アクセスキーを作成しようとするとAWSコンソールが頑張って代替案を推してくるので…)

インスタンスプロファイルを作成するCloudFormationテンプレートを準備する。

```yaml:demo-dev-infra-c9-iam.yml
---
AWSTemplateFormatVersion: 2010-09-09
Resources:
  InfraCloud9IamRole:
    Type: AWS::IAM::Role
    Properties: 
      RoleName: !Sub '${AWS::StackName}-InfraCloud9Role'
      AssumeRolePolicyDocument: 
        Version: "2012-10-17"
        Statement: 
          - 
            Effect: "Allow"
            Principal: 
              Service:
                - "ec2.amazonaws.com"
                - "cloud9.amazonaws.com"
            Action: 
              - "sts:AssumeRole"
      Path: "/"
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AWSCloud9SSMInstanceProfile
        - !Ref InfraAnsibleOperationIamPolicy
  InfraAnsibleOperationIamPolicy:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      ManagedPolicyName: !Sub '${AWS::StackName}-InfraAnsibleOperation'
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - # デモなのでAdministratorAccess相当の権限つけているが、実際にはAnsibleでのインフラ管理に必要な権限を定義するべし
            Effect: Allow
            Action: "*"
            Resource: "*"
  InfraCloud9InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties: 
      InstanceProfileName: !Sub '${AWS::StackName}-InfraCloud9InstanceProfile'
      Path: "/"
      Roles: 
        - !Ref InfraCloud9IamRole
```
このテンプレートでCloudFormationスタックを作成する。
```shell:スタック作成コマンド
aws cloudformation deploy --stack-name demo-dev-infra-c9-iam \
  --template-file demo-dev-infra-c9-iam.yml \
  --s3-bucket demo-dev-cfn-s3-ap-northeast-1 \
  --s3-prefix cfn-templates \
  --tags system-code=demo environment=dev \
  --capabilities CAPABILITY_NAMED_IAM
  ```
  作成に成功したら、インスタンスプロファイルをCloud9環境にアタッチする。
  ```shell:インスタンスプロファイルアタッチのコマンド
my_cloud9_instance_id=$(aws ec2 describe-instances --filters Name=instance-state-name,Values=running --query 'Reservations[*].Instances[?contains(to_string(Tags),`aws-cloud9-mgmt-c9-MyCloud9-`)].InstanceId' --output text)
instance_profile_assoc_id=$(aws ec2 describe-iam-instance-profile-associations --filters Name=instance-id,Values=${my_cloud9_instance_id} --query 'IamInstanceProfileAssociations[*].AssociationId' --output text)
aws ec2 replace-iam-instance-profile-association --association-id ${instance_profile_assoc_id} --iam-instance-profile Name=demo-dev-infra-c9-iam-InfraCloud9InstanceProfile
```
インスタンスプロファイルを使うようにするため、Cloud9のAWSマネージド一時認証情報を無効化する。
GUIからの操作で、Cloud9 IDEの右上の歯車を押してPreferencesタブを開いて、AWS Settings > Credentials > AWS managed temporary credentials: をオフにする。

Cloud9のAWSマネージド一時認証情報を無効化するとAWS CLIのデフォルトリージョンが未設定になるので、東京リージョンに設定する。
```shell:設定コマンド
aws configure set region ap-northeast-1
```

AWS CLIがインスタンスプロファイルの権限で実行されるようになっていることを確認する。
```shell-session:確認コマンド
$ aws sts get-caller-identity 
{
    "UserId": "<ランダムな文字列>:<EC2インスタンスID>",
    "Account": "<AWSアカウントID>",
    "Arn": "arn:aws:sts::<AWSアカウントID>:assumed-role/demo-dev-infra-c9-iam-InfraCloud9Role/<EC2インスタンスID>"
}
```
Arnがassume-role/<アタッチし直したインスタンスプロファイルのロール>になっているのでOK。

再度、動作確認用のプレイブックを実行する。
```shell-session
$ ansible-playbook demo_servers.yml -vD -i inventory/dev/
No config file found; using defaults
Using inventory plugin 'ansible_collections.amazon.aws.plugins.inventory.aws_ec2' to process inventory source '/home/ec2-user/environment/AnsibleCfnDemoRepo/inventory/dev/hosts_aws_ec2.yml'

(中略)

PLAY [demo_servers] *******************************************************************************************************************************************************************************************************************

TASK [Gathering Facts] ****************************************************************************************************************************************************************************************************************
ok: [DEMOSV01]

TASK [hellow world] *******************************************************************************************************************************************************************************************************************
ok: [DEMOSV01] => {
    "msg": "Hello World!"
}

PLAY RECAP ****************************************************************************************************************************************************************************************************************************
DEMOSV01                   : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
localhost                  : ok=3    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0   

```
うまくいった。