【GitHubActions】利用 GitHub Actions 来优雅地执行 terraform【terraform】

简要概述

在GitHub Actions中运行terraform,能自动化地做得很好。
“做得好”的定义是在尽量不烦扰工程师的情况下,实现各种自动化。

目标

自動化萬歲。

此外,这次的目的是将从实际业务中获得的专业知识进行输出,因此内容并非个人相关,希望团队可以共同利用。

预先调查

GitHub Actions
GitHub Actionsというものを利用すると、Git上で色々なことが出来るらしい。
terraformを実行(planやapply)をすることも出来るので、ローカルに実行環境を持たなくて良くなる、素晴らしい。

公式のGitHubホストランナーとして下記が提供されており(実際にはAzure内でインスタンスが起動する)、terraform最新版(多分)他様々なツールがプレインストール済みだが、それ以外のツールを使用したい場合、追加インストールしてカスタマイズすることが出来る。

About GitHub-hosted runners

Terraform用ワークフロー
上記のランナーに対してワークフロー(ジョブやスクリプトみたいなもの)を定義し、実際に実行される内容を設定する。
ワークフローについて

Terraform用Action
hashicorpから「setup-terraform」というActionが提供されており、これを使用することで特定のバージョンのterraformを使ったり、terraformコマンドを簡単に実行したり出来るようになる。

setup-terraform

このActionで「terraformをどこで実行するか?」の指定が可能になっており、「Terraform Cloud」か「GitHubホストランナー」かを選択出来る。
ただ、どちらの場合にせよ「実行環境の起動」があるので、実行時間は長くなると思われる(とはいえ、以前Terraform Cloudを試用した場合もそこまで極端に遅くはなかった印象)
この実行時間やインスタンスのスペックが許容出来ない場合は、自前で用意したインスタンスに「ランナーアプリケーション」をインストールした「セルフホストランナー」を選択することになる。

About self-hosted runners

実際の GitHub Actions の動き(デモ)は下記を参照(音声出るため注意)
GitHub & HashiStackで始めるクラウドインフラ自動化入門(38:01 – GitHub Actions の紹介, GitHub で Terraform 運用するメリット)

今回は、導入の手軽さ・運用管理コストなどの面から、GitHubホストランナーを採用する。

クラウドサービスの認証について
クラウドサービス側にリソースを作成するにあたっての認証方式として考えられるのが主に下記2つ。
①サービスアカウントキー認証
②OIDC認証
お手軽なのは①だが、セキュリティ面を考慮すると②が推奨。

1. 创建工作流程

在使用GitHub Actions将Terraform应用于各云服务时,需要进行以下三项构建和设置。

1. GitHub:创建仓库。
2. 云服务:在通过步骤1创建的仓库中进行OIDC身份验证的设置。
3. GitHub:在GitHub Actions中进行包含OIDC身份验证的工作流程设置。

本次,假设①②已经完成,将重点放在工作流程上。

1.1. 工作流全文

在GitHub存储库的根目录下创建以下内容。

.github\workflows\terraform.yml
name: ‘Terraform’on:
push:
branches:
– master
– ‘feature/**’
pull_request:
branches:
– master

permissions: write-all

env:
terraform_version: ‘1.x.y’ # 使用的 Terraform 版本
project_number: ‘hoge’ # 用於 OIDC 認證的 workload_identity_pool 所在的項目
workload_identity_pool: ‘hoge’ # 用於 OIDC 認證的 workload_identity_pool
workload_identity_provider: ‘hoge’ # 用於 OIDC 認證的 workload_identity_provider
service_account: ‘hoge’ # 用於 OIDC 認證的 SA
reviewer: ‘hoge’ # 設置為 Pull Request 的審查者
GITHUB_TOKEN: ${{ secrets.FOR_RUNNER_NPRD }} # GitHub 個人訪問權杖
jira_org: ‘hoge’

jobs:
# 獲取 Terraform 執行目錄
set-matrix:
name: ‘Set Matrix’

runs-on: ubuntu-latest

env:
TF_ROOT_DIR: .

steps:
# 將存儲庫檢查到 GitHub Actions運行器
– name: ‘Checkout’
id: ‘checkout’
uses: actions/checkout@v3
#with:
# fetch-depth: 0

– name: Find tfstate directories
id: find-tfstate-dirs
run: |
dirs=$(find ${TF_ROOT_DIR} -type f -name ‘*.tf’ -exec dirname {} \; | grep -v ‘modules\|\.terraform’ | uniq | jq -R -s -c ‘split(“\n”)[:-1]’)
dirs=`echo ${dirs//\.\//}` # 刪除目錄路徑中的「./」
echo $dirs # 用於驗證
echo “::set-output name=dirs::${dirs}”

outputs:
dirs: ${{ steps.find-tfstate-dirs.outputs.dirs }}

# 執行 Terraform
terraform:
needs: set-matrix

name: ‘Terraform’

permissions: write-all
# permissions:
# id-token: write # OIDC 認證所需
# contents: read # actions/checkout 所需

runs-on: ubuntu-latest

# 將 Terraform 執行目錄存儲於 matrix 變量中
strategy:
fail-fast: false
max-parallel: 5
matrix:
TF_DIR: ${{ fromJson(needs.set-matrix.outputs.dirs) }}

env:
TF_DIR: ${{ matrix.TF_DIR }}

defaults:
run:
shell: bash
working-directory: ${{ env.TF_DIR }}

steps:
# 將存儲庫檢查到 GitHub Actions運行器
– name: ‘Checkout’
id: ‘checkout’
uses: actions/checkout@v3
with:
fetch-depth: 2

# 檢查更新記錄並進行差異檢查
– name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v29.0.0
with:
files: |
${{ env.TF_DIR }}/*.tf

# OIDC 認證
– name: ‘Authenticate to Google Cloud’
id: ‘auth’
if: steps.changed-files.outputs.any_modified == ‘true’
uses: ‘google-github-actions/auth@v0’
with:
token_format: ‘access_token’
workload_identity_provider: ‘projects/${{ env.project_number }}/locations/global/workloadIdentityPools/${{ env.workload_identity_pool }}/providers/${{ env.workload_identity_provider }}’
service_account: ${{ env.service_account }}

# 安裝最新版的 Terraform CLI,並使用 Terraform Cloud 用戶 API 令牌配置 Terraform CLI 配置文件
– name: Setup Terraform
id: setup
if: steps.changed-files.outputs.any_modified == ‘true’
uses: hashicorp/setup-terraform@v2
with:
terraform_version: ${{ env.terraform_version }}
terraform_wrapper: true

# 檢查所有的 Terraform 配置文件是否符合規範格式
– name: Terraform Format (${{ env.TF_DIR }})
id: fmt
if: steps.changed-files.outputs.any_modified == ‘true’
run: terraform fmt
continue-on-error: true

# 通過創建初始文件、加載任何遠程狀態、下載模塊等操作,初始化新的或現有的 Terraform 工作目錄
– name: Terraform Init (${{ env.TF_DIR }})
id: init
if: steps.changed-files.outputs.any_modified == ‘true’
run: terraform init

# 檢查所有的 Terraform 配置文件是否符合規範語法
– name: Terraform Validate (${{ env.TF_DIR }})
id: validate
if: steps.changed-files.outputs.any_modified == ‘true’
run: terraform validate -no-color

# 檢查發布用的 PR 是否已存在,並設置 PR 內容
– name: Check if pr exists
id: check_pr
if: ( github.ref != ‘refs/heads/master’ && github.event_name == ‘push’ && steps.changed-files.outputs.any_modified == ‘true’ )
env:
GH_TOKEN: ${{ env.GITHUB_TOKEN }}
run: |
base_branch=master
pr_title=${{ github.ref }}
pr_title=`echo ${pr_title//refs\/heads\//}`
pr_ticket=`echo ${pr_title//feature\//}`
pr_body=`echo “【Ticket】
https://docomo-common.atlassian.net/browse/${pr_ticket}

【Changes】
${{ github.event.head_commit.message }}”`

# 對換行符進行轉義處理
pr_body=”${pr_body//’%’/’%25′}”
pr_body=”${pr_body//$’\n’/’%0A’}”
pr_body=”${pr_body//$’\r’/’%0D’}”

# 用於後續處理的輸出
echo “::set-output name=count::$(gh pr list -S ${pr_title}’ in:title’ -B $base_branch | wc -l)”
echo “::set-output name=base_branch::$base_branch”
echo “::set-output name=pr_title::$pr_title”
echo “::set-output name=pr_body::$pr_body”

# 創建發布用的 PR
– name: Create release pr
if: ( github.ref != ‘refs/heads/master’ && github.event_name == ‘push’ && steps.changed-files.outputs.any_modified == ‘true’ && steps.check_pr.outputs.count == 0 )

下面是关于要点的解释。

1.2. 设置变量

env:
  terraform_version: '1.x.y' # 使用する terraform のバージョン
  project_number: 'hoge' # OIDC認証に使用する workload_identity_pool が存在する Project
  workload_identity_pool: 'hoge'  # OIDC認証に使用する workload_identity_pool
  workload_identity_provider: 'hoge' # OIDC認証に使用する workload_identity_provider
  service_account: 'hoge' # OIDC認証に使用するSA
  reviewer: 'hoge' # Pull Request に設定するレビュワー
  GITHUB_TOKEN: ${{ secrets.FOR_RUNNER_NPRD }} # GitHub Personal Access Token

关于详细信息,请参考评论。
“GITHUB_TOKEN”是指定为GitHub的Secret所注册的内容。
这是用于创建拉取请求和在拉取请求中添加评论所必需的。

1.3. 获取 Terraform 执行目录

  set-matrix:
    name: 'Set Matrix'

    runs-on: ubuntu-latest

    env:
      TF_ROOT_DIR: .

    steps:
      # Checkout the repository to the GitHub Actions runner
      - name: 'Checkout'
        id: 'checkout'
        uses: actions/checkout@v3
        #with:
        #  fetch-depth: 0

      - name: Find tfstate dirs
        id: find-tfstate-dirs
        run: |
          dirs=$(find ${TF_ROOT_DIR} -type f -name '*.tf' -exec dirname {} \; | grep -v 'modules\|\.terraform' | uniq | jq -R -s -c 'split("\n")[:-1]')
          dirs=`echo ${dirs//\.\//}` # ディレクトリパスの「./」を削除
          echo $dirs # 確認用
          echo "::set-output name=dirs::${dirs}"

    outputs:
      dirs: ${{ steps.find-tfstate-dirs.outputs.dirs }}

在创建 Terraform 代码时,关于如何进行目录结构的问题似乎总是会困扰着人们。
官方的最佳实践并不一定适用于实际情况,而公司文化和个人喜好也可能起到很大的作用。
因此,可以获取存在 tf 文件的目录列表,并将它们全部视为“Terraform 执行目录”。
通过输出获取的目录列表,可在后续的任务中使用。
(关于为什么要分开任务会在下文中解释)

1.4. 使用 Terraform 的执行目录来定义矩阵。

    strategy:
      fail-fast: false
      max-parallel: 5
      matrix:
        TF_DIR: ${{ fromJson(needs.set-matrix.outputs.dirs) }}

    env:
      TF_DIR: ${{ matrix.TF_DIR }}

如果将前一个Job中指定的Terraform执行目录定义为上述的矩阵变量,那么可以并行执行Job的个数与矩阵的元素数量相等。
由于这个矩阵是在Job级别上定义的变量,因此如果想要动态指定,就需要通过前一个Job中动态定义的变量通过输出来传递。
(如果只需要静态地指定多个目录,则前一个Job是不必要的)

使用矩阵来管理工作

1.5. 代码差异检查

    steps:
      # Checkout the repository to the GitHub Actions runner
      - name: 'Checkout'
        id: 'checkout'
        uses: actions/checkout@v3
        with:
          fetch-depth: 2

      # 更新履歴を比較し、差分チェック
      - name: Get changed files
        id: changed-files
        uses: tj-actions/changed-files@v29.0.0
        with:
          files: |
            ${{ env.TF_DIR }}/*.tf

由于只想在代码发生更改的地方执行terraform,所以想要进行差异检查。
虽然整个目录批量升级也可以用于漂移检查,但执行时间可能会因代码规模而变长。
如果想要进行漂移检查,我认为最好另外定义一个工作流程或使用其他方法。

1.6. OpenID Connect 认证

      # OIDC 認証
      - name: 'Authenticate to Google Cloud'
        id: 'auth'
        if: steps.changed-files.outputs.any_modified == 'true'
        uses: 'google-github-actions/auth@v0'
        with:
          token_format: 'access_token'
          workload_identity_provider: 'projects/${{ env.project_number }}/locations/global/workloadIdentityPools/${{ env.workload_identity_pool }}/providers/${{ env.workload_identity_provider }}'
          service_account: ${{ env.service_account }}

只需一种选项来用中文翻译以下内容:
正在使用OIDC认证GCP。
如果使用其他云服务,只需更改“uses”参数即可。
虽然不同云服务可能会有不同的变量指定方式,但这次只在GCP上进行了确认。

另外,在「如果」条件下,根据之前进行的差分检查的结果,判断是否执行该步骤。

1.7. 设置Terraform。

      # Install the latest version of Terraform CLI and configure the Terraform CLI configuration file with a Terraform Cloud user API token
      - name: Setup Terraform
        id: setup
        if: steps.changed-files.outputs.any_modified == 'true'
        uses: hashicorp/setup-terraform@v2
        with:
          terraform_version: ${{ env.terraform_version }}
          terraform_wrapper: true

在之前的调查中已经提到,使用HashiCorp提供的Action“setup-terraform”,对terraform进行版本指定。
虽然官方的GitHub主机运行程序中预装了terraform,但由于它可能是最新版本,因此根据执行的时机,terraform的版本可能会有所不同。
通常,在编写代码时,我们会指定或固定terraform的版本,因此我认为应明确指定和使用执行环境的版本。

1.8. 使用 terraform fmt & init & validate

      # Checks that all Terraform configuration files adhere to a canonical format
      - name: Terraform Format (${{ env.TF_DIR }})
        id: fmt
        if: steps.changed-files.outputs.any_modified == 'true'
        run: terraform fmt
        continue-on-error: true

      # Initialize a new or existing Terraform working directory by creating initial files, loading any remote state, downloading modules, etc.
      - name: Terraform Init (${{ env.TF_DIR }})
        id: init
        if: steps.changed-files.outputs.any_modified == 'true'
        run: terraform init

      # Checks that all Terraform configuration files adhere to a canonical syntax
      - name: Terraform Validate (${{ env.TF_DIR }})
        id: validate
        if: steps.changed-files.outputs.any_modified == 'true'
        run: terraform validate -no-color

terraform计划之前进行代码检查。此外,如果fmt存在差异(格式修正),返回代码为3,GitHub Actions会停止并标记为错误,因此我们添加”continue-on-error: true”以在出错时继续执行。

1.9. 创建Pull Request

      # リリース用PRが既に存在するかどうかをチェック、及び PR 内容設定
      - name: Check if pr exists
        id: check_pr
        if: ( github.ref != 'refs/heads/master' && github.event_name == 'push' && steps.changed-files.outputs.any_modified == 'true' )
        env:
          GH_TOKEN: ${{ env.GITHUB_TOKEN }}
        run: |
          base_branch=master
          pr_title=${{ github.ref }}
          pr_title=`echo ${pr_title//refs\/heads\//}`
          pr_ticket=`echo ${pr_title//feature\//}`
          pr_body=`echo "【チケット】
          https://${jira_org}.atlassian.net/browse/${pr_ticket}
          
          【変更内容】
          ${{ github.event.head_commit.message }}"`

          # 改行エスケープ処理
          pr_body="${pr_body//'%'/'%25'}"
          pr_body="${pr_body//$'\n'/'%0A'}"
          pr_body="${pr_body//$'\r'/'%0D'}" 

          # 後続処理に受け渡すため Output
          echo "::set-output name=count::$(gh pr list -S ${pr_title}' in:title' -B $base_branch | wc -l)"
          echo "::set-output name=base_branch::$base_branch"
          echo "::set-output name=pr_title::$pr_title"
          echo "::set-output name=pr_body::$pr_body"

      # リリース用PRを作成
      - name: Create release pr
        if: ( github.ref != 'refs/heads/master' && github.event_name == 'push' && steps.changed-files.outputs.any_modified == 'true' && steps.check_pr.outputs.count == 0 )
        env:
          GH_TOKEN: ${{ env.GITHUB_TOKEN }}
        run: |
          gh pr create -B ${{ steps.check_pr.outputs.base_branch }} -t ${{ steps.check_pr.outputs.pr_title }} -b "${{ steps.check_pr.outputs.pr_body }}" --reviewer ${{ env.reviewer }}

在将特性分支推送到特性分支时,自动创建一个用来将代码合并到主分支的拉取请求。在创建之前,先检查该特性分支的拉取请求是否已经存在。

我执行Pull Request的撰写等操作,使得提交时的提交信息以”变更内容”的形式显示在正文中。

因为这是最能看到可视化影响的部分,所以我想要细致地处理。
例如,将terraform graph的结果粘贴到PR评论中(尽管个人觉得terraform graph本身很难看懂…)。

1.10. 规划 Terraform

尽管摘录内容有些长,但这里是一套的,所以…。

      # Generates an execution plan for Terraform
      - name: Terraform Plan (${{ env.TF_DIR }})
        id: plan
        if: steps.changed-files.outputs.any_modified == 'true'
        run: terraform plan -no-color -input=false
        continue-on-error: true

      # Pull Request/コード差分有り/Planにて変更有り の場合、コメントを追記する
      - name: Adding Pull Request Comment
        uses: actions/github-script@v6
        if: ( github.event_name == 'pull_request' && steps.changed-files.outputs.any_modified == 'true' && !contains(steps.plan.outputs.stdout, 'No changes.') )
        env:
          PLAN: "terraform\n${{ steps.plan.outputs.stdout }}"
        with:
          github-token: ${{ env.GITHUB_TOKEN }}
          script: |
            const output = `#### Terraform Format and Style ?\`${{ steps.fmt.outcome }}\`
            #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
            #### Terraform Validation ?\`${{ steps.validate.outcome }}\`
            #### Terraform Plan ?\`${{ steps.plan.outcome }}\`

            <details><summary>Show Plan</summary>

            \`\`\`terraform\n
            ${process.env.PLAN}
            \`\`\`

            </details>

            *Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;

            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })

      # Pull Request/コード差分有り/Planにて変更有り の場合、コメントを追記する
      - name: Adding Pull Request Comment 2
        uses: actions/github-script@v6
        if: ( github.event_name == 'pull_request' && steps.changed-files.outputs.any_modified == 'true' && !contains(steps.plan.outputs.stdout, 'No changes.') )
        env:
          PLAN: "terraform\n${{ steps.plan.outputs.stdout }}"
        with:
          github-token: ${{ env.GITHUB_TOKEN }}
          script: |
            const output = `\n#### Terraform Format and Style ? \`${{ steps.fmt.outcome }}\`\n
            #### Terraform Initialization ⚙️ \`${{ steps.init.outcome }}\`\n
            #### Terraform Validation ? \`${{ steps.validate.outcome }}\`\n
            #### Terraform Plan ? \`${{ steps.plan.outcome }}\`\n

            <details><summary>Show Plan</summary>

            \`\`\`terraform\n
            ${process.env.PLAN}
            \`\`\`

            </details>

            *Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;

            await core.summary
              .addHeading('Terraform plan report')
              .addRaw(output)
              .write()

运行 terraform plan,并将结果发表在拉取请求的评论中。
使用“if”条件,可以详细指定运行条件,如果 plan 结果为“No changes.”,则不发表评论等等。
因为在一个拉取请求中可以并行运行多个 terraform 执行文件夹,所以当只进行了代码重构或代码内部的注释修正等操作时,虽然不会对实际资源产生影响,但可能会导致大量的拉取请求评论被投稿。
这会影响可读性,所以只有在实际资源发生更改时才发表评论。

1.11. 应用terraform

      # Plan に失敗していたら、Applyの実行はしない
      - name: Terraform Plan Status
        if: steps.plan.outcome == 'failure'
        run: exit 1

      # masterブランチ/push(merge)/Planにて変更有り の場合、Applyを実行する
      - name: Terraform Apply (${{ env.TF_DIR }})
        id: apply
        if: ( github.ref == 'refs/heads/master' && github.event_name == 'push' && steps.changed-files.outputs.any_modified == 'true' && !contains(steps.plan.outputs.stdout, 'No changes.') )
        run: terraform apply -auto-approve -input=false

如果在之前的 plan 结果中出现错误,最后将不会执行 terraform apply,并添加类似 break 的处理机制。

2. 提示和困难的东西 hé de

2.1.关于秘密

GitHub 内的 Secret(类似于GCP的SecretManager或AWS的Parameter Store)需要在每个存储库中设置个人访问令牌(以下简称PAT),如果存储库数量较多或需要更改和更新PAT,可能会有些麻烦。据说可以注册组织级别的Secret,可以从多个存储库中使用,但权限方面还未确认能否实现。

将来也希望考虑使用 HashiCorp 的 Vault,并且 HashiCorp 还提供了用于 Vault 的 Action。

哈希谷仓库/保险库操作

2.2. 关于Pull Request的创建者

假设我们是由共同的用户来创建Pull Request,但在这种情况下无法确定“Pull Request 创建者(代码修正者)”。
如果为评审者指定了团队或从团队中进行随机分配,那么有时候Pull Request的创建者也会被分配为评审者。

2.3. 关于分支名称

假设使用Jira并联合使用Pull Request,在Pull Request中将分支名称自动插入Jira票据的URL。
换句话说,假设”Jira票据号=分支名称”,因此不考虑为一个票据创建多个分支。
不过,由于这部分可以通过脚本轻松处理,所以我希望根据实际情况进行改进。

2.4. 关于 GitHub 的个人访问令牌

默认的个人访问令牌没有执行工作流的权限,需要修改范围(添加工作流权限)。
此外,在创建拉取请求时,需要[‘read:org’,’read:discussion’]的范围才能指定审核者组。
(如果审核者不是组或者不需要指定审核者,则不需要上述权限。)

以下是一个在范围不足的情况下出现的错误。

Run gh pr create -B master -t feature/gitac-test -b "" --reviewer hoge/hoge
Warning: 1 uncommitted change
GraphQL: Your token has not been granted the required scopes to execute this query. The 'id' field requires one of the following scopes: ['read:org', 'read:discussion'], but your token has only been granted the: ['repo', 'workflow'] scopes. Please modify your token's scopes at: https://github.com/settings/tokens.,  Your token has not been granted the required scopes to execute this query. The 'slug' field requires one of the following scopes: ['read:org', 'read:discussion'], but your token has only been granted the: ['repo', 'workflow'] scopes. Please modify your token's scopes at: https://github.com/settings/tokens. 
Error: Process completed with exit code 1.

3.总结

这次我尝试写了一些我在工作中经历过的事情,而不是以往写的私人内容。我尽量避免具体描述,但如果有什么地方还是透露出来了的话,请在评论中 discreetly 提醒我,谢谢。

由于它涉及到我个人非常喜欢的领域如自动化、优化和管理服务的利用,这个内容无论是在工作中还是感到非常有乐趣。我希望在未来的工作中能够继续应用这些知识。

广告
将在 10 秒后关闭
bannerAds