ハンズオン: サービスを1本作って動かす
この部の 1 / 2 章 ・ 全体で 75 / 76 章 ・ 読了目安 90 分
- スキーマから実装までを自分の手で通せる
- ローカルで動くものを本番相当の形にできる
- 各章の知識がどこで使われるかを体感できる
ここまでの章は、道具や技術をひとつずつ説明してきました。 この章では、それらを全部つないで動くサービスを1本作ります。
設計から始めて、実装し、テストを書き、コンテナにして、Kubernetes に載せ、 ログを見るところまでを通します。手を動かす前提で、90分ほど見ておいてください。
何を作るか
作るのは短縮 URL サービスです。長い URL を渡すと短いキーを返し、 そのキーでアクセスすると元の URL に転送されます。
題材としては地味ですが、実務のサービスに必要な要素がほぼ全部入っています。
- 入力の検証(変な URL を弾く)
- 一意なキーの採番と衝突の扱い
- 保存(今回はメモリ、実務では DB)
- 一覧とページネーション
- 転送(HTTP のリダイレクト)
API は3つだけです。
| 操作 | 何をするか |
|---|---|
CreateLink(url) | 短縮キーを返す |
GetLink(key) | 元の URL を返す |
ListLinks(page_size, page_token) | 一覧をページ単位で返す |
完成形
完成したコードは examples/hands-on/ にあります。
先に動かして、ゴールを見てから読み進めても構いません。
cd examples/hands-on
make test
make run別のターミナルで叩きます。
curl -s -X POST localhost:8080/v1/links \
-H 'Content-Type: application/json' \
-d '{"url":"https://go.dev/doc/effective_go"}'{"key":"oyezGIN","url":"https://go.dev/doc/effective_go","created_at":"2026-08-05T07:31:22.056344Z"}この章は上から順に自分で書けるように並べていますが、
examples/hands-on/ を写経して、各ステップで何が起きているかを追うだけでも効果があります。
大事なのは「設計 → 実装 → テスト → コンテナ → デプロイ → 運用」という順番を1度通すことです。
手順1: 設計する(.proto を書く)
この工程で使う章: 第5部 protobuf と gRPC / API を設計する
コードを書く前に、外から見た形を決めます。 先に決めておくと、実装の途中で「引数どうしよう」と悩む回数が激減します。
proto/link/v1/link.proto を作ります。
syntax = "proto3";
package link.v1;
import "google/protobuf/timestamp.proto";
option go_package = "github.com/example/linkshort/gen/link/v1;linkv1";
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;
}
message CreateLinkRequest {
string url = 1;
}
message CreateLinkResponse {
Link link = 1;
}
message ListLinksRequest {
// 0 ならサーバー既定値(20)。上限は 100。
int32 page_size = 1;
// 前回の応答の next_page_token をそのまま渡す。中身は不透明。
string page_token = 2;
}
message ListLinksResponse {
repeated Link links = 1;
// 空文字なら最終ページ。
string next_page_token = 2;
}ここで決めた設計上の判断は3つあります。
1つ目は、リクエストとレスポンスを必ずメッセージで包むこと。
rpc GetLink(string) returns (string) のようには書けませんし、書けたとしても書きません。
あとでフィールドを1つ足したい時に、包んでいれば互換性を壊さずに足せるからです。
2つ目は、返すのを bool や裸の文字列にしないこと。
CreateLinkResponse が Link を持っているので、
あとで「作成者」や「有効期限」を足しても呼び出し側は壊れません。
3つ目は、ページネーションをトークン方式にすること。
offset 方式(何件目から)だと、ページを送っている間に新しいレコードが増えると
同じレコードが2回出たり、飛ばされたりします。
「前回の続きはここから」という不透明なトークンを返す方式なら、これが起きません。
string url = 2; の 2 は、通信の際に実際に流れる識別子です。
一度リリースしたら番号を変えてはいけません。フィールド名は変えられますが、番号は変えられません。
使わなくなったフィールドは削除ではなく reserved 2; と書いて番号を封印します。
番号を再利用すると、古いクライアントが新しいフィールドを古い型として読んで、静かに壊れます。
生成コードはコミットしない
.proto から Go のコードを生成する手順は Makefile に書きます。
gen:
mkdir -p gen
protoc -I proto \
--go_out=gen --go_opt=paths=source_relative \
--go-grpc_out=gen --go-grpc_opt=paths=source_relative \
proto/link/v1/link.proto生成物は .gitignore に入れてコミットしません。
.proto が正本で、生成物は誰でも作り直せるからです。
コミットすると、.proto を直したのに生成を忘れた差分がレビューをすり抜けます。
生成コードをコミットしない方針にすると、protoc を入れていない人は
そのままではビルドできません。この章では確実に動くことを優先して、
internal/ を素の Go の型で書き、入口は net/http の JSON サーバーにします。
.proto は「設計の成果物」として残します。
gRPC に載せ替える時は、生成された LinkServiceServer を実装する薄い層を足して、
その中から今回書く service を呼びます。ロジックは1行も書き直しません。
その形になっているかどうかが、この章の裏テーマです。
手順2: 環境を用意する
この工程で使う章: 第1部 開発環境を作る / マシンとシェル
まずディレクトリとモジュールを作ります。
mkdir -p linkshort && cd linkshort
go mod init github.com/example/linkshort
mkdir -p cmd/server internal/service internal/store proto/link/v1 k8sgo.mod はこれだけです。今回は標準ライブラリしか使いません。
module github.com/example/linkshort
go 1.24
Go の標準ライブラリは HTTP サーバー・JSON・構造化ログ・暗号乱数まで揃っています。 ルーターやログライブラリを反射的に入れる前に、標準で足りないか確認してください。 依存はゼロにはできませんが、依存の数だけ、更新と脆弱性対応の当番が増えます。
Docker Compose は、この段階では「あとで使うもの」として置いておきます。
docker-compose.yml はこれだけです。
services:
linkshort:
build: .
image: linkshort:dev
ports:
- "8080:8080"
environment:
ADDR: ":8080"
restart: unless-stopped実務では、ここに PostgreSQL や Redis が並びます。
「手元で docker compose up すれば依存も含めて全部立ち上がる」という状態を作るのが目的です。
手順3: 実装する(インターフェースで切る)
この工程で使う章: 第4部 Go / 第6部 読まれるコード
依存の向きを先に決めます。今回はこうします。
cmd/server → internal/service → internal/store
(HTTP) (業務ロジック) (保存先)
矢印は一方通行です。service は HTTP を知りませんし、store は業務ルールを知りません。
この向きを守ると、入口を gRPC に変えても service は無傷です。
保存先をインターフェースで切る
internal/store/store.go から書きます。先に口(インターフェース)を決めます。
package store
var (
ErrNotFound = errors.New("store: link not found")
ErrConflict = errors.New("store: key already exists")
)
type Link struct {
Key string
URL string
CreatedAt time.Time
}
// Store は保存先の口。呼び出し側はこの口だけに依存するので、
// インメモリを PostgreSQL に差し替えても service は変わらない。
type Store interface {
Create(ctx context.Context, link Link) error
Get(ctx context.Context, key string) (Link, error)
// List は after より後ろのキーを昇順で最大 limit 件返す。
List(ctx context.Context, limit int, after string) ([]Link, error)
}実装はインメモリです。map と sync.RWMutex だけです。
type Memory struct {
mu sync.RWMutex
links map[string]Link
}
func NewMemory() *Memory {
return &Memory{links: make(map[string]Link)}
}
func (m *Memory) Create(_ context.Context, link Link) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.links[link.Key]; exists {
return ErrConflict
}
m.links[link.Key] = link
return nil
}HTTP サーバーはリクエストごとに別の goroutine で動きます。
map は同時に書くと実行時に落ちるので、ロックは必須です。
Goの go test -race で確認できるのがこの種類のバグです。
一覧はキーの昇順で切り出します。
func (m *Memory) List(_ context.Context, limit int, after string) ([]Link, error) {
m.mu.RLock()
defer m.mu.RUnlock()
// キーの全順序で区切ることで、途中で件数が増減してもページ境界がずれない
keys := make([]string, 0, len(m.links))
for key := range m.links {
if key > after {
keys = append(keys, key)
}
}
sort.Strings(keys)
if len(keys) > limit {
keys = keys[:limit]
}
links := make([]Link, 0, len(keys))
for _, key := range keys {
links = append(links, m.links[key])
}
return links, nil
}なお Go では、インターフェースを実装側ではなく使う側の都合で定義します。
今回 Store を store パッケージに置いているのは入門としての分かりやすさを取ったためで、
実務では service 側に必要なメソッドだけの小さいインターフェースを置くこともよくあります。
判断基準は「そのインターフェースのメソッドを、本当に全部使っているか」です。
使っていないメソッドが並んでいるインターフェースは、大きすぎます。
業務ロジックを書く
internal/service/service.go です。ここがこのサービスの本体になります。
依存は構造体で受け取ります。引数を4つ5つと並べる代わりに Config にまとめると、
あとで依存が増えても呼び出し側のシグネチャが壊れません。
type Config struct {
Store store.Store
NewKey func() (string, error)
Now func() time.Time
Logger *slog.Logger
}
type Service struct {
store store.Store
newKey func() (string, error)
now func() time.Time
logger *slog.Logger
}
func New(cfg Config) *Service {
svc := &Service{store: cfg.Store, newKey: cfg.NewKey, now: cfg.Now, logger: cfg.Logger}
if svc.newKey == nil {
svc.newKey = RandomKey
}
if svc.now == nil {
svc.now = time.Now
}
if svc.logger == nil {
svc.logger = slog.Default()
}
return svc
}NewKey と Now を外から差し込めるようにしているのがこの設計の要点です。
乱数と現在時刻が中に埋め込まれていると、テストで期待値が書けません。
テストを書くの「テストが書きにくいのは設計のサイン」がここに効いてきます。
作成処理はこうなります。
func (s *Service) CreateLink(ctx context.Context, rawURL string) (Link, error) {
target, err := normalizeURL(rawURL)
if err != nil {
return Link{}, err
}
created, err := s.storeWithNewKey(ctx, target)
if err != nil {
return Link{}, err
}
s.logger.InfoContext(ctx, "link created", "key", created.Key, "url", created.URL)
return toLink(created), nil
}
// storeWithNewKey はキー衝突のときだけ採番からやり直す。
func (s *Service) storeWithNewKey(ctx context.Context, target string) (store.Link, error) {
for attempt := range createMaxRetry {
key, err := s.newKey()
if err != nil {
return store.Link{}, fmt.Errorf("generate key: %w", err)
}
record := store.Link{Key: key, URL: target, CreatedAt: s.now().UTC()}
switch err := s.store.Create(ctx, record); {
case err == nil:
return record, nil
case errors.Is(err, store.ErrConflict):
s.logger.WarnContext(ctx, "key collision", "key", key, "attempt", attempt+1)
default:
return store.Link{}, fmt.Errorf("create link: %w", err)
}
}
return store.Link{}, ErrExhausted
}リトライ回数を有限にしているところに注目してください。
for { ... } で無限にやり直すと、保存先が壊れた時に CPU を焼き切ります。
再試行には必ず上限を付けます。
入力の検証はこうです。
func normalizeURL(rawURL string) (string, error) {
trimmed := strings.TrimSpace(rawURL)
if trimmed == "" {
return "", fmt.Errorf("%w: url is required", ErrInvalidArgument)
}
if len(trimmed) > maxURLLength {
return "", fmt.Errorf("%w: url is too long", ErrInvalidArgument)
}
parsed, err := url.Parse(trimmed)
if err != nil {
return "", fmt.Errorf("%w: url is not parsable", ErrInvalidArgument)
}
// javascript: や file: を通すと、転送先がそのまま攻撃面になる
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", fmt.Errorf("%w: url must be http or https", ErrInvalidArgument)
}
if parsed.Host == "" {
return "", fmt.Errorf("%w: url must have a host", ErrInvalidArgument)
}
return parsed.String(), nil
}短縮 URL サービスはオープンリダイレクタになりやすい題材です。
javascript: を許すと、リンクを踏んだ人のブラウザで任意のコードが動きます。
さらに実務では、http://169.254.169.254/(クラウドのメタデータ)や
社内ネットワークの IP を弾く必要があります。セキュリティの SSRF の話がそのまま当てはまります。
「ユーザーが指定した URL に、サーバーが何かする」機能は、常にここを疑ってください。
キーの採番は暗号乱数です。連番にすると、他人のリンクを総当たりで全部読めてしまいます。
const keyAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
func RandomKey() (string, error) {
limit := big.NewInt(int64(len(keyAlphabet)))
key := make([]byte, keyLength)
for i := range key {
n, err := rand.Int(rand.Reader, limit)
if err != nil {
return "", err
}
key[i] = keyAlphabet[n.Int64()]
}
return string(key), nil
}math/rand ではなく crypto/rand を使っています。
math/rand は種が分かれば次の値を計算できるので、推測されて困る値には使いません。
ページネーションを実装する
一覧は「1件多く取って、あふれたら次ページがある」という定石で実装します。
func (s *Service) ListLinks(ctx context.Context, req ListLinksRequest) (ListLinksResponse, error) {
size, err := normalizePageSize(req.PageSize)
if err != nil {
return ListLinksResponse{}, err
}
after, err := decodePageToken(req.PageToken)
if err != nil {
return ListLinksResponse{}, err
}
// 次ページの有無を知るために1件多く取る
records, err := s.store.List(ctx, size+1, after)
if err != nil {
return ListLinksResponse{}, fmt.Errorf("list links: %w", err)
}
next := ""
if len(records) > size {
next = encodePageToken(records[size-1].Key)
records = records[:size]
}
...
}トークンは base64 にして不透明にします。中身がキーだと読者に分かってしまうと、 あとで並び順を変えた時に「トークンを自作していた利用者」が壊れます。
func encodePageToken(key string) string {
return base64.RawURLEncoding.EncodeToString([]byte(key))
}page_size にも上限を付けます。page_size=1000000 を投げられて DB を舐め切られないためです。
func normalizePageSize(size int) (int, error) {
switch {
case size < 0:
return 0, fmt.Errorf("%w: page_size must not be negative", ErrInvalidArgument)
case size == 0:
return defaultPageSize, nil
case size > maxPageSize:
return maxPageSize, nil
default:
return size, nil
}
}手順4: テストを書く
この工程で使う章: 第6部 テストを書く
Go のテストは table-driven で書きます。 「入力と期待値の表」と「1回だけ書く検証ロジック」に分けるのが基本形です。
まず、テスト用の Service を作るヘルパーを用意します。
ここで乱数と時刻を固定するのが肝です。
var fixedTime = time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC)
func newTestService(t *testing.T) (*service.Service, store.Store) {
t.Helper()
memory := store.NewMemory()
counter := 0
svc := service.New(service.Config{
Store: memory,
NewKey: func() (string, error) {
counter++
return fmt.Sprintf("key%03d", counter), nil
},
Now: func() time.Time { return fixedTime },
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
return svc, memory
}Logger を io.Discard に向けているのは、テスト出力をログで埋めないためです。
検証の本体です。
func TestCreateLink(t *testing.T) {
tests := []struct {
name string
url string
wantURL string
wantErr error
}{
{name: "https を受け付ける", url: "https://example.com/a", wantURL: "https://example.com/a"},
{name: "前後の空白を落とす", url: " https://example.com/b ", wantURL: "https://example.com/b"},
{name: "空文字は弾く", url: "", wantErr: service.ErrInvalidArgument},
{name: "スキームなしは弾く", url: "example.com", wantErr: service.ErrInvalidArgument},
{name: "javascript は弾く", url: "javascript:alert(1)", wantErr: service.ErrInvalidArgument},
{name: "ホストなしは弾く", url: "https://", wantErr: service.ErrInvalidArgument},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, _ := newTestService(t)
got, err := svc.CreateLink(context.Background(), tt.url)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("err = %v, want %v", err, tt.wantErr)
}
if tt.wantErr != nil {
return
}
if got.URL != tt.wantURL {
t.Errorf("URL = %q, want %q", got.URL, tt.wantURL)
}
})
}
}ケースを1つ足すのが1行で済みます。これが table-driven の効き目です。 バグを見つけたら、まず表に1行足してから直します。
異常系はインターフェースで作る
「保存先が落ちた時に 500 を返すか」を確かめたい時、 本物の DB を落とす必要はありません。インターフェースを差し替えます。
// failingStore は「保存先が落ちた」状況を作るための差し替え実装。
type failingStore struct {
store.Store
err error
}
func (f failingStore) Create(context.Context, store.Link) error { return f.err }
func TestCreateLinkPropagatesStoreFailure(t *testing.T) {
boom := errors.New("disk on fire")
svc := service.New(service.Config{
Store: failingStore{Store: store.NewMemory(), err: boom},
NewKey: func() (string, error) { return "fixedkey", nil },
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
_, err := svc.CreateLink(context.Background(), "https://example.com")
if !errors.Is(err, boom) {
t.Fatalf("err = %v, want %v をラップした値", err, boom)
}
}store.Store を埋め込んでいるので、上書きしたい Create だけ書けば済みます。
ページネーションは「最後までたどると全件がちょうど1回ずつ出る」ことを確かめます。 境界を1つずつ確かめるより、この性質を1本書くほうが効きます。
func TestListLinksWalksEveryPageOnce(t *testing.T) {
svc, _ := newTestService(t)
const total = 23
seedLinks(t, svc, total)
seen := make(map[string]int)
token := ""
for page := 0; ; page++ {
if page > total {
t.Fatal("ページ送りが終わりません")
}
got, err := svc.ListLinks(context.Background(), service.ListLinksRequest{PageSize: 5, PageToken: token})
if err != nil {
t.Fatalf("ListLinks: %v", err)
}
for _, link := range got.Links {
seen[link.Key]++
}
if got.NextPageToken == "" {
break
}
token = got.NextPageToken
}
if len(seen) != total {
t.Errorf("見えた件数 = %d, want %d", len(seen), total)
}
}無限ループの保険(page > total で t.Fatal)を入れているところも真似してください。
テストが返ってこないと、CI が30分止まります。
走らせます。
go test ./...
go test -race ./... # 同時アクセスのバグを見つける? github.com/example/linkshort/cmd/server [no test files]
ok github.com/example/linkshort/internal/service 0.186s
? github.com/example/linkshort/internal/store [no test files]
もし time.Now() や rand が関数の奥に埋まっていたら、
このテストは「時刻をモックするライブラリ」を探すところから始まっていたはずです。
テストを書くのがつらい時、悪いのはたいていテストではなく本体の設計です。 「外から差し替えられるか」を先に考えると、テストは自然に短くなります。
手順5: HTTP で公開する
この工程で使う章: 第3部 Web と HTTP / ブラウザの仕組み
service は HTTP を知りません。ここでようやく HTTP を足します。
func newRouter(svc *service.Service) http.Handler {
handlers := &api{svc: svc}
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/links", handlers.createLink)
mux.HandleFunc("GET /v1/links", handlers.listLinks)
mux.HandleFunc("GET /v1/links/{key}", handlers.getLink)
mux.HandleFunc("GET /r/{key}", handlers.redirect)
mux.HandleFunc("GET /healthz", alwaysOK)
mux.HandleFunc("GET /readyz", alwaysOK)
return mux
}Go 1.22 以降の http.ServeMux は、メソッドとパスのパターンを直接書けます。
ルーターのライブラリを入れる前に、これで足りないか確認してください。
ハンドラの仕事は3つだけです。受け取る・呼ぶ・返す。業務ロジックは書きません。
func (a *api) createLink(w http.ResponseWriter, r *http.Request) {
var body struct {
URL string `json:"url"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
writeError(w, r, http.StatusBadRequest, "リクエストボディが JSON として読めません")
return
}
link, err := a.svc.CreateLink(r.Context(), body.URL)
if err != nil {
writeServiceError(w, r, err)
return
}
writeJSON(w, http.StatusCreated, link)
}http.MaxBytesReader は、巨大なボディを送りつけられてメモリを食い潰さないための蓋です。
エラーをステータスコードに翻訳する
業務エラーと HTTP の対応表は1箇所にまとめます。
各ハンドラで if err == ... { w.WriteHeader(400) } と書くと、すぐにズレます。
func writeServiceError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, service.ErrInvalidArgument):
writeError(w, r, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrNotFound):
writeError(w, r, http.StatusNotFound, "そのキーのリンクはありません")
default:
slog.ErrorContext(r.Context(), "unhandled error", "error", err, "path", r.URL.Path)
writeError(w, r, http.StatusInternalServerError, "内部エラーが発生しました")
}
}default で内部の詳細を返していないのがポイントです。
SQL のエラー文をそのまま返すと、テーブル名やカラム名が外に漏れます。
詳細はログに、利用者には一般化したメッセージを。
構造化ログを入れる
この工程で使う章: 第8部 監視とオンコール
全リクエストを1行ずつ、JSON で出します。
func withRequestLog(next http.Handler, logger *slog.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(recorder, r)
logger.InfoContext(r.Context(), "http request",
"method", r.Method,
"path", r.URL.Path,
"status", recorder.status,
"duration_ms", time.Since(started).Milliseconds(),
"user_agent", r.UserAgent(),
)
})
}http.ResponseWriter はステータスコードを覚えていないので、
記録用に薄く包みます。
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(status int) {
s.status = status
s.ResponseWriter.WriteHeader(status)
}止められるサーバーにする
main.go では、SIGTERM を受けたら受付を止めて、処理中のリクエストを待ってから終わります。
func run(server *http.Server, logger *slog.Logger) error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
logger.Info("server listening", "addr", server.Addr)
errCh <- server.ListenAndServe()
}()
select {
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
logger.Info("shutdown signal received")
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
return server.Shutdown(shutdownCtx)
}
}Kubernetes は Pod を消す前に SIGTERM を送ります。 これを無視して即死するサーバーは、処理中のリクエストを道連れにします。
デプロイのたびに数十件の 502 が出るサービスは、たいていここが原因です。 毎日デプロイするなら、毎日エラーを出していることになります。
手順6: 動かして確かめる
この工程で使う章: 第3部 Web と HTTP / 第1部 コマンドラインで生きる
起動します。
go run ./cmd/server{"time":"2026-08-05T16:31:19.114815+09:00","level":"INFO","msg":"server listening","addr":":8080"}別のターミナルから叩きます。
# 作る
curl -s -X POST localhost:8080/v1/links \
-H 'Content-Type: application/json' \
-d '{"url":"https://go.dev/doc/effective_go"}'{"key":"oyezGIN","url":"https://go.dev/doc/effective_go","created_at":"2026-08-05T07:31:22.056344Z"}# 引く
curl -s localhost:8080/v1/links/oyezGIN
# 弾かれることを確かめる
curl -s -w ' status=%{http_code}\n' -X POST localhost:8080/v1/links \
-d '{"url":"javascript:alert(1)"}'{"error":"invalid argument: url must be http or https"} status=400# 一覧をページ送りする
curl -s 'localhost:8080/v1/links?page_size=2'{"links":[{"key":"BktH3sQ","url":"https://example.com/2","created_at":"..."},
{"key":"b6nlkBE","url":"https://example.com/3","created_at":"..."}],
"next_page_token":"YjZubGtCRQ"}返ってきた next_page_token をそのまま渡すと次のページが出ます。
curl -s 'localhost:8080/v1/links?page_size=2&page_token=YjZubGtCRQ'転送も見ておきます。-i を付けるとヘッダが見えます。
curl -s -i localhost:8080/r/oyezGIN | head -3HTTP/1.1 302 Found
Content-Type: text/html; charset=utf-8
Location: https://go.dev/doc/effective_go
ブラウザで開くと転送されますが、curl は既定で追いません。
Web と HTTPで見た「リダイレクトはヘッダで指示される」が、そのまま目で確認できます。
ログを見る
サーバー側の出力はこうなっています。
{"time":"...","level":"INFO","msg":"link created","key":"oyezGIN","url":"https://go.dev/doc/effective_go"}
{"time":"...","level":"INFO","msg":"http request","method":"POST","path":"/v1/links","status":201,"duration_ms":0,"user_agent":"curl/8.7.1"}
{"time":"...","level":"WARN","msg":"request rejected","status":400,"message":"invalid argument: url must be http or https"}JSON で出しているので、あとから機械的に絞れます。
go run ./cmd/server 2>&1 | jq 'select(.status >= 400)'これが構造化ログの利点です。
log.Printf("request %s %s took %dms", ...) と書いていたら、
この絞り込みは正規表現との戦いになります。
手順7: 詰まったら
この工程で使う章: 第2部 デバッグの技術 / 第3部 ネットワーク
うまく動かない時、闇雲に直さず切り分けます。 外側から順に、疑いを1つずつ潰していきます。
| 症状 | まず確かめること | コマンド |
|---|---|---|
curl: connection refused | サーバーが起動しているか | lsof -i :8080 |
| ずっと待たされる | ポートは合っているか、別プロセスが掴んでいないか | ps aux | grep server |
| 404 が返る | ルーティングのパターンとメソッド | サーバーのログの path を見る |
| 400 が返る | 送っているボディの中身 | curl -v でリクエストを表示 |
| 500 が返る | サーバー側のログ | level:"ERROR" の行を探す |
| ビルドが通らない | import パスとモジュール名 | go build ./... の1つ目のエラーだけ読む |
順番が大事です。「どこまでは正しいか」を下から確定させます。
- プロセスは生きているか(
ps) - ポートを listen しているか(
lsof -i :8080) - TCP は繋がるか(
curl -vのConnected toが出るか) - HTTP は返ってきているか(ステータスコードは何番か)
- 中身は期待通りか
この5段のどこで切れているかが分かれば、原因は自然に絞られます。
Go のビルドエラーが20行出ても、直すのは1つ目だけです。 2つ目以降は1つ目の巻き添えであることがほとんどで、1つ直すと全部消えます。
これはテストの失敗でも同じです。全部を見渡す前に、一番上を1つ直して走らせ直してください。
手順8: コンテナにする
この工程で使う章: 第5部 Kubernetes / 第7部 セキュリティ
Dockerfile を書きます。マルチステージにして、最終イメージにはバイナリだけを残します。
# --- 1段目: ビルドだけを行う。Go ツールチェインは成果物に持ち込まない ---
FROM golang:1.24-alpine AS build
WORKDIR /src
# 依存の解決だけを先に済ませると、コードを直してもこの層のキャッシュが効く
COPY go.mod ./
RUN go mod download
COPY . .
# CGO を切って静的リンクにする。これで libc の無いイメージでも動く
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
# --- 2段目: バイナリ1つだけの最小イメージ。シェルもパッケージマネージャも無い ---
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
# root で動かさない。コンテナを破られても、できることを減らしておく
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]ここには3つの意図があります。
1つ目は、ビルド環境を成果物に持ち込まないこと。
golang:alpine は数百 MB ありますが、最終イメージは十数 MB で済みます。
イメージが小さいと、デプロイもロールバックも速くなります。
2つ目は、COPY go.mod を先に書くこと。
Docker は行ごとに層をキャッシュします。
コードを1行直しただけで依存のダウンロードからやり直すと、ビルドが毎回数分になります。
3つ目は、root で動かさないこと。
distroless の nonroot タグには、シェルもパッケージマネージャも入っていません。
侵入されても、そこからできることが激減します。
docker build -t linkshort:dev .
docker compose up --build -d
curl -s localhost:8080/healthzひとつ注意があります。distroless には shell が無いので、
docker exec -it <container> sh は動きません。これは欠点ではなく設計です。
入って調べたくなったら、それはログとメトリクスが足りていないサインだと考えてください。
どうしても中を見たい時は :debug タグのイメージに一時的に差し替え、
本番のイメージには戻さないでください。
手順9: Kubernetes に載せる
この工程で使う章: 第5部 Kubernetes / 第8部 クラウドの基礎
k8s/deployment.yaml です。最低限、probe と resources は必ず書きます。
apiVersion: apps/v1
kind: Deployment
metadata:
name: linkshort
spec:
replicas: 2
selector:
matchLabels:
app: linkshort
template:
metadata:
labels:
app: linkshort
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65532
containers:
- name: server
image: linkshort:dev
ports:
- name: http
containerPort: 8080
# 落ちていたら再起動する。重い依存を見に行かせないこと
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 3
periodSeconds: 10
failureThreshold: 3
# 受け入れ準備ができたらトラフィックを流す。失敗しても再起動はしない
readinessProbe:
httpGet:
path: /readyz
port: http
initialDelaySeconds: 1
periodSeconds: 5
failureThreshold: 3
resources:
# requests はスケジューラが置き場所を決める基準、limits は上限
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
# SIGTERM 後にロードバランサから外れる猶予を取る
terminationGracePeriodSeconds: 30Service は Pod の前に立つ固定の宛先です。
apiVersion: v1
kind: Service
metadata:
name: linkshort
spec:
type: ClusterIP
selector:
app: linkshort
ports:
- name: http
port: 80
targetPort: http適用して確認します。
kubectl apply -f k8s/
kubectl get pods -l app=linkshort
kubectl logs -l app=linkshort -f
kubectl port-forward svc/linkshort 8080:80livenessProbe が失敗すると Pod は再起動されます。
readinessProbe が失敗しても、トラフィックが止まるだけで再起動はされません。
ここで DB への接続確認を liveness に入れると、DB が一瞬詰まっただけで 全 Pod が同時に再起動し、復旧をさらに遅らせます。 liveness は「プロセスが自力で回復できない状態か」だけを見ます。 依存の状態は readiness で見てください。
resources を書かない Pod は、同じノードの他のサービスのメモリを食い潰します。
requests はスケジューラが配置を決める基準、limits は超えたら止められる上限です。
limits の memory を超えると、Pod は OOMKilled されます。
手順10: CI を通す
この工程で使う章: 第8部 CI/CD
手元で通ることと、他人の環境で通ることは別です。CI に判定させます。
.github/workflows/ci.yaml を置きます。
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
cache: true
- name: format
run: test -z "$(gofmt -l .)"
- name: vet
run: go vet ./...
- name: test
run: go test -race ./...
- name: build image
run: docker build -t linkshort:${{ github.sha }} .順番に意味があります。速くて落ちやすいものから並べます。
gofmt は数秒で終わるので最初に置き、重い docker build は最後です。
30分かけてイメージを作ってから整形漏れで落ちるのは、誰の得にもなりません。
test -z "$(gofmt -l .)" は「整形されていないファイルが1つも無いこと」の確認です。
gofmt -w をここで走らせてはいけません。CI は判定するもので、直すものではありません。
CI が落ちた時は、ログを睨む前に、落ちたステップのコマンドをそのまま手元で実行してください。 たいてい再現します。再現しない時が、本当に価値のある情報です (Go のバージョン違い、環境変数、タイムゾーン、ファイル名の大文字小文字)。
手順11: 運用を考える
この工程で使う章: 第8部 監視とオンコール
動いた後のほうが、動かすまでより長く続きます。
何を見るか
| 見るもの | このサービスでの具体例 |
|---|---|
| ログ | status >= 500 の行、key collision の頻発 |
| メトリクス | リクエスト数、エラー率、レイテンシの p50 / p95 / p99 |
| トレース | 転送1回あたり、どの処理に時間を使ったか |
今回はログだけ入れました。メトリクスを足すなら、まずこの3つです。
http_requests_total(method, path, status 別)http_request_duration_seconds(ヒストグラム)links_total(現在の保存件数)
平均値だけを見ないでください。平均は、遅い1%を隠します。 p99 が 3 秒なら、100人に1人は3秒待っています。
SLO を先に決める
「速くする」「落ちないようにする」は目標になりません。数字にします。
可用性 SLO: 30日間で成功率 99.9%(= 43分/月 のエラー予算)
レイテンシ SLO: 転送リクエストの 99% が 200ms 以内
このサービスなら、転送(GET /r/{key})が最重要です。
リンクが作れないのは不便ですが、リンクが開けないのは他人のサイトが壊れます。
機能ごとに重要度は違うので、全部を同じ厳しさで守ろうとしないでください。
アラートは「人が今すぐ動く必要があるもの」だけ
起こしてよい: 転送のエラー率が5分間 5% を超えた
起こさなくてよい: CPU 使用率が 80% を超えた
CPU が高くても、利用者が困っていないなら夜中に起こす理由はありません。 症状(利用者が困っている)でアラートを出し、原因(CPU)はダッシュボードで見ます。
最後に、このサービスに残っている運用上の爆弾を1つ確認しておきます。 インメモリ実装なので、Pod が再起動すると全リンクが消えます。 replicas を2にしているので、そもそも Pod ごとに別のデータを持っており、 「作った直後に引けない」が普通に起きます。これは意図的に残した宿題で、次の節で片付けます。
手順12: ここから先
ここまでで、設計からデプロイまでが1本通りました。 実務に近づけるには、この順で足していきます。
1. 永続化する(データベース データベース)
store.Store インターフェースはもう切ってあるので、
internal/store/postgres.go に実装を1つ足すだけです。service は1行も変わりません。
type Postgres struct {
db *sql.DB
}
func (p *Postgres) Create(ctx context.Context, link store.Link) error {
// 文字列結合は絶対にしない。プレースホルダを使う
_, err := p.db.ExecContext(ctx,
`INSERT INTO links (key, url, created_at) VALUES ($1, $2, $3)`,
link.Key, link.URL, link.CreatedAt)
return err
}key に UNIQUE 制約を張ると、衝突の検出をデータベースに任せられます。
アプリ側でチェックしてから INSERT すると、その隙間に別のリクエストが割り込みます。
一意性の保証は、最後の砦であるデータベースに持たせます。
docker-compose.yml に PostgreSQL を足せば、手元でもそのまま動きます。
2. 認証を足す(セキュリティ セキュリティ)
今は誰でも作れて、誰でも全件一覧できます。
最低限、ListLinks は自分が作ったものだけを返すべきです。
API キーでも OAuth でも構いませんが、認可(誰が何をしてよいか)を ハンドラではなく service に置くのを忘れないでください。 入口が増えた時に、認可チェックが片方だけ抜けます。
3. gRPC に置き換える(スキーマと RPC protobuf と gRPC)
手順1で書いた .proto から生成して、薄い層を1枚足します。
type grpcServer struct {
linkv1.UnimplementedLinkServiceServer
svc *service.Service
}
func (g *grpcServer) CreateLink(ctx context.Context, req *linkv1.CreateLinkRequest) (*linkv1.CreateLinkResponse, error) {
link, err := g.svc.CreateLink(ctx, req.GetUrl())
if err != nil {
return nil, toStatusError(err) // ErrInvalidArgument → codes.InvalidArgument
}
return &linkv1.CreateLinkResponse{Link: toProto(link)}, nil
}やることは、writeServiceError を codes.InvalidArgument などに翻訳し直すだけです。
業務ロジックを HTTP から独立させておいた利益が、ここで返ってきます。
4. 転送数を数える(監視とオンコール 監視とオンコール)
「どのリンクが何回踏まれたか」は、短縮 URL サービスの本命の機能です。 ただし、転送のたびに DB を更新すると、書き込みがボトルネックになります。 非同期にする・まとめて書く・別のストアに置く、といった判断が要ります。
ここまで来ると、もうマイクロサービスの実務のマイクロサービスの話になります。
つまずきやすいところ
go build が通らない。
go mod init で指定したモジュール名と、import しているパスが一致しているか確認してください。
github.com/example/linkshort で init したのに linkshort/internal/store を import していると通りません。
curl で connection refused になる。
サーバーのログに server listening が出ているか、ポート番号が合っているかを見てください。
別のプロセスが 8080 を掴んでいることもあります(lsof -i :8080)。
page_token を渡すと 400 になる。
シェルで ?page_size=2&page_token=... をクォートせずに書くと、
& でコマンドがバックグラウンドに送られて URL が途中で切れます。URL 全体を ' で囲んでください。
テストが通るのにサーバーだと落ちる。
テストは1本ずつ順に走りますが、サーバーは同時に走ります。
go test -race ./... を試してください。ロック漏れはこれで出ます。
Pod が CrashLoopBackOff になる。
kubectl logs <pod> --previous で、死ぬ直前のログを見ます。
distroless なので kubectl exec で入って調べることはできません。ログが唯一の手がかりです。
Pod が Running なのに繋がらない。
readinessProbe が通っていない可能性が高いです。
kubectl describe pod <pod> の Events を見てください。probe の失敗理由が書いてあります。
イメージを更新したのに古いまま動く。
kind / minikube では、ローカルでビルドしたイメージをクラスタに読み込ませる必要があります
(kind load docker-image linkshort:dev)。
タグが :latest のままだと、更新されたかどうかも分かりません。タグは必ず変えてください。
まとめ
この章でやったことを、章の地図として並べ直します。
| 手順 | やったこと | 対応する章 |
|---|---|---|
| 1 | .proto で API の形を決めた | スキーマと RPC protobuf と gRPC / API を設計する |
| 2 | モジュールと Compose を用意した | 第1部 開発環境を作る |
| 3 | インターフェースで切って実装した | Go Go / 読まれるコードを書く 読まれるコード |
| 4 | table-driven test を書いた | テストを書く テストを書く |
| 5 | HTTP で公開し、構造化ログを入れた | Web と HTTP Web と HTTP / 監視とオンコール 監視 |
| 6 | curl で叩いて、ログを読んだ | コマンドラインで生きる コマンドライン |
| 7 | 症状から原因を切り分けた | 第2部 デバッグの技術 |
| 8 | マルチステージでコンテナにした | コンテナと Kubernetes Kubernetes / セキュリティ セキュリティ |
| 9 | probe と limits 付きでデプロイした | コンテナと Kubernetes Kubernetes / クラウドの基礎 クラウド |
| 10 | CI で判定させた | CI/CD CI/CD |
| 11 | SLO とアラートを決めた | 監視とオンコール 監視とオンコール |
改めて見ると、24章分の知識が、たった1つの短縮 URL サービスに全部使われています。 現場の仕事も、これと同じ構造です。 規模が大きくなり、関わる人が増え、壊れた時の影響が広がるだけで、 やることの並びは変わりません。
そして、この章で本当に伝えたかったのは技術の使い方ではありません。
分けて考える、ということです。
保存先を分けたからテストが書けました。 業務ロジックを HTTP から分けたから、gRPC に載せ替えられます。 症状と原因を分けたから、アラートが意味を持ちました。 液体のように混ざったコードは、どこから触ればいいか分かりません。
分けるべきところで分ける。これができるかどうかだけが、 半年後にそのコードを触れるかどうかを決めます。
ここまで読んだあなたは、もう「作れる人」です。 最初のうちは遅くていいので、この12手順をもう一度、自分のアイデアで通してみてください。 2周目からは、覚えるのではなく選べるようになります。
ようこそ、現場へ。ここからが本番です。
次の章では、ここで作ったものを宣言したゴールまで引き上げます。 保存先を Spanner に、通信を gRPC に、実行の場を Kubernetes に置き換え、 TypeScript の BFF を前に立てます。