由 上篇 得知 .NET Core 应用程式可以接收 SIGINT/SIGTERM讯号,在处理批次流程,当应用程式接收到 SIGINT/SIGTERM 讯号后,就要进入状态的保存,避免服务被强制中断,导致状态混乱。预设 Container 预设等待 10 sec,也就是这个时间範围内就要完成状态保存,如果超过时间就可以考虑送出 Timeout 参数,延长工作关闭流程。Linux 执行应用程式的时候有区分 shell model 以及 exec mode,若使用不当,会导致接收不到 SIGINT/SIGTERM 讯号,无法 graceful shutdown。
开发环境
- Windows 11 Home
- Rider 2024.3.6
- WSL2 + Ubuntu 24.04
- .NET Core 8 Console App
观察容器接收 SIGINT/SIGTERM 讯号状况
新增一个 Console App 专案并使用以下程式码,如果正确的收到关闭讯号,会快的印出成功;反之,会等到预设时间到达后,才强制关闭应用程式。
GracefulShutdownService1.cs 内容如下:
class GracefulShutdownService1 : BackgroundService
{
private readonly ILogger<GracefulShutdownService1> _logger;
public GracefulShutdownService1(ILogger<GracefulShutdownService1> logger)
{
this._logger = logger;
}
private int _count = 1;
public override Task StartAsync(CancellationToken cancellationToken)
{
this._logger.LogInformation($"{DateTime.Now} 服务已启动!");
return base.StartAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (true)
{
if (stoppingToken.IsCancellationRequested)
{
break;
}
this._logger.LogInformation($"{DateTime.Now} ,执行次数:{_count++}");
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
}
}
public override Task StopAsync(CancellationToken cancellationToken)
{
this._logger.LogInformation($"{DateTime.Now} 完成!");
return base.StopAsync(cancellationToken);
}
}
在 Program.cs 拦截了相关的讯号
using System.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Runtime.Loader;
using Lab.GracefulShutdown.Net8;
using Serilog;
using Serilog.Formatting.Json;
var sigintReceived = false;
var formatter = new JsonFormatter();
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.Enrich.FromLogContext()
.WriteTo.Console(formatter)
.WriteTo.File("logs/host-.txt", rollingInterval: RollingInterval.Day)
.CreateBootstrapLogger()
;
Log.Information($"Process id: {Process.GetCurrentProcess().Id}");
Log.Information("等待以下讯号 SIGINT/SIGTERM");
Console.CancelKeyPress += (sender, e) =>
{
e.Cancel = true;
Log.Information("已接收 SIGINT (Ctrl+C)");
sigintReceived = true;
};
AssemblyLoadContext.Default.Unloading += ctx =>
{
if (!sigintReceived)
{
Log.Information("已接收 SIGTERM,AssemblyLoadContext.Default.Unloading");
}
else
{
Log.Information("@AssemblyLoadContext.Default.Unloading,已处理 SIGINT,忽略 SIGTERM");
}
};
AppDomain.CurrentDomain.ProcessExit += (sender, e) =>
{
if (!sigintReceived)
{
Log.Information("已接收 SIGTERM,ProcessExit");
}
else
{
Log.Information("@AppDomain.CurrentDomain.ProcessExit,已处理 SIGINT,忽略 SIGTERM");
}
};
await Host.CreateDefaultBuilder(args)
.ConfigureServices((hostContext, services) =>
{
// services.Configure<HostOptions>(opts => opts.ShutdownTimeout = TimeSpan.FromSeconds(15));
// services.AddHostedService<GracefulShutdownService>();
services.AddHostedService<GracefulShutdownService1>();
// services.AddHostedService<GracefulShutdownService_Fail>();
})
.UseSerilog()
.RunConsoleAsync();
Log.Information("下次再来唷~");
Exec mode
在 Dokerfile 使用 ENTRYPOINT 启动 .NET Core 应用程式
FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Lab.GracefulShutdown.Net8/Lab.GracefulShutdown.Net8.csproj", "Lab.GracefulShutdown.Net8/"]
RUN dotnet restore "Lab.GracefulShutdown.Net8/Lab.GracefulShutdown.Net8.csproj"
COPY . .
WORKDIR "/src/Lab.GracefulShutdown.Net8"
RUN dotnet build "Lab.GracefulShutdown.Net8.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "Lab.GracefulShutdown.Net8.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Lab.GracefulShutdown.Net8.dll"]
沿用上篇,用 WSL 开启专案
在 Rider 的 Terminal 介面,或是 Windows Terminal 介面,把 Container 叫起来
docker build -t graceful-shutdown-net8:dev -f ./Lab.GracefulShutdown.Net8/Dockerfile .
docker run --name graceful-shutdown-net8 --rm graceful-shutdown-net8:dev
执行结果如下:
在另外一个 Terminal 送出关闭讯号
time docker container stop graceful-shutdown-net8
在 Terminal 可以观察到,应用程式有接收到 SIGINT/SIGTERM 并且顺利的完成流程,StartAsync → ExecuteAsync → StopAsync
如果 10 sec 内无法完成处理,可以再把时间延长
time docker container stop -t 30 graceful-shutdown-net8
Shell Mode
在 Dockerfile,把
ENTRYPOINT ["dotnet", "Lab.GracefulShutdown.Net8.dll"]
换成
CMD dotnet /app/Lab.GracefulShutdown.Net8.dll #shell mode
再按照上述步骤
docker build -t graceful-shutdown-net8:dev -f ./Lab.GracefulShutdown.Net8/Dockerfile .
docker run --name graceful-shutdown-net8 --rm graceful-shutdown-net8:dev
最后执行
time docker container stop graceful-shutdown-net8
从下图观察到,这次,等了 10 sec 才强制关闭容器,跟上一个实验,不一样。
从下图观察到,应用程式的 log 没有收到 SIGINT/SIGTERM 讯号,硬生生地被强迫关闭
在 shell mode 下,送出 SIGTERM/SIGINT 也完全不会有作用
time docker container kill --signal=SIGTERM graceful-shutdown-net8
time docker container kill --signal=SIGINT graceful-shutdown-net8
但,对 SIGKILL 有作用
time docker container kill --signal=SIGKILL graceful-shutdown-net8
观察 Container Process
进入到容器
docker container exec -it graceful-shutdown-net8 bash
安装
apt-get update && apt-get install -y procps
查看 Process 状况
ps -aux
exec mode
PID 1 为 dotnet,kill 1 可以顺利的终止应用程式/关闭整个容器
shell mode
PID 1 为 /bin/sh -c
kill 1,没有反应,kill 7 才能终止应用程式/关闭整个容器
Alpine vs Debian
本以为使用 shell mode 一定会有问题,没想到换一个 image 就没有问题了
改用 Alpine Image
PID 1 就变成 dotnet 而不是 /bin/sh -c,如下图
对 container 送出 stop 命令也有顺利处理 SIGINT/SIGTERM
顺利地走完流程了
cat /etc/os-release 观察作业系统版本
心得
Ubuntu Image
- shell mode:用 bin/sh 执行 cmd 时,PID 1 的 process 会是 bin/sh
- exec mode:直接执行 cmd,PID 1 的 process 会是 cmd
差异在于,当 container 送出 SIGTERM 讯号,例如 docker container stop, bin/sh 不会把 SIGTERM 讯号传给 cmd(子 Process),导致 process 没有处理关闭流程;反之 exec mode 会收到 SIGTERM 讯号
alpine Image
不管是用哪一种 mode,PID 1 的 process 都是 cmd
範例位置
sample.dotblog/Graceful Shutdown/Lab.GracefulShutdownWithSellMode at master · yaochangyu/sample.dotblog
若有谬误,烦请告知,新手发帖请多包涵
Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET