當有一個應用程序被用戶 ( SIGINT /Ctrl+C) 或 Docker ( SIGTERM / docker stop ) 停止時,它需要優雅地關閉一個長時間運行的工作;換句煥說,當應用程式收到關閉訊號的時候,要把工作做完,應用程式才可以關閉。微軟的 Microsoft.Extensions.Hosting 可以幫我們接收/處理關閉訊號,我們只需要告訴它要怎麼做就可以了,我在實作的過程當中,碰到了一些問題,以下是我的心得
開發環境
- .NET 6
- Rider 2021.3-EAP9-213.5744.160)
接收 SIGINT / SIGTERM 訊號
新增一個 .NET 6 Console 應用程式,並加入以下套件
dotnet add package Microsoft.Extensions.Hosting --version 6.0.0
dotnet add package Serilog.Extensions.Hosting --version 7.0.0
dotnet add package Serilog.Settings.Configuration --version
dotnet add package Serilog.Sinks.Console --version 4.1.0
dotnet add package Serilog.Sinks.File --version 5.0.0
在 .NET Core 的應用程式我知道有以下的方法
- Console.CancelKeyPress
- AssemblyLoadContext.Default.Unloading
- AppDomain.CurrentDomain.ProcessExit
var sigintReceived = false;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.Enrich.FromLogContext()
.WriteTo.Console()
.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");
}
};
Graceful Shutdown for Generic Host
.NET 6 的 Generic Host 就已經支援接收關閉訊號
下段內容出自 .NET 泛型主機 - .NET | Microsoft Learn
使用 ConsoleLifetime (UseConsoleLifetime) ,它會接聽下列訊號,並嘗試正常停止主機。
如果使用 Host 裝載應用程式,關機流程會是這樣
下圖內容出自 .NET 泛型主機 - .NET | Microsoft Learn
接下來,我要實作,當任務需要較長時間的工作時,如果 .NET Core 應用程式收到 SIGINT/SIGTERM 訊號的時候會發生甚麼事。
@ Program.cs
註冊 HostedService
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((context, services, config) =>
{
var formatter = new JsonFormatter();
config.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.WriteTo.Console(formatter)
.WriteTo.File(formatter, "logs/app-.txt", rollingInterval: RollingInterval.Minute);
})
.RunConsoleAsync();
RunConsoleAsync 預設就會使用 UseConsoleLifetime
GracefulShutdownService
這裡我用 IHostedService 來建立服務
- StartAsync(),用 Task.Run 調用 ExecuteAsync
this._backgroundTask = Task.Run(async () => { await this.ExecuteAsync(cancel); }, cancel);,ExecuteAsync 是一個長時間運行的方法 - StopAsync(),等待 _backgroundTask 完成
internal class GracefulShutdownService : IHostedService
{
private readonly IHostApplicationLifetime _appLifetime;
private Task _backgroundTask;
private bool _stop;
private ILogger<GracefulShutdownService> _logger;
public GracefulShutdownService(IHostApplicationLifetime appLifetime,
ILogger<GracefulShutdownService> logger)
{
this._appLifetime = appLifetime;
this._logger = logger;
}
public Task StartAsync(CancellationToken cancel)
{
this._logger.LogInformation($"{DateTime.Now} 服務啟動中...");
this._backgroundTask = Task.Run(async () => { await this.ExecuteAsync(cancel); }, cancel);
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancel)
{
this._logger.LogInformation($"{DateTime.Now} 服務停止中...");
this._stop = true;
await this._backgroundTask;
this._logger.LogInformation($"{DateTime.Now} 服務已停止");
}
private async Task ExecuteAsync(CancellationToken cancel)
{
this._logger.LogInformation($"{DateTime.Now} 服務已啟動!");
while (!this._stop)
{
this._logger.LogInformation($"{DateTime.Now} 1.服務運行中...");
this._logger.LogInformation($"1.IsCancel={cancel.IsCancellationRequested}");
await Task.Delay(TimeSpan.FromSeconds(30), cancel);
this._logger.LogInformation($"2.IsCancel={cancel.IsCancellationRequested}");
this._logger.LogInformation($"{DateTime.Now} 2.服務運行中...");
}
this._logger.LogInformation($"{DateTime.Now} 服務已完美的停止(Graceful Shutdown)");
}
}
本機跑一下,觀察下,當我送出 Ctrl+C 發現一下就停了並沒有等待工作完成;關閉應用程式也沒有等待工作就直接關閉了。
或者是使用 BackgroundService,它已經把上述的行為封裝起來了,省掉了不少程式碼
class GracefulShutdownService1 : BackgroundService
{
private readonly ILogger<GracefulShutdownService1> _logger;
public GracefulShutdownService1(ILogger<GracefulShutdownService1> logger)
{
this._logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
this._logger.LogInformation($"{DateTime.Now} 服務已啟動!");
while (!stoppingToken.IsCancellationRequested)
{
this._logger.LogInformation($"{DateTime.Now} 1.服務運行中...");
this._logger.LogInformation($"1.IsCancel={stoppingToken.IsCancellationRequested}");
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
this._logger.LogInformation($"2.IsCancel={stoppingToken.IsCancellationRequested}");
this._logger.LogInformation($"{DateTime.Now} 2.服務運行中...");
}
this._logger.LogInformation($"{DateTime.Now} 服務已完美的停止(Graceful Shutdown)");
}
}
對容器送出關閉訊號
這裡我打算用 Docker,接下來,在專案新增 Docker File
Build 一下這個 Image,Rider 內建兩種方式可以 Build,當然你也可以用 docker build
在 Terminal 把應用程式叫起來
docker run lab.gracefulshutdown.net6
先找出 container id
再關掉它,送出 SIGTERM 訊號
docker kill -s SIGTERM b91aa1002128
當 Container 收到 SIGTERM 訊號,我的應用程式沒有馬上停止,正在等待工作完成
確定服務完成工作才停止
送出 SIGINT 訊號,效果也是一樣會等待工作完成才中止服務
docker kill -s SIGINT 0f999539c499
Ctrl+C 本機(Windows)執行跟 Container(Linux) 的行為不一樣,這是需要注意的
Shutdown Timeout
在 .NET 5 的時候會有 Shutdown Timeout 的問題
Extending the shutdown timeout setting to ensure graceful IHostedService shutdown (andrewlock.net)
我在 .NET 6 模擬不出這個問題,翻了一下代碼,已經沒有 token.ThrowIfCancellationRequested() 這一行
https://github.com/dotnet/runtime/blob/e9036b04357e8454439a0e6cf22186a0cb19e616/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs#L111
參考資料
當 .NET Core 執行在 Linux 或 Docker 容器中如何優雅的結束 | The Will Will Web (miniasp.com)
Graceful Shutdown C# Apps. I recently had a C# application that… | by Rainer Stropek | Medium
範例位置
若有謬誤,煩請告知,新手發帖請多包涵
Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET