Cloud FunctionsからCloudRun Jobsへの変更理由

2024/01/05に公開

はじめに

株式会社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を開発する人がバッチ処理側のコードを気にかけることが果たして可能なのかといった潜在的な問題も含んでました。

解決方法の概要

  1. プライベートリポジトリで管理しているapi/配下のmoduleをjobs/scheduler配下のmoduleにimportして共通化する
    ※ Go言語の仕様として、internal/は、スペシャルディレクトリとして作用し、internal/配下に定義してるpackageは、importできないので注意が必要です。

  2. CloudScheduler + PubSub + CloudFunction を CloudScheduler + CloudRun Jobsへ変更 (変更が必要になった理由は後述)

解決方法の詳細

1. Github Appsを用いて認証を行いプラベートリポジトリのpackageをimportする

Githubの認証の方法として、パーソナルアクセストークンを使う方法とGithub Appsを使う方法があります。
パーソナルアクセストークンを使う方法は、各開発者が各々tokenを作成し、環境変数として登録する必要があるため属人性があることが問題視されてました。トークンの有効期限を開発者に委ねてしまうと、大概更新するのがめんどくさいので長く設定されます。スノーデンさんがいたらすぐに突っ込まれそうですね。なので、今はGithub Appsで一時トークンを毎回発行する方針に切り替わっています。

2023年の10月頃にやっと公式のActionsが出ましたが、セキュリティが気になる方は各々自前実装してくださいと書いてあったので、自前で作成したworkflowを使って今も認証してます。

  1. 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 よりご連絡下さい。

https://envader.plus
https://raretech.site
https://var.co.jp

また、弊社では一緒に働く仲間を募集しています。

  • フロントエンドエンジニア
  • Bubble エンジニア
  • IT スクール RareTECH のメンター
  • ウェブマーケター

など様々なポジションで採用活動を強化しております。興味のある方は以下 Wantedly よりご連絡下さい。

https://www.wantedly.com/companies/var/projects

GitHubで編集を提案

Discussion