내가 원하던 배포 도구 만들기
(ruuda.nl)- Deptool은 DNS와 웹서버 설정을 직접 운영하기 위해 만든 배포 도구로, 변경 계획을 먼저 보여주고 확인 후 대상 호스트에 적용함
- 전체 클러스터 설정을 미리 렌더링해 Git으로 관리하고, 호스트별
/var/lib/deptool아래에 commit별 디렉터리를 둔 뒤current심볼릭 링크를 바꿔 버전을 원자적으로 전환함 - 배포 전 각 호스트에서 락을 잡고 로컬이 알고 있는 commit과 실제 배포 상태를 비교해 stale 계획을 중단하며, 영향받는 모든 호스트의 락을 확보한 경우에만 진행함
- 서비스는 systemd 유닛으로 실행되며 설정 변경 시 재시작하고, 시작 실패 시 이전 known-good 버전으로 링크를 되돌린 뒤 다시 재시작해 밀리초 단위 자동 롤백을 수행함
- 원격 실행은 SSH를 전송 계층으로만 쓰는 정적 에이전트 방식이며, Flatcar Linux처럼 Python과 패키지 관리자가 없는 환경에서도 coreutils만으로 자동 설치 가능함
Deptool을 만든 배경
- 유럽 디지털 주권에 대한 글을 미국 호스팅과 미국 통제 하이퍼스케일러 위에 올리는 모순을 피하려고 블로그를 유럽으로 옮기는 작업에서 출발함
- DNS도 Cloudflare에 의존하고 있었기 때문에 DNS 서버를 직접 운영할 필요가 생김
- 기존 웹서버는 작은 VM에서 Nginx와 인증서 갱신용 Lego를 실행하고, Nginx 설정은 Nix로 생성한 뒤 작은 Python 스크립트로 서버에 복사하고 Nginx를 재시작하는 방식이었음
- DNS 서버를 운영하려면 최소 두 대의 서버, 더 많은 systemd 유닛, 설정 파일, zonefile이 필요해져 기존 스크립트로는 부족해짐
- NixOS로 전환하는 선택지도 있었지만, 최소한의 기본 OS와 읽기 전용 chroot에서 필요한 바이너리만 포함해 서비스를 실행하는 현재 방식을 유지하고 새 배포 도구를 만들기로 함
Deptool의 사용 모습
- Deptool은 클러스터 설정 변경 계획을 먼저 보여주고, 확인을 받은 뒤 대상 호스트에 적용함
- DNS 레코드 업데이트 예시는
deptool deploy실행 후s4.ruuda.nl과s5.ruuda.nl에서nsd설정 파일 변경과nsd.service재시작을 계획으로 보여줌 - 배포 실패 시 자동 롤백이 적용되며, 예시에서는
prod클러스터의 2개 호스트에 적용할지 확인한 뒤 0.99초 만에 성공함 - 출력은 대상 호스트, 변경 애플리케이션, 변경 파일, 재시작할 systemd 유닛을 분리해 보여줘 배포 전에 실제 수행될 작업을 확인할 수 있게 함
원하는 배포 도구의 조건
-
빠름
- 설정 업데이트는 1초 미만이어야 하며, 대서양 횡단 ping도 100ms 수준이므로 본질적으로 더 느려야 할 이유가 없다고 봄
-
예측 가능함
- 도구는 수행할 작업을 먼저 보여주고 그대로 실행해야 함
- OpenTofu처럼 계획(plan) 과 적용(apply) 단계를 분리하는 방식을 원함
- Ansible의 check mode는 명령형 단계가 실행된 뒤에야 연쇄 변경이 드러날 수 있고, check와 실제 실행 사이에 호스트 상태가 바뀌는 것도 막지 못해 신뢰하기 어렵다고 봄
-
안전함
- Nginx 설정이 깨져도 웹서버가 몇 분 동안 내려가 있지 않도록, 도구가 밀리초 단위로 자동 롤백해야 함
-
단순함
- 필요한 것은 노트북에서 서버로 설정 파일을 복사하고 몇 개의 systemd 유닛을 재시작하는 것뿐임
- 모든 배포 문제를 해결하거나 제어 흐름, 임의 코드 실행을 제공할 필요는 없음
- 설정 파일 템플릿 처리는 별도 도구로 할 수 있으며, YAML 템플릿에 대한 문제의식은 generate와 별도 파일 생성 도구로 분리됨
-
선언적이어야 함
- 설정에서 파일이나 애플리케이션을 제거하면 서버에서도 제거되어야 함
- 명시적인 정리 단계를 추가하지 않아도 되고, 잊어버려서 drift나 남은 파일이 생기지 않아야 함
-
초기 설정이 없어야 함
- 서버를 프로비저닝한 직후부터 관리할 수 있어야 함
- 에이전트, 데몬, 의존성을 수동 설치하거나 호스트를 등록하는 절차가 필요하면, 그 절차를 다시 자동화해야 하는 문제가 생김
설정 생성과 배포의 분리
- 핵심 아이디어는 설정 생성과 배포를 분리하는 것임
- 직장에서 David가 만든 Unsible은 Ansible playbook을 단계별로 실행하지 않고, 로컬에서 tarball을 만든 뒤 호스트로 보내 파일을 배치하는 방식임
- 기존의 단순 배포 스크립트도 외부에서 설정을 빌드하고 스크립트는 파일 복사에 가까운 역할을 했음
- NixOS도 이 아이디어를 로컬 시스템에 적용한 것으로 볼 수 있으며, Nix에서 배울 수 있는 점은 생성된 산출물을 여러 버전이 공존할 수 있는 위치에 저장하고, system administration의 명령형 부분을 몇 개의 심볼릭 링크를 바꾸는 작은 활성화 단계로 제한하는 것임
- 이 설계는 패키지 관리와 시스템 설정 모두에 잘 맞음
Deptool의 동작 방식
-
전체 클러스터 설정을 미리 렌더링함
- 전체 클러스터의 설정 파일을 미리 생성해 디스크의 디렉터리에 저장함
- 디렉터리 트리는 두 단계 깊이이며, 최상위에는 대상 호스트별 디렉터리, 그 아래에는 애플리케이션별 디렉터리가 있음
-
Git 저장소에 넣음
- 설정 디렉터리를 Git 저장소에 넣으면 버전 간 차이를 비교할 수 있고, 전체 클러스터에서 무엇이 바뀌었는지 확인 가능함
- diffstat으로 영향을 받는 호스트와 변경된 앱을 알 수 있으며, 각 설정 파일의 정확한 diff를 볼 수 있음
-
호스트의 격리된 디렉터리에 파일을 실체화함
- 모든 파일은
/var/lib/deptool아래에 두어 다른 요소와 간섭하지 않게 함 - 배포할 commit 이름으로 디렉터리를 만들기 때문에 여러 버전이 디스크에 공존 가능함
current심볼릭 링크가 배포된 버전을 가리키게 해 버전을 원자적으로 바꿀 수 있음- 삭제된 파일은 다음 버전에 실체화되지 않으므로 남은 파일이 생기지 않음
- 특정 위치에 파일을 요구하는 애플리케이션에는 파일시스템의 필요한 위치에서
/var/lib/deptool로 향하는 심볼릭 링크를 만들 수 있음 - 심볼릭 링크 생성·삭제는 원자적이지 않지만, 파일 내용 수정 때가 아니라 링크 추가·삭제 때만 필요함
- 이후 배포 버전에 해당 심볼릭 링크가 포함되지 않으면 diff를 통해 삭제해야 함을 알 수 있어 파일이 남지 않음
- 모든 파일은
-
원격 추적 ref로 배포 상태를 기록함
- 운영자 노트북에서 각 호스트에 배포된 commit을 추적함
- 배포 상태는 클러스터 전체 속성이 아니라 호스트별 속성임
- 어떤 변경이 특정 호스트에 영향을 주지 않으면 그 호스트에 새 commit을 배포할 필요가 없음
- 이 정보로 클러스터 diff를 오프라인에서 계산할 수 있고, 이 diff가 배포 계획이 되어 밀리초 단위로 표시 가능함
-
배포 전 대상 호스트에서 락을 획득함
- SSH로 연결해 락 요청을 보내며, 요청에는 해당 호스트에 배포됐다고 생각하는 commit을 포함함
- 락을 획득하면 계획이 유효했고, 락을 해제할 때까지 다른 배포가 해당 호스트에서 진행될 수 없어 계획이 계속 유효함
- 변경의 영향을 받는 모든 호스트의 락을 보유한 경우에만 배포가 진행됨
- ref가 오래되어 다른 무언가가 호스트에 배포된 상태라면 계획은 stale 상태이므로 중단함
- 이후 로컬 ref를 갱신하면 다음 실행에서 최신 계획을 볼 수 있음
-
systemd 유닛을 재시작함
- 모든 서비스는 systemd 유닛으로 실행되고 빠르게 시작되므로, 확실하지 않을 때는 재시작하는 쪽을 택함
- 애플리케이션 설정이 바뀌면 영향받는 systemd 유닛을 재시작함
- 유닛 시작이 실패하면 심볼릭 링크를 이전 known-good 버전으로 되돌리고 다시 재시작해 밀리초 단위 자동 롤백이 가능함
낙관적 동시성 모델
- Deptool 배포에는 낙관적 동시성 요소가 있음
- 현재 클러스터 상태를 알고 있다고 가정하고 계획을 세우며, 그 가정이 틀렸다면 다시 시도해야 함
- 경합이 없을 때는 매우 빠르며, 개인 인프라를 한 사람이 같은 노트북에서 배포하는 경우가 여기에 해당함
- 여러 사람이 계속 배포를 시도하는 환경에서는 한 명만 성공하고 나머지는 재시도해야 하므로 성능이 매우 나빠질 수 있음
- 이 모델은
git push와 같으며, 수백 명이나 수천 대 서버 규모로 확장되지는 않지만 개인 인프라 용도에는 충분함 - 직접 도구를 만들면 자신의 정확한 사용 사례에 맞게 최적화할 수 있음
에이전트 구축
-
Flatcar Linux와 초기 호스트 제약
- 웹서버는 Flatcar Linux에서 실행됨
- Flatcar Linux는 이미지 기반 OS이며 userspace가 매우 작고, coreutils와 Bash는 있지만 패키지 관리자와 Python은 없음
- 공격 표면을 줄이는 데는 좋지만 무언가를 설치하기에는 불리함
- 도구가 작동하려고 먼저 무언가를 설치해야 한다면, 그 설치 과정을 자동화해야 하는 새 문제가 생김
-
SSH를 전송 계층으로만 사용함
- 새 호스트를 바깥에서 관리해야 하므로 SSH와 passwordless sudo를 사용할 수 있음
- 명령을 SSH로 직접 실행하는 방식은 handshake가 느릴 뿐 아니라 argv가 SSH 경계를 안전하게 넘지 못하고, shell-over-SSH의 word splitting과 escaping 문제를 다뤄야 함
- Deptool은 예측 가능한 위치에 있는 인자 없는 단일 프로그램을 실행하고, 이 프로그램을 에이전트로 사용함
- 에이전트는 stdin에서 메시지를 읽고 stdout으로 응답함
- SSH는 소켓처럼 전송 수단으로만 쓰이며, 사용자 제어 입력이 SSH나 shell 명령에 들어가지 않아 escaping 문제를 피함
-
정적 바이너리를 사용함
- 에이전트는 정적 바이너리로 빌드함
- 커널 외에 무엇이 있는지 가정하지 않고, 유용한 일을 하기 전에 수 MB 코드를 파싱해야 하는 인터프리터도 필요 없음
- Ansible은 최악의 결함을 mitigate한 뒤에도 연결할 때마다 수 MB의 Python 모듈을 전송하며, Flatcar에는 Python도 없음
-
commit 기반 경로에 바이너리를 둠
- 에이전트 바이너리는 빌드된 commit을 포함한 경로에 저장함
- 양쪽 연결 끝이 같은 버전을 실행하도록 보장해 프로토콜 호환성 문제가 생기지 않게 함
- 경로는
/var/lib/deptool/bin/deptool-<version>-<commit>형식임
-
먼저 바이너리가 있다고 가정함
- SSH handshake는 비용이 크므로, 프로브나 멱등 설치 단계에 낭비하지 않음
- 에이전트 바이너리는 약 1.6MB로 전송이 금지될 정도로 크지는 않지만 무료도 아님
- 클러스터 설정 변경은 Deptool 업데이트보다 훨씬 자주 일어나므로, 보통은 바이너리가 이미 존재한다고 봄
-
실행 실패 시 바이너리를 설치함
- 바이너리 시작에 실패하면 두 번째 SSH 연결로 설치를 수행함
- 실행 명령은 다음과 같음
uname -sm
&& sudo mkdir -p /var/lib/deptool/{bin,apps,store}
&& sudo dd status=none of=<remote_bin_path>
&& sudo chmod +x <remote_bin_path>
&& sudo sha256sum <remote_bin_path>
- 먼저 stdout에서 한 줄을 읽어
uname출력을 얻고, 이를 통해 OS와 CPU 아키텍처를 확인해 해당 플랫폼용 에이전트 바이너리를 보냄 - 바이너리를 stdin에 쓰면 원격의
dd가 디스크에 기록함 - 마지막으로 stdout에서 한 줄을 더 읽어 원격에서 계산된 shasum을 확인하고, 전송 성공 여부를 검증함
- 이 과정은 표준화된 coreutils 프로그램에만 의존함
- 이후 에이전트 실행을 다시 시도하면 성공해야 하며, 에이전트는 디스크가 가득 차지 않도록 오래된 버전을 정리함
에이전트 방식의 효과와 비용
- 원격 호스트에서 에이전트를 실행하고 통신하는 방법을 얻음
- 원격 호스트에 coreutils 외의 요구사항 없이 자동 설치가 가능함
- 양쪽이 같은 버전을 실행하므로 프로토콜 호환성이 구조적으로 보장됨
- 사용자 제어 입력은 SSH 기반 소켓을 통해서만 전달되고 SSH나 shell 명령에 들어가지 않아 escaping 문제와 길이 제한을 피함
- 일반적인 경우에는 SSH handshake 한 번만 필요해 지연 시간이 작음
- 새 머신에 배포하거나 도구 업데이트 뒤처럼 흔하지 않은 경우에는 추가 연결 2번과 1회성 1.6MB 전송이 필요함
ControlMaster를 쓰면 이후 연결 오버헤드 대부분을 건너뛸 수 있어 전체 비용은 몇 초 수준임- 이 경우에는 1초 미만 배포는 아니지만 Ansible보다는 낫다고 봄
- 설정을 배포하고 조금 수정한 뒤 다시 배포하는 흐름에서는 SSH가 기본 연결을 유지할 수 있어 배포가 즉각적으로 느껴짐
사용 결과와 공개
- Deptool은 지난 한 달 동안 개인 인프라 관리에 사용됨
- 연결하기 전에 정확한 계획을 즉시 볼 수 있고 자동 롤백이 있다는 점이 좋지만, 가장 큰 변화는 1초 미만 배포임
- 올바른 배포 방식이 몇 분 걸리면 피드백 루프를 줄이기 위해 서버에서 직접 파일을 편집하고 싶어지지만, Deptool에서는 로컬에서 수정하고 배포하는 편이 SSH로 서버에 접속해 편집기를 여는 것보다 빠름
- 마찰이 가장 적은 방식이 올바른 방식이 되며, 적용된 모든 수정은 Git 히스토리에 기록됨
- 무언가를 깨뜨려도 깨졌다는 사실을 인식하기 전에 Deptool이 롤백함
- Deptool은 개인 문제를 정확히 해결하기 위해 만들어졌고, 모든 사람의 모든 배포 문제를 풀려 하지 않는 점이 해당 사용 사례에서 빛나게 하는 요소임
- 특히 이미지 기반 운영체제에서 유용할 수 있으며, Codeberg와 GitHub에 공개되어 있고 자세한 manual도 제공됨
Lobste.rs 의견들
-
이 프로젝트에 LLM 생성 텍스트를 넣지 않았다고 공개한 점이 정말 반가움: not putting LLM-generated text anywhere near this
도구 자체도 잘 다듬어지고 설계가 좋아 보이지만, 나는 당분간 NixOS를 계속 쓸 듯함 -
꼭 써볼 생각임. systemd 기반 서비스를 배포하려고 직접 만든 시스템의 더 다듬어진 버전처럼 보임
튜토리얼을 보니 좋아 보이는데, 로컬 상태는 어떻게 다루는 게 좋을지 궁금함. 예를 들어 앱의 sqlite 데이터베이스는 어디에 저장해야 하는지 문서에서 찾지 못했음
또 앱 바이너리를 서버로 전송해서 systemd 유닛에서 쓰게 하는 방법이 있는지도 궁금함. 없다면 바이너리 배포는 어떻게 처리하는지 알고 싶음- 서버 쪽이라면 보통 저장하던 곳에 두면 되고, 표준 위치는
/var/lib/<yourapp>임
애플리케이션을 systemd 유닛으로 실행한다면StateDirectory=를 써서 systemd가 올바른 사용자 소유로 디렉터리를 만들게 할 수 있음 - Deptool만으로는 바이너리 전송을 하지 않음
내가 운영하는 애플리케이션은 이 Nix 기반 스크립트로 작은 EROFS 이미지로 빌드하고, 그 스크립트가 이미지를 서버에 푸시하는 기능도 포함. 예전에는 별도 단계였지만 지금은 빌드와 푸시를 한 단계로 묶었고, 고유 디렉터리에 들어가서 여러 버전이 공존할 수 있음
빌드 결과에는 파일 경로가 담긴 JSON도 포함되며, 이를 클러스터 설정으로 가져와 systemd 유닛으로 렌더링한 뒤 Deptool로 배포함. 즉 한 도구는 이미지 배포를 맡고, Deptool은 활성화를 맡는 구조임
컨테이너를 쓴다면 보통 레지스트리에 푸시하고, 서버에는 무엇을 가져올지 지정하는 설정 파일만 있으므로 그 부분은 Deptool만으로 관리 가능함
- 서버 쪽이라면 보통 저장하던 곳에 두면 되고, 표준 위치는
-
다른 접근으로는 bootable containers를 쓰는 방법도 꽤 괜찮음
아직 아쉬운 건 적절한 호스트에서 실제로bootc update --apply를 실행해주는 무언가가 없다는 점임. 자동 업데이트 메커니즘은 있지만 조율되지 않기 때문에 클러스터에서는 원하지 않는 방식임
지금은 손으로 하고 있지만, 결국 실행해야 하는 건 bootc 명령 하나라서 나중에 스크립트로 만들기는 쉬워 보임 -
새 배포 도구가 나올 때마다 조금 회의적으로 보게 되지만, 이건 설계가 좋고 잘 다듬어진 것 같음
ssh명령을 직접 쓰는 것도 올바른 선택으로 보임. 사용자가 가진 이ssh가 실제로 동작한다는 걸 알고 있고, 아주 특수한 설정이나 패치된 ssh 바이너리를 쓰고 있을 수도 있음
외부 라이브러리로 ssh를 직접 구현하려는 도구는 일부 사용자에게 방해가 될 가능성이 큼 -
EROFS를 어떻게, 왜 쓰는지 더 자세히 알고 싶음
- Nginx와 몇몇 애플리케이션을 Flatcar에 배포하는 데 쓰고 있으며, 사람들이 이 용도로 OCI 이미지를 쓰는 방식과 거의 비슷함
Flatcar에는 패키지 관리자가 없어서 소프트웨어와 의존성을 어떻게든 직접 올려야 하고, 자체 완결적인 파일시스템 이미지가 그 방법 중 하나임
OCI 이미지는 Podman이나 Docker 같은 별도 도구가 tar를 어딘가에 풀고 오버레이 마운트 스택을 구성해야 하지만, 이미 파일시스템 이미지라면RootImage=로 systemd 유닛에서 바로 실행할 수 있음
Nix로 이미지를 빌드해서 정말 최소 구성만 들어감. Nginx 바이너리, LibreSSL, libc, 몇몇 공유 라이브러리만 있고 Bash도 없음
이건 심층 방어의 일부임. Nginx에 원격 코드 실행 취약점이 있어도 공격자가 다음 단계 익스플로잇을 만들 재료가 거의 없는 파일시스템 네임스페이스 안에서 실행되고, 전체 파일시스템은 읽기 전용임. 단순히 읽기 전용으로 마운트했기 때문이 아니라 EROFS에는 쓰기 자체가 없음
예전에는 Squashfs를 썼고 잘 동작했지만, 그 파일시스템은 라이브 CD 시대를 기준으로 설계됐음. EROFS는 오늘날 시스템에 더 맞는 절충을 택하지만, 솔직히 내 용도에서는 측정 가능한 차이는 없을 듯함
이미지가 더 작긴 한데 그건 압축 설정을 다르게 쓰기 때문임. 이론적으로는 EROFS가 버전이 다른 이미지 사이에서 데이터를 재사용하려는 경우 내용 정의 청킹에 더 적합하지만, 아직 이미지 전송에 실제로 쓰고 있지는 않음
- Nginx와 몇몇 애플리케이션을 Flatcar에 배포하는 데 쓰고 있으며, 사람들이 이 용도로 OCI 이미지를 쓰는 방식과 거의 비슷함
-
마침 친구와 단순한 배포 전략을 논의하던 중에 이 글이 올라왔고, 우리가 도달하던 결론과 꽤 가까움
다만 이 구성에서 비밀값 관리는 어떻게 하는지 궁금함 -
“Prompting the deployment tool I wish I had”라고 했는데,
https://codeberg.org/ruuda/deptool/…
어떤 의미에서는 부동소수점들이 Rust를 쓰도록 설득했다는 점이 대단하긴 함- LLM으로 코드를 생성한다면, 여러 면에서 오히려 Rust를 선호함
좋게 말하면 Rust는 “규율 있는” 언어이고, 강한 관례와 도구 체계가 있음. 둘 다 LLM에 도움이 됨
이상하게도 LLM은 적어도 약간 유도해주면 어떤 언어들보다 Rust에서 더 짧은 프로그램을 생성하는 경향이 있음. 어차피 모든 코드를 읽고 손볼 생각이라서, 나에게는 짧은 쪽이 더 좋음
- LLM으로 코드를 생성한다면, 여러 면에서 오히려 Rust를 선호함
-
이걸로 비밀값은 어떻게 처리하는지 궁금함. 선호하는 작업 흐름이 따로 있는지, EROFS 이미지에 넣는지 아니면 systemd로 주입하는지 알고 싶음
- 지금 비밀값은 TLS 인증서뿐인데, 한 서버에만 있으면 되고 Lego가 거기에 직접 배치함
해당 디렉터리는 Lego 유닛에서는 읽기-쓰기로 마운트하고, Nginx 유닛에서는 읽기 전용으로 마운트함
- 지금 비밀값은 TLS 인증서뿐인데, 한 서버에만 있으면 되고 Lego가 거기에 직접 배치함