この記事は Voicy Advent Calendar 2020 の 3 日目の記事です。
先日は, @moonum さんの MVVM + LiveData + coroutine アーキテクチャにSpek2を使って資産価値のあるUnitTestを追加していく でした。明日は, @yamagenii さんの 0と1のキーボードでプログラミングしてみた です。
はじめに
「モダンな環境」魅力的ですよね。エンジニアである以上, 新しい技術を追い続けたいものです。今回は, そんなモダンな環境を極力お金をかけずに今実現するなら...??というところについて考えてみました。
一言で「モダン」といってもそこは人によって定義が異なるかなとは思います。
今回の「モダンな開発環境」は以下を満たしているという前提です。
- アプリケーション
- マイクロサービス化
- レイヤードアーキテクチャ
- インフラ
- コード化されてる
- パブリッククラウドにデプロイされてる
- 環境が複数あることを想定出来てる
- CI/CD あり
- 外部ストレージへの永続化の考慮があるz
今回パブリッククラウドとしては, GCP を選択してます。(一番慣れているため)
結論
大項目 | 種別 | リソース |
---|---|---|
アプリケーション | 言語 | golang |
アプリケーション | プロトコル | gRPC |
インフラ | コンピューティング | Cloud Run |
インフラ | 外部ストレージ兼DBクライアント | Compute Engine |
インフラ | IaC | terraform |
インフラ | IaCリモートストレージ他 | Cloud Storage |
CI/CD | - | GitHub Actions(一部CloudBuildを経由) |
レポジトリ→モノレポ。
モノレポ構成例。
├── .github
│ └── workflows
├── build
├── databases
├── deployments
│ ├── environments
│ │ ├── dev
│ │ └── prod
│ └── modules
│ ├── bastion_server
│ ├── firewall
│ └── vpc
├── docs
├── proto
│ ├── go
│ │ └── schema
│ └── schema
├── clients
├── bff
└── services
├── bar-service
├── foo-service
└── hoge-service
- .github: GitHub Actions
- build: Dockerfile とか
- databases: db のマイグレーションとか dockernize とか
- deployments: terraform code
- docs: ER図とかシーケンスとかあれば
- proto: proto ファイル及び自動生成ファイル
- clients: アプリケーションクライアント
- bff: アプリケーションクライアントに対応するバックエンド(bffパターン)
- services: ドメイン実装サービス
選定
レポジトリ
アプリケーションやインフラをどう管理していくかという点で最初に決めが必要になるのがここかなと思います。今回は個人開発ということもあり, 全体の把握しやすさを優先してモノレポで構成してみました。
アプリケーション
アプリケーション選定については予算が絡んでこないので自由です。お好きな言語, お好きなプロトコルでというところで今回はマイクロサービスと相性が良くエコシステムが発展している golang x gRPC を選択してみました。
マイクロサービス周辺のエコシステム(go)
- kubernetes
- docker cli
- grpc-go
-
grpc-ecosystem
-
gateway
とかmiddleware
実装とか
-
サンプルや採用事例も多く実装に困らないです。
CI/CD
お金をかけないでというところから無料枠があるものが望ましいです。が, 大抵の CI ツールには無料枠が存在するので, ここも好みで良さそうです。選定対象は, CircleCI, Cloud Build, GitHub Actions, Travis ... etc のような感じです。今回モノレポなので, ディレクトリ単位での hook の分け方が容易であったり, 目的別で分けられるみたいなところから GitHub Actions を選びました。
外部ストレージ兼DBクライアント
ここが一番お金がかかるところです。GCP には「Always Free プロダクト」というのがあって特定のリソースは一定無料みたいなのがありこちらを利用します。
がっつりアプリケーション作りたいな...と考えたとき, firestore は 1GB と少し物足りなく, 他となったときの選択肢が GCE になります。
1 f1-micro インスタンス(1 か月あたり、北バージニア [us-east4] を除く米国リージョンのみ)
30 GB 月の HDD
というわけで, us-west1 に 30GB HDD な GCE を立てて外部ストレージサーバーとして稼働させることにしてみました。
コンピューティング
前提として, golang x gRPC なアプリケーションを稼働させる必要があります。
節約を考えてとりあえずデプロイ出来ればみたいな点を考えると, 上述の GCE に対して minikube などで k8s を立てるなどの選択肢もありそうです。ただ, 無料枠の容量としては限られているため極力ストレージ以外の用途では使いたくない都合があります。
そこで候補に上がるのが, Cloud Run か GKE というところです。
Cloud Run は無料枠も大きいですし, 直近 gRPC streaming 対応が入った。というところからの興味もあったので, Cloud Run を選択してみました。
IaC
これは terraform, ansible あるいは, GCP に Cloud Deployment Manager という terraform 互換のあるツールもあります。Cloud Deployment Manager については, コーディングを python で残せてより柔軟な IaC が可能。みたいなメリットがあるんですが, 過去使った感触では結構不具合とかつまづきポイントが多く扱いづらい印象でした。改めるとよくなる可能性はありますが, terraform で十分に柔軟なコード化が可能なので, terraform を選択してます。
細かいところ
プロトコルの管理について
ディレクトリとしてはこういう想定です。
- go>schema: protoc generate したファイル置き場
- schema: *.proto ファイル置き場
├── go
│ └── schema
└── schema
現状, golang からの利用しかないため, go>schema のみですが, ここには scala>schema だったり, python>schema だったり言語毎の自動生成ファイルも置けるよう考慮してる感じです。
protoc 自体は, バージョンによって生成される形が変わったりするので, なんらかの方法で protoc 差分が出ないようにする必要があります。
今回は, protoc の dockernize によってそちらを解決してます。
FROM golang:1.14
RUN apt-get update && \
apt-get install unzip
RUN go get -u github.com/golang/protobuf/protoc-gen-go && \
go get -u github.com/gogo/protobuf/proto && \
go get -u github.com/gogo/protobuf/gogoproto && \
go get -u github.com/gogo/protobuf/protoc-gen-gofast && \
go get -u github.com/gogo/protobuf/protoc-gen-gogo && \
go get -u github.com/gogo/protobuf/protoc-gen-gogofast && \
go get -u github.com/gogo/protobuf/protoc-gen-gogofaster && \
go get -u github.com/gogo/protobuf/protoc-gen-gogoslick && \
go get -u github.com/pseudomuto/protoc-gen-doc/cmd/protoc-gen-doc && \
go get -u google.golang.org/grpc/cmd/protoc-gen-go-grpc
RUN curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v3.13.0/protoc-3.13.0-linux-x86_64.zip && \
unzip -o protoc-3.13.0-linux-x86_64.zip -d /usr/local bin/protoc && \
unzip -o protoc-3.13.0-linux-x86_64.zip -d /usr/local include/* && \
rm -rf protoc-3.13.0-linux-x86_64.zip
RUN mkdir -p /go/src/github.com/google && \
git clone --branch master https://github.com/google/protobuf /go/src/github.com/google/protobuf && \
git clone --branch master https://github.com/openconfig/gnmi /go/src/github.com/openconfig/gnmi && \
mkdir -p /go/src/github.com/ &&\
wget "https://github.com/grpc/grpc-web/releases/download/1.2.1/protoc-gen-grpc-web-1.2.1-linux-x86_64" --quiet && \
mv protoc-gen-grpc-web-1.2.1-linux-x86_64 /usr/local/bin/protoc-gen-grpc-web && \
chmod +x /usr/local/bin/protoc-gen-grpc-web
WORKDIR "/go/src/github.com/"
ENTRYPOINT ["protoc"]
後は, Makefile などにこのコンテナを走らせるようなスクリプトを書いておけばすぐ自動生成が行えて便利です。
protoc -I ./schema \
--go_out=${go_out} --go_opt=paths=source_relative\
--go-grpc_out=${go_out} --go-grpc_opt=paths=source_relative\
./schema/$$t/*.proto;\
地味にハマってしまった点なんですが, go_out, go-grpc_out 毎に souce_relative 指定がないと, よく分からない絶対パスで自動生成されるため注意が必要でした。
今回はやってなかった(今思いついた)んですが, エンジニアは proto ファイルさえ定義すれば, 自動生成ファイルのコミット自体は CD にしてしまうとかでも良さそうです。
アプリケーションレイヤー
サービスを複数立てるとき各々のサービスのレイヤー自体は異なっていても良いとは思うのですが, ある程度決めておいた方が後々楽そうなので決めてみました。
レイヤーの依存関係は以下で定義してみました。
- adapter → usecase → domain ← infra
として, util はレイヤーと関係ないパッケージみたいなイメージです。
ディレクトリ構成としては以下の感じです。
├── adapter
├── build
├── config
├── di
├── domain
│ ├── entity
│ ├── repository
│ └── service
├── go.mod
├── go.sum
├── infra
│ ├── bridge
│ ├── psql
│ └── inmemory
├── main.go
├── test
│ └── e2e
├── usecase
└── util
マイクロサービス間の通信はどこのレイヤーだ...??みたいなのは修行中です。今回は, infra レイヤーの責務として infra>bridge で別サービスとの通信を実装してます。
domain repository にエンティティ操作用のインターフェースが定義されてて, infra 層で実装みたいな感じです。DIには wire ライブラリを利用してます。
gRPC における E2E テスト
サービス単位での E2E は, サービスをモックとして扱うという前提です。gRPC サーバーを立てる→client でサーバーを叩く。みたいな感じのテストコードになります。
こんな感じでテストコード時にサーバーが立つようにして
func init() {
lis = bufconn.Listen(bufSize)
s := grpc.NewServer()
adapter := di.NewAdapter()
pb.Register**Service(s, ***)
go func() {
if err := s.Serve(lis); err != nil {
log.Fatalf("Server exited with error: %v", err)
}
}()
}
あとは単純に client 経由でサーバーを叩くだけです。
テストなので, Insecure 接続で良いと思います。
ctx := context.Background()
conn, err := grpc.DialContext(ctx, "bufnet", grpc.WithContextDialer(bufDialer), grpc.WithInsecure())
if err != nil {
t.Fatalf("Failed to dial bufnet: %v", err)
}
defer conn.Close()
client := pb.New***Client(conn)
name := "test name"
resp, err := client.Create***(ctx, &pb.Request_Create***{Name: name})
if err != nil {
t.Fatalf("failed: %v", err)
}
assert.Equal(t, name, resp.Name)
client やリクエスト, レスポンスが自動生成されているのでテスタビリティが高くて良いです
アプリケーション間の認証
クラウド上では, 基本的に未認証を許可してインターネットに公開することはせずに, 認証済みの通信だけ許可するようにします。
Gloud Run の場合は以下の通りで認証できます。
https://cloud.google.com/run/docs/authenticating/service-to-service?hl=ja
具体的にコードでいうとこんな感じで token とって Bearer に乗っける形になります。
tokenSource, err := idtoken.NewTokenSource(ctx, config.***ServiceAudience())
if err != nil {
return fmt.Errorf("idtoken.NewTokenSource: %v", err)
}
token, err := tokenSource.Token()
if err != nil {
return fmt.Errorf("TokenSource.Token: %v", err)
}
ctx = grpcMetadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token.AccessToken)
アプリケーションで proto 参照を行う
module 解決は replace で解決できます。
replace github.com/{autor}/{repo}/proto => ../../proto
モノレポの利点として, CI/CD 時もパス変わらないがあるので, CDするときだけ replace 外すみたいなことは必要なく便利です。
アプリケーションCI
PR+ディレクトリ単位で発火みたいなのは以下でいけます。repository をホームとしてそこから見た相対パスになります。
on:
pull_request:
branches:
- dev
paths:
- services/foo-service/**
テストやビルドは golang に標準に備わってるのでコマンドを叩くだけです。
- name: Build
working-directory: services/foo-service
run: go build -v .
- name: Test
working-directory: services/foo-service
run: go test -v .
静的解析には, PR にコメントをくれる reviewdog が便利です。
- uses: reviewdog/action-setup@v1
with:
reviewdog_version: latest
- name: Setup golint
run: go get -u golang.org/x/lint/golint
- name: Review
working-directory: services/foo-service
run: reviewdog -conf=./.reviewdog.yml -reporter=github-pr-check
env:
REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
アプリケーション dockernize
サービス毎で固有のミドルウェアを導入したいみたいな要件がない場合, 一つの Dockerfile を使い回しできます。
FROM golang:1.14 as build-stage
WORKDIR /work
COPY proto ../proto
ARG SERVICE
COPY services/${SERVICE}/go.mod go.mod
COPY services/${SERVICE}/go.sum go.sum
RUN go mod download
COPY services/${SERVICE}/. .
RUN CGO_ENABLED=0 GOOS=linux go build -o app
FROM debian:buster-slim
RUN set -x && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
ca-certificates && \
rm -rf /var/lib/apt/lists/*
COPY --from=build-stage /work/app /work/app
CMD [ "/work/app" ]
実際ビルドするときは以下みたいな makefile を用意しといて
build-foo-service:
docker build \
--tag=${IMAGE} \
--file=./build/Dockerfile.api \
--build-arg SERVICE="foo-service" \
.
以下のようなステップを Actions に組み込む感じです。
...
IMAGE: gcr.io/${{ secrets.GCP_PROJECT }}/foo-service:${{ github.sha }}
...
...
- name: Build a docker image
run: make build-foo-service IMAGE=${IMAGE}
...
...
クライアント→クラウドの認証
ローカルの場合, Insecure で大丈夫ですが, 本番(クラウド)に向けての通信となるとセキュアに行った方が良さそうです。
こんな感じで証明書付きで dial connection を生成して
creds, err = credentials.NewClientTLSFromFile("/etc/ssl/certs/ca-certificates.crt", "")
if err != nil {
return nil, err
}
conn, err := grpc.Dial(addr, grpc.WithTransportCredentials(creds))
こんな感じでトークンを渡してあげることでいけます。許可された iam なら, $(gcloud auth print-identity-token)
みたいな感じでアクセストークンが取得できます。
ctx, cancel := context.WithTimeout(ctx, time.Second)
md := metadata.New(map[string]string{"Authorization": fmt.Sprintf("Bearer %s", token)})
ctx = metadata.NewOutgoingContext(ctx, md)
return ctx, func() {
cancel()
}
ちなみに IaM 認証ありなのは, dev などの想定で, prod になってくると bff については未認証を許可してあげる必要はありそうです。
ストレージ dockernize
ローカルでの開発効率を上げる点を考えるとローカルに dockernize されたストレージがあると便利です。(volume 消して再作成とかも簡単)
マイグレーションなど加味した最終的な docker-compose.yaml は以下です。
version: "3.7"
services:
postgres:
container_name: postgres
image: postgres:13
restart: always
command: postgres -c log_destination=stderr -c log_statement=all -c log_connections=on -c log_disconnections=on
logging:
options:
max-size: "10k"
max-file: "5"
ports:
- 5000:5432
volumes:
- postgres_data:/var/lib/postgresql/data
- ./postgres/initdb.d:/docker-entrypoint-initdb.d
- ./seeds:/seeds
environment:
- POSTGRES_USER
- POSTGRES_PASSWORD
- POSTGRES_DB
networks:
- backend
wait:
container_name: wait
image: jwilder/dockerize
command: ["dockerize", "-wait", "tcp://postgres:5432", "-timeout", "30s"]
networks:
- backend
pgweb:
container_name: pgweb
restart: always
image: sosedoff/pgweb
ports:
- 8081:8081
environment:
- DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
networks:
- backend
migrate:
build:
context: build/
dockerfile: Dockerfile.migrate
command: ["up", "-env=${ENV}"]
volumes:
- ./migrations:/work/migrations
- ./dbconfig.yml:/work/dbconfig.yml
networks:
- backend
rollback:
build:
context: build/
dockerfile: Dockerfile.migrate
command: ["down", "-limit=1", "-env=${ENV}"]
volumes:
- ./migrations:/work/migrations
- ./dbconfig.yml:/work/dbconfig.yml
networks:
- backend
status:
build:
context: build/
dockerfile: Dockerfile.migrate
command: ["status", "-env=${ENV}"]
volumes:
- ./migrations:/work/migrations
- ./dbconfig.yml:/work/dbconfig.yml
networks:
- backend
generate:
build:
context: build/
dockerfile: Dockerfile.migrate
command: ["new", "-env=${ENV}"]
volumes:
- ./migrations:/work/migrations
- ./dbconfig.yml:/work/dbconfig.yml
networks:
- backend
volumes:
postgres_data:
networks:
backend:
よく使うデータベースは mysql なんですが, mysql の docekrnize で若干苦労するのがログ部分で, postgres については標準出力が簡単なので良いです。
マイグレーションについては, golang で一番スターが多いのはなぜか golang-migrate/migrate ですが, こちらのマイグレーションツールは, 「マイグレーションファイル毎のステータスを見れない。」という明確なデメリットがあります。
rubenv/sql-migrate はその点クリアしているため, こちらを利用してます。
FROM golang:1.14
WORKDIR /work
RUN go get -v github.com/rubenv/sql-migrate/...
ENTRYPOINT [ "sql-migrate" ]
うまくいくとこんな感じになります。
❯ make st
docker-compose -p grpgーdatabases run --rm status
Creating grpgdatabases_status_run ... done
+----------------------------------+--------------------------------------+
| MIGRATION | APPLIED |
+----------------------------------+--------------------------------------+
| 20201114070429-character.sql | 2020-11-14 07:38:06.876282 +0000 UTC |
| 20201114071017-character_log.sql | 2020-11-14 07:38:06.883342 +0000 UTC |
+----------------------------------+--------------------------------------+
データベースのデプロイ
Cloud SQL など利用する場合, マイグレーションするとかになります。今回 GCE に dockernize したものを立てるので, databases ディレクトリを GCE に sync して, GCE に対して docker コマンドを叩かせる。みたいなことでデプロイを実現します。
コピーは gcloud compute scp
から
run: |
gcloud compute scp \
databases appuser@dev-bastion-instance:~/ \
--zone us-west1-a \
--recurse --force-key-file-overwrite
recurse 指定でディレクトリをコピーになります。
コマンド叩くみたいなのは gcloud compute ssh --command
から
run: |
gcloud compute ssh \
appuser@dev-bastion-instance \
--zone us-west1-a \
--command="cd databases; make up && make wait && make migrate;"
めちゃめちゃお手軽です。
インフラ terraform state ファイルを GS 管理する
terraform は state ファイルでコードとパブリッククラウドのリソースを紐付けてます。リモートに state ファイルがあることで分散管理に強くなります。
利用はめちゃめちゃ簡単で, bucket 名を指定するだけです。
terraform {
backend "gcs" {
bucket = "{bucket name}"
prefix = "env/dev"
}
}
インフラコードのモジュール化
環境毎に定義したいのは, 「こういう名前のVPC」「こういう条件のファイアーウォール」...などで, 実際リソースをどう定義するかはモジュールとして定義できます。
ちなみにモジュールについては, GCPが公式に作っているモジュールなど外部定義のモジュールも利用できます。
GCPが公式に使っているモジュールを利用したVPCモジュールは以下みたいな感じです。
module 側
module "vpc" {
source = "terraform-google-modules/network/google"
version = "~> 2.5"
project_id = var.project
network_name = var.env
subnets = [
{
subnet_name = "${var.env}-subnet-01"
subnet_ip = "10.${var.env == "dev" ? 10 : 20}.10.0/24"
subnet_region = "us-west1"
},
]
secondary_ranges = {
"${var.env}-subnet-01" = []
}
}
environments 側
module "vpc" {
source = "../../modules/vpc"
project = var.project
env = local.env
}
module は使い回しがきくのでうまく利用して保守性の高い IaC を心掛けたいですね。
インフラのCI
terraform にはファイルの静的解析を行ってくれる validate というコマンドと, 反映せず内容だけ確認する plan というものがあります。
また, terraform 公式に GitHub Actions を用意してくれているので以下のような記述のみで済みます。
- name: tf validate
uses: hashicorp/terraform-github-actions@master
with:
tf_actions_version: 0.12.29
tf_actions_subcommand: 'validate'
tf_actions_working_dir: 'deployments/environments/dev'
tf_actions_comment: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tf_actions_comment で一点注意が必要なのが, GitHub Actions 上では秘匿してくれている情報も comment として PR に乗ったりしちゃうとそのまま出力されてしまう。という点です。公開 repository で上記を扱う場合注意が必要。
インフラのCD
上記の actions を利用して apply コマンドで実際反映できます。
秘匿したい情報については, 簡易だと Actions 上でシークレットを渡す形で生成できます。
ちゃんとやるなら KMS とか使った方が良いのかもしれません。
- name: Generate tfvars
run: |
cat <<EOF > deployments/environments/dev/terraform.tfvars
project = "${{ secrets.GCP_PROJECT }}"
allow_ip = "${{ secrets.ALLOW_IP }}"
EOF
最後に
サーバーアプリケーション構築の上で必要最低限必要になるモダンな環境というところで極力コストを抑えて用意というところで, 以上となります!いかがでしたでしょうか?
個人的には割と全体的には満足してるところはありつつ, CD周りもうちょっと改善できそうだなぁとか色々と考えてます。
ぜひこれを見た上で, 「こここれに切り替えると良いよ。」とか「このツールも便利。」とかとかあったら教えて欲しいです!
モダンと呼ばれる環境が必ずしもプロダクトとマッチするかというとそうではないですが, キャッチアップを続けて最適なアーキテクトを提供できるようになりたいですね。