觀察 .NET Core Container 的 Exec Mode 和 Shell Mode 差異

上篇 得知 .NET Core 應用程式可以接收 SIGINT/SIGTERM訊號,來完成 graceful shutdown;在處理批次流程中,當應用程式接收到 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 Ubuntu Image

本以為使用 shell mode 一定會有問題,沒想到換一個 image 就沒有問題了,改用 Alpine Image

mcr.microsoft.com/dotnet/runtime:8.0-alpine

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 訊號

 

使用以下語法觀察 bin/sh 連結到的是 dash

docker container exec -it graceful-shutdown-net8 ls -l /bin/sh

 

alpine Image

不管是用哪一種 mode,PID 1 的 process 都是 cmd

使用以下語法觀察 bin/sh 連結到的是 busybox

docker container exec -it graceful-shutdown-net8 ls -l /bin/sh

 

不同的 image 的可能連結的應用程式有些不一樣,必須要確定 PID 1 的 process 是 cmd (你開發的應用程式),而不是 bin/sh

範例位置

sample.dotblog/Graceful Shutdown/Lab.GracefulShutdownWithSellMode at master · yaochangyu/sample.dotblog

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


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

Image result for microsoft+mvp+logo