[.NET 6] 如何優雅(Gracefully Shutdown)的關閉 .NET Core Console 應用程式

當有一個應用程序被用戶 ( 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 的應用程式我知道有以下的方法

  1. Console.CancelKeyPress
  2. AssemblyLoadContext.Default.Unloading
  3. 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) ,它會接聽下列訊號,並嘗試正常停止主機。

  • SIGINT (或 CTRL+C)。
  • SIGQUIT (或 CTRL+BREAK (Windows 上)、CTRL+\ (Unix 上))。
  • SIGTERM (由其他應用程式傳送,例如 docker stop)。

 

如果使用 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 來建立服務 

  1. StartAsync(),用 Task.Run 調用 ExecuteAsync
    this._backgroundTask = Task.Run(async () => { await this.ExecuteAsync(cancel); }, cancel);,ExecuteAsync 是一個長時間運行的方法
  2. 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

範例位置

sample.dotblog/Graceful Shutdown/Lab.GracefulShutdown at master · yaochangyu/sample.dotblog (github.com)

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo