Go + Ginアプリを本番品質に仕上げる:設定・構成・CI導入まで

  • golang
    golang
  • gin
    gin
  • github
    github
2023/12/06に公開

はじめに

これまで本シリーズでは、Go × Gin を使ってブログ記事投稿用の Web API を構築し、MVC構成の基本からテスト戦略の実装まで段階的に進めてきました。

  • Part1 では、アプリの基本構造を設計し、Ginを使って最小限のAPIを立ち上げました。
  • Part2 では、サービス・ハンドラー単位のテストから統合テストまでを通じて、信頼性の高いコードを育てるための実践方法を紹介しました。

https://shinagawa-web.com/blogs/go-gin-mvc-basics

https://shinagawa-web.com/blogs/go-gin-mvc-testing

この Part3 は、いよいよシリーズの最終回です。

ここからは「テストが通る」だけでは足りない、本番品質のアプリケーションとしての完成度を高めるために、以下のような観点を扱っていきます。

  • 開発・本番で切り替え可能な設定管理
  • 保守性を高めるログ設計とエラーハンドリング
  • プロジェクトの再現性を支えるタスク管理(Makefile / Taskfile)
  • 実運用に備えたCI構成(GitHub Actions)
  • ローカル開発環境を本番に近づけるDocker構成

どれも「アプリケーションが動く」状態から「運用できる」「安心して育てられる」構成に進化させるために欠かせない要素です。

特にこのパートでは、インメモリ構成のままでもできる工夫を重視し、DBを使わずとも本番を意識した設計の第一歩を踏み出せる構成にしています。

それでは一緒に、Go + Gin アプリを本番品質へと仕上げていきましょう。

今回作成したコードは下記のリポジトリに載せています。

https://github.com/shinagawa-web/go-gin-blog-api

環境ごとに設定を切り替える:設定管理のベストプラクティス

開発・本番・テストといった複数の環境に対応するために、環境ごとの設定切り替えは必須です。本番環境ではログレベルやポート番号、外部サービスのURLなどが開発環境とは異なるのが一般的です。

Goアプリケーションでも、設定の外部化と環境変数の活用が基本になります。

config パッケージの導入

まず、アプリケーション全体の設定を一元管理する config パッケージを作成します。

config/config.go
package config

import (
	"log"
	"os"

	"github.com/joho/godotenv"
)

type Config struct {
	Env      string
	Port     string
	LogLevel string
}

var AppConfig *Config

func Load() {
	_ = godotenv.Load()

	AppConfig = &Config{
		Env:      getEnv("APP_ENV", "development"),
		Port:     getEnv("PORT", "8080"),
		LogLevel: getEnv("LOG_LEVEL", "debug"),
	}
}

func getEnv(key, fallback string) string {
	if val := os.Getenv(key); val != "" {
		return val
	}
	return fallback
}

.env ファイルの例

ルートディレクトリに .env ファイルを作成し、以下のように記述します:

APP_ENV=development
PORT=8080
LOG_LEVEL=debug

これにより、環境変数の管理がコードと分離され、環境ごとの .env ファイルを切り替えることで柔軟な構成が可能になります。

main.go 側での適用

main.go
package main

import (
+	"fmt"
+	"go-gin-blog-api/config"
	"go-gin-blog-api/handler"
	"go-gin-blog-api/repository"
	"go-gin-blog-api/service"
+	"log"

	"github.com/gin-gonic/gin"
)

func main() {
+	config.Load()
	r := gin.Default()

	r.GET("/healthz", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"status": "ok",
		})
	})

	postRepo := repository.NewPostRepository()
	postService := service.NewPostService(postRepo)
	postHandler := handler.NewPostHandler(postService)
	postHandler.RegisterRoutes(r)

- r.Run(":8080") // ポート8080で起動
+	addr := fmt.Sprintf(":%s", config.AppConfig.Port)
+	log.Printf("Server running on %s (%s)", addr, config.AppConfig.Env)
+	r.Run(addr)
}

サーバーを起動するとコンソールにログが出力されます。

2025/06/03 08:15:26 Server running on :8080 (development)

ログ設計とエラーハンドリングの改善

本番運用において、「何が起こっているか」を把握するには、ログの出力とエラーハンドリングが不可欠です。このセクションでは、最低限押さえておくべきログの仕組みと、シンプルなエラーハンドリングの方針を紹介します。

ログ出力の目的

  • リクエストの追跡・トラブルシュート
  • パフォーマンス分析(処理時間、頻度など)
  • セキュリティ監査(不正アクセス、失敗ログインなど)

開発中だけでなく、本番環境での保守性に直結します。

Ginのログ機能を活用する

Ginはミドルウェアとしてログを提供しています。以下はデフォルトのログ出力を使った例です。

r := gin.New()
r.Use(gin.Logger())      // リクエストログ
r.Use(gin.Recovery())    // panic をキャッチして500を返す

ログ出力は標準出力(stdout)に流れるため、Dockerやクラウドのログ収集機構と親和性が高いです。

ログレベルと出力形式を整える

本格的にログを扱うなら、構造化ログ出力に対応したライブラリの導入を検討しましょう。たとえば uber-go/zap は高速・構造化・使いやすさで人気です。

https://github.com/uber-go/zap

Gin アプリに zap ロガーを導入する手順

logger/logger.go
package logger

import (
	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"
)

var Log *zap.Logger

func Init(env string) error {
	var err error
	if env == "production" {
		Log, err = zap.NewProduction()
	} else {
		cfg := zap.NewDevelopmentConfig()
		cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
		Log, err = cfg.Build()
	}
	return err
}
var Log *zap.Logger
  • グローバルに使えるロガーインスタンス。
  • 他のパッケージから logger.Log.Info(...) のように使う。
		cfg := zap.NewDevelopmentConfig()
		cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
		Log, err = cfg.Build()
  • 開発用では「INFO」「WARN」などのレベルに色を付ける設定をしています。

main.go 側での適用

main.go
package main

import (
	"fmt"
	"go-gin-blog-api/config"
	"go-gin-blog-api/handler"
+	"go-gin-blog-api/logger"
	"go-gin-blog-api/repository"
	"go-gin-blog-api/service"
	"log"

	"github.com/gin-gonic/gin"
+	"go.uber.org/zap"
)

func main() {
	cfg := config.Load()
+	if err := logger.Init(cfg.Env); err != nil {
+		log.Fatalf("failed to init logger: %v", err)
+	}
+	defer logger.Log.Sync()

- r := gin.Default()
+	r := gin.New()
+	r.Use(gin.Recovery())
+	r.Use(GinZapMiddleware())

	r.GET("/healthz", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"status": "ok",
		})
	})

	postRepo := repository.NewPostRepository()
	postService := service.NewPostService(postRepo)
	postHandler := handler.NewPostHandler(postService)
	postHandler.RegisterRoutes(r)

	addr := fmt.Sprintf(":%s", config.AppConfig.Port)
	log.Printf("Server running on %s (%s)", addr, config.AppConfig.Env)
	r.Run(addr)
}

+func GinZapMiddleware() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		path := c.Request.URL.Path
+		method := c.Request.Method
+
+		c.Next()
+
+		status := c.Writer.Status()
+		logger.Log.Info("request completed",
+			zap.String("method", method),
+			zap.String("path", path),
+			zap.Int("status", status),
+		)
+	}
}+

コードの解説

if err := logger.Init(cfg.Env); err != nil {
	log.Fatalf("failed to init logger: %v", err)
}
  • ここで zap を初期化しています。
  • 環境に応じて、ログ出力形式を 開発用(人間向け) or 本番用(JSON構造化) に切り替えます。
defer logger.Log.Sync()
  • ログ出力をフラッシュして、ログの書き残しを防ぐ重要な処理です。
func GinZapMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		path := c.Request.URL.Path
		method := c.Request.Method

		c.Next() // ハンドラーを実行

		status := c.Writer.Status()
		logger.Log.Info("request completed",
			zap.String("method", method),
			zap.String("path", path),
			zap.Int("status", status),
		)
	}
}

  • このミドルウェアで、リクエスト完了時にログを出力。リクエストのトレース・可観測性の向上に繋がります。

サーバーを起動してcurlなどでアクセスするとログが出てきます。
日時、URLパス、メソッド、HTTPステータスが確認できます。

Image from Gyazo

サーバー起動周りの整理:エントリーポイントの責務分離

main.go が肥大化しがちなのは、Goアプリによくある悩みのひとつです。
設定読み込み、ロガー初期化、ルーティング、DI、サーバー起動などがすべて main() に詰め込まれていると、以下の問題が生じます。

  • 関心の分離が不十分で、見通しが悪い
  • ユニットテストが困難
  • 責務が混在しており、変更に弱い

そこで、本セクションではmain.goの責務を適切に分離し、サーバー初期化処理をモジュール化していく方針を紹介します。

分離する責務一覧

責務 移譲先のモジュール例
設定の読み込み config.Load()
ロガーの初期化 logger.Init()
サーバーの初期化(DIなど) internal/server.New()
ルーティング登録 handler.RegisterRoutes()
アプリケーション起動 main()

internal/server パッケージの導入

server.gointernal/server/ に作成し、アプリケーション全体を初期化する関数を用意します。

internal/server/server.go
package server

import (
	"fmt"
	"go-gin-blog-api/config"
	"go-gin-blog-api/handler"
	"go-gin-blog-api/logger"
	"go-gin-blog-api/repository"
	"go-gin-blog-api/service"

	"github.com/gin-gonic/gin"
	"go.uber.org/zap"
)

func New() (*gin.Engine, error) {
	if err := logger.Init(config.AppConfig.Env); err != nil {
		return nil, fmt.Errorf("failed to init logger: %w", err)
	}

	r := gin.New()
	r.Use(gin.Recovery())
	r.Use(GinZapMiddleware())

	postRepo := repository.NewPostRepository()
	postService := service.NewPostService(postRepo)
	postHandler := handler.NewPostHandler(postService)
	postHandler.RegisterRoutes(r)

	r.GET("/healthz", func(c *gin.Context) {
		c.JSON(200, gin.H{"status": "ok"})
	})

	return r, nil
}

func GinZapMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		path := c.Request.URL.Path
		method := c.Request.Method

		c.Next()

		status := c.Writer.Status()
		logger.Log.Info("request completed",
			zap.String("method", method),
			zap.String("path", path),
			zap.Int("status", status),
		)
	}
}

main.go はこれだけになります。

main.go
package main

import (
	"fmt"
	"go-gin-blog-api/config"
	"go-gin-blog-api/internal/server"
	"go-gin-blog-api/logger"
	"log"
)

func main() {
	config.Load()
	r, err := server.New()
	if err != nil {
		log.Fatalf("failed to init logger: %v", err)
	}
	defer logger.Log.Sync()

	addr := fmt.Sprintf(":%s", config.AppConfig.Port)
	log.Printf("Server running on %s (%s)", addr, config.AppConfig.Env)
	r.Run(addr)
}

これで main.go の責務は "アプリの起動" のみに絞られ、以下のメリットが得られます。

  • 初期化の失敗時に安全に終了
  • server.New() はテストや CLI 向けツールでも再利用可能
  • main の責務が明確になることで 保守性が格段に向上

サーバーの Graceful Shutdown 対応

GoでWebサーバーを作った際、r.Run() だけで起動していると、SIGINT(Ctrl+C)や SIGTERM(プロセス終了)を受けたときに即座にプロセスが終了してしまい、処理中のリクエストが途中で切断されることがあります。

  • 本番環境では、アプリケーションのリロードやPodの再起動などが日常的に発生します。
  • その際、現在処理中のリクエストは可能な限り完了させてからサーバーを停止したい。
  • これを実現するのが「Graceful Shutdown(優雅な停止)」です。

main.goを修正します。

main.go
package main

import (
	"context"
	"fmt"
	"go-gin-blog-api/config"
	"go-gin-blog-api/internal/server"
	"go-gin-blog-api/logger"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"go.uber.org/zap"
)

func main() {
	config.Load()
	if err := logger.Init(config.AppConfig.Env); err != nil {
		log.Fatalf("failed to initialize logger: %v", err)
	}
	defer logger.Log.Sync()
	r, err := server.New()
	if err != nil {
		log.Fatalf("failed to create server: %v", err)
	}

	srv := &http.Server{
		Addr:    fmt.Sprintf(":%s", config.AppConfig.Port),
		Handler: r,
	}

	go func() {
		logger.Log.Info("starting server",
			zap.String("addr", srv.Addr),
			zap.String("env", config.AppConfig.Env),
		)
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			logger.Log.Fatal("server error", zap.Error(err))
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	logger.Log.Info("shutting down server...")

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := srv.Shutdown(ctx); err != nil {
		logger.Log.Fatal("forced shutdown", zap.Error(err))
	}

	logger.Log.Info("server exited gracefully")
}

Graceful Shutdownが正常に動作しているかを確認するためにレスポンスの遅いAPIを追加します。
リクエストを送ってから3秒後にレスポンスが返ってきます。

internal/server/server.go
	r.GET("/slow", func(c *gin.Context) {
		time.Sleep(3 * time.Second)
		c.JSON(200, gin.H{"message": "done"})
	})

実際にリクエストを送った後にサーバーを停止すると、リクエストを返してからサーバーが停止していることがログからもわかります。

Image from Gyazo

Makefile によるタスク管理

本番運用を視野に入れた開発において、開発・ビルド・テスト・実行といった定型作業を「手動で繰り返す」のは非効率です。
こうしたタスクを自動化し、誰が見ても、何度でも、同じ操作ができるようにするのが Makefile の役割です。

  • Goプロジェクトでは特に、次のような用途に向いています。
  • go rungo build の簡略化
  • go test ./...linters の実行
  • .env ファイルの読み込みと合わせた開発用起動
  • docker-compose の wrapper
  • CI/CD での再現性あるコマンド定義
run:
	go run main.go

build:
	go build -o app main.go

test:
	go test ./...

lint:
	go vet ./...

dev:
	APP_ENV=development go run main.go
  • make run:本番起動
  • make dev:環境変数つき開発起動
  • make test:すべてのテスト実行
  • make lint:静的解析(golangci-lint)

プロジェクトが大きくなるほど「ちょっとした作業」も属人化・煩雑化しがちです。
Makefile を導入しておけば、開発・テスト・デプロイの再現性が担保され、
結果としてチーム全体の生産性・信頼性が向上します。

テストやローカル起動を誰でも同じ手順で実行できる――そんな状態を目指しましょう。

CI/CDへの第一歩:GitHub Actionsでテスト自動化

.github/workflows/ci.yml の作成

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.21'

      - name: Install dependencies
        run: go mod download

      - name: Run vet
        run: go vet ./...

      - name: Run tests
        run: go test -v ./...
  • go vet ./...:Go標準の静的解析
  • go test -v ./...:すべてのパッケージに対してテスト実行
  • -v オプション:テスト出力をCIログに出すことで、デバッグしやすくなる

実行確認と失敗時のログ

GitHub の「Actions」タブから、実行結果やログを簡単に確認できます。
テストが失敗すると PR に「赤バツ」がつくため、壊れたコードがマージされにくくなるのも大きなメリットです。

Image from Gyazo

セキュリティと依存ライブラリの管理

本番運用で絶対に避けたいのが、知らぬ間に脆弱性を含んだアプリを動かし続けてしまうことです。このセクションでは、セキュリティを意識した依存管理と基本的な対策について解説します。

セキュリティ対策の基本チェックポイント

以下の項目は最低限確認しておきたいセキュリティ対策です。

項目 対策内容
不要な情報のログ出力防止 パスワードや認証トークンなどをログに出さない
HTTPのヘッダ設定 セキュリティヘッダ(例:Content-Security-PolicyX-Content-Type-Options)の追加
Panicのハンドリング panicが起きてもサーバーが止まらないようにする(recoverを使う)
入力バリデーション 特にAPIで受け取るパラメータはbindingvalidatorを使って厳密にチェック
CORS設定 必要最低限のドメインのみ許可する(c.Use(cors.New(corsConfig))など)

Go Modulesと依存ライブラリの監視

Goにはセキュリティチェックに便利なツールもいくつかあります。

govulncheck

Go公式の脆弱性スキャナ。Go1.18以降なら標準で使えます。

go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...

下記が出力結果例です。
例えば1番目ですが、Windows環境でosに関する脆弱性の問題があるようです。

=== Symbol Results ===

Vulnerability #1: GO-2025-3750
    Inconsistent handling of O_CREATE|O_EXCL on Unix and Windows in os in
    syscall
  More info: https://pkg.go.dev/vuln/GO-2025-3750
  Standard library
    Found in: os@go1.23
    Fixed in: os@go1.23.10
    Platforms: windows
    Example traces found:
      #1: config/config.go:4:2: config.init calls os.init, which calls os.Getwd
      #2: config/config.go:4:2: config.init calls os.init, which calls os.NewFile
      #3: config/config.go:18:19: config.Load calls godotenv.Load, which eventually calls os.Open
      #4: logger/logger.go:17:23: logger.Init calls zap.Config.Build, which eventually calls os.OpenFile
      #5: main.go:40:31: gin.main calls http.Server.ListenAndServe, which eventually calls os.ReadFile
      #6: main.go:40:31: gin.main calls http.Server.ListenAndServe, which eventually calls os.Remove
      #7: handler/post_handler.go:56:28: handler.PostHandler.UpdatePost calls gin.Context.ShouldBindJSON, which eventually calls os.Stat
      #8: config/config.go:4:2: config.init calls os.init, which eventually calls syscall.Open

Vulnerability #2: GO-2025-3749
    Usage of ExtKeyUsageAny disables policy validation in crypto/x509
  More info: https://pkg.go.dev/vuln/GO-2025-3749
  Standard library
    Found in: crypto/x509@go1.23
    Fixed in: crypto/x509@go1.23.10
    Example traces found:
      #1: config/config.go:18:19: config.Load calls godotenv.Load, which eventually calls x509.Certificate.Verify

Dependabot

GitHubリポジトリであれば、dependabot を有効にすることで問題のあるパッケージを検出してくれます。

下記が出力例です。
cryptoパッケージでCritical(緊急性の高い)アラートが出ています。

Image from Gyazo

パッケージのアップデートを実施した際は必ずテストを実施しましょう。
アップデートにより既存のコードが動かなくなるケースがあります。
コードの修正が必要なケースも多々ありますのでアップデートはこまめにかつ小さい単位で実施していくことをおすすめします。

Docker化による本番環境への布石

本番品質のアプリケーションを目指すうえで、「環境差異をなくす」ことは非常に重要です。Goアプリは単体でビルドしても動作しますが、Dockerによるコンテナ化をしておくと、次のようなメリットが得られます。

Docker化のメリット

  • 本番・ステージング・ローカルで同じ実行環境を再現できる
  • CI/CDでのデプロイパイプラインに組み込みやすい
  • 依存のインストール漏れやGoバージョン差異による事故を防げる

最小構成の Dockerfile

以下はGoアプリ向けの最小構成の Dockerfile です。

Dockerfile
# ビルドステージ
FROM golang:1.22 AS builder
WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN go build -o blog-api main.go

# 実行ステージ(軽量イメージ)
FROM gcr.io/distroless/base-debian11
WORKDIR /app

COPY --from=builder /app/blog-api .
COPY .env .env

EXPOSE 8080
ENTRYPOINT ["/app/blog-api"]

ビルド・実行方法

docker build -t go-gin-blog-api .
docker run -p 8080:8080 --env-file .env go-gin-blog-api

正常に起動できこれまでと同様curlでアクセスを確認できます。

Image from Gyazo

おわりに

ここまで、Go × Gin を使ったブログAPIの開発を進めてきました。
Part1でのMVC構成による基本設計から始まり、Part2ではモックや統合テストによる堅牢なテスト戦略を実践。
そしてPart3では、プロダクション品質を意識した設定・ロギング・Graceful Shutdown・CI導入・Docker化と、開発から運用に耐える仕組みを整えてきました。

本シリーズを通じて伝えたかったことは、「アプリが動くことと、アプリを育てていけることは別物だ」ということです。

動くコードを書くことは大切ですが、それにプラスして

  • チームで保守しやすい設計
  • 設定や環境の切り替えやすさ
  • 問題発生時にすぐ対応できる観測性
  • 変更に強いテスト体制
  • 安心してリリースできるCIの導入

といった「地に足のついた土台づくり」が、開発者としての力を高めてくれます。

これから先へ

このシリーズは一つの到達点ではありますが、技術的な改善はここからです。

  • 本格的なデータベース設計
  • 認証・認可の導入
  • フロントエンドとの連携(例えばNext.jsなど)
  • 本番デプロイ環境(Fly.io, Render, GCPなど)への移行
  • モニタリング(Prometheus, Grafana)やアラート設計

など、様々あります。

本サイトでも機会を見つけてこれらについてもご紹介していく予定です。

Xでシェア
Facebookでシェア
LinkedInでシェア

記事に関するお問い合わせ📝

記事の内容に関するご質問、ご意見などは、下記よりお気軽にお問い合わせください。
ご質問フォームへ

技術支援などお仕事に関するお問い合わせ📄

技術支援やお仕事のご依頼に関するお問い合わせは、下記よりお気軽にお問い合わせください。
お問い合わせフォームへ

関連する技術ブログ

弊社の技術支援サービス

お問い合わせ

経営と現場をつなぐ“共創型”の技術支援。
成果に直結するチーム・技術・プロセスを共に整えます。

お問い合わせ