The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

當 .NET Core 執行在 Linux 或 Docker 容器中如何優雅的結束

當我們將 .NET Core 跑在 Linux 或 Docker 容器中,我們不但希望程式可以穩定的執行,當程式需要結束的時候,更應該考慮如何「優雅的結束」(Graceful Shutdown)。這部分資訊很難在 docs.microsoft.com 或 GitHub 原始碼專案中找到,本篇文章將分享 .NET Core 如何捕捉作業系統送出的 TERM 訊號,在關閉程式前可以妥善處理程式結束前該做的準備。

緣起

由於我們越來越多 .NET Core 程式跑在 Docker 容器中(包含 Kubernetes 叢集),漸漸的也會遇到版本升級的需求。雖然我們很容易可以重新啟動容器,但是每次 docker stop 的時候,程式跑到一半就被「強制」關閉,偶爾就會遇到殘留資料,造成應用程式狀態不一致的情況。這種情況通常不容易偵錯,所以學習如何「優雅的結束」其實相當重要。

理解 docker stop 的運作細節

基本上,即便你跑在 Kubernetes 叢集中,最底層也是大量依賴容器技術來執行程式,所以首要目的,就是理解 Docker 如何讓「應用程式」停止。

每當你執行 docker stop 的時候,預設「最多」只會等待 10 秒,如果容器中的「主要應用程式」並沒有結束,就會「強制關閉」應用程式,讓容器停止。

$ docker stop -h
Flag shorthand -h has been deprecated, please use --help

Usage:  docker stop [OPTIONS] CONTAINER [CONTAINER...]

Stop one or more running containers

Options:
  -t, --time int   Seconds to wait for stop before killing it (default 10)

如果想要調整最多結束等待時間,可以加入 -t 參數,例如:docker stop --time=30 CONTAINER_ID

事實上,當你執行 docker stop 的時候,你的 Docker Engine 會對容器中的「主要應用程式」(root process) (PID 1) 送出 SIGTERM 訊號(Signal)。如果在指定的等待時間內沒有結束,就會改送出 SIGKILL 訊號,此時作業系統就會嘗試將應用程式「強制關閉」。不過,有時候你還是有可能會遇到 SIGKILL 還是無法將應用程式關閉的狀況,此時就會造成容器無法停止,此時還可以再嘗試透過 docker rm -f 來強制刪除容器。若再不行,那可能就要重開機才能解決了!

除此之外,你也可以使用 docker kill 來終止一個容器,這個命令可以讓你送出「任意類型的訊號」,預設將會送出 SIGKILL 訊號,而且完全沒有等待時間。

$ docker kill -h
Flag shorthand -h has been deprecated, please use --help

Usage:  docker kill [OPTIONS] CONTAINER [CONTAINER...]

Kill one or more running containers

Options:
  -s, --signal string   Signal to send to the container (default "KILL")

如果你想查詢 Linux 下總共有多少訊號可用,可以執行以下命令查詢:

$ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

因此,如果你想對容器送出 SIGINT 訊號,可以輸入以下命令:

docker kill -s INT CONTAINER_ID

使用以下命令也可以:

docker kill -s SIGINT CONTAINER_ID

不同的應用程式處理的訊號種類不同

有個更棘手的問題,如果想要對不同的應用程式執行優雅的結束(graceful shutdown),不同的應用程式能夠處理的訊號種類不同!

例如 nginx 預設若要執行優雅的結束,必須對他送出 SIGQUIT 訊號。而 Apache HTTP Server 則要送出 SIGWINCH 訊號,才會優雅的結束。然而,大部分的應用程式,都是用 SIGTERM 訊號為主。

通常較為知名的開源軟體系統,都一定會實作優雅的結束機制,但並不是「所有的程式」都會實作優雅的結束機制,這當然也包含了 .NET Core 所寫的應用程式,無論你收到什麼訊號,通常應用程式就是直接殘暴的結束!

也因為不同的應用程式處理的訊號種類不同,所以執行在容器中的程式,不能假設都以 Docker 預設的 SIGTERM 為主,直接執行 docker stop 絕對不是最佳解決方案!

還好 Dockerfile 有定義一個 STOPSIGNAL 命令,可以讓你指定當前 Image 要用什麼訊號當成 graceful shutdown 的預設訊號,如下範例:

FROM nginx

RUN echo 'server {\n\
    listen 80 default_server;\n\
    location / {\n\
      proxy_pass      http://httpbin.org/delay/10;\n\
    }\n\
}' > /etc/nginx/conf.d/default.conf

STOPSIGNAL SIGQUIT

CMD ["nginx", "-g", "daemon off;"]

由於 Docker 預設採用 SIGTERM 為預設的 graceful shutdown 訊號,因此你自己開發的應用程式,強烈建議使用 SIGTERM 為預設的處理訊號!

.NET Core 如何實作優雅的結束

以下我透過一個簡單的逐步上手步驟,讓你可以自行驗證當應用程式接收到 SIGTERM 訊號時,該怎樣妥善的處理。

  1. 建立 Console 專案

    dotnet new console -n c1
    
  2. 修改 Program.cs 檔案

    只要透過 AssemblyLoadContext.Unloading 事件,就可以捕捉到 SIGTERM 訊號!

    using System;
    using System.Threading;
    namespace c1
    {
        class Program
        {
            static void Main(string[] args)
            {
                System.Runtime.Loader.AssemblyLoadContext.Default.Unloading += (ctx) => {
                    System.Console.WriteLine("Closing");
                    Thread.Sleep(5000);
                    System.Console.WriteLine("Ended");
                };
                Console.WriteLine("Hello World!");
                Console.ReadLine();
            }
        }
    }
    

    由於 .NET Framework 無法跨平台執行,因此不支援這個事件!

  3. 發行專案 & 執行程式

    dotnet publish -c Release -o bin/Debug/test
    
    dotnet bin/Debug/test/c1.dll
    
  4. 在新的 Terminal 找出目前執行程式的 PID

    ps aux
    
    pidof dotnet
    

    假設我們查到 PID 為 6198

  5. 對特定程式送出 SIGTERM 訊號

    kill -s SIGTERM 6198
    
  6. 此時你應該會看到應用程式這樣回應

    你將會發現應用程式會先出現 Closing 字樣,然後停頓五秒,然後才顯示 Ended 字樣,並且結束應用程式。這也代表我們已經可以實作出優雅的結束機制,你只要在 System.Runtime.Loader.AssemblyLoadContext.Default.Unloading 事件中寫好優雅的結束該做哪些事情即可!

    $ dotnet bin/Debug/test/c1.dll
    Hello World!
    Closing
    Ended
    

.NET Core 如何捕捉 SIGQUIT 訊號

當你在 Terminal 按下 Ctrl-C 快速鍵的時候,其實就是對正在執行的程式送出 SIGQUIT 訊號。因此,你要在 .NET Core 應用程式中捕捉 SIGQUIT 訊號,只要實現 Console.CancelKeyPress 事件即可!

以下為範例程式:

using System;
using System.Threading;
namespace c1
{
    class Program
    {
        static void Main(string[] args)
        {
            System.Runtime.Loader.AssemblyLoadContext.Default.Unloading += (ctx) => {
                System.Console.WriteLine("Closing");
                Thread.Sleep(5000);
                System.Console.WriteLine("Ended");
            };

            Console.CancelKeyPress += (sender, e) =>
            {
                System.Console.WriteLine("Canceled");
            };

            Console.WriteLine("Hello World!");
            Console.ReadLine();
        }
    }
}

如此一來,你不但能處理容器中優雅的結束(SIGTERM),更能在使用者按下 Ctrl-C 的時候優雅的結束程式,是不是相當不錯呢! 😃

相關連結

留言評論