Cloud FunctionsからCloudRun Jobsへの変更理由
はじめに
株式会社varの開発に携わってるitoです。
みなさんは、バッチ処理をどのように開発してますか?
AWSのLambdaやECS on Fargate、Google CloudのCloudFunctionsやCloudRunをスケジューラと共に使ってバッチ処理を実装する際に、APIサーバで定義したモデルや関数を再度定義してませんか?
この記事では、妥協せずにDRYの原則に則ってリファクタリングした過程でCloud FunctionsからCloud Run Jobsへ変更する必要があった理由について紹介します。
前提となる環境
ディレクトリ構成とインフラ構成は下記の通りです。
- APIサーバ、定時バッチ共にGo言語で開発
- api/配下はCloudRunにデプロイし、APIサーバとして常時稼働
- jobs/scheduler配下はCloud Functionsにデプロイし、定時バッチとして稼働
├── api
│ ├── cmd
│ ├── controllers
│ ├── entities
│ ├── infrastructure
│ ├── interfaces
│ ├── usecases
│ ├── pkg
│ ├── tools
│ :
│ :
│ ├── go.mod
│ └── go.sum
│
├── jobs
│ ├── scheduler
│ │ ├── entities
│ │ ├── usecases
│ │ ├── handlers
│ │ ├── pkg
│ │ ├── main.go
│ : :
│ │ ├── go.mod
│ │ └── go.sum
課題としてあったこと
課題としてあったことは、APIサーバと定時バッチが同じDBスキーマを使うためDBモデルを表現するapi/entitiesとjobs/entitiesで同じモデルを定義しており、ダブルメンテする運用になっていたことです。また、ダブルメンテできればまだいいのですが、機能が増えてきたり開発メンバーが入れ替わった際に、APIを開発する人がバッチ処理側のコードを気にかけることが果たして可能なのかといった潜在的な問題も含んでました。
解決方法の概要
-
プライベートリポジトリで管理しているapi/配下のmoduleをjobs/scheduler配下のmoduleにimportして共通化する
※ Go言語の仕様として、internal/は、スペシャルディレクトリとして作用し、internal/配下に定義してるpackageは、importできないので注意が必要です。 -
CloudScheduler + PubSub + CloudFunction を CloudScheduler + CloudRun Jobsへ変更 (変更が必要になった理由は後述)
解決方法の詳細
1. Github Appsを用いて認証を行いプラベートリポジトリのpackageをimportする
Githubの認証の方法として、パーソナルアクセストークンを使う方法とGithub Appsを使う方法があります。
パーソナルアクセストークンを使う方法は、各開発者が各々tokenを作成し、環境変数として登録する必要があるため属人性があることが問題視されてました。トークンの有効期限を開発者に委ねてしまうと、大概更新するのがめんどくさいので長く設定されます。スノーデンさんがいたらすぐに突っ込まれそうですね。なので、今はGithub Appsで一時トークンを毎回発行する方針に切り替わっています。
2023年の10月頃にやっと公式のActionsが出ましたが、セキュリティが気になる方は各々自前実装してくださいと書いてあったので、自前で作成したworkflowを使って今も認証してます。
- The token is masked, it cannot be logged accidentally.
ちなみに、公式のActionsでもログはちゃんとマスクされてるようです。
下記、自前実装してるworkflowになります。参考
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Display Python version
run: python -c "import sys; print(sys.version)"
- name: Install pyjwt
run: pip install pyjwt[crypto]
- name: Generate access token
id: generate-access-token
env:
GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }}
GH_APP_ID: ${{ secrets.GH_APP_ID }}
run: |
jwt=$(python3 ./../../.github/generate_jwt.py)
generated_token=$(./../../.github/generate_access_token.sh ${jwt})
echo "token=${generated_token}" >> $GITHUB_OUTPUT
~ 省略 ~
- name: Build & Ship
run: |
docker build \
--tag $IMAGE_NAME \
--build-arg GITHUB_REPOSITORY=$GITHUB_REPOSITORY \
--build-arg ACCESS_TOKEN=${{ steps.generate-access-token.outputs.token }} \ # 生成しておいたアクセストークンをビルドで使用
. && \
docker push $IMAGE_NAME
#! /usr/bin/env python3
# This file is used to generate JWT for a GitHub App
import os
import jwt
import time
secret_key_value = os.environ.get("GH_APP_PRIVATE_KEY")
app_id = os.environ.get("GH_APP_ID")
if secret_key_value is not None:
payload = {
# Issued at time
'iat': int(time.time()),
# JWT expiration time (10 minutes maximum)
'exp': int(time.time()) + 60 * 10,
# GitHub App's identifier
'iss': app_id
}
try:
jwt_token = jwt.encode(payload, secret_key_value, algorithm="RS256")
print(jwt_token)
except Exception as e:
print("JWT generation failed:", e)
else:
print("Secret key not found in environment variables.")
#!/usr/bin/env bash
# This shell script is to generate Access Token of GitHub App using generated JWT
# Ref. https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-an-installation-access-token-for-a-github-app
JWT=$1
GITHUB_API_URL="https://api.github.com"
GITHUB_REPOSITORY="var-co-jp/リポジトリ名" # CI上ではデフォルトの環境変数だが、ローカルで実行することも考えて定義
if [ -z ${JWT} ]; then
echo "The first argument 'jwt' is empty"
exit 1
fi
installation_id="$(
curl --location --silent --request GET \
--url "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/installation" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
--header "Authorization: Bearer ${JWT}" |
jq -r '.id'
)"
token="$(
curl --location --silent --request POST \
--url "${GITHUB_API_URL}/app/installations/${installation_id}/access_tokens" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
--header "Authorization: Bearer ${JWT}" |
jq -r '.token'
)"
echo $token
2. CloudScheduler + PubSub + CloudFunction を CloudScheduler + CloudRun Jobsへ変更
DockerfileにCI上で作成しアクセストークンを下記のように設定して、ビルドすると無事プラベートリポジトリのpackageをimportすることができます。
RUN export GOPRIVATE=github.com/${GITHUB_REPOSITORY}
RUN git config --global url."https://x-access-token:${ACCESS_TOKEN}@github.com/var-co-jp".insteadOf "https://github.com/var-co-jp"
RUN go mod download
しかしながら、稼働させてたCloudFunctionのビルドプロセスは、CloudBuildが裏で走り開発者がビジネスロジックだけに集中できるような設計であるため、アクセストークンをビルドプロセスに含めることができませんでした。
そこで、これを機に2022年5月にリリースされたCloudRun jobsを使ってバッチ処理を実装することに変更しました。また、CloudRun jobsでは、HTTP通信でCloudSchedulerから起動することができ、間にPubSubを挟む必要がなくなり、よりシンプルな構成でバッチ処理を作成することができます。
下記は、Github Actionsのyamlファイルとmain.goのサンプルです。
引数にjob名を渡すことで、一つのDocker Imageを使って複数の定時バッチをデプロイしてます。
- name: deploy CloudRun Jobs
run: |
gcloud run jobs deploy Job名 \
--image=$IMAGE_NAME \
--args=job名 \ # デプロイしたいjob名を引数として渡す。ex. JobA, JobB
--env-vars-file env/dev.yaml \
--region $GCP_REGION \
--project $GCP_PROJECT_ID \
--vpc-connector $VPC_CONNECTOR \
--quiet
package main
import (
"context"
"log"
"os"
)
type schedulerJobExec func(ctx context.Context) error
var (
schedulerJobMap = map[string]schedulerJobExec{
"jobA": handlers.JobA,
"jobB": handlers.JobB,
"jobC": handlers.JobC,
}
)
func main() {
schedulerType := os.Args[1]
execFunc, ok := schedulerJobMap[schedulerType]
if !ok {
log.Printf("unexpected scheduler type. please confirm argument of CloudRun Jobs")
os.Exit(1)
}
log.Printf("[start] %s", schedulerType)
ctx := context.Background()
if err := config.Set(ctx); err != nil {
log.Printf("failed to set: %v", err)
os.Exit(1)
}
if err := execFunc(ctx); err != nil {
log.Printf("failed to exec fun. err: %w", err)
os.Exit(1)
}
log.Printf("[finish] %s", schedulerType)
}
まとめ
本記事では、API側のコードとバッチ処理側のコードを共通化する過程で必要になった変更点や技術について説明しました。
- GithubAppを利用した認証を通して、プライベートリポジトリで管理しているAPI側のpackageを定時バッチ側のmoduleにimport
- ビルドプロセスをカスタマイズするために、CloudScheduler + PubSub + CloudFunction の構成を CloudScheduler + CloudRun Jobsへ変更
今回は、Go言語とGoogleCloudで運用しているプロジェクトに対してでしたが、バッチ処理側とAPI側のコードを共通化して、より良い運用体験を追い求める考え方はプログラミング言語やクラウドベンダ問わず大切な考え方だと思います。
宣伝
弊社では、インフラ学習サイト Envader(エンベーダー)と IT スクール(RareTECH)を運営しています。また、企業研修やシステム開発なども行っていますので興味がある方は HP よりご連絡下さい。
また、弊社では一緒に働く仲間を募集しています。
- フロントエンドエンジニア
- Bubble エンジニア
- IT スクール RareTECH のメンター
- ウェブマーケター
など様々なポジションで採用活動を強化しております。興味のある方は以下 Wantedly よりご連絡下さい。
Discussion