gRPC と okteto で開発したら最高だった話
初めに
学生の皆様テスト期間お疲れ様です。普段は iOS ✖️ ML みたいなことをさせていただいている学生です。
最近寒いし眠いし、1日を無駄に過ごしてしまっていることが多いような気がしています。そこで僕は、Notion のデータベースに習慣トラッカーを作成して、毎日無駄にダラダラ過ごさないように頑張っています。
でもお察しの通り、毎日 Notion のページを開いてチェックボックスにチェックを入れるのは大変だし、何より忘れちゃいます。リマインドとかその他諸々の利便性を考えた結果、Slack に Google Forms を送信してそれに回答すればいいのではと結論が出ました。
今回作成したアプリケーションは、23時になったら Google Forms が送られてくるので、その日を振り返りながら Form に回答します。回答した結果が、Notion のデータベースに追加されます。チェックボックスにいっぱいチェックが入れば 🎉ハッピー🎉 です。
↓
これに回答すると...
↓
ちなみにざっくりアーキテクチャはこんな感じです。
3 つアプリケーションが動いていることがわかりますね。普通に lambda とか使ってイベントドリブンな感じで作ればもっと簡単だったのかもしれないですが、k8s を選びました。これには理由が2つあって、まず okteto が完全無料(上限あり)で動かせること、そして最近 k8s を勉強しているので Workloads API を使用して何かしらアウトプットしようと考えたからです。
notifier
Slack に URL を送るだけです。23 時になったら job が走って欲しいので cronjob を採用しています。Image の転送料を考えたくないので Golang でマルチステージにしています。
func main() {
err := godotenv.Load(".env")
if err != nil {
log.Fatal("Error loading .env file")
}
tkn := os.Getenv("BOT_TOKEN")
gForm := os.Getenv("GOOGLE_FORM_URL")
c := slack.New(tkn)
msg := fmt.Sprintf("Please fill out the form: \n%s", gForm)
_, _, err = c.PostMessage("#チャンネル名", slack.MsgOptionText(msg, false))
if err != nil {
log.Fatal(err)
}
}
aggregator
この子がやっているのは、cron で毎日 23 時から 24 時の間、10分おきに Google Forms の回答結果を集計します。
ここと registerer は gRPC で通信しています。これはスキーマの自動生成とか、Golang との相性とか考えて採用しました。自動生成のおかげでマジでびっくりするくらい簡単に実装できて最高でした。
ちなみに registerer には、k8s の Service で名前解決して通信しています。
Google Forms API の Golang クライアント実装が地味に参考少なかったり、OAuth 周りがだるかった気がします。結局サービスアカウント使っています。
サービスアカウント周りの参考も少なかったので共有させていただきます。
import (
"golang.org/x/oauth2/google"
"google.golang.org/api/forms/v1"
"google.golang.org/api/option"
)
type Client struct {
Svc *forms.Service
Quiz map[string]string
formID string
}
func NewClient(formID string) (*Client, error) {
json, err := ioutil.ReadFile("creds.json")
if err != nil {
log.Printf("Error failed to read creds.json: %v", err)
return nil, err
}
config, err := google.JWTConfigFromJSON(json, forms.FormsResponsesReadonlyScope, forms.FormsBodyScope)
if err != nil {
log.Printf("Error failed to get JWT config: %v", err)
return nil, err
}
ctx := context.Background()
tokenSource := config.TokenSource(ctx)
svc, err := forms.NewService(ctx, option.WithTokenSource(tokenSource))
if err != nil {
log.Printf("Error failed to create service: %v", err)
}
c := &Client{Svc: svc, formID: formID}
err = c.createQuestionMap()
if err != nil {
log.Printf("Error failed to create question map: %v", err)
}
return c, nil
}
スキーマの生成は Makefile とかシェルスクリプトとか使いましょう。
こんな感じです。
PROTO_DEST_GO=./apps/aggregator/proto
.PHONY: gen_go
gen_go:
@echo "generating schema for go grpc server by proto..."
@mkdir -p $(PROTO_DEST_GO)
@protoc \
--go_out=$(PROTO_DEST_GO) --go_opt=paths=source_relative \
--go-grpc_out=$(PROTO_DEST_GO) --go-grpc_opt=paths=source_relative\
-I $(PROTO_SRC) \
$(PROTO_SRC)/*.proto
Github Copilot がこの辺完璧に補完してくれます。最高ですね。
gRPC クライアントの実装は、
詳しくはぜひリポジトリみてください。
registerer
aggregator の集計結果を gRPC 経由で受け取り、Notion API でデータベースに格納しています。Notion API のドキュメントが Node だったり、構造体にマッピングしなくてよかったりで Node.js にした気がします。まあ個人開発だし遊び心で 1 つくらい違う言語にした方が楽しいですよね 🥹
ここもまた gRPC の自動生成で最高の開発体験を得られました。ただ、イメージのサイズがデカかったり、そもそもビルド時間が長かったり... なんとかなる方法があれば知りたいです。
PROTO_SRC=./protobuf
PROTO_DEST_NODE=./apps/registerer/src/proto
GRPC_TOOLS_BIN=./apps/registerer/node_modules/.bin/grpc_tools_node_protoc
GRPC_TOOLS_PLUGIN=./apps/registerer/node_modules/.bin/grpc_tools_node_protoc_plugin
GRPC_TOOLS_TS_BIN=./apps/registerer/node_modules/.bin/protoc-gen-ts
.PHONY: gen_node
gen_node:
@echo "generating schema for node grpc server by proro..."
@mkdir -p $(PROTO_DEST_NODE)
@$(GRPC_TOOLS_BIN) \
--js_out=import_style=commonjs,binary:$(PROTO_DEST_NODE) \
--grpc_out=$(PROTO_DEST_NODE) \
--plugin=protoc-gen-grpc=$(GRPC_TOOLS_PLUGIN) \
-I $(PROTO_SRC) \
$(PROTO_SRC)/*
@$(GRPC_TOOLS_BIN) \
--plugin=protoc-gen-ts=$(GRPC_TOOLS_TS_BIN) \
--ts_out=$(PROTO_DEST_NODE) \
-I $(PROTO_SRC) \
$(PROTO_SRC)/*.proto
gRPC サーバの実装は
Notion API は
などを参考にさせていただきました。
こちらも詳しくはリポジトリをみてください。
インフラ周り
Google Cloud のコンテナレジストリと、okteto を使用しています。 GCP を選んだのは、単に AWS 以外も勉強したかったからです。Cloud Build でビルドして、レジストリに push する感じです。ゆくゆくは Github Actions とかで CI/CD もやりたいですね。
ちなみに、okteto はこんな感じでブラウザ上でログ見れます。
Cloud Build は
options:
env:
- GO111MODULE=on
volumes:
- name: go-modules
path: /go
steps:
- name: gcr.io/cloud-builders/docker
args: ['build', '-t', 'gcr.io/$_PROJECT_ID/$_REPO_NAME_NOTIFIER:$_COMMIT_SHA', './apps/notifier']
- name: gcr.io/cloud-builders/docker
args: ['build', '-t', 'gcr.io/$_PROJECT_ID/$_REPO_NAME_REGISTERER:$_COMMIT_SHA', './apps/registerer']
- name: gcr.io/cloud-builders/docker
args: ['build', '-t', 'gcr.io/$_PROJECT_ID/$_REPO_NAME_AGGREGATOR:$_COMMIT_SHA', './apps/aggregator']
- name: gcr.io/cloud-builders/docker
args: ['push', 'gcr.io/$_PROJECT_ID/$_REPO_NAME_NOTIFIER:$_COMMIT_SHA']
- name: gcr.io/cloud-builders/docker
args: ['push', 'gcr.io/$_PROJECT_ID/$_REPO_NAME_REGISTERER:$_COMMIT_SHA']
- name: gcr.io/cloud-builders/docker
args: ['push', 'gcr.io/$_PROJECT_ID/$_REPO_NAME_AGGREGATOR:$_COMMIT_SHA']
substitutions:
_REPO_NAME_NOTIFIER: notifier
_REPO_NAME_REGISTERER: registerer
_REPO_NAME_AGGREGATOR: aggregator
_COMMIT_SHA: v0.0.1
タグ変わらんのかーいってのは多めにみていただけると助かります。
k8s のマニフェストファイル内の変数埋め込みには envsubst を使用しています。ここら辺をよしなにやってくれるのが Kustomize とかだと認識していますが、簡単に envsubst で行いました。
.PHONY: _subst
_subst:
@mkdir -p ./infra/manifests/tmp
@envsubst < ./infra/manifests/notifier.yaml > ./infra/manifests/tmp/notifier.yaml
@envsubst < ./infra/manifests/aggregator.yaml > ./infra/manifests/tmp/aggregator.yaml
@envsubst < ./infra/manifests/registerer.yaml > ./infra/manifests/tmp/registerer.yaml
.PHONY: kapply
kapply:
@echo "Applying..."
@make _subst
@kubectl apply -f ./infra/manifests/tmp/ -R
@rm -rf ./infra/manifests/tmp
さいごに
アウトプットやっぱり楽しいし、k8s 楽しいし良いですね 🥹
簡単なアプリではありますが、間違いなく日常を便利にしてくれます。
今後は IaC とかにも挑戦したいです !!
↓ リポジトリです
Discussion