Dockerfile과 함께 Nix 사용하기
원문은 Mitchell Hashimoto님이 에 게재했습니다. 이 블로그 구독하기
Nix는 강력한 크로스 플랫폼 패키지 관리 도구입니다. Nix의 장점은 매우 광범위하지만, 가장 큰 장점 중 하나는 Nix를 도입하면 개발(리눅스와 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는 여러분의 소프트웨어를 빌드하고 실행하는 데 필요한 것들에 대한 단일 진실 공급원(single source of truth)입니다. 이 하나의 진실 공급원만 업데이트하면 대체로 어디서든 그냥 동작합니다. 더 이상 소프트웨어를 빌드하고 실행하는 방법을 여러 번 설명해 둘 필요가 없습니다.
진심입니다. 저는 수년간 “내 컴퓨터에서는 되는데 [다른 곳에서는 안 되는]” 문제를 겪은 적이 없습니다. 단 한 번도요. 저는 지금 Nix 신 콤플렉스를 극복하려고 애쓰고 있습니다. 너무 오래 그런 일이 없었기 때문에, Nix를 쓰지 않는 사람들이 여러 환경에서 소프트웨어를 실행하는 문제로 불평하는 걸 보면 정말로 어리둥절합니다. 마치 강을 바라보며 헤엄쳐 건너야 한다고 한탄하는 사람 옆에서, 저는 자전거를 타고 다리를 건너는 기분입니다.
이것은 수많은 장점 중 하나일 뿐이지만, 가장 실용적인 장점이라고 생각합니다. Nix 순수주의자들2은 순수성, 재현 가능성, 강력한 언어 등 다른 것들을 내세울 겁니다. 물론 다 맞는 말이지만, 진짜 핵심은 여러분이 원하는 건 환경이 그냥 동작하는 것이라는 점입니다.
단독으로 보면, Docker와 함께 Nix를 쓰는 것이 Docker만 쓰는 것보다 대체로 더 어렵다는 점을 인정합니다. 하지만 동일한 설정으로 CI와 개발 환경까지 개선할 수 있다는 Nix의 누적된 이점을 생각하면, Docker와 함께 Nix를 쓰는 방식이 Docker만 쓰는 것보다 오히려 더 쉬워지고, 그 외에도 수많은 실질적인 이점이 생깁니다.
이번 글에서는 Docker 이미지에 집중할 예정이므로, 동일한 설정을 CI나 개발 환경 등에 어떻게 활용하는지는 자세히 다루지 않겠습니다. 이 주제에 대해서는 이미 많은 블로그 글들이 있습니다. 로컬 개발 환경에 대해서는 Nix and Direnv를 참고하세요. CI에 대해서는 제 GitHub Actions 워크플로를 살펴보세요.
핵심 아이디어
먼저 핵심 아이디어를 큰 그림에서 설명하고, 이어서 코드와 셸 명령어가 포함된 실제 예제를 보여드리겠습니다. 아이디어는 다음과 같습니다:
- 애플리케이션을 빌드하고 실행하는 방법을 설명하는 Nix 코드를 작성합니다.
- Dockerfile과 공식 Nix 이미지를 사용해, 대략 한 줄의 셸 명령어로 Nix를 이용해 애플리케이션을 빌드합니다.
- 멀티 스테이지 빌드의
FROM scratch를 사용해 빌드된 애플리케이션을 가능한 가장 작은 이미지에 복사합니다. 이 최종 이미지에는 Nix가 전혀 설치되어 있지 않습니다 — 우리는 그저 빌드를 위해 Nix를 사용했을 뿐입니다.
1단계가 재사용 가능한 부분입니다. 이 동일한 코드를 개발 환경이나 CI 환경을 만드는 데도 쓸 수 있습니다. 앞서 말씀드렸듯, 이번 글에서는 그 부분은 자세히 다루지 않겠습니다.
다른 “Nix와 Docker” 관련 글들 중에는 오직 Nix만으로 Docker 이미지를 빌드하고 docker나 Dockerfile을 전혀 사용하지 않는 경우도 있습니다. 그것도 가능하고 전혀 문제될 게 없지만, 저는 Dockerfile을 사용하는 방법에 대해 쓰고 싶었습니다. Dockerfile이 사람들에게 더 익숙하고 덜 부담스러울 가능성이 크고, 생태계의 많은 도구들이 Dockerf ile을 직접 읽어 사용하기 때문입니다.
예제: 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 공식 베이스 이미지입니다.
이 빌더에서 먼저 nix build를 실행합니다:
RUN nix \
--extra-experimental-features "nix-command flakes" \
--option filter-syscalls false \
build이는 앞서 했던 것과 동일합니다. 추가 플래그는 Nix 명령어에서 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 디렉터리 전체 목록을 출력합니다. 구체적으로 말하면, 우리 애플리케이션에만 필요한 의존성의 클로저(closure)입니다. 헷갈릴 수 있다는 걸 알기 때문에 한 번 더 정리하자면, 애플리케이션을 실행하는 데 필요한 의존성(파일과 폴더) 중 가능한 가장 작은 집합이며, 그 외에는 정말 아무것도 포함하지 않는다는 뜻입니다.
마지막으로, 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를 배우는 데 일정 시간이 드는 만큼, 이 단점은 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 사용으로 나아가는 관문으로 이 접근 방식을 활용하시길 권합니다.
각주
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기