Minikubeで構築するマイクロサービスでKubernetesを学ぶ
Kubernetes 完全ガイドとマイクロサービスパターン[実践的システムデザインのためのコード解説]を読み終え、だいぶマイクロサービスのイメージが湧きました!
ただ、まだ実際のプロジェクトレベルでの運用がイメージしきれなかったので最小構成のマイクロサービスをローカル環境に構築してみたのでその備忘録です。
今回の成果物はこちら
想定読者
- 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のスキーマ定義を以下のようにします。
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;
}
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コードを自動生成します。
.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処理
とりあえず処理の流れとしては以下のような感じです。
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関数でこのインターフェイスを登録しているのでこのインターフェイスを満たす実装を作成していきます。
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操作に関しての実装は省略いたしますが、もし全ての実装を確認したい場合は以下をご参照ください。
アイテムサービスの実装
アイテムサービスの構成はほとんどガチャサービスの方と同様です。
go mod init item-service
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()
}
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サーバーを起動します。
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クライアントは自動生成されており以下のような実装としました。
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の実装まで確認したい方は以下をご参照ください。
これでアプリケーション側の実装は完了です。
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.yaml
とvalues.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 }}
# 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
に以下のように追加します。
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を作成して、イメージをビルドします。
# 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 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のイメージをビルドしたイメージに差し替えます。
...
image:
+ name: gacha
- repository: nginx
+ repository: jy8752/gacha-service
pullPolicy: IfNotPresent
- tag: ""
+ tag: "1.0.0"
...
repositoryの値をビルドしたイメージに差し替えます。また、コンテナレジストリにイメージを探しにいかないようにpullPolicy
はIfNotPresent
にしておいてください。
helm-secretsを使用してMySQLに秘匿情報を渡す
bitnami/mysqlチャートのデフォルト値は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を使うかに関しては以下の記事を作成しましたので興味がある方はこちらもご参照ください。
sopsとageのインストール
helm-secretsは暗号化のバックエンドにsopsとvalsを使用することができますが今回はsopsを使用します。
brew install sops
そして、実際に暗号化にはageを使用します。
brew install age
暗号化に使用する鍵を生成する
age-keygen -o key.txt
生成されたkey.txtはバージョン管理されないように.gitignoreに追記してください。
valuesファイルを暗号化する
秘匿情報はsecrets.yaml
に記載します。
mysql:
auth:
password: gacha # ここにDBにアクセスする際のパスワードを指定する
このsecrets.yamlも平文で秘匿情報が記載されてしまっているのでバージョン管理には含まれないようにしてください。
作成したsecrets.yamlは以下のコマンドを実行して暗号化します。コマンドには先ほど作成したkey.txtに記載の公開鍵を指定します。
sops --encrypt --age age158hhkp5kn6aeauhqkaqf2rqcqvjxan0mkuz30qkcr4g3sf9skpzqkm07ad secrets/secrets.yaml > secrets/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に変更します。
...
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を別途作成しました。
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実装で大変お世話になっています
dockerイメージをpushしないで使いたい
Minikubeのdriverでdockerを選択しているときにNodePortが接続できない
Discussion