在 NixOS 上运行一个简单的 Go Web 服务
我有几个玩具级的实用小应用,全天候(24/7)运行在云基础设施上。其中一个例子是 PicoShare,一个简单的 Web 应用,让我可以轻松地与朋友和队友共享文件。
还有一些便利类应用,如果运行起来足够省事,我本来是会全天候运行的。但即使是运行一个简单的应用,7×24 小时不间断地跑起来也有足够的摩擦,所以我没有这么做。
过去,我曾尝试在家庭服务器上运行玩具级应用。我设置过 cron 任务和 systemd 服务,但总会有东西出问题,我修烦了之后就干脆让服务死掉。
我想我终于找到了一种低摩擦的方式来托管个人应用,既能保持它们持续可用,又能把维护开销降到最低:NixOS 模块。
我的方案能让我做什么
- 对于任意一个 Web 服务,我在 NixOS 服务器上让它 7×24 小时运行所需的工作量不到 15 分钟。
- 我可以通过修改一行代码并执行一条重建命令来升级/降级或更改配置选项。
- 我可以在同一台服务器上运行依赖相互冲突的应用(例如 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 就变得更难用了。例如,如果你的 Web 应用依赖 Postgres,那现在就有两个 Docker 容器,管理起来就麻烦了一些。
我见过 Podman、Rancher 和 k3s 这类解决方案,但从没用过。它们看起来额外复杂度太高,不过也许比我预想的要简单。
NixOS 要求
要跟着本文操作,你需要一台启用了 flakes 的 NixOS 系统。我用的是 NixOS 24.05。
我写过关于安装 NixOS 的教程:在 Raspberry Pi 4 上,或者作为虚拟机安装在 Proxmox 下。
一个基础的演示微服务
好了,解释完毕,下面我来展示我是如何创建第一个 NixOS 模块,并让它在家庭服务器上作为一个微服务运行的。
创建一个名为 main.go 的文件,定义一个简单的 Go Web 服务:
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我也可以在 Web 浏览器中查看这个服务:
这个服务刻意做得简单甚至有点无聊,因为我想把重点放在 NixOS 打包这一部分。
创建一个简单的 Go module
接下来,为这个应用创建一个 Go module,因为 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 系统可以通过在其 NixOS 配置中加入类似这样的行来导入并启用这个服务:
{
services.basic-go-web-app = {
enable = true;
port = 3000;
};
}定义 systemd 配置
我使用 systemd 让我的 Web 应用持续在后台运行。当某个 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 上获取:
结语
我展示的这个 Web 应用简单又小巧,但我实际使用的实用工具的复杂度也差不多就是这个水平。难点在于找到一种方式,能让这些服务一直运行,同时又不会增加额外的打包或维护工作。
我才刚开始用 NixOS 模块来管理我的个人服务,但投入这么少就能获得如此好的效果,让我感到很兴奋。
随机一篇博客
