Go×gRPC×Kubernetesでマイクロサービス開発！Colima+K3sで環境構築

## はじめに

go言語とgrpc を 使って 何かサービスチックなものを作って マイクロアーキテクチャを実践したいと思い立ったので 開発してみました🎉


##  設計

### 要件

:::note info
ホテル情報の管理（名前、所在地、設備、価格、空室数）
予約管理（ユーザーがホテルを予約できる）
:::

### 全体構成

- 動作環境
  - go grpc server / client / database
      -  `Local Mac` 上で `Colima(k3sをローカルで動かす 軽量なツール)` を使って動かしている
  - Imageの取得
    - 各 `Pod` の Image は `ECR` から取得します。その際の認証情報は `secret` に保存し `container` へマウントしている
  - アプリケーションのインターフェース
    -  `Pub/Sub` となっている。メッセージが入ると grpc client である `hotel-reserve-client` が動作し一連の処理が開始される
    - `GCP Pub/Sub` へのアクセスも `ECR` 同様に `secret` に保存し マウントしています。
![billsHotell.drawio (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2737245/22a2ce5c-ef4b-43c6-9b07-a3850da55bce.png)


### APIエンドポイント設計

ホテル情報管理 grpc server

| rpc | 説明 |
| --- | --- |
| GetHotel | IDを指定しホテル情報を取得する |
| UpdateHotel | IDを指定しホテル情報を更新する |
| CreateHotel | 新しくホテル情報を登録する |

ホテル予約 grpc client

pub/sub へメッセージ送信後、 JSON を解析し db へ `予約情報の登録` 及び ホテル管理 server の空室数を更新する

pub/subへ送信するメッセージ 

```bash
"{\"id\": 0, \"is_cancel\": false, \"hotel_id\": 0, \"user_id\": 12345, \"reserved_datetime\": 1700000000, \"checkin_datetime\": 1700604800}"
```



## DBの構築

https://github.com/GitEngHar/MyK8sResources/tree/master/BillsHotel

### 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

```yaml
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

環境変数

```yaml
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

```yaml
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) の実装

https://github.com/GitEngHar/billsHotelService

### protoc の環境を構築する

```yaml
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を実行する

```yaml
protoc --go_out=. --go-grpc_out=. proto/hotel.proto
```

### hotel.proto

```protobuf
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

```protobuf
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)

https://github.com/GitEngHar/reserveBillsHotelService

Pub/Subと連携しデータが入っていれば 内容に合わせて予約を行うシステム

認証部分は構築しないため User 情報はMockとする

hotel.prototは同じものを利用するので割愛

### grpc_client.go

```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環境でデプロイして動作確認

https://github.com/GitEngHar/MyK8sResources/tree/master/BillsHotel

### AWS ECR から Imageを取得する設定

Nodeで aws ecr から pullできるようにする設定 (シークレットを作成する)

```go
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 リソースへ登録)

```go
      imagePullSecrets:
        - name: ecr-secret
```

### GCP Pub/Sub を利用するための SA 作成と key発行と設定

SAを作る

```bash
gcloud iam service-accounts create <YOUR_SA_NAME> \
    --description="Kubernetes Pod 用の SA" \
    --display-name="<YOUR_SA_NAME>"
```

権限を付与

```bash
gcloud projects add-iam-policy-binding <PROJECT_ID> \
    --member="serviceAccount:<YOUR_SA_NAME>@<PROJECT_ID>.iam.gserviceaccount.com" \
    --role="roles/pubsub.publisher"

```

keyを発行

```bash
gcloud iam service-accounts keys create key.json \
    --iam-account=<YOUR_SA_NAME>@<PROJECT_ID>.iam.gserviceaccount.com

```

secretへ登録

```bash
kubectl create secret generic gcp-key --from-file=key.json
```

deploymentへマウント

```yaml
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

```

### デプロイ

:::note info
デプロイは以下のディレクトリ階層で実施します
https://github.com/GitEngHar/MyK8sResources/tree/master/BillsHotel
:::

colimaを起動
```go
colima start && colima start --network-address
```

リソースを配置する

:::note warn
前提作業
AWS ECR から Imageを取得する設定
GCP Pub/Sub を利用するための SA 作成と key発行と設定
:::

```go
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 共に動いている

```bash
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 して実行)

```bash
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 にメッセージを送信する

```bash
❯ 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

```bash
❯ 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

```bash
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経由で別サーバの関数を呼び出す

```protobuf
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 で使われる
        - ゴルーチンと言われる単位でリクエストを処理することができる
            - 具体的には キャンセルやタイムアウト処理とか
- JavaThreadと比べる ゴルーチンの強さ
    - Threadと比べて並列実行がすごく軽量
    - 扱いやすい。ctx doneでクローズできて 処理の長いゴルーチンを簡単にクローズできる
- goのモジュール追加方法
    - `go get モジュール名` 勝手にmodファイルに追記してくれる