GitHub ActionsでEnvironmentsを活用してよりセキュアなCI/CD構築


こんにちは!
LIFULLエンジニアの吉永です。

本日はGitHub ActionsでEnvironmentsを活用してよりセキュアなCI/CD環境を構築する手順について紹介したいと思います。

GitHub Actions Environmentsとは?

公式ドキュメント

  • GitHub Actionsを実行する際のプロテクションルールを環境毎に構築することができる。
    • GitHub Actionsを実行する前に必ず承認者の承認を必要とする、実行するまで時間にウェイト時間などが設定可能。
  • GitHub Actionsを実行する際のブランチに制限をかけられる。
    • developブランチ、featureブランチからのみ実行可能など。
  • 環境毎に環境変数を用意することができる。
    • 通常のリポジトリSecretsだとテスト環境用のキー、本番環境用のキーで名前を分けて変数を作り、ワークフローYAMLの中でも環境毎のSecrets変数を読み込んでいるので、ほとんど内容は同じワークフローなのに共通化できなかったりした。
    • 環境毎に用意できるので、上記課題が解決できる。
    • ただし、環境毎に共通で利用する変数もリポジトリSecretsなら一つで良かったが、Environmentsだと環境毎に個別に用意する必要があるので、一部のユースケースでは冗長性が発生することもある。

GitHub Actions Environmentsを利用するまでの設定の流れ

  1. 対象となるリポジトリ管理画面で[Settings]→[Environments]を選択して、[New environment]を押す。
  2. 作成する環境名を入力して、[Configure environment]を押す。名前は任意だが、この後設定するワークフローでは環境名とブランチ名と揃えてある前提で動的に切り替える予定なので、開発用ブランチ、リリース用ブランチの名前で環境を作成することをお勧めする。
  3. Required reviewersにチェックを入れ、承認者に自分のGitHubアカウントの名前を入力し、[Save protection rules]を押す。
  4. Environment secretsの[Add Secret]を押して、CI/CDで利用する外部システム連携用のAPIキーなどの変数を作成する。
  5. リポジトリ内の.github/workflows/配下に任意に名称のYAMLファイルを作成する。
  6. ワークフロー内で、jobs配下でenvironmentのnameを宣言して、ワークフロー内で利用する環境名をバリューに設定する。

というのがざっとした利用までの流れになります。

GitHub Actions Environmentsに対応したワークフローYAMLを作る

次に、HerokuにWebアプリケーションをデプロイする為のワークフローをEnvironmentsを利用するようにして、テスト環境本番環境でコードが重複しているのに別れてしまっていた課題を解決できた事例を紹介します。

下記がEnvironments適用前のデプロイ用のワークフローです。

name: deploy

on:
  pull_request:
    branches:
      - master
      - develop
    types: [closed]

jobs:
  production:
    if: github.ref == 'master' && github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
        with:
          ref: ${{github.ref}}
      - name: Unshallow
        run: git fetch --prune --unshallow --tags
      - name: Heroku Deploy
        uses: akhileshns/[email protected]
        with:
          heroku_api_key: ${{secrets.HEROKU_API_KEY}}
          heroku_app_name: ${{secrets.HEROKU_APP_NAME}}
          heroku_email: ${{secrets.HEROKU_EMAIL}}
      - name: Get Next Version TAG
        id: get-next-version-tag
        run: |
          MajorVersion=`git describe --tags $(git rev-list --tags --max-count=1) | awk '{split($0, version, "."); print version[1]}'`
          MinorVersion=`git describe --tags $(git rev-list --tags --max-count=1) | awk '{split($0, version, "."); print version[2]}'`
          PatchVersion=`git describe --tags $(git rev-list --tags --max-count=1) | awk '{split($0, version, "."); print version[3]}'`
          CurrentVersion=`git describe --tags $(git rev-list --tags --max-count=1)`
          echo "::set-output name=tag::$(git diff --name-status $CurrentVersion origin/master -w --ignore-blank-lines | awk -v v1=$MajorVersion -v v2=$MinorVersion -v v3=$PatchVersion 'BEGIN{b1=0;b2=0;}{if($1=="A" || $1=="M"){if($2~/pages\//){b1++}else if($2~/components\//){b2++}}}END{if(b1!=0){v1++;v2=0;v3=0;}else if(b2!=0){v2++;v3=0;}else{v3++}printf "%s.%s.%s\n",v1,v2,v3}')"
      - name: Create Release
        id: create_release
        uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{ github.token }}
        with:
          tag_name: ${{ steps.get-next-version-tag.outputs.tag }}
          release_name: Release ${{ steps.get-next-version-tag.outputs.tag }}
          draft: false
          prerelease: false
  develop:
    if: github.ref == 'develop' && github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
        with:
          ref: ${{github.ref}}
      - name: Unshallow
        run: git fetch --prune --unshallow --tags
      - name: Heroku Deploy
        uses: akhileshns/[email protected]
        with:
          heroku_api_key: ${{secrets.HEROKU_API_KEY_DEV}}
          heroku_app_name: ${{secrets.HEROKU_APP_NAME_DEV}}
          heroku_email: ${{secrets.HEROKU_EMAIL_DEV}}

上記コードでは、productionとdevelopでHerokuデプロイまでのフローは全く同じなのですが、リポジトリSecretsを環境別で名前を分けて定義していたので、同じコードを重複して書いてありました。
※きっとこの状態でもうまく環境変数を分けて使う方法はあるんでしょうけど...

上記コードをEnvironmentsに対応させて、重複コードを除去した形が以下になります。

name: deploy

on:
  pull_request:
    branches:
      - master
      - develop
    types: [closed]

jobs:
  deploy:
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    environment:
      name: ${{ github.ref }}
    steps:
      - name: Checkout
        uses: actions/checkout@v2
        with:
          ref: ${{ github.ref }}
      - name: Unshallow
        run: git fetch --prune --unshallow --tags
      - name: Heroku Deploy
        uses: akhileshns/[email protected]
        with:
          heroku_api_key: ${{ secrets.HEROKU_API_KEY }}
          heroku_app_name: ${{ secrets.HEROKU_APP_NAME }}
          heroku_email: ${{ secrets.HEROKU_EMAIL }}
      - name: Get Next Version TAG
        if: github.ref == 'master'
        id: get-next-version-tag
        run: |
          MajorVersion=`git describe --tags $(git rev-list --tags --max-count=1) | awk '{split($0, version, "."); print version[1]}'`
          MinorVersion=`git describe --tags $(git rev-list --tags --max-count=1) | awk '{split($0, version, "."); print version[2]}'`
          PatchVersion=`git describe --tags $(git rev-list --tags --max-count=1) | awk '{split($0, version, "."); print version[3]}'`
          CurrentVersion=`git describe --tags $(git rev-list --tags --max-count=1)`
          echo "::set-output name=tag::$(git diff --name-status $CurrentVersion origin/master -w --ignore-blank-lines | awk -v v1=$MajorVersion -v v2=$MinorVersion -v v3=$PatchVersion 'BEGIN{b1=0;b2=0;}{if($1=="A" || $1=="M"){if($2~/pages\//){b1++}else if($2~/components\//){b2++}}}END{if(b1!=0){v1++;v2=0;v3=0;}else if(b2!=0){v2++;v3=0;}else{v3++}printf "%s.%s.%s\n",v1,v2,v3}')"
      - name: Create Release
        if: github.ref == 'master'
        id: create_release
        uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{ github.token }}
        with:
          tag_name: ${{ steps.get-next-version-tag.outputs.tag }}
          release_name: Release ${{ steps.get-next-version-tag.outputs.tag }}
          draft: false
          prerelease: false

ポイントとしては2点ありまして、

    runs-on: ubuntu-latest
    environment:
      name: ${{ github.ref }}

上記のenvironmentのnameでワークフロー内で利用する環境名を指定します。

もう一点は

      - name: Create Release
        if: github.ref == 'master'
        id: create_release
        uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{ github.token }}
        with:
          tag_name: ${{ steps.get-next-version-tag.outputs.tag }}
          release_name: Release ${{ steps.get-next-version-tag.outputs.tag }}
          draft: false
          prerelease: false

上記はmasterブランチへのマージ時(すなわちリリース時)に自動でタグ付けを行う為のコードなのですが、このコードはdevelopへのマージ時には動作させたくないので、ifで現在のブランチ名を取得してmasterブランチの時だけ実行するようにしています。

PRマージ後のGitHub Actionsを実行するまでの操作手順

上記設定後にPRを作成して、レビュー完了後にマージをすると、下記キャプチャのような表示がされます。

キャプチャ内の[Show environments]を押して、表示されるリンク先かもしくはrequested a deploymentを押すとActions画面に遷移します。

Actions画面で上記キャプチャの赤枠内をクリックします。

Reviewr needed fromの上のチェックボックスにチェックを入れて、[Approve and deploy]を押します。

これでGitHub Actionsのワークフローの実行が開始されます。
このようにすることで、よりセキュアなCI/CD環境をGitHub Actionsで構築することが可能です。

もちろん、CIとして、例えばpushされる度にlinterの実行やユニットテストの実行を行って、開発者へ結果をFBするような使い方の場合であればこのような承認手順を踏まなくても良いと思います。

ただし、CIだけでなくCDも行う場合にはセンシティブな情報をGitHubのリポジトリSecretsに登録していることが多いと思うので、この場合には注意が必要です。
詳細は攻撃などの参考にされてしまうかもしれないので控えますが、とあるエクスプロイトコードを実行することで、リポジトリSecretsは結構簡単に盗むことが可能なようですので、GitHub ActionsでCDも行っている方はなるべくEnvironmentsを活用して、承認者の承認なくCI/CDを実行することができないような環境を構築しておいた方が良いと思います。

余談

若干謎が残るのが、${{ github.ref }}公式ドキュメントだとrefs/heads/<branch_name>の形式の文字が入ってくるので、上記コードだとrefs/heads/developとかになるはずなんですが、何故か上記コードだとgithub.refはブランチ名だけになっています。
PRマージする時のイベントだとそういう値になるってことなんですかね?
私はずっとGitHub Actionsのワークフローを上記のような形式で実装していたのでgithub.refはブランチ名だけが入っていると思いこんでいたのですが、同僚からrefs/heads/<branch_name>になってない?と聞かれ公式ドキュメント見てみたら確かにそうなっている...でもechoするとブランチ名だけになってる...
というモヤモヤが残りますが、本日はこれくらいにしたいと思います。

最後までご覧いただきありがとうございました。

それではまた次の記事でお会いしましょう!