Docker Compose 重启后 EF Core 重复创建数据库
结论
每次启动应用时执行 MigrateAsync() 是正常的。它只会应用尚未执行的迁移,不会重复创建状态完整的数据库。
当前配置有两个问题:
- SQL Server 的 volume 挂载目录有误。容器的默认数据目录是
/var/opt/mssql,不是/var/lib/mssql/data。 depends_on默认只保证数据库容器先启动,并不会等待 SQL Server 完成初始化和数据库恢复。如果 API 启动过快,EF Core 可能在数据库尚未可用时执行检查,随后尝试创建数据库,最终收到“数据库已存在”的错误。
本地开发没有出现这个问题,通常是因为启动 API 时 SQL Server 已经运行完毕,没有遇到启动时序冲突。
修正 SQL Server 的 volume
可以持久化整个 SQL Server 工作目录:
volumes:
- db-data:/var/opt/mssql
也可以只持久化数据目录:
volumes:
- db-data:/var/opt/mssql/data
持久化整个工作目录会保存系统数据库、用户数据库以及其他 SQL Server 状态,用起来通常更省事。
建议为 volume 指定固定名称,以免切换 Compose 项目名或工作目录后创建新的 volume:
volumes:
db-data:
name: room-reservation-sql-data
下面几组命令的行为不同:
docker compose stop
docker compose start
这组命令只会停止并重新启动原容器,不会删除数据。
docker compose down
docker compose up -d
这组命令会重新创建容器,但默认会保留命名 volume。
docker compose down -v
其中,-v 会删除 Compose 管理的 volume,数据库数据也会被删除。
可以用以下命令确认当前实际使用的 volume:
docker volume ls
docker volume inspect room-reservation-sql-data
等待 SQL Server 真正可用
可以在修正后的 Compose 配置中加入健康检查,让 API 等到数据库进入健康状态后再启动:
services:
db:
image: mcr.microsoft.com/mssql/server:2022-latest
container_name: db-mssql
environment:
MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD}
ACCEPT_EULA: "Y"
ports:
- "1433:1433"
networks:
- db-network
volumes:
- db-data:/var/opt/mssql
healthcheck:
test:
[
"CMD-SHELL",
"/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$${MSSQL_SA_PASSWORD}\" -C -Q \"SELECT 1\" -b -o /dev/null"
]
interval: 10s
timeout: 5s
retries: 20
start_period: 20s
app:
build:
context: .
dockerfile: API/Dockerfile
container_name: meeting-room-reservation
environment:
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://+:80
ConnectionStrings__DefaultConnection: Server=db,1433;Database=RoomReservationDB;User Id=sa;Password=${MSSQL_SA_PASSWORD};TrustServerCertificate=True;
ports:
- "5000:80"
depends_on:
db:
condition: service_healthy
networks:
- db-network
networks:
db-network:
driver: bridge
volumes:
db-data:
name: room-reservation-sql-data
不同 SQL Server 镜像中的 sqlcmd 路径可能不同。有些镜像使用:
/opt/mssql-tools/bin/sqlcmd
如果健康检查提示找不到命令,需要进入容器确认实际路径,再修改 healthcheck.test。
给 EF Core 增加瞬时故障重试
健康检查可以处理启动顺序问题,应用本身仍应应对数据库启动、恢复或网络连接期间的短暂故障。
注册 DbContext 时可以开启 SQL Server 重试:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("DefaultConnection"),
sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(10),
errorNumbersToAdd: null);
}));
迁移代码可以继续保留:
try
{
await using var scope = app.Services.CreateAsyncScope();
var context = scope.ServiceProvider
.GetRequiredService<AppDbContext>();
await context.Database.MigrateAsync();
await DbSeeder.SeedData(context);
}
catch (Exception ex)
{
Console.WriteLine(
$"An error occurred while migrating or seeding the database: {ex}");
throw;
}
记录完整的 ex,不要只记录 ex.Message。这样可以看到内部异常、SQL Server 错误编号和调用栈,也更容易判断问题来自连接时序还是迁移脚本。
检查迁移历史是否完整
EF Core 根据数据库中的 __EFMigrationsHistory 表判断哪些迁移已经执行。需要检查:
RoomReservationDB中是否存在__EFMigrationsHistory。- 表中是否包含当前项目的初始迁移记录。
- 连接字符串是否始终指向同一个 SQL Server 实例和数据库。
- 迁移代码中是否手动执行了
CREATE DATABASE。 - 项目是否在其他位置调用了
EnsureCreated()或EnsureDeleted()。
使用 migrations 时,不要同时调用:
await context.Database.EnsureCreatedAsync();
EnsureCreatedAsync() 不使用迁移历史机制。它与 MigrateAsync() 混用时,可能出现数据库对象已经存在,但 EF Core 无法正确识别迁移状态的情况。
迁移历史表存在且记录完整,并且 SQL Server 已经可用时,MigrateAsync() 应该直接结束,或者只应用新增的迁移。
种子数据也要支持重复执行
应用每次启动都会执行 DbSeeder.SeedData(context),所以种子逻辑需要具备幂等性。例如:
public static async Task SeedData(AppDbContext context)
{
if (!await context.Rooms.AnyAsync())
{
context.Rooms.AddRange(
new Room { Name = "Meeting Room A" },
new Room { Name = "Meeting Room B" });
await context.SaveChangesAsync();
}
}
对于必须保持唯一的数据,最好同时建立唯一索引或唯一约束,避免多个应用实例同时启动时插入重复记录。
如果只执行过 docker compose stop 和 docker compose start,容器本身并未删除。即使 volume 挂载有误,数据通常仍保存在容器可写层中。此时应先检查 SQL Server 的就绪时序,并查看完整异常。修正 volume 路径,是为了在容器重新创建后仍能可靠保留数据库。
备注:内容仅供参考。
