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.2flake 버전은 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.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 모듈을 사용하기 시작한 지 얼마 되지 않았지만, 적은 투자로도 이렇게 잘 동작한다는 점이 무척 기대된다.
글을 무작위로 읽기

댓글
로그인하고 댓글 남기기