仕事がずっとコンサルワークなので, 休日のプログラミングがめちゃくちゃ楽しみになっている人です.
最初にお礼をさせてください, Developers Summit 2023の発表, なんだか好評価(高評価)だったみたいです.
#devsumi 開催報告によると、私のトークの満足度は全90セッション中11位でした🎉
— Shinichi Nakagawa / 中川 伸一 (@shinyorke) 2023年3月2日
フィードバックコメントも多数いただき、誠にありがとうございました!
次回作にご期待ください(多分夏ぐらいになるだろう)https://t.co/68lYY2vj25
練習やレビューに協力いただいた皆様, そして何よりも当日私のトークを聞いていただいた皆様, 誠にありがとうございました.
さて, 当日の発表では「GoでRESTful APIを作って運用しましたよ」という話をしました.
ただ, この資料のスライド枚数(約70枚)中, わずか1スライドでサラッと触れた程度で何も話をしていないので,
- Cloud RunでGo製アプリを動かす
- GoからBigQuery, Cloud Storageを使う
- Pythonじゃなくて敢えてGoにした理由
について, このエントリーで改めて書きたいと思います.
この話の範囲・ユースケースはこちらで,
「BigQueryでSelectした結果をRESTful APIで返す」「検索結果のCacheをCloud Storageで持つ」という構成です*1.
また, 調べながら作って苦労してる時の呟きも残ってるのでこちらも合わせて読むといいかもしれません, 私と同じく駆け出しGopherさんは.
Pythonで実装したデータ基盤用のバックエンド, DatabaseをCloud FirestoreからBigQueryに変えるんだけど.
— Shinichi Nakagawa / 中川 伸一 (@shinyorke) 2022年12月3日
いっそのこと, リハビリと練習がてらGolangでやろうかなっていう気になってきた.
7年ぶりのGolang再入門
そう, ちゃんと書いたの7年ぶりだったんですよ, ほとんど忘れてました...w
なお本日のスタメン(メニュー)はこちらです.
- 対象読者と前提条件
- Cloud RunでGo製 APIを作る
- GoからBigQueryを使う
- GoからCloud Storageを使う
- 結び - PythonとGo使い分け
- Appendix: 参考文献
対象読者と前提条件
- Go言語で何かしらのものを作ったり書いたりしている方. なお執筆者(私)のGo歴は割と浅いですw*2
- 何かしらの言語でCloud Runを使ったことがある方. 無い方はクイックスタートをやってみることをオススメします.
- サーバーサイドのAPI開発, 運用をしている・経験ある方.
Cloud Run, BigQuery, Cloud Storageといったサービスの解説は省略します, それぞれのページをご覧頂くか, 私のデブサミ発表を御覧ください.
前提条件として, GoのAPIは以下条件の元実装しています.
- Goのバージョンは 1.20.1
- Web FrameworkとしてGinを使用
コードスニペットが多数登場しますが, コピペでは動かない奴ですご了承ください🙏
「Ginじゃなくて標準のnet/http
で良くね?」とかツッコミたくなる方もいらっしゃると思いますが, まあ今回はFrameworkが何であっても関係ない話なので許してください*3.
また, 「PythonよりGoがいいぜ」という話ではないことを強く言っておきます.
理由は最後にちゃんと書きますが, 単に使い分けと好みの話ということで読んでもらえれば.
Cloud RunでGo製 APIを作る
Cloud RunでGoを動かすのはさほど難しくありません, クイックスタートを写経したらひとまずHello Worldは余裕です.
改めてここに一つずつ書くのは無駄な繰り返しな気がするので割愛します.
「クイックスタートを終えたあとに何をするか」という話からします.
ひとまず作る・動かす
ひとまず作って動かすだけなら,
- クイックスタート通りに写経して一旦デプロイする
- 必要なライブラリ(今回はGin)を
go install github.com/gin-gonic/gin
とかやって入手する - 必要なコードを書いてデプロイ -> 動くかどうか確認
- Dockerfileを書いて手元で動かす
- うまく行ったらビルドしてArtifact Registryにimageをpush*4
- docker imageを元にCloud Runにデプロイ
ぐらいで一旦完了です.
私も一旦このような手順で作りました, コードの雰囲気だけ伝えるとこんな感じです.
// main.goの内容 package main import ( "github.com/Shinichi-Nakagawa/sample/api/fielding" "github.com/Shinichi-Nakagawa/sample/api/tracking" "github.com/gin-gonic/gin" ) func getTrackingByBatter(c *gin.Context) { var response tracking.Response // 何かしらの処理(省略) c.IndentedJSON(http.StatusOK, response) } func getTrackingByPitcher(c *gin.Context) { var response tracking.Response // 何かしらの処理(省略) c.IndentedJSON(http.StatusOK, response) } func getFielding(c *gin.Context) { var response fielding.Response // 何かしらの処理(省略) c.IndentedJSON(http.StatusOK, response) } func main() { router := gin.Default() router.GET("/tracking/batter/:name", getTrackingByBatter) router.GET("/tracking/pitcher/:name", getTrackingByPitcher) router.GET("/fielding/:name", getFielding) router.Run(":8080") }
依存する外部ライブラリなんかもあるので, Dockerfileも書きました.
# Dockerfileの内容 # Build Stage FROM golang:1.20 as builder # /app以下に必要なもの全部ある前提 WORKDIR /app COPY . /app/ RUN go mod download RUN CGO_ENABLED=0 go build -o /go/bin/app # Run Stage(Build Stageからバイナリをコピっているだけ) FROM alpine:latest COPY --from=builder go/bin/app / EXPOSE 8080 CMD [ "/app" ]
これで手元で docker compose up
とかで動いたらOK.
gcloud build
した結果をArtifact Registryに保存するためいろんな設定を書きます.
以下の内容でcloudbuild.yaml
を記載.
steps: - name: "gcr.io/cloud-builders/docker" args: [ "build", "-t", "asia-northeast1-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}:${_TAG}", ".", ] images: - "asia-northeast1-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}:${_TAG}"
こんな感じでbuild -> deployのシェルを書いて動かせるようにします(deploy.sh
という名前で保存*5).
project=$1 tag=$2 # image build & submit gcloud builds submit --config=cloudbuild.yaml \ --substitutions=_REPOSITORY=sample,_IMAGE=api,_TAG=${tag} . # deploy gcloud run deploy --image asia-northeast1-docker.pkg.dev/${project}/sample/api:${tag} \ --platform managed \ --port 8080 \ --memory 4Gi \ --cpu 2 \ --region asia-northeast1 \ --max-instances 4 \ --min-instances 0
ここまで来たら,
sh ./deploy.sh ${プロジェクトの名前} ${docker imageのタグ名}
とかでひとまず一通りのモノが動きます.
GitHub ActionsでCI/CD
いつまでも手動でデプロイするのはイケていないので,
- リポジトリ(GitHub)のブランチ(何でもいい)にpushしたらテストが走る.
- mainブランチだったらCloud Buildが走ってからのCloud Runにデプロイ.
というCI/CDをGitHub Actionsに加えます.
絵で描くとこういうやつです.
.github/workflows/api.yaml
という名前で↑のCI/CDが実現できます(ちなみにapi.yaml
のファイル名は何でもいいです).
name: API on: push defaults: run: working-directory: api # work directoryを固定 env: PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }} # Google CloudのプロジェクトID REPOSITORY: sample SERVICE_NAME: api REGION: asia-northeast1 jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Go uses: actions/setup-go@v3 with: go-version: "1.20" - name: Build run: go build -v ./... - name: Test run: go test -v ./... build: name: Build runs-on: ubuntu-latest needs: test if: github.ref == 'refs/heads/main' permissions: contents: "read" id-token: "write" steps: - id: "checkout" name: "Checkout" uses: actions/checkout@v3 - id: "auth" # Workload Identity(WID)を使った認証認可 name: "Authenticate to Google Cloud" uses: "google-github-actions/auth@v0" with: token_format: "access_token" workload_identity_provider: "${{ secrets.GCP_WID_PROVIDER }}" service_account: "${{ secrets.GCP_SERVICE_ACCOUNT }}" - id: "docker-auth" # Artifact Registryにログイン(WIDで取ったtokenを使う) name: Authorize Docker uses: "docker/login-action@v1" with: username: "oauth2accesstoken" password: "${{ steps.auth.outputs.access_token }}" registry: "${{ env.REGION }}-docker.pkg.dev" - id: "docker-build" name: Build Docker image run: docker build -t "${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE_NAME }}:${{ github.sha }}" . - id: "docker-push" name: Push Docker Image run: docker push "${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE_NAME }}:${{ github.sha }}" deploy: name: Deploy runs-on: ubuntu-latest needs: build if: github.ref == 'refs/heads/main' permissions: contents: "read" id-token: "write" env: GIN_MODE: "release" # Ginをリリースモードで動かす steps: - id: "checkout" name: "Checkout" uses: actions/checkout@v3 - id: "auth" # Workload Identity(WID)を使った認証認可(Build Stageと同じことをしています) name: "Authenticate to Google Cloud" uses: "google-github-actions/auth@v0" with: workload_identity_provider: "${{ secrets.GCP_WID_PROVIDER }}" service_account: "${{ secrets.GCP_SERVICE_ACCOUNT }}" - id: "deploy" name: "Deploy to Cloud Run" uses: "google-github-actions/deploy-cloudrun@v0" with: service: "${{ env.SERVICE_NAME }}" region: "${{ env.REGION }}" image: "${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE_NAME }}:${{ github.sha }}" env_vars: "ENV_HOGE=${{ secrets.ENV_HOGE }}" # Container内で使う環境変数 flags: "--cpu=2 --memory=4Gi --min-instances=0 --max-instances=4 --port=8080" - id: "output" name: Show Output run: echo ${{ steps.deploy.outputs.url }}
ここまで組み込んだら,
- 毎push毎にビルドとテストが走る
main
ブランチにpushかつテストがすべて通るとbuild -> deployされてCloud Runのアプリケーションが入れ替わる
というCI/CDが爆誕します.
GoからBigQueryを使う
GoからBigQueryを使いたいときはbigquery
パッケージを使います.
アプリケーションに組み込む
使い方はドキュメントの通りなので,
main.go
とは別のファイルにBigQueryクライアントを実装main.go
にクライアントを取り込んで使う
こんな感じで組み込みます.
// datastore/bq.go package datastore import ( "cloud.google.com/go/bigquery" "context" "log" ) func NewBigQueryClient(ctx context.Context, projectID string) *bigquery.Client { client, err := bigquery.NewClient(ctx, projectID) if err != nil { log.Fatalf("bigquery.NewClient: %v", err) } defer client.Close() return client }
main.go
への組み込みはこんな感じ(もっといい書き方あったら教えて下さい).
package main import ( "context" "fmt" "cloud.google.com/go/bigquery" "github.com/Shinichi-Nakagawa/sample/api/datastore" "github.com/Shinichi-Nakagawa/sample/api/fielding" "github.com/Shinichi-Nakagawa/sample/api/tracking" "github.com/gin-gonic/gin" ) var ctx context.Context = context.Background() // BigQuery Client var bq *bigquery.Client func getTrackingByBatter(c *gin.Context) { var response tracking.Response // 何かしらの前処理(省略) var results []tracking.SelectResult queryString := fmt.Sprintf(`select a, b, c from hoge.fielding where d = 1`) // 適当なクエリ文字列 query := bq.Query(queryString) iter, err := query.Read(ctx) if err != nil { log.Fatalf("query.Read: %v", err) } for { var row tracking.SelectResult err := iter.Next(&row) if err == iterator.Done { return results } if err != nil { log.Fatalf("iter.Next: %v", err) } results = append(results, row) } // resultsからいい感じにresponseを作る処理(省略) c.IndentedJSON(http.StatusOK, response) } func getTrackingByPitcher(c *gin.Context) { var response tracking.Response // 何かしらの前処理(省略) var results []tracking.SelectResult queryString := fmt.Sprintf(`select a, b, c from hoge.fielding where d = 1`) // 適当なクエリ文字列 query := bq.Query(queryString) iter, err := query.Read(ctx) if err != nil { log.Fatalf("query.Read: %v", err) } for { var row tracking.SelectResult err := iter.Next(&row) if err == iterator.Done { return results } if err != nil { log.Fatalf("iter.Next: %v", err) } results = append(results, row) } // resultsからいい感じにresponseを作る処理(省略) c.IndentedJSON(http.StatusOK, response) } func getFielding(c *gin.Context) { var response fielding.Response // 何かしらの前処理(省略) var results []fielding.SelectResult queryString := fmt.Sprintf(`select a, b, c from hoge.fielding where d = 1`) // 適当なクエリ文字列 query := bq.Query(queryString) iter, err := query.Read(ctx) if err != nil { log.Fatalf("query.Read: %v", err) } for { var row fielding.SelectResult err := iter.Next(&row) if err == iterator.Done { return results } if err != nil { log.Fatalf("iter.Next: %v", err) } results = append(results, row) } // resultsからいい感じにresponseを作る処理(省略) c.IndentedJSON(http.StatusOK, response) } func main() { // Initialize bq = datastore.NewBigQueryClient(ctx, googleCloudProjectID) router := gin.Default() router.GET("/tracking/batter/:name", getTrackingByBatter) router.GET("/tracking/pitcher/:name", getTrackingByPitcher) router.GET("/fielding/:name", getFielding) router.Run(":8080") }
こんな感じで書いて程よく動きました.
データ型は組み込み型を使う
Schema的な使い方をするstructはbigquery
パッケージの組み込み型を使いましょう(でないとNull対応ができない).
package fielding import ( "cloud.google.com/go/bigquery" ) // jsonやcsvと相互で読み込み・書き込みするのでこんな感じになっています. type SelectResult struct { GameDate bigquery.NullDate `bigquery:"game_date" json:"game_date" csv:"game_date"` Inning bigquery.NullInt64 `bigquery:"inning" json:"inning" csv:"inning"` FielderName bigquery.NullString `bigquery:"fielder_name" json:"fielder_name" csv:"fielder_name"` FielderPosition bigquery.NullInt64 `bigquery:"fielder_position" json:"fielder_position" csv:"fielder_position"` PitcherName bigquery.NullString `bigquery:"pitcher_name" json:"pitcher_name" csv:"pitcher_name"` HitLocation bigquery.NullInt64 `bigquery:"hit_location" json:"hit_location" csv:"hit_location"` // 以下, 100個近くあるので省略 LaunchSpeedAngleCategory bigquery.NullString `bigquery:"launch_speed_angle_category" json:"launch_speed_angle_category" csv:"launch_speed_angle_category"` OfFieldingAlignment bigquery.NullString `bigquery:"of_fielding_alignment" json:"of_fielding_alignment" csv:"of_fielding_alignment"` IfFieldingAlignment bigquery.NullString `bigquery:"if_fielding_alignment" json:"if_fielding_alig` }
値に欠損がある可能性が高いデータ型はNull**
的なデータ型を使うのがポイントです, パッケージのリファレンスにも記載があるので見てみると良いでしょう.
requiredなカラムであればNull的なのは不要です.
GoからCloud Storageを使う
GoからBigQueryを使いたいときはstorage
パッケージを使います.
使い方はドキュメントの通りかつ, 雰囲気的にはbigquery
とさほど変わりません.
main.go
とは別のファイルにCloud Storageクライアントを実装main.go
にクライアントを取り込んで使う
BigQueryと同じ感じで組み込みます.
// datastore/gcs.go package datastore import ( "cloud.google.com/go/storage" "context" "fmt" "io" "log" ) // クライアントの初期化 func NewStorageClient(ctx context.Context) *storage.Client { client, err := storage.NewClient(ctx) if err != nil { log.Fatalf("storage.NewClient: %v", err) } return client } // バケット取得 func GetBucket(client *storage.Client, name string) *storage.BucketHandle { bucket := client.Bucket(name) return bucket } // オブジェクトの書き込み(文字列をファイルに書き込む) func WriteObject(bkt *storage.BucketHandle, name string, value string, ctx context.Context) { obj := bkt.Object(name) w := obj.NewWriter(ctx) if _, err := fmt.Fprintf(w, value); err != nil { log.Fatalf("fmt.Fprintf: %v", err) } if err := w.Close(); err != nil { log.Fatalf("w.Close: %v", err) } } // オブジェクトの読み込み(テキストファイルを読み込んで文字列にする) func ReadObject(bkt *storage.BucketHandle, name string, ctx context.Context) string { var result string obj := bkt.Object(name) r, err := obj.NewReader(ctx) if err != nil { if err.Error() == "storage: object doesn't exist" { return result } log.Fatalf("obj.NewReader: %v", err) } defer r.Close() b, err := io.ReadAll(r) if err != nil { log.Fatalf("io.ReadAll: %v", err) } result = string(b) return result }
私が作ったAPIでは,
- 関数呼び出しのCacheとしてヒットしたらファイルの中身を読み込み
- ヒットしないときは新規にファイルを書き込み
というCache的な使い方*6をしているので, main.go
でこんな感じで書き込みます.
package main import ( "context" "fmt" "encoding/json" "cloud.google.com/go/bigquery" "cloud.google.com/go/storage" "github.com/Shinichi-Nakagawa/sample/api/datastore" "github.com/Shinichi-Nakagawa/sample/api/fielding" "github.com/Shinichi-Nakagawa/sample/api/tracking" "github.com/gin-gonic/gin" ) var ctx context.Context = context.Background() // BigQuery Client var bq *bigquery.Client // Cloud Storage Bucket var bucket *storage.BucketHandle func getTrackingByBatter(c *gin.Context) { var response tracking.Response // 何かしらの前処理(省略) // ファイル名を生成してひとまずRead filename := fmt.Sprintf("hoge/fuga.json") responseCache := datastore.ReadObject(bucket, filename, ctx) // 中身があったらJSONに読み直して戻す if responseCache != "" { if err := json.Unmarshal([]byte(responseCache), &response); err != nil { c.IndentedJSON(http.StatusBadRequest, gin.H{"message": "Invalid Response"}) return } c.IndentedJSON(http.StatusOK, response) return } var results []tracking.SelectResult queryString := fmt.Sprintf(`select a, b, c from hoge.fielding where d = 1`) // 適当なクエリ文字列 query := bq.Query(queryString) iter, err := query.Read(ctx) if err != nil { log.Fatalf("query.Read: %v", err) } for { var row tracking.SelectResult err := iter.Next(&row) if err == iterator.Done { return results } if err != nil { log.Fatalf("iter.Next: %v", err) } results = append(results, row) } // resultsからいい感じにresponseを作る処理(省略) // Cacheに書き込み datastore.WriteObject(bucket, filename, "書き込み対象の文字列", ctx) c.IndentedJSON(http.StatusOK, response) } func getTrackingByPitcher(c *gin.Context) { var response tracking.Response // 何かしらの前処理(省略) // ファイル名を生成してひとまずRead filename := fmt.Sprintf("hoge/fuga.json") responseCache := datastore.ReadObject(bucket, filename, ctx) // 中身があったらJSONに読み直して戻す if responseCache != "" { if err := json.Unmarshal([]byte(responseCache), &response); err != nil { c.IndentedJSON(http.StatusBadRequest, gin.H{"message": "Invalid Response"}) return } c.IndentedJSON(http.StatusOK, response) return } var results []tracking.SelectResult queryString := fmt.Sprintf(`select a, b, c from hoge.fielding where d = 1`) // 適当なクエリ文字列 query := bq.Query(queryString) iter, err := query.Read(ctx) if err != nil { log.Fatalf("query.Read: %v", err) } for { var row tracking.SelectResult err := iter.Next(&row) if err == iterator.Done { return results } if err != nil { log.Fatalf("iter.Next: %v", err) } results = append(results, row) } // resultsからいい感じにresponseを作る処理(省略) // Cacheに書き込み datastore.WriteObject(bucket, filename, "書き込み対象の文字列", ctx) c.IndentedJSON(http.StatusOK, response) } func getFielding(c *gin.Context) { var response fielding.Response // 何かしらの前処理(省略) // ファイル名を生成してひとまずRead filename := fmt.Sprintf("hoge/fuga.json") responseCache := datastore.ReadObject(bucket, filename, ctx) // 中身があったらJSONに読み直して戻す if responseCache != "" { if err := json.Unmarshal([]byte(responseCache), &response); err != nil { c.IndentedJSON(http.StatusBadRequest, gin.H{"message": "Invalid Response"}) return } c.IndentedJSON(http.StatusOK, response) return } var results []fielding.SelectResult queryString := fmt.Sprintf(`select a, b, c from hoge.fielding where d = 1`) // 適当なクエリ文字列 query := bq.Query(queryString) iter, err := query.Read(ctx) if err != nil { log.Fatalf("query.Read: %v", err) } for { var row fielding.SelectResult err := iter.Next(&row) if err == iterator.Done { return results } if err != nil { log.Fatalf("iter.Next: %v", err) } results = append(results, row) } // resultsからいい感じにresponseを作る処理(省略) // Cacheに書き込み datastore.WriteObject(bucket, filename, "書き込み対象の文字列", ctx) c.IndentedJSON(http.StatusOK, response) } func main() { // Initialize bq = datastore.NewBigQueryClient(ctx, googleCloudProjectID) gcs := datastore.NewStorageClient(ctx) bucket = datastore.GetBucket(gcs, "buket-name") router := gin.Default() router.GET("/tracking/batter/:name", getTrackingByBatter) router.GET("/tracking/pitcher/:name", getTrackingByPitcher) router.GET("/fielding/:name", getFielding) router.Run(":8080") }
この辺は割と直感的なので書きやすかったかも.
結び - PythonとGo使い分け
というわけで,
- Cloud RunでGo製アプリを動かす
- GoからBigQuery, Cloud Storageを使う
話を(GitHub ActionsからのCI/CDも加えた上で)紹介しました.
なんやかんやでやってることが盛りだくさんで端折った箇所もあるので, 「もう少し詳しく」的なリクエストありましたらSNS等でコメントを頂戴出来ると幸いです.
最後に, Pythonじゃなくて敢えてGoにした理由 についてお話をすると,
- 仕様が決まりやすいかつ, どのみち型安全に実装するなら最初からGoとかRustでいいじゃん!(最大の理由)*7
- クラウドネイティブ, Containerとの相性を考えるとGoいいじゃん(二番目の理由)
- 10年以上使ってるPythonでやれば休みの日丸一日使ったらdeployまで持っていけるが, それだと面白くない(違う言語も使いたい).
私はPythonもJavaScriptも極力, 型安全を担保して作りたい人でtypehint(Python)もTypeScriptも使うのですが,
Backend作る目的でPython書いてる時に型のコードだらけで何と戦っているのかイマイチわからん
というか,
「Goに馴染む学習コストを払ってでも最初から静的型付けな言語で書くべきじゃね?」
となりこのような選択になりました.
また,
- コンテナ化するためにimage/Containerのサイズを小さくする
- CI/CD(継続的インテグレーション・継続的デプロイ)をシュッとした構成でやる
という観点で,
Goが持つ言語仕様や特性が「クラウドネイティブな開発」という視点で照らし合わせるとPythonより良いんじゃない?
という知識(と過去経験から来る実感)があったのですが, いかんせん手を動かして試したことがなかったのでやりたかったのも大きな理由の一つです.
&結果的に開発者体験と共に知識も習得できましたし, 思ってたことは大正解でした, 他の言語で苦労してたの何だったんだマジで.
今回のコードは2022年の末から1月にかけて(デブサミに間に合うように)作ったものですが, 良きことも悪きこともたくさん学んだので,
今後はやりたいことの親和性・特徴に合わせてPythonとGoとTypeScriptを渡り歩きたいなと思います.
一個言語と環境知ってればひとまずエンジニアリングできる, なんて時代じゃないんで色々使ってやってくぞ.
最後までお読みいただきありがとうございました.
Appendix: 参考文献
7年ぶりのGolang開発ではこちらの書籍が私の相棒として大活躍してくださりました.
また, 最近始まったSoftware Design詩の連載「なるほど納得Go言語」を読みながら学んだりリファクタリングしたりしています.
*1:後者のCacheはともかく, 前者のBigQueryで検索した結果をAPIに, はあるようで無いケースかつ, あんまり使う機会も無いかもしれません. 状況によってBad Caseだと思うので.
*2:実務で書いたのはほぼゼロに等しいですが, 自分用の何かを作る・運用する程度には理解しています
*3:何だったらオススメのFrameworkとか方法があったら教えて下さい.
*4:この手のやつはググると代替Container Registryが出てくると思いますが, 機能面・セキュリティ面云々の事もあるので後発であるArtifact Registryを使うのがベストです&移行もさほど難しくないと思います(Enterpriseな使い方じゃなければ.)参考
*5:makeファイルじゃなくてすいません...w
*6:Cloud FirestoreとかのBigTable使えよ!とツッコまれそうですが, ドキュメントサイズの限界および, DB系はそれなりに処理とお金のコストがかかるのでこのような仕様にしました.
*7:Rustにしなかったのはこの程度のAPIをRustで作るのは流石にやりすぎと感じたからです.