Run a Simple Go Web Service on NixOS

Michael Lynch

NixOS에서 간단한 Go 웹 서비스 실행하기

원문은 Michael Lynch님이 에 게재했습니다. 이 블로그 구독하기

클라우드 인프라에서 24시간 내내 돌리고 있는 토이 유틸리티 앱이 몇 개 있다. 대표적인 예가 PicoShare인데, 친구나 팀원들과 파일을 쉽게 공유할 수 있게 해주는 간단한 웹 앱이다.

상시로 돌리기만 쉽다면 당장이라도 운영하고 싶은 편의용 앱이 여러 개 있다. 하지만 단순한 앱 하나를 24시간 돌리는 일조차 마찰이 꽤 커서 결국 실행하지 않게 된다.

예전에는 홈 서버에서 토이 앱을 돌려본 적이 있다. cron 작업과 systemd 서비스를 설정했지만, 어김없이 뭔가가 고장 났고, 고치는 게 지쳐 결국 서비스를 방치하게 됐다.

그러다 마침내 유지보수 부담은 최소화하면서 개인용 앱을 항상 사용할 수 있게 유지하는, 마찰이 적은 방법을 찾은 것 같다. 바로 NixOS 모듈이다.

내 해결책으로 할 수 있는 것

  • 임의의 웹 서비스가 주어지면, NixOS 서버에서 24시간 구동까지 15분도 채 걸리지 않는다.
  • 코드 한 줄을 바꾸고 리빌드 명령을 실행하는 것만으로 업그레이드/다운그레이드나 설정 변경을 할 수 있다.
  • 의존성이 충돌하는 앱(예: Python 2와 Python 3)을 같은 서버에서 돌려도 버전 충돌을 걱정할 필요가 없다.
  • 서버 전체 설정을 소스 컨트롤로 관리하므로 언제든 원하는 상태로 롤백할 수 있다.

왜 NixOS인가?

24시간 서비스를 계속 돌리는 작업에 NixOS가 유용하다고 느끼는 이유는 몇 가지가 있다:

  • 전체 시스템 설정이 텍스트 파일로 되어 있다.
  • 시스템을 다시 빌드하는 속도가 비교적 빠르다(보통 수 초에서 수 분).
  • NixOS의 서비스는 조합이 용이하다(여러 서비스를 함께 구성할 수 있다)
  • 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 컨테이너가 두 개가 되고, 관리가 조금 더 복잡해진다.

Podman, Rancher, k3s 같은 솔루션도 봤지만 직접 써본 적은 없다. 너무 복잡해 보여서인데, 어쩌면 내가 생각하는 것보다 쉬울지도 모른다.

NixOS 요구 사항

이 글을 따라 하려면 flakes가 활성화된 NixOS 시스템이 필요하다. 나는 NixOS 24.05를 사용했다.

Raspberry Pi 4에 NixOS를 설치하는 방법이나 Proxmox에서 VM으로 설치하는 방법에 대한 튜토리얼을 작성해 두었다.

간단한 데모 마이크로서비스

설명은 이 정도로 하고, 첫 번째 NixOS 모듈을 만들어 홈 서버에서 마이크로서비스로 실행한 과정을 보여주겠다.

main.go라는 파일을 만들어 간단한 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 줄에서 gopkgs 버전을 지정했기 때문인데, 해당 버전이 go 1.23.2에 해당한다. 나는 정확한 버전을 고정하는 걸 좋아한다.

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를 사용한다. 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

실행 중이다!

이제 개발할 때 사용하던 mike라는 사용자 이름 대신 basic-go-web-app이라는 사용자 이름으로 실행된다는 점에 주목하자.

소스 코드

이 예제의 전체 소스는 Codeberg에서 볼 수 있다:

결론

내가 보여준 웹 앱은 단순하고 작지만, 비슷한 복잡도를 가진 실제 유틸리티들도 가지고 있다. 어려웠던 부분은 패키징이나 유지보수에 추가 작업을 더하지 않으면서 서비스를 항상 실행할 방법을 찾는 것이었다.

개인 서비스를 관리하는 데 NixOS 모듈을 사용하기 시작한 지 얼마 되지 않았지만, 적은 투자로도 이렇게 잘 동작한다는 점이 무척 기대된다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글