把程式做成系統服務

邊城發表於2021-11-03

寫程式,難免會遇到需要做成系統服務的需求。Windows 下寫系統服務需要實現一些特定的介面,做起來有一定難度,所以不少程式採用了 近似的備選方案 —— 做成帶系統工作列圖示的桌面應用。但是,服務之所以是服務,就在於他有一個非常重要的特點:可以開機自啟動,而且不需要使用者登入。要不然每次重啟還得人工去登入,是件多麼辛苦的事情。Windows 當然是可以設定自動登入的,但如果是託管伺服器,你真放心自動登入嗎?

而 Linux 下面似乎就要方便得多,大概不需要 GUI 持續執行的程式都可以做成服務。

1. 在 Windows 中做服務

先說 Windows。如果你還在用 Windows XP,那我們就此別過 ……

1.1. Windows Service Wrapper

[Windows Service Wrapper] 是全稱。其簡稱 WinSW 的知名度可能更高一些。

WinSW 基於 .NET Framework 4.6.1 和 .NET 5 實現,所以至少需要 Windows 7 SP1 / Windows Server 2008 R2 SP1 才可以使用。它可以把任意 Windows 程式封裝成 Windows 服務,你所需要做的,只是寫個配置檔案,然後用 WinSW 註冊一個 Windows 服務即可。WinSW 下載下來是個獨立的可執行檔案,使用前需要寫一個與可執行檔名同名但副檔名是 .xml 的配置檔案置於同一目錄下。

舉例來說,Nginx 本身並沒有提供註冊成 Windows 服務的能力,如果需要註冊成 Windows 服務,就可以用 WinSW 來封裝一下。把下載的 WinSW 可執行檔案改名為 winsw.exe(隨便改成什麼名字都行,配置檔名按相同的名稱建立即可),放在 nginx 的主目錄下面,建立配置檔案之後的目錄結構大概是這樣:

[-] nginx
 |-- conf
 |-- ...(其他 nginx 的目錄或檔案)
 |-- nginx.exe
 |-- winsw.exe
 `-- winsw.xml

winsw.xml 中的配置內容如下,看註釋就能理解。

<service>
    <!-- 配置服務名稱 nginx-service,顯示名稱 Nginx Service,以及服務描述 -->
    <id>nginx-service</id>
    <name>Nginx Service</name>
    <description>Nginx Service</description>

    <!-- 服務執行的工作目錄,給絕對路徑 -->
    <workingdirectory>C:\Local\Nginx</workingdirectory>

    <!-- 服務可執行檔案,給絕對路徑 -->
    <executable>C:\Local\Nginx\nginx.exe</executable>

    <!-- 停止服務的可執行檔案 -->
    <stopexecutable>C:\Local\Nginx\nginx.exe</stopexecutable>
    <!-- 停止服務的引數 -->
    <stoparguments>-s stop</stoparguments>

    <priority>Normal</priority>
    <stoptimeout>15 sec</stoptimeout>
    <stopparentprocessfirst>false</stopparentprocessfirst>

    <!-- 配置服務型別是「自動」啟動 -->
    <startmode>Automatic</startmode>
    <waithint>15 sec</waithint>
    <sleeptime>1 sec</sleeptime>

    <!-- 將服務的控制檯輸出(標準輸出/錯誤輸出)寫入日誌 -->
    <!-- 其中 %BASE% 是指 winsw.exe 所在目錄 -->
    <!-- 參考:https://github.com/winsw/winsw/blob/master/doc/loggingAndErrorReporting.md -->
    <logpath>%BASE%\logs</logpath>
    <log mode="roll-by-time">
        <pattern>yyyyMMdd</pattern>
    </log>
</service>

這個配置建立了名為 nginx-service 的 Windows 服務,它在 Windows 的「服務 (services.msc)」顯示名稱為 Nginx Service。啟動服務的時候直接執行 nginx.exe 來啟動,這是一個會執行佔用控制檯的程式;而停止服務則是執行 nginx.exe -s stop,可執行程式和引數分別配置在 <stopexecutable><stoparguments> 中 —— 由此不難推斷,如果啟動服務需要引數,是配置在 <arguments> 中的。

詳細的配置可以在 github 庫裡的 XML configuratoin file 中查到,也可以查到一些示例

配置完成之後執行 winsw.exe install 即可安裝為 Windows 服務。安裝完成之後可以使用 winsw.exe start 命令啟動服務,也可以去 Windows 的服務管理器啟動,或者使用 net start 命令來啟動。github 庫首頁的 Usage 部分有完整的命令說明。

1.2. 用 .NET Framework/Core/5 自己寫一個

用 .NET 寫個服務還是比較容易的,因為有現成的包(元件)可以用:NuGet Gallery | Microsoft.Extensions.Hosting.WindowsServices,官方出品。它至少需要依賴兩個包:

在引入元件之後,只需要少量程式碼就可以讓當前 .NET 的 Console Application 成為一個支援 Windows 服務介面的服務程式。

// Program.cs

class Program {
    static async Task<int> Main(string[] args) {
        await CreateHostBuilder(args).Build().RunAsync();
    }

    public static IHostBuilder CreateHostBuilder(string[] args) {
        return Host.CreateDefaultBuilder(args)
            .ConfigureServices((hostContext, services) => {
                services.AddHostedService<DaemonService>();
            })
            .UseWindowsService();
    }
}

注意到 AddHostedService<DaemonServce>,這裡的 DaemonServce 是一個自己實現的服務業務類,命名自由,但需要從 Microsoft.Extensions.Hosting.BackgroundService 繼承

class DaemonService : BackgroundService {
    protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
        // TODO 提供服務內容的程式碼
    }
}

服務的業務程式碼通常都是持續執行,或者監聽的程式碼。如果是計劃性/週期性的任務,可以考慮使用 Quartz 來實現。

程式完成後可以使用 Windows 提供的 sc 命令來註冊/登出服務。假設生成的程式是 MyService.exe,那麼註冊、配置和啟動服務的命令如下:

sc create "my-service" binPath="C:\MyService\MyService.exe --service"
sc config "my-service" start= auto
sc start "my-service"

注意:binPath 中應該給絕對路徑。

2. 在 Ubuntu 中做 Systemd 服務

Linux 下服務種類比較多,最近主要是用 Ubuntu,所以做 Ubuntu 下的 Systemd 服務。

假設我們寫了一個 .NET 5 的 ASP.NET 應用,放在 /app/my-web/,主檔案是 MyWeb.dll。如果用命令列啟動這個 Web 應該應該是

cd /app/my-web
dotnet MyWeb.dll
注意:需要提前準備好 .NET 5 的執行環境,可參考在 Ubuntu 上安裝 .NET - .NET | Microsoft Docs

接下來是寫 Systemd 服務配置。配置檔名起為 my-web.service,放在 /etc/systemd/system 目錄下。內容(含註釋)如下:

[Unit]
# 服務說明
Description=My Web Application
# 在啟動網路服務之後啟動
After=network.target

[Service]
# 總是重啟(無論什麼原因結束都會立即重啟)
Restart=always
RestartSec=10
# 工作目錄
WorkingDirectory=/app/my-web
# 啟動服務的命令
ExecStart=/usr/bin/dotnet MyWeb.dll
# 通過殺主程式來結束服務
ExecStop=/bin/kill -HUP $MAINPID
TimeoutStopSec=5
KillMode=mixed
SyslogIdentifier=my-web
# 指定執行此服務的使用者,涉及到目錄訪問許可權等問題
User=james

[Install]
WantedBy=multi-user.target

配置完之後還不能馬上啟動服務,需要 systemd 重新載入配置,然後才啟動服務:

sudo systemctl daemon-reload
sudo systemctl start my-web

順便,再介紹一下,如果想在內容釋出之後自動重啟,需要加兩個配置檔案,一個 .path 監控變化,一個 .service 來重啟 my-web

  • restart-my-web.path
[Path]
# 監控主檔案 MyWeb.dll 的變動,如果有變動會觸發 restart-my-web.service 啟動
PathModified=/app/my-web/MyWeb.dll

[Install]
WantedBy=multi-user.target
  • restart-my-web.service
[Unit]
Description=My Web Restarter
After=network.target

[Service]
Type=oneshot
# 防抖,60 秒內只啟動 1 次
ExecStartPre=/bin/sleep 60
# 重啟 my-web.service
ExecStart=/bin/systemctl restart my-web.service

[Install]
WantedBy=multi-user.target

3. 小小的總結一下

做服務並不難,上面唯一的一個需要寫程式碼的方式,還是開箱即用的元件實現的。但話說回來,做服務不難,做服務的設計還是有不少事情需要考慮。比如

  • 如何監控服務的狀態?—— 程式監控、心跳檢查……
  • 如何分析服務中出現的錯誤?—— 系統日誌
  • 如何提供 GUI 來對服務進行管理?—— Web 或其他 UI 跟服務程式進行互動(程式通訊、管理 API 等)
  • ……

既然做服務不難,那就不要太糾結如何“做”(提供)服務,還是多糾結糾結如何做好(設計)服務吧。

相關文章