Nix를 Dockerfile과 함께 사용하기
Nix는 강력한 크로스 플랫폼 패키지 관리 도구입니다. Nix의 장점은 매우 많지만, 그중 큰 장점 하나는 Nix를 도입하고 나면 개발 환경( Linux와 Mac 모두), CI, 프로덕션에서 일관된 환경을 구성할 수 있다는 점입니다.
저는 수년째 Nix를 사용해 왔고, 최근에는 Dockerfile과 Nix를 함께 사용해 Docker 이미지를 만들기 시작했습니다. 이 글에서는 이 접근 방식의 장점과 함께, 실제 모습과 사용감을 보여 주는 간단한 예제를 설명하겠습니다.
죄송하지만 이 글은 Nix 입문서가 아닙니다. 이 글을 읽기 위해 Nix 사용법을 알 필요는 없지만, 기본적인 Nix 개념을 설명하거나 Nix 언어를 소개하지도 않을 것입니다. Nix를 모르는 분도 이 글을 읽고 Nix를 배워 볼 만한지 판단하는 데 활용할 수는 있습니다.
Dockerfile은 쉬운데, 왜 Nix인가?
Dockerfile은 꽤 쉽습니다. 셸 명령을 차례로 이어 붙이면 되니까요. 따라서 여기에 Nix 같은 도구를 추가하는 것을 주저하는 것도 당연합니다. 실용적인 이유는 Nix를 사용하면 로컬 머신, CI, Docker 등에서 언제나 작동하는 환경을 공짜로1 얻을 수 있기 때문입니다. 중복 작업도 거의 또는 전혀 없습니다.
일반적인 비(非)Nix 방식에서는 로컬 개발, CI, Docker를 각각 따로 구성합니다(이 글은 Docker 이미지 빌드에 관한 글이므로 Docker를 "프로덕션" 환경이라고 부르겠습니다).
로컬 개발 환경에는 방대한 README가 있을 수도 있고, Docker Compose나 Vagrant 등을 사용할 수도 있습니다.
그다음 CI에서 코드를 실행하거나 테스트해야 할 때는 환경을 다시 설정하는 YAML 정의를 작성하게 됩니다. CI 환경은 로컬 환경과 충분히 달라서 셸 요구 사항이 약간씩 다른 경우가 많습니다.
마지막으로 프로덕션에는 최종 이미지를 빌드하기 위해 실행할 셸 명령을 별도로 담은 Dockerfile이 있습니다.
각각 따로 보면 괜찮습니다. 물론 여전히 조금 성가시다고 할 수는 있겠지만요. 하지만 이 모든 것을 합치면 아주 성가시고 매우 취약합니다. 기능 하나를 위해 로컬 개발 환경과 Dockerfile을 수정했다가 CI를 망가뜨린 것을 깨달은 사람이 저뿐만은 아니길 바랍니다. 또는 개발 환경을 제대로 구성하고 PR에 초록색 체크 표시 ✅까지 확인했는데, 프로덕션 런타임이 망가져 있는 것을 뒤늦게 알아차린 적도 있을 겁니다.
Nix를 사용하면 이런 문제는 모두 사라집니다. Nix는 소프트웨어를 빌드하고 실행하는 데 필요한 것을 정의하는 단일 진실 공급원입니다. 이 단일 진실 공급원만 업데이트하면 대체로 모든 환경에서 그대로 작동합니다. 이제 소프트웨어를 빌드하고 실행하는 방법을 여러 방식으로 따로 관리할 필요가 없습니다.
진심입니다. 저는 수년째 "내 컴퓨터에서는 되는데 다른 컴퓨터에서는 안 되는" 문제를 겪지 않았습니다. 단 한 번도요. 요즘은 Nix 신 콤플렉스를 극복하려고 적극적으로 노력하고 있습니다. 너무 오랫동안 그런 문제를 겪지 않다 보니, Nix를 사용하지 않는 사람들이 여러 환경에서 소프트웨어를 실행하는 데 문제가 있다고 불평하는 모습을 보면 정말 이해가 안 됩니다. 마치 저는 다리를 타고 자전거를 타고 강을 건너고 있는데, 누군가 강을 바라보며 건너려면 물속을 걸어야 한다고 한탄하는 것 같습니다.
이것은 여러 장점 중 하나일 뿐이지만, 가장 실용적인 장점이라고 생각합니다. Nix 순수주의자들2은 순수성, 재현성, 강력한 언어 등을 다른 장점으로 내세울 것입니다. 모두 맞는 말이지만, 진짜 문제는 환경이 그냥 제대로 작동하기를 바란다는 것이라고 생각합니다.
Docker만 단독으로 사용하는 것과 비교하면, Nix를 Docker와 함께 사용하는 일은 대체로 더 어렵다고 인정할 수밖에 없습니다. 하지만 Nix를 사용해 동일한 설정으로 CI와 개발 환경까지 개선할 수 있다는 누적 효과를 고려하면, Nix를 Docker와 함께 사용하는 편이 Docker만 사용하는 것보다 쉬워지고, 실용적인 장점도 수없이 생깁니다.
이 글에서는 Docker 이미지에 집중하겠습니다. 따라서 동일한 설정을 CI나 개발 환경 등에 사용하는 방법은 자세히 다루지 않겠습니다. 이 주제를 다룬 글은 이미 많습니다. 로컬 개발 환경은 Nix와 Direnv를 참고하세요. CI는 제 GitHub Actions 워크플로를 살펴보면 됩니다.
핵심 아이디어
먼저 핵심 아이디어를 큰 틀에서 설명한 뒤, 실제 코드와 셸 명령을 사용한 예제를 보여 드리겠습니다. 아이디어는 다음과 같습니다.
- Nix 코드로 애플리케이션을 빌드하고 실행하는 방법을 기술합니다.
- Dockerfile과 공식 Nix 이미지를 사용해 대략 하나의 셸 명령으로 Nix를 통해 애플리케이션을 빌드합니다.
- 멀티 스테이지 빌드에서
FROM scratch를 사용해 빌드한 애플리케이션을 가능한 한 작은 이미지로 복사합니다. 최종 이미지에는 Nix가 아예 설치되지 않습니다. 빌드에 Nix를 사용했을 뿐입니다.
1단계가 재사용 가능한 부분입니다. 이 코드는 개발 환경이나 CI 환경을 빌드하는 데도 그대로 사용할 수 있습니다. 앞서 말했듯 이 글에서는 그 부분을 자세히 다루지 않겠습니다.
Nix만 사용해 Docker 이미지를 빌드하고 docker나 Dockerfile은 전혀 사용하지 않는 "Nix와 Docker" 글도 다른 곳에 있습니다. 그것도 가능하고 전혀 문제없습니다. 다만 Dockerfile을 사용하는 방법을 쓰고 싶었습니다. Dockerfile이 사람들에게 더 익숙하고 덜 위협적으로 느껴질 가능성이 높으며, 생태계의 수많은 도구가 Dockerfile을 읽고 사용하기 때문입니다.
예제: Python과 Flask
실제 사례로 Flask 웹 애플리케이션을 실행하기 위한 Docker 이미지를 Nix로 빌드해 보겠습니다. 전체 코드는 GitHub에서 확인할 수 있습니다.
Python 애플리케이션
이 글의 목적은 Flask를 배우는 것이 아니므로 Flask 애플리케이션 코드는 src/app.py에서 확인하면 됩니다. 대략 아래 코드 블록과 같습니다. 루트 페이지에 "Hello World"를 출력하는 Flask 퀵스타트 코드일 뿐입니다.
from flask import Flask
app = Flask(__name__)
@app.route("/")
def hello_world():
return "<p>Hello, World!</p>"Nix Flake
다음으로 애플리케이션을 빌드하는 방법을 기술하는 Nix flake를 만들어야 합니다. Nix flake는 개발 환경을 만들고 패키지를 빌드하는 방법 등을 기술하는 일종의 Nix 환경입니다. pyproject.toml, Cargo.toml, go.mod, package.json 등과 비슷하지만 Nix를 위한 파일이라고 보면 됩니다.
Nix flake는 flake.nix에 있으며 다음과 같습니다.
{
description = "flask-example";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/release-22.11";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let pkgs = import nixpkgs { inherit system; };
in with pkgs; rec {
# Development environment
devShell = mkShell {
name = "flask-example";
nativeBuildInputs = [ python3 poetry ];
};
# Runtime package
packages.app = poetry2nix.mkPoetryApplication {
projectDir = ./.;
};
# The default package when a specific package name isn't specified.
defaultPackage = packages.app;
}
);
}방금 저보고 엿 먹으라고 한 건가요? (만화) Nix를 배우기 전에는 누군가 Nix 코드 블록을 던져 줄 때마다 보통 이런 기분이었습니다. 처음 보는 코드는 대개 위협적으로 느껴지고, 게다가 Nix는 미적으로 가장 보기 좋은 언어도 아닙니다. 그래도 조금만 더 따라와 주세요. 이 글에서 Nix 코드를 보는 것은 이번이 마지막이며, 이후 내용에는 대부분 큰 영향을 주지 않습니다.
대부분은 보일러플레이트입니다. 핵심은 devShell과 packages.app가 있는 줄입니다. devShell은 Python과 Poetry가 설치된 개발 환경을 만듭니다. packages.app은 최종 패키지를 빌드하는 방법을 기술합니다. Nix는 Poetry를 일급 시민으로 지원하므로 Poetry 애플리케이션을 빌드하라고 요청하기만 하면 됩니다. 대부분의 주요 언어에도 Nix를 쉽게 사용할 수 있도록 비슷한 수준의 도우미가 있습니다.
시스템에 Nix를 설치한 뒤 nix build를 실행하면 모든 것이 제대로 작동하는지 확인할 수 있습니다. 이 명령은 패키지를 빌드하며, result/bin/app에서 애플리케이션을 실행할 수 있습니다.
$ nix build
...
$ result/bin/app
* Serving Flask app 'src.app'
* Debug mode: off
* Running on http://127.0.0.1:5000
Press CTRL+C to quit잠깐! 이건 은근히 대단합니다. Nix가 익숙하지 않다면 방금 일어난 일이 얼마나 인상적인지 눈치채지 못했을 수도 있습니다. result/bin/app가 무엇인지 살펴보세요(cat으로 확인해 보세요). 그리고 그 안을 계속 따라가 보세요. 이것은 Nix로 설치한 소프트웨어에만 의존해 Python 앱을 실행하는 스크립트입니다. 로컬 시스템에는 전혀 의존하지 않고 충돌도 일으키지 않습니다. 다른 버전의 Python이나 Flask 등이 설치되어 있어도 전혀 상관없습니다. 애플리케이션은 완벽하게 패키징되어 있습니다.
Dockerfile
이제 Docker를 사용해 모든 것을 하나로 합쳐 보겠습니다. 다음은 Dockerfile입니다.
# Nix builder
FROM nixos/nix:latest AS builder
# Copy our source and setup our working dir.
COPY . /tmp/build
WORKDIR /tmp/build
# Build our Nix environment
RUN nix \
--extra-experimental-features "nix-command flakes" \
--option filter-syscalls false \
build
# Copy the Nix store closure into a directory. The Nix store closure is the
# entire set of Nix store values that we need for our build.
RUN mkdir /tmp/nix-store-closure
RUN cp -R $(nix-store -qR result/) /tmp/nix-store-closure
# Final image is based on scratch. We copy a bunch of Nix dependencies
# but they're fully self-contained so we don't need Nix anymore.
FROM scratch
WORKDIR /app
# Copy /nix/store
COPY --from=builder /tmp/nix-store-closure /nix/store
COPY --from=builder /tmp/build/result /app
CMD ["/app/bin/app"]이것은 멀티 스테이지 빌드입니다. 먼저 nixos/nix를 기반으로 하는 builder 컨테이너에서 시작합니다. 이것은 nix만 설치된 Nix 공식 베이스 이미지입니다.
이 builder에서 먼저 nix build를 실행합니다.
RUN nix \
--extra-experimental-features "nix-command flakes" \
--option filter-syscalls false \
build앞에서 실행한 것과 동일합니다. 추가 플래그는 Nix 명령에서 flakes를 사용할 수 있도록 합니다(flakes는 아직 실험적 기능으로 분류되어 있습니다). filter-syscalls 옵션을 사용하면 원할 때 Apple Silicon에서 Intel용으로 크로스 컴파일할 수 있습니다.
다음 단계는 다음과 같습니다.
RUN mkdir /tmp/nix-store-closure
RUN cp -R $(nix-store -qR result/) /tmp/nix-store-closure이것이 핵심 단계입니다. nix store -qR result는 애플리케이션에 필요한 Nix 디렉터리의 전체 목록을 출력합니다. 구체적으로 말하면 애플리케이션에 필요한 의존성의 클로저입니다. 헷갈릴 수 있으니 마지막으로 다시 표현하겠습니다. 애플리케이션을 실행하는 데 필요한 의존성(파일과 폴더)의 최소 집합이며, 말 그대로 그 외에는 아무것도 포함하지 않습니다.
마지막으로 from scratch 컨테이너를 사용해 최종 이미지를 빌드합니다.
FROM scratch
WORKDIR /app
COPY --from=builder /tmp/nix-store-closure /nix/store
COPY --from=builder /tmp/build/result /app
CMD ["/app/bin/app"]클로저를 /nix/store에 복사하면 애플리케이션에 필요한 모든 의존성을 갖추게 됩니다. 그런 다음 result 심볼릭 링크를 /app으로 복사합니다. 이후 /app/bin/app을 엔트리포인트로 설정합니다. 이는 Nix 파일을 테스트할 때 위에서 resuilt/bin/app을 실행한 방식과 비슷합니다.
실행해 보기
Docker 이미지를 빌드하고 실행합니다.
$ docker build -t flask-example:dev .
...
$ docker run --rm flask-example:dev
* Serving Flask app 'src.app'
* Debug mode: off
* Running on http://127.0.0.1:5000
Press CTRL+C to quit단점
이 방식으로 Docker 이미지를 빌드할 때 단점은 많지 않지만, 지적 정직성을 위해 제가 생각할 수 있는 단점을 최대한 적어 보겠습니다.
가장 분명한 단점은 Nix 지식이 필요하다는 점입니다. Nix는 배우기가 특별히 쉽지 않다는 평판이 있습니다. 최근 몇 년 사이 Nix 문서는 크게 개선되었고, 많은 도움을 주는 Zero to Nix 같은 추가 자료도 생겼습니다. 또한 Nix Installer 생태계도 훨씬, 훨씬 좋아졌습니다.
Nix를 배우는 데 드는 시간이 0은 아니므로, 이 단점은 CI나 개발 환경 같은 추가 기능에도 Nix를 사용할 계획이 있을 때만 감수할 만하다고 생각합니다. 제 의견으로는 결국 Nix를 배우게 될 것입니다. 정말 끝내주게 좋기 때문입니다.
또 다른 단점은 Docker 이미지에서 생성되는 레이어가 최적의 형태가 아니라는 점입니다. 단일 RUN nix build 명령이 모든 의존성을 포함한 거대한 레이어를 생성합니다. 빌드 시간 측면에서는 의존성 대부분을 바이너리 캐시에서 내려받기 때문에 매우 빠릅니다. 하지만 캐싱에는 최적이 아니며, 사실상 배포할 때마다 런타임 환경이 이미지에서 가장 큰 레이어를 다시 다운로드해야 합니다.
Nix는 Dockerfile 대신 기본 제공 Nix dockerTools를 사용해 이미지를 빌드하면 Docker 이미지 레이어를 더 최적화할 수 있습니다. 하지만 이 글의 핵심은 Dockerfile 방식을 보여 주는 것입니다.
다음 단계
꽤 간단했다고 생각합니다. 주석을 제외하면 Dockerfile은 15줄도 되지 않습니다. 게다가 Nix를 사용하면 개발 환경과 CI 환경을 빌드할 때 사용하는 코드와 정확히 동일한 코드를 사용하므로 모든 로직을 공유할 수 있습니다. Dockerfile은 변경할 필요가 없습니다.
이 방식은 평범한 Dockerfile을 사용하므로, 이미 익숙할 가능성이 높은 docker, 다양한 CI/CD 도구, PaaS 서비스 등과 아주 잘 통합됩니다.
마지막으로 Nix의 추가 장점도 모두 얻을 수 있습니다. Docker 이미지에는 애플리케이션을 실행하는 데 필요한 최소한의 파일만 들어가며 그 외에는 아무것도 없습니다. Docker 이미지의 내용도 재현 가능합니다(메타데이터가 이미지 자체의 해시를 변경할 수는 있습니다). 이런 점이 중요할 수도 있고 그렇지 않을 수도 있지만, 단점은 전혀 없으며 공짜로 얻을 수 있습니다.
앞서 말했듯 이 접근 방식의 장점은 로컬 개발이나 CI 같은 다른 환경에서 Nix 설정을 재사용하기 시작하면 훨씬 크게 누적됩니다. 따라서 현재의 Nix 사용을 확장하고 싶거나, 더 폭넓게 Nix를 사용하기 위한 입문 경로를 찾고 있다면 이 방식을 추천합니다.
각주
글을 무작위로 읽기