分類: Bug Fix、Go、Azure App Service、golang-migrate
對象: 任何將 Go 後端部署至 Azure Web App for Containers 的開發者
背景
將 Go 後端容器成功推送至 Container Registry 並部署至 Azure Web App 後,雖然映像已可正常拉取,容器卻在啟動階段反覆 crash。從 Log stream 可看到四個連環錯誤,每修一個就出現下一個。本文逐一記錄症狀、根本原因與修復方式。
Bug 1 — 資料庫連線失敗:Login failed for user ''
症狀
{"level":"fatal","msg":"Failed to run database migration",
"error":"failed to open database: mssql: login error: Login failed for user ''."}
容器一啟動就立即退出,完全沒有執行任何 migration。
根本原因
DATABASE_URL 中設定了 fedauth=ActiveDirectoryMSI,預期使用 Azure AD Managed Identity 認證,但 main.go 中匯入的是基礎 driver:
import _ "github.com/microsoft/go-mssqldb"
這個 driver 只支援 SQL 帳號密碼認證,完全忽略 fedauth= 參數,導致它以空白使用者名稱嘗試登入,SQL Server 因而拒絕連線。
Azure AD 認證有兩種正確做法:
| 做法 | 說明 |
|---|---|
改用 go-mssqldb/azuread | 匯入 _ "github.com/microsoft/go-mssqldb/azuread" 並使用 "azuresql" driver |
改用 golang-migrate 的 useMsi=true | 讓 migrate 函式庫自行透過 ADAL 取得 token |
本次採用第二種做法,同時也修改 initDatabasePool 以 ADAL token provider 建立 database/sql 連線:
修復方式
1. 修改連線字串(環境變數)
# 錯誤(會被 base driver 靜默忽略)
DATABASE_URL=sqlserver://db.example.com?database=mydb&fedauth=ActiveDirectoryMSI&...
# 正確
DATABASE_URL=sqlserver://db.example.com?database=mydb&useMsi=true&encrypt=true&TrustServerCertificate=false
2. 修改 Go 程式碼,用 ADAL 取得 MSI token
import (
"database/sql"
"net/url"
mssql "github.com/microsoft/go-mssqldb"
adal "github.com/Azure/go-autorest/autorest/adal"
)
func getMSITokenProvider(resource string) func() (string, error) {
return func() (string, error) {
spt, err := adal.NewServicePrincipalTokenFromManagedIdentity(resource, nil)
if err != nil {
return "", err
}
if err = spt.Refresh(); err != nil {
return "", err
}
return spt.Token().AccessToken, nil
}
}
func initDatabasePool(databaseURL string) (*sqlx.DB, error) {
u, err := url.Parse(databaseURL)
if err != nil {
return nil, err
}
useMsi := u.Query().Get("useMsi") == "true"
if useMsi {
tokenProvider := getMSITokenProvider("https://database.windows.net/")
connector, err := mssql.NewAccessTokenConnector(databaseURL, tokenProvider)
if err != nil {
return nil, err
}
return sqlx.NewDb(sql.OpenDB(connector), "sqlserver"), nil
}
return sqlx.Connect("sqlserver", databaseURL)
}
重點: Azure SQL 的 MSI resource URL 為
https://database.windows.net/,結尾斜線不可省略。
Bug 2 — Migration 反覆失敗:Invalid column name 'xxx'
症狀
{"level":"fatal","msg":"Failed to run database migration",
"error":"migration failed: Invalid column name 'old_column_name'. in line 15: MERGE INTO some_table ..."}
Migration 從第 1 版開始跑,跑到中途某個 seed SQL 時因欄位不存在而失敗,schema_migrations 被標記為 dirty=true,下次啟動仍繼續失敗。
根本原因
部署流程分兩個階段:
- 在 Azure SQL Query Editor 執行
init.sql(一次性初始化腳本) - 後端啟動時由
golang-migrate執行 migration
init.sql 為了方便,直接包含了所有 migration 的最終狀態(含後期的欄位更名,例如 old_column_name → new_column_name)。執行完 init.sql 後,資料庫已是最新 schema,但 schema_migrations 追蹤表完全是空的。
後端啟動時,golang-migrate 看不到任何已完成的 migration,從第 1 版開始執行。早期的 migration(如 seed 測試資料)引用了舊欄位名稱,而 init.sql 已把那個欄位改名,導致 SQL 執行失敗。
init.sql 執行後: schema 已是最終狀態,但 schema_migrations = 空
golang-migrate: 以為從未 migrate 過,從頭執行 → 欄位衝突 → dirty
修復方式
短期(已壞掉的環境): 直接以 SQL 強制將版本設為最新
-- 將版本號改為你的最新 migration 編號
DELETE FROM schema_migrations;
INSERT INTO schema_migrations (version, dirty) VALUES (最新版本號, 0);
長期(防止再次發生): 在 init.sql 結尾加入 schema_migrations 初始化
-- ============================================================
-- schema_migrations 版本追蹤(與 golang-migrate 同步)
-- init.sql 已套用所有 migration,在此設定版本號,
-- 避免後端啟動時重跑 migration 造成欄位衝突。
-- ============================================================
IF OBJECT_ID(N'dbo.schema_migrations', N'U') IS NULL
BEGIN
CREATE TABLE schema_migrations (
version BIGINT NOT NULL PRIMARY KEY,
dirty BIT NOT NULL
);
END
DELETE FROM schema_migrations;
INSERT INTO schema_migrations (version, dirty) VALUES (最新版本號, 0);
版本號維護: 每新增一個 migration 檔案時,記得同步更新
init.sql裡的版本號。
Bug 3 — 環境變數設定錯誤:invalid mode "xxx"
症狀
{"level":"fatal","msg":"Failed to create External API client",
"error":"some-client: invalid mode \"prod\", must be 'mock' or 'http'"}
根本原因
部署腳本的預設值沿用了自訂的命名慣例("prod"),但程式碼實際接受的值只有:
"http"— 呼叫真實的外部 API"mock"— 使用 mock 資料(本地開發用)
部署腳本撰寫時沒有對照程式碼確認合法值,造成一個完全不必要的啟動錯誤。
修復方式
# deploy.ps1 — 修改前
$SOME_CLIENT_MODE = "prod" # ❌ 程式碼不接受這個值
# deploy.ps1 — 修改後
$SOME_CLIENT_MODE = "http" # ✅ 正確:正式環境使用真實 API
若已部署,可直接更新 App Settings,不需重新部署映像:
az webapp config appsettings set \
--name my-backend-app \
--resource-group my-resource-group \
--settings SOME_CLIENT_MODE=http
小提醒:
az webapp config appsettings set只更新指定的 key,不會覆蓋其他設定,比az rest PUT /config/appsettings安全。
Bug 4 — 冷啟動時資料庫連線瞬斷:Read: EOF
症狀
{"level":"fatal","msg":"Failed to run database migration",
"error":"failed to open database: Read: EOF"}
容器啟動後約 5–7 秒閃退,exit code 1。每次重啟都重現,沒有任何 retry 訊息。Log 顯示 migration 剛要開始就立刻失敗。
根本原因
Azure Web App for Containers 採用邊車(sidecar)架構:主容器與 MSI sidecar(負責核發 Managed Identity token)幾乎同時啟動。MSI sidecar 需要幾秒鐘才能就緒。
在這個短暫的空窗期(約 1–7 秒),golang-migrate 試圖開啟 TCP 連線,經過 TLS handshake 時遭遇 TCP RST 或連線中斷,因而回傳 Read: EOF。
golang-migrate 的 MigrationUp 不具備內建的 retry 機制——第一次連線失敗就直接回傳錯誤,由呼叫端負責處理。Go 程式在沒有 retry 保護的情況下直接呼叫 logger.Fatal,容器立即退出。
容器啟動
├─ MSI sidecar:正在初始化(尚未就緒)
└─ Go 程式:立刻呼叫 MigrationUp
└─ golang-migrate 開啟 TCP 連線 → Read: EOF
└─ main.go 呼叫 logger.Fatal → 容器退出(exit 1)
修復方式
在呼叫 MigrationUp 前,加一層帶有指數退避(exponential backoff)的 retry 包裝:
// retryDB 以指數退避重試資料庫操作,最多重試 maxAttempts 次。
// 等待時間:2s → 4s → 8s → 16s(每次左移一位)
func retryDB(logger *zap.Logger, maxAttempts int, fn func() error) error {
var err error
for attempt := 0; attempt < maxAttempts; attempt++ {
err = fn()
if err == nil {
return nil
}
if attempt < maxAttempts-1 {
wait := time.Duration(1<<(attempt+1)) * time.Second
logger.Warn("Database operation failed, retrying",
zap.Int("attempt", attempt+1),
zap.Duration("wait", wait),
zap.Error(err),
)
time.Sleep(wait)
}
}
return err
}
// 使用範例
err = retryDB(logger, 5, func() error {
return databaseutil.MigrationUp(cfg.MigrationSource, cfg.DatabaseURL, logger)
})
if err != nil {
logger.Fatal("Failed to run database migration", zap.Error(err))
}
這樣即使第一次連線遇到 EOF,程式會等待 2 秒後重試,最多嘗試 5 次(共可等待 30 秒),足以涵蓋 MSI sidecar 的啟動時間。
驗證
修復後重新建置映像並強制 Azure 拉取新版:
# 強制 Azure 拉取新映像(az webapp restart 只重啟,不重拉映像)
az webapp config container set \
--name my-backend-app \
--resource-group my-resource-group \
--container-image-name "my-registry.azurecr.io/my-backend:latest"
若 retry 有發生,Log 會出現以下 warn 訊息,並最終成功啟動:
{"level":"warn","msg":"Database operation failed, retrying","attempt":1,"wait":"2s","error":"failed to open database: Read: EOF"}
{"level":"info","msg":"Current migration version","version":9,"dirty":false}
{"level":"info","msg":"Database schema is up to date, no migration required"}
若 MSI sidecar 已就緒,第一次連線即可成功,不會有 warn 訊息,容器直接順利啟動。
注意:
az webapp restart會使用本地快取的映像,不會拉取最新版本。若需強制更新,請使用az webapp config container set重新設定映像名稱。
偵錯流程回顧
四個 bug 必須依序排除——前一個 bug 掩蓋了後一個:
啟動失敗
└─ Bug 1: Login failed for user ''
└─ 修復後 → 繼續啟動
└─ Bug 2: Invalid column name 'xxx' (migration dirty)
└─ 修復後 → 繼續啟動
└─ Bug 3: invalid mode "prod"
└─ 修復後 → 繼續啟動
└─ Bug 4: Read: EOF (MSI sidecar not ready)
└─ 修復後 → 啟動成功 ✅
每次改一個問題就要重新部署或重啟容器,並等待 30–60 秒的 cold start 時間,再從 Log stream 確認下一個錯誤。
取得最新 container log 的方式(Azure CLI):
# 快速方式(Kudu VFS API,不需解壓縮)
TOKEN=$(az account get-access-token --query accessToken -o tsv)
curl -H "Authorization: Bearer $TOKEN" \
-H "Accept: text/plain" \
"https://my-backend-app.scm.azurewebsites.net/api/vfs/LogFiles/YYYY_MM_DD_<instanceId>_default_docker.log" \
| tail -30
# 完整下載方式
az webapp log download \
--name my-backend-app \
--resource-group my-resource-group \
--log-file backend-logs.zip
最終確認
後端正常啟動後,Log 應呈現以下模式:
{"msg":"Database schema is up to date, no migration required"}
{"msg":"External API HTTP client initialized","base_url":"https://api.example.com"}
{"msg":"Application initialization","host":"0.0.0.0","port":"8080"}
{"msg":"Starting listening request","host":"0.0.0.0","port":"8080"}
以 HTTP 呼叫受保護的 API 端點,應回傳 401 Unauthorized(JSON 格式),而非 Azure 的 HTML 錯誤頁面:
curl https://my-backend-app.azurewebsites.net/api/some-protected-endpoint
# 預期回應:{"title":"Unauthorized","status":401,...}
重點整理
| Bug | 症狀關鍵字 | 根本原因 | 修復 |
|---|---|---|---|
| 1 | Login failed for user '' | base go-mssqldb driver 忽略 fedauth= | 改用 useMsi=true + ADAL token provider |
| 2 | Invalid column name + migration dirty | init.sql 與 golang-migrate 狀態不同步 | init.sql 末尾補 schema_migrations 初始化 |
| 3 | invalid mode "xxx" | 部署腳本使用無效的環境變數值 | 對照程式碼確認合法值,改為 "http" |
| 4 | Read: EOF (migration) | MSI sidecar 啟動期間 TCP 連線瞬斷,無 retry | 加 retryDB 指數退避包裝(最多 5 次,最長 30s) |