TECHSTEP

ITインフラ関連の記事を公開してます。

IaCの継続的テストを検証する: Amazon EC2に対するテスト

背景

前回AWS CloudFormationで管理するAmazon S3に対し、CI/CDによる継続的なテストと更新を検証しましたが、今回はAmazon EC2を対象に検証してみました。

Amazon EC2で気を付けた点

まず、Amazon EC2に対してテストを行う前に、以前のAmazon S3と比べて異なる点をいくつか記載しておきます。今回は扱うリソースが異なるのは当然ですが、それによって前回は気にしなくてもよかった部分も少し工夫が必要となります。

OSレイヤーの管理が必要

Amazon EC2AWS上で仮想サーバーを利用できるサービスです。EC2インスタンスは多くの場合インスタンスを作成するだけでなく、インスタンス内部にソフトウェアや実行ファイルなどを配置し、何らかの機能を提供するよう設定します。

そのため、EC2インスタンスを管理するには、AWS CloudFormationだけでは完結できず、別の方法が必要になります。例えば Ansible を使えばNginxのインストールやファイルの配置などの操作を宣言的に定義することが可能です。

リソース内部の状態が正常であることの確認が必要

Amazon EC2を作成すると、インスタンス自体は起動して Running の状態であるにもかかわらず、起動直後は内部プロセスの初期化が完了していないため、インスタンスに接続できないことがあります。これは更新後のテストにも影響するため、インスタンス内部の状態が安定してからテストを行うよう制御したいです。

Amazon EC2DescribeInstanceStatus というAPIを提供しています。このAPIではインスタンスが起動しているかだけでなく、インスタンス内部の状態がどうなっているかも提供します。具体的には instanseStatusインスタンス内部に関する障害を、systemStatusインスタンスをサポートするシステムに関連した障害を、それぞれチェックします。

またこれだけではなく、インスタンス内部で起動するサービス・プロセスが正常に起動しているかも確認が必要となります。

インスタンスへの接続が必要

前項の DescribeInstanceStatus APIなどは、AWS APIを通じてリソースにアクセスすることで情報を取得できます。しかし、EC2の設定変更、特にインスタンスへのファイルの配置やプロセスの再起動などを行う場合、SSHなどのプロトコルを用いて直接インスタンスにアクセスしなければなりません (AWSでは AWS Systems Manager Session ManagerEC2 Instance Connect など別の接続方法も提供されていますが、ここでは割愛します) 。

SSH接続の場合、デフォルトでは22番ポートを開放する必要がありますが、セキュリティの観点から必要以上のポートを開放するのは避けたいところです。そのため、インスタンスにアクセスして設定変更を行う場合だけ、一時的にポートを開放するような処理を追加しました。

テストの観点

続いて今回実施するテストの観点を整理します。ただし前回実施した内容は継続して行うため、今回新しく導入したテストについてのみ触れます。

利用するリソースの大部分をコードで管理する

前回記事の検証では、一連の処理で扱うリソースの大部分をコードで管理するようにしました。Amazon S3の場合はAWS CloudFormationでリソースの定義を完結できますが、上述の通りAmazon EC2はCloudFormationだけでリソースを管理することはできません。

以前紹介した書籍のPractice に従うのであれば、本来は Ansible などOSレイヤーも宣言的に管理できるIaCツールを利用すべきです。ただし今回はEC2で扱うリソース・プロセスを最小限にし、scp コマンドなどを利用してファイルを配置する形としました。理由は、「サーバーに対して継続的テストを行う」ことに集中するため、CloudFormation以外のIaCツールを使わないようにしたかったからです。

こう考えていたのですが結果的にはかなり複雑な処理になってしまいました。。。

オンラインテストを導入し、起動後のサーバーの状態を確認する

前回は構文チェックなど静的解析を中心にしましたが、今回はインスタンス起動後の動的解析も実施しています。

まず、前回実施した cfn-lint trivy による静的解析は、AWSの幅広いリソースに適用可能なため、今回もテストに組み込んでいます。

一方、サーバーに対して継続的テストを行う場合、サーバー上のリソースやプロセスに対して更新を適用した後は、該当のファイルやプロセスなどが正常か、確認する必要があります。今回は、以下の点を確認しています。

  • ファイルの同一性: サーバー上に配置したファイルの内容は、GitHub上のファイルと一致すること。md5sum コマンドで確認。
  • Nginxプロセスの正常性: 更新後にNginxのプロセスが正常に起動していること。 systemctl list-units コマンドで確認。
  • Webページへの疎通性: 更新後にサーバーにアクセスし、Webページがアクセス可能であること。 curl コマンドで確認。

※以前紹介した書籍の中では Verification: Making Assertions About Infrastructure Resources の箇所が該当します。

検証

ここから検証した内容を紹介します。前回に引き続き GitHub Actionsを使ってCI/CDとAWS CloudFormationとの組み合わせになります。

環境

今回の検証環境は以下の通りです。

  • 環境: AWS
  • IaCツール: AWS CloudFormation
  • CI/CDツール: GitHub Actions
  • テストツール: cfn-lint / trivy
  • その他: あらかじめEC2インスタンスNginx のインストールと、テスト用のファイル ( index.html ) を配置したAMIを用意しておきます。

構成は以下の通りです。

処理の流れ

処理の流れは以下の通りです。今回はCloudFormation定義ファイルと index.html という2つのファイルを更新対象としており、それぞれのファイルで更新があれば別の処理を起動するようにしています。また同時に2つのファイルが更新される場合も想定し、追加の処理を行います。

  • (更新対象のCloudFormation Stackはあらかじめ作成しておく)
  • Amazon S3を定義するIaCファイルを更新し、GitHub上でPRを作成する
  • PRの作成をトリガーに、GitHub ActionsがCIを実行する
    • コード解析
    • ドリフトのチェック
    • Change setの作成
  • CIの結果を確認し、問題なければマージする
  • マージをトリガーにGitHub ActionsがCDを実行する
    • CIと同様の処理
    • EC2インスタンスの状態確認
    • CloudFormationファイルを更新した場合
      • Change setの適用
    • index.htmlファイルを更新した場合
      • Securty Groupの設定変更によるSSHポート開放
      • インスタンスへのログインとindex.htmlファイルの配置
      • コピー元とコピー先のファイル比較
      • Nginxプロセスの確認
      • Webページへのアクセス確認
      • Security Groupの設定変更によるポート閉鎖
    • 両方を一度に更新した場合
      • CloudFormationと同じ処理
      • インスタンスの状態確認
      • index.htmlと同じ処理

今回使用したコード、およびGitHubリポジトリ上のディレクトリ構成は以下の通りです。

.
├── .github
│   └── workflows
│       ├── ec2-github-actions-check.yaml
│       └── ec2-github-actions-apply.yaml
├── README.md
├── ec2-github-actions-check
│   ├── ec2.yaml
│   └── src
│       └── index.html
└── iam.yaml

ec2-github-actions-check.yaml

name: EC2 check at Pull Request

on:
  pull_request:
    types: 
      - opened
      - synchronize
    paths:
      - ec2-github-actions-check/**

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: ap-northeast-1
  AWS_IAM_ROLE: ${{ secrets.AWS_IAM_ROLE }}
  AWS_STACK_NAME: ec2-github-actions-check

jobs:
  pr:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v3
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.AWS_IAM_ROLE }}
          aws-region: ${{ env.AWS_REGION }}
      - name: Testing with CFN Lint Command
        uses: scottbrenner/cfn-lint-action@v2
        with:
          command: cfn-lint -t ./ec2-github-actions-check/*.yaml
      - name: Testing with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: 'config'
          exit-code: '1'
          severity: 'CRITICAL'
      - name: Drift Detection
        run: |
          STACK_DRIFT_DETECT_ID=`aws cloudformation detect-stack-drift --stack-name ${{ env.AWS_STACK_NAME }} --query 'StackDriftDetectionId' --output text`
          sleep 10s
          DRIFT_STATUS=`aws cloudformation describe-stack-drift-detection-status --stack-drift-detection-id $STACK_DRIFT_DETECT_ID --query 'StackDriftStatus' --output text`
          echo "DRIFT_STATUS=${DRIFT_STATUS}" >> $GITHUB_ENV
      - name: Stop workflow when drift detected
        if: contains(env.DRIFT_STATUS, 'IN_SYNC') == false
        run: |
          aws cloudformation describe-stack-resource-drifts --stack-name ${{ env.AWS_STACK_NAME }}
          exit 1
      - name: Get changed files
        id: changed-files
        uses: tj-actions/changed-files@v39
        with:
          files: |
            ec2-github-actions-check/**
      - name: Create changeset
        if: contains(steps.changed-files.outputs.all_changed_files, 'ec2-github-actions-check/ec2.yaml')
        run: |
          CHANGE_SET_NAME="changeset-$(date "+%Y%m%d-%H%M%S")"
          echo "CHANGE_SET_NAME=${CHANGE_SET_NAME}" >> $GITHUB_ENV
          aws cloudformation create-change-set --template-body file://ec2-github-actions-check/ec2.yaml --stack-name ${{ env.AWS_STACK_NAME }} --change-set-name $CHANGE_SET_NAME --capabilities CAPABILITY_NAMED_IAM
          aws cloudformation wait change-set-create-complete --stack-name ${{ env.AWS_STACK_NAME }} --change-set-name $CHANGE_SET_NAME
          aws cloudformation describe-change-set --stack-name ${{ env.AWS_STACK_NAME }} --change-set-name $CHANGE_SET_NAME

ec2-github-actions-apply.yaml

name: EC2 check & apply at Pull Request Merge

on:
  pull_request:
    types: 
      - closed
    paths:
      - ec2-github-actions-check/**

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: ap-northeast-1
  AWS_IAM_ROLE: ${{ secrets.AWS_IAM_ROLE }}
  AWS_STACK_NAME: ec2-github-actions-check
  INSTANCE_NAME: ec2-github-actions-check-ec2

jobs:
  merge:
    runs-on: ubuntu-22.04
    if: github.event.pull_request.merged == true
    outputs:
      changed-files: ${{ steps.changed-files.outputs.all_changed_files}}
    steps:
      - uses: actions/checkout@v3
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.AWS_IAM_ROLE }}
          aws-region: ${{ env.AWS_REGION }}
      - name: Testing with CFN Lint Command
        uses: scottbrenner/cfn-lint-action@v2
        with:
          command: cfn-lint -t ./ec2-github-actions-check/*.yaml
      - name: Testing with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: 'config'
          exit-code: '1'
          severity: 'CRITICAL'
      - name: Drift Detection
        run: |
          STACK_DRIFT_DETECT_ID=`aws cloudformation detect-stack-drift --stack-name ${{ env.AWS_STACK_NAME }} --query 'StackDriftDetectionId' --output text`
          sleep 10s
          DRIFT_STATUS=`aws cloudformation describe-stack-drift-detection-status --stack-drift-detection-id $STACK_DRIFT_DETECT_ID --query 'StackDriftStatus' --output text`
          echo "DRIFT_STATUS=${DRIFT_STATUS}" >> $GITHUB_ENV
      - name: Stop workflow when drift detected
        if: contains(env.DRIFT_STATUS, 'IN_SYNC') == false
        run: |
          aws cloudformation describe-stack-resource-drifts --stack-name ${{ env.AWS_STACK_NAME }}
          exit 1
      - name: Get changed files
        id: changed-files
        uses: tj-actions/changed-files@v39
        with:
          files: |
            ec2-github-actions-check/**
      - name: Check EC2 instance status
        run: |
          INSTANCE_ID=`aws ec2 describe-instances --filter "Name=tag:Name,Values=${{ env.INSTANCE_NAME }}" --query 'Reservations[].Instances[].InstanceId' --output text`
          INSTANCE_STATUS=`aws ec2 describe-instance-status --instance-ids $INSTANCE_ID --query 'InstanceStatuses[0].InstanceStatus.Status' --output text`
          SYSTEM_STATUS=`aws ec2 describe-instance-status --instance-ids $INSTANCE_ID --query 'InstanceStatuses[0].SystemStatus.Status' --output text`
          INSTANCE_STATE=`aws ec2 describe-instance-status --instance-ids $INSTANCE_ID --query 'InstanceStatuses[0].InstanceState.Name' --output text`
          echo "INSTANCE_STATUS=${INSTANCE_STATUS}" >> $GITHUB_ENV
          echo "SYSTEM_STATUS=${SYSTEM_STATUS}" >> $GITHUB_ENV
          echo "INSTANCE_STATE=${INSTANCE_STATE}" >> $GITHUB_ENV
      - name: Stop workflow if EC2 instance have problems
        if: >-
          contains(env.INSTANCE_STATUS, 'ok') == false ||
          contains(env.SYSTEM_STATUS, 'ok') == false ||
          contains(env.INSTANCE_STATE, 'running') == false
        run: |
          echo "INSTANCE_STATUS = ${{ env.INSTANCE_STATUS }}"
          echo "SYSTEM_STATUS = ${{ env.SYSTEM_STATUS }}"
          echo "INSTANCE_STATE is ${{ env.INSTANCE_STATE }}"
          exit 1
  cfn-apply:
    runs-on: ubuntu-22.04
    needs: merge
    if: contains(needs.merge.outputs.changed-files, 'ec2-github-actions-check/ec2.yaml')
    steps:
      - uses: actions/checkout@v3
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.AWS_IAM_ROLE }}
          aws-region: ${{ env.AWS_REGION }}
      # CloudFormation change
      - name: Create changeset
        run: |
          CHANGE_SET_NAME="changeset-$(date "+%Y%m%d-%H%M%S")"
          echo "CHANGE_SET_NAME=${CHANGE_SET_NAME}" >> $GITHUB_ENV
          aws cloudformation create-change-set --template-body file://ec2-github-actions-check/ec2.yaml --stack-name ${{ env.AWS_STACK_NAME }} --change-set-name $CHANGE_SET_NAME --capabilities CAPABILITY_NAMED_IAM
          aws cloudformation wait change-set-create-complete --stack-name ${{ env.AWS_STACK_NAME }} --change-set-name $CHANGE_SET_NAME
          aws cloudformation describe-change-set --stack-name ${{ env.AWS_STACK_NAME }} --change-set-name $CHANGE_SET_NAME
      - name: Execute changeset
        run: |
          aws cloudformation execute-change-set --change-set-name ${{ env.CHANGE_SET_NAME }} --stack-name ${{ env.AWS_STACK_NAME }}
          aws cloudformation wait stack-update-complete --stack-name ${{ env.AWS_STACK_NAME }}
          aws cloudformation describe-stacks --stack-name ${{ env.AWS_STACK_NAME }} --query 'Stacks[*].StackStatus' --output text
  os-file-apply:
    runs-on: ubuntu-22.04
    needs: merge
    if: >-
      contains(needs.merge.outputs.changed-files, 'ec2-github-actions-check/src/index.html') &&
      !contains(needs.merge.outputs.changed-files, 'ec2-github-actions-check/ec2.yaml')
    steps:
      - uses: actions/checkout@v3
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.AWS_IAM_ROLE }}
          aws-region: ${{ env.AWS_REGION }}
      # index.html change
      - name: Get public ip
        id: ip
        uses: haythem/public-ip@v1.3
      - name: Open security group
        run: |
          aws ec2 authorize-security-group-ingress --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
          aws ec2 authorize-security-group-ingress --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} --protocol tcp --port 80 --cidr ${{ steps.ip.outputs.ipv4 }}/32
      - name: Update index.html
        run: |
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > private_key
          chmod 400 private_key
          scp -oStrictHostKeyChecking=no -i private_key ec2-github-actions-check/src/index.html ${{ secrets.EC2_USER_NAME }}@${{ secrets.EC2_HOST_NAME }}:/tmp/index.html
          ssh -oStrictHostKeyChecking=no -i private_key ${{ secrets.EC2_USER_NAME }}@${{ secrets.EC2_HOST_NAME }} "sudo cp /tmp/index.html /usr/share/nginx/html/sample/index.html"
      - name: Check whether index.html is correctly updated
        run: |
          ORIGINAL_FILE_HASH=`md5sum ec2-github-actions-check/src/index.html | awk '{ print $1 }'`
          COPIED_FILE_HASH=`ssh -oStrictHostKeyChecking=no -i private_key ${{ secrets.EC2_USER_NAME }}@${{ secrets.EC2_HOST_NAME }} "sudo md5sum /usr/share/nginx/html/sample/index.html" | awk '{ print $1 }'`
          echo "ORIGINAL_FILE_HASH=${ORIGINAL_FILE_HASH}" >> $GITHUB_ENV
          echo "COPIED_FILE_HASH=${COPIED_FILE_HASH}" >> $GITHUB_ENV
      - name: Stop workflow if index.html file is not matched
        if: env.ORIGINAL_FILE_HASH != env.COPIED_FILE_HASH
        run: |
          echo "ORIGINAL_FILE_HASH = ${{ env.ORIGINAL_FILE_HASH }}"
          echo "COPIED_FILE_HASH = ${{ env.COPIED_FILE_HASH }}"
          exit 1
      - name: Check OS status after update index.html
        run: |
          NGINX_PROCESS_STATUS=`ssh -oStrictHostKeyChecking=no -i private_key ${{ secrets.EC2_USER_NAME }}@${{ secrets.EC2_HOST_NAME }} "sudo systemctl list-units -t service | grep nginx"`
          CURL_STATUS_CODE=`curl ${{ secrets.EC2_HOST_NAME }}/sample/ -o /dev/null -w '%{http_code}\n' -s`
          echo "NGINX_PROCESS_STATUS=${NGINX_PROCESS_STATUS}" >> $GITHUB_ENV
          echo "CURL_STATUS_CODE=${CURL_STATUS_CODE}" >> $GITHUB_ENV
      - name: Stop workflow if OS status has problem
        if: >-
          contains(env.NGINX_PROCESS_STATUS, 'nginx.service') == false ||
          contains(env.CURL_STATUS_CODE, '200') == false
        run: |
          echo "NGINX_PROCESS_STATUS = ${{ env.NGINX_PROCESS_STATUS }}"
          echo "CURL_STATUS_CODE = ${{ env.CURL_STATUS_CODE }}"
          exit 1
      - name: close security group
        if: always()
        run: |
          aws ec2 revoke-security-group-ingress --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
          aws ec2 revoke-security-group-ingress --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} --protocol tcp --port 80 --cidr ${{ steps.ip.outputs.ipv4 }}/32
  os-file-apply-with-cfn:
    runs-on: ubuntu-22.04
    needs: [merge, cfn-apply]
    if: >-
      contains(needs.merge.outputs.changed-files, 'ec2-github-actions-check/src/index.html') &&
      contains(needs.merge.outputs.changed-files, 'ec2-github-actions-check/ec2.yaml')
    steps:
      - uses: actions/checkout@v3
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.AWS_IAM_ROLE }}
          aws-region: ${{ env.AWS_REGION }}
      - name: Wait if CloudFormation file is modified
        run: |
          INSTANCE_ID=`aws ec2 describe-instances --filter "Name=tag:Name,Values=${{ env.INSTANCE_NAME }}" --query 'Reservations[].Instances[].InstanceId' --output text`
          while :
          do
            INSTANCE_STATUS=`aws ec2 describe-instance-status --instance-ids $INSTANCE_ID --query 'InstanceStatuses[0].InstanceStatus.Status' --output text`
            SYSTEM_STATUS=`aws ec2 describe-instance-status --instance-ids $INSTANCE_ID --query 'InstanceStatuses[0].SystemStatus.Status' --output text`
            if [ "$INSTANCE_STATUS" = "ok" ] && [ "$SYSTEM_STATUS" = "ok" ]; then
              echo "Instance is running successfully"
              break
            elif [ "$INSTANCE_STATUS" = "initializing" ] || [ "$SYSTEM_STATUS" = "initializing" ]; then
              echo "Instance is initializing..."
              sleep 10
            else
              echo "INSTANCE_STATUS = $INSTANCE_STATUS"
              echo "SYSTEM_STATUS = $SYSTEM_STATUS"
              exit 1
            fi
          done
      # index.html change
      - name: Get public ip
        id: ip
        uses: haythem/public-ip@v1.3
      - name: Open security group
        run: |
          aws ec2 authorize-security-group-ingress --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
          aws ec2 authorize-security-group-ingress --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} --protocol tcp --port 80 --cidr ${{ steps.ip.outputs.ipv4 }}/32
      - name: Update index.html
        run: |
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > private_key
          chmod 400 private_key
          scp -oStrictHostKeyChecking=no -i private_key ec2-github-actions-check/src/index.html ${{ secrets.EC2_USER_NAME }}@${{ secrets.EC2_HOST_NAME }}:/tmp/index.html
          ssh -oStrictHostKeyChecking=no -i private_key ${{ secrets.EC2_USER_NAME }}@${{ secrets.EC2_HOST_NAME }} "sudo cp /tmp/index.html /usr/share/nginx/html/sample/index.html"
      - name: Check OS status after update index.html
        run: |
          NGINX_PROCESS_STATUS=`ssh -oStrictHostKeyChecking=no -i private_key ${{ secrets.EC2_USER_NAME }}@${{ secrets.EC2_HOST_NAME }} "sudo systemctl list-units -t service | grep nginx"`
          CURL_STATUS_CODE=`curl ${{ secrets.EC2_HOST_NAME }}/sample/ -o /dev/null -w '%{http_code}\n' -s`
          echo "NGINX_PROCESS_STATUS=${NGINX_PROCESS_STATUS}" >> $GITHUB_ENV
          echo "CURL_STATUS_CODE=${CURL_STATUS_CODE}" >> $GITHUB_ENV
      - name: Stop workflow if OS status has problem
        if: >-
          contains(env.NGINX_PROCESS_STATUS, 'nginx.service') == false ||
          contains(env.CURL_STATUS_CODE, '200') == false
        run: |
          echo "NGINX_PROCESS_STATUS = ${{ env.NGINX_PROCESS_STATUS }}"
          echo "CURL_STATUS_CODE = ${{ env.CURL_STATUS_CODE }}"
          exit 1
      - name: close security group
        if: always()
        run: |
          aws ec2 revoke-security-group-ingress --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
          aws ec2 revoke-security-group-ingress --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} --protocol tcp --port 80 --cidr ${{ steps.ip.outputs.ipv4 }}/32

ec2.yaml

AWSTemplateFormatVersion: "2010-09-09"
Parameters:
  EnvName:
    Type: String
    Default: ec2-github-actions-check
  EC2InstanceType:
    Type: String
    Default: t3.micro
  SubnetId:
    Type: String
    Default: <Subnet IDを指定>
  EC2ImageId:
    Type: AWS::EC2::Image::Id
    Default: <AMI IDを指定>
  EC2SecurityGroup:
    Type: String
    Default: <Security Group IDを指定>
  KeyPair:
    Type: String
    Default: <Key名を指定>

Resources:
  ElasticIP:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc
  EC2Instance:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: !Ref EC2InstanceType
      SubnetId: !Ref SubnetId
      ImageId: !Ref EC2ImageId
      SecurityGroupIds:
        - !Ref EC2SecurityGroup
      IamInstanceProfile: !Ref EC2InstanceProfile
      KeyName: !Ref KeyPair
      Tags:
        - Key: Name
          Value: !Sub ${EnvName}-ec2
  EIPAssociationToEC2:
    Type: AWS::EC2::EIPAssociation
    Properties:
      InstanceId: !Ref EC2Instance
      AllocationId: !GetAtt ElasticIP.AllocationId
  EC2IAMRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${EnvName}-SSM-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 ${EnvName}-EC2InstanceProfile

検証結果

ここから実際の検証した様子を紹介します。

CloudFormationファイルを更新した場合

まずはCloudFormationファイルを更新した場合です。この場合は前回のS3バケットの更新とほぼ同じ流れとなります。

index.htmlファイルを更新した場合

続いて index.html ファイルを更新した場合です。この場合は今までのCloudFormationを扱う場合と大きくプロセスが異なります。

まず、GitHub Actions runnerからインスタンスにアクセスできるよう、Security Groupの変更を行います。今回はGitHub Actions GitHub-hosted runnerを利用しており、毎回アクセス元のサーバーのIPが更新されます。そのため、haythem/public-ip というActionを利用し、GitHub Actions runnerのIPアドレスを取得します。

次に index.html ファイルの配置ですが、SSHでアクセスができればいくつか方法はあります。今回は宛先のディレクトリに直接SCPで配置できなかったため、SCPとSSHを組み合わせて実現しています。

ファイルの配置を完了したら、ファイルが正常に配置できていること、Nginxのプロセスが起動していること、Webページに正常にアクセスできることを確認し、最後にSecurity Groupを閉じます。なおSecurity Groupを開放後にCDプロセスが途中で失敗しても、Security GroupからSSHアクセス許可設定を削除するよう、Stepの条件に always() を指定しています。

処理が正常に完了した場合は以下のようになります。またWebブラウザなどからインスタンスにアクセスしても、 index.html への更新内容が反映されていることを確認しています。

両方のファイルを更新した場合

最後に両方のファイルを更新した場合です。前述の2つの処理は、GitHub Actions上でjobを別々に定義すれば、それぞれ並行で実施されます。ただしこの場合、 index.html ファイルの配置とインスタンスの更新のタイミングがバッティングすると、処理が失敗する可能性があります。そこで今回、両方のファイルが更新された場合は先にCloudFormationの更新を行い、その後 index.html ファイルの配置を行うよう、Jobの実行順を制限しています。

CloudFormationファイル更新後、 DescribeInstanceStatus APIからインスタンスの状態をチェックし、インスタンスの初期化が完了するのを待ってから後続の処理を実行するようにしました。

処理が正常に完了した場合は以下のようになります。CloudFormationファイルの更新Jobの完了後に index.html の処理が行われていることが視覚的にも確認できました。

参考リンク