跳到正文

发布与部署 ​

本章把第 22 章的 Todo API 变成可以在另一台机器上运行的程序。核心问题是:代码发布后,配置和数据放在哪里? 镜像负责携带程序,环境变量提供配置,数据卷保存数据库。

先看完整入口。端点和按功能组织的目录继续沿用上一章;新增的是独立的迁移命令与存活检查:

Program.cs
cs
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
using TodoApi.Data;
using TodoApi.Features.Auth;
using TodoApi.Features.Todos;

var migrateOnly = args.Contains("--migrate");
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
var connectionString = builder.Configuration.GetConnectionString("Todos")
    ?? throw new InvalidOperationException("Missing ConnectionStrings:Todos configuration.");
builder.Services.AddDbContext<TodoDbContext>(options => options.UseSqlite(connectionString));
builder.Services.AddScoped<TodoService>();
builder.Services.AddTodoAuthentication(builder.Configuration, builder.Environment, migrateOnly);
builder.Services.AddCors(options => options.AddPolicy("LocalFrontend", policy =>
    policy.WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? [])
        .WithMethods("GET", "POST", "PUT", "DELETE")
        .WithHeaders("Authorization", "Content-Type")
        .WithExposedHeaders("Location")));

var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseRouting();
app.UseCors("LocalFrontend");
app.UseAuthentication();
app.UseAuthorization();
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference();
}

if (migrateOnly)
{
    await TodoDatabase.MigrateAsync(app.Services);
    Console.WriteLine("Database migration complete.");
    return;
}

app.MapGet("/health", () => TypedResults.Ok(new { Status = "ok" }));
app.MapAuthEndpoints();
app.MapTodoEndpoints();
app.Run();
发布配置与数据库初始化
xml
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <ContainerRepository>todo-api</ContainerRepository>
    <ContainerImageTag>chapter23</ContainerImageTag>
    <ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:10.0</ContainerBaseImage>
    <UserSecretsId>aspnetcore-first-steps-23-deployment</UserSecretsId>
  </PropertyGroup>
  <ItemGroup>
    <Compile Remove="Tests/**/*.cs" />
    <Content Remove="Tests/**;.env;compose.yaml" />
    <None Remove="Tests/**;.env" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" PrivateAssets="all" />
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
    <PackageReference Include="Scalar.AspNetCore" Version="2.17.10" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
  </ItemGroup>
</Project>
cs
namespace TodoApi.Features.Auth;

public static class AuthConfiguration
{
    public static IServiceCollection AddTodoAuthentication(this IServiceCollection services,
        IConfiguration configuration, IHostEnvironment environment, bool migrateOnly)
    {
        if (!environment.IsDevelopment() && !migrateOnly)
        {
            var authority = configuration["Authentication:Schemes:Bearer:Authority"];
            var audience = configuration["Authentication:Schemes:Bearer:Audience"];
            if (!Uri.TryCreate(authority, UriKind.Absolute, out var uri) || uri.Scheme != "https"
                || string.IsNullOrWhiteSpace(audience))
                throw new InvalidOperationException("Production requires an HTTPS Authority and an Audience. Configure a trusted identity service.");
        }

        services.AddAuthentication("Bearer").AddJwtBearer();
        services.AddAuthorization(options => options.AddPolicy("CanWriteTodos",
            policy => policy.RequireAuthenticatedUser().RequireRole("editor")));
        return services;
    }
}
cs
using Microsoft.EntityFrameworkCore;

namespace TodoApi.Data;

public static class TodoDatabase
{
    // Run once during deployment; normal API startup does not change the database schema.
    public static async Task MigrateAsync(IServiceProvider services)
    {
        using var scope = services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
        await db.Database.MigrateAsync();
    }
}
cs
using Microsoft.EntityFrameworkCore;
using TodoApi.Features.Todos;

namespace TodoApi.Data;

public class TodoDbContext(DbContextOptions<TodoDbContext> options) : DbContext(options)
{
    public DbSet<Todo> Todos => Set<Todo>();
    public DbSet<Category> Categories => Set<Category>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Category>().HasData(
            new Category { Id = 1, Name = "Work" },
            new Category { Id = 2, Name = "Life" });
    }
}

先在本机运行 ​

从仓库根目录执行:

bash
cd samples/23-deployment
dotnet run -- --migrate
dotnet user-jwts create --name alice --role editor
dotnet run

第一条运行命令执行迁移后退出,最后一行是 Database migration complete.。随后生成的开发令牌用于本机调试,服务地址仍是 http://localhost:5080。

另开终端:

bash
curl http://localhost:5080/health
json
{"status":"ok"}

/health 只确认进程能够响应 HTTP,没有检查数据库和身份服务。需要检查这些依赖时,可以进一步使用 ASP.NET Core 健康检查功能。

使用本章自己的数据库

本章从新的 todos-23.db 开始,使用迁移创建表。不要把第 15~22 章通过 EnsureCreated 创建的数据库直接拿来执行迁移;这两种建表方式不能直接混用。有旧数据需要升级时,先读数据库迁移。

发布程序与创建镜像 ​

发布(publish)会收集运行应用需要的程序集和配置;容器镜像(container image)还包含运行时与操作系统基础文件。容器从镜像启动,数据另外保存。

普通发布可以这样做:

bash
dotnet publish -c Release -o ./publish

输出目录中的 Deployment.dll 可以在安装了相应 ASP.NET Core 运行时的机器上通过 dotnet Deployment.dll 启动。发布产物不会自动使用 launchSettings.json 的开发环境设置,正式运行前要提供下文的认证配置。

本章用 .NET SDK 直接创建 Linux x64 镜像,无需另外维护 Dockerfile:

bash
dotnet publish -c Release --os linux --arch x64 /t:PublishContainer

这条命令默认把 todo-api:chapter23 写入本机容器运行时。后续步骤需要已启动的 Docker,并使用 Linux 容器。目标机器是 ARM64 时,把 x64 改成 arm64,在相应机器上运行。

镜像基于 mcr.microsoft.com/dotnet/aspnet:10.0,应用以非 root 用户运行。本示例使用的默认用户 ID 是 1654。SDK 也支持直接推送到镜像仓库或生成归档,见官方容器发布说明。

配置生产环境 ​

完整 Compose 配置如下。Docker Compose 用一个文件描述要启动的容器、环境变量和数据卷:

compose.yaml
yaml
name: todo-chapter23
services:
  init-data:
    image: todo-api:chapter23
    user: "0:0"
    entrypoint: ["sh", "-c"]
    command: ["mkdir -p /data && chown 1654:1654 /data"]
    volumes:
      - todo-data:/data

  migrate:
    image: todo-api:chapter23
    command: ["--migrate"]
    environment:
      ASPNETCORE_ENVIRONMENT: Production
      ConnectionStrings__Todos: Data Source=/data/todos.db
    volumes:
      - todo-data:/data

  api:
    image: todo-api:chapter23
    restart: unless-stopped
    env_file: .env
    environment:
      ASPNETCORE_ENVIRONMENT: Production
      ASPNETCORE_HTTP_PORTS: "8080"
      ConnectionStrings__Todos: Data Source=/data/todos.db
    ports:
      - "127.0.0.1:5080:8080"
    volumes:
      - todo-data:/data

volumes:
  todo-data:

其中三个服务各有一个任务:init-data 准备数据目录权限,migrate 升级数据库后退出,api 持续提供 HTTP 服务。

复制环境变量模板:

powershell
Copy-Item .env.example .env
bash
cp .env.example .env
.env.example
dotenv
# Replace these with real identity-service settings; this placeholder domain cannot issue or validate tokens.
Authentication__Schemes__Bearer__Authority=https://identity.example.com
Authentication__Schemes__Bearer__Audience=todo-api
Cors__Origins__0=https://frontend.example.com

把 .env 中的占位值换成自己的配置:

配置填什么
Authority可信身份服务的 HTTPS 地址,它需要提供 JWT 验证所需的元数据和公钥
Audience身份服务为这个 API 配置的受众标识
Cors__Origins__0允许读取 API 的前端源,例如 https://todo.example.com,末尾不加 /

环境变量里的双下划线对应配置路径的冒号。数据库连接字符串由 Compose 单独设置为 /data/todos.db,不依赖容器的工作目录。

开发令牌不能拿去正式上线

dotnet user-jwts 用于本机开发。本章的生产配置改为信任身份服务签发的访问令牌。你需要从该服务获取面向此 API 的令牌;写入操作还要求服务端识别到 editor 角色。不同服务的角色 claim 可能不同,要按其格式配置角色映射。

保留模板中的占位地址可以检查 /health 和匿名请求的 401,但不能完成有效令牌的认证。这不代表身份服务已经接通。

生产环境如果没有 HTTPS Authority 或没有 Audience,示例会拒绝启动并提示缺少配置。这个检查只验证配置是否齐全,不证明远端身份服务可用。

创建数据库,再启动 API ​

仍在 samples/23-deployment 目录执行:

bash
docker compose run --rm init-data
docker compose run --rm migrate
docker compose up -d api

init-data 只以 root 身份调整数据目录的所有权;迁移和 API 都用普通用户运行。迁移成功后,再启动 API,避免用户请求碰到还没更新完的表。

迁移使用 MigrateAsync() 应用尚未执行的变更,已执行过的迁移不会重复执行。本章仓库已包含两个迁移:创建初始表,再给 Todo 增加可空的 Note 列。这个列用于演示升级,暂未加入 HTTP 请求和响应。迁移文件与升级验证见数据库迁移。

检查运行结果:

bash
curl http://localhost:5080/health
curl -i http://localhost:5080/todos
curl -i http://localhost:5080/openapi/v1.json
curl -i http://localhost:5080/scalar
请求预期
/health200,响应体为 {"status":"ok"}
/todos,不带令牌401
/openapi/v1.json404
/scalar404

OpenAPI 和 Scalar 只在 Development 环境注册。Production 环境仍保留错误处理中间件,不会把开发者异常页暴露给调用方。

让数据跟着应用升级保留下来 ​

Compose 把命名数据卷 todo-data 挂载到 /data。替换容器时,数据库留在卷里,新的容器继续使用它。

用身份服务签发的 editor 令牌调用上一章的创建接口,记下返回的任务 ID。然后重建容器:

bash
docker compose up -d --force-recreate api

再带令牌查询 /todos/{id},应能读到之前的任务。docker compose down 停止并移除容器,但默认保留命名卷;不要给它加 --volumes 或 -v,除非确定要删除数据。

升级程序时,先备份数据库,再重新发布镜像;停止 API 后执行迁移,最后重新创建 API 容器。本例只有一个 API 实例,这会造成短暂不可用。SQLite 备份需要覆盖一致的数据状态,可以停止写入后备份,也可以使用 SQLite 的备份机制,不能在写入时随手复制单个 .db 文件就认为备份完成。

从本机验证到公网运行 ​

这里的端口映射是 127.0.0.1:5080:8080,只允许宿主机访问。把镜像和 Compose 配置放到有 Docker 的服务器后,在同一台机器的反向代理上配置域名与 HTTPS,再把请求转发到 127.0.0.1:5080。前端的实际源也要加入 CORS 配置。

如果代理改变了请求的协议、主机名或客户端地址,应用需要按代理与转发头文档配置受信任的代理。不要无条件信任公网发来的转发头。

Cloudflare Pages 托管的是本教程生成的静态网页,不能直接运行这个 ASP.NET Core API。API 需要能运行 .NET 或容器的主机。

排查启动问题可以用:

bash
docker compose ps
docker compose logs --tail 100 api
docker compose logs --tail 100 migrate
现象先检查
API 启动时报告认证配置缺失.env 是否存在,Authority 与 Audience 是否填写
SQLite 无法打开文件或只读数据卷挂载路径,以及 init-data 是否成功
带令牌仍是 401签发者、受众、签名、有效期和身份服务元数据是否匹配
可以查询,但写入返回 403令牌里的角色能否映射为 editor

自动验证发布前的行为 ​

bash
dotnet test --project Tests/TodoApi.Tests.csproj -c Release -p:TreatWarningsAsErrors=true

本章共 13 个测试用例:上一章的 11 个,再加生产环境行为和保留旧数据的迁移测试。CI 还会创建镜像、启动 Compose、重建容器,并确认数据库中的记录没有丢失。

FastAPI 对照

这和把 FastAPI 应用放进容器类似:程序在镜像里,环境配置在外部,持久数据放在数据卷中。容器不会替你完成认证配置和数据库升级。

总结 ​

  • 发布产物携带程序,生产配置通过环境变量提供;开发启动配置不会自动用于正式运行。
  • .NET SDK 可以直接创建容器镜像,应用用普通用户运行。
  • 先执行数据库迁移,再启动 API;升级前备份真实数据。
  • SQLite 文件放在数据卷中,重建容器后仍然保留。
  • 存活检查成功只是第一步,还要验证认证、授权、数据持久化和公网 HTTPS。

上一章:按功能组织项目。主线到这里结束,下一步可以按需要阅读进阶主题。

基于 .NET 10 与 Minimal API · 所有示例均可直接 dotnet run