GitHub Actionsで実現する、APIキー不要でGitOps-likeなインフラCI/CD

※ 今はGitHub ActionsでOIDCが使えるので、本記事の内容は少し古いです。*1 現場のルール等で「インフラを触るワークロードはオンプレでしか動かしてはならない」みたいなルールがある場合には多少参考になるかと思います。

SREのたっち(@TatchNicolas)です。

JX通信社では「インフラチーム」のようなものは存在せず、開発したチームが運用までやるFull-cycleなスタイルを取っています。AWS・GCPリソースの管理も特定のメンバーが担当するのではなく、必要とする人が必要な時に作成・修正等を行います。すると、terraformなどIaCのツールを利用する場合に「今リポジトリにあるコードは実態を正しく反映しているのか」「誰かが矛盾する変更を加えていないか」という問題が発生します。

CIツール上でterraformを実行することで、問題の一部は回避できるかもしれませんが、CIにSaaSを利用している場合、クラウド上のリソースを操作する強い権限を持ったAPIキーを外部に預けることになってしまいます。

GCPのCloudBuildを使った解決策 *2、AWSのCodeBuildを使った例*3はそれぞれあるのですが、せっかくterraformをつかうならできるだけ同じ仕組みに載せたいところです。

そこで、「CIの仕組みとしてはSaaS(今回はGitHub Actions)を利用しつつも、実行場所はクラウド内にとどめることでGitOpsの考え方を一部取り入れたterraform運用のしくみ」を作ってみました。

TL;DR;

f:id:TatchNicolas:20201005152126p:plain
GCPの場合

f:id:TatchNicolas:20201005152147p:plain
AWSの場合

  • Self-hosted runnerを使えば、GitHubにクラウドの認証情報を渡さなくともGitHub Actionsでterraformを実行できる
  • Pull Request作成でplan、masterへのマージでapplyして、また定期的にmaster最新コミットでplanして乖離をチェック することでGitOps-likeに運用できる *4
  • 発想としては単純で「GCP Service AccountまたはAWS IAM Roleを使ってCIのRunnerにterraform実行に必要な権限を与える」なので、GitLab.comでも同じようにできるはず *5

やってみる

GKE(GCP)・EKS(AWS)にて、実際にやってみます。 AWS・GCPの権限を引き受けることができれば良いので、k8sではなくEC2・GCEでも実現可能なのですが、できるだけクラウドベンダごとの違いがでないようにk8s上のPodとして動かしました。

  • GKE・EKSクラスタは構築済みの前提
  • RunnerにGCP・AWSの強い権限を持ったAPIキーを渡さない
    • GKE・EKSの機能を使ってAPIキー自体を発行しないで権限をRunnerに付与する
  • 定期的にplanを実行して、masterの最新commitと食い違いを検知する

1. Runnerをつくる

今回は下記の記事の中で紹介されている gh-runners/gke at master · bharathkkb/gh-runners · GitHub を参考にしました

github.blog

気を付けるポイントとしては、

  • あとで hachicorp/setup-terraform を使うために、unzipをDockerイメージにインストールする必要がある
  • 権限付与の動作確認用にgcloudやaws cliなども追加しておくと便利かもしれません

Podが削除されるときにGitHub上のからRunnerを除名する処理をシェルスクリプトで書いて、preStop のlifecycleで実行するようにしても良いでしょう。

apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: github-runner
  name: github-runner
  labels:
    app: github-runner
spec:
  replicas: 1
  selector:
    matchLabels:
      app: github-runner
  template:
    metadata:
      labels:
        app: github-runner
    spec:
      serviceAccount: github-runner
      containers:
      - name: runner
        image: gcr.io/your-project-name/github-runner
        env:
        - name: REPO_OWNER
          value: "your_github_name"
        - name: REPO_NAME
          value: "your_repo_name"
        - name: GITHUB_TOKEN
          valueFrom:
            secretKeyRef:
              name: name-of-secret
              key: GITHUB_TOKEN
        lifecycle:
          preStop:
            exec:
              command: ["sh", "-c", "'./remove.sh'"]

ちなみにPodが削除されたまま放っておいても、GitHub側で30日経てば自動で取り除いてくれるようです。しかしお片付けはきちんとしたほうが気持ちが良いでしょう。

A self-hosted runner is automatically removed from GitHub if it has not connected to GitHub Actions for more than 30 days.

About self-hosted runners - GitHub Docs

また、今回は使いませんでしたがRunnerの登録/削除などを良い感じに管理してくれる素敵なControllerもありました。

github.com

2. Actionを定義する

HashiCorpのチュートリアルにGitHub Actionsを使った自動化の例 があるので、そちらを参考にすれば環境やチームのリポジトリ構成に合わせてすんなり書けると思います。

learn.hashicorp.com

また、追加で定期的にplanを実行して、tfファイルと実態の乖離を検知するworkflowを定義してみます。

検知の方法は色々あるとおもいますが、planをファイルに書き出して、jsonとしてterraform showしたものをjqでパースして判断してみます。今回はついでに例として、乖離があったらIssueを作成してみます。

下記の例の通りやると乖離が解決されない限り重複するIssueを作成しつづけてしまうので、cronの頻度を落としたり通知先をGitHub IssueではなくSlackにする、復帰のためのapplyまで実行してしまうなど、チームの方針に合わせて調整してみてください。

name: check-drift

on:
  push:
  schedule:
    - cron:  '*/10 * * * *'

jobs:
  check-drift:
    runs-on:
      - self-hosted
    steps:
      - uses: actions/checkout@v2

      - uses: hashicorp/setup-terraform@v1
        with:
          terraform_version: 0.13.2

      - name: Terraform Init
        id: init
        run: terraform init

      - name: Terraform Plan
        id: plan
        run: terraform plan -out tfplan

      - name: Print the result of terraform plan as json
        id: print_json
        run:  terraform show -json tfplan

      - name: Check configuration drift
        id: check
        env:
          PLAN_JSON: ${{ steps.print_json.outputs.stdout }}
        run: |
          DIFF=$(echo "$PLAN_JSON"| jq -c '.resource_changes[] | select(.change.actions!=["no-op"])')
          if [ -z "$DIFF" ]; then
            echo 'No configuration drift detected'
            echo '::set-output name=STATUS::synced'
          else
            echo 'Configuration drift detected!'
            echo '::set-output name=STATUS::drifted'
            terraform show tfplan -no-color
          fi

      - uses: actions/github-script@0.9.0
        id: show_plan
        if: steps.check.outputs.STATUS == 'drifted'
        env:
          PLAN: "terraform\n${{ steps.plan.outputs.stdout }}"
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const output = `\`\`\`${process.env.PLAN}\`\`\``;

            github.issues.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: "Configuration drift detected",
              body: output
            });

3. Runnerに権限を渡す

GCPのservice accountまたはAWSのIAM roleと、k8s service account(KSA)の紐付けをするterraformのサンプルコードを示しておきます。どちらも github-runner というk8s namespaceにrunnerをデプロイすることを前提としています。 *6

GCPの場合

GKE上のPodにGCPリソースの操作権限を渡すには、Workload Identityを使います。

resource "google_service_account" "github_runner" {
  account_id   = "github-runner"
  display_name = "github-runner"
}

resource "google_project_iam_member" "github_runner_owner" {
  role   = "roles/owner"
  member = "serviceAccount:${google_service_account.github_runner.email}"
}

resource "google_service_account_iam_member" "github_runner_wi" {
  service_account_id = google_service_account.github_runner.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "serviceAccount:${var.project_id}.svc.id.goog[github-runner/github-runner]"
}

紐付けるKSA

apiVersion: v1
kind: ServiceAccount
metadata:
  name: github-runner
  namespace: github-runner
  labels:
    app: github-runner
  annotations:
    iam.gke.io/gcp-service-account: github-runner@your-project-id.iam.gserviceaccount.com

AWSの場合

EKS上のPodにAWSリソースの操作権限を渡すには、こちらのドキュメントを参考にします。

resource "aws_iam_role" "github_runner" {
  name               = "GitHubRunnerRole"
  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "${aws_iam_openid_connect_provider.this.arn}"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "${replace(aws_iam_openid_connect_provider.this.url, "https://", "")}:sub": "system:serviceaccount:github-runner:github-runner"
        }
      }
    }
  ]
}
EOF
}

resource "aws_iam_role_policy_attachment" "github_runner" {
  role       = aws_iam_role.github_runner.name
  policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"

}

紐付けるKSA

apiVersion: v1
kind: ServiceAccount
metadata:
  name: github-runner
  namespace: github-runner
  labels:
    app: github-runner
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/GitHubRunnerRole

動かしてみる

実際にPull Requestを作成してみると、下記のようなコメントが自動でつきます。 show plan のところをクリックすれば terraform plan した内容が確認できます。 確認できたらマージすると、applyが実行されます。

f:id:TatchNicolas:20201005065742p:plain
自動でplanの結果がコメントされる

また、定期的にplanして定義と実態の乖離を検知すると、Issueが作成されます。

f:id:TatchNicolas:20201005075351p:plain
自動で作成されたIsuue

まとめ

得られたメリット

  • GCP・AWSの強い権限をもったAPIキーをGitHubに預けなくてよくなった
  • AWSでもGCPでも、仕組みの作り方がほぼ共通で利用・メンテナンスがしやすい
    • クラウドベンダごとの違いはterraformとk8sで吸収
  • terraformを運用者の手元で実行する必要がなくなった
    • 「これって反映済み?どれが実態?」「planが食い違ってしまった、今誰か作業してる?」が発生しなくなる
    • tfenvに頼らなくてもterraformのバージョンを指定して実行できる
  • terraformの定義と実態の乖離を検知できるようになった

注意点・残った課題

  • GitHubのself-hosted runnerはリポジトリ単位か組織単位でしか設定できない
    • 組織でterraform用runnerを共有すると、組織以下のすべてのリポジトリがCIでAWS・GCPの強い権限を利用できてしまう
    • GitLabにはgroup単位のrunnerを設定できるようですが、GitHubにはそのあたりを細かく設定できなさそうなのでもうひと工夫する必要がありそう
  • masterへpushできる人は(アプリケーションでもそうですが)しっかり管理する必要がある
    • masterへpushできる人は(与えた権限の範囲で)Runnerを通じて何でも出来てしまうため
    • 同じ理由でrunnerを動かしているk8sクラスタ上でのPodへの権限も、きちんと管理する必要がある
  • HashiCorpのチュートリアルにあったworkflow定義では、Pull Request作成時にplanした分はファイルへ書き出さず、マージ時に改めてapplyしているので、Pull Requestコメントに書き出したplanと実際に行われるapplyの内容が異なる可能性がある
    • planした結果を何処かへ保存して、その内容を参照するようにworkflowを改善できるかも

チームの体制やポリシーによって、持たせる権限やRunnerの運用方法には工夫が必要ですが、「credentialを外に出さない」「GitリポジトリをSingle Source of Truthにする」「乖離を検知する」というGitOpsの特徴を持ったterraform運用が実現できました。少しでも参考になれば幸いです。

*1:https://github.blog/changelog/2021-10-27-github-actions-secure-cloud-deployments-with-openid-connect/

*2:https://cloud.google.com/solutions/managing-infrastructure-as-code

*3:https://medium.com/mixi-developers/terraform-on-aws-codebuild-44dda951fead

*4:GitOpsの定義は https://www.weave.works/technologies/gitops/https://www.gitlab.jp/blog/2020/09/03/is-gitops-the-next-big-thing-in-automation/ など揺らぎがあるので、今回はその一部である「権限を外に出さない」「GitリポジトリをSingle Source of Truthとする」を取り入れつつもsyncはCI上で行っていることからGitOps-"like"としました

*5:調べてみると、事例がありました 絶対に止められない超重要サービスをGitOpsで安全に開発できるようにしている話

*6:本記事ではroles/ownerやAdministratorAccessといった強い権限を渡していますが、実際にはチームの方針にあわせて調整してください。