プログラマのための IT 教科書

通しで作る: gRPC・Kubernetes・Spanner

この部の 2 / 2 章 ・ 全体で 76 / 76 章 ・ 読了目安 90 分

この章を読むとできるようになること
  • 1つの .proto から Go と TypeScript のコードを生成して繋げられる
  • Spanner にデータを保存し、本番との差を説明できる
  • kind 上のクラスタに載せ、どこで壊れたかを切り分けられる

この教科書の使い方で、最終的なゴールをこう書きました。

TypeScript と Go でマイクロサービスを書き、gRPC で通信させ、 Kubernetes にデプロイして、Spanner にデータを保存できるようになること。

前の章のハンズオンは、Go 1本・HTTP・メモリ保存で止まっていました。 この章で、宣言したところまで到達します。

ブラウザ / curl
   │ HTTP
   ▼
[ BFF ]         TypeScript。HTTP で受けて gRPC に変換する
   │ gRPC
   ▼
[ link ]        Go。短縮リンクの業務ロジック
   │ Spanner API
   ▼
[ Spanner ]     手元はエミュレータ。本番は Cloud Spanner

これを Kubernetes の上で動かし、スキーマは Terraform で作ります。

先に環境を確認してください

この章は手元の環境に依存します。揃っていない状態で進めると、途中で必ず詰まります。

asdf か mise を使っているなら、1コマンドで揃います。

git clone https://github.com/makoto-developer/learning-it-skills-examples
cd learning-it-skills-examples/microservices
mise install      # asdf なら asdf install

.tool-versions を置いてあるので、Go・Node・pnpm・Terraform・kind・kubectl・buf が このディレクトリの中だけ指定のバージョンに切り替わります。他のプロジェクトには影響しません。

切り替わっているかは、こう確認できます。

mise ls --current    # 何がどのバージョンで解決されているか
node --version       # 24 系になっていれば有効

シェルで有効にしてあれば(mise activate を .zshrc などに入れてあれば)、 make の中で動くコマンドまで同じバージョンに揃います。

使っていない場合は、次を手で入れてください。

docker version     # 29 系で確認。Desktop / Colima / OrbStack どれでもよい
kind version       # 0.32 で確認
kubectl version --client
terraform version  # 1.15 で確認
go version         # 1.25 で確認
node --version     # 24 で確認
pnpm --version
buf --version      # 1.72 で確認

Docker だけは、どちらの場合も別途インストールが必要です。 バージョン管理ツールの対象外だからです。

マシンの条件

□ Docker に 4GB 以上のメモリを割り当てられること(実測で 1.5GB 程度使う)
□ ディスクの空きが 3GB 程度
□ ポート 19080 が空いていること(`lsof -nP -iTCP:19080 -sTCP:LISTEN` で確認)
□ 初回はイメージの取得で外部通信が発生する(数分かかる)

要らないもの

✕ GCP のアカウント   ✕ gcloud コマンド   ✕ クレジットカード

課金は発生しません。 Spanner は Google が配布しているエミュレータを Docker で動かします。

所要時間は、環境が揃っていれば60〜90分です。

なぜこの構成なのか

いきなり4つの技術が出てくるので、それぞれが何を解決しているかを先に押さえます。

使うもの無いとどうなるか
gRPCサービス間の約束が文書だけになる。片方が形を変えても、動かすまで気づけない
BFF を分けるブラウザは gRPC を話せない。誰かが変換しなければならない
Spannerサーバーを再起動するとデータが消える。複数台に増やすと保存先がバラバラになる
Kubernetes落ちても誰も起こしてくれない。台数を増やすのが手作業になる
Terraformテーブル定義が「誰かの手元のコマンド履歴」になる。再現できない
なぜ Spanner なのか

学習目的なら PostgreSQL でも構いません。ここで Spanner を選んでいるのは、 手元のエミュレータと本番とで、アプリのコードが1行も変わらないからです。

手元:  SPANNER_EMULATOR_HOST=localhost:9010 を設定する
本番:  その環境変数を消すだけ

「手元では動くのに本番で動かない」を減らす作りが、実際に体験できます。

手順1: 契約を書く

まず .proto です。ここが2つのサービスの間の約束になります。

syntax = "proto3";
package link.v1;
 
service LinkService {
  rpc CreateLink(CreateLinkRequest) returns (CreateLinkResponse);
  rpc GetLink(GetLinkRequest) returns (GetLinkResponse);
  rpc ListLinks(ListLinksRequest) returns (ListLinksResponse);
}
 
message Link {
  string key = 1;
  string url = 2;
  google.protobuf.Timestamp create_time = 3;
}

buf で Go と TypeScript の両方を生成します。

make generate    # buf lint && buf generate
# buf.gen.yaml
plugins:
  - remote: buf.build/protocolbuffers/go   # Go: メッセージ
  - remote: buf.build/grpc/go              # Go: サーバーとクライアント
  - remote: buf.build/bufbuild/es          # TypeScript

1つの定義から、2つの言語のコードが出ます。 これが gRPC を使う最大の理由です。 スキーマと RPCで扱った話が、ここで実物になります。

フィールド番号を変えない
string url = 2;    // この 2 を後から変えてはいけない

番号はワイヤ上の identity です。片方だけ更新した瞬間、 古い側は別のフィールドとして読みます。エラーにならないのが厄介なところです。

buf breaking を CI に入れると、この事故を機械が止めてくれます。

手順2: Spanner のテーブルを Terraform で作る

まずエミュレータを起動します。

make emulator
# docker run -d -p 9010:9010 -p 9020:9020 gcr.io/cloud-spanner-emulator/emulator

テーブルは手で作りません。Terraform に書きます。

provider "google" {
  project = "learning-project"
  # 本番ではこの行を消す。認証は Application Default Credentials に任せる
  spanner_custom_endpoint = "http://localhost:9020/v1/"
}
 
resource "google_spanner_instance" "main" {
  name         = "learning-instance"
  config       = "emulator-config"   # 本番では regional-asia-northeast1 など
  display_name = "learning"
  num_nodes    = 1
}
 
resource "google_spanner_database" "links" {
  instance = google_spanner_instance.main.name
  name     = "links"
 
  ddl = [
    <<-SQL
      CREATE TABLE links (
        key        STRING(16)   NOT NULL,
        url        STRING(2048) NOT NULL,
        created_at TIMESTAMP    NOT NULL OPTIONS (allow_commit_timestamp = true),
      ) PRIMARY KEY (key)
    SQL
    ,
    "CREATE INDEX links_by_created_at ON links (created_at DESC)",
  ]
 
  deletion_protection = false   # 学習用。本番では true のまま
}
make schema    # terraform init && terraform apply
エミュレータにも Terraform を向けられる

spanner_custom_endpoint を書き換えるだけで、 本番と同じ書き方のまま手元のエミュレータに対して流せます。

つまり「Terraform の書き方を練習する」ために GCP のアカウントは要りません。 Terraform と Infrastructure as Codeで読んだことを、ここで実際に試せます。

主キーの選び方が性能を決める
✕ 連番の ID を主キーにする
○ ランダムなキーを主キーにする   ← 今回の設計

Spanner は主キーの範囲でデータを分割します。連番だと書き込みが常に末尾に集中し、 1台のサーバーだけが働く状態(ホットスポット)になります。

短縮 URL のキーはランダムなので、自然に分散します。 エミュレータでは体感できませんが、本番では性能に直結します。

手順3: Go で gRPC サーバーを書く

保存層はインターフェースで切ります。テストで差し替えるためです。

type Store interface {
	Create(ctx context.Context, link store.Link) error
	Get(ctx context.Context, key string) (store.Link, error)
	List(ctx context.Context, limit int, after time.Time) ([]store.Link, error)
}

Spanner への書き込みはこうなります。

func (s *Spanner) Create(ctx context.Context, link Link) error {
	mutation := spanner.Insert("links",
		[]string{"key", "url", "created_at"},
		// 時刻はサーバー側で決める。クライアントの時計を信用しない
		[]any{link.Key, link.URL, spanner.CommitTimestamp},
	)
	if _, err := s.client.Apply(ctx, []*spanner.Mutation{mutation}); err != nil {
		if spanner.ErrCode(err) == codes.AlreadyExists {
			return ErrAlreadyExists   // 呼び出し側が採番し直せるようにする
		}
		return fmt.Errorf("insert に失敗しました: %w", err)
	}
	return nil
}
ライブラリのエラーを、そのまま外に出さない

ErrAlreadyExists という自分の言葉に翻訳しています。

こうしておくと、保存先を PostgreSQL に変えても、 呼び出し側のコードは変わりません。設計の基礎で扱った依存の向きの話です。

キーの衝突を扱う

ランダムなキーは、まれに衝突します。握り潰さず、採番し直します。

for attempt := range createRetries {
	key, err := newKey()
	// ...
	err = s.store.Create(ctx, store.Link{Key: key, URL: req.GetUrl()})
	switch {
	case err == nil:
		return &linkv1.CreateLinkResponse{Link: toProto(saved)}, nil
	case errors.Is(err, store.ErrAlreadyExists):
		slog.WarnContext(ctx, "key collision", "key", key, "attempt", attempt+1)
		continue                      // 諦めずにもう一度
	default:
		return nil, status.Error(codes.Internal, "保存に失敗しました")
	}
}
return nil, status.Error(codes.ResourceExhausted, "キーを採番できませんでした")

衝突をログに残しているのが重要です。頻発し始めたら、キーの長さが足りていない証拠になります。

エラーは gRPC のコードで返す

return nil, status.Error(codes.InvalidArgument, "url は http または https で始めてください")
return nil, status.Error(codes.NotFound, "そのキーは存在しません")
入力を検証しないと、何でも保存できてしまう
if parsed.Scheme != "http" && parsed.Scheme != "https" {
	return errors.New("url は http または https で始めてください")
}

これが無いと javascript:alert(1) を保存でき、 転送先としてそのまま返してしまいます。Web の脆弱性を体系的にで扱った オープンリダイレクトと XSS の入口です。

手順4: TypeScript で BFF を書く

ブラウザは gRPC を直接話せません。 間に HTTP で受ける層を置きます。

const transport = createGrpcTransport({ baseUrl: LINK_SERVICE_URL });
const links = createClient(LinkService, transport);
 
// POST /links → CreateLink
const created = await links.createLink({ url: String(body['url'] ?? '') });
send(res, 201, { key: created.link?.key, url: created.link?.url });

LinkService は .proto から生成された型です。 サーバー側が返す形を変えたら、ここでコンパイルエラーになります。

gRPC のコードを HTTP のステータスに翻訳する

この2つは1対1ではありません。変換は自分で書きます。

function toHttpStatus(error: unknown): number {
  if (!(error instanceof ConnectError)) return 500;
  switch (error.code) {
    case Code.InvalidArgument:   return 400;
    case Code.NotFound:          return 404;
    case Code.AlreadyExists:     return 409;
    case Code.ResourceExhausted: return 429;
    case Code.Unavailable:       return 503;
    default:                     return 500;
  }
}
上流が落ちている時、メッセージが空になる

接続できない場合、gRPC 側のエラーには本文が無いことがあります。 そのまま返すと、利用者にはこう見えます。

{"error":""}

実際にこれを踏みました。 上流を止めて叩いたら、503 は正しく返るのに中身が空でした。

function messageFor(error: unknown, status: number): string {
  if (error instanceof ConnectError && error.rawMessage !== '') return error.rawMessage;
  if (status === 503) return '一時的に利用できません。時間をおいて再度お試しください';
  return '内部エラーが発生しました';
}

利用者に返す文面と、ログに残す詳細は分けます。 詳細をそのまま画面に出すと、内部の構成が漏れます(Web の脆弱性を体系的に)。

上流が戻れば、勝手に繋ぎ直す

link を止めて BFF が 503 を返す状態にし、link だけを起動し直すと、 BFF は再起動しなくても 200 に戻ります。

gRPC のクライアントは接続を管理していて、切れたら再接続を試みるからです。 とはいえすべてのライブラリがそうとは限りません。 再接続とタイムアウトの挙動は、使う前に確かめてください。

全部 500 で返すと、原因が分からなくなる

面倒だからと catch で 500 を返すと、 利用者の入力ミス(400)とサーバーの不具合(500)が区別できません。

監視のアラートも意味を失います。500 が出たら本当に異常、という状態を保ってください (監視とオンコール)。

手順5: テストを書く

保存層をインターフェースで切ったのは、ここで差し替えるためでした。

make test              # メモリに差し替えた単体テスト。数秒
make test-integration  # Spanner エミュレータに対する結合テストも走らせる

make test はエミュレータが無くても通ります。 Spanner のテストは環境変数が無ければスキップされるので、CI の設定に左右されません。

依存は注入できる形にする

type Config struct {
	Store Store
	// 省略時は crypto/rand。テストでは固定の値を返す関数を渡す
	NewKey func() (string, error)
}

これで、乱数に依存するはずの「キー衝突」を確実に再現できます。

// 1回目と2回目で同じキーを返し、3回目に別のキーを返す
svc := newTestServer(t, "dup", "dup", "fresh")
テストが「通ること」ではなく「落ちること」を確かめる

書いたテストが本当に効いているかは、わざと壊してみないと分かりません。

この教材を作る時、実際にこれをやって2つの穴が見つかりました。

1つ目。 URL の検証を無効にしても、テストが通ってしまいました。

javascript:alert(1)   → ホストが空なので、別の判定で弾かれていた

スキームの検証を消しても落ちない、つまりその行をテストできていなかったわけです。 ホスト付きの javascript://example.com/%0aalert(1) を足して、初めて落ちるようになりました。

2つ目。 ページサイズの上限(100)を外しても通りました。 テストデータが5件しか無く、クランプしてもしなくても結果が同じだったからです。

こちらは、保存先が受け取った値を記録する仕組みで直接確かめるようにしました。

if spy.gotLimit != tt.wantLimit {
	t.Errorf("保存先へ渡した limit が違います: got=%d want=%d", spy.gotLimit, tt.wantLimit)
}

通っているテストほど疑ってください。 テストを書くで扱った話が、そのまま出てきます。

Spanner に対しては、本物に当てる

メモリ実装で通っても、Spanner で通るとは限りません。

□ 型の制約(STRING(16) に収まるか)
□ commit timestamp が本当にサーバー側で入るか
□ クエリの書き方(NULL の扱い、パラメータの型)

だからエミュレータに対する結合テストを別に用意しています。

フレーキーなテストを作らないために

最初、テスト用のキーを時刻から作っていました。

return "t" + time.Now().Format("150405.000000000")[:9] + "x"   // 10ms の分解能しかない

単体で走らせると通るのに、通しで走らせると落ちる——典型的なフレーキーテストです。 続けて実行されたテスト同士でキーが衝突していました。

buf := make([]byte, 6)
rand.Read(buf)
return "t" + hex.EncodeToString(buf)   // 実行順にも速度にも左右されない

「たまに落ちる」を retry で隠さないでください。 たいてい、本番で「たまに壊れる」の予告です(見つけにくいバグ)。

手順6: コンテナにする

Go はマルチステージで、実行イメージにツールチェーンを持ち込みません。

FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download          # 依存だけ先に。ソースを変えてもここは再利用される
COPY gen ./gen
COPY services/link ./services/link
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
    -o /out/server ./services/link/cmd/server
 
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
USER nonroot:nonroot         # 非 root で動かす
ENTRYPOINT ["/server"]
distroless にはシェルが無い
kubectl exec -it link-xxxxx -- sh   # 動かない

シェルもパッケージマネージャも入っていないので、 破られた時にできることが激減します。

代わりに調査は kubectl debug や、ログと監視で行います。 「入って調べられない」のは不便ですが、それが狙いです。

make images    # 2つビルドして kind に読み込ませる

手順7: Kubernetes に載せる

make cluster   # kind create cluster
make deploy    # エミュレータ → Terraform → link → bff

マニフェストで押さえる点は3つです。

接続先はサービス名

env:
  - { name: LINK_SERVICE_URL, value: "http://link:8080" }   # localhost ではない

コンテナの中の localhost は自分自身です。Docker を使いこなすでも出てきた話が、 Kubernetes でもそのまま効きます。

Probe を入れる

readinessProbe:
  grpc: { port: 8080 }     # 受けられる状態か
livenessProbe:
  grpc: { port: 8080 }     # 生きているか

gRPC のヘルスチェックはプロトコルとして決まっています。 grpc_health_v1.Health を登録しておけば、kubelet がそれを叩いてくれます。

制限をかける

resources:
  requests: { cpu: 50m, memory: 64Mi }
  limits:   { memory: 256Mi }
securityContext:
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities: { drop: ["ALL"] }

limits を超えると OOMKilled で落ちます(プロセスとメモリ)。 落ちること自体は正常な動作で、上限が無いほうが危険です。1つのコンテナがノード全体を食い潰します。

手順8: 動いていることを確かめる

make smoke

実際の出力です。

作成:
  key=9jjbqVZ
一覧:
{"links":[{"key":"9jjbqVZ","url":"https://example.com/hello"}],"next_page_token":""}
転送:
  302 -> https://example.com/hello
不正な URL:
  400

curl でも叩けます。

curl -X POST localhost:19080/links \
  -H 'content-type: application/json' \
  -d '{"url":"https://example.com/very/long/url"}'
 
curl -i localhost:19080/9jjbqVZ          # 302 で転送
curl 'localhost:19080/links?page_size=5'

gRPC を直接見ることもできます。BFF を挟まずに link を叩くので、切り分けに使えます。

kubectl port-forward svc/link 19001:8080
 
grpcurl -plaintext localhost:19001 list                    # サービス一覧
grpcurl -plaintext localhost:19001 describe link.v1.LinkService   # メソッドの定義
grpcurl -plaintext -d '{}' localhost:19001 grpc.health.v1.Health/Check
grpcurl -plaintext -d '{"url":"https://example.com"}' \
  localhost:19001 link.v1.LinkService/CreateLink

.proto のファイルが手元に無くても、定義を引けます。 サーバーがリフレクションを 登録しているからです(reflection.Register(grpcServer))。

本当に gRPC で喋っているかを確かめる

gRPC は HTTP/2 の上に載っています。 普通の curl で確かめられます。

curl --http2-prior-knowledge -X POST \
  -H 'content-type: application/grpc' http://localhost:19001/link.v1.LinkService/GetLink
# → HTTP/2 で 200 が返る

そして結果のステータスは、本体ではなくトレーラ(grpc-status)で返ります。

grpcurl -plaintext -v -d '{"key":"nosuchkey"}' localhost:19001 link.v1.LinkService/GetLink
# Response trailers received:
#   Code: NotFound

「HTTP は 200 なのにエラー」に見えるのは、この仕組みのためです。

HTTP/1.1 で叩くと、port-forward が切れる

gRPC サーバーは HTTP/1.1 を話せません。試しに普通の curl を投げると——

curl http://localhost:19001/     # 応答しない

kubectl port-forward を経由している場合、これでトンネルごと落ちます。

E0814 portforward.go:522] an error occurred forwarding 19001 -> 8080:
  read: connection reset by peer
error: lost connection to pod

実際にこれを踏みました。 以降 grpcurl も connection refused になり、 サービスが壊れたように見えます。壊れているのはトンネルだけです。

kubectl port-forward svc/link 19001:8080    # 立て直せば戻る

port-forward はデバッグ用の細い管で、落ちるのが前提です。 「さっきまで動いていたのに急に繋がらない」時は、まずこれを疑ってください。

レプリカを2つにしても、負荷は分かれない

実際に確かめると、こうなります。

kubectl logs -l app=link --tail=50 | grep -c 'link created'
# link-xxxx-2tccx: 2
# link-xxxx-jtr77: 0    ← 片方に偏っている

gRPC は接続を張りっぱなしにするためです。 Kubernetes の Service は接続単位で振り分けるので、一度繋がった先にずっと流れ続けます。

HTTP/1.1 のようにリクエストごとに繋ぎ直す前提だと、ここで期待が外れます。 実務では次のどれかで対処します。

□ クライアント側で負荷分散する(gRPC のラウンドロビン設定)
□ サービスメッシュを入れる(Envoy などが L7 で分ける)
□ 定期的に接続を張り直す(max connection age)

「レプリカを増やしたのに1台しか働いていない」の正体がこれです。

Pod を1つ消しても止まらないことを確かめる
kubectl delete pod -l app=link --field-selector status.phase=Running | head -1
# 別のターミナルで叩き続ける

実際にやると、応答は 200 のままで、消えた Pod は自動的に作り直されます。 これがコンテナと Kubernetesで扱った「宣言した状態に戻す」の実物です。

止まらない理由は、状態をアプリが持っていないからでもあります。 データは Spanner にあるので、どの Pod が応答しても結果は同じです。

どこで壊れたかを切り分ける順番
1. kubectl get pods              Running になっているか
2. kubectl logs -l app=bff       BFF は gRPC を呼べているか
3. kubectl logs -l app=link      link は Spanner に繋がっているか
4. kubectl port-forward + grpcurl  BFF を飛ばして link を直接叩く

層を1つずつ外して、どこまで動くかを見ます。 つながらない時に何を見るかと同じ考え方です。

手元で直接動かす

Kubernetes を経由するとデバッグが遅くなります。普段の開発は直接動かすほうが速いです。

make emulator && make schema
make run-link     # 別のターミナル
make run-bff      # さらに別のターミナル

同じコードが、環境変数だけで両方で動きます。 これは開発環境を作るで扱った、設定を外に出す設計の効果です。

本番(GKE)との差

エミュレータで動いても、本番で同じとは限りません。知っておくべき差を挙げます。

エミュレータCloud Spanner
認証要らないWorkload Identity や ADC が要る
性能単一プロセス。負荷試験の参考にならないノード数に応じてスケールする
ホットスポット再現しない主キーの偏りが性能に直結する
バックアップ無いある
課金無料ノード時間とストレージで課金

GKE へ移す時に変えるのは3点だけです。

1. イメージを Artifact Registry に push する(kind load の代わり)
2. SPANNER_EMULATOR_HOST を外し、Workload Identity を設定する
3. Service を NodePort から LoadBalancer / Ingress に変える

アプリのコードは変わりません。

GKE と Spanner は課金されます

この章は kind で完結するので費用はかかりません。 しかし GKE と Cloud Spanner を実際に作ると、放置しただけで課金が続きます。

□ 試す前に、料金表と無料枠を確認する
□ 終わったら terraform destroy とクラスタの削除まで行う
□ 会社のプロジェクトで試す場合は、必ず先に許可を取る

「勉強のつもりで立てたまま忘れる」が、いちばん多い事故です。

後片付け

make down    # kind のクラスタとエミュレータを消す

必ず実行してください。 kind のクラスタは、放置するとメモリを使い続けます。

つまずきやすいところ

症状原因
port is already allocated19080 を別のツールが使っている。kind/cluster.yaml と Makefile の両方を変える
ImagePullBackOffmake images を忘れている。kind はローカルのイメージを自動では見ない
link が CrashLoopBackOffエミュレータが立つ前に起動した。kubectl logs で接続エラーを確認
Terraform が 404エンドポイントに /v1/ が付いていない
BFF から繋がらないlocalhost を指している。Kubernetes ではサービス名(link)を使う
permission denied でビルドできないDocker に割り当てたメモリかディスクが足りない

まとめ

  • 1つの .proto から、Go と TypeScript のコードが生成される。 これが gRPC を使う理由
  • ブラウザは gRPC を話せないので、BFF が HTTP との境界になる
  • gRPC のコードと HTTP のステータスは1対1ではない。翻訳は自分で書く
  • Spanner はエミュレータと本番でアプリのコードが変わらない。環境変数の差だけ
  • 主キーの選び方が性能を決める。 連番は書き込みが1箇所に集中する
  • テーブル定義はTerraform に書く。手で作ると再現できない
  • Kubernetes ではサービス名で名前解決する。localhost は自分自身
  • limits を付ける。 上限が無いほうが危険
  • エミュレータは性能とホットスポットを再現しない。そこは本番でしか分からない
  • 終わったら片付ける。 特に GKE と Spanner は放置すると課金が続く

公式ドキュメント

章末問題

BFF から link サービスへの接続先を LINK_SERVICE_URL=http://localhost:19001 と設定したところ、Kubernetes 上で繋がりませんでした。原因は何でしょうか?

短縮リンクの主キーとして、連番の ID とランダムな文字列のどちらを選ぶべきでしょうか(Cloud Spanner を使う前提)?

読み終わったら記録しておくと、目次で進み具合が分かります。