⚓️

Minikubeで構築するマイクロサービスでKubernetesを学ぶ

2023/06/30に公開

Kubernetes 完全ガイドマイクロサービスパターン[実践的システムデザインのためのコード解説]を読み終え、だいぶマイクロサービスのイメージが湧きました!

ただ、まだ実際のプロジェクトレベルでの運用がイメージしきれなかったので最小構成のマイクロサービスをローカル環境に構築してみたのでその備忘録です。

今回の成果物はこちら

https://github.com/JY8752/microservice-demo

想定読者

  • Kubernetes初学者で次に実践的な使い方を学びたい方
  • Goを使ったマイクロサービスの構築に興味がある方

使用環境

  • Minikube v1.30.1
  • kubectl(client) v1.25.9
  • kubectl(server) v1.26.3
  • Helm v3.12.0
  • Go 1.20.4
  • protoc 3.11.4
  • helm-secrets 4.4.2
  • sops 3.7.3
  • age v1.1.1

今回構築するアプリケーション

今回作成するマイクロサービスは以下の構成です。いろいろ省いてますがソーシャルゲームのガチャを引く操作を想定してます。

  • gacha-service ガチャを引きます。アイテムの抽選とガチャを引いた履歴をDBに保存します。
  • item-service アイテムを管理します。今回は抽選したアイテムを記録します。
  • api-gateway 外部からのリクエストをここで受け付けます。

サービス間通信はgRPCで行い、外部との通信はHTTP通信で行います。

Kubernetesクラスターは今回ローカルのみに構築するためMinikubeを使用しています。また、サービスごとのリソースはHelmを使用してチャートを作成しました。

通常マイクロサービスを運用するならばサービスごとにリポジトリを作成することになると思いますが今回は1つのリポジトリにまとめて配置しています。

.
├── Makefile
├── README.md
├── api-gateway
│   ├── Dockerfile
│   ├── Makefile
│   ├── README.md
│   ├── gacha.go
│   ├── go.mod
│   ├── go.sum
│   ├── item.go
│   ├── main.go
│   └── pkg
├── gacha-service
│   ├── Dockerfile
│   ├── Makefile
│   ├── README.md
│   ├── go.mod
│   ├── go.sum
│   ├── item.go
│   ├── main.go
│   ├── pkg
│   └── service.go
├── go.work
├── go.work.sum
├── item-service
│   ├── Dockerfile
│   ├── Makefile
│   ├── README.md
│   ├── go.mod
│   ├── go.sum
│   ├── main.go
│   ├── pkg
│   └── service.go
├── k8s
│   ├── Makefile
│   ├── README.md
│   ├── api-gateway
│   ├── debug.yaml
│   ├── gacha
│   └── item
└── proto
    ├── gacha.proto
    ├── item.proto
    └── rarity.proto

ガチャサービスの実装

Goのモジュールを作成します。

go mod init gacha-service

また、今回はサービス間通信にgRPCを使用するため以下のモジュールをインストールします。

go get -u google.golang.org/grpc
go get -u google.golang.org/grpc/cmd/protoc-gen-go-grpc

protoファイルの作成

ガチャを引く処理とガチャ履歴を取得するrpcのスキーマ定義を以下のようにします。

gacha.proto
syntax = "proto3";

option go_package = "pkg/grpc";

package gacha;

import "proto/rarity.proto";
import "google/protobuf/timestamp.proto";

service GachaService {
  rpc Draw(DrawRequest) returns (DrawResponse) {}
  rpc GetHistories(GetHistoriesRequest) returns (GetHistoriesResponse) {}
}

message DrawRequest {
  int64 user_id = 1;
}

message DrawResponse {
  int64  item_id   = 1;
  string item_name = 2;
  rarity.Rarity rarity    = 3;
}

message GetHistoriesRequest {
  int64 user_id = 1;
}

message History {
  int64  item_id   = 1;
  string item_name = 2;
  rarity.Rarity rarity    = 3;
  google.protobuf.Timestamp created_at = 4;
}

message GetHistoriesResponse {
  repeated History histories = 1;
}
rarity.proto
syntax = "proto3";

option go_package = "pkg/grpc";

package rarity;

enum Rarity {
  RARITY_UNKNOWN = 0;
  RARITY_N       = 1;
  RARITY_R       = 2;
  RARITY_SR      = 3;
  RARITY_SSR     = 4;
}

ファイルが作成できたらprotoc-gen-goを使用して、Goコードを自動生成します。

Makefile
.PHONY: proto
proto:
	protoc --proto_path=../ \
		--go_out=./pkg/grpc --go_opt=paths=source_relative \
		--go-grpc_out=./pkg/grpc --go-grpc_opt=paths=source_relative \
		../proto/*.proto
mkdir -p pkg/grpc
make proto

main処理

とりあえず処理の流れとしては以下のような感じです。

main.go
func main() {
	log.Println("Start Gacha Service")

	dsn, err := getDatasourceName()
	if err != nil {
		log.Fatal(err)
	}

	db, err := connectDb(dsn)
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	createTable(db)

	// grpc server の起動
	listener, err := net.Listen("tcp", ":8080")
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}

	server := grpc.NewServer()

	reflection.Register(server)

	// ガチャの登録
	items := []Item{
		{Id: 1, Name: "item1", Rarity: gachapb.Rarity_RARITY_N, Weight: 40},
		{Id: 2, Name: "item2", Rarity: gachapb.Rarity_RARITY_R, Weight: 30},
		{Id: 3, Name: "item3", Rarity: gachapb.Rarity_RARITY_SR, Weight: 20},
		{Id: 4, Name: "item4", Rarity: gachapb.Rarity_RARITY_SSR, Weight: 10},
	}
	gachapb.RegisterGachaServiceServer(server, NewGachaServiceServer(db, items))

	go func() {
		if err := server.Serve(listener); err != nil {
			log.Fatalf("failed to serve: %v", err)
		}
	}()

	defer Close()

	// Graceful Shutdown
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGTERM, os.Interrupt)
	<-quit
	log.Println("Shutdown Server ...")
	server.GracefulStop()
}

DBには今回MySQLを使用し、接続、テーブル作成などのクエリ操作にはdatabase/sqlモジュールを使用しています。また、gRPCサーバーとして起動するのでインターフェイスの登録と起動処理を書いています。後述しますが仮想ガチャのマスターデータはベタ書きでservice処理に渡しています。

service処理

protoファイルで定義したスキーマをもとに自動生成されたインターフェイスは以下のようになっています。

type GachaServiceServer interface {
	Draw(context.Context, *DrawRequest) (*DrawResponse, error)
	GetHistories(context.Context, *GetHistoriesRequest) (*GetHistoriesResponse, error)
	mustEmbedUnimplementedGachaServiceServer()
}

main関数でこのインターフェイスを登録しているのでこのインターフェイスを満たす実装を作成していきます。

service.go
type gachaServiceServer struct {
	gachapb.UnimplementedGachaServiceServer
	db    *sql.DB
	items []Item
}

func NewGachaServiceServer(db *sql.DB, items []Item) gachapb.GachaServiceServer {
	return &gachaServiceServer{
		db:    db,
		items: items,
	}
}

func (g *gachaServiceServer) Draw(ctx context.Context, req *gachapb.DrawRequest) (*gachapb.DrawResponse, error) {
	// itemsからitemを重み付抽選する
	weights := make([]int, len(g.items))
	for i, item := range g.items {
		weights[i] = item.Weight
	}

	seed := time.Now().UnixNano()
	i := linearSearchLottery(weights, seed)
	item := g.items[i]

	// DBに保存する
	if err := save(ctx, g.db, req.UserId, item); err != nil {
		return nil, err
	}

	// item所持情報も更新する
	res, err := GetItem(ctx, req.UserId, item.Id, item.Name, item.Rarity)
	if err != nil {
		return nil, err
	}

	fmt.Printf("get_item_response: %+v\n", res)

	return &gachapb.DrawResponse{
		ItemId:   item.Id,
		ItemName: item.Name,
		Rarity:   item.Rarity,
	}, nil
}

処理の流れとしては以下のような流れです。

  • 重み付抽選によりアイテムを一つ抽選する。
  • ガチャの履歴をDBに保存する。
  • 抽選したアイテムを後述するitem-serviceのGetItem()を呼び出すことで記録する。

抽選のロジックとDB操作に関しての実装は省略いたしますが、もし全ての実装を確認したい場合は以下をご参照ください。

https://github.com/JY8752/microservice-demo/tree/main/gacha-service

アイテムサービスの実装

アイテムサービスの構成はほとんどガチャサービスの方と同様です。

go mod init item-service

protoファイルの作成

以下のようにスキーマを定義します。

item.proto
package item;

import "proto/rarity.proto";

service ItemService {
    rpc GetItem(GetItemRequest) returns (GetItemResponse) {}
    rpc GetInventory(GetInventoryRequest) returns (GetInventoryResponse) {}
}

message GetItemRequest {
    int64 user_id  = 1;
    int64 item_id  = 2;
    string item_name = 3;
    rarity.Rarity rarity = 4;
}

message GetItemResponse {
    int64 item_id  = 1;
    string item_name = 2;
    rarity.Rarity rarity = 3;
    int32 count = 4;
}

message GetInventoryRequest {
    int64 user_id  = 1;
}

message InventoryItem {
    int64 item_id  = 1;
    string item_name = 2;
    rarity.Rarity rarity = 3;
    int32 count = 4;
}

message GetInventoryResponse {
    repeated InventoryItem items = 1;
}

作成できたらガチャの時と同様Goコードを自動生成します。

mkdir -p pkg/grpc
make proto

service処理

main関数はガチャとほとんど変わらないため省略します。スキーマから自動生成されたインターフェイスは以下のようになっておりこのインターフェイスを満たすよう実装していきます。

type ItemServiceServer interface {
	GetItem(context.Context, *GetItemRequest) (*GetItemResponse, error)
	GetInventory(context.Context, *GetInventoryRequest) (*GetInventoryResponse, error)
	mustEmbedUnimplementedItemServiceServer()
}
service.go
type itemServiceServer struct {
	itempb.UnimplementedItemServiceServer
	db *sql.DB
}

type Inventory struct {
	Id        int64
	UserId    int64
	ItemId    int64
	ItemName  string
	Rarity    string
	Count     int
	CreatedAt time.Time
}

func NewItemServiceServer(db *sql.DB) itempb.ItemServiceServer {
	return &itemServiceServer{
		db: db,
	}
}

func (s *itemServiceServer) GetItem(ctx context.Context, req *itempb.GetItemRequest) (*itempb.GetItemResponse, error) {
	// アイテムを所持しているか確認
	inventry, err := get(req.UserId, req.ItemId, s.db)
	if err = handleError(err); err != nil {
		return nil, err
	}

	// DB更新
	if inventry.Id == 0 {
		// 未所持
		if _, err = insert(req.UserId, req.ItemId, req.ItemName, req.Rarity.String(), s.db); err != nil {
			return nil, err
		}
	} else {
		// 所持済み
		if err = update(req.UserId, req.ItemId, s.db); err != nil {
			return nil, err
		}
	}

	return &itempb.GetItemResponse{
		ItemId:   req.ItemId,
		ItemName: req.ItemName,
		Rarity:   req.Rarity,
		Count:    int32(inventry.Count) + 1,
	}, nil
}

func (i *itemServiceServer) GetInventory(ctx context.Context, req *itempb.GetInventoryRequest) (*itempb.GetInventoryResponse, error) {
	rows, err := getInventries(req.UserId, i.db)
	if err != nil {
		log.Printf("failed to get inventries: %v\n", err)
		return nil, err
	}

	inventories := make([]*itempb.InventoryItem, len(rows))
	for i, inventry := range rows {
		inventories[i] = &itempb.InventoryItem{
			ItemId:   inventry.ItemId,
			ItemName: inventry.ItemName,
			Rarity:   itempb.Rarity(itempb.Rarity_value[inventry.Rarity]),
			Count:    int32(inventry.Count),
		}
	}

	return &itempb.GetInventoryResponse{
		Items: inventories,
	}, nil
}

処理の流れは以下のような感じです

GetItem()

  • 指定のユーザーのアイテム所持情報を取得する。
  • レコードがなければ新規で取得したアイテムなのでインサートする。
  • 既にレコードが存在していれば所持済のアイテムなので所持数をインクリメントする。

GetInventory()

  • 指定のユーザーのアイテム所持情報を取得して返す。

ここで実装したGetItem関数をgacha-serviceの方から呼び出しています。

API-Gatewayの実装

API-Gatewayの実装ではgRPC通信の他に外部とのHTTP通信が必要なため今回はechoを使用します。

go mode init api-gateway

gRPCサーバーとしての実装はありませんがクライアントを使用するため今まで同様Goのコードを自動生成しておきます。

mkdir -p pkg/grpc
make proto

また、今回はechoを使用するのでモジュールのインストールもしておきます。

go get github.com/labstack/echo/v4

main処理

echoのルーテイング設定をして、HTTPサーバーを起動します。

main.go
package main

import (
	"fmt"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {
	fmt.Println("API Gateway Start!!")
	// Echo instance
	e := echo.New()

	// Middleware
	e.Use(middleware.Logger())
	e.Use(middleware.Recover())

	// Routes
	e.POST("/draw", Draw)
	e.GET("/histories/:user_id", GetHistories)
	e.GET("/inventories/:user_id", GetInventories)
	e.GET("/test", func(c echo.Context) error {
		return c.String(200, "test")
	})

	defer func() {
		if itemConn != nil {
			itemConn.Close()
		}
		if gachaConn != nil {
			gachaConn.Close()
		}
	}()

	// Start server
	e.Logger.Fatal(e.Start(":8080"))
}

gRPCクライアントの実装

HTTP通信のリクエストを受信したら、gRPCクライアントを使用して処理を実行します。gRPCクライアントは自動生成されており以下のような実装としました。

gacha.go
package main

import (
	gachapb "api-gateway/pkg/grpc/proto"
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"strconv"

	"github.com/labstack/echo/v4"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
)

var (
	gachaClient gachapb.GachaServiceClient
	gachaConn   *grpc.ClientConn
)

func init() {
	if gachaClient != nil {
		return
	}

	host := os.Getenv("GACHA_SERVICE_HOST")
	if host == "" {
		log.Fatal("GACHA_SERVICE_HOST is not set")
	}

	var err error
	gachaConn, err = grpc.Dial(
		fmt.Sprintf("%s:%s", host, "8080"),
		grpc.WithTransportCredentials(insecure.NewCredentials()),
		grpc.WithBlock(),
	)
	if err != nil {
		panic(err)
	}

	gachaClient = gachapb.NewGachaServiceClient(gachaConn)
}

type DrawRequest struct {
	UserId int64 `json:"user_id"`
}

// /draw
func Draw(c echo.Context) error {
	dr := new(DrawRequest)
	if err := c.Bind(dr); err != nil {
		return err
	}

	req := &gachapb.DrawRequest{
		UserId: dr.UserId,
	}
	res, err := gachaClient.Draw(context.Background(), req)
	if err != nil {
		return err
	}
	return c.JSON(http.StatusOK, res)
}

func GetHistories(c echo.Context) error {
	p := c.Param("user_id")
	userId, err := strconv.ParseInt(p, 10, 64)
	if err != nil {
		return err
	}

	req := &gachapb.GetHistoriesRequest{
		UserId: userId,
	}
	res, err := gachaClient.GetHistories(context.Background(), req)
	if err != nil {
		return err
	}
	return c.JSON(http.StatusOK, res)
}

func CloseGachaConnection() error {
	if gachaConn != nil {
		return gachaConn.Close()
	}
	return nil
}

今回使用しているgRPCのコネクションを確立するためのホストドメインやDB情報などは環境変数として読み込んでいますが、これは後述のKubernetesのマニフェストでコンテナに割り当てられるように設定しています。

item-serviceの実装まで確認したい方は以下をご参照ください。

https://github.com/JY8752/microservice-demo/tree/main/api-gateway

これでアプリケーション側の実装は完了です。

MinikubeでKubernetesクラスターの構築

アプリケーション側の実装が完了したのでKubernetes側の構築をしていきます。今回はローカル環境に構築するのでMinikubeを使用します。

まだ、Minikubeをインストールしていない方はインストールしてください。Macの方はbrewでインストールできます。

Minikubeは複数のdriverをサポートしていますが今回はdockerを使用しています。

brew install minikube

# Minikubeを起動する
minikube start

Helmでチャートを作成する

今回はKubernetesリソースをHelmを使用してサービス単位でチャートを作成しデプロイしていきます。

# install
brew install helm

# create
helm create {gacha|item|api-gateway}
tree .

.
├── Makefile
├── README.md
├── api-gateway
│   ├── Chart.yaml
│   ├── Makefile
│   ├── charts
│   ├── templates
│   │   ├── NOTES.txt
│   │   ├── _helpers.tpl
│   │   ├── deployment.yaml
│   │   ├── hpa.yaml
│   │   ├── ingress.yaml
│   │   ├── service.yaml
│   │   ├── serviceaccount.yaml
│   │   └── tests
│   │       └── test-connection.yaml
│   └── values.yaml
├── debug.yaml
├── gacha
│   ├── Chart.lock
│   ├── Chart.yaml
│   ├── Makefile
│   ├── charts
│   │   └── mysql-9.10.4.tgz
│   ├── keys.txt
│   ├── secrets
│   │   ├── secrets.enc.yaml
│   │   └── secrets.yaml
│   ├── templates
│   │   ├── NOTES.txt
│   │   ├── _helpers.tpl
│   │   ├── deployment.yaml
│   │   ├── hpa.yaml
│   │   ├── ingress.yaml
│   │   ├── service.yaml
│   │   ├── serviceaccount.yaml
│   │   └── tests
│   │       └── test-connection.yaml
│   └── values.yaml
└── item
    ├── Chart.lock
    ├── Chart.yaml
    ├── Makefile
    ├── charts
    │   └── mysql-9.10.4.tgz
    ├── keys.txt
    ├── secrets
    │   ├── secrets.enc.yaml
    │   └── secrets.yaml
    ├── templates
    │   ├── NOTES.txt
    │   ├── _helpers.tpl
    │   ├── deployment.yaml
    │   ├── hpa.yaml
    │   ├── ingress.yaml
    │   ├── service.yaml
    │   ├── serviceaccount.yaml
    │   └── tests
    │       └── test-connection.yaml
    └── values.yaml

templates/deployment.yamlvalues.yamlは以下のようになっています。

deployment.yaml
piVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "api-gateway.fullname" . }}
  labels:
    {{- include "api-gateway.labels" . | nindent 4 }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
  selector:
    matchLabels:
      {{- include "api-gateway.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      {{- with .Values.podAnnotations }}
      annotations:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      labels:
        {{- include "api-gateway.selectorLabels" . | nindent 8 }}
    spec:
      {{- with .Values.imagePullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      serviceAccountName: {{ include "api-gateway.serviceAccountName" . }}
      securityContext:
        {{- toYaml .Values.podSecurityContext | nindent 8 }}
      containers:
        - name: {{ .Chart.Name }}
          securityContext:
            {{- toYaml .Values.securityContext | nindent 12 }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: {{ .Values.service.port }}
              protocol: TCP
          # ヘルスチェックは今回省略
          # livenessProbe:
          #   httpGet:
          #     path: /
          #     port: http
          # readinessProbe:
          #   httpGet:
          #     path: /
          #     port: http
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
      {{- with .Values.nodeSelector }}
      nodeSelector:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.affinity }}
      affinity:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.tolerations }}
      tolerations:
        {{- toYaml . | nindent 8 }}
      {{- end }}
values.yaml
# Default values for test.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.

replicaCount: 1

image:
  repository: nginx
  pullPolicy: IfNotPresent
  # Overrides the image tag whose default is the chart appVersion.
  tag: ""

imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""

serviceAccount:
  # Specifies whether a service account should be created
  create: true
  # Annotations to add to the service account
  annotations: {}
  # The name of the service account to use.
  # If not set and create is true, a name is generated using the fullname template
  name: ""

podAnnotations: {}

podSecurityContext: {}
  # fsGroup: 2000

securityContext: {}
  # capabilities:
  #   drop:
  #   - ALL
  # readOnlyRootFilesystem: true
  # runAsNonRoot: true
  # runAsUser: 1000

service:
  type: ClusterIP
  port: 80

ingress:
  enabled: false
  className: ""
  annotations: {}
    # kubernetes.io/ingress.class: nginx
    # kubernetes.io/tls-acme: "true"
  hosts:
    - host: chart-example.local
      paths:
        - path: /
          pathType: ImplementationSpecific
  tls: []
  #  - secretName: chart-example-tls
  #    hosts:
  #      - chart-example.local

resources: {}
  # We usually recommend not to specify default resources and to leave this as a conscious
  # choice for the user. This also increases chances charts run on environments with little
  # resources, such as Minikube. If you do want to specify resources, uncomment the following
  # lines, adjust them as necessary, and remove the curly braces after 'resources:'.
  # limits:
  #   cpu: 100m
  #   memory: 128Mi
  # requests:
  #   cpu: 100m
  #   memory: 128Mi

autoscaling:
  enabled: false
  minReplicas: 1
  maxReplicas: 100
  targetCPUUtilizationPercentage: 80
  # targetMemoryUtilizationPercentage: 80

nodeSelector: {}

tolerations: []

affinity: {}

MySQLチャートを依存関係に追加する

gacha-serviceとitem-service、それぞれMySQLを使用するので今回はbitnamiのチャートを使用してみました。Chart.yamlに以下のように追加します。

Chart.yaml
apiVersion: v2
name: gacha
description: A Helm chart for Kubernetes
type: application
version: 0.1.0
appVersion: "1.16.0"
+dependencies:
+  - name: "mysql"
+    version: "9.10.4"
+    repository: "https://charts.bitnami.com/bitnami"

追加したら以下のコマンドを実行してインストールします。

# 依存関係解決
helm dependencies build

完了するとcharts配下にMySQLのチャートが配置されているはずです。

アプリケーションのdockerイメージをビルドする

作成したHelmのチャートにはnginxのイメージが指定されているので各マイクロサービスのdockerイメージを指定するように変更していきます。なお、今回はDockerHubのようなコンテナレジストリにpushせずMinikubeが起動しているdocker環境上にイメージをビルドすることでレジストリにpushせずにコンテナを起動させます。

Minikube VM上のdockerを使うように設定する

前述したようにdockerイメージをpushしないとコンテナ起動時にレジストリにイメージを探しに行ってしまうのでMinikube VM上でビルドします。以下のコマンドを実行することでMinikube VM上のdockerを向くように設定できます。

eval $(minikube docker-env)

Dockerfileを作成する

これまでに作成した各マイクロサービスにDockerfileを作成して、イメージをビルドします。

Dockerfile
# syntax=docker/dockerfile:1

##
## Build
##
FROM golang:1.20-buster AS build

WORKDIR /app

COPY go.mod ./
COPY go.sum ./
RUN go mod download

COPY . .

RUN go build -o gacha-service

##
## Deploy
##
FROM debian:12-slim

WORKDIR /

COPY --from=build app/gacha-service .

EXPOSE 8080

ENTRYPOINT ["/gacha-service"] 

Dockerfileが作成できたらビルドします。注意点としては前述したMinikube VM上のdockerに向いている状態でビルドする必要があります。

docker build -t jy8752/gacha-service:1.0.0

values.yamlのイメージを差し替える

ビルドが完了したらvalues.yamlで指定しているnginxのイメージをビルドしたイメージに差し替えます。

values.yaml
...
image:
+  name: gacha
-  repository: nginx
+  repository: jy8752/gacha-service
  pullPolicy: IfNotPresent
-  tag: ""
+  tag: "1.0.0"
...

repositoryの値をビルドしたイメージに差し替えます。また、コンテナレジストリにイメージを探しにいかないようにpullPolicyIfNotPresentにしておいてください。

helm-secretsを使用してMySQLに秘匿情報を渡す

bitnami/mysqlチャートのデフォルト値はvalues.yamlに指定することで上書きすることが可能です。

values.yaml
...
+mysql:
+  auth:
+    database: Gacha
+    username: gacha

mysql.auth.databaseはDB起動時に作成するdatabase名を上書きすることができます。mysql.auth.usernameはroot以外のユーザーを作成します。ここで、mysql.auth.passwordで作成したユーザーのパスワードを上書きすることができますが秘匿情報のためバージョン管理するvalues.yamlにはそのまま書きたくありません

このようなSecretをバージョン管理する方法は様々あるようですが今回はhelm-secretsを使用して、values.yamlを暗号化して、チャート作成時に複合化して使うようにします。

helm-secretsはAWSやGCPのシークレットマネージャを使用することもできますが、今回はGo製のファイル暗号化ツールであるageを使用して暗号化します。

なぜageを使うかに関しては以下の記事を作成しましたので興味がある方はこちらもご参照ください。

https://zenn.dev/jy8752/articles/eaef90b33f9ab3

sopsとageのインストール

helm-secretsは暗号化のバックエンドにsopsvalsを使用することができますが今回はsopsを使用します。

brew install sops

そして、実際に暗号化にはageを使用します。

brew install age

暗号化に使用する鍵を生成する

age-keygen -o key.txt

生成されたkey.txtはバージョン管理されないように.gitignoreに追記してください

valuesファイルを暗号化する

秘匿情報はsecrets.yamlに記載します。

secrets.yaml
mysql:
    auth:
        password: gacha # ここにDBにアクセスする際のパスワードを指定する

このsecrets.yamlも平文で秘匿情報が記載されてしまっているのでバージョン管理には含まれないようにしてください

作成したsecrets.yamlは以下のコマンドを実行して暗号化します。コマンドには先ほど作成したkey.txtに記載の公開鍵を指定します。

sops --encrypt --age age158hhkp5kn6aeauhqkaqf2rqcqvjxan0mkuz30qkcr4g3sf9skpzqkm07ad secrets/secrets.yaml > secrets/secrets.enc.yaml
secrets.enc.yaml
mysql:
    auth:
        password: ENC[AES256_GCM,data:1QxD+x8=,iv:8SNmFSkdBOP2a+4zqJrQzWMdUsEkA0cHsKpOeqB7xNs=,tag:6o1Vz5MCOshuMtlHNUmMog==,type:str]
sops:
    kms: []
    gcp_kms: []
    azure_kv: []
    hc_vault: []
    age:
        - recipient: age158hhkp5kn6aeauhqkaqf2rqcqvjxan0mkuz30qkcr4g3sf9skpzqkm07ad
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBaSFlKMEkwM0RVYjlMVldk
            dTlDek1XMXRVVGs0MEhTaEFlQzFSQzRtYTBjCk5STDE5blBSSlZqY2RaMm94bm4w
            SGRQSU81REZaMy9Fc3lCV0JtYzFaQzQKLS0tIEQwUXhQT3dOK0lYeXQveWVyWFdN
            QjRvSThFS3lEc0dTL3dnRlJPK0JWbUkKmDWgpwEEtT4baIqBQqCX9gBNDOZeoQAb
            Y9Ew3ahiQGx5rKPcZn5TB4pGU2bXUpnSlFb2z8Kt/TnjJw+zygUmkw==
            -----END AGE ENCRYPTED FILE-----
    lastmodified: "2023-06-19T19:28:27Z"
    mac: ENC[AES256_GCM,data:32EPhpXdIQ2poaB9B5AUH3ocXduTbgyfE/3Jc1sxAsHf5LOUJxH52va2WWmSTDDv3JomBOmCEYYBU2r0ZA70/JepIl1mlufI7sauS1VxHPZ7CFNZ45/UujkT85hZa2QSH9mxVN88xFaeoyAtQnd5MBvlgpNRtsujiDoYOl+a5LI=,iv:bHGMVZsqYXCt9pdWQUZL1+WYl4r/0sgRCardFnypnEU=,tag:k+0YtSNoEqZp4WeoxyhWfQ==,type:str]
    pgp: []
    unencrypted_suffix: _unencrypted
    version: 3.7.3

あとは以下のようにhelm secrets ...として暗号化したファイルを指定してinstallすることで内部的にkey.txtを読み取り複合化した上でチャートを作成することができます。

helm secrets upgrade gacha . --install \
	-f values.yaml \
	-f secrets/secrets.enc.yaml

チャートの作成に成功すると上書きしたDBのパスワードはSecretリソースとして作成されてているのでDeploymentテンプレートファイルに環境変数としてSecretの値が割り当てられるように指定することができるようになります。

起動と動作確認

ここまででKubernetesリソースは一通り構築できたので最後に外部からの接続を可能にするためにapi-gatewayチャートのserviceタイプをNodePortに変更します。

api-gateway/values.yaml
...
service:
-  type: ClusterIP
+  type: NodePort
-  port: 80
+  port: 8080
...

各チャートがインストールできていると以下のように3つのマイクロサービスとMySQL2つで計5つのPodが起動しているはずです。

$ kubectl get pods
NAME                                       READY   STATUS    RESTARTS        AGE
api-gateway-59dd9d5657-rpl7l               1/1     Running   1 (45h ago)     45h
gacha-66cf9f6754-kjw2l                     1/1     Running   0               45h
gacha-mysql-0                              1/1     Running   0               45h
item-75859b6957-b4z9d                      1/1     Running   4 (45h ago)     45h
item-mysql-0                               1/1     Running   0               45h

serviceは以下のようになっているはず

$ kubectl get service
NAME                      TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
api-gateway               NodePort    10.106.149.254   <none>        8080:32256/TCP   45h
gacha                     ClusterIP   10.103.104.5     <none>        8080/TCP         45h
gacha-mysql               ClusterIP   10.107.114.35    <none>        3306/TCP         45h
gacha-mysql-headless      ClusterIP   None             <none>        3306/TCP         45h
item                      ClusterIP   10.105.34.196    <none>        8080/TCP         45h
item-mysql                ClusterIP   10.106.181.122   <none>        3306/TCP         45h
item-mysql-headless       ClusterIP   None             <none>        3306/TCP         45h
kubernetes                ClusterIP   10.96.0.1        <none>        443/TCP          12d

MinikubeのdriverをdockerにしていてNodePortを使用する場合、minikube service <Service名>としてアクセス可能なURLを発行する必要がある。

minikube service api-gateway
...

🏃  api-gateway サービス用のトンネルを起動しています。
|-----------|-------------|-------------|------------------------|
| NAMESPACE |    NAME     | TARGET PORT |          URL           |
|-----------|-------------|-------------|------------------------|
| default   | api-gateway |             | http://127.0.0.1:56487 |
|-----------|-------------|-------------|------------------------|

上記のようにアクセスできるURLが発行されたら別のterminalから接続を確認することができるようになっている。

ガチャを引く

curl -XPOST -H 'Content-Type: application/json' -d '{"user_id": 1}' localhost:56487/draw | jq

{
  "item_id": 3,
  "item_name": "item3",
  "rarity": 3
}

ガチャの履歴を確認する

curl localhost:56487/histories/1 | jq

{
  "histories": [
    {
      "item_id": 3,
      "item_name": "item3",
      "rarity": 3,
      "created_at": {
        "seconds": 1687778806
      }
    },
    {
      "item_id": 2,
      "item_name": "item2",
      "rarity": 2,
      "created_at": {
        "seconds": 1687778249
      }
    },
    ...
}

アイテム所持情報を確認する

curl localhost:56487/inventories/1 | jq

{
  "items": [
    {
      "item_id": 1,
      "item_name": "item1",
      "rarity": 1,
      "count": 6
    },
    {
      "item_id": 2,
      "item_name": "item2",
      "rarity": 2,
      "count": 2
    },
    {
      "item_id": 3,
      "item_name": "item3",
      "rarity": 3,
      "count": 2
    },
    {
      "item_id": 4,
      "item_name": "item4",
      "rarity": 4,
      "count": 2
    }
  ]
}

(おまけ)debug用のPodを作成する

MySQLや他のgRPCサーバーへの疎通確認するためにdebug用にPodを別途作成しました。

debug.yaml
apiVersion: v1
kind: Pod
metadata:
  name: debug-pod
spec:
  initContainers:
  - name: install
    image: debian
    command:
    - /bin/bash
    - -c
    - |
      apt update && apt install -y default-mysql-client-core
      apt install -y curl unzip
      VERSION=$(curl -s https://api.github.com/repos/fullstorydev/grpcurl/releases/latest | grep 'tag_name' | cut -d\" -f4)
      DOWNLOAD_URL="https://github.com/fullstorydev/grpcurl/releases/download/$VERSION/grpcurl_${VERSION:1}_linux_x86_64.tar.gz"
      curl -L $DOWNLOAD_URL -o grpcurl.tar.gz
      tar xvf grpcurl.tar.gz grpcurl
      mv grpcurl /usr/local/bin/
      rm -rf grpcurl.tar.gz
    volumeMounts:
    - name: local-bin
      mountPath: /usr/local/bin
  containers:
  - name: app
    image: debian
    command: ["/bin/bash", "-c", "--"]
    args: ["while true; do sleep 30; done;"]
    volumeMounts:
    - name: local-bin
      mountPath: /usr/local/bin
  volumes:
  - name: local-bin
    emptyDir: {}

やりたいこととしてはMySQLクライアントとgrpcurlが入った状態のコンテナを起動したいだけなので本来はそれらをインストールした状態のdockerイメージを作成するのが正解な気がしますが今回はPodの初期化処理で実施しました。

Podの初期化処理にはinitContainersを使用することで別途初期化用のコンテナを起動して実行することが可能です。MySQLクライアントとgrpcurlをインストールするのは別途起動した初期化用のコンテナのためvolumesを作成し、マウントすることで使用できるようにします。

あとは以下のようにしてPodを起動して、コンテナに入ることでマイクロサービスやMySQLにアクセスできるようになります。

kubectl apply debug.yaml
kubectl exec -it debug-pod -- bash

まとめ

思ってたより長くなってしまいましたが今回は以下のことについて紹介しました。

  • Goで作成したマイクロサービス間のgRPC通信について
  • Helmを使用したKubernetesリソースの作成について
  • Minikubeを利用したローカル開発について
  • helm-secretsを使用したKuberneteにおける秘匿情報の扱いについて

今回はローカル環境で構築しましたがAWSやGCPといったクラウドサービスにKubernetes環境を構築したり、KubernetesのCI/CDだったり、skaffoldを利用した開発環境だったりまだまだやろうと思えばやることはたくさんあって本当にKubernetesは奥が深い。。(本当はsagaやサービスメッシュまでやろうとしてたのですが断念しました。)

本記事が何かの参考になれば幸いです。

以上🐼

参考

GoのgRPC実装で大変お世話になっています
https://zenn.dev/hsaki/books/golang-grpc-starting

dockerイメージをpushしないで使いたい
https://qiita.com/ocadaruma/items/efe720e46ae7ecb9ec25

Minikubeのdriverでdockerを選択しているときにNodePortが接続できない
https://info.drobe.co.jp/blog/engineering/minikube-driver#6a2bb7fcc3b9496d9410641b7fddd40a

GitHubで編集を提案

Discussion