GitHub Actions を使って Next.js × AWS EC2 を自動デプロイした話


※ この記事は K-Ruby #25 のLT資料として書かれた記事です。

こんにちは!

先日、GMOペパボの東証一部上場が決まったことで「東証一部上場の Web 系企業に未経験転職した29歳」になって怪しさに磨きがかかりましたよしこ @k2_yoshikouki です。そろそろエンジニアになれる石売ります。

最近 yoshikouki.net という個人サイトを作っている最中で、勉強も兼ねて以下の要件で作っています

  • Infrastructure as Code で環境構築
    • Chef(ホスト内の実装について定義)
    • Terraform(各ホストの関係について定義)
  • AWS を使用
    • EC2 に nginx (リバースプロキシ)を載せる
    • ECS, EKSなどのコンテナサービスは使わない
  • フレームワークは Next.js (React) を使用
    • バックエンドは node.js (しばらくは Next.js のルーティングを使用したベタ書きかなあと考えている)
    • CI/CD は GitHub Actions を利用

苦節一週間の結果、GitHub Actions で自動デプロイ(Continuous Delivery)を実装できたのでその内容を紹介いたします。

Ruby? 知らない子ですね...

ゴール

GitHub Actions を使って AWS EC2 への自動デプロイを導入します

  • テストがないのでCIの優先順位が低い

課題

  1. AWS へのデプロイでいい感じにしてくれるサービスはECS, EKS関係が潮流で情報が豊富
    (しかし、今回のコンテナ不使用という仕様には合わない)

  2. 候補のデプロイサービスは AWS CodeDeploy や Capistrano がある

    • CodeDeploy はAWS専用サービスかつ情報が少ない
    • Capistrano は Ruby環境が必要なので、node.js な今回の仕様でスマートじゃない
  3. コードの移設は rsync なりscp なりでできるが、プロセスの起動 next start がややこしい

    • 色々試行しているとき、ワークフローで ssh ~~~ "npm run start" を実行してCI/CDが一生終わらないバグを埋め込んだ
      (プロセスが待機状態になるため)
    • nohup npm run start & などで裏プロセスとして動かせたが、2度目以降のデプロイではプロセスが生きているのでエラーになる
      -> 前回のプロセスを kill しないと行けない

対応策

  • コード移設には rsync コマンドで対応
  • Next.js のプロセス管理に PM2 を採用

GitHub Actions ワークフロー

./.github/workflows/main.yml

name: Deploy to yoshiko.net

on:
  push:

env:
  ssh_key_path: ~/.ssh/yoshikouki.net.pem
  app_path: /var/www/

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Cache multiple paths
        uses: actions/cache@v2
        with:
          path: |
            ~/.npm
            **/node_modules
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-
      - uses: actions/setup-node@v1
        with:
          node-version: '12.x'
      - run: npm install
      - run: npm build

  deploy:
    if: github.ref == 'refs/heads/main'
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Generate SSH key
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ${{ env.ssh_key_path }}
          chmod 400 ${{ env.ssh_key_path }}
          eval "$(ssh-agent -s)"
          ssh-add ${{ env.ssh_key_path }}
      - name: Deploy with "rsync" command
        run: |
          rsync -avL --progress --exclude ".git/" \
          -e "ssh -i ${{ env.ssh_key_path }} -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no" \
          ./ \
          ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ env.app_path }}
      - name: Build and Start Next.js
        run: |
          ssh -i ${{ env.ssh_key_path }} -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no \
          ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \
          'cd ${{ env.app_path }} \
          && sudo npm install \
          && sudo npm install pm2 -g\
          && sudo npm run build \
          && pm2 startOrReload app.json --env production'

PM2 のプロセス設定ファイル

./app.json

{
  "name" : "app",
  "script" : "./node_modules/next/dist/bin/next",
  "env" : {
    "NODE_ENV" : "development"
  },
  "env_production" : {
    "NODE_ENV" : "production"
  }
}

PM2 is 何

まとめ

GitHub Actions の便利アクションやコンテナ技術を使用することで、実際の処理やデプロイ先の環境を意識することなく簡単にCI/CDできることができますが、今回のこの実装で「便利なラッパーが実際どういう処理をしているのか」ということが学べました。

Capistrano のデフォルト挙動では、アップロードしたディレクトリへのシンボリックリンクを公開パスに配置するという頭良すぎる工夫を知ることもできて(なのでロールバックする場合は以前アップロードしたディレクトリにシンボリックリンクを張り直すだけ)、「便利なライブラリをただ利用するだけでなく、その仕組がどうなっているのかまで理解すると応用できる」というエンジニアとしての重要な気付きも得ることができたのでした。

P.S.
Ruby on Rails で最速CDする記事も上げましたので、そちらもよろしくお願いします。本来このLTでやろうと思っていた内容でした(やれよ)