はじめに
go言語とgrpc を 使って 何かサービスチックなものを作って マイクロアーキテクチャを実践したいと思い立ったので 開発してみました🎉
設計
要件
ホテル情報の管理(名前、所在地、設備、価格、空室数)
予約管理(ユーザーがホテルを予約できる)
全体構成
- 動作環境
- go grpc server / client / database
-
Local Mac
上でColima(k3sをローカルで動かす 軽量なツール)
を使って動かしている
-
- Imageの取得
- 各
Pod
の Image はECR
から取得します。その際の認証情報はsecret
に保存しcontainer
へマウントしている
- 各
- アプリケーションのインターフェース
- go grpc server / client / database
APIエンドポイント設計
ホテル情報管理 grpc server
rpc | 説明 |
---|---|
GetHotel | IDを指定しホテル情報を取得する |
UpdateHotel | IDを指定しホテル情報を更新する |
CreateHotel | 新しくホテル情報を登録する |
ホテル予約 grpc client
pub/sub へメッセージ送信後、 JSON を解析し db へ 予約情報の登録
及び ホテル管理 server の空室数を更新する
pub/subへ送信するメッセージ
"{\"id\": 0, \"is_cancel\": false, \"hotel_id\": 0, \"user_id\": 12345, \"reserved_datetime\": 1700000000, \"checkin_datetime\": 1700604800}"
DBの構築
reserve_hotel (ホテル予約管理 table)
カラム | 型 |
---|---|
id | int primary key |
name | VARCHAR(255) |
price_pernight | int |
rooms_available | int |
reserve_hotel (ホテル予約管理 table)
カラム | 型 |
---|---|
id | int primary key |
iscancel | bool |
hotelid | int (hotelsのidと紐づく) |
userid | int |
reserved_datetime | int |
checkin_datetime | int |
K3s Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: hotel-db-deployment
labels:
app: hotel-db
spec:
replicas: 1
selector:
matchLabels:
app: hotel-db
template:
metadata:
labels:
app: hotel-db
spec:
containers:
- name: hotel-db
image: mysql
ports:
- containerPort: 3306
env:
# 環境変数を定義します。
- name: MYSQL_DATABASE # ここではConfigMap内のキーの名前とは違い大文字
# 大文字が使われていることに着目してください。
valueFrom:
configMapKeyRef:
name: hotel-cf # この値を取得するConfigMap。
key: mysql_database # 取得するキー。
- name: MYSQL_USER
valueFrom:
configMapKeyRef:
name: hotel-cf
key: mysql_user
- name: MYSQL_PASSWORD
valueFrom:
configMapKeyRef:
name: hotel-cf
key: mysql_password
- name: MYSQL_ROOT_PASSWORD
valueFrom:
configMapKeyRef:
name: hotel-cf
key: mysql_root_password
volumeMounts:
- name: init-sql
mountPath: "/docker-entrypoint-initdb.d/init.sql"
subPath: init.sql
readOnly: true
volumes:
- name: init-sql
configMap:
name: hotel-init-sql-cf
---
apiVersion: v1
kind: Service
metadata:
name: hotel-db
spec:
selector:
app: hotel-db
ports:
- protocol: TCP
port: 3306
targetPort: 3306
Configmap
環境変数
apiVersion: v1
kind: ConfigMap
metadata:
name: hotel-cf
data:
# プロパティーに似たキー。各キーは単純な値にマッピングされている
# MYSQL_DATABASE
mysql_database: "hotel_db"
# MYSQL_USER
mysql_user: "user"
# MYSQL_PASSWORD
mysql_password: "password"
# MYSQL_ROOT_PASSWORD
mysql_root_password: "password"
ファイルとして利用 init.sql
apiVersion: v1
kind: ConfigMap
metadata:
name: hotel-init-sql-cf
data:
init.sql: |
CREATE TABLE IF NOT EXISTS hotels
(id INT PRIMARY KEY, name VARCHAR(255), price_pernight INT, rooms_available INT);
CREATE TABLE IF NOT EXISTS reserve_hotel
( id INT PRIMARY KEY,
iscancel BOOL,
hotelid INT,
userid INT,
reserved_unix_datetime int,
checkin_unix_datetime int,
foreign key hotel_id_foreign_key (hotelid) references hotels (id)
);
ホテル情報 管理 Server (grpcServer) の実装
protoc の環境を構築する
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
protoファイルを用意して protoc commandを実行する
protoc --go_out=. --go-grpc_out=. proto/hotel.proto
hotel.proto
syntax = "proto3";
package hotel;
option go_package = "billsHotelService/proto/hotel";
service HotelService{
rpc GetHotel (HotelRequest) returns (HotelResponse);
rpc CreateHotel (HotelRequest) returns (HotelResponse);
}
message HotelRequest{
int32 id = 1;
string name = 2;
int32 price_pernight = 3;
int32 rooms_available = 4;
}
message Hotel{
int32 id = 1;
string name = 2;
int32 price_pernight = 3;
int32 rooms_available = 4;
}
message HotelResponse{
Hotel hotel = 1;//TODO: 設定内容を詳細にする
}
grpc_server.go
package main
import (
"billsHotelService/domain/entity"
domainrep "billsHotelService/domain/repository"
"billsHotelService/infrastructure/database"
"billsHotelService/proto"
"billsHotelService/server/repository"
"context"
"google.golang.org/grpc"
"log"
"net"
)
type HotelServiceServer struct {
proto.UnimplementedHotelServiceServer //grpc で構築していないメソッドの実装を許容する
repo domainrep.HotelRepository
}
func NewHotelServiceServer(repo domainrep.HotelRepository) *HotelServiceServer {
return &HotelServiceServer{repo: repo}
}
func (s *HotelServiceServer) GetHotel(ctx context.Context, req *proto.HotelRequest) (*proto.HotelResponse, error) {
hotel, err := s.repo.HotelGetById(int(req.GetId()))
if err != nil {
return nil, err
}
return &proto.HotelResponse{
Hotel: &proto.Hotel{
Id: int32(hotel.ID),
Name: hotel.Name,
PricePernight: int32(hotel.PricePerNight),
RoomsAvailable: int32(hotel.RoomsAvailable),
},
}, nil
}
func (s *HotelServiceServer) CreateHotel(ctx context.Context, req *proto.HotelRequest) (*proto.HotelResponse, error) {
newHotel := entity.NewHotel(int(req.GetId()), req.GetName(), int(req.GetPricePernight()), int(req.GetRoomsAvailable()))
err := s.repo.HotelSave(*newHotel)
if err != nil {
return nil, err
}
return &proto.HotelResponse{
Hotel: &proto.Hotel{
Id: int32(newHotel.ID),
Name: newHotel.Name,
PricePernight: int32(newHotel.PricePerNight),
RoomsAvailable: int32(newHotel.RoomsAvailable),
},
}, nil
}
func (s *HotelServiceServer) UpdateHotel(ctx context.Context, req *proto.HotelRequest) (*proto.HotelResponse, error) {
newHotel := entity.NewHotel(int(req.GetId()), req.GetName(), int(req.GetPricePernight()), int(req.GetRoomsAvailable()))
err := s.repo.HotelUpdateById(*newHotel)
if err != nil {
return nil, err
}
return &proto.HotelResponse{
Hotel: &proto.Hotel{
Id: int32(newHotel.ID),
Name: newHotel.Name,
PricePernight: int32(newHotel.PricePerNight),
RoomsAvailable: int32(newHotel.RoomsAvailable),
},
}, nil
}
func main() {
db, err := database.NewMySQL()
if err != nil {
log.Fatalf("Failed to connect to DB: %v", err) //ログの出力
}
defer db.Close()
repo := repository.NewMySQLHotelRepository(db)
server := grpc.NewServer()
proto.RegisterHotelServiceServer(server, NewHotelServiceServer(repo))
listener, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
log.Println("gRPC server is running on port 50051...")
err = server.Serve(listener)
if err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}
ホテル予約API(grpc client)
Pub/Subと連携しデータが入っていれば 内容に合わせて予約を行うシステム
認証部分は構築しないため User 情報はMockとする
hotel.prototは同じものを利用するので割愛
grpc_client.go
package main
import (
"cloud.google.com/go/pubsub"
"context"
"encoding/json"
"fmt"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"log"
"reserveBillsHotelService/client/repository"
"reserveBillsHotelService/domain/entity"
domainrep "reserveBillsHotelService/domain/repository"
"reserveBillsHotelService/infrastructure/database"
"reserveBillsHotelService/infrastructure/message"
pb "reserveBillsHotelService/proto"
"reserveBillsHotelService/usecase"
"strconv"
"time"
)
type Subscriber struct {
sub *pubsub.Subscription
client pb.HotelServiceClient
repository domainrep.ReserveHotelRepository
}
/*
*
handlerの実装(受け取ったメッセージに応じて処理を行う)
*/
func (s *Subscriber) Receive(ctx context.Context, handler func(context.Context, *pubsub.Message) error) {
err := s.sub.Receive(ctx, func(ctx context.Context, msg *pubsub.Message) {
fmt.Printf("📩 受信したメッセージ: %s\n", string(msg.Data))
defer msg.Ack() //関数が終了した時にmsgをackする
err := handler(ctx, msg)
if err != nil {
log.Fatalf("failed message process %v", err)
}
})
if err != nil {
log.Fatalf("failed message revieve%v", err)
}
}
func (s *Subscriber) grpcHandler(ctx context.Context, msg *pubsub.Message) error {
//ホテル一覧の取得
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
//msgを解析してNewHotelへ代入する
var hotelReserve *entity.HotelReserve
err := json.Unmarshal(msg.Data, &hotelReserve)
if err != nil {
msg.Nack()
log.Fatalf("JSON の解析に失敗しました:%v", err)
return err
}
//一旦MockでNewHotelを生成する
//hotelReserve := entity.NewHotelReserve(0, false, 0, 1000, time.Now().Unix(), time.Now().Unix())
//Hotel情報を取得する
resp, err := s.client.GetHotel(ctx, &pb.HotelRequest{Id: int32(hotelReserve.HotelID)})
if err != nil {
msg.Nack()
log.Fatalf("Server Error : %v", err)
}
rsvHotel := resp.GetHotel()
hotel := entity.NewHotel(int(rsvHotel.Id), rsvHotel.Name, int(rsvHotel.PricePernight), int(rsvHotel.RoomsAvailable))
fmt.Println("GetHotel 🏨 ID: ", hotel.ID)
hotel = usecase.Reserve(hotel, hotelReserve, s.repository)
if hotel != nil {
//ホテルの予約ができた場合空室を-1にして返す
resp, err = s.client.UpdateHotel(ctx, &pb.HotelRequest{
Id: int32(hotel.ID),
Name: hotel.Name,
PricePernight: int32(hotel.PricePerNight),
RoomsAvailable: int32(hotel.RoomsAvailable),
})
if err != nil {
msg.Nack()
log.Fatalf("Server Error : %v", err)
return err
}
fmt.Printf("UserID:%v のお客様の予約が完了しました🎉\n", strconv.Itoa(hotelReserve.UserID))
} else {
//ホテルの予約ができない場合nilにして返す
log.Fatalln("満室で予約ができませんでした😭")
}
return nil
}
/*
*
clientサーバを立ち上げる
*/
func main() {
ctx := context.Background()
// hotel-server-service
connection, err := grpc.NewClient("hotel-server-service:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("did not : %v", err)
}
defer connection.Close()
// db実体化
db, _ := database.NewMySQL()
// 予約MYSQLの実行
reserveHotel := repository.NewHotelReserveRepository(db)
// Pub/Sub クライアント作成
pubSubClient, err := message.NewPubSubClient(ctx)
if err != nil {
log.Fatalf("Client failed: %v", err)
}
defer pubSubClient.Close()
// サブスクリプション取得とハンドリング
sub := pubSubClient.Subscription("reserve-sub-dev")
subscriber := &Subscriber{sub: sub, client: pb.NewHotelServiceClient(connection), repository: reserveHotel}
subscriber.Receive(ctx, subscriber.grpcHandler)
}
K3s環境でデプロイして動作確認
AWS ECR から Imageを取得する設定
Nodeで aws ecr から pullできるようにする設定 (シークレットを作成する)
aws ecr get-login-password --region ap-northeast-1 | kubectl create secret docker-registry ecr-secret \
--docker-server=AWS-ACCOUNT-ID.dkr.ecr.ap-northeast-1.amazonaws.com \
--docker-username=AWS \
--docker-password=$(aws ecr get-login-password --region ap-northeast-1)
image pull secret を登録する (deployment リソースへ登録)
imagePullSecrets:
- name: ecr-secret
GCP Pub/Sub を利用するための SA 作成と key発行と設定
SAを作る
gcloud iam service-accounts create <YOUR_SA_NAME> \
--description="Kubernetes Pod 用の SA" \
--display-name="<YOUR_SA_NAME>"
権限を付与
gcloud projects add-iam-policy-binding <PROJECT_ID> \
--member="serviceAccount:<YOUR_SA_NAME>@<PROJECT_ID>.iam.gserviceaccount.com" \
--role="roles/pubsub.publisher"
keyを発行
gcloud iam service-accounts keys create key.json \
--iam-account=<YOUR_SA_NAME>@<PROJECT_ID>.iam.gserviceaccount.com
secretへ登録
kubectl create secret generic gcp-key --from-file=key.json
deploymentへマウント
env:
- name: GOOGLE_APPLICATION_CREDENTIALS
value: "/secrets/key.json"
volumeMounts:
- name: gcp-secret
mountPath: "/secrets"
readOnly: true
volumes:
- name: gcp-secret
secret:
secretName: gcp-key
デプロイ
デプロイは以下のディレクトリ階層で実施します
https://github.com/GitEngHar/MyK8sResources/tree/master/BillsHotel
colimaを起動
colima start && colima start --network-address
リソースを配置する
前提作業
AWS ECR から Imageを取得する設定
GCP Pub/Sub を利用するための SA 作成と key発行と設定
k apply -f ./hotel-cf.yml
k apply -f ./hotel-init-sql-cf.yml
k apply -f ./hotel-db.yml
k apply -f ./hotel-server.yml
k apply -f ./hotel-reserve-client.yml
動作確認
Podの起動確認 (k は kubectl)
db server client 共に動いている
k get po
NAME READY STATUS RESTARTS AGE
hotel-db-deployment-7db9bc5b6d-m668v 1/1 Running 0 122m
hotel-reserve-client-deployment-58fd484954-wddvk 1/1 Running 0 95s
hotel-server-deployment-8459f5f8d-b6dld 1/1 Running 0 6m35s
検証用に mysqlへinsert (pod へ exec して実行)
mysql> INSERT INTO hotels VALUES(0,"myhotel",10000,100)
-> ;
Query OK, 1 row affected (0.01 sec)
## 一応確認
mysql> select * from hotels;
+----+---------+----------------+-----------------+
| id | name | price_pernight | rooms_available |
+----+---------+----------------+-----------------+
| 0 | myhotel | 10000 | 100 |
+----+---------+----------------+-----------------+
pubsub にメッセージを送信する
❯ gcloud pubsub topics publish reserve --message "{\"id\": 0, \"is_cancel\": false, \"hotel_id\": 0, \"user_id\": 12345, \"reserved_datetime\": 1700000000, \"checkin_datetime\": 1700604800}"
messageIds:
- '14081426287797800'
結果 pod log
❯ k logs hotel-reserve-client-deployment-58fd484954-wddvk
✅ MySQL 接続成功!
📩 受信したメッセージ: {"id": 0, "is_cancel": false, "hotel_id": 0, "user_id": 12345, "reserved_datetime": 1700000000, "checkin_datetime": 1700604800}
GetHotel 🏨 ID: 0
UserID:12345 のお客様の予約が完了しました🎉
結果 db
mysql> select * from hotels;
+----+---------+----------------+-----------------+
| id | name | price_pernight | rooms_available |
+----+---------+----------------+-----------------+
| 0 | myhotel | 10000 | 99 |
+----+---------+----------------+-----------------+
1 row in set (0.00 sec)
mysql> select * from reserve_hotel;
+----+----------+---------+--------+------------------------+-----------------------+
| id | iscancel | hotelid | userid | reserved_unix_datetime | checkin_unix_datetime |
+----+----------+---------+--------+------------------------+-----------------------+
| 0 | 0 | 0 | 12345 | 1700000000 | 1700604800 |
+----+----------+---------+--------+------------------------+-----------------------+
1 row in set (0.00 sec)
補足 : gRPCの振る舞いについて
サンプルコード
- フィールド番号
- json形式のようなデータで どの位置に対象データのフィールドが来るのかを記述する(一意である必要がある)
- service
- クライアントとサーバのRPC(リモートプロージャコール)を設定
- RPC : NW経由で別サーバの関数を呼び出す
- クライアントとサーバのRPC(リモートプロージャコール)を設定
syntax = "proto3";
package hotel;
option go_package = "billsHotelService/proto/hotel";
service HotelService{
rpc GetHotel (HotelRequest) returns (HotelResponse);
rpc CreateHotel (HotelRequest) returns (HotelResponse);
}
message HotelRequest{
int32 id = 1(フィールド番号);
string name = 2(フィールド番号);
int32 price_pernight = 3(フィールド番号);
int32 rooms_available = 4(フィールド番号);
}
message Hotel{
int32 id = 1(フィールド番号);
string name = 2(フィールド番号);
int32 price_pernight = 3(フィールド番号);
int32 rooms_available = 4(フィールド番号);
}
message HotelResponse{
Hotel hotel = 1(フィールド番号);
}
GOで知らなかったことを整理する
-
ctx context.Context
- 概要 : GOにおけるコンテキスト管理
- gRPC / DB / HTTP REQUEST で使われる
- ゴルーチンと言われる単位でリクエストを処理することができる
- 具体的には キャンセルやタイムアウト処理とか
- 概要 : GOにおけるコンテキスト管理
- JavaThreadと比べる ゴルーチンの強さ
- Threadと比べて並列実行がすごく軽量
- 扱いやすい。ctx doneでクローズできて 処理の長いゴルーチンを簡単にクローズできる
- goのモジュール追加方法
-
go get モジュール名
勝手にmodファイルに追記してくれる
-