在 NixOS 上執行簡易 Go 網頁服務
我有幾個全天候運行在雲端基礎架構上的玩具型實用程式。其中一個例子是PicoShare,這是一個讓我能輕鬆與朋友和同事分享檔案的簡易網頁應用程式。
有好幾個便利型應用程式,如果能輕鬆地讓它們持續運行,我其實很想把它們跑起來。但即使只是讓一個簡單的應用程式全天候運行,也有不少阻礙,所以我就沒去做了。
過去我曾嘗試在家用伺服器上運行這些小工具。我設定過 cron 排程與 systemd 服務,但總是難免有東西出錯,修到最後覺得厭煩,就乾脆讓服務停擺了。
我想我終於找到了一種低摩擦力的方式來託管個人應用程式,既能讓它們保持隨時可用,又能將維護負擔降到最低:NixOS modules(NixOS 模組)。
我的解決方案能做到什麼
- 對於任意一個網頁服務,我只需不到 15 分鐘就能讓它在我的 NixOS 伺服器上全天候運行。
- 我只要改一行程式碼並執行重建指令,就能升級/降級或變更設定選項。
- 我可以在同一台伺服器上運行具有衝突依賴的應用程式(例如 Python 2 與 Python 3),完全不用擔心版本衝突。
- 我能將整台伺服器的設定納入版本控制,隨時回溯到任何狀態。
為什麼選擇 NixOS?
我認為 NixOS 適合用來全天候運行一系列服務,有幾個原因:
- 整個系統的設定都寫在文字檔中。
- 重建系統的速度相對很快(通常只需數秒或數分鐘)。
- NixOS 上的服務易於組合(我可以組合多個服務)
- NixOS 上的服務易於擴充(我可以輕鬆調整選項或修補行為)。
但 Nix 不是很複雜嗎?
是的,Nix 很複雜,但它比你想像中更容易上手。
在過去一年裡,我陸陸續續學了一點 Nix,發現不需要完全搞懂整個系統,也能學到實用的技巧。舉例來說,我用 Nix 來建立每個專案各自的開發環境,而且不需要對 Nix 有非常深入的了解就能做到。
我會把 NixOS modules 的難度歸為中等,但我認為只要看過一個範例,就相對容易複製。
為什麼不用 Ansible?
我以前曾覺得 Ansible 是全天候運行服務的絕佳解決方案。
好幾年來,我為各種專案維護了一組虛擬機器,並用 Ansible 來設定它們。
第一個問題是 Ansible 很慢。每次在伺服器上新增一個服務,執行 Ansible 所花的時間就會變長。我有一些不算太複雜的伺服器,每次套用設定都要超過 10 分鐘。在 Nix 上,小幅度的設定變更只要幾秒鐘就能完成,較大的變更也大約只需 30 秒。
我在使用 Ansible 時也遇到了版本衝突。如果一個服務依賴 Python 2,另一個依賴 Python 3.7,還有一個依賴 Python 3.10,它們就會互相干擾,試圖覆寫相同的檔案。
為什麼不用 Docker?
我也試過用 Docker 來運行服務,效果還可以。
Docker 對開發不太友善。使用 NixOS 和 Ansible 時,你大多可以重複使用打包程式碼來進行開發工作。雖然也有辦法在 Docker 容器內進行開發,但那並非 Docker 的設計初衷,所以會有點跟工具對抗的感覺。結果,我總是得用與開發環境設定重複的方式來定義 Docker 映像檔。
我也發現,當你有超過一個正在運行的行程時,Docker 就會變得更難用。舉例來說,如果你有一個依賴 Postgres 的網頁應用程式,現在就有兩個 Docker 容器,管理起來就變得有點麻煩。
我看過像 Podman、Rancher 和 k3s 這類解決方案,但我從未使用過。它們看起來像是額外增加了太多複雜度,不過也許實際上比我預期的要簡單。
NixOS 需求條件
若要跟著實作,你會需要一個已啟用 flakes(flake 功能) 的 NixOS 系統。我使用的是 NixOS 24.05。
我有寫過在 Raspberry Pi 4 上或在 Proxmox 下以虛擬機器形式安裝 NixOS 的教學。
一個基本的示範微服務
好的,說明就到這裡,接下來我會示範如何建立我的第一個 NixOS module,並讓它作為微服務在家用伺服器上運行。
建立一個名為 main.go 的檔案,定義一個簡易的 Go 網頁服務:
package main
import (
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"os/user"
"runtime"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
hostname, err := os.Hostname()
if err != nil {
log.Fatalf("failed to get hostname: %v", err)
}
currentUser, err := user.Current()
if err != nil {
log.Fatalf("failed to get username: %v", err)
}
localIP, err := getLocalIP()
if err != nil {
log.Fatalf("failed to get local IP: %v", err)
}
fmt.Fprintf(w, "Time: %s\n", getFormattedTime())
fmt.Fprintf(w, "Hostname: %s\n", hostname)
fmt.Fprintf(w, "Username: %s\n", currentUser.Username)
fmt.Fprintf(w, "Local IP: %s\n", localIP.String())
fmt.Fprintf(w, "Compiled with: %s\n", runtime.Version())
}
func getLocalIP() (net.IP, error) {
addrs, err := net.InterfaceAddrs()
if err != nil {
return net.IP{}, err
}
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
return ipnet.IP, nil
}
}
}
return net.IP{}, errors.New("no local IP address found")
}
func getFormattedTime() string {
now := time.Now()
zone, _ := now.Zone()
return now.Format("2006-01-02 03:04:05 PM") + fmt.Sprintf(" (%s)", zone)
}
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
http.HandleFunc("/", handler)
fmt.Printf("listening on :%s\n", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}我這樣執行服務:
$ nix-shell -p go --command 'PORT=5000 go run main.go'
listening on :5000然後在另一個終端機中,我可以用 curl 呼叫服務:
$ curl http://localhost:5000
Time: 2024-12-01 10:51:34 AM (EST)
Hostname: nixon
Username: mike
Local IP: 10.0.0.31
Compiled with: go1.22.8我也可以在網頁瀏覽器中檢視服務:
這個服務刻意做得很簡單、有點無聊,因為我想把焦點放在 NixOS 的打包部分。
建立一個簡易的 Go 模組
接下來,為這個應用程式建立一個 Go module,因為 Nix 需要它來建置 NixOS module:
$ nix-shell -p go --command 'go mod init codeberg.org/mtlynch/basic-go-web-app'
go: creating new go.mod: module codeberg.org/mtlynch/basic-go-web-app
go: to add module requirements and sums:
go mod tidy這會建立一個名為 go.mod 的檔案,內容如下:
$ cat go.mod
module codeberg.org/mtlynch/basic-go-web-app
go 1.22.8建立一個基本的 Nix flake
現在,我要建立一個能在 Nix 下建置並執行此應用程式的 Nix flake。請在名為 flake.nix 的檔案中加入以下內容:
{
description = "Basic Go web app";
inputs = {
# 1.23.2 release
go-nixpkgs.url = "github:NixOS/nixpkgs/4ae2e647537bcdbb82265469442713d066675275";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, go-nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system: let
gopkg = go-nixpkgs.legacyPackages.${system};
in {
packages.default = gopkg.buildGoModule {
pname = "basic-go-web-app";
version = "0.1.0";
src = ./.;
vendorHash = null;
};
apps.default = {
type = "app";
program = "${self.packages.${system}.default}/bin/basic-go-web-app";
};
});
}有了這個 flake,我就可以在 Nix 下執行我的應用程式:
$ PORT=5000 nix run
listening on :5000同樣地,我可以用 curl 呼叫我的服務:
$ curl http://localhost:5000
Time: 2024-12-01 10:14:49 AM (EST)
Hostname: nixon
Username: mike
Local IP: 10.0.0.31
Compiled with: go1.23.2請注意,flake 版本回報的編譯版本是 go1.23.2,而 nix-shell 版本回報的則是 go1.22.8。這是因為我在 go-nixpkgs 那一行指定了對應 go 1.23.2 的 gopkgs 版本,因為我喜歡鎖定確切的版本。
在 Nix flake 中加入 NixOS module
如果每次重新啟動 NixOS 系統都得執行 nix run 來啟動這個服務,那會很麻煩。我的目標是讓服務在背景自動運行,不需要我手動處理或管理。這就是 NixOS module 派上用場的地方。
調整 flake.nix 來定義一個 NixOS module:
{
description = "Basic Go web app";
inputs = {
# 1.23.2 release
go-nixpkgs.url = "github:NixOS/nixpkgs/4ae2e647537bcdbb82265469442713d066675275";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {
self,
go-nixpkgs,
flake-utils,
}: let
nixosModule = {
config,
lib,
pkgs,
...
}: {
options.services.basic-go-web-app = {
enable = lib.mkEnableOption "Basic Go web app service";
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "Port to listen on";
};
};
config = lib.mkIf config.services.basic-go-web-app.enable {
systemd.services.basic-go-web-app = {
description = "Basic Go Web App Service";
wantedBy = ["multi-user.target"];
after = ["network.target"];
serviceConfig = {
ExecStart = "${self.packages.${pkgs.system}.default}/bin/basic-go-web-app";
Restart = "always";
Type = "simple";
DynamicUser = "yes";
};
environment = {
PORT = toString config.services.basic-go-web-app.port;
};
};
};
};
in
(flake-utils.lib.eachDefaultSystem (system: let
gopkg = go-nixpkgs.legacyPackages.${system};
in {
packages.default = gopkg.buildGoModule {
pname = "basic-go-web-app";
version = "0.1.0";
src = ./.;
vendorHash = null;
};
apps.default = {
type = "app";
program = "${self.packages.${system}.default}/bin/basic-go-web-app";
};
}))
// {
nixosModules.default = nixosModule;
};
}現在,這個 Nix flake 裡有很多內容。我會在下面逐一拆解。
定義 NixOS module 選項
首先,我為我的 NixOS module 定義了選項。只有兩個:
{
...
options.services.basic-go-web-app = {
enable = lib.mkEnableOption "Enable basic-go-web-app service";
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "Port to listen on";
};
};這表示 NixOS 系統可以透過在 NixOS 設定中加入像下面這樣的幾行來匯入並啟用此服務:
{
services.basic-go-web-app = {
enable = true;
port = 3000;
};
}定義 systemd 設定
我使用 systemd 讓我的網頁應用程式在背景持續運行。當 NixOS 系統啟用 basic-go-web-app 時,它會為我的應用程式建立一個 systemd 服務:
{
...
config = lib.mkIf config.services.basic-go-web-app.enable {
systemd.services.basic-go-web-app = {
description = "Basic Go Web App Service";
wantedBy = ["multi-user.target"];
after = ["network.target"];
serviceConfig = {
ExecStart = "${self.packages.${pkgs.system}.default}/bin/basic-go-web-app";
Restart = "always";
Type = "simple";
DynamicUser = "yes";
};
environment = {
PORT = toString config.services.basic-go-web-app.port;
};
};
};ExecStart 這一行指定了 basic-go-web-app 的位置,它是從我 buildGoModule 步驟的輸出中取得的。
該服務也會將 NixOS module 的 port 選項轉換為 PORT 環境變數,因為這正是 main.go 讀取的內容(透過 os.Getenv("PORT"))。
DynamicUser = "yes" 會告訴 systemd 建立一個權限受限的使用者,並在該使用者的脈絡下運行服務。這可以防止服務意外與系統上的其他服務衝突,也能提升安全性。在 systemd 中還有許多其他強化選項,但由於這個玩具範例基本上沒有攻擊面,我就先略過了。
匯出 NixOS module
最後,在檔案末尾,我匯出 NixOS module,讓 NixOS 系統可以匯入它:
{
...
nixosModules.default = nixosModule;安裝 NixOS module
現在,該把 basic-go-web-app 的 NixOS module 匯入到我的 NixOS 系統根目錄的 flake.nix 檔案中了。
注意:我在談的是兩個不同的 flake.nix 檔案,這有點容易混淆。
第一個 flake.nix 是 basic-go-web-app 的 Nix flake,應該與 main.go 位於同一個資料夾中。
第二個 flake.nix 是 NixOS 系統根目錄的 Nix flake。在我的 NixOS 系統上,我的佈局類似這個 Misterio77 範例。
在我伺服器根目錄的 flake.nix 中,我像這樣匯入它並傳遞給我的主機(nixon):
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
nixos-hardware.url = "github:NixOS/nixos-hardware";
# Point this to wherever you placed basic-go-web-app's flake.nix.
basic-go-web-app.url = "path:/home/mike/basic-go-web-app";
};
outputs = { nixpkgs, nixos-hardware, basic-go-web-app, ... }: {
nixosConfigurations.nixon = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
specialArgs = {inherit inputs;};
modules = [
./hosts/nixon
basic-go-web-app.nixosModules.default
];
};
};
}然後,在我的主機的 Nix 檔案中(相對於我的 NixOS 系統根 flake,位於 ./hosts/nixon/default.nix),我寫了這些內容:
{
...
}: {
networking.hostName = "nixon";
services.basic-go-web-app = {
enable = true;
port = 3000;
};
networking.firewall.allowedTCPPorts = [3000];
system.stateVersion = "24.05";
}現在來到見真章的時刻。是時候用已啟用的 basic-go-web-app NixOS module 來重建我的主機了:
sudo nixos-rebuild switch --flake ".#${HOSTNAME}"執行成功了,所以我來檢查新服務的 systemd 日誌:
$ journalctl -u basic-go-web-app
Dec 01 10:20:53 nixon systemd[1]: Started Basic Go Web App Service.
Dec 01 10:20:53 nixon basic-go-web-app[186195]: listening on :3000日誌顯示服務正在運行,所以我試著呼叫它:
$ curl localhost:3000
Time: 2024-12-01 10:28:46 AM (EST)
Hostname: nixon
Username: basic-go-web-app
Local IP: 10.0.0.31
Compiled with: go1.23.2運行成功了!
請注意,現在它是在使用者名稱 basic-go-web-app 下運行,而不是我開發時的使用者名稱 mike。
原始碼
此範例的完整原始碼可在 Codeberg 上取得:
結論
我展示的這個網頁應用程式簡單又小巧,但我手邊有一些實際的工具程式,複雜度也差不多。困難的部分一直是如何找到一種方法,讓這些服務能全天候運行,又不會在打包或維護上增加額外的工作。
我才剛開始使用 NixOS modules 來管理我的個人服務,但對於這麼少的投入就能有這麼好的效果,我感到非常期待。
隨機一篇部落格
