Run a Simple Go Web Service on NixOS

Michael Lynch

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

原文は Michael Lynch により に公開されました。 このブログを購読する

私はクラウドインフラ上で24時間365日動かしている、小さなユーティリティアプリがいくつかあります。一例がPicoShareで、友人やチームメンバーとファイルを簡単に共有するためのシンプルなウェブアプリです。

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

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

常時稼働を保ちつつ、運用の手間を最小限に抑えて個人用アプリをホストする、摩擦の少ない方法をようやく見つけたと思います。それがNixOSモジュールです。

私の方法でできること

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

なぜNixOSなのか?

NixOSが24時間365日サービスを動かし続ける用途に役立つと感じる理由はいくつかあります:

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

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

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

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

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

なぜAnsibleではないのか?

以前は、Ansibleはサービスを24時間365日動かすための素晴らしいソリューションだと思っていました。

何年もの間、さまざまなプロジェクト用に複数の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モジュールの出番です。

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モジュールのオプションを定義しました。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モジュールをインストールする

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

注意: ここでは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モジュールを使い始めたばかりですが、わずかな手間でこれほど上手く機能することにワクワクしています。

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

コメント