C# · #deployment#csharp#dotnet-core#docker

.NET Core 容器化部署:Docker 最佳实践

2018.11.20 C# 3 min 1.2k
// 目录 · contents

把 .NET Core 应用跑在 Docker 里,是我们团队迁移到容器化部署的第一步。这篇文章记录整个过程,重点是怎么把镜像做得又小又安全。

1. 基础 Dockerfile(不推荐)

1
2
3
4
5
6
7
FROM mcr.microsoft.com/dotnet/core/sdk:2.1
WORKDIR /app
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /out
EXPOSE 80
ENTRYPOINT ["dotnet", "/out/MyApp.dll"]

这个写法有几个问题: 1. 镜像包含 SDK(1.7GB),而运行时只需要 runtime(约 200MB) 2. 每次代码改动都会重新 dotnet restore,无法利用 Docker 层缓存

2. 多阶段构建(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 阶段 1:构建
FROM mcr.microsoft.com/dotnet/core/sdk:2.1 AS build
WORKDIR /src

# 先复制 csproj,利用 Docker 层缓存
# 只有依赖变化时才重新 restore(通常不频繁)
COPY ["src/MyApp/MyApp.csproj", "src/MyApp/"]
COPY ["src/MyApp.Domain/MyApp.Domain.csproj", "src/MyApp.Domain/"]
RUN dotnet restore "src/MyApp/MyApp.csproj"

# 再复制源代码并构建
COPY src/ src/
WORKDIR "/src/src/MyApp"
RUN dotnet publish -c Release -o /app/publish

# 阶段 2:运行时(只包含 Runtime,不含 SDK)
FROM mcr.microsoft.com/dotnet/core/aspnet:2.1 AS runtime
WORKDIR /app

# 非 root 用户运行(安全实践)
RUN adduser --disabled-password --home /app --gecos "" appuser && \
chown -R appuser:appuser /app
USER appuser

COPY --from=build /app/publish .

EXPOSE 80
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost/health || exit 1

ENTRYPOINT ["dotnet", "MyApp.dll"]

镜像大小对比: - 单阶段(SDK):~1.7GB - 多阶段(Runtime):~250MB - 多阶段 + 发布为 self-contained:~100MB(包含运行时,无需预装 .NET)

3. 配置管理

.NET Core 配置系统天然支持环境变量覆盖 appsettings.json

1
2
3
4
5
6
7
8
9
// appsettings.json(开发默认值)
{
"ConnectionStrings": {
"Default": "Server=localhost;Database=myapp;..."
},
"Logging": {
"LogLevel": { "Default": "Information" }
}
}
1
2
3
4
5
6
7
# Docker 运行时通过环境变量覆盖配置
# 规则:双下划线 __ 代替 JSON 层级分隔符 :
docker run \
-e "ConnectionStrings__Default=Server=prod-db;..." \
-e "Logging__LogLevel__Default=Warning" \
-p 8080:80 \
myapp:latest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# docker-compose.yml
version: '3.7'
services:
api:
image: myapp:latest
ports:
- "8080:80"
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ConnectionStrings__Default=${DB_CONNECTION_STRING}
env_file:
- .env.production # 不提交到 git 的敏感配置
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
restart: unless-stopped

4. 健康检查端点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddHealthChecks()
.AddSqlServer(Configuration.GetConnectionString("Default"),
name: "database",
failureStatus: HealthStatus.Unhealthy)
.AddRedis(Configuration["Redis:ConnectionString"],
name: "cache");
}

public void Configure(IApplicationBuilder app)
{
app.UseHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = async (context, report) =>
{
context.Response.ContentType = "application/json";
var result = JsonSerializer.Serialize(new
{
status = report.Status.ToString(),
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
description = e.Value.Description
})
});
await context.Response.WriteAsync(result);
}
});

app.UseMvc();
}

5. 日志配置

容器环境里日志应该输出到 stdout(Docker 会收集):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Program.cs
WebHost.CreateDefaultBuilder(args)
.ConfigureLogging(logging =>
{
logging.ClearProviders(); // 清除默认提供者
logging.AddConsole(options =>
{
options.TimestampFormat = "yyyy-MM-dd HH:mm:ss ";
options.IncludeScopes = true;
});

if (env.IsDevelopment())
logging.AddDebug();
})
.UseStartup<Startup>()
.Build();

生产环境配合 ELK Stack 或 Seq 做集中式日志:

1
2
3
4
5
# 用 Docker 日志驱动把日志发到 Fluentd
docker run \
--log-driver=fluentd \
--log-opt fluentd-address=localhost:24224 \
myapp:latest

踩坑记录

部署到生产环境后,应用在高并发下偶发 SocketException: Address already in use。排查发现是 HttpClient 使用方式不对——每次请求 new 一个 HttpClient 然后 Dispose,导致 socket 进入 TIME_WAIT 状态堆积,最终耗尽可用端口。

在容器里这个问题更明显,因为容器 IP 固定,TIME_WAIT 的 socket 无法快速复用。

解决方案:使用 IHttpClientFactory(.NET Core 2.1 引入):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 注册
services.AddHttpClient<PaymentService>(client =>
{
client.BaseAddress = new Uri("https://payment-api.example.com");
client.DefaultRequestHeaders.Add("Accept", "application/json");
});

// 使用(IHttpClientFactory 管理连接池,避免 socket 耗尽)
public class PaymentService
{
private readonly HttpClient _httpClient;

public PaymentService(HttpClient httpClient)
{
_httpClient = httpClient;
}
}

总结

.NET Core Docker 部署要点:

  • 多阶段构建:SDK 用于构建,Runtime 用于运行,镜像从 1.7GB 减到 250MB
  • 层缓存优化:先 COPY csproj,再 dotnet restore,最后 COPY 源码
  • 非 root 用户运行:安全基线要求
  • 环境变量覆盖配置:双下划线替换 JSON 层级分隔符
  • IHttpClientFactory:管理 HttpClient 连接池,避免 socket 泄漏
  • 健康检查:容器编排(K8s/Swarm)依赖它判断服务是否就绪
作者 · authorzt
发布 · date2018-11-20
篇幅 · length1.2k 字 · 3 min
许可 · licenseCC BY-SA 4.0
$ echo "comments" · 评论