CloudFormationで古典的なサービスインフラを作った話を忘れないうちに書いとく

# はじめに

「CloudFormationを使えば、一発で環境を構築できるらしい」という大雑把な話を聞いて、
まずはやってみようとおもって試行錯誤しました。
作ってた時に考えたこととか、これ便利だなって思ったところを書いてみようと思います。

ただ、新しい技術に対応してcfn使ったら便利だった、という話ではなく、「古典的なサービスのインフラを作ることになったんだけど、古典的なインフラの作り方をcfnでやったらこんな感じになったよ」的な話です。
むしろこうしたらええんやで、とかアドバイスいただけたら嬉しいです(大きい独り言)

# やったこと

- CloudFormationを使って、Cloudfront+EC2&RDSを使ったサービスのインフラを立ち上げた
- VPCは2つ作成して、本番用と、ステージング・開発用にした
- EC2を作成する際にcfn-initを使って最初から必要なパッケージを導入した
- Route53 も設定できるからやってみた、cfnでスタック構築ポンで名前解決して繋がるの強くない？
- ただ、AWSに移行できていないステージング用DNSを都合上使ったので、そのためにいくつか苦心した

# 方針と構成

## スタックを作る方針

初めてCloudformationを触るにあたって、ベストプラクティスを読めば読むほど訳が分からなくなった()ので、下記の内容を考えました。

1. 既に必要だとわかっているものは、最初から全部作ることにする
2. できるだけスタックを一つにまとめたいが、性質の異なるコンポーネントで分けて考える
    1. 一回作ったら変わらない・変えないものでまとめる
    2. 階層構造で積み上げる形で考えてまとめる
    3. 頻繁に作ったり壊したりするものでまとめる 

## 実際のスタックの構成

いろいろやってみた結果、スタックは最終的に5分割になりました。
それぞれのおおまかな説明と、実際に何を設定しているかは以下の通り。

### 1, VPCとネットワーク周りを設定するスタック
1. Route53のホストゾーン
2. VPC,サブネット,インターネットゲートウェイ,ルートテーブルとルート
3. 固定でネットワークインターフェース（本番EC2用・ステージングEC2用）
4. 上記に紐づけるEIP
5. 固定でセキュリティグループ（例えば社内全アクセス許可と、Web公開許可、とか）

### 2, 固定で用意するEC2とRDSとS3を設定するスタック

1. EC2(本番用・ステージング用)
2. RDS(本番用・ステージング用)
3. RDSのセキュリティグループ,パラメータグループ,サブネットグループ
4. S3(本番用・ステージング用)
5. Cloudfront->S3のオリジンアクセスアイデンティティ、バケットポリシー
6. EC2への名前解決を行うRoute53レコードセット(本番用・ステージング用)

### 3, バージニア北部リージョンでACM証明書を作成するスタック
1. Cloudfrontで使うACM証明書(本番用・ステージング用)

### 4, 公開するために必要なコンポーネントを設定するスタック
1. Cloudfront(本番用・ステージング用)
2. Cloudfrontへの名前解決を行うRoute53レコードセット(本番用・ステージング用)

### 5, 開発環境を設定するスタック
1. ネットワークインターフェース,EIP
2. EC2
3. EC2への名前解決を行うRoute53レコードセット
4. その他必要なコンポーネントの設定とか


# 作ったスタックの説明

## 各スタックで共通するパラメータ

- `PjName`
同様のインフラをコピーして作成するときのことを考えて、同じプロジェクトの全てのスタックで共通した名前を指定して実行するようにします。
例えば、出力のExportなどで使います。(スタック間で値を渡す際に、どのプロジェクトのものか判別できるようにするために使います)

- `ServiceDomainName`
本番環境のCloudfrontに設定する、公開するドメイン名を設定します。

- `StagingDomainName`
ステージング環境のCloudfrontに設定するドメイン名を設定します。

これらのパラメータについて、`PjName`については全スタックで入力が必要で、その他については、リージョンごとに一番最初に実施するスタックで入力したものをExportして利用します。

## 1, VPCとネットワーク周りを設定するスタック
```yaml:NetworkFoundation.yml
AWSTemplateFormatVersion: 2010-09-09
Description: >-
  Cloudformation stack for some project Infrastructure, 
  Creates Network Foundations

Metadata:
  'AWS::CloudFormation::Interface':
    ParameterGroups:
      - Label:
          default: Global Setting
        Parameters:
          - PjName
          - ServiceDomainName
          - StagingDomainName
      - Label:
          default: VPC configuration
        Parameters:
          - VPCCIDR
          - SubnetACIDR
          - SubnetCCIDR

Parameters:
    PjName:
        Type: String
        Default: MyProject
    ServiceDomainName:
        Type: String
        Default: example.com
        Description: Service domain 
    StagingDomainName:
        Type: String
        Default: example.com
        Description: Service domain 
    VPCCIDR:
        Type: String
        Default: 192.168.0.0/16
    SubnetACIDR:
        Type: String
        Default: 192.168.0.0/18
    SubnetCCIDR:
        Type: String
        Default: 192.168.64.0/18

Resources:
######################################################################################################
# General components
######################################################################################################
# ------------------------------------------------------------#
#  Route 53 Hosted zone 
# ------------------------------------------------------------#
  R53HostedZone:
    Type: "AWS::Route53::HostedZone"
    Properties:
      Name: !Ref ServiceDomainName


######################################################################################################
# Production environment
######################################################################################################
# ------------------------------------------------------------#
#  VPC for prod
# ------------------------------------------------------------#
  ProdVPC:
    Type: 'AWS::EC2::VPC'
    Properties:
      CidrBlock: !Ref VPCCIDR
      EnableDnsSupport: 'true'
      EnableDnsHostnames: 'true'
      InstanceTenancy: default
      Tags:
        - Key: Name
          Value: !Sub '${PjName}-vpc-prod'
        
# ------------------------------------------------------------#
#  Prod VPC IGW
# ------------------------------------------------------------#
  IGWProdVPC:
    Type: 'AWS::EC2::InternetGateway'
    Properties:
        Tags:
          - Key: Name
            Value: !Sub '${PjName}-igw-prod'
  InternetGatewayAttachment:
    Type: 'AWS::EC2::VPCGatewayAttachment'
    Properties:
      InternetGatewayId: !Ref IGWProdVPC
      VpcId: !Ref ProdVPC

# ------------------------------------------------------------#
#  Prod VPC Routetable
# ------------------------------------------------------------#
  rtbProdVPC:
    Type: 'AWS::EC2::RouteTable'
    Properties:
        Tags:
          - Key: Name
            Value: !Sub '${PjName}-rtb-prod'
        VpcId: !Ref ProdVPC

  rtProdVPC:
    Type: "AWS::EC2::Route"
    DependsOn: IGWProdVPC
    Properties:
       RouteTableId: !Ref rtbProdVPC
       DestinationCidrBlock: 0.0.0.0/0
       GatewayId: !Ref IGWProdVPC

# ------------------------------------------------------------#
#  Subnet Prod 1a
# ------------------------------------------------------------#
  SubnetAProd:
    Type: 'AWS::EC2::Subnet'
    Properties:
      AvailabilityZone: ap-northeast-1a
      CidrBlock: !Ref SubnetACIDR
      VpcId: !Ref ProdVPC
      Tags:
        - Key: Name
          Value: !Sub '${PjName}-subnet-1a-prod'
  SubnetAProdRtbAssociate:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: 
      RouteTableId: !Ref rtbProdVPC
      SubnetId: !Ref SubnetAProd

# ------------------------------------------------------------#
#  Subnet Prod 1c
# ------------------------------------------------------------#
  SubnetCProd:
    Type: 'AWS::EC2::Subnet'
    Properties:
      AvailabilityZone: ap-northeast-1c
      CidrBlock: !Ref SubnetCCIDR
      VpcId: !Ref ProdVPC
      Tags:
        - Key: Name
          Value: !Sub '${PjName}-subnet-1c-prod'
  SubnetCProdRtbAssociate:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: 
      RouteTableId: !Ref rtbProdVPC
      SubnetId: !Ref SubnetCProd

# ------------------------------------------------------------#
#  Security Group for Prod VPC
# ------------------------------------------------------------#
  InternalAccessSGProdVPC:
    Type: 'AWS::EC2::SecurityGroup'
    Properties:
      VpcId: !Ref ProdVPC
      GroupDescription: access from Inside
      Tags:
        - Key: Name
          Value: !Sub '${PjName}-vpc-prod-internal-access'
      SecurityGroupIngress:
        - CidrIp: xxx.xxx.xxx.xxx/32
          Description: Inside
          FromPort: -1
          IpProtocol: -1
          ToPort: -1
  PublicWebSGProdVPC:
    Type: 'AWS::EC2::SecurityGroup'
    Properties:
      VpcId: !Ref ProdVPC
      GroupDescription: Enable HTTP and HTTPS Public access
      Tags:
        - Key: Name
          Value: !Sub '${PjName}-vpc-prod-public-access'
      SecurityGroupIngress:
        - CidrIp: 0.0.0.0/0
          FromPort: 80
          IpProtocol: tcp
          ToPort: 80
        - CidrIp: 0.0.0.0/0
          FromPort: 443
          IpProtocol: tcp
          ToPort: 443

# ------------------------------------------------------------#
#  Network components for Prod EC2 Instance
# ------------------------------------------------------------#
  NwIfEC2Prod:
    Type: 'AWS::EC2::NetworkInterface'
    Properties:
      Description: Network Interface for Prod EC2
      GroupSet:
        - !Ref InternalAccessSGProdVPC
        - !Ref PublicWebSGProdVPC
      InterfaceType: interface
      SourceDestCheck: false
      SubnetId: !Ref SubnetAProd
      Tags:
        - Key: Name
          Value: !Sub '${PjName}-prod-1a-nwif'
  EIPEC2Prod:
    Type: 'AWS::EC2::EIP'
    Properties:
      Domain: vpc
  EIPEC2ProdAssociate:
    Type: 'AWS::EC2::EIPAssociation'
    Properties:
      AllocationId: !GetAtt 
        - EIPEC2Prod
        - AllocationId
      NetworkInterfaceId: !Ref NwIfEC2Prod


######################################################################################################
# Staging environment
######################################################################################################

#### 各コンポーネントのProd→Stgに変更する以外は本番の丸コピー

######################################################################################################
# Output section
######################################################################################################
Outputs:
  R53HostedZone:
    Description: Hosted Zone for Service Domain
    Value: !Ref R53HostedZone
    Export:
      Name: !Sub "${PjName}-R53HostedZone"
  VPCProd:
    Description: VPC for Production environment
    Value: !Ref ProdVPC
    Export:
      Name: !Sub "${PjName}-ProdVPC"
  VPCStg:
    Description: VPC for Staging and Develop environment
    Value: !Ref StgVPC
    Export:
      Name: !Sub "${PjName}-StgVPC"
  SubnetAProd:
    Description: Subnet for Prod VPC 1a
    Value: !Ref SubnetAProd
    Export:
      Name: !Sub "${PjName}-SubnetAProd"
  SubnetCProd:
    Description: Subnet for Prod VPC 1c
    Value: !Ref SubnetCProd
    Export:
      Name: !Sub "${PjName}-SubnetCProd"
  SubnetAStg:
    Description: Subnet for Stg VPC 1a
    Value: !Ref SubnetAStg
    Export:
      Name: !Sub "${PjName}-SubnetAStg"
  SubnetCStg:
    Description: Subnet for Stg VPC 1c
    Value: !Ref SubnetCStg
    Export:
      Name: !Sub "${PjName}-SubnetCStg"
  NWIFProd:
    Description: Network Interface for Prod instance
    Value: !Ref NwIfEC2Prod
    Export:
      Name: !Sub "${PjName}-NwIfEC2Prod"
  NWIFStg:
    Description: Network Interface for Stg instance
    Value: !Ref NwIfEC2Stg
    Export:
      Name: !Sub "${PjName}-NwIfEC2Stg"
  EIPEC2Prod:
    Description: EIP for Prod instance
    Value: !Ref EIPEC2
    Export:
      Name: !Sub "${PjName}-EIPEC2"
  EIPEC2Stg:
    Description: EIP for Stg instance
    Value: !Ref EIPEC2Stg
    Export:
      Name: !Sub "${PjName}-EIPEC2Stg"
  VPCCIDR:
    Description: CIDR of VPC (common Prod and Stg)
    Value: !Ref VPCCIDR
    Export:
      Name: !Sub "${PjName}-VPCCIDR"
  InternalAccessSGStg:
    Description: Security group for stg VPC Internal Access
    Value: !Ref InternalAccessSGStgVPC
    Export:
      Name: !Sub "${PjName}-stg-sg-internal"
  InternalAccessSGProd:
    Description: Security group for prod VPC Internal Access
    Value: !Ref InternalAccessSGProdVPC
    Export:
      Name: !Sub "${PjName}-prod-sg-internal"
  PublicWebSGStgVPC:
    Description: Security group for stg VPC public web Access
    Value: !Ref PublicWebSGStgVPC
    Export:
      Name: !Sub "${PjName}-stg-sg-public-web"
  PublicWebSGProdVPC:
    Description: Security group for prod VPC public web Access
    Value: !Ref PublicWebSGProdVPC
    Export:
      Name: !Sub "${PjName}-prod-sg-public-web"
  ServiceDomainName:
    Description: Service Domain Name
    Value: !Ref ServiceDomainName
    Export:
      Name: !Sub "${PjName}-ServiceDomainName"
  StagingDomainName:
    Description: Staging Domain Name
    Value: !Ref StagingDomainName
    Export:
      Name: !Sub "${PjName}-StagingDomainName"

```

- ネットワークの構成について
本番用VPCとステージング用VPCで、サブネットの切り方を同じにする想定をしています。設定分けると面倒かなと思って。
各サブネットは1aと1cのアベイラビリティゾーンに関連づけています。3冗長構成にする場合は、1dに関連づいたサブネットを作成する形になると思いますが、今回は使わないと判断して省略。
ただし、ネットワークアドレス的には後々作成可能なように、VPCのサブネットマスクを`/16`、各サブネットワークのサブネットマスクを`/18`とし、サブネットを4つ作れるようにしています。

- インターネットゲートウェイ(IGW)について
IGWは作成とVPCへのアタッチが必要なので、それぞれの記述があります。

- ルートテーブルについて
ルートテーブルは、VPCを作るとデフォルトのルートテーブルが作成されますが、このルートテーブルのIDを知る機会がない(GetAttで取得はできる？未調査)ため、ルートテーブルと、IGWに通信を流すルートを新しく作成し、これを各サブネットにアタッチしています。

- ネットワークインターフェースをここで作った理由
いくつかの都合で、Stg環境のドメインをRoute53以外のDNSで管理する必要がありました。
ネットワークインターフェースをEC2と同時に作成するようにスタックに記述すると、Stg環境のEC2の再構築が必要になった場合に、EIPの取得しなおしが発生し、DNSの再設定を行う必要が出てくるため、このような形になっています。
Route53でStg環境のドメインを管理するのであれば、ネットワークアダプタの作成をここで行わず、EC2と同時にネットワクアダプタとEIPとレコードセットを作るようなスタックにできるので、このほうが楽だろうなあと思います。

- Webの公開セキュリティグループを最初から設定している理由
Let's encryptを使ってSSL証明書を取得する際、外部からアクセスできるようにしておく必要があるため。
最終的にインフラが完成したらこのセキュリティグループは手動で外します。

## 2, 固定で用意するEC2とRDSとS3を設定するスタック

```yaml:web-backside.yml

AWSTemplateFormatVersion: 2010-09-09
Description: >-
  Cloudformation stack for some project Infrastructure, 
  Creates EC2, RDS, S3, Route53RecordSet

Metadata:
  'AWS::CloudFormation::Interface':
    ParameterGroups:
      - Label:
          default: Global Setting
        Parameters:
          - PjName
      - Label:
          default: EC2 configuration
        Parameters:
          - EC2ProdHostName
          - EC2ProdKeyName
          - EC2StgHostName
          - EC2StgKeyName
      - Label:
          default: RDS configuration
        Parameters:
          - DBEngineVersion
          - DBEngineMinorVersion
          - DBName
          - DBMasterUserName
          - DBMasterpassword
      - Label:
          default: S3 configuration
        Parameters:
          - BucketName


Parameters:
    PjName:
        Type: String
        Default: MyProject
    EC2ProdKeyName:
        Type: 'AWS::EC2::KeyPair::KeyName'
        Description: Prod env EC2 instance Keypair name. Keypair must be created before use. 
    EC2StgKeyName:
        Type: 'AWS::EC2::KeyPair::KeyName'
        Description: Stg env EC2 instance Keypair name. Keypair must be created before use.
    EC2ProdHostName:
        Type: String
        Default: prod-hostname
        Description: Prod env EC2 instance Domain name will set [EC2ProdHostName].[ServiceDomainName]
    EC2StgHostName:
        Type: String
        Default : stg-hostname
        Description: Staging env EC2 instance Domain name will set [EC2StgHostName].[StagingDomainName]
    DBEngineVersion:
        Type: String
        Default: 8.0
        AllowedValues: [ "8.0" ]
        Description: Select major version of mysql.
    DBEngineMinorVersion:
        Type: String
        Default: 16
        Description: minor version of mysql.
    DBName:
        Type: String
        Default: DatabaseName
        Description: Database name
    DBMasterUserName:
        Type: String
        Default: dbmaster
        Description: Database master user
    DBMasterpassword:
        Default: "dbpassword"
        NoEcho: true
        Type: String
        MinLength: 8
        MaxLength: 41
        AllowedPattern: "[a-zA-Z0-9]*"
        ConstraintDescription: "must contain only alphanumeric characters."
        Description: Database master user password


Resources:
######################################################################################################
# Production environment
######################################################################################################
# ------------------------------------------------------------#
#  EC2 for Prod
# ------------------------------------------------------------#
    EC2Prod:
        Type: 'AWS::EC2::Instance'
        Metadata:
          'AWS::CloudFormation::Init':
            configSets:
              Initialize:
                - Install
            Install:
                packages:
                    yum:
                        mariadb: []
                        mariadb-libs: []
                        httpd: []
                        unzip: []
                        git: []
                        certbot: []
                        python2-certbot-apache: []
                files:
                    /var/www/html/index.php:
                        content: !Join 
                          - ''
                          - - |
                                  <?php
                            - |2
                                    phpinfo();
                            - |2
                                  ?>
                        mode: '000600'
                        owner: apache
                        group: apache
                    /etc/cfn/cfn-hup.conf:
                        content: !Join 
                          - ''
                          - - |
                              [main]
                            - stack=
                            - !Ref 'AWS::StackId'
                            - |+

                            - region=
                            - !Ref 'AWS::Region'
                            - |+

                        mode: '000400'
                        owner: root
                        group: root
                    /etc/cfn/hooks.d/cfn-auto-reloader.conf:
                        content: !Join 
                          - ''
                          - - |
                              [cfn-auto-reloader-hook]
                            - |
                              triggers=post.update
                            - >
                              path=Resources.WebServerInstance.Metadata.AWS::CloudFormation::Init
                            - 'action=/opt/aws/bin/cfn-init -v '
                            - '         --stack '
                            - !Ref 'AWS::StackName'
                            - '         --resource EC2Prod '
                            - '         --configsets Initialize '
                            - '         --region '
                            - !Ref 'AWS::Region'
                            - |+

                            - |
                              runas=root
                        mode: '000400'
                        owner: root
                        group: root
                services:
                    sysvinit:
                      httpd:
                        enabled: 'true'
                        ensureRunning: 'true'
                      cfn-hup:
                        enabled: 'true'
                        ensureRunning: 'true'
                        files:
                          - /etc/cfn/cfn-hup.conf
                          - /etc/cfn/hooks.d/cfn-auto-reloader.conf
        Properties:
            ImageId: 'ami-0ff21806645c5e492'
            InstanceType: 't3.small'
            KeyName: !Ref EC2ProdKeyName
            BlockDeviceMappings: 
              - DeviceName: "/dev/xvda"
                Ebs:  
                  DeleteOnTermination: False
                  Encrypted: False
                  VolumeSize: 30
                  VolumeType: gp2
            NetworkInterfaces:
              - NetworkInterfaceId:
                  Fn::ImportValue: 
                    !Sub "${PjName}-NwIfEC2"
                DeviceIndex: 0
            Tags:
              - Key: Name
                Value: !Ref EC2PRodHostName
            UserData: !Base64 
              'Fn::Join':
                - ''
                - - |
                    #!/bin/bash -xe
                  - |
                    yum update -y aws-cfn-bootstrap
                  - |
                    amazon-linux-extras install ansible2 epel php7.3 
                  - |
                    # Install the files and packages from the metadata
                  - '/opt/aws/bin/cfn-init -v '
                  - '         --stack '
                  - !Ref 'AWS::StackName'
                  - '         --resource EC2Prod '
                  - '         --configsets Initialize '
                  - '         --region '
                  - !Ref 'AWS::Region'
                  - |+

                  - |
                    # Signal the status from cfn-init
                  - '/opt/aws/bin/cfn-signal -e $? '
                  - '         --stack '
                  - !Ref 'AWS::StackName'
                  - '         --resource EC2Prod '
                  - '         --region '
                  - !Ref 'AWS::Region'
                  - |+

                  - |
                    !Sub "hostnamectl set-hostname --static ${EC2ProdHostName}"
                  - |+


# ------------------------------------------------------------#
#  RDS components for Prod
# ------------------------------------------------------------#
    RDSProdSecurityGroup:
        Type: "AWS::EC2::SecurityGroup"
        Properties:
            VpcId: { "Fn::ImportValue": !Sub "${PjName}-ProdVPC" }
            GroupName: !Sub "RDSProd-sg"
            GroupDescription: "-"
            Tags:
            - Key: "Name"
              Value: !Sub "RDSProd-sg"
            SecurityGroupIngress:
              - IpProtocol: tcp
                FromPort: 3306
                ToPort: 3306
                CidrIp: { "Fn::ImportValue": !Sub "${PjName}-VPCCIDR" }

    RDSProdParameterGroup:
        Type: "AWS::RDS::DBParameterGroup"
        Properties:
            Family: !Sub "MySQL${DBEngineVersion}"
            Description: !Sub "${PjName}-RDSProdParam"

    RDSProdSubnetGroup:
        Type: "AWS::RDS::DBSubnetGroup"
        Properties: 
            DBSubnetGroupName: !Sub "${PjName}-prod-rds-subnetgroup"
            DBSubnetGroupDescription: "-"
            SubnetIds: 
              - { "Fn::ImportValue": !Sub "${PjName}-SubnetAProd" }
              - { "Fn::ImportValue": !Sub "${PjName}-SubnetCProd" }

    RDSProd: 
        Type: "AWS::RDS::DBInstance"
        Properties: 
            DBInstanceIdentifier: !Sub "${PjName}-RDSProd"
            Engine: MySQL
            EngineVersion: !Sub "${DBEngineVersion}.${DBEngineMinorVersion}"
            DBInstanceClass: db.t3.micro
            AllocatedStorage: 20
            StorageType: gp2
            DBName: !Ref DBName
            MasterUsername: !Ref DBMasterUserName
            MasterUserPassword: !Ref DBMasterpassword
            DBSubnetGroupName: !Ref RDSProdSubnetGroup
            PubliclyAccessible: false
            MultiAZ: True
            PreferredBackupWindow: "18:00-18:30"
            PreferredMaintenanceWindow: "sat:19:00-sat:19:30"
            AutoMinorVersionUpgrade: false
            DBParameterGroupName: !Ref RDSProdParameterGroup  
            VPCSecurityGroups:
                - !Ref RDSProdSecurityGroup
            CopyTagsToSnapshot: true
            BackupRetentionPeriod: 7
            Tags: 
            - Key: "Name"
              Value: !Sub "${PjName}-RDSProd"
            DeletionProtection: true


# ------------------------------------------------------------#
#  S3 bucket for prod 
# ------------------------------------------------------------#
    S3Prod:
        Type: 'AWS::S3::Bucket'
        Properties:
          BucketName: !Sub "${BucketName}-prod"
    CfAccessIdentityProd:
        Type: 'AWS::CloudFront::CloudFrontOriginAccessIdentity'
        Properties:
          CloudFrontOriginAccessIdentityConfig:
            Comment: !Sub 'access-identity-${S3Prod}'
    S3BucketPolicyProd:
        Type: 'AWS::S3::BucketPolicy'
        Properties:
          Bucket: !Ref S3Prod
          PolicyDocument:
            Statement:
              - Action: 's3:GetObject'
                Effect: Allow
                Resource: !Sub 'arn:aws:s3:::${S3Prod}/*'
                Principal:
                  CanonicalUser: !GetAtt CfAccessIdentityProd.S3CanonicalUserId


# ------------------------------------------------------------#
#  Route53 Recordset for prod
# ------------------------------------------------------------#
    R53RSEC2Host:
        Type: "AWS::Route53::RecordSet"
        Properties:
            HostedZoneId: 
              Fn::ImportValue: 
                !Sub "${PjName}-R53HostedZone"
            Name: 
              - Fn::Join:
                - "."
                - - !Ref EC2ProdHostName
                  - Fn::ImportValue: !Sub "${PjName}-ServiceDomainName"
            Type: A
            TTL: 300
            ResourceRecords: 
              - Fn::ImportValue: 
                  !Sub "${PjName}-EIPEC2Prod" 

######################################################################################################
# Staging environment
######################################################################################################

#### 各コンポーネントのProd→Stgに変更する以外は本番の丸コピー

######################################################################################################
# Output section
######################################################################################################
Outputs:
    EC2HostNameProd:
      Value: !Ref EC2HostName
      Export:
        Name: !Sub "${PjName}-EC2HostNameProd"
    EC2StgHostName:
      Value: !Ref EC2StgHostName
      Export:
        Name: !Sub "${PjName}-EC2StgHostName"
    EC2ProdDomainName:
      Value: !Sub "${EC2HostName}.${ServiceDomainName}"
      Export:
        Name: !Sub "${PjName}-EC2ProdDomainName"
    EC2StgDomainName:
      Value: !Sub "${EC2StgHostName}.${StagingDomainName}"
      Export:
        Name: !Sub "${PjName}-EC2StgDomainName"
    CfAccessIdentityProd:
      Value: !Ref CfAccessIdentityProd:
      Export:
        Name: !Sub "${PjName}-CfAccessIdentityProd"
    CfAccessIdentityStg:
      Value: !Ref CfAccessIdentityStg:
      Export:
        Name: !Sub "${PjName}-CfAccessIdentityStg"
    S3Prod:
        Value: !Ref S3Prod
    S3Stg:
        Value: !Ref S3Stg
    RDSProdEndpoint:
        Value: !GetAtt RDSProd.Endpoint.Address
    RDSStgEndpoint:
        Value: !GetAtt RDSStg.Endpoint.Address
    RDSDevEndpoint:
        Value: !GetAtt RDSDev.Endpoint.Address
    DBName:
        Value: !Ref DBName
    DBMasterUser:
        Value: !Ref DBMasterUserName
```

- キーペアについて
キーペアはCloudformationから作成できないので、あらかじめ、Webコンソールから作成しておく必要があります。
というかcfnで作成したとすると、インスタンスを起動するたびに新しいキーが生成されることになる。。
Parametersで、`'AWS::EC2::KeyPair::KeyName'`を指定すると、現在のリージョンに登録されているEC2のキーペアのリストがプルダウンで選択できるようになるので、こちらを使うのがよさそう。

- 可変にできるパラメータを固定している点について
例えばインスタンスサイズやAMIについて、サンプルテンプレートと同様に選択可能にしたり、他のリージョンでも動作するように記述することもできますが、あまりに長くなるのを避けたかったため、固定の値にしてあります。

### ハマりどころ

- スタック間で値をやり取りするImportValueの記述<br>
ImportValueについて、省略形式である`!ImportValue`を使うと、この中で`!sub`が使えません。
省略形式ではない`"Fn::ImportValue":`を使う必要があります。

```yaml:これはNG
!ImportValue !Sub "${PjName}-ExportedParameter"
```
```yaml:これなら通る
"Fn::ImportValue": !Sub "${PjName}-ExportedParameter"
```

### EC2作成時にどんな動作をするのか

EC2を作成する際、インスタンスが出来上がってまず最初に`UserData:`に記載した内容が実行されます。
今回の場合、下記の動作をします。

- `aws-cfn-bootstrap`を導入し、Cloudformation関連の操作がEC2内部で実施できるようにします。
- `amazon-linux-extras`コマンドを使って、AmazonLinux向けに用意されているパッケージから、`ansible2` `epel` `php7.3`を導入しています。
    - `Ansible2`は、Let's encryptのcertbotが自動的に証明書を作るために必要なバーチャルホストの設定などを実施します。
    - `epel`は、Let's encryptのcertbotをyumで導入するために導入します。
    - `php7.3`は、ここで導入するのが最も簡単かと。

- cfn-initコマンドを実行します。
このコマンドを実行すると、スタックに記述したEC2の`Metadata:` `'AWS::CloudFormation::Init':`以下に記載した内容が実行されます。この中身については、[AWS::CloudFormation::Init](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-init.html)を参照してください。
今回の内容については、AWSのLAMP環境を構築するサンプルテンプレートを参考にしています。

-  **注意** `cfn-init`コマンドの中で指定するスタックの名前と操作対象のEC2の名前(今回の場合`EC2Prod`)を間違えるとcfn-initコマンドが実行されないので注意が必要です。これでだいぶハマった。

- `'AWS::CloudFormation::Init':`以下に記載した内容が実行される
ここで、必要なパッケージとして、`git`や`Apache`、RDSに接続するための`mariadb`クライアント、Let's encryptの`Certbot`を導入しています。

このスタックが作成されると、本番環境のEC2に対して`http://[EC2ProdHostName].[ServiceDomainName]`でアクセスできるようになります。


## 3, バージニア北部リージョンでACM証明書を作成するスタック

```yaml:ACM.yml
AWSTemplateFormatVersion: 2010-09-09
Description: >-
  Cloudformation stack for some project Infrastructure, 
  Create ACM Certificate 
  this stack should to execute at us-east-1

Metadata:
  'AWS::CloudFormation::Interface':
    ParameterGroups:
      - Label:
          default: Global Setting
        Parameters:
          - PjName
          - ServiceDomainName
          - StagingDomainName

Parameters:
    PjName:
        Type: String
        Default: MyProject
    ServiceDomainName:
        Type: String
        Default: example.com
        Description: Service domain name will set ACM certificate.
    StagingDomainName:
        Type: String
        Default: staging.example.com
        Description: Staging env service domain name will set ACM certificate.

Resources:
######################################################################################################
# Production environment
######################################################################################################
# ------------------------------------------------------------#
#  ACM Certificate Cloudfront for Prod
# ------------------------------------------------------------#
  ACMCertCFProd:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: !Ref ServiceDomainName
      DomainValidationOptions:
        - DomainName: !Ref ServiceDomainName
          ValidationDomain: !Ref 'ServiceDomainName'
      Tags:
        - Key: Name
          Value: !Sub '${ServiceDomainName}-acm'
      ValidationMethod: DNS


######################################################################################################
# Staging environment
######################################################################################################

#### 各コンポーネントのProd→Stgに変更する以外は本番の丸コピー


Outputs:
  ACMCertCFProd:
    Description: ACM Certificate for Prod environment
    Value: !Ref ACMCertCFProd
  ACMCertCFStg:
    Description: ACM Certificate for Stg environment
    Value: !Ref ACMCertCFStg
```

Cloudfrontからhttps通信を行う場合、Cloudfrontの仕様でバージニア北部リージョンでACM証明書を作る必要があります。
Cloudformationのスタックは、スタックを実行したリージョンで動作が完結することを前提としているようで、リージョンをまたいだ記述ができません。(できるのかもしれないけど調べた範囲では不可だった)
このため、この部分だけは独立してバージニア北部リージョンにて実行し、スタックの実行後に出力タブにACM証明書のarnを出力するように設定します。
この時に出力されるarnを、Cloudfrontを設定するスタック実行時に、パラメータとして渡します。
※出力した値をExport設定しても、リージョンをまたいで利用できません。

### 後処理

ACM証明書を作成する際、DNSで認証するように設定しているので、WebコンソールのACMを開き、各証明書の認証に必要なCNAMEレコードを作成します。
Route53を使っている場合は、ACMの画面にCNAMEレコードを作成するボタンが出るので、こちらを使うと楽です。
この操作を実施しないと、スタックの作成が完了しません。また、この状態で長時間(12時間？)放置するとスタックの作成がロールバックされてしまいます。

## 4, 公開するために必要なコンポーネントを設定するスタック

```yaml:frontside.yml
AWSTemplateFormatVersion: 2010-09-09
Description: >-
  Cloudformation stack for some project Infrastructure, 
  Creates Route53 Recordset, Cloudfront

Metadata:
  'AWS::CloudFormation::Interface':
    ParameterGroups:
      - Label:
          default: Global Setting
        Parameters:
          - PjName
      - Label:
          default: Cloudfront ACM Certificates
        Parameters:
          - ACMCertCFProd
          - ACMCertCFStg

Parameters:
    PjName:
        Type: String
        Default: MyProject
    ACMCertCFProd:
        Type: String
        Default : ARN of ACM Certificate for Prod
        Description: ARN of ACM certificate for Production environment 
    ACMCertCFStg:
        Type: String
        Default : ARN of ACM Certificate for Stg
        Description: ARN of ACM certificate for Staging environment 

Resources:
######################################################################################################
# Production environment
######################################################################################################
# ------------------------------------------------------------#
#  Route53 Recordset for prod
# ------------------------------------------------------------#
    R53RSCF:
        Type: "AWS::Route53::RecordSet"
        DependsOn: CfProd
        Properties:
            Name: 
              Fn::ImportValue: 
                !Sub "${PjName}-ServiceDomainName"
            Type: A
            HostedZoneId: 
              Fn::ImportValue: 
                !Sub "${PjName}-R53HostedZone"
            AliasTarget:
                DNSName: !GetAtt CfProd.DomainName
                HostedZoneId: "Z2FDTNDATAQYW2"
# ------------------------------------------------------------#
#  Cloudfront for Prod
# ------------------------------------------------------------#
    CfProd:
        Type: 'AWS::CloudFront::Distribution'
        Properties:
          DistributionConfig:
            PriceClass: PriceClass_All
            Aliases:
              - Fn::ImportValue: 
                  !Sub "${PjName}-ServiceDomainName"
            Origins:
              - DomainName: !GetAtt S3Prod.DomainName
                Id: !Sub 'S3origin-${BucketName}-prod'
                S3OriginConfig:
                  OriginAccessIdentity: 
                    - Fn::Join:
                        - ""
                        - -'origin-access-identity/cloudfront/'
                          - Fn::ImportValue: !Sub "${PjName}-CfAccessIdentityProd"
              - DomainName: 
                  Fn::ImportValue:
                    !Sub "${PjName}-EC2ProdDomainName"
                Id: 
                  Fn::ImportValue:
                    !Sub "${PjName}-EC2ProdDomainName"
                CustomOriginConfig:
                  OriginProtocolPolicy: match-viewer

            DefaultRootObject: index.html
            DefaultCacheBehavior:
              TargetOriginId: !Sub 'S3origin-${BucketName}-prod'
              ViewerProtocolPolicy: redirect-to-https
              AllowedMethods:
                - GET
                - HEAD
              CachedMethods:
                - GET
                - HEAD
              DefaultTTL: 3600
              MaxTTL: 86400
              MinTTL: 60
              Compress: true
              ForwardedValues:
                Cookies:
                  Forward: none
                QueryString: false
            CacheBehaviors:
              - TargetOriginId: 
                  Fn::ImportValue:
                    !Sub "${PjName}-EC2ProdDomainName"
                AllowedMethods:
                  - HEAD
                  - DELETE
                  - POST
                  - GET
                  - OPTIONS
                  - PUT
                  - PATCH
                CachedMethods:
                  - GET
                  - HEAD
                Compress: true
                DefaultTTL: 3600
                MaxTTL: 86400
                MinTTL: 60
                PathPattern: /ec2/*
                ViewerProtocolPolicy: redirect-to-https
                ForwardedValues:
                  Cookies:
                    Forward: all
                  QueryString: true
            ViewerCertificate:
              SslSupportMethod: sni-only
              MinimumProtocolVersion: TLSv1.1_2016
              AcmCertificateArn: !Ref ACMCertCFProd
            HttpVersion: http2
            Enabled: true

######################################################################################################
# Staging environment
######################################################################################################

#### 各コンポーネントのProd→Stgに変更する以外は本番の丸コピー

######################################################################################################
# Output section
######################################################################################################
Outputs:
    DistributionIDProd:
        Value: !Ref CfProd
    DistributionIDStg:
        Value: !Ref CfStg

```

- Cloudfrontに対抗するRoute53レコードセットについて
上記に記載したような`AliasTarget`の設定で固定になっています。
Route53以外のDNSを使う場合はCNAMEレコードを設定しますが、Route53ではこのように設定できるらしい。

- Cloudfrontの`CacheBehaviors`の`AllowedMethods`について
柔軟に対応できそうな見た目をしているが、決め打ちで3パターンぐらいしか通るのがなく、EC2側オリジンの記述では、アクセスメソッドが全部OKみたいな形になってしまいます。

## 5, 開発環境を設定するスタック

内容としてはここまで書いてきたものをコピペして作ることができます。
開発環境については、

- 複数作成する可能性があること
- 作成と削除を繰り返す可能性があること

から、独立性を高めておく必要があると考えて、このようにしています。

```yaml:dev-env.yml
AWSTemplateFormatVersion: 2010-09-09
Description: >-
  Cloudformation stack for some project Infrastructure, 
  Creates NWIF, EIP, EC2, Route53RS for develop environment

Metadata:
  'AWS::CloudFormation::Interface':
    ParameterGroups:
      - Label:
          default: Global Setting
        Parameters:
          - PjName
      - Label:
          default: EC2 configuration
        Parameters:
          - EC2HostName
          - EC2KeyName

Parameters:
    PjName:
        Type: String
        Default: MyProject
    EC2HostName:
        Type: String
        Default: dev-hostname
        Description: Prod env EC2 instance Domain name 
    EC2KeyName:
        Type: 'AWS::EC2::KeyPair::KeyName'
        Description: Prod env EC2 instance Keypair name. Keypair must be created before use. 

Resources:
    NWIF:
        Type: 'AWS::EC2::NetworkInterface'
        Properties:
            Description: Network Interface for EC2
            GroupSet:
              - Fn::ImportValue:
                  !Sub "${PjName}-stg-sg-internal"
              - Fn::ImportValue:
                  !Sub "${PjName}-stg-sg-public-web"
            InterfaceType: interface
            SourceDestCheck: false
            SubnetId: 
              Fn::ImportValue:
                !Sub "${PjName}-SubnetCStg"
            Tags:
              - Key: Name
                Value: !Sub '${PjName}-dev-1c-nwif'
    EIP:
        Type: 'AWS::EC2::EIP'
        Properties:
            Domain: vpc
    EIPEC2Associate:
        Type: 'AWS::EC2::EIPAssociation'
        Properties:
            AllocationId: !GetAtt 
              - EIP
              - AllocationId
            NetworkInterfaceId: !Ref NWIF

    EC2:
        Type: 'AWS::EC2::Instance'
        Metadata:
          'AWS::CloudFormation::Init':
            configSets:
              Initialize:
                - Install
            Install:
                packages:
                    yum:
                        mariadb: []
                        mariadb-libs: []
                        httpd: []
                        unzip: []
                        git: []
                        certbot: []
                        python2-certbot-apache: []
                files:
                    /var/www/html/index.php:
                        content: !Join 
                          - ''
                          - - |
                                  <?php
                            - |2
                                    phpinfo();
                            - |2
                                  ?>
                        mode: '000600'
                        owner: apache
                        group: apache
                    /etc/cfn/cfn-hup.conf:
                        content: !Join 
                          - ''
                          - - |
                              [main]
                            - stack=
                            - !Ref 'AWS::StackId'
                            - |+

                            - region=
                            - !Ref 'AWS::Region'
                            - |+

                        mode: '000400'
                        owner: root
                        group: root
                    /etc/cfn/hooks.d/cfn-auto-reloader.conf:
                        content: !Join 
                          - ''
                          - - |
                              [cfn-auto-reloader-hook]
                            - |
                              triggers=post.update
                            - >
                              path=Resources.WebServerInstance.Metadata.AWS::CloudFormation::Init
                            - 'action=/opt/aws/bin/cfn-init -v '
                            - '         --stack '
                            - !Ref 'AWS::StackName'
                            - '         --resource EC2'
                            - '         --configsets Initialize '
                            - '         --region '
                            - !Ref 'AWS::Region'
                            - |+

                            - |
                              runas=root
                        mode: '000400'
                        owner: root
                        group: root
                services:
                    sysvinit:
                      httpd:
                        enabled: 'true'
                        ensureRunning: 'true'
                      php-fpm:
                        enabled: 'true'
                        ensureRunning: 'true'
                      cfn-hup:
                        enabled: 'true'
                        ensureRunning: 'true'
                        files:
                          - /etc/cfn/cfn-hup.conf
                          - /etc/cfn/hooks.d/cfn-auto-reloader.conf

        Properties:
            ImageId: 'ami-0ff21806645c5e492'
            InstanceType: 't3.micro'
            KeyName: !Ref EC2KeyName
            BlockDeviceMappings: 
              - DeviceName: "/dev/xvda"
                Ebs:  
                  DeleteOnTermination: False
                  Encrypted: False
                  VolumeSize: 16
                  VolumeType: gp2
            NetworkInterfaces:
              - NetworkInterfaceId: !Ref NWIF
                DeviceIndex: 0
            Tags:
              - Key: Name
                Value: !Ref EC2HostName
            UserData: !Base64 
              'Fn::Join':
                - ''
                - - |
                    #!/bin/bash -xe
                  - |
                    yum update -y aws-cfn-bootstrap
                  - |
                    amazon-linux-extras install ansible2 epel php7.3 
                  - |
                    # Install the files and packages from the metadata
                  - '/opt/aws/bin/cfn-init -v '
                  - '         --stack '
                  - !Ref 'AWS::StackName'
                  - '         --resource EC2 '
                  - '         --configsets Initialize '
                  - '         --region '
                  - !Ref 'AWS::Region'
                  - |+

                  - |
                    # Signal the status from cfn-init
                  - '/opt/aws/bin/cfn-signal -e $? '
                  - '         --stack '
                  - !Ref 'AWS::StackName'
                  - '         --resource EC2 '
                  - '         --region '
                  - !Ref 'AWS::Region'
                  - |+

```

- VPCについて
Dev環境用にVPCは作成していません。
Stg環境の1cのサブネットを間借りするような形にしています。


# 残作業

このままではCloudfront-EC2の間の通信がhttpsにならないので、SSL通信の設定をする必要があります。

## Let's encryptを使ってCloudfront-EC2の間の通信をhttpsにする
のだが、Certbotの設定を行う際、先にApacheのバーチャルホストの設定がないと設定に失敗します。

1. Ansibleでバーチャルホストの設定を作る
http通信のバーチャルホスト設定を作成します。

2. 改めてCertbotを実行してhttpsで通信できるようにする。
この際、Cloudfrontの設定に合わせるように、http通信をhttps通信にリダイレクトするように設定しておくといいかもしれない。

3. EC2のネットワークインターフェースに紐づいているWeb全公開のセキュリティグループを外す
Certbotの認証が通って、無事SSL証明書が発行されたら、いったん外部からの接続をできないようにします。


## ApacheとPHPの基本的な設定が必要
タイムゾーンの設定を入れるとか、PHP7.3をPHP-FPMで動くようにするとかそういう
これも上記のAnsibleに入れてしまえばOKかと。

## 残作業に関するコメント
実際のAnsible-playbookについてはあまりに稚拙なのでちょっと乗せるのを躊躇う。。(ひどい

うまくいっているかどうかの指標として、バーチャルホストを設定し、DocumentRootを元の`/var/www/html/`から移動すると、PHPinfoのページからApacheデフォルトのページに変化します。これがうまくいっていることを確認してからCertbotを動かすといいかも。
Certbotの設定がうまくいくと、Cloudfrontに設定したドメイン名でアクセスが通るようになります。

# まとめ

張り切りすぎて記事が長くなりすぎた。(大風呂敷過ぎたんだ)

というのもさることながら、EC2を使ったシステムを稼働させるためには、これだけたくさんの設定を入れる必要があって、どれか一つ失敗するとシステムはうまく動かないということを再認識。やはり自動化は必要、、、という気持ちが強くなりました。




# (余談)cfnを使うことになった経緯

1. 新サービスやるよ、インフラコストは可能な限り安くしてね(いつものこと)
- じゃあサーバレスでやればいいよ(半ギレ)、それに向けて準備するね？
- 開発の都合は知らないけど納期はこっちで指定するしその日までにリリースできなきゃ怒るよ(いつもの開発者を怒らせる一言)
- うーんじゃあサーバレスやめてよくある構成で考え直して(上司の常識的な判断)
- わかりました(しろめ)、でも今までやってなかった要素は取り入れるよ

という話の中で、2番の段階でどう使うか調べ始めていたCloudFormationを使うことに。
これまでインスタンスの構成管理のためにAnsibleを使っていたので、これも併用する形で、運用しやすい環境を構築してやろうと考えたわけです。