NixOS에서 MirageOS Unikernels 사용하기
요약
본 글은 DNS 서버를 안정적이고 재현 가능하게 배포하는 방법을 다루며, 특히 MirageOS Unikernels을 NixOS 환경에서 사용하는 과정을 설명합니다. 전통적인 방식의 한계를 극복하고 보안성과 효율성을 높이기 위해 OCaml 기반의 유니커널 아키텍처와 Nix의 재현성 시스템을 결합하는 것이 핵심입니다.
핵심 포인트
- NixOS는 DNS 서버 배포를 간편하게 만들어줍니다.
- MirageOS는 애플리케이션에 특화된 '유니커널' OS 생성을 가능하게 합니다.
- Unikernels은 데드 코드 제거로 보안성과 효율성이 높습니다.
- 본 글은 Nix의 재현성 시스템을 활용하여 Mirage unikernel 배포를 구현하는 방법을 탐구합니다.
NixOS에서의 MirageOS Unikernels
도메인 이름 시스템(DNS)은 현대 인터넷의 핵심 구성 요소로, 도메인 이름을 IP 주소, 메일 서버 등과 연결할 수 있게 합니다. 이를 통해 사용자들은 사람이 읽을 수 있는 이름을 사용하여 위치에 구애받지 않고 서비스에 접근할 수 있습니다. 우리는 자체 DNS 서버를 호스팅하여 우리 도메인에 대한 권한적(authoritative) 제어권을 확보하고, 서버를 사용하는 사람들의 개인 정보를 보호하며, 제3자 DNS 제공업체에 의존하지 않음으로써 신뢰성을 높이고, 제공하는 레코드(또는 서버 자체의 동작)를 더 크게 맞춤 설정할 수 있습니다. 하지만 제가 석사 논문을 진행하면서 발견했듯이, 자체 서버를 안정적이고 재현 가능하게 배포하는 것은 상당히 어려울 수 있습니다 [2]. Nix 배포 시스템은 이러한 문제를 해결하는 것을 목표로 합니다. NixOS 머신을 사용하면 DNS 서버 배포가 다음과 같이 간단합니다:
{services.bind = {
enable = true;
...
}
그리고 다음 명령어로 쿼리할 수 있습니다.
$ dig ryan.freumh.org @ns1.ryan.freumh.org +short
135.181.100.27
사용자가 네임서버를 지정하지 않고도 우리 도메인을 쿼리할 수 있도록 하려면, 등록기관(registrar)에 ns1.freumh.org를 DNS 호스팅 머신의 IP 주소로 가리키는 글루 레코드(glue record)를 생성해야 합니다.
이 설정은 C 언어로 작성된 전통적인 bind2를 실행하고 있다는 것을 알 수 있습니다. 대안으로, 기능적이고(functional), 고수준이며(high-level), 타입 안전한(type-safe) 프로그래밍 언어를 사용하여 네트워크 애플리케이션을 생성하면 성능 좋은 실행을 유지하면서도 안전성과 사용성을 크게 향상시킬 수 있습니다 madhavapeddyMelangeCreatingFunctional2007?.
그러한 언어 중 하나가 OCaml입니다.
MirageOS3는 이러한 OCaml 프로그램들을 위한 배포 방식 [3]입니다. 전통적인 Unix 프로세스로 실행하는 대신, 애플리케이션을 실행하기 위해 특화된 '유니커널(unikernel)' 운영체제를 생성합니다. 이는 데드 코드 제거를 가능하게 하여 공격 표면적을 줄이고 보안성을 개선하며 효율성을 높여줍니다.
하지만 NixOS에서 Mirage unikernel을 배포하려면 OCaml 생태계 고유의 명령형(imperative) 배포 방법론을 사용해야 하며, 이는 Nix가 제공하는 재현 가능한 시스템(reproducible systems)의 이점을 제거합니다. 본 블로그 게시물에서는 Nix를 사용하여 Mirage unikernels의 재현 가능한 배포를 어떻게 가능하게 했는지 탐구할 것입니다.
이 시점에서 궁금해하는 독자는 ‘Nix’가 무엇인지 궁금할 수 있습니다. 자세한 내용은 별도의 Nix 웹페이지를 참조하십시오.
§MirageOS
MirageOS는 사용자가 unikernels을 생성할 수 있도록 하는 라이브러리 운영체제(library operating system)입니다. 이는 저수준 운영체제 코드와 고수준 애플리케이션 코드를 단일 커널과 단일 주소 공간에 모두 포함하는 특화된 운영체제입니다 [3].
이는 최초의 ‘unikernel 생성 프레임워크’였지만, exokernel 라이브러리 OS 아키텍처 [4]와 같은 오랜 OS 연구 계보에서 비롯되었습니다. 애플리케이션 코드를 커널에 임베딩하면 데드 코드 제거(dead-code elimination)가 가능하여 OS
패키지. 니크스(Nix) 파생물에 대한 모든 입력은 그 자체가 니크스 파생물이라는 점을 기억하십시오 (§); 즉, 패키지를 Nix 표현식 – 다시 말해, NixOS 모듈 – 에서 사용하려면 해당 패키지를 Nix로 빌드해야 합니다. Mirage unikernel을 Nix로 빌드하면, 이를 배포하기 위한 NixOS 모듈을 작성할 수 있습니다.
§Unikernels 빌드하기
Mirage는 OCaml용 패키지 관리자인 opam7을 사용합니다. opam의 의존성(dependencies)은 프로그래밍 언어 패키지 관리자에서 흔히 볼 수 있듯이, 메타데이터 외에도 빌드/설치 스크립트를 포함하는 파일을 가지고 있으며, 이 파일에 의존성과 그 버전 제약 조건이 명시됩니다. 예를 들어8
...
depends: [
"arp" { ?monorepo & >= "3.0.0" & < "4.0.0" }
...
이러한 각 의존성은 자체적인 의존성과 그 고유의 버전 제약 조건을 가집니다. 결과 프로그램에 하나의 의존성만 링크할 수 있기 때문에, 우리는 이러한 제약 조건을 만족시키는 일련의 의존성 버전을 해결해야 합니다. 이것은 쉽지 않은 문제입니다. 사실, 이는 NP-완전(NP-complete) [5]합니다. Opam은 의존성 해결을 위해 Zero Install9 SAT 솔버를 사용합니다.
Nixpkgs에는 우리가 Nix 파생물의 빌드 입력으로 제공할 수 있는 많은 OCaml 패키지10가 있습니다11. 그러나 Nixpkgs는 전역적이고 일관된 패키지 버전 세트12, 13을 가지고 있습니다. 여러 버전의 패키지를 동시에 설치할 수 있다는 지원은 그것들이 고유한 경로에 저장되어 필요할 때 별도로 참조하거나 심볼릭 링크(symlinked)될 수 있다는 사실에서 비롯됩니다. 따라서 다른 프로젝트나 사용자가 다른 버전의 Nixpkgs를 사용하더라도 충돌하지 않지만, Nix는 의존성 버전 해결을 수행하지 않습니다 – 모든 것이 고정(pinned)됩니다14.
이것은 정적인 Nixpkgs 인스턴스로 만족될 수 없는 버전 제약 조건을 가진 opam 프로젝트에게 문제가 됩니다.
다행히도, Tweag의 프로젝트가 이미 존재하여(opam-nix)
이를 처리합니다15, 16. 이 프로젝트는 Nix 파생물 내부에서 opam 의존성 버전 솔버를 사용하고, 그 결과로 나온 의존성 버전들로부터 파생물을 생성합니다17.
하지만 이것만으로는 Mirage unikernels를 빌드하는 것을 지원하지는 않습니다. Unikernel은 종종 크로스 컴파일(cross-compiled)이 필요합니다. 즉, 빌드되는 플랫폼이 아닌 다른 플랫폼에서 실행되도록 컴파일해야 합니다. 일반적인 대상인 Solo518은 unikernels를 위한 격리된 실행 환경입니다. 이는 unikernels와 다양한 하이퍼바이저 백엔드 간의 인터페이스 역할을 하는 최소한의 shim 레이어(minimal shim layer) 역할을 합니다. Solo5는 다른 glibc를 사용하며, 이로 인해 크로스 컴파일이 필요합니다. Mirage 419는 Dune 빌드 시스템에서 툴체인(toolchains)을 사용하여 크로스 컴파일을 지원합니다20. 이는 일반적인 방식으로 opam 스위치(가상 환경)에 설치된 호스트 컴파일러와 대상 컴파일러21를 모두 사용합니다. 하지만 패키지의 크로스 컴파일 컨텍스트는 빌드 시간(build time)에만 알려지기 때문에, 일부 메타프로그래밍 모듈은 호스트 컴파일러를 이용한 전처리(preprocessing)가 필요할 수 있습니다. 올바른 컴파일 컨텍스트가 사용되도록 보장하기 위해, 우리는 Dune에게 모든 소스 의존성(sources’ dependencies)을 제공해야 합니다. 이를 수행하기 위해 opam-monorepo라는 도구가 만들어졌습니다22.
우리는 이 풀 리퀘스트를 통해 opam-nix 프로젝트를 확장하여 opam-monorepo 워크플로우를 지원했습니다:
github.com/tweag/opam-nix/pull/18.
하지만 이것은 Nix로 Mirage unikernels를 빌드하기 위한 매우 낮은 수준의(low-level) 지원입니다. 더 나은 사용자 경험을 제공하기 위해, 우리는 Hillingar Nix flake: github.com/RyanGibb/hillingar도 만들었습니다. 이는 Mirage 툴링과 opam-nix 함수 호출을 래핑하여 간단한 고수준(high-level) flake이 Mirage 프로젝트에 드롭되어 Nix로 빌드되도록 지원합니다. unikernel에 Nix 빌드 지원을 추가하려면, 단순히 다음을 수행하면 됩니다:
# hillingar의 기본 템플릿으로 flake 생성
$ nix flake new . -t github:/RyanGibb/hillingar
# 빌드하려는 unikernel의 이름을 대체합니다
...
예를 들어, Nix로 Mirage 웹사이트를 unikernel로 빌드하는 flake는 다음을 참고하세요: github.com/RyanGibb/mirage-www/blob/master/flake.nix.
§의존성 관리(Dependency Management)
잠시 뒤로 물러나서 큰 그림을 살펴보면, 여기서 작용하고 있는 여러 종류의 의존성을 고려할 수 있습니다:
- 시스템 의존성 (System dependencies): 시스템 패키지 관리자를 통해 설치되는 의존성을 말합니다. 예를 들어 opam의 용어로는
depexts가 있습니다. 이는 Hillingar를 위한 Nix이며, 다른 플랫폼의 패키지 관리자에는apt,pacman,brew등이 포함됩니다. 유니커널의 경우, 이러한 것들은 종종gmp와 같은 C 라이브러리입니다. - 라이브러리 의존성 (Library dependencies): 프로그래밍 언어 패키지 관리자를 통해 설치되는 의존성을 말합니다. 예를 들어opam,pip,npm등이 있습니다. 이 의존성들은 종종 버전 제약 조건을 가지며, SAT 솔버를 사용하여 해결해야 할 수도 있습니다. - 파일 의존성 (File dependencies): 파일 시스템 수준의 세분화된 의존성을 말합니다. 예를 들어 C 파일, Java(내부 클래스 아님) 클래스 또는 OCaml 모듈이 있습니다. 대부분은 단일 프로젝트에 국한되겠지만, 모노레포에서는 상호 운용되는 여러 프로젝트에 걸쳐 있을 수 있습니다 (예: Nixpkgs). 이는 Make, Dune, Bazel과 같은 빌드 시스템이 다루는 세분화 수준입니다. - 함수 의존성 (Function dependencies): 언어 고유의 함수 또는 다른 코드 단위 간의 의존성을 말합니다. 예를 들어 함수a가 함수b를 호출한다면,a는b에 '의존'한다고 합니다. 이는 컴파일러와 인터프리터가 일반적으로 관심을 갖는 세분화 수준입니다. 고차 함수(higher-order functions) 영역에서는 이러한 의존성이 사전에 알려지지 않을 수 있지만, 이는 본질적으로 빌드 시스템이 동적 의존성(dynamic dependencies)[6]과 함께 직면하는 것과 같은 문제입니다.
Nix는 시스템 의존성을 잘 처리하지만, 라이브러리 의존성 버전을 해결하는 네이티브한 방법은 없습니다. Opam은 라이브러리 의존성을 잘 처리하지만, 시스템 패키지를 재현 가능한 방식으로 설치하는 일관된 방법이 없습니다. 그리고 Dune은 파일 의존성을 다루지만, 다른 것들은 그렇지 못합니다. OCaml 컴파일러는 프로그램을 컴파일하고 링크할 때 함수 의존성을 추적합니다.
§교차 컴파일 (Cross-Compilation)
Dune은 Mirage 유니커널의 교차 컴파일(cross-compilation)을 지원하는 데 사용됩니다 (§). 우리는 Dune의 DSL에서 preprocess 구문을 사용하여 교차 컴파일 컨텍스트를 인코딩합니다. 예를 들어 mirage-tcpip에서 다음과 같습니다.
(library
(name tcp)
(public_name tcpip.tcp)
...
이 코드는 Dune에게 호스트 컴파일러를 사용하여 opam 패키지 ppx_cstruct를 전처리하도록 지시합니다. 이 정보는 빌드 관리자로부터만 얻을 수 있기 때문에, opam-monorepo 도구를 사용한 크로스 컴파일을 지원하려면 모든 종속성 소스를 가져와야 합니다:
크로스 컴파일 - 일부 네이티브 코드를 빌드하는 방법의 세부 정보는 파이프라인 후반에 나올 수 있으며, 소스가 이용 가능하다면 이는 문제가 되지 않습니다.
이는 본질적으로 빌드 시스템 규칙 내에서 컴파일 컨텍스트를 인코딩한다는 것을 의미합니다. opam-monorepo로 종속성 소스를 로컬에서 클론해야 하는 요구 사항을 제거하려면, 패키지 관리자 내에 컴파일 컨텍스트를 인코딩하려고 시도할 수 있습니다. 하지만 전처리는 OCaml 모듈 수준의 세밀함(granularity)일 수 있습니다. Dune은 파일 종속성으로 이 수준의 세밀함을 처리하지만, opam은 그렇지 않습니다. Rust의 Cargo처럼 빌드 관리자와 패키지 관리자 간의 더 긴밀한 통합이 이러한 상황을 개선할 수 있을 것입니다. opam을 모듈화하고 Dune과의 더 긴밀한 통합을 만드는 방향으로 일부 계획들이 있습니다.
또한 크로스 컴파일을 피하기 위해 Nix를 사용할 가능성도 있습니다. Nixpkgs의 크로스 컴파일24은 단순히 소프트웨어를 크로스 컴파일 친화적인 방식으로 패키징하는 방법을 지정할 뿐이므로, 여기서 본질적으로 도움이 되지 않습니다. 하지만 Nix가 설치된 원격 머신25에서 재현 가능한 빌드를 가능하게 하는 Nix 원격 빌더는 특정 컨텍스트에서 크로스 컴파일의 필요성을 우회할 수 있습니다.
§버전 해결 (Version Resolution)
Hillingar는 opam을 통해 Zero Install SAT 솔버를 사용하여 버전 해결을 수행합니다. 이것이 작동하지만, Nix가 라이브러리 종속성과 함께 작동하도록 하는 가장 원칙적인 접근 방식은 아닙니다. 일부 패키지 관리자는 시스템 종속성에 대해서만 Nix를 사용하고, 라이브러리 종속성에는 기존 도구를 정상적으로 사용하는 경우26가 있습니다. 하지만 일반적으로, X2nix
프로젝트는 매우 많고 ad hoc 방식으로 생성됩니다. 이 중 일부는 모든 언어 생태계의 패키지 저장소 시스템을 다루는 것과 관련되어 있으며, 코드 중복을 줄이는 것을 목표로 하는 기존 접근 방식들이 존재하지만27, 28 여전히 버전 해결이라는 근본적인 문제가 남아 있습니다. Nix는 포인터(경로)를 사용하여 종속성의 다른 버전을 참조하는데, 이는 시스템 종속성에 대한 다이아몬드 의존성 문제(diamond dependency problem)를 해결할 때 잘 작동하지만, 라이브러리 종속성을 가진 바이너리를 연결할 때는 이러한 여유가 없습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Lobste.rs ML의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기