「Self-hosted GitHub Actions runners in AWS CodeBuild」を使ったバッチ実行基盤

お疲れ様です、SREの西田 ( @k_bigwheel ) です。 最近、バッチ処理を実行するための新しい基盤を構築したので、この記事ではそれの紹介をしたいと思います。

少々前提の説明を丁寧にやりすぎてしまったので、作ったものをまず見たいという方は「構築したバッチ実行基盤のアーキテクチャ図と概要」の項目へジャンプしてください。

バッチ実行基盤とは

バッチジョブ、バッチ処理のための仕組みは、多くのWebサービスで設けられていると思います。 とてもプリミティブなものであれば、Webアプリが動いているEC2インスタンス/コンテナにログインして rake task ... などを実行するパターンが典型でしょう。

もう少し複雑になってくるとAWS CodeBuildとEventBridgeを組み合わせてサーバレスに定期実行したり、更に複雑な例ではApache AirflowやArgo Workflowで依存関係や実行順序・再実行の制御などを行う場合があります。

これらのうちどれを使うべきかはそのサービスのバッチ処理に求められる要求によります。基本的に高機能なものほど環境の維持や学習にコストがかかります。

GitHub Actions

GitHub ActionsはGitHub公式のCIサービスです。GitHubリポジトリとの親和性が高く、今日では多くのエンジニアが使っているCIサービスです。

私は直近5年間このGitHub Actionsを非常に重用しており、正式リリース後はCIサービスとして最初の選択肢として常に使ってきました。*1

GitHub Actionsをバッチ実行基盤として使うときのイメージ

GitHub Actionsをバッチ実行基盤に据える、という考え方はあんまり聞かない手法だと思いますが、私は直近2社のバッチ実行基盤はそのように構築してきました。

典型的な実行イメージは以下です:

  1. GitHub ActionsのSelf Hosted Runner機能を使用してVPC内に実行箇所を確保
  2. バッチ処理をWebアプリケーションと同じバイナリで実装し、DockerイメージをECRへpush
  3. GitHub Actionsのジョブ内でECRからdockerイメージをpull
  4. docker container run に適切なパラメーターを与えてバッチ処理を起動

GitHub Actionsをバッチ実行基盤に採用したときのメリット

次の様なメリットがあります。

1. バッチ処理を書くために追加の学習がほぼいらない

GitHub Actionsは前述の通りすでに多くのエンジニアが使い方を知っているため、追加の学習コストがいりません。

Apache Airflowなどのワークフローエンジンを使う場合はその概念だけでなく、Pythonでの書き方やDAGの概念など多くを学ぶ必要があります。

2. GitHub Actions用の各種ノウハウが活用できる

GitHub Actionsはシンプルな使い方もできますが、 workflow_runon などで依存関係を表現することにより、その実行順序の制御は柔軟にできます。

また、reusable workflowやcustom actions、公開されたOSSアクションの利用など、車輪の再発明を避けるための各種手法も使うことができます。

3. 実行結果の確認やエラー通知・モニタリングなどが楽

実行の成否やそのログもGitHub Actionsの画面を使うことができるため、エラーの原因調査やエラー時の通知・実行回数やログのトラッキングなどもすべてGitHub Actionsのプラクティスを適用できます。

Apache Airflowなどのバッチ実行基盤を使うと書くと、実行結果の把握も不慣れで経験が必要なため、そこをスキップできることもメリットです。

これらのメリット1,2,3は開発チームが自信を持って自分たちの力のみでバッチ処理を管理運用することを支援します。これは、チームトポロジーやスクラムの言う、1チーム完結で仕事ができるという理想へ近づくことができます。

4. 運用コスト(TCO: Total Cost of Ownership)が低い

GitHubとGitHub Actionsをすでに使用している組織では、GitHub Actionsをバッチ処理基盤に採用することで増えるインフラ・SaaSはありません。また、後ほど説明しますが最近発表された「Self-hosted GitHub Actions runners in AWS CodeBuild」という機能を使えばAWSサイドのリソースの構築運用やコンピューティングコストもかなり低く抑えられます。

インフラを構築運用するSREの目線からすると、扱うものの種類が増えると認知負荷が増え、結局TCOも増えるため、この点も大きいです。

GitHub Actionsをバッチ実行基盤に採用したときのデメリット

逆に明確なデメリットもいくつかあります。

1. GitHub Actionsのダウンに影響される

GitHub Actionsは、結構不安定なサービスです。 直近3ヶ月の障害記録を確認してみましたが、2回ほど障害が発生しています。ここに記録されない軽微なものを含めるともう少し発生している体感で、だいたい毎月なにか起こっています。

そのため、その時間に必ず一度だけ実行してほしいジョブや、再実行でリカバーできない(=冪等性のない)処理などには向いていません。ここは処理側でGitHub Actionsの性質を考慮して冪等性があるように書いたり、最悪遅延しても大丈夫な仕組みにしたりする必要があります。

2. 起動が少し遅い

GitHub Actionsのワークフローの実行は、実際にステップ1が実行されるまで30秒 ~ 2分程度の遅延があります。これはDockerイメージのpullや実行環境の用意などに時間がかかっているようです(後述するSelf-hosted GitHub Actions runners in AWS CodeBuildを使うとCodeBuildが実行環境を準備する時間もかかるため、全体で2~4分程度最初のステップの実行までかかります)。

例えば踏み台サーバー経由でコマンドを実行する場合、長くても数秒で実行に移れるので、ここは明確なデメリットです。一方で、実際にバッチ処理が運用に入った場合、この立ち上がりの遅さが問題になることはほとんどないです。問題になるのは主に処理のデバッグ・開発中で、1度実行して結果を確認するのに時間がかかるため開発効率が落ちます。この点は、Debugging with ssh · Actions · GitHub Marketplaceなどのアクションを活用することで軽減するなどの工夫が必要かもしれません。

3. VPCの壁がデフォルトでは超えられない

GitHub Actionsのデフォルトの実行環境であるGitHub Hosted Runnerは、AWSの外にあるサービスです。

バッチジョブからプライベートサブネット内のDBやインターナルALBへアクセスさせたい場合、当然そのままではアクセスできません。

これを解決するには方法については続けて詳しく書きます。

VPCの壁を超えるための手法について

AWSでバッチ処理を行う場合、多くの人が頭を悩ますのがDBへのアクセスです。

バッチ処理はその多くでDBへのアクセスを行うため、DBへのアクセス経路を確保する必要があります。一方でAWS環境のDBはプライベートサブネットに置くことが多く、GitHub Actionsのデフォルトの実行方法(GitHub Hosted Runner)を使う場合、実行箇所がAWS内ではないためDBへアクセスすることはできません。

この解決策は僕が知っている限り以下の種類があります。

1. DBをパブリックサブネットに配置し、DBのpublicly accessibleオプションを有効にする

DBをパブリックサブネットに配置し、DBのpublicly accessibleオプションを有効にするとDBの各インスタンスへグローバルIPが割り当てられます。そうすればインターネットのどこからでもアクセス可能になるため、GitHub Hosted Runnerからもアクセスできる寸法です。 RDSのサブネット間の移動は手間はかかるものの可能であるため(rds プライベートサブネット パブリックサブネット 移動 - Google 検索)、後からこうすることもできます。

この方法の欠点はセキュリティ観点で比較的弱いことです。GitHub Hosted Runnerは使用するIPを固定したり専有したりすることはできないため(※ はてなブックマークのコメントで、Larger Runnerを利用すればIP帯を限定できると教えていただきました。Enterprise Cloud契約が必要でコストも変わりますが、有力な選択肢になりえます)、セキュリティグループで接続元を制限することもできません。ですのでDBのセキュリティはネットワークトポロジーで担保することはできずDB自体の認証機構のみに頼ることになります。それもあってAWSの設計としては一般にはバッドプラクティスとして考えられているようです。

蛇足ですが、私はこの手法は場合によっては有用と考えており一概に切り捨てるべきではないと思っていたりします(bastionを廃してRDSのパブリックアクセス可能(publicly accessible)を有効にしてはどうか提言 - kbigwheelのプログラミング・ソフトウェア技術系ブログ)。

2. 踏み台サーバーの利用

パブリックサブネットに踏み台サーバーを置き、DBへのアクセスを中継させる手法です。

比較的取りやすい構成ではありますが、先述のブログにも書いている通り、実はこれはDBのpublicly accessibleを使うこととセキュリティ水準的にはそれほど変わりません。また、踏み台サーバー内にファイルが置かれてインフラが状態を持ってしまい、踏み台サーバーの置き換え・アップグレードができないなどの問題がよく発生します。なので、この方法はおすすめしません。これでよい要件であれば、先程のpublicly accessibleのほうがセキュリティ水準は同じで運用コストはより低くて済みます。

3. Self Hosted Runnerの利用

GitHubにはデフォルトの実行環境(GitHub Hosted Runner)の他に、自分たちで実行環境を用意するSelf Hosted Runnerという仕組みがあります。AWSのVPCの壁を突破する方法としてはこれを使うことが一般的です。

ただし、このSelf Hosted Runner、結構TCO(Total Cost of Ownership)が高いです。 素朴なEC2インスタンスとして立てた場合、中のOSレイヤー以上のメンテナンスを自分たちで行う必要があり、また実行中以外もインスタンスが起動しっぱなしであるためコンピューティングコストが高くつきます。

Fargateでサーバレスに実行時のみ起動するアイデアは数年前からありますが、先行例はたくさんあるもののOSSの形で公開された定評のあるソリューションはありません。 Kubernetes上で動かすソリューションはOSSかつ公式に認められたものとしてありますが(Actions Runner Controller)、Kubernetes自体の運用コストが低くない上、EKS上のFargateはECSのFargateよりかなり立ち上がり時間が遅い(2022年時点で4~7分程度かかった)などの欠点もあります。

そんな中で、2024年4月に満を持して出たのがSelf-hosted GitHub Actions runners in AWS CodeBuildでした。

Self-hosted GitHub Actions runners in AWS CodeBuildについて

2024年4月のリリースが以下です:

平たく説明すると、Self Hosted Runnerの実行箇所としてCodeBuildを選べるようになったというものです。 これによりSelf Hosted RunnerとしてEC2インスタンスを使った場合のようにOSレイヤーを管理する手間や利用時以外のコンピューティングコストを払う必要もなく、ECS Fargateのパターンのように自分たちで構築・運用する必要もなく、Kubernetesを使う場合のようにKubernetes自体とControllerの高い管理コストを払う必要もなくなりました。

前職ではEKSを使ってインフラを構築していたことと、まだSelf-hosted GitHub Actions runners in AWS CodeBuildが登場していなかったこともあって前述のActions Runner Controllerを使っていたのですが、現職ではECSベースかつCodeBuildを使用していることも後押しして、こちらを採用することにしました。

構築したバッチ実行基盤のアーキテクチャ図と概要

アーキテクチャはざっくりですが以下です。

キーポイントとしては

  1. Self-hosted GitHub Actions runners in AWS CodeBuildを使うことでシームレスに実行箇所をVPC内へ移す
  2. AWS - GitHub OIDCを使うことでIAM権限の取得をノンパスで行う
  3. IAMデータベース認証を使うことでDBへの接続も同じくノンパスで行う

などです。これらにより、バッチ処理を書く方としてはCodeBuildの存在を一切意識する必要なく、またIAMユーザーやDBのパスワードについても別途GitHub Secretsなどへ保存する必要がなくなります。

GitHub Actionsのワークフローファイルの例

name: workflow-a
on:
  workflow_dispatch: {}

jobs:
  job1:

    environment: dev

    # configure-aws-credentials アクションのためにid-tokenとcontentsの権限は必須
    permissions:
      id-token: write
      contents: read

    runs-on: codebuild-batch_jobs_dev-${{ github.run_id }}-${{ github.run_attempt }}

    steps:
      # GitHub AWS間のOIDCを使ってノンパスでIAM権限を取得する
      - name: configure aws credentials
        uses: aws-actions/configure-aws-credentials@v3
        with:
          role-to-assume: arn:aws:iam::1234567890:role/batch-jobs

      - run: aws sts get-caller-identity # IAM権限を正常に取得できていることの確認

      # 次の2ステップは、取得したIAM権限でECRからdockerイメージを取得できることの確認
      - uses: aws-actions/amazon-ecr-login@v2
        id: login-ecr
      - run: |
          docker pull ${{ steps.login-ecr.outputs.registry }}/github/bm-sms/project-a/project-a:sha-aaaaa1215bb084e9ad3d6abf30b91348043bbbbb

      # 最後にIAMデーベース認証の動作確認
      # GitHub SecretsやParameter Storeにパスワードを保存することなく、IAM権限からFederatedな一時DBパスワードを取得し、それを持って接続する
      # 通常、Publicly AccessibleではないDBにはGitHub Actionsからはアクセスできないが、
      # self-hosted GitHub Actions runners in AWS CodeBuild を使用しているため接続が可能になっている
      - run: sudo apt update && sudo apt install postgresql -y
      - run: |
          # https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.Connecting.AWSCLI.PostgreSQL.html
          export RDSHOST="db-cluster.cluster-aaaaylxtbbbb.ap-northeast-1.rds.amazonaws.com"
          export PGPASSWORD="$(aws rds generate-db-auth-token --hostname $RDSHOST --port 5432 --region ap-northeast-1 --username batch_job)"
          psql "host=$RDSHOST port=5432 dbname=hogehoge user=batch_job password=$PGPASSWORD" -c "SELECT CURRENT_SCHEMA, CURRENT_SCHEMA()"

セキュリティ・証跡関連で気をつけたこと

補足的ですが、セキュリティ関連で気をつけたことについていくつか書いておきます。

本番環境でのバッチ実行への制限

上記の仕組みを素朴に作ると、本番環境でのジョブ実行が危険なほど容易にできます。

具体的にはGitHub - AWS OIDC連携をしているリポジトリへWrite権限を持っているユーザーは任意の処理を書けます。個人情報へのアクセスもできてしまうでしょう。一方でジョブ実行に必ず人力のApproveを求めるのは現実的ではありません。それは、バッチ処理の中には一日1回など定期的に実行するものがあるからです。

そこで、その辺りを解決するために次のように設定しました(詳細は後ほど説明します):

  1. Environment

    1. リポジトリの設定から次のEnvironmentを作成する
      • prod-with-review
      • prod-without-review
      • dev-with-review
      • dev-without-review
    2. prod-with-review prod-without-review についれそれぞれ画像のように設定する

       

  2. GitHub - AWS OIDC

    1. AWS側のOIDC設定を次のように行い、AWSの本番環境アカウントは prod-with-review prod-without-review環境、 開発環境アカウントは dev-with-review dev-without-reviewの環境の権限があるときのみ使えるようにする
resource "aws_iam_role" "batch_job" {
  name               = "batch_job"
  assume_role_policy = data.aws_iam_policy_document.batch_job.json
}

data "aws_iam_policy_document" "batch_job" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    effect  = "Allow"

    condition {
      test = "StringLike"
      values = [
        "repo:bm-sms/repo-a:environment:${var.environment}-with-review",
        "repo:bm-sms/repo-a:environment:${var.environment}-without-review",
      ]
      variable = "token.actions.githubusercontent.com:sub"
    }

    principals {
      identifiers = [data.aws_iam_openid_connect_provider.this.arn]
      type        = "Federated"
    }
  }
}

data "aws_iam_openid_connect_provider" "this" {
  url = "https://token.actions.githubusercontent.com"
}

3.(ブランチプロテクション)ルールセット

  1. 画像のように設定することでレビューなしで main ブランチを変更できないようにする

全体の意図を説明します。

まず、本番環境での実行は原則承認のステップを挟みたいため、AWSの本番環境アカウントへの接続には prod-with-review Environmentを要求し、Deployment*2にはレビューを要求しています。

一方で、定期実行など仕組み上レビューを挟めない実行方式もあります。その場合は prod-without-review Environmentを使用しますが、その際でも実行ブランチは main に限定されており、同時にブランチプロテクションルールセットで main ブランチの変更には常にレビューと承認を必要とするため、レビューなしに本番環境で任意の処理を行うことはできません。

(補足: dev環境などは毎回の承認は冗長なため with / without の両方のEnvironmentを用意する必要は原則ないですが、環境差異を小さくしたいためあえて本番環境と同様に作り dev-with-review でのReviewのチェックだけ外しています)

証跡としての実行ログについて

Self-hosted GitHub Actions runners in AWS CodeBuildを使った場合、バッチ実行のログはCodeBuild側にはまったく残らず、GitHub Actions側に残ります。そして、GitHub Actionsの実行ログはリポジトリへのwrite権限という比較的弱い権限で削除できます。そのため、悪意のあるユーザーがあるジョブを実行してすぐにログを消すことができます。

これの対策として、エス・エム・エスではDatadog CI VisibilityのLog Collectionを使うことにしました。これであればログを削除することはできません。

ジョブ自体の実行の証跡について

ジョブ自体の実行の証跡については、GitHub OrganizationのAudit log機能を使用しています。

IAMデータベース認証のなりすまし可能問題について

IAMデータベース認証を素朴に使うと実行ログの証跡としての価値が非常に下がってしまう問題については次のように対処しました。

クエリログへのアクセスを制限したい

クエリに個人情報が含まれている可能性があり、クエリログへアクセスできる人を制限したい件については次のように対処しました。

終わりに

本記事ではここ数ヶ月作っていた、CodeBuildとGitHub Actionsを使ったバッチ実行基盤について紹介しました。

この方式はまだ主流ではなく、GitHub Actions自体の稼働率に引っ張られるなど明確な欠点もありながら、GitHub Actions自体の普及率が爆発的に増えたことにより、大きなメリットが出てきた手法かなと思います。

個人的には、TCOがかなり低いにもかかわらずバッチ処理を書く側・実行する側のDeveloper Experienceがかなり良いこと、この記事に書いたような概要の説明とアーキテクチャ図があればSRE(構築側)への問い合わせをあまりしなくてもバッチを書ける (= スクラムチームやストリームアラインドチームの独立実行能力を担保しやすい)ことの2点で非常に気に入っています。