GithubActionsでEC2への自動デプロイ(APIGateway＆Lambda構成)

## 背景
Reactアプリケーションの機能追加/改修などのたびにmainブランチにpushする。
そのあと、Apacheサーバーへ接続してpullを行いApacheサーバー上でビルドをするという完全手動のデプロイを行っていた。
これを完全自動化したい。

## 目的
1. Github Actionsの概要を理解する。(YAML/料金)
2. ビルド＆デプロイを自動化できることを確認する

## イメージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/28428b4c-873a-3d15-f02c-b50e52e97648.png)

:::note
本来は
・③、④でGithubActionsからのwebhookであることの認証やIAMを用いた正当性の確認
・APIGateway間とのHTTPS化
・プライベートなんだからインターネットゲートウェイじゃなくてNATだろ...
などツッコミどころありますが、許して下さい(あくまで検証なので)
:::


## 手順
### Github Actionsの導入
Github ActionsはGitリポジトリ配下にYAMLファイルを設置して実行する。
このYAMLファイルにワークフローを定義する。
ワークフロー内部にYAML記法を用いて以下を定義する。
・どのタイミングで実行するか
・前処理(仮想サーバーなら各種インストールなど）
・ジョブ（テスト・ビルド・デプロイ）
・タイムアウト時間

まずは、該当リポジトリのGithubリポジトリに移動し、『▶Actions』をクリックする。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/551aed66-6f3d-3548-c5bb-0cd35fb2e864.png)

リポジトリの特性に応じて、github側がワークフローを提供してくれています。
が、今回は自分で書いていくので『set up a workflow yourself』をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/cb3a8a87-cb6b-b63e-720f-536f94a34133.png)

『リポジトリ名/.github/workflows』直下に『ブランチ名.yml』が作成されます。
ファイル内の記述は後で行うとして一旦ファイル作成までをコミットします。

『Commit changes』をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/b1a0d188-c334-2c6d-2256-35c50ab4d468.png)

今回は検証ですのでコミットメッセージ＆ディスクリプションは適当でよいでしょう。
また、コミットするブランチもmainに直接で良いでしょう。（本来は別ブランチ切ってプルリクはさむのが正しい気がしますが...)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/b79891f8-13c1-50a0-9f82-4ae3bb556f95.png)


### YAMLファイルにワークフローを定義する
次は、実際にymlファイルにCI/CDの内容を記載していきます。

:::note
Github Actionsの構文に関しては以下ドキュメントに纏められています。
より高度なワークフローを定義したい場合は下記サイトを見ながら実装しましょう。
https://docs.github.com/ja/actions/writing-workflows/workflow-syntax-for-github-actions#defaultsrun
:::

#### ワークフローの名前
ワークフローには名前を付けられます。
今回は、『test_cicd』と付けます。

```.yaml
name: test_cicd
```

#### トリガー条件
トリガー条件は　『on』で書きます。
例えば、mainブランチにpush/mergeされた時は以下になります。

```.yml
name: test_cicd # ワークフローの名前
on: # トリガー条件
    push:
        branches:
            - main
```

branchesの箇所は複数ブランチを記載することもできます。

:::note
トリガー条件はcronコマンドによる定期実行などもできるらしいです。
バックアップなどに便利かもしれませんね。

```
on:
  schedule:
    - cron: '30 5 * * 1,3'
:::

#### ジョブ
ジョブは「jobs」の中に複数に分けて記載します。
「jobs」の中にjob1,job2...のように分割したジョブを定義します。
これらのジョブはGithubから提供されるホストサーバー上で実行され、「runs-on」内にこのジョブを実行するOS環境を定義します。

:::note
それぞれのジョブは明記がない場合は並列実行されます。
直列実行させたい（前のジョブが成功した場合にこのジョブを実行）などは後述します。
:::

一旦、ジョブ1から書いていきます。
```.yml
name: test_cicd # ワークフローの名前
on: # トリガー条件
    push:
        branches:
            - main

jobs:
    job1:
        name: set-up-and-build-job
        runs-on: ubuntu-latest
        steps: 
            - name: Checkout code
              uses: actions/checkout@v2
        
            - name: Set up Node.js
              uses: actions/setup-node@v2
              with:
                node-version: '14'
        
            - name: Install dependencies
              run: npm install
        
            - name: Build project
              run: npm run build

            - name: upload build artifacts
              uses: actions/upload-artifact@v4
              with:
              　name: build-artifacts
                path: ./build/*
```

jobsの中にstepsを記載してその中に実行するコマンドを記述していきます。
「run」は実際にコンソール上で実行するコマンド
「uses」はGitHub Marketplaceで提供されるアクションになります。

『uses: actions/setup-node@v2』はnode.jsをインストール
『uses: softprops/action-gh-release@v1』は指定した成果物フォルダをArtifactsとしてGithub上でダウンロードできるようになります。

:::note
actions/setup-node のドキュメントは以下になります。
https://github.com/actions/setup-node

upload-artifact　のドキュメントは以下になります。
https://github.com/actions/upload-artifact?tab=readme-ov-file#usage
:::



### pushしてymlを反映
さて、ymlファイルを修正したらmainにpushしましょう。
pushすると早速、Github Actionsのワークフローが動作すると思います。

しかし、失敗してしまいました。。。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/617f120a-f152-e9ca-4fe2-cbe5ccb828f7.png)

エラーが出たワークフローをクリックすると、どのステップでエラーが発生したのかが確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/ef7d4f78-ef79-8396-fd54-4957791a8f25.png)

これを見るとnode.jsのインストール～npm installまでは成功しています。
npm run buildコマンド、つまりbuildで失敗しています。
さらに、コマンドの実行結果を確認できるのでエラー文を確認してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/3f36b25a-04b2-ba9c-d935-2fb13eebb2c5.png)

Treating warnings as errors because process.env.CI = true.
Most CI servers set it automatically.

と書いてありますね。
これは 「process.env.CI = True」になっているとビルド実行中の「警告」も「エラー」として扱われます。
その下に「ESLint」とあり、僕のコードが指摘されています。
eslintはJavaScriptの静的解析ライブラリであり、npm run buildの際にセットで実行されています。この静的解析に引っかかってしまったためにビルドが失敗しています。

回避方法は2つあります。

1. process.env.CI = False　を指定
2. 指摘された静的解析の箇所を修正

本来はビルドの前にdevelopへのマージタイミングで静的解析をするはずなので1.でもいいですが、今回は直すことにしました。

直してもう一回mainにpushします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/12a0c693-5824-dbb4-c468-19bad0b733a7.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/d5970cac-4501-dc68-ef46-3e68a6d23d76.png)

無事全てのステップが成功してワークフローのステータスも完了となりました。

Artifactsもしっかりと「１」となっており、ダウンロードもできます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/3eb40977-4b78-61ea-6684-770fb2da50d4.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/d37143c7-4183-9fde-2bcd-1316f9fb5988.png)

:::note
Artifactsはgithub側で管理されているストレージで保管されており、このストレージには容量の限界があります。
超過するとワークフローにエラーが発生したり、超過料金を取られます。
Artifactsの作成時には、数か月後に自動削除するなどのオプションも指定できるので実務で使用する際には必ず指定するようにしましょう。
:::


### ビルド成果物のサーバーへデプロイ方式の検討
さて、ワークフローでビルドを成功させることはできましたので続いてはこの成果物をサーバーへデプロイします。

デプロイには2つの方法があります。

#### 1. SFTP(SCPコマンド)を用いてビルドフォルダを転送
一つ目はSFTPでファイルを転送する方法です。
こちらの方法はFWやセキュリティグループでSSH接続を許可しなければなりませんが、GithubActionsでホストされるサーバーに付与されるIPアドレスは動的に決定されます。
そのため、AWSのIAMユーザを使用するなどして一時的にFWやAWSのセキュリティグループにGithubActions用のIPアドレスを穴あけするなどの前処理が必要です。

そのため、
・穴あけ後にエラーが発生してセキュリティグループが開けっぱなしになる懸念(例外処理をするといいがコードへのある程度の理解が必須)
・ログ集約がだるい(セキュリティグループの穴あけなどはCloudTrailに飛ぶため)

などの問題があります。

#### 2. APIGatewayを用意してCurlでHTTPリクエストを送ってLambdaを呼び出し、EC2にコマンドを流す
この方法だと
・セキュリティグループに穴あけが不要（というか既存リソースに影響を与えない）
・LambdaのログはCloudWatchLogsに流せるのでログ管理しやすい
というメリットがあります。

デメリット
・APIGatewayとLambdaを使用するのでコストが増加（大した額ではないが）
・APIGatewayとLambdaを使用することによる学習コスト
・セキュリティ上の懸念（HTTPS/IAM/エンドポイント認証の考慮が必須）
があります。
今回はLambdaの勉強にもなるので、２番で行きます。

### デプロイ用のジョブを実装
まずはビルド成果物をpushする用のリポジトリを作成しましょう。
「react_build」という名称で作成しました。

:::note
新規リポジトリ作成後は必ず、空コミット(プッシュ）を行いましょう。
empty_repositoryだとブランチがない状態と判断されるのでエラーが発生します。
:::

続いて、別リポジトリへのpushの際に認証トークンが必要となりますので作成します。

「setting」⇒「Tokens」⇒「Generate New Token」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/8bf8df7c-1e61-0dc2-1e81-3b4f0ba2b2b2.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/85baf6e8-b910-9b08-dd41-9616583256a1.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/fa8f5f97-d904-1167-1fee-976581a76b35.png)

トークンを作成するとトークンキーが生成されるので保存してください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/36003f61-9f54-fe7e-150d-2e9558445029.png)

続いて、GithubActionsのSecretに先ほどのトークンキーを登録します。
プロジェクトの「setting」⇒「secret」⇒「Action」⇒「New repository secret」⇒「トークンキーのsecret追加」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/b819cbb4-5cd8-ed8a-9de7-f55671d3faf1.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/574b6917-ab69-5680-171a-80a8dc47ce47.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/831d0ceb-6580-9f7e-3f75-3b875031dff4.png)
↑今回はTOKEN_KEYという名前で登録しました。

このSecretを用いるとハードコーディングしたくない文字列を変数で扱えるので積極的に使用しましょう。

次に、yamlファイルにjob2を追加します。
```main.yml
name: test_cicd # ワークフローの名前
on: # トリガー条件
    push:
        branches:
            - main

jobs:
    job1:
        name: set-up-and-build-job
        runs-on: ubuntu-latest
        steps: 
            - name: Checkout code
              uses: actions/checkout@v2
        
            - name: Set up Node.js
              uses: actions/setup-node@v2
              with:
                node-version: '14'
        
            - name: Install dependencies
              run: npm install
        
            - name: Build project
              run: npm run build
    
            - name: upload build artifacts #ビルド成果物アップロード
              uses: actions/upload-artifact@v4
              with:
                name: build-artifacts
                path: ./build/*
                
    job2: #新規作成
        name: push-to-reactbuild-repository
        runs-on: ubuntu-latest
        needs: job1 #job1が成功したら実行
        steps:
          - name: Checkout the target repository
            uses: actions/checkout@v4
            with:
              repository: 'masanori001/react_build'  # push先のリポジトリ
              ref: 'main'
              token: ${{ secrets.TOKEN_KEY }}  # GITHUB_TOKENを使って認証
    
          - name: Download build artifact
            uses: actions/download-artifact@v4
            with:
              name: 'build-artifacts' #ダウンロード対象のArtifacts
              path: ./build/*
    
          - name: Commit and push changes to another repository
            run: |
              git config user.name "<自分のユーザー名>"
              git add .
              git commit -m "Add build artifacts"
              git push origin main  # 別リポジトリの`main`ブランチにpush
            env:
              GITHUB_TOKEN: ${{ secrets.TOKEN_KEY }}  # GITHUB_TOKENを使って認証
```
以上のようになります。
このymlをpushしてワークフローの動作確認をします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/02387e92-dfae-68f9-5945-217014f1fea6.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/06eec95e-86e0-e741-4c16-7d97219b18ec.png)

job2の方も成功しました。
react_buildリポジトリーを確認すると実際にpushされています。（コミットメッセージも一致）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/b30bcf3a-a02c-cb8a-c9bb-3c9096de1655.png)

気持ちいいですね。

次にこのreact_buildリポジトリへのpushをトリガーにこのリポジトリをpullさせていきます。

### AWS設定
#### リソース作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/dd54d753-2992-cc65-294b-7dcaf1b077b8.png)
↑AWSのコンソールからAPIGatewayを作成します。
まだLambda関数を作成してないのでルート・ステージは適用で良いです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/9c63e5f4-aee4-ffac-2bb5-49b52de07f3a.png)
↑作成出来たらコンソールに表示されます。

次にLambdaを作成します。
ランタイムは『node.js 22』で作成します。（言語はjavascriptとなります。)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/dab5100d-0f28-de23-0417-1eb9dc5a1123.png)
↑『トリガー追加』でさっき作成した『APIGateway名』を指定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/124964df-c444-618e-f838-fd6d58e10c80.png)
↑　APIGatewayが指し示すURLにアクセスするとLambda関数が実行されます。（リクエスト方式がANYなのでGETも受け付けてくれます）

次にLambda関数を経由してEC2にアクセスしてコマンドを実行するようにします。
Lambda関数からEC2にシェルスクリプトを流す際にはssh接続ではなく、SSMを使用できます。

:::note
SSMを使用する関係上
Lambda関数に『AmazonEC2ReadOnlyAccess』と『AmazonSSMFullAccess』ポリシーをアタッチします。
EC2はSSM接続ができる状態にしておく必要があります。
EC2のSSM接続手順は他記事で解説が多くあるため、割愛します。
:::

次にLambda関数を変更します。
```index.mjs
import { SSMClient, SendCommandCommand } from "@aws-sdk/client-ssm";

// SSMClient インスタンスを作成
const ssm = new SSMClient({});

export const handler = async (event) => {
    const params = {
        DocumentName: 'AWS-RunShellScript',
        Targets: [
            {
                Key: 'instanceIds',
                Values: [<デプロイ先となるEC2のインスタンスID>],
            },
        ],
        Parameters: {
            commands: [
                'echo "Hello, world!"',
                'cd /var/www/git/react_build/',  // gitリポジトリに移動
                'sudo git pull',  // git pull を実行
            ],
        },
    };

    try {
        // SendCommandCommand を作成して送信
        const command = new SendCommandCommand(params);
        const data = await ssm.send(command);
        console.log('Command sent successfully:', data);

        const commandId = data.Command.CommandId;

        // コマンドの実行結果を取得
        const commandInvocationParams = {
            CommandId: commandId,
            InstanceId: '<デプロイ先となるEC2のインスタンスID>',
        };

        // コマンド実行結果を取得
        const invocationData = await ssm.send(new SendCommandCommand(commandInvocationParams));
        console.log('Command execution result:', invocationData);

        return {
            statusCode: 200,
            body: JSON.stringify({
                message: 'Command sent and executed successfully',
                invocationData: invocationData,
            }),
        };
    } catch (err) {
        console.error('Error:', err);
        return {
            statusCode: 500,
            body: JSON.stringify({
                error: 'Failed to execute command',
                details: err,
            }),
        };
    }
};

```
インスタンスIDは別途、置き換えてください。

:::note
node.jsのランタイムバージョンによってAWS SDKのimport記述が変わるので注意してください。
node.js 18以降は「AWS SDK for javascript v3」のみ対応しているようです。
importの際に使用するコマンドもセットでimportする必要があったり、一括でimportなどはできないようです。
「@aws-sdk/client-ssm」のドキュメントを一応添付しておきます。
https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-client-ssm/
:::

### デプロイ用リポジトリのYAMLファイル
APIGatewayのURLにCurlコマンドでPOSTすることでLambdaを発火させるだけです。
実際にはヘッダーに認証情報などを付加しますが今回はありません。

```main.yaml
name: Notify on push

on:
  push:
    branches:
      - main

jobs:
  notify-webhook:
    runs-on: ubuntu-latest
    steps: #test
      - name: Send Webhook to External Server
        run: |
          curl -X POST <APIGatewayのURL> \
          -H "Content-Type: application/json" \
          -d '{"push": "true", "branch": "main", "commit": "${{ github.sha }}"}'
```
<APIGatewayのURL>にはコンソールに表示されたURLに置き換えてください。
テスト用のpushをして着火します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801383/c7f0aa7a-7ddf-e3e2-344e-8a944f125941.png)
↑無事Curlが成功しています。