1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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

設計

要件

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

全体構成

  • 動作環境
    • 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

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経由で別サーバの関数を呼び出す
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ファイルに追記してくれる
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?