3-7. 本番品質の API - graceful shutdown・構造化ログ・メトリクス

所要時間: 50-60分 コミット内容: ~/learn/go/level3/day07/ に本番品質 API + 本番チェックリスト Level 3 卒業基準: 自作 API を VPS に Docker でデプロイし、HTTPS化・認証・監視ログまで通る


このレッスンのゴール - Level 3 の総決算

  • SIGINT/SIGTERM を捕捉して http.Server.Shutdown で graceful shutdown
  • log/slog で JSON 構造化ログ + リクエスト ID
  • /healthz(liveness)と /readyz(readiness)を分離
  • panic recover ミドルウェアでスタック + リクエスト ID をログ
  • net/http/pprof内部ポートで提供
  • Prometheus メトリクス(Counter/Histogram)+ 4ゴールデンシグナル
  • golang-migrate でマイグレーション運用
  • リトライ(exponential backoff)+ レート制限

なぜ学ぶか - Level 3 卒業基準

curl で 200 を返す状態 = 完成の30%」。本番に出すには「シャットダウンで取りこぼさない」「障害時に何が起きたか分かる」「誰でも見える稼働状態」「panic でサーバー全停止しない」「設定がコードに混ざらない」が必要。これを すべて1つの API に統合 するのが本章。クリアしたら **「Goで書かれた小規模サービスを1人で本番運用できる」**水準。

前章とのつながり(間隔反復・Level 3 全章)

Level 3 の全要素を1つの本番API に統合する。

これができると何が嬉しいか

  • 本番チェックリスト30項目 を即答できる
  • VPS / Lightsail で自作 API を運用 できる
  • 障害時に pprof で原因特定 できる
  • Grafana で SLO ダッシュボード を組める
  • Kubernetes へのデプロイ で困らない

ストーリー導入: 「深夜2時の呼び出し」を防ぐ章

サーバーが応答しない」「メモリ膨張中」「ゴルーチン10万」 - 深夜2時のアラートで叩き起こされた時、ログから何が分かるか で運命が分かれる。log.Println("done") だけでは原因不明で2時間溶ける。slog.Info("query slow", "duration_ms", 5000, "request_id", "...") + pprof でメモリプロファイル取得 → 5分で原因特定。本章は「深夜の呼び出しを最小化する装備一式」


大前提: 「動く API」と「本番に置ける API」の差

net/http で書いた API が curl で 200 を返す状態は、完成の30% です。本番に出すには:

  • シャットダウン時に進行中リクエストを取りこぼさない (graceful shutdown)
  • 障害時に「何が起きたか」が分かるログ (構造化ログ、リクエストID)
  • 誰でも見える稼働状態 (ヘルスチェック、メトリクス)
  • パニックでサーバー全停止しない (recover ミドルウェア)
  • 設定がコードに混ざらない (環境変数、シークレット管理)
  • スキーマの再現性 (DBマイグレーション)

これらを満たさないと「深夜に呼び出されて原因不明で2時間溶かす」運用になります。Level 3 の最後にして、ここから始まる Level 4「インフラ」への橋渡し。


セッション①: 起動と停止の品質(25-30分)

0. 録画と作業ディレクトリ

mkdir -p ~/log ~/learn/go/level3/day07
cd ~/learn/go/level3/day07
script ~/log/go_level3_day07.log
go mod init example.com/prod

1. graceful shutdown - 進行中リクエストを取りこぼさない

package main
 
import (
    "context"
    "errors"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)
 
func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    slog.SetDefault(logger)
 
    mux := http.NewServeMux()
    mux.HandleFunc("/healthz", healthz)
 
    srv := &http.Server{
        Addr:              ":8080",
        Handler:           mux,
        ReadHeaderTimeout: 5 * time.Second,
        ReadTimeout:       10 * time.Second,
        WriteTimeout:      10 * time.Second,
        IdleTimeout:       120 * time.Second,
    }
 
    // サーバ起動を goroutine で
    serverErr := make(chan error, 1)
    go func() {
        slog.Info("server starting", "addr", srv.Addr)
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            serverErr <- err
        }
    }()
 
    // シグナル待ち
    stop := make(chan os.Signal, 1)
    signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
 
    select {
    case err := <-serverErr:
        slog.Error("server error", "err", err)
        os.Exit(1)
    case sig := <-stop:
        slog.Info("shutdown signal received", "signal", sig)
    }
 
    // graceful shutdown
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    if err := srv.Shutdown(shutdownCtx); err != nil {
        slog.Error("shutdown error", "err", err)
        os.Exit(1)
    }
    slog.Info("server stopped cleanly")
}

http.Server.Shutdown の動作

  1. 新規接続の受付を停止: Listener を閉じる
  2. アイドル接続を閉じる: Keep-Alive で待機中の接続を即終了
  3. 進行中リクエストの完了を待つ: 渡された context のデッドラインまで
  4. デッドライン到達: 残ったリクエストは強制終了

ListenAndServe() は Shutdown が呼ばれると http.ErrServerClosed を返す。これはエラーではなく正常終了 なので errors.Is で除外する。

SIGINT と SIGTERM

シグナル送信元意味
SIGINTCtrl+Cターミナルから割り込み
SIGTERMkill <pid> / Docker / Kubernetes「丁寧に終わって」
SIGKILLkill -9強制終了(捕捉不能)

Kubernetes は Pod 終了時に SIGTERM を送って、30秒(terminationGracePeriodSeconds)待ってから SIGKILL する。30秒以内にシャットダウンする実装 が求められる。

タイムアウト設定なしのサーバーは絶対NG

// NG
srv := &http.Server{Addr: ":8080", Handler: mux}
// ReadTimeout 等を設定していない

クライアントが TCP 接続を開いて何も送らない(Slowloris 攻撃)と、その接続が永久に滞留して 接続枯渇DoS になる。

必須:

  • ReadHeaderTimeout: HTTP ヘッダを受信するタイムアウト(5秒目安)
  • ReadTimeout: ボディも含むリクエスト全体(10-30秒)
  • WriteTimeout: レスポンス送信のタイムアウト
  • IdleTimeout: Keep-Alive アイドル期間

Go 1.8 以降の http.Server でこれを設定するのは 本番運用の最低条件

2. 構造化ログ - log/slog

import "log/slog"
 
// JSON 出力
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
 
// 使い方
slog.Info("user logged in",
    "user_id", u.ID,
    "ip", r.RemoteAddr,
    "duration_ms", 42,
)
// 出力: {"time":"2026-05-14T10:30:00Z","level":"INFO","msg":"user logged in","user_id":1,"ip":"1.2.3.4","duration_ms":42}

log/slog は Go 1.21 標準

Go の構造化ログは長年 zap, zerolog, logrus などが乱立していたが、Go 1.21 で 公式標準 として log/slog が入った。

構造化ログの何が嬉しいか:

  • 検索可能: user_id=42 AND level=ERROR のようにフィルタ
  • 集計可能: Grafana Loki / Datadog / CloudWatch で集計
  • 機械可読: 例えば slow_query_duration_ms > 1000 でアラート発火

log.Println のような自由フォーマットは、Grafana の検索画面に流れた時点で価値がほぼゼロ。本番では構造化ログ一択。

// context ベースのロガー伝搬
type ctxKey string
 
const loggerKey ctxKey = "logger"
 
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
    return context.WithValue(ctx, loggerKey, l)
}
 
func LoggerFromContext(ctx context.Context) *slog.Logger {
    if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
        return l
    }
    return slog.Default()
}
 
// ミドルウェアでリクエストID + ロガーを context に
func RequestLogger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        reqID := r.Header.Get("X-Request-ID")
        if reqID == "" {
            reqID = newID() // UUID 生成
        }
        l := slog.Default().With("request_id", reqID, "path", r.URL.Path)
        ctx := WithLogger(r.Context(), l)
 
        start := time.Now()
        next.ServeHTTP(w, r.WithContext(ctx))
        l.Info("request completed", "duration_ms", time.Since(start).Milliseconds())
    })
}

リクエストIDをログに含める

1つのリクエストで複数のログ行が出る(受付→DB→外部API→レスポンス)。共通のリクエスト ID で結べると、障害調査で「このリクエストで何が起きたか」を grep 一発で追える。

慣習:

  • 入口で X-Request-ID ヘッダがあれば使う、なければ生成
  • レスポンスヘッダにも X-Request-ID を返す
  • ロードバランサ層で発行する設計も多い(AWS ALB, Cloudflare)

3. 設定管理 - 環境変数

type Config struct {
    Port         string
    DBDSN        string
    JWTSecret    string
    LogLevel     slog.Level
    Environment  string
}
 
func LoadConfig() (*Config, error) {
    c := &Config{
        Port:        getenv("PORT", "8080"),
        DBDSN:       os.Getenv("DB_DSN"),
        JWTSecret:   os.Getenv("JWT_SECRET"),
        Environment: getenv("APP_ENV", "development"),
    }
    if c.DBDSN == "" {
        return nil, errors.New("DB_DSN required")
    }
    if c.JWTSecret == "" {
        return nil, errors.New("JWT_SECRET required")
    }
    switch os.Getenv("LOG_LEVEL") {
    case "DEBUG":
        c.LogLevel = slog.LevelDebug
    case "WARN":
        c.LogLevel = slog.LevelWarn
    case "ERROR":
        c.LogLevel = slog.LevelError
    default:
        c.LogLevel = slog.LevelInfo
    }
    return c, nil
}
 
func getenv(key, defaultVal string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return defaultVal
}

「The Twelve-Factor App」の Config 原則

12factor.net の3番目「Config in the environment」。

  • コード: バージョン管理に入れる
  • 設定: 環境ごとに違うので環境変数で

なぜコードに書かない:

  • 環境(dev/staging/prod)ごとに別ブランチ管理になる
  • パスワード等が git に混入
  • ビルド成果物の usability(同じバイナリを複数環境で動かせる)が落ちる

環境変数ライブラリの選択肢

アプローチ特徴
素の os.Getenv標準依存ゼロ。明示的だが冗長
kelseyhightower/envconfigstruct タグで一括DBDSN, AppPort などをまとめて読む
caarlos0/env同上envconfig より新しい、機能多い
spf13/viperYAML + env + flag複雑になりがちで賛否両論
// envconfig 使用例
import "github.com/kelseyhightower/envconfig"
 
type Config struct {
    Port      string        `envconfig:"PORT" default:"8080"`
    DBDSN     string        `envconfig:"DB_DSN" required:"true"`
    Timeout   time.Duration `envconfig:"TIMEOUT" default:"30s"`
}
 
var cfg Config
envconfig.MustProcess("APP", &cfg) // APP_PORT, APP_DB_DSN, APP_TIMEOUT

シークレットを git に入れない

.env ファイルを git に commit する事故は今でも起きる。.env.gitignore に入れるgit-secret や AWS Secrets Manager で管理 する。

既に commit してしまった場合: git filter-repo で履歴から消す + 流出済みと見なしてシークレットを全部ローテーション。「漏れたかも」ではなく「漏れた」前提で動く。

4. ヘルスチェック - /healthz と /readyz

// /healthz: プロセスが生きているか
func healthz(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    _, _ = w.Write([]byte("ok"))
}
 
// /readyz: トラフィックを受けられる状態か(DB接続OKか等)
func readyz(db *sql.DB) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
        defer cancel()
        if err := db.PingContext(ctx); err != nil {
            http.Error(w, "db not ready", http.StatusServiceUnavailable)
            return
        }
        w.WriteHeader(http.StatusOK)
    }
}

/healthz と /readyz の役割分離

エンドポイントチェック内容失敗時の動作
/healthz (liveness)プロセスが生きているかプロセス再起動
/readyz (readiness)トラフィックを受けられるかLB から外す

Kubernetes での例:

  • liveness probe 失敗 → Pod を再起動
  • readiness probe 失敗 → Service エンドポイントから外す(プロセスは生かす)

なぜ分けるか: 「起動直後の DB 接続待ち」では readiness を失敗させても、liveness は OK。「再起動でなく、トラフィックだけ止めて欲しい」状況がある。

/healthz で重い DB チェックをしない

liveness で DB ping すると、「DB が遅い → liveness 失敗 → プロセス再起動 → さらに DB 負荷増」の悪循環。

/healthz は浅く、/readyz は深く が原則。

5. リカバリミドルウェア

func RecoverPanic(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                stack := debug.Stack()
                slog.Error("panic recovered",
                    "panic", rec,
                    "stack", string(stack),
                    "path", r.URL.Path,
                )
                w.Header().Set("Connection", "close") // 接続も切る
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

panic を recover でキャッチ

Go の panic は、捕捉されないと そのgoroutine が死ぬnet/httppanic を内部で recover するが、その挙動は「接続を切る・スタックは隠す」と最低限。

自前のリカバリミドルウェアで:

  • 詳細なスタックトレースをログ出力debug.Stack()
  • リクエスト ID をログに紐付け (後追い調査用)
  • クライアントには 500 を返す(panic 内容は隠す → 情報漏洩防止)

panic と error の使い分け

Go では 想定内のエラーは error 戻り値想定外(nil pointer、配列範囲外)は panic が慣習。

エラー戻り値を panic に置き換える書き方(Java の例外のように使う)は Go 文化に反する。panic は本当に「サーバー継続不能」なときのみ

6. アンチパターン総まとめ

Shutdown 実装なし

os.Exit(0) で即終了 → 進行中リクエストが切断される。ユーザーが操作中の決済が中断みたいな悲劇に。

log.Println 多用

log.Println("user", u.ID, "did something")

構造化されておらず、Grafana で検索不能。slog.Info に置き換え

パスワード等をハードコード

dbPassword := "myproductionpass123"

平文の git commit → リポジトリ漏洩で全DB乗っ取り。環境変数 + シークレット管理

ヘルスチェック未実装

Kubernetes / ALB / nginx upstream が「生きてるか分からない」 → 障害検知が遅れる。


セッション②: 運用視点と本番チェック(25-30分)

7. pprof - パフォーマンス調査

import (
    _ "net/http/pprof"
    "net/http"
)
 
// pprof を別ポートで(公開しないこと)
go func() {
    slog.Info("pprof listening", "addr", "localhost:6060")
    http.ListenAndServe("localhost:6060", nil)
}()
# CPU プロファイル取得
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
 
# heap プロファイル
go tool pprof http://localhost:6060/debug/pprof/heap
 
# goroutine 数
curl http://localhost:6060/debug/pprof/goroutine?debug=1

net/http/pprof の力

ブランク import するだけで /debug/pprof/* 配下にプロファイリングエンドポイントが生える。

  • CPU プロファイル: ホットスポット特定
  • heap プロファイル: メモリ使用箇所
  • goroutine 数: goroutine リーク発見
  • block / mutex: ロック待ち

本番障害で「CPU使用率が異常」「メモリが膨張」「ゴルーチンが10万」みたいな時、pprof があると数分で原因特定できる。

pprof を公開ポートで露出させない

// NG
http.ListenAndServe(":8080", nil) // メインサーバーと同じポート + DefaultServeMux に pprof が登録される

インターネット越しに /debug/pprof/heap が叩けると、heap ダンプ → 機密情報漏洩プロファイル取得でDoS など事故多発。

必ず内部ポート(127.0.0.1)か、認証付き で公開する。

8. メトリクス - Prometheus

import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)
 
var requestsTotal = prometheus.NewCounterVec(
    prometheus.CounterOpts{
        Name: "http_requests_total",
        Help: "Total HTTP requests",
    },
    []string{"method", "path", "status"},
)
 
var requestDuration = prometheus.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    "http_request_duration_seconds",
        Help:    "HTTP request latency",
        Buckets: prometheus.DefBuckets,
    },
    []string{"method", "path"},
)
 
func init() {
    prometheus.MustRegister(requestsTotal, requestDuration)
}
 
// ミドルウェア
func Metrics(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        sw := &statusWriter{ResponseWriter: w, code: 200}
        next.ServeHTTP(sw, r)
        elapsed := time.Since(start).Seconds()
 
        requestsTotal.WithLabelValues(r.Method, r.URL.Path, fmt.Sprint(sw.code)).Inc()
        requestDuration.WithLabelValues(r.Method, r.URL.Path).Observe(elapsed)
    })
}
 
// エンドポイント
mux.Handle("/metrics", promhttp.Handler())

メトリクスの3つの型

用途
Counter単調増加(累積)リクエスト数、エラー数
Gauge上下する瞬間値接続数、メモリ使用量
Histogram分布レイテンシ、レスポンスサイズ

Prometheus は pull モデル: Prometheus サーバーが定期的に /metrics を叩いて取得。Grafana で可視化、Alertmanager で通知。

本番で見るべきメトリクスの「4ゴールデンシグナル」

Google SRE 本由来:

  1. Latency: レスポンス時間(p50, p95, p99)
  2. Traffic: リクエスト数/秒
  3. Errors: 5xx率
  4. Saturation: CPU、メモリ、接続プールの飽和度

これだけ計測しておけば、障害の入口は大体捕まる。

9. DBマイグレーション - golang-migrate

go install -tags 'mysql' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
 
# マイグレーションファイル作成
migrate create -ext sql -dir migrations -seq create_users_table
# → migrations/000001_create_users_table.up.sql
# → migrations/000001_create_users_table.down.sql
 
# 実行
migrate -path migrations -database "mysql://user:pass@tcp(host:3306)/db" up
 
# ロールバック
migrate -path migrations -database "..." down 1
-- 000001_create_users_table.up.sql
CREATE TABLE users (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    name VARCHAR(100) NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
 
-- 000001_create_users_table.down.sql
DROP TABLE users;

マイグレーションの意義

「DBスキーマの変更を、コードと同じくバージョン管理する」仕組み。

  • 再現性: 新環境でも migrate up でスキーマが揃う
  • 巻き戻し: 失敗時に migrate down 1 で1つ前へ
  • 共同作業: チームメンバーが同じスキーマで開発できる

「dump して共有」では本番反映時に手作業が混ざって事故る。マイグレーションファイル = SQL の git と考える。

本番でのマイグレーション運用

  • down マイグレーション は危険(DROP COLUMN で本番データ消失)。書いても本番では実行しない運用が多い
  • アプリ起動と同時に migrate up はマルチインスタンスで競合する。デプロイ前に1回だけ 実行する設計に
  • 大量行のテーブルへの ALTER はロックで本番停止。gh-ostpt-online-schema-change を使う

10. リトライとサーキットブレーカー

// 簡単なリトライ(exponential backoff)
func retryWithBackoff(ctx context.Context, op func() error, maxAttempts int) error {
    var err error
    backoff := 100 * time.Millisecond
    for i := 0; i < maxAttempts; i++ {
        err = op()
        if err == nil {
            return nil
        }
        if !isRetryable(err) {
            return err
        }
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(backoff):
        }
        backoff *= 2
    }
    return err
}

リトライ戦略の常識

  • 冪等な操作のみリトライ: GET、PUT は OK。POST(決済等)は要注意
  • exponential backoff + jitter: 一斉リトライでサーバー過負荷を避ける
  • 最大回数を切る: 無限リトライは絶対NG
  • 5xx でリトライ、4xx でしない: 4xx は再試行しても結果が変わらない
  • タイムアウトを切る: ctx 経由で全体期限を持つ

サーキットブレーカー

「外部 API が連続して失敗したら、しばらくリクエストを送らない」パターン。OSS: sony/gobreaker

  • Closed: 通常モード
  • Open: 失敗多発 → リクエストを通さない(即エラー)
  • Half-Open: 一定時間後に1回だけ通して様子見

マイクロサービス間通信で必須。1サービスの障害が連鎖して全部死ぬ カスケード障害を防ぐ。

11. レート制限

import "golang.org/x/time/rate"
 
func RateLimit(rps int) func(http.Handler) http.Handler {
    limiter := rate.NewLimiter(rate.Limit(rps), rps*2) // バースト分も許容
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if !limiter.Allow() {
                http.Error(w, "too many requests", http.StatusTooManyRequests)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

レート制限の役割

  • 悪意ある攻撃の防御: ブルートフォース、スクレイピング
  • 公平な利用: 1ユーザーがリソースを独占しないように
  • DB過負荷予防: 急なトラフィック増で DB が落ちないように

Go 標準周辺の golang.org/x/time/raterate.Limiter は token bucket 実装。グローバルなレート制限なら上の例、ユーザー単位なら map[userID]*rate.Limiter + Mutex で管理。

12. すべて統合した main.go

func main() {
    cfg, err := LoadConfig()
    if err != nil {
        slog.Error("config error", "err", err)
        os.Exit(1)
    }
 
    setupLogger(cfg.LogLevel)
    slog.Info("starting", "env", cfg.Environment)
 
    // DB
    db, err := openDB(cfg.DBDSN)
    if err != nil {
        slog.Error("db open", "err", err)
        os.Exit(1)
    }
    defer db.Close()
 
    // ハンドラ組み立て
    mux := http.NewServeMux()
    mux.HandleFunc("/healthz", healthz)
    mux.Handle("/readyz", readyz(db))
    mux.Handle("/metrics", promhttp.Handler())
    mux.Handle("/api/", apiRoutes(db, cfg))
 
    // ミドルウェアチェーン
    handler := RecoverPanic(Metrics(RequestLogger(mux)))
 
    // pprof は別ポート
    go http.ListenAndServe("localhost:6060", nil)
 
    // サーバ起動 + graceful shutdown
    srv := &http.Server{
        Addr:              ":" + cfg.Port,
        Handler:           handler,
        ReadHeaderTimeout: 5 * time.Second,
        ReadTimeout:       30 * time.Second,
        WriteTimeout:      30 * time.Second,
        IdleTimeout:       120 * time.Second,
    }
    runWithGracefulShutdown(srv)
}

本番チェックリスト(コピペして次のプロジェクトで使う)

ネットワーク・タイムアウト

  • http.Server のタイムアウト4種を設定
  • 外部 API 呼び出しに context.WithTimeout
  • http.ClientTimeout も設定(最終防衛線)

ログ

  • log/slog で JSON 構造化ログ
  • リクエスト ID を全ログに含める
  • パスワード・トークン等を 絶対にログに出さない

観測

  • /healthz + /readyz 分離
  • /metrics で Prometheus 出力
  • pprof は内部ポートで露出
  • 4ゴールデンシグナル(latency, traffic, errors, saturation)を計測

障害対応

  • graceful shutdown 実装
  • panic recover ミドルウェア
  • DB 接続プール設定
  • リトライ戦略(冪等な操作のみ、exponential backoff)
  • サーキットブレーカー検討(外部依存があるなら)

セキュリティ

  • HTTPS のみ(Cookie に Secure
  • パスワードは bcrypt cost=12+
  • SQL injection 対策(プレースホルダ)
  • CORS 設定
  • レート制限
  • CSP / X-Content-Type-Options 等のセキュリティヘッダ

設定・シークレット

  • 設定は環境変数経由
  • シークレットは git に入れない
  • .env.gitignore
  • 本番では Secrets Manager / Vault 等を使用

データベース

  • マイグレーション運用(golang-migrate)
  • 接続プール(MaxOpenConns, MaxIdleConns, ConnMaxLifetime)
  • N+1 を全クエリで点検

デプロイ

  • Dockerfile でマルチステージビルド(バイナリだけ含める)
  • 環境ごとの設定が同じバイナリで動く
  • バージョンタグ(git tag)で本番リリース

練習課題(Level 3 卒業課題)

  1. Level 2 で作った REST API に すべての本番要素 を載せる
  2. Dockerfile を書いてマルチステージビルド(バイナリ + alpine ベース)
  3. docker compose で API + MySQL を起動
  4. golang-migrate でスキーマ初期化
  5. VPS(Lightsail / DigitalOcean / さくらの VPS)にデプロイ
  6. nginx + Let’s Encrypt で HTTPS 化
  7. /metrics をローカル Prometheus + Grafana に流して可視化
  8. SIGTERM テスト: kill -TERM <pid> してログが「shutdown signal received」→「stopped cleanly」と流れることを確認

締め: Level 3 完了コミット

cd ~/learn/go/level3/day07
git add .
git commit -m "feat(go): 本番品質の API を完成(Level 3 卒業)"
git tag -a v0.3.0 -m "Level 3 完了"

アンチパターン集 - 本番事故の典型

本番運用の地雷

1. Shutdown 実装なし os.Exit(0) で即終了 → 進行中決済が中断。ユーザー苦情。

2. log.Println 多用 構造化されておらず Grafana 検索不能。slog.Info に置換。

3. パスワードハードコード 平文 git commit → リポジトリ漏洩で全 DB 乗っ取り。環境変数 + Secrets Manager

4. ヘルスチェック無 K8s/ALB が「生きてるか」判定不能。/healthz /readyz 分離。

5. pprof を公開ポートで露出 インターネットから /debug/pprof/heap 叩ける = heap ダンプで機密漏洩。内部ポート(127.0.0.1)か認証必須

6. liveness で重い DB ping DB遅い → liveness 失敗 → Pod再起動 → DB負荷増 の悪循環。/healthz は浅く、/readyz は深く。

7. 全 panic を error で握る Go 文化に反する。想定内は error、想定外(nil 参照)は panic。

8. リトライで指数バックオフなし 一斉リトライで DB/API を更に殺す。exponential backoff + jitter

9. POST をリトライ 決済が二重発火。冪等な GET/PUT のみ リトライ。

10. マイグレーションをアプリ起動時に実行 マルチインスタンスで競合。デプロイ前に1回だけ実行。

対比表で違いを明確化

/healthz vs /readyz

観点/healthz (liveness)/readyz (readiness)
何を見るプロセス生存トラフィック受け入れ可能
失敗時Pod 再起動LB から外す
DB pingしない(軽く)する(深く)
K8s probelivenessProbereadinessProbe

メトリクスの3つの型

用途
Counter単調増加リクエスト数、エラー数
Gauge上下する瞬間値接続数、メモリ
Histogram分布レイテンシ、サイズ

4ゴールデンシグナル

シグナル計測
Latencyp50/p95/p99 レスポンス時間
Trafficリクエスト数/秒
Errors5xx 率
SaturationCPU/メモリ/接続プール飽和度

設定管理ライブラリ

方法推奨度
素の os.Getenv◯ 依存ゼロ
kelseyhightower/envconfig◎ struct タグで一括
caarlos0/env◯ 後発、機能多い
spf13/viper△ 複雑

自己評価チェックリスト

手を動かせた

  • signal.Notify + srv.Shutdown(ctx) でgraceful shutdown
  • log/slog で JSON 構造化ログ
  • リクエスト ID ミドルウェアで全ログに付与
  • envconfig または os.Getenv で設定読み込み
  • /healthz(浅い) + /readyz(深い) 分離
  • panic recover ミドルウェアで stack + ログ
  • pproflocalhost:6060 で提供
  • Prometheus Counter + Histogram + /metrics
  • golang-migrate で up/down マイグレーション
  • rate.NewLimiter でレート制限
  • exponential backoff リトライ
  • Dockerfile マルチステージビルド
  • VPS にデプロイ + HTTPS化

説明できる

  • graceful shutdown が必要な理由(K8s SIGTERM + 30秒)
  • 構造化ログが Grafana で価値を生む仕組み
  • liveness と readiness の役割分離
  • 4ゴールデンシグナル(Latency/Traffic/Errors/Saturation)
  • サーキットブレーカーの 3状態(Closed/Open/Half-Open)

やらかし回避

  • pprof を公開ポートに出さない
  • パスワードを git に入れない
  • liveness で DB を ping しない
  • POST をリトライしない
  • アプリ起動時にマイグレーション実行しない

詰まった時のチートシート

やりたいこと書き方
シグナル待ちsignal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
graceful停止srv.Shutdown(ctx)
JSON ログslog.New(slog.NewJSONHandler(os.Stdout, opts))
ログにフィールドslog.Info("msg", "key", value, ...)
環境変数os.Getenv("KEY") または envconfig
pprofimport _ "net/http/pprof" + http.ListenAndServe("localhost:6060", nil)
Prometheusprometheus.NewCounterVec(...) + promhttp.Handler()
マイグレーションmigrate -path ... -database ... up
レート制限rate.NewLimiter(rate.Limit(rps), burst)
panic 復帰defer func() { if r := recover(); r != nil { ... } }()

「実務OK」基準(Level 3 卒業)

  • 本番チェックリストを30個以上即答できる
  • 「動く API」と「本番に置ける API」の差を10個以上具体的に挙げられる
  • SIGTERM 後の挙動を語れる: Kubernetes 30秒以内に shutdown 完了
  • slog でログを書ける: フィールドを意識して構造化
  • pprof で CPU プロファイルを取れる: 本番障害で原因特定の道具に
  • マイグレーションファイル up/down を書ける
  • panic recover ミドルウェアの意義を語れる
  • 自作APIをVPSに Docker でデプロイし、HTTPS化、認証、監視ログまで通る

これができれば、「Goで書かれた小規模サービスを1人で本番運用できる」 水準。Level 4 のインフラ章(VPS、Docker、CI/CD、監視構築)に進む準備完了。


さらに深掘りするなら


次のレベル

Level 4: インフラ章(Docker、Linux サーバー運用、Nginx、CI/CD、監視)へ。Level 3 で作った API を実際にインターネットに出して動かす段階に入る。