在 NixOS 上运行一个简单的 Go Web 服务
原文由 Michael Lynch 于 发布,订阅该博客
我在云基础设施上 7×24 小时运行着几个小型的工具类应用。其中一个例子是 PicoShare,一个能让我轻松与朋友和同事分享文件的简单 Web 应用。
还有不少顺手的小工具,如果能毫不费力地让它们持续运行,我肯定会用起来。但现实是,哪怕只是让一个简单的应用 7×24 小时在线,也足够麻烦,所以我一直没去做。
过去,我尝试过在自己的家用服务器上跑这些小应用。我设置过 cron 任务和 systemd 服务,但总是会有东西出问题,修来修去最后也厌烦了,干脆让服务自生自灭。
现在,我好像终于找到了一种低负担的方式来托管个人应用,既能让它们始终在线,又能把维护成本降到最低:NixOS 模块。
我的方案能做什么
- 对于任意一个 Web 服务,在我的 NixOS 服务器上让它 7×24 小时跑起来,花费不超过 15 分钟。
- 只需改一行代码再执行一次重建命令,就能升级/降级或修改配置选项。
- 可以在同一台服务器上运行依赖冲突的应用(例如同时运行依赖 Python 2 和 Python 3 的应用),完全不用担心版本冲突。
- 整个服务器的配置都能纳入版本控制,随时可以回滚到任意历史状态。
为什么选择 NixOS?
之所以觉得 NixOS 适合用来 7×24 小时运行一系列服务,主要有以下几个原因:
- 整个系统配置都是文本文件。
- 重建系统的速度相对很快(通常只需几秒到几分钟)。
- NixOS 上的服务易于组合(我可以把多个服务组合在一起)
- NixOS 上的服务易于扩展(我可以轻松地调整选项或修改行为)。
但 Nix 不是很复杂吗?
是的,Nix 确实复杂,但它比你想象的要容易上手。
过去一年里,我陆陆续续学了一些 Nix,发现即便不完全搞懂整个体系,也能掌握一些实用的技巧。例如,我用 Nix 来搭建按项目隔离的开发环境,当时并不需要对 Nix 有多深入的理解就能做成。
我会把 NixOS 模块归为中等难度,但我觉得只要看过一个例子,再去模仿就相对容易了。
为什么不用 Ansible?
我曾经认为 Ansible 是让服务 7×24 小时运行的绝佳方案。
多年来,我为各种项目维护了一组虚拟机,并用 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 这类方案,但从未实际用过。它们看起来像是额外增加了太多复杂性,不过也许实际用起来比我想的要简单。
环境要求
要跟着本文操作,你需要一个已启用 flakes 的 NixOS 系统。我使用的是 NixOS 24.05。
我写过在树莓派 4 上或在 Proxmox 中作为虚拟机安装 NixOS 的教程。
一个简单的演示微服务
好了,背景介绍就到这里,下面我来演示如何创建我的第一个 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也可以在浏览器中查看该服务:
这个服务特意做得非常简单甚至有点无聊,因为我想把重点放在 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 让 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 模块来管理个人服务,但仅用这么小的投入就能获得如此好的效果,让我感到非常兴奋。
随机一篇博客

评论
登录后参与讨论