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 全章)
- 3-1_database_sql の接続プール → 本章で メトリクスとして観測
- 3-2_CRUDハンドラ の
QueryContext→ graceful shutdown と連動 - 3-3_テスト基礎 / 3-4_テーブル駆動テスト のテスト → 本章コードを検証
- 3-5_認証 の Bearer Token → 本番チェックリストの「認証必須」
- 3-6_context の
signal.Notify+srv.Shutdown(ctx)→ 本章の核
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/prod1. 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の動作
- 新規接続の受付を停止: Listener を閉じる
- アイドル接続を閉じる: Keep-Alive で待機中の接続を即終了
- 進行中リクエストの完了を待つ: 渡された context のデッドラインまで
- デッドライン到達: 残ったリクエストは強制終了
ListenAndServe()は Shutdown が呼ばれるとhttp.ErrServerClosedを返す。これはエラーではなく正常終了 なのでerrors.Isで除外する。
SIGINT と SIGTERM
シグナル 送信元 意味 SIGINT Ctrl+C ターミナルから割り込み SIGTERM kill <pid>/ Docker / Kubernetes「丁寧に終わって」 SIGKILL kill -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/httpは panic を内部で 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 本由来:
- Latency: レスポンス時間(p50, p95, p99)
- Traffic: リクエスト数/秒
- Errors: 5xx率
- 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-ostやpt-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/rateのrate.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.ClientのTimeoutも設定(最終防衛線)
ログ
-
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 卒業課題)
- Level 2 で作った REST API に すべての本番要素 を載せる
Dockerfileを書いてマルチステージビルド(バイナリ + alpine ベース)docker composeで API + MySQL を起動golang-migrateでスキーマ初期化- VPS(Lightsail / DigitalOcean / さくらの VPS)にデプロイ
- nginx + Let’s Encrypt で HTTPS 化
/metricsをローカル Prometheus + Grafana に流して可視化- 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 probe livenessProbe readinessProbe
メトリクスの3つの型
型 用途 例 Counter 単調増加 リクエスト数、エラー数 Gauge 上下する瞬間値 接続数、メモリ Histogram 分布 レイテンシ、サイズ
4ゴールデンシグナル
シグナル 計測 Latency p50/p95/p99 レスポンス時間 Traffic リクエスト数/秒 Errors 5xx 率 Saturation CPU/メモリ/接続プール飽和度
設定管理ライブラリ
方法 推奨度 素の 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 + ログ
-
pprofをlocalhost: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 |
| pprof | import _ "net/http/pprof" + http.ListenAndServe("localhost:6060", nil) |
| Prometheus | prometheus.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、監視構築)に進む準備完了。
さらに深掘りするなら
- The Twelve-Factor App - 本番運用の聖典
- 公式: pkg.go.dev/log/slog
- 公式: pkg.go.dev/net/http/pprof
- prometheus/client_golang
- golang-migrate/migrate
- 書籍: 『Site Reliability Engineering』 (Google) - 4ゴールデンシグナル含む運用の原典
- 書籍: 『Let’s Go Further』 (Alex Edwards) - 本番品質 API を1冊で
- OSS:
caddyserver/caddy,traefik/traefik- Go で書かれた本番運用OSSの構造を読む
次のレベル
Level 4: インフラ章(Docker、Linux サーバー運用、Nginx、CI/CD、監視)へ。Level 3 で作った API を実際にインターネットに出して動かす段階に入る。