Run a Simple Go Web Service on NixOS

Michael Lynch

NixOSでシンプルなGoウェブサービスを動かす

クラウドインフラ上で24時間365日動かしている、小さな自作ユーティリティがいくつかあります。例えば、PicoShareは、友人や同僚と手軽にファイルを共有するためのシンプルなウェブアプリです。

常時起動できれば本当は使いたい便利アプリもいくつかあるのですが、シンプルなアプリでさえ24時間動かし続けるには手間がかかるため、結局やらずじまいでした。

以前は自宅サーバーでおもちゃのようなアプリを動かしていました。cronジョブやsystemdサービスを設定したこともありますが、決まってどこかが壊れ、修正するのが面倒になって、そのままサービスを放置してしまっていました。

やっと、個人用アプリを常に利用可能な状態に保ちつつ、保守の手間を最小限に抑えてホストできる、摩擦の少ない方法を見つけたと思います。それがNixOSモジュールです。

私の解決策でできること

  • 任意のウェブサービスを、15分以内の作業でNixOSサーバー上で24時間稼働させられます。
  • コードを1行変更して再ビルドコマンドを実行するだけで、アップグレードやダウングレード、設定変更ができます。
  • 依存関係が競合するアプリ(例えばPython 2とPython 3)を同じサーバーで動かしても、バージョンの衝突を気にする必要がありません。
  • サーバー全体の設定をバージョン管理できるので、いつでも任意の状態にロールバックできます。

なぜNixOSなのか?

NixOSが、複数のサービスを24時間稼働させる用途に役立つと感じる理由はいくつかあります。

  • システム全体の設定がテキストファイルで記述されています。
  • システムの再ビルドが比較的高速です(通常は数秒から数分で完了します)。
  • NixOS上のサービスは合成しやすく、複数のサービスを組み合わせられます。
  • 拡張性にも優れており、オプションの調整や挙動へのパッチも簡単に行えます。

でも、Nixって複雑なんじゃないの?

はい、Nixは複雑です。ただ、思っているよりもずっととっつきやすいものです。

この1年ほど、少しずつNixを学んできましたが、全体を理解しなくても実用的なテクニックは身につけられると感じています。例えば、Nixを使ってプロジェクトごとの開発環境を構築していますが、それを実現するためにNixを深く理解する必要はありませんでした。

NixOSモジュールは難易度としては中級くらいだと思いますが、一度実例を見れば、比較的簡単に再現できるはずです。

なぜAnsibleではないのか?

以前は、Ansibleこそサービスを24時間動かすための素晴らしい解決策だと考えていました。

長年、さまざまなプロジェクト用に複数のVMを維持し、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コンテナが2つになり、管理が少し面倒になります。

PodmanやRancher、k3sといった解決策もあるのは知っていますが、使ったことはありません。余計に複雑になりそうな印象がありますが、思っているより簡単なのかもしれません。

NixOSの要件

この記事の内容を試すには、flakesを有効にしたNixOSシステムが必要です。私はNixOS 24.05を使いました。

NixOSのインストールについては、Raspberry Pi 4へのインストールProxmox上のVMとしてのインストールに関するチュートリアルを書いています。

シンプルなデモ用マイクロサービス

では、説明はここまでにして、最初のNixOSモジュールをどのように作成し、自宅サーバーでマイクロサービスとして動かしたかをお見せします。

シンプルなGoのウェブサービスを定義する、main.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でビルドして実行するための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モジュールを追加する

NixOSシステムを再起動するたびに、このサービスのためにnix runを実行しなければならないとしたら面倒です。私の目標は、何もしなくても、特に管理しなくてもサービスが自動でバックグラウンドで動き続けることです。そこでNixOSモジュールの出番です。

NixOSモジュールを定義するよう、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,
  }: 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モジュールのオプションを定義しました。今回は2つだけです。

{
  ...
  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モジュールのportオプションをPORT環境変数に変換しています。main.go読み取っているのが(os.Getenv("PORT")で)この環境変数だからです。

DynamicUser = "yes"は、systemdに権限を制限したユーザーを作成させ、そのユーザーの権限でサービスを実行するように指示します。これにより、システム上の他のサービスと誤って競合するのを防ぎ、セキュリティも向上します。systemdには他にも多くの強化オプションがありますが、今回のようなおもちゃの例では攻撃対象がほとんどないため、省略しています。

NixOSモジュールをエクスポートする

最後に、NixOSシステムがインポートできるよう、NixOSモジュールをエクスポートします。

{
  ...
  nixosModules.default = nixosModule;

NixOSモジュールをインストールする

次は、NixOSシステムのルートにあるflake.nixファイルに、basic-go-web-appのNixOSモジュールをインポートします。

補足: ここでは2つの異なるflake.nixファイルについて話しており、少し紛らわしいかもしれません。

1つ目のflake.nixbasic-go-web-app用のNix flakeで、main.goと同じフォルダに置く必要があります。

2つ目の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

動いています!

なお、今度は開発時のユーザー名であるmikeではなく、basic-go-web-appというユーザー名で実行されています。

ソースコード

このサンプルの完全なソースコードはCodebergで公開しています。

まとめ

今回紹介したウェブアプリはシンプルで小規模なものですが、実際のユーティリティでも複雑さは同じ程度のものがあります。難しかったのは、パッケージングや保守の手間を増やさずに、サービスを常時稼働させる方法を見つけることでした。

NixOSモジュールで個人用サービスを管理し始めたばかりですが、わずかな手間でこれほど上手くいくことにワクワクしています。

原文は Michael Lynch により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。