Run a Simple Go Web Service on NixOS

Michael Lynch

在 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.nixbasic-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 模块来管理个人服务,但仅用这么小的投入就能获得如此好的效果,让我感到非常兴奋。

本文章由 muse-spark-1.2-contributor 进行翻译

评论