Goデータベースマイグレーション実戦:golang-migrateでゼロダウンタイムSchemaバージョン管理を実現

技术架构

Goデータベースマイグレーション:なぜプロジェクトにバージョン管理が必要なのか

リリースのたびに手動でSQLを実行していませんか?複数人での協業時にSchemaの不一致が本番障害を引き起こしていませんか?ロールバック時にどの変更を取り消すべきかわからない状態になっていませんか?**データベースマイグレーション(Database Migration)**はこれらの問題を解決する核心的な手段です。2026年、golang-migrateはGoエコシステムで最も主流のマイグレーションツールとなり、MySQL、PostgreSQL、SQLiteなど15以上のデータベースをサポートしています。CI/CDと組み合わせることで、マイグレーションスクリプト→バージョン追跡→ゼロダウンタイム変更→自動ロールバックのフルパイプライン管理を実現します。

本記事では5つのコアパターンから、マイグレーション初期化→増分変更→ゼロダウンタイム戦略→ロールバック機構→CI/CD統合のフルパイプライン実戦を解説します。


コア概念

概念 説明
Migration データベースSchema変更のバージョン管理単位
golang-migrate GoデータベースマイグレーションCLIとライブラリ
Up Migration 前方へのSchema変更実行
Down Migration Schema変更のロールバック
Dirty State マイグレーション実行失敗によるデータベースバージョンロック
Zero-Downtime サービスを中断しないSchema変更戦略
Seed Data 初期データのマイグレーションスクリプト
Idempotent マイグレーションスクリプトが繰り返し実行可能でエラーにならない

問題分析:データベースマイグレーションの5つの課題

  1. Schemaドリフト:複数環境のデータベース構造が不一致、本番と開発環境の差異が大きい
  2. マイグレーション失敗:大規模テーブルのALTER TABLEによるロックでサービス中断
  3. ロールバック困難:Downマイグレーションがない、または不可逆
  4. 協業の競合:複数人が同時にSchemaを変更し、マージコンフリクトが発生
  5. CI統合:マイグレーションスクリプトが自動テストとデプロイフローに組み込まれていない

ステップバイステップ:5つのデータベースマイグレーションコアパターン

パターン1:golang-migrateのインストールとプロジェクト初期化

# macOS
brew install golang-migrate

# Linux
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.1/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/local/bin/

# Go install
go install -tags 'postgres mysql sqlite' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
migrate create -ext sql -dir db/migrations -seq init_users_table
-- db/migrations/000001_init_users_table.up.sql
CREATE TABLE IF NOT EXISTS users (
    id BIGSERIAL PRIMARY KEY,
    username VARCHAR(255) NOT NULL UNIQUE,
    email VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_username ON users(username);
-- db/migrations/000001_init_users_table.down.sql
DROP INDEX IF EXISTS idx_users_username;
DROP INDEX IF EXISTS idx_users_email;
DROP TABLE IF EXISTS users;

パターン2:Goコード統合と増分変更

// internal/database/migrate.go
package database

import (
	"fmt"
	"log"

	"github.com/golang-migrate/migrate/v4"
	_ "github.com/golang-migrate/migrate/v4/database/postgres"
	_ "github.com/golang-migrate/migrate/v4/source/file"
)

type Migrator struct {
	migrate *migrate.Migrate
}

func NewMigrator(databaseURL string) (*Migrator, error) {
	m, err := migrate.New("file://db/migrations", databaseURL)
	if err != nil {
		return nil, fmt.Errorf("failed to create migrator: %w", err)
	}
	return &Migrator{migrate: m}, nil
}

func (m *Migrator) Up() error {
	if err := m.migrate.Up(); err != nil && err != migrate.ErrNoChange {
		return fmt.Errorf("migration up failed: %w", err)
	}
	log.Println("Migrations applied successfully")
	return nil
}

func (m *Migrator) Down() error {
	if err := m.migrate.Steps(-1); err != nil && err != migrate.ErrNoChange {
		return fmt.Errorf("migration down failed: %w", err)
	}
	log.Println("Migration rolled back successfully")
	return nil
}

func (m *Migrator) Version() (uint, bool, error) {
	return m.migrate.Version()
}

func (m *Migrator) Force(version int) error {
	if err := m.migrate.Force(version); err != nil {
		return fmt.Errorf("force version failed: %w", err)
	}
	log.Printf("Forced migration version to %d", version)
	return nil
}

パターン3:ゼロダウンタイムマイグレーション戦略

-- db/migrations/000002_add_phone_column.up.sql
-- ステップ1:NULL許可列の追加(テーブルロックなし)
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
-- db/migrations/000003_phone_not_null.up.sql
-- データバックフィル完了後に実行
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
ALTER TABLE users ALTER COLUMN phone SET DEFAULT '';
// internal/database/backfill.go
package database

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"time"
)

type BackfillConfig struct {
	BatchSize    int
	DelayBetween time.Duration
	DryRun       bool
}

func BackfillPhoneNumbers(ctx context.Context, db *sql.DB, config BackfillConfig) error {
	if config.BatchSize <= 0 {
		config.BatchSize = 1000
	}
	if config.DelayBetween == 0 {
		config.DelayBetween = 100 * time.Millisecond
	}

	var totalUpdated int64

	for {
		var updated int64
		err := db.QueryRowContext(ctx, `
			WITH batch AS (
				SELECT id FROM users WHERE phone IS NULL LIMIT $1
			)
			UPDATE users SET phone = ''
			WHERE id IN (SELECT id FROM batch)
			RETURNING 1
		`, config.BatchSize).Scan(&updated)

		if err != nil {
			return fmt.Errorf("backfill batch failed: %w", err)
		}

		if updated == 0 {
			break
		}

		totalUpdated += updated
		log.Printf("Backfilled %d rows (total: %d)", updated, totalUpdated)

		if config.DryRun {
			log.Println("Dry run mode, stopping after first batch")
			break
		}

		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(config.DelayBetween):
		}
	}

	log.Printf("Backfill complete. Total rows updated: %d", totalUpdated)
	return nil
}

パターン4:ロールバック機構と安全戦略

// internal/database/safe_migrate.go
package database

import (
	"fmt"
	"log"

	"github.com/golang-migrate/migrate/v4"
)

type SafeMigrator struct {
	migrate *migrate.Migrate
	dryRun  bool
}

func NewSafeMigrator(databaseURL string, dryRun bool) (*SafeMigrator, error) {
	m, err := migrate.New("file://db/migrations", databaseURL)
	if err != nil {
		return nil, fmt.Errorf("failed to create migrator: %w", err)
	}
	return &SafeMigrator{migrate: m, dryRun: dryRun}, nil
}

func (sm *SafeMigrator) SafeUp() error {
	currentVersion, dirty, err := sm.migrate.Version()
	if err != nil && err != migrate.ErrNilVersion {
		return fmt.Errorf("failed to get current version: %w", err)
	}

	if dirty {
		return fmt.Errorf("database is in dirty state at version %d, run force first", currentVersion)
	}

	if sm.dryRun {
		log.Printf("[DRY RUN] Would apply migrations from version %d", currentVersion)
		return nil
	}

	if err := sm.migrate.Up(); err != nil && err != migrate.ErrNoChange {
		return fmt.Errorf("migration up failed: %w", err)
	}

	newVersion, _, _ := sm.migrate.Version()
	log.Printf("Migrated from version %d to %d", currentVersion, newVersion)
	return nil
}

パターン5:CI/CD統合と自動化

# .github/workflows/migrate.yml
name: Database Migration

on:
  push:
    paths:
      - 'db/migrations/**'
      - 'internal/database/**'

jobs:
  migrate-dry-run:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test_db
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: '1.24'

      - name: Install migrate
        run: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

      - name: Run migrations
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db?sslmode=disable
        run: |
          migrate -path db/migrations -database "$DATABASE_URL" up

      - name: Verify rollback
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db?sslmode=disable
        run: |
          migrate -path db/migrations -database "$DATABASE_URL" down 1
          migrate -path db/migrations -database "$DATABASE_URL" up

よくある落とし穴

落とし穴1:不可逆なマイグレーションスクリプト

-- ❌ 間違い:Downマイグレーションでデータが消失
ALTER TABLE users DROP COLUMN phone;

-- ✅ 正しい:データを保持するDownマイグレーション
ALTER TABLE users ALTER COLUMN phone DROP NOT NULL;
ALTER TABLE users ALTER COLUMN phone DROP DEFAULT;

落とし穴2:大規模テーブルでの直接ALTER TABLE

-- ❌ 間違い:NOT NULL列の追加でテーブルロック
ALTER TABLE users ADD COLUMN phone VARCHAR(20) NOT NULL DEFAULT '';

-- ✅ 正しい:段階的ゼロダウンタイムマイグレーション
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
-- アプリケーション層でデータバックフィル
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;

落とし穴3:Dirty Stateの未処理

# ❌ 間違い:dirty状態を無視してマイグレーション継続
migrate -path db/migrations -database "$DB_URL" up

# ✅ 正しい:先にforceでdirty状態を修復
migrate -path db/migrations -database "$DB_URL" force <version>
migrate -path db/migrations -database "$DB_URL" up

落とし穴4:データベース固有の構文使用

-- ❌ 間違い:PostgreSQL固有の構文、MySQLに移行不可
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;

-- ✅ 正しい:標準SQLまたは条件付きコンパイルを使用
-- PostgreSQL: ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
-- MySQL: ALTER TABLE users MODIFY COLUMN phone VARCHAR(20) NOT NULL;

落とし穴5:マイグレーションとコードデプロイの非同期

# ❌ 間違い:先にコードをデプロイしてからマイグレーション
kubectl apply -f deployment.yaml
migrate -path db/migrations up

# ✅ 正しい:先にマイグレーションしてからコードをデプロイ
migrate -path db/migrations up
kubectl apply -f deployment.yaml

エラートラブルシューティング

# エラーメッセージ 原因 解決方法
1 no change データベースは最新バージョン 正常、対応不要
2 dirty database マイグレーション中断 forceコマンドでバージョンを修復
3 syntax error in migration SQL構文エラー マイグレーションファイルのSQL構文を確認
4 migration locked 同時マイグレーション実行 一度に1つのマイグレーションプロセスのみ保証
5 column already exists 重複マイグレーション実行 バージョン番号を確認
6 permission denied データベースユーザー権限不足 CREATE/ALTER権限を付与
7 connection refused データベース接続失敗 DATABASE_URLとデータベース状態を確認
8 foreign key violation 外部キー制約による操作ブロック 関連データを先に処理
9 lock timeout 大規模テーブルDDLロックタイムアウト ゼロダウンタイム戦略で段階的実行
10 migration file not found マイグレーションファイルパスエラー migrate -pathパラメータを確認

高度な最適化

  1. マイグレーションテスト:CIで各マイグレーションをup→down→upで検証
  2. マイグレーションLinter:sqlfluffやカスタムツールでマイグレーションスクリプトの規範性をチェック
  3. マルチデータベースサポート:ビルドタグで異なるデータベースのマイグレーションファイルを区別
  4. データバックフィル監視:大規模テーブルのバックフィル時に進捗を記録、再開対応
  5. ブルーグリーンデプロイ統合:マイグレーションとブルーグリーンデプロイを連携、真のゼロダウンタイムリリースを実現

比較分析

次元 golang-migrate Goose Atlas Liquibase
Goネイティブ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐
マルチデータベース ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
宣言型マイグレーション ⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐
バージョン追跡 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
Dirty修復 ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐
CI統合 ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐
学習曲線

まとめ:データベースマイグレーションは本番級Goプロジェクトに不可欠な能力です。golang-migrateはシンプルなCLI、Goネイティブ統合、マルチデータベースサポートにより2026年の最適解です。核心原則:コードデプロイより先にマイグレーション、Downマイグレーションは必ず可逆、大規模テーブル変更は段階的実行、CI/CDで自動検証


オンラインツール推奨

ブラウザローカルツールを無料で試す →

#Go数据库迁移#golang-migrate#Schema管理#数据库版本控制#2026#技术架构