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つの課題
- Schemaドリフト:複数環境のデータベース構造が不一致、本番と開発環境の差異が大きい
- マイグレーション失敗:大規模テーブルのALTER TABLEによるロックでサービス中断
- ロールバック困難:Downマイグレーションがない、または不可逆
- 協業の競合:複数人が同時にSchemaを変更し、マージコンフリクトが発生
- 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パラメータを確認 |
高度な最適化
- マイグレーションテスト:CIで各マイグレーションをup→down→upで検証
- マイグレーションLinter:sqlfluffやカスタムツールでマイグレーションスクリプトの規範性をチェック
- マルチデータベースサポート:ビルドタグで異なるデータベースのマイグレーションファイルを区別
- データバックフィル監視:大規模テーブルのバックフィル時に進捗を記録、再開対応
- ブルーグリーンデプロイ統合:マイグレーションとブルーグリーンデプロイを連携、真のゼロダウンタイムリリースを実現
比較分析
| 次元 | golang-migrate | Goose | Atlas | Liquibase |
|---|---|---|---|---|
| Goネイティブ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐ |
| マルチデータベース | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 宣言型マイグレーション | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| バージョン追跡 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Dirty修復 | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| CI統合 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 学習曲線 | 低 | 低 | 中 | 高 |
まとめ:データベースマイグレーションは本番級Goプロジェクトに不可欠な能力です。golang-migrateはシンプルなCLI、Goネイティブ統合、マルチデータベースサポートにより2026年の最適解です。核心原則:コードデプロイより先にマイグレーション、Downマイグレーションは必ず可逆、大規模テーブル変更は段階的実行、CI/CDで自動検証。
オンラインツール推奨
- JSONフォーマッター:/ja/json/format
- Hash計算:/ja/encode/hash
- cURL→コード変換:/ja/dev/curl-to-code
ブラウザローカルツールを無料で試す →
#Go数据库迁移#golang-migrate#Schema管理#数据库版本控制#2026#技术架构