💡

手軽に稼働環境を増減できるデプロイの仕組み

2023/12/03に公開

この記事はMICIN Advent Calendar 2023の3日目の記事です。

https://adventar.org/calendars/9595

前回はdoutoriさんのクロンのデザインシステムを作っている話でした。


株式会社MICINでインフラを担当しているarapowerです。今回は直近で私が担当したWebアプリケーションのデプロイパイプラインで新しく導入した仕組みについて紹介します。

手軽に稼働環境を用意したい

アプリケーション開発において開発環境の整備は生産性を高めるために重要な要素です。しかしインフラについてはエコシステムがまだまだ成熟していないのが現状です。例えば以下のような用途で新しい稼働環境を用意するとなった場合、どれほどの手間がかかるでしょうか。

  • 新機能の動作確認
  • お客様用のデモ
  • 社内用のデモ
  • インフラ構成変更の検証
  • 品質保証(QA: Quality Assurance)工程
  • アプリケーションの旧バージョンの動作確認
  • 負荷試験
  • 脆弱性試験

組織によっては、自由な用途で使える環境をいくつか用意し、それらの環境を使い回すことで対応しているでしょう。では同時期に複数の用途で利用したい場合はどうでしょうか。環境を共有できない用途もあります。そのような場合は新しい環境を用意すると思いますが、構築は手順書があっても手間がかかるものです。

私が担当するプロダクトでは上で挙げたような用途の期間が重複すると予想されました。そこで新しい試みとして、稼働環境を柔軟に増減できる仕組みを構築することにしました。

対象アプリケーションの前提

参考までに、本記事で紹介する内容を適用したアプリケーション開発の前提条件について簡単に記載しておきます。ただし本記事の内容は使用するツール類に依存しません。

  • プロダクトを新規で構築開始するタイミング
  • 複数の稼働環境を一定期間維持する必要がある
  • 開発チームは事業部のアプリケーション担当者が複数名、インフラ担当部署メンバー(筆者)が分かれている
  • アプリケーションはWebアプリケーション
  • フロントエンドとバックエンドはRedwoodJS、TypeScriptで実装
  • インフラはAWS上に構築し、フロントエンドはS3、バックエンドはECSにデプロイする
  • インフラ構築ツールは主にTerraform、アプリケーションのデプロイにecspressoを利用

稼働環境を用意するのに苦労する理由

稼働環境を新しく用意するのに苦労する理由は複数あると思います。以下に特に影響が大きいと考えるものを挙げます。

世の中のベストプラクティス

世の中には様々なベストプラクティスが存在しますが、Terraformに関してはGoogle Cloudの以下のページが有名だと思います。

上記ページの「環境固有のサブディレクトリにアプリケーションを分割する」にはモジュールを利用し、environmentsディレクトリ配下にdevqaprodのディレクトリを作成することで各環境を構築することが想定されています。

このディレクトリ構成を採用する場合、環境を新規構築する場合はディレクトリの数を増やすことになります。また、例えばURLを環境ごとに分けるためにはlocalstfvarsファイルなどを書き換える必要があります。単純なアプリケーションであれば書き換える値も少なく済むかもしれませんが、開発が進めば負担が増えていきます。

開発環境を柔軟に用意する手段は他にもいくつかあります。例えばKubernetes上に開発用コンテナをデプロイする方法や、Docker Compose等でローカルPCに稼働環境を再現する方法などです。しかしいずれもあくまで開発用の環境を増やすことを目的としており、上に挙げた用途によっては不十分です。

インフラとアプリケーション間のデータ参照

アプリケーションが稼働するためにはインフラの情報が必要になります。そのためアプリケーションのビルド時にインフラの情報を与えなければいけません。これについては多くの場合、事前に環境ごとの定義ファイルをセットしておくことになります。

定義ファイルは実装方法によって様々なものがあります。例えばNode.jsであれば.envファイルですし、ECSにデプロイする場合は対象のService名等を変える必要があります。これらの情報の書き換えを環境構築のたびに行う必要があります。

解決の指針

以上のように、インフラを用意するには手間がかかります。その主な理由は以下の二点だと考えました。

  1. 構築する環境固有の値を手動で設定しなければならない
  2. インフラの情報をアプリケーションに与えなければならない

そこで環境ごとの固有値の設定やインフラとアプリケーションのデータ連携をGitHub Actionsで自動化させることにしました。この仕組みを導入することでGitのブランチ操作で新しい開発環境を構築できるようになりました。

デプロイパイプライン全体の流れ

デプロイ全体の流れは以下の図のようになります。

まずアプリケーションのデプロイはアプリケーションリポジトリでは行いません。代わりにデプロイリポジトリを用意し、アプリケーションのデプロイはデプロイリポジトリのGitHub Actionsで行うことにしました。

リポジトリを3つに分けた理由

例えばインフラとアプリケーションを同じリポジトリで管理するモノレポ構成の場合、同一リポジトリ内ですべてが完結するメリットがあります。
しかし現状のMICINではアプリケーションとインフラは担当部署が分かれています。同一リポジトリで管理するとPRやコミットログはアプリケーションとインフラのものが混ざった状態になります。今のチーム構成のままで進めるとコミュニケーションコストが課題になると予想されました。そのため素直にコンウェイの法則に準じてアプリケーションとインフラを別リポジトリにしました。
別解としてアプリケーションとインフラのどちらかのリポジトリにデータを集約してデプロイする方向性も考えました。ですが以下のような理由からアプリケーション・インフラ・デプロイの3つのリポジトリを用意する形式にしました。

  • アプリケーションとインフラでそれぞれの環境への反映タイミングをコントロールしやすい
  • デプロイリポジトリのコミットログは基本的にデプロイ単位となるため、環境の状態を把握しやすい
  • デプロイリポジトリのコミットログからデプロイがアプリケーション起点かインフラ起点かわかる
  • アプリケーションのみ、インフラのみの変更で余計なGitHub Actionsのコストが発生しない
  • アプリケーションとインフラのデプロイが分かれていることでインフラのみの検証をCI経由で可能

アプリケーションリポジトリの処理

アプリケーションリポジトリのGitHub Actionsはアプリケーションリポジトリの中身をそのままデプロイリポジトリにgit pushするだけです。

インフラリポジトリの処理

アプリケーションのデプロイにはインフラの情報が必要です。そこでインフラリポジトリでterraform applyを実施後、インフラのデータをデプロイリポジトリにgit pushします。

デプロイリポジトリの処理

インフラリポジトリとアプリケーションリポジトリの処理が正常に終わると、デプロイリポジトリにはインフラとアプリケーション両方のデータが揃いますので、アプリケーションのデプロイができます。

Terraformのディレクトリ構成

リソースを共有すると思わぬタイミングで複数環境に影響が出てしまいます。それを回避するため、AWSのリソースは可能な限り環境ごとに用意したいと考えました。一方でコスト観点ではリソース数を増やすことは避けたいです。そのため環境ごとに作成するリソースと、以下のような稼働環境の分類ごとに共有するリソースを分けました。

  • 本番環境
  • ステージング環境
  • 開発環境

それらの考えを反映させ、Terraformのルートモジュールは以下のように分けました。

.
├── general_dev
├── general_prod
├── general_stg
└── common

general_*ディレクトリは稼働環境の分類ごとに共有するリソースを管理します。
commonディレクトリはすべての環境で使用するTerraformのルートモジュールです。commonディレクトリで管理するリソースは重複を避けるためリソース名等を環境ごとに変えなければなりません。そのため固有値は以下のようにGitのブランチ名から取得し、環境変数TF_VAR_*を用いる形式にしました。

サンプルコード: terraform_apply.yaml
      - name: "Set base environment valiables"
        env:
          BASE_BRANCH: ${{ github.event.pull_request.base.ref }}
        run: |
          TF_VAR_DEPLOY_ENV=${BASE_BRANCH#deploy/}
          TF_VAR_COMMIT_ID=$(git rev-parse --short=7 HEAD)

          cat <<-EOF | tee -a ./stdout.log >> $GITHUB_ENV
          	TF_VAR_DEPLOY_ENV=${TF_VAR_DEPLOY_ENV}
          	TF_VAR_COMMIT_ID=${TF_VAR_COMMIT_ID}
          EOF

      - name: "Apply Terraform"
        run: terraform apply -auto-approve

インフラの情報をどうやってアプリケーションに渡すか

あらかじめアプリケーションで必要な情報をTerraformのoutputとして定義しておき、GitHub Actions上でterraform outputコマンドを実行して値を出力します。出力した値をファイルに保存し、そのファイルもまとめてデプロイリポジトリにgit pushします。

サンプルコード: output.tf
output "api_domain_name" {
  value = "example-api-domain.com"
}
サンプルコード: terraform_push.yaml
name: "Terraform Output to File and Push"

on:
  pull_request:
    branches:
      - 'deploy/**'
    types: [opened, synchronize, reopened ,closed]

jobs:
  terraform-output:
    runs-on: ubuntu-latest

    steps:
      - name: "Checkout Repository"
        uses: actions/checkout@v3

      - name: "Set base environment valiables"
        run: |

      # ~~ snip ~~

      - name: "Set up Terraform"
        uses: hashicorp/setup-terraform@v1
        with:
          terraform_version: latest

      - name: "Initialize Terraform"
        run: terraform init

      - name: "Get and Save Terraform Output"
        run: |
          # Run Terraform output and save it to a file
          app_domain_name="$(terraform output -no-color app_domain_name)"
          api_domain_name="$(terraform output -no-color api_domain_name)"

          cat <<-EOF > output_values.txt
          	APP_DOMAIN_NAME=${app_domain_name}
          	API_DOMAIN_NAME=${api_domain_name}
          EOF

      - name: "Setup git config"
        env:
          GITHUB_DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} # デプロイリポジトリのデプロイキー
        run: |
          echo "$GITHUB_DEPLOY_KEY" > ~/deploy_key.pem
          chmod 600 ~/deploy_key.pem
          # Following E-mail is dummy.
          git config --global user.email "infra_ci@example.com"
          git config --global user.name "infra_ci"

      - name: "Push files"
        env:
          # - `GIT_SSH_COMMAND`: GitがSSH接続を行う際に使用するコマンドを指定する環境変数
          # - `-i ~/deploy_key.pem`: SSH接続に使用する秘密鍵のパスを指定します。
          # - `-o StrictHostKeyChecking=no`: ホストキーのチェックを無効にします。これにより、初めて接続するホストに対しても自動的に接続が行われます。
          # - `-F /dev/null`: SSHの設定ファイルを/dev/nullに設定します。これにより、SSHの設定ファイルを使用しないことを明示的に指定します。
          GIT_SSH_COMMAND: ssh -i ~/deploy_key.pem -o StrictHostKeyChecking=no -F /dev/null
        run: |
          git clone "git@github.com:example-com/${deploy_repository}.git"
          cd "./${deploy_repository}"
          # Push先のブランチに切り替え
          if git branch -lr | grep -q "^ *origin/$BRANCH_NAME$"; then
            # 現在のブランチが存在する場合
            echo "Branch: ${BRANCH_NAME} is exist."
            git checkout -b "${BRANCH_NAME}" "origin/${BRANCH_NAME}"
          else
            # 現在のブランチが存在しない場合
            echo "Branch: ${BRANCH_NAME} is not exist."
            git checkout -b "${BRANCH_NAME}"
          fi

          # Pushするディレクトリをコピーする
          [ ! -d "./terraform" ] && mkdir "./terraform"
          rm -rf "./terraform/${{ env.TF_VAR_env_category }}" && cp -a "../terraform/${{ env.TF_VAR_env_category }}" "./terraform/${{ env.TF_VAR_env_category }}"

          # Push先と差分がある場合のみpushする
          git add .
          if ! git diff --cached --quiet; then
            git commit -m "Updated by ${BRANCH_NAME}:${COMMIT_ID}" &&
            git push -f --set-upstream origin "${BRANCH_NAME}" &&
            echo "Git repository: ${deploy_repository}"
            echo "Git branch: ${BRANCH_NAME}"
          fi

デプロイリポジトリでそのファイルを参照して利用します。

サンプルコード: import.yaml
      - name: "Import valiables"
        run: |
          . ./terraform/common/output_values.txt

これでインフラの情報をアプリケーションのデプロイ時に利用できます。

効果

今回のシステムを導入した結果、主に以下のような効果がありました。

  • 新規の環境構築がGitブランチの操作で完結するため、手軽に実施できる
  • アプリケーションの検証を固有の稼働環境で手軽に試せる
  • インフラの検証作業を他の環境への影響を気にせず実施できる
  • 稼働環境を必要なだけ構築・削除できるため、インフラコストを適切に抑制できる
  • 稼働環境ごとの差分はデプロイリポジトリのブランチ間の差分で確認可能
  • 検証などで保存されたデータによる不具合影響が他の稼働環境に影響しない

課題

上記のように多くの成果の出た仕組みですが、改善すべき点もいくつかありました。

環境構築用のPR作成・削除が煩雑

今回の仕組みはGitHub Actionsが中心となって動作します。GitHub ActionsのトリガーはPRとしました。すると1つの環境を構築するのに必要なブランチがインフラリポジトリとアプリケーションのそれぞれで2つ必要なため、合計4つのブランチ作成と2回のPR作成をすることになります。手作業の構築より早いですが煩雑であることは否めません。
同様に環境削除の処理もPRとブランチを削除しなければなりません。デプロイブランチも対象になるためさらに数が増えます。

そこでこれらの作業を一括で行うシェルスクリプトを作成しました。

シェルスクリプトを使用して環境の構築と削除を行うことで削減される時間は、多く見積もっても1回ごとに数分です。ですが作業時間の削減よりも以下の点で効果がありました。

  1. 正確な作業の保証
    • AWSリソースなどの制約から各環境の識別子には文字種や文字列長に制約があるなど、手作業だとミスが発生しやすいポイントをソフトウェアでカバーする
  2. ミスを気にせず使えることで新規構築・削除の精神的なハードルを下げる

これによりシェルスクリプトを実行すると30分ほどで環境構築・削除が完了するようになりました。

初期データの登録が煩雑

新しく稼働環境を構築すると、データは当然ですが初期状態です。検証に使うユーザ等を登録しなければなりません。これに対しては必要なデータを登録したDBのダンプファイルを使って復元したり、初期データの登録用スクリプトを用意する必要があります。

稼働環境の一覧が把握しづらい

当初は稼働環境の一覧をどのように管理すべきか考えが固まっていませんでした。そのためGitHubのPRページ(https://github.com/*/*/pulls)で代用することにしました。必要であれば各PRの概要欄で使用用途を記載するなどの汎用性を考えてのことです。
ですが運用を始めた現在はPRで稼働環境の一覧を把握するよりも、定期的に稼働環境の一覧をSlackに通知するのが良いのではないかと考えています。稼働環境の一覧を把握したい主な理由はインフラコストの抑制であり、Slack通知で十分ではないかと思われたためです。
また、稼働環境の把握をPRで行わないのであれば、GitHub Actionsのトリガーをリモートリポジトリへのpushにしても良いのではないかと考えています。

インフラ担当チーム内の差

これはこの記事で紹介した仕組みの問題ではなく、何かを新しく導入するにあたって共通する課題だと思います。

この半年間、私の担当した部署では新規アプリケーションの立ち上げが連続しました。そのため今回の仕組みはすでに3つのアプリケーションで導入されています。それらのインフラ構築は人的リソースやプロジェクト期限の都合から、ほとんど私一人が担当しました。そして構築作業にかかりっきりだったため、仕組みについてチーム内での情報共有が不十分となりました。結果的にチーム内で詳細を把握しているのは私しかいないのが現状です。

そのような状況を改善するために種々のドキュメントを作成するのは当然として、ペアプログラミングのような形式での知識の共有も検討しています。この記事もその一環です。

チーム外メンバーの意識

今回の仕組みを導入するまで、デモや検証はすべてステージング環境で行うのが全社的にも当たり前でした。そのため仕組みを導入しても「開発環境はあくまでアプリケーション開発者が使うもの。デモ等にはステージング環境を使う」という認識が続いています。

ステージング環境は可能な限り本番同等の状態を維持し、本番リリース前の手順の確認や致命的な不整合が起こらないことを確認するのに留めるのが理想だと考えます。このような認識はインフラ担当者だけでなく、開発メンバーやビジネスサイドも含め、アプリケーション開発に携わるメンバー全体に浸透するのが望ましいです。そうでなければ仕組みを有効活用できず、防げたはずのインシデント対応に駆り出されることになるかもしれません。

これまでにエンジニア以外に向けたドキュメントの作成や仕組みの意義を説明するなど、機会があるごとに言及してきましたが、この先もしばらくそういった活動が必要だと感じています。


MICINではメンバーを大募集しています。
「とりあえず話を聞いてみたい」でも大歓迎ですので、お気軽にご応募ください!

https://recruit.micin.jp/

株式会社MICIN

Discussion