在 NixOS 上執行簡單的 Go 網頁服務
原文由 Michael Lynch 于 發布,訂閱此部落格
我有幾個全年無休跑在雲端基礎架構上的小工具程式。其中一個例子是PicoShare,一個能讓我輕鬆與朋友和同事分享檔案的簡單網頁應用程式。
還有好幾個方便的小工具,如果能輕鬆地讓它們一直跑著,我本來會想跑。但即使只是讓一個簡單的應用程式全年無休地執行,麻煩程度也足以讓我打消念頭。
過去我曾試著在家用伺服器上跑這些小工具。我設定過 cron 排程和 systemd 服務,但總是難免有東西出錯,修到最後覺得煩了,就放著讓服務掛掉不管。
我想我終於找到一種低摩擦力的做法,可以讓個人應用程式保持隨時可用,同時把維護的負擔降到最低:NixOS 模組。
我的解法能做到什麼
- 對於任意一個網頁服務,我只要花不到 15 分鐘就能讓它在我的 NixOS 伺服器上全年無休地跑起來。
- 我只要改一行程式碼、執行重建指令,就能升級/降級或修改設定選項。
- 我可以在同一台伺服器上跑有相依性衝突的應用程式(例如 Python 2 和 Python 3),完全不用擔心版本衝突。
- 把整台伺服器的設定都放進版本控制,隨時可以回復到任何一個先前的狀態。
為什麼選擇 NixOS?
我覺得 NixOS 適合用來全年無休地跑一整組服務,有幾個原因:
- 整個系統的設定都在純文字檔案裡。
- 重建系統的速度相對很快(通常只要幾秒或幾分鐘)。
- NixOS 上的服務很好組合(我可以把多個服務結合在一起)
- NixOS 上的服務也很好擴充(我可以輕鬆調整選項或修補行為)。
但 Nix 不是很複雜嗎?
是的,Nix 很複雜,但它比你想像中更容易上手。
過去一年來我一點一點地學 Nix,發現就算沒有搞懂全貌,也能學到實用的技巧。舉例來說,我用 Nix 來設定每個專案各自的開發環境,當時也不需要對 Nix 有多深入的了解就能做起來。
我會把 NixOS 模組歸在中等難度,但我覺得只要看過一個範例,要複製就相對容易。
為什麼不用 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 的 NixOS 系統。我用的是 NixOS 24.05。
我有寫過在 Raspberry Pi 4 上或在 Proxmox 下以虛擬機形式安裝 NixOS 的教學。
一個基本的示範微服務
好了,解釋告一段落,接下來我要示範我是怎麼建立第一個 NixOS 模組,並讓它在家用伺服器上以微服務的形式跑起來。
建立一個名為 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 模組,因為 Nix 需要它來建構 NixOS 模組:
$ 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 flake 來在 Nix 下建構並執行這個應用程式。把以下內容加入名為 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 模組
如果每次重開 NixOS 系統都要手動執行 nix run 來啟動這個服務,那就太麻煩了。我的目標是讓服務在背景自動執行,不需要我手動介入或管理。這就是 NixOS 模組派上用場的地方。
調整 flake.nix 來定義一個 NixOS 模組:
{
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 模組選項
首先,我為 NixOS 模組定義選項,只有兩個:
{
...
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 系統只要在設定檔中加入像這樣的幾行,就能匯入並啟用這個服務:
{
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 模組的 port 選項轉成 PORT 環境變數,因為 main.go 就是讀取這個變數(透過 os.Getenv("PORT"))。
DynamicUser = "yes" 是告訴 systemd 建立一個權限有限的使用者,並在該使用者的環境下執行服務。這可以避免服務不小心跟系統上其他服務衝突,也能提升安全性。systemd 內還有許多其他強化安全的選項,但因為這個玩具範例基本上沒有什麼攻擊面,我就先略過了。
匯出 NixOS 模組
最後,我在結尾匯出這個 NixOS 模組,讓 NixOS 系統可以匯入它:
{
...
nixosModules.default = nixosModule;安裝 NixOS 模組
現在,該把 basic-go-web-app 這個 NixOS 模組匯入到我 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 模組來重建我的主機了:
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 模組來管理我的個人服務,但我很驚訝只花這麼少的投入,就能有這麼好的效果。
隨機一篇部落格

留言
登入後參與討論