NixOS에서 간단한 Go 웹 서비스 실행하기
클라우드 인프라에서 24시간 내내 실행하는 간단한 유틸리티 앱 몇 개를 운영하고 있습니다. 그중 하나가 PicoShare로, 친구나 팀원과 파일을 손쉽게 공유할 수 있게 해주는 단순한 웹 앱입니다.
상시로 실행하기만 쉽다면 돌리고 싶은 편의성 앱도 여러 개 있습니다. 하지만 아무리 단순한 앱이라도 24시간 내내 돌리는 일에는 마찰이 있어 결국 실행하지 않게 됩니다.
예전에는 집 서버에서 이런 장난감 앱들을 돌려 본 적이 있습니다. cron 작업과 systemd 서비스를 설정해 봤지만, 결국 어딘가가 고장 나고 고치는 게 지쳐 서비스가 죽도록 내버려 두곤 했습니다.
개인용 앱을 상시 가용 상태로 유지하면서도 유지보수에 들어가는 수고를 최소화하는 저마찰 호스팅 방법을 드디어 찾은 것 같습니다. 바로 NixOS 모듈입니다.
내 해결책으로 할 수 있는 일
- 임의의 웹 서비스가 주어졌을 때, NixOS 서버에서 24시간 내내 실행되도록 만드는 데 15분도 걸리지 않습니다.
- 코드 한 줄을 바꾸고 rebuild 명령어 한 번 실행하는 것만으로 업그레이드/다운그레이드하거나 설정 옵션을 변경할 수 있습니다.
- 의존성이 충돌하는 앱들(예: Python 2와 Python 3)을 같은 서버에서 실행해도 버전 충돌을 걱정할 필요가 없습니다.
- 서버 전체 설정을 소스 컨트롤로 관리하므로 언제든지 원하는 상태로 롤백할 수 있습니다.
왜 NixOS인가?
24시간 내내 여러 서비스를 실행하는 작업에 NixOS가 유용하다고 느끼는 이유는 몇 가지가 있습니다:
- 전체 시스템 설정이 텍스트 파일로 되어 있습니다.
- 시스템을 다시 빌드하는 속도가 비교적 빠릅니다(보통 수 초에서 수 분).
- NixOS의 서비스는 조합하기 쉽습니다(I can combine services)
- 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에 의존하는 웹 앱이 있다면 컨테이너가 두 개가 되고, 관리가 조금 더 복잡해집니다.
Podman, Rancher, k3s 같은 솔루션도 접해 봤지만 직접 써 본 적은 없습니다. 불필요하게 복잡해 보이지만, 어쩌면 생각보다 쉬울지도 모르겠습니다.
NixOS 요구 사항
이 과정을 따라 하려면 flakes가 활성화된 NixOS 시스템이 필요합니다. 저는 NixOS 24.05를 사용했습니다.
Raspberry Pi 4에 NixOS를 설치하는 방법과 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 모듈 만들기
다음으로, Nix가 NixOS 모듈을 빌드하는 데 필요하므로 이 앱을 위한 Go 모듈을 만듭니다:
$ 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.2flake 버전은 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 모듈의 옵션을 정의했습니다. 두 가지뿐입니다:
{
...
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.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실행되고 있습니다!
이제 개발용 사용자 이름인 mike가 아니라 basic-go-web-app이라는 사용자 이름으로 실행되고 있다는 점에 주목해 주세요.
소스 코드
이 예제의 전체 소스는 Codeberg에서 볼 수 있습니다:
결론
제가 보여드린 웹 앱은 단순하고 작지만, 복잡도가 비슷한 실제 유틸리티들도 가지고 있습니다. 어려운 부분은 패키징이나 유지보수에 추가 작업을 더하지 않으면서 서비스를 상시 실행할 방법을 찾는 것이었습니다.
개인 서비스를 관리하는 데 NixOS 모듈을 사용하기 시작한 지 얼마 되지 않았지만, 이렇게 적은 투자로도 훌륭하게 동작한다는 점에 기대가 큽니다.
글을 무작위로 읽기
