「組織としてクラウド開発のスピードをいかに上げるか?」、
「エンジニア人数が増えたときにリリース数もスケールできるようにするか?」、
こういった議論はCloud開発当たり前の昨今ますます重要になっています。
このテーマを考えるにあたって 2018 年からのメルカリさんの取り組みをとても参考にしています。
この記事では公開されている mercari-microservices-exampleの
中身を読んで理解して、勝手に解説していきたいとおもいます。
参照
-
https://github.com/mercari/mercari-microservices-example
- 2021/11/11時点 commit hash cc77b35bfc7cbd5aacedbcbba595dadbd44f0a4a を参照する
方針
mercari-microservices-example の Readme.md には 魔法のコマンド
make cluster
を実行すると、以下のことをやってくれると書かれています。
すごいですね、ローカル開発環境が一発で整います。(20分ほどはかかりました。)
- Launch the [Kubernetes](https://kubernetes.io/) cluster on your laptop by using [kind](https://github.com/kubernetes-sigs/kind).
- Install [Istio](https://istio.io/) to the Kubernetes cluster.
- Build Docker images of all microservices placed under the `/services` directory.
- Deploy all microservices to the Kubernetes cluster.
これでも、概要は十分伝わるのですが、もう少しMakefileをのぞいてみることで何をしているのかを詳細に理解したいと思いました。そしてmercari のマイクロサービスexampleとは何かを把握するのには、Makefileの解説をしていくのが一番の近道なのではないかと思いました。
ここからMakefileをガイドに microservice-example 全体の解説していきたいと思います。(それにしても、読みやすいMakefileです。)
使用する 外部コマンド定義
Makefileの最初で、外部ツールのバージョン定義、インストール先の定義をおこなっています。
OS := $(shell go env GOOS)
ARCH := $(shell go env GOARCH)
KUBERNETES_VERSION := 1.21.1
ISTIO_VERSION := 1.11.0
KIND_VERSION := 0.11.1
BUF_VERSION := 0.44.0
PROTOC_GEN_GO_VERSION := 1.27.1
PROTOC_GEN_GO_GRPC_VERSION := 1.1.0
BIN_DIR := $(shell pwd)/bin
KUBECTL := $(abspath $(BIN_DIR)/kubectl)
ISTIOCTL := $(abspath $(BIN_DIR)/istioctl)
KIND := $(abspath $(BIN_DIR)/kind)
BUF := $(abspath $(BIN_DIR)/buf)
PROTOC_GEN_GO := $(abspath $(BIN_DIR)/protoc-gen-go)
PROTOC_GEN_GO_GRPC := $(abspath $(BIN_DIR)/protoc-gen-go-grpc)
PROTOC_GEN_GRPC_GATEWAY := $(abspath $(BIN_DIR)/protoc-gen-grpc-gateway)
外部コマンドとその概要
- Kubernetes,kubectl : container , cluster 管理
- ISTIO,istioctl : k8s の service mesh 管理のための constroller
- kind: localでk8sを立ち上げるためのもの。一括してcluster, controllerをつくったり消したりを軽量に実行できるのがminikubeよりも開発フレームワークとして使いやすい
- buf : protocol buffers のエコシステムツール
- protoc-gen-go : protocol buffers(インタフェース定義言語 で構造を定義する通信や永続化での利用を目的としたシリアライズフォーマット) のgo実装
- protoc-gen-go-grpc: grpc(モダンでパフォーマンスがよいremote procedure call framework)
- protoc-gen-grpc-gateway: protobuf service definitions をよんで RESTful HTTP API into gRPC に変換する reverse-proxy serverを生成する
使用する 外部コマンドのInstall
つぎに定義したVersionで、各ツールのInstallをおこなっています。
環境を汚さないように ./bin/ 以下にインストールしています。
kubectl: $(KUBECTL)
$(KUBECTL):
curl -Lso $(KUBECTL) https://storage.googleapis.com/kubernetes-release/release/v$(KUBERNETES_VERSION)/bin/$(OS)/$(ARCH)/kubectl
chmod +x $(KUBECTL)
istioctl: $(ISTIOCTL)
$(ISTIOCTL):
ifeq ($(OS)-$(ARCH), darwin-amd64)
curl -sSL "https://storage.googleapis.com/istio-release/releases/$(ISTIO_VERSION)/istioctl-$(ISTIO_VERSION)-osx.tar.gz" | tar -C $(BIN_DIR) -xzv istioctl
else ifeq ($(OS)-$(ARCH), darwin-arm64)
curl -sSL "https://storage.googleapis.com/istio-release/releases/$(ISTIO_VERSION)/istioctl-$(ISTIO_VERSION)-osx-arm64.tar.gz" | tar -C $(BIN_DIR) -xzv istioctl
else
curl -sSL "https://storage.googleapis.com/istio-release/releases/$(ISTIO_VERSION)/istioctl-$(ISTIO_VERSION)-$(OS)-$(ARCH).tar.gz" | tar -C $(BIN_DIR) -xzv istioctl
endif
kind: $(KIND)
$(KIND):
curl -Lso $(KIND) https://github.com/kubernetes-sigs/kind/releases/download/v$(KIND_VERSION)/kind-$(OS)-$(ARCH)
chmod +x $(KIND)
buf: $(BUF)
$(BUF):
curl -sSL "https://github.com/bufbuild/buf/releases/download/v${BUF_VERSION}/buf-$(shell uname -s)-$(shell uname -m)" -o $(BUF) && chmod +x $(BUF)
protoc-gen-go: $(PROTOC_GEN_GO)
$(PROTOC_GEN_GO):
curl -sSL https://github.com/protocolbuffers/protobuf-go/releases/download/v$(PROTOC_GEN_GO_VERSION)/protoc-gen-go.v$(PROTOC_GEN_GO_VERSION).$(OS).$(ARCH).tar.gz | tar -C $(BIN_DIR) -xzv protoc-gen-go
protoc-gen-go-grpc: $(PROTOC_GEN_GO_GRPC)
$(PROTOC_GEN_GO_GRPC):
curl -sSL https://github.com/grpc/grpc-go/releases/download/cmd%2Fprotoc-gen-go-grpc%2Fv$(PROTOC_GEN_GO_GRPC_VERSION)/protoc-gen-go-grpc.v$(PROTOC_GEN_GO_GRPC_VERSION).$(OS).$(ARCH).tar.gz | tar -C $(BIN_DIR) -xzv ./protoc-gen-go-grpc
protoc-gen-grpc-gatewayはprotobuf のpluginのため、go build して組み込んでいます
protoc-gen-grpc-gateway: $(PROTOC_GEN_GRPC_GATEWAY)
$(PROTOC_GEN_GRPC_GATEWAY):
cd ./tools && go build -o ../bin/protoc-gen-grpc-gateway github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway
Makefileのメインコマンド (make cluster)
Readme で指示されているメインコマンド make cluaster
です。
.PHONY: cluster
cluster: $(KIND) $(KUBECTL) $(ISTIOCTL)
$(KIND_CMD) delete cluster
$(KIND_CMD) create cluster --image kindest/node:v${KUBERNETES_VERSION} --config ./kind.yaml
./script/istioctl install --set meshConfig.defaultConfig.tracing.zipkin.address=jaeger.jaeger.svc.cluster.local:9411 -y
$(KUBECTL_CMD) apply --filename ./platform/ingress-nginx/ingress-nginx.yaml
$(KUBECTL_CMD) wait \
--namespace ingress-nginx \
--for=condition=ready pod \
--selector=app.kubernetes.io/component=controller \
--timeout=90s
$(KUBECTL_CMD) apply --filename ./platform/kiali/kiali.yaml
sleep 5
$(KUBECTL_CMD) apply --filename ./platform/kiali/dashboard.yaml
$(KUBECTL_CMD) apply --kustomize ./platform/jaeger
make db
make gateway
make authority
make catalog
make customer
make item
この実行のために kind, kubectl, istioctlが必要と定義があるので
cluseter 実行前にそれらのコマンドのインストールが走ります。
コマンドが installされたら以下の順番でclusterが構築されます。
- まず kindを使用して local PC 開発環境上にk8s環境を構築する(以下のkind.yamlにしたがってclusterを生成)
- kind 環境に istio(jaeger) install
- k8s に incress controller nginx をインストール
- k8s に kiali (istio service mesh の可視化用Dashboard ツール) を適用
- k8s に kiali でMonitoringする以下の項目を./platform/kiali/dashboard.yamlにしたがい適用!!(これすごい、監視がexampleですでに組み込まれてる!! 興奮する)
- envoy
- go
- protocol buffer JVM
- Microprofile
- node js
- Quarkus
- Spring boot
- tomcat
- Vert.x
- k8s に jager(transaction のtracing, performance 分析、依存分析のツール ) 適用
- その他コマンドを実行し、microservice exampleへreqeust, respose受け取れるようにする
- make db
- make gateway
- make authority
- make catalog
- make customer
- make item
example db microservice 作成
ここから以下の各Makefileのコマンドでは手順は以下のように共通化されています
- k8s から一度delete
- docker buildをおこない
- kind でcontaime image をload
- k8s にdeployment.yamlをdeploy
.PHONY: db
db:
$(KUBECTL_CMD) delete deploy -n db --ignore-not-found app
docker build -t mercari/mercari-microservices-example/db:latest --file ./platform/db/Dockerfile .
$(KIND) load docker-image mercari/mercari-microservices-example/db:latest --name $(KIND_CLUSTER_NAME)
$(KUBECTL_CMD) apply --filename ./platform/db/deployment.yaml
下記のような interfaceを持つexample用のDBを作成しています。
exampleでは memory 上に map で保存しているだけです。
商用のDBは当然microservieごとに cloud のmanaged DBを使用していると思いますが、
exampleで動作をみせるだけなのでk8s内でDBをも完結するシンプルな形に割り切っています。
type DB interface {
CreateCustomer(ctx context.Context, name string) (*model.Customer, error)
GetCustomer(ctx context.Context, id string) (*model.Customer, error)
GetCustomerByName(ctx context.Context, name string) (*model.Customer, error)
CreateItem(ctx context.Context, item *model.Item) (*model.Item, error)
GetItem(ctx context.Context, id string) (*model.Item, error)
GetAllItems(ctx context.Context) ([]*model.Item, error)
}
func New() DB {
return &db{
customersByID: map[string]*model.Customer{
customerGoldie.ID: customerGoldie,
},
customersByName: map[string]*model.Customer{
customerGoldie.Name: customerGoldie,
},
items: map[string]*model.Item{
"e0e58243-4138-48e5-8aba-448a8888e2ff": {
ID: "e0e58243-4138-48e5-8aba-448a8888e2ff",
CustomerID: customerGoldie.ID,
Title: "Mobile Phone",
Price: 10000,
},
"0b185d96-d6fa-4eaf-97f6-3f6d2c1649b6": {
ID: "0b185d96-d6fa-4eaf-97f6-3f6d2c1649b6",
CustomerID: customerGoldie.ID,
Title: "Laptop",
Price: 20000,
},
},
}
}
共通Architecture: Gateway の実装例 http-grpc の変換
メルカリのマイクロサービス共通アーキテクチャ定義であるGatewayの実装例です。
.PHONY: gateway
gateway:
$(KUBECTL_CMD) delete deploy -n gateway --ignore-not-found app
docker build -t mercari/mercari-microservices-example/gateway:latest --file ./services/gateway/Dockerfile .
$(KIND) load docker-image mercari/mercari-microservices-example/gateway:latest --name $(KIND_CLUSTER_NAME)
$(KUBECTL_CMD) apply --filename ./services/gateway/deployment.yaml
以下のproto定義のなかに post, body とcustom annotationを追加することで
http のreverse proxy , grpcへの変換の定義をおこなっています。
service GatewayService {
rpc Signup(authority.SignupRequest) returns (authority.SignupResponse){
option (google.api.http) = {
post: "/auth/signup"
body: "*"
};
}
rpc Signin(authority.SigninRequest) returns (authority.SigninResponse){
option (google.api.http) = {
post: "/auth/signin"
body: "*"
};
}
rpc CreateItem(catalog.CreateItemRequest) returns (catalog.CreateItemResponse){
option (google.api.http) = {
post: "/catalog/items"
body: "*"
};
}
rpc GetItem(catalog.GetItemRequest) returns (catalog.GetItemResponse){
option (google.api.http) = {
get: "/catalog/items/{id}"
};
}
rpc ListItems(catalog.ListItemsRequest) returns (catalog.ListItemsResponse){
option (google.api.http) = {
get: "/catalog/items"
};
}
}
そしてgatewayからgrpc呼び出し時に行われる共通のauth 処理 呼び出しは下記の箇所で定義されています
func RunServer(ctx context.Context, port int, logger logr.Logger) error {
opts := []grpc.DialOption{
grpc.WithInsecure(),
grpc.WithBlock(),
grpc.WithDefaultCallOptions(grpc.WaitForReady(true)),
}
aconn, err := grpc.DialContext(ctx, "authority.authority.svc.cluster.local:5000", opts...)
if err != nil {
return fmt.Errorf("failed to dial authority grpc server: %w", err)
}
auth のヘッダー定義や、検証も下記に実装があります。
ただし、このリポジトリはexampleが動くことに重点をおこなれていると想定しています。
authorityやgatewayを商用レベルでそのまま使うことを意図されていないと思いますのでご注意ください。
func (s *server) AuthFuncOverride(ctx context.Context, fullMethodName string) (context.Context, error) {
_, ok := publicRPCMethods[fullMethodName]
if ok {
return ctx, nil
}
token, err := auth.AuthFromMD(ctx, "bearer")
if err != nil {
s.log(ctx).Info("failed to get token from authorization header")
return nil, status.Error(codes.Unauthenticated, "unauthenticated")
}
res, err := s.authorityClient.ListPublicKeys(ctx, &authority.ListPublicKeysRequest{})
if err != nil {
s.log(ctx).Error(err, "failed to call authority's ListPublicKeys")
return nil, status.Error(codes.Internal, "failed to authenticate")
}
key, err := jwk.Parse(bytes.NewBufferString(res.Jwks).Bytes())
if err != nil {
s.log(ctx).Error(err, "failed to parse jwks")
return nil, status.Error(codes.Internal, "failed to authenticate")
}
_, err = jwt.Parse([]byte(token), jwt.WithKeySet(key))
if err != nil {
s.log(ctx).Info(fmt.Sprintf("failed to verify token: %s", err.Error()))
return nil, status.Error(codes.Unauthenticated, "unauthenticated")
}
return ctx, nil
}
共通アーキテクチャ: authoriy 認証とToken発行
メルカリのマイクロサービス共通アーキテクチャ定義であるAuthorityのexampleです。
.PHONY: authority
authority:
$(KUBECTL_CMD) delete deploy -n authority --ignore-not-found app
docker build -t mercari/mercari-microservices-example/authority:latest --file ./services/authority/Dockerfile .
$(KIND) load docker-image mercari/mercari-microservices-example/authority:latest --name $(KIND_CLUSTER_NAME)
$(KUBECTL_CMD) apply --filename ./services/authority/deployment.yaml
authority 自体も下記のようなIFをもつmicroserviceとして実装されています。
service AuthorityService {
rpc Signup(SignupRequest) returns (SignupResponse);
rpc Signin(SigninRequest) returns (SigninResponse);
rpc ListPublicKeys(ListPublicKeysRequest) returns (ListPublicKeysResponse);
}
認証部分(id, pass検証)の実装は省略されているようです。
下記のようにcustomer に存在していればそれだけで
Access Tokenを生成して応答を返しています。
Access Token生成も最低限の情報をJWTに設定しているのみにしているようです。
(exampleなのでなるべく色をつけないようにしているのだと思います。)
func (s *server) Signin(ctx context.Context, req *proto.SigninRequest) (*proto.SigninResponse, error) {
res, err := s.customerClient.GetCustomerByName(ctx, &customer.GetCustomerByNameRequest{Name: req.Name})
if err != nil {
s.log(ctx).Info(fmt.Sprintf("failed to authenticate the customer: %s", err))
return nil, status.Error(codes.Unauthenticated, "unauthenticated")
}
token, err := createAccessToken(res.GetCustomer().Id)
if err != nil {
s.log(ctx).Error(err, "failed to create the access token")
return nil, status.Error(codes.Internal, "failed to create access token")
}
return &proto.SigninResponse{
AccessToken: string(token),
}, nil
}
上記の記事を参考にすると、商用のauthorityサービスではOIDCとつないで認証を行い、
外部用Tokenと内部用Tokenの切り替え、認可のマッピング等を行っているのだと思います。
ただこのexampleではとても割り切った実装になっていて、外部用、内部用のTokenは同じものを使っています。
BFF(Backend For Frtontend) のExample実装 catalog
Readmeで以下のように説明があるように gateway と データドメインに特化したmicroservice との中間に位置する、BackendForFrontend(BFF)に位置するのがCatalogです。
- Catalog
- This microservice is responsible for aggragating data from the Customer and the Item microservices to make a API caller easily consume it.
- This microserivce acts like a Backend For Frontend (BFF).
customer, itemの応答をまとめて応答する役割を持ちます。
.PHONY: catalog
catalog:
$(KUBECTL_CMD) delete deploy -n catalog --ignore-not-found app
docker build -t mercari/mercari-microservices-example/catalog:latest --file ./services/catalog/Dockerfile .
$(KIND) load docker-image mercari/mercari-microservices-example/catalog:latest --name $(KIND_CLUSTER_NAME)
$(KUBECTL_CMD) apply --filename ./services/catalog/deployment.yaml
以下のように ある一つのmicroservice: item の応答をするときに 別のmicroservice: client にも通信を行い、2つのresponseを集約してもとのリクエストへ応答しています。
func (s *server) GetItem(ctx context.Context, req *proto.GetItemRequest) (*proto.GetItemResponse, error) {
ires, err := s.itemClient.GetItem(ctx, &item.GetItemRequest{Id: req.Id})
if err != nil {
st, ok := status.FromError(err)
if ok && st.Code() == codes.NotFound {
return nil, status.Error(codes.NotFound, "not found")
}
return nil, status.Error(codes.Internal, "internal error")
}
i := ires.GetItem()
if i == nil {
return nil, status.Error(codes.Internal, "internal error")
}
cres, err := s.customerClient.GetCustomer(ctx, &customer.GetCustomerRequest{Id: i.CustomerId})
if err != nil {
st, ok := status.FromError(err)
if ok && st.Code() == codes.NotFound {
return nil, status.Error(codes.NotFound, "not found")
}
return nil, status.Error(codes.Internal, "internal error")
}
c := cres.GetCustomer()
if c == nil {
return nil, status.Error(codes.Internal, "internal error")
}
return &proto.GetItemResponse{
Item: &proto.Item{
Id: i.Id,
CustomerId: i.CustomerId,
CustomerName: c.Name,
Title: i.Title,
Price: int64(i.Price),
},
}, nil
}
この例だけだと BFFのありがたさがまだ分かりませんが、clientがweb, mobile, 組み込み機器 等で別れている時に達成したいuser experience ごとにAPIの要件は変わってくることはよくあり、その要件の違いを吸収する役割を持たせることができるのがBFFです。
BFFはそれ自体が大きなテーマになりますので詳細は別の記事にします。
microservice service 実例としてのcatalog, customer, item
db で定義された model customer, itemそれぞれを管理するmicroserviceの例です。
.PHONY: customer
customer:
$(KUBECTL_CMD) delete deploy -n customer --ignore-not-found app
docker build -t mercari/mercari-microservices-example/customer:latest --file ./services/customer/Dockerfile .
$(KIND) load docker-image mercari/mercari-microservices-example/customer:latest --name $(KIND_CLUSTER_NAME)
$(KUBECTL_CMD) apply --filename ./services/customer/deployment.yaml
.PHONY: item
item:
$(KUBECTL_CMD) delete deploy -n item --ignore-not-found app
docker build -t mercari/mercari-microservices-example/item:latest --file ./services/item/Dockerfile .
$(KIND) load docker-image mercari/mercari-microservices-example/item:latest --name $(KIND_CLUSTER_NAME)
$(KUBECTL_CMD) apply --filename ./services/item/deployment.yaml
exampleではシンプルにcustomer, itemの情報をdb microserviceに読み書きするのみです。
まとめ
mercari-microservices-example の Makefileで行われていることから順に読み解いていくことで、このmicroservice-exampleが行っていることは非常によく理解できました。
一つの疑問として残るのはこれはexampleと名付けられているが、どこまで本気でつくられたものなのか? どのような位置づけでメルカリ内で使用されているのかというところです。
auth , db も動作するうえで最低限のものにしぼられていて、商用では使えるものではないのであくまで講演での説明用、 新人研修用なのかと想定します。 tag もhands-onと書かれているものが多いですし。
しかし、commit 履歴をみるととてもよくメンテナンスされていますね。
https://github.com/mercari/mercari-microservices-example/commits/main
ここからは私の想像ですが、このexampleをもとにメルカリの各エンジニアもmicroserviceの全体アーキテクチャ、動作原理確認をLocalで行っているのではないでしょうか。 そしてその原理確認したものを商用設計に移行するところで、商用認証の仕組みへの、DB設計をおこなうという本格設計にはいるのではないかと想像しています。 microserviceの設計原理に関わる k8sの監視/Deploy, grpc, authorityとの連携の仕方はこのrepositoryがあることで作ってみて、動かしてみて、非常によく分かりますし理解も早くなります。
このようなシンプルで少ない行数で、かつ技術的な内容の濃いexampleをつくれる
mercariのみなさんに心から敬意を表します。公開していただきありがとうございます。