Mac 두 대를 쓰게 된 후, 개발 환경을 어떻게 동기화하고 관리했는가
몇 년 동안 제 개발 환경은 MacBook Pro 한 대에만 있었기 때문에 관리도 비교적 대충 했습니다. 시스템 계층은 nix-darwin이었고, 모든 것을 /etc/nix-darwin/flake.nix라는 파일 하나에 적다 보니 점점 길어졌습니다. 홈 디렉터리의 여러 설정 파일, 예를 들어 zsh, git, 터미널, 편집기 설정은 예전에는 ~/dot 저장소에 두고, 작은 스크립트로 그것들을 cp 로 저장소에 복사한 뒤 직접 커밋했습니다.
# duplicate configuration file
cp ~/.config/starship.toml ./config # starship
cp -r ~/.config/alacritty ./config # alacritty
cp -r ~/.config/helix ./config # helix
cp ~/.zshrc ./home/.zshrc
컴퓨터가 한 대일 때는 이렇게 대충 써도 괜찮았습니다. 하지만 최근 Mac mini M4를 새로 사면서 문제가 한꺼번에 드러났습니다.
두 컴퓨터는 대부분의 것이 같습니다. 명령줄 도구, shell, 터미널, git 설정은 완전히 동일하게 유지하고 싶었습니다. 하지만 다른 점도 적지 않았습니다. MacBook에는 JetBrains 전체 제품군, 가상 머신, 각종 리버싱 및 하드웨어 도구가 설치되어 있고, Mac mini는 주로 서비스를 계속 띄워 두는 용도라 이런 것들이 필요 없습니다. mise에 설치된 언어도 다르고, Zed 설정도 다르며, 각 머신의 환경 변수도 다릅니다.
거대한 flake.nix 하나로는 두 머신을 담아낼 수 없었고, cp 스크립트는 한 컴퓨터의 설정을 저장소로 한 방향으로 복사할 뿐, 다른 컴퓨터의 존재는 전혀 몰랐습니다. 그래서 시간을 들여 전체 환경을 다시 정리했습니다.
하나의 저장소, 세 계층
먼저 모든 것을 하나의 저장소 ~/env에 모은 뒤, 역할에 따라 몇 개의 계층으로 나눴습니다.
| 계층 | 담당하는 것 | 도구 |
|---|---|---|
| 시스템 | 시스템 설정, 명령줄 도구, Homebrew cask, launchd 서비스, fish 자체 | nix-darwin |
| home | $HOME 아래의 설정 파일 | envctl |
| 런타임 | 프로그래밍 언어와 개발 도구 | mise |
| shell 플러그인 | fish 플러그인 | fisher |
~/env
├── flake.nix
├── nix/
│ ├── hosts/ # 每台机器一个文件
│ └── modules/ # 可以组合的模块
├── home/
│ ├── shared/ # 所有机器共用的配置文件
│ └── hosts/<host>/ # 某台机器独有的配置文件
├── sync/manifest.toml # 哪些文件要同步、归谁
├── cmd/envctl/ # envctl 的入口
└── internal/envctl/ # envctl 的实现
이 저장소는 비공개입니다. 각 머신의 환경 변수처럼 저만의 것이 들어 있기 때문입니다.
nix 설정 분리하기
예전 flake.nix 파일에는 시스템 설정, 명령줄 도구, 수십 개의 cask가 모두 뒤섞여 있었습니다. 이번에는 이를 작은 모듈들로 나누고, 각 모듈이 한 종류의 것만 담당하게 했습니다.
nix/modules/
├── common/
│ ├── base.nix # nixpkgs、flakes、stateVersion
│ ├── cli.nix # 日常命令行工具
│ ├── devtools.nix # 编译器和开发工具
│ └── package-overrides.nix # overlay
├── darwin/
│ ├── defaults.nix # 系统设置、Touch ID sudo
│ ├── fish.nix
│ ├── homebrew.nix # Homebrew 的公共设置
│ ├── launchd.nix
│ └── shell-env.nix
├── apps/ # 按类别分组的 cask
│ ├── editors.nix
│ ├── fonts.nix
│ ├── jetbrains.nix
│ ├── terminals.nix
│ └── ...
├── profiles/ # 某个方向的一整套工具
│ ├── network-tools.nix
│ ├── ai-tools.nix
│ └── media.nix
└── private/local.nix
모듈은 모두 짧습니다. 예를 들어 터미널 모듈에는 cask가 두 개뿐입니다.
{ ... }:
{
homebrew.casks = [
"alacritty-full"
"ghostty"
];
}
nix-darwin의 모듈 시스템은 같은 옵션의 값을 여러 모듈에 걸쳐 병합하므로, homebrew.casks 는 여러 파일에 나눠 적어도 최종적으로 하나의 완전한 목록으로 합쳐집니다. 이 점 덕분에 분리가 아주 자연스러웠습니다.
그다음 각 머신에는 자체 host 파일이 있어, 어떤 모듈을 사용할지 고르고 이 머신에만 필요한 것을 추가합니다. MacBook의 파일은 대략 이렇습니다.
{ ... }:
{
imports = [
../modules/common/base.nix
../modules/common/cli.nix
../modules/common/devtools.nix
../modules/darwin/defaults.nix
../modules/darwin/fish.nix
../modules/darwin/homebrew.nix
../modules/apps/editors.nix
../modules/apps/fonts.nix
../modules/apps/jetbrains.nix
../modules/apps/terminals.nix
../modules/profiles/network-tools.nix
../modules/profiles/ai-tools.nix
../modules/profiles/media.nix
# ...
];
networking.hostName = "brian-mbp-m1max";
system.primaryUser = "brian";
homebrew.casks = [
# Virtualization and remote access
"parallels"
"utm"
"vmware-fusion"
# Reverse engineering, hardware, and inspection
"imhex"
"qflipper"
# ...
];
}
Mac mini에서는 devtools, jetbrains 와 media 모듈이 빠지고, 그 대신 모니터링용 beszel-agent처럼 이 머신에서만 실행되는 서비스가 몇 개 더 들어갑니다.
{ pkgs, ... }:
{
imports = [
../modules/common/base.nix
../modules/common/cli.nix
../modules/darwin/defaults.nix
../modules/darwin/fish.nix
../modules/darwin/homebrew.nix
../modules/apps/terminals.nix
../modules/profiles/network-tools.nix
# ...
];
networking.hostName = "brian-macmini-m4";
system.primaryUser = "brian";
homebrew = {
taps = [ "henrygd/beszel" ];
brews = [
{
name = "beszel-agent";
restart_service = "changed";
start_service = true;
}
];
};
}
마지막으로 flake.nix 에는 몇 줄만 남아, 작은 함수 하나로 각 머신의 설정을 생성합니다.
outputs = inputs@{ self, nix-darwin, nixpkgs, ... }:
let
mkDarwin = host: system:
nix-darwin.lib.darwinSystem {
inherit system;
specialArgs = { inherit inputs self; };
modules = [ ./nix/hosts/${host}.nix ];
};
in
{
darwinConfigurations."brian-mbp-m1max" = mkDarwin "brian-mbp-m1max" "aarch64-darwin";
darwinConfigurations."brian-macmini-m4" = mkDarwin "brian-macmini-m4" "aarch64-darwin";
};
이렇게 해 두면 두 컴퓨터 모두에 도구를 추가하고 싶을 때는 공통 모듈을 고치고, 한 대에만 추가하고 싶을 때는 그 머신의 host 파일을 고치면 됩니다. 나중에 머신이 한 대 더 늘어나도 새 host 파일을 하나 작성해 몇 개의 모듈을 골라 조합하면 됩니다.
home 디렉터리의 설정
시스템 계층은 nix-darwin이 담당한다면, home 디렉터리의 설정 파일은 어떨까요.
처음에는 chezmoi를 사용했고, 그 위에 shell 스크립트를 한 겹 덧씌워 자주 쓰는 작업을 backup-home, apply-home, merge-home 같은 명령으로 감쌌습니다. 하지만 두 달 남짓 써 보니 chezmoi의 동작 방식이 제 습관과 잘 맞지 않았습니다.
chezmoi의 사고방식은 저장소의 소스 파일만이 유일한 진실이니, 소스 파일을 수정한 뒤 apply 로 home 디렉터리에 반영해야 한다는 것입니다. 그런데 제 평소 습관은 정반대였습니다. 대부분은 home 디렉터리의 파일을 직접 고쳤고, 많은 설정은 애초에 제가 수동으로 바꾸는 것이 아니었습니다. 예를 들어 Zed 설정은 Zed 화면에서 몇 번 클릭하면 Zed가 스스로 다시 써 넣습니다. 그래서 거의 매번 반대로 re-add해서 변경 사항을 저장소로 되돌려야 했고, 양쪽 모두 바뀐 경우에는 3-way 병합까지 해야 했습니다.
또 chezmoi는 소스 파일의 이름을 바꿉니다. ~/.config/fzf/fzf-preview.sh 는 저장소에서 dot_config/fzf/executable_fzf-preview.sh라는 이름이 되므로, 저장소를 둘러볼 때마다 머릿속으로 이름을 변환해야 했습니다. 두 머신 사이의 차이는 chezmoi의 경우 템플릿을 사용해 파일 안에 조건 분기를 쓰는 방식이었는데, 제게는 이것도 너무 무거웠습니다.
생각해 보니 제가 필요한 것은 사실 아주 단순했습니다. 예전 그 cp 스크립트와 비슷하되, 어떤 파일이 공용이고 어떤 파일이 특정 머신의 것인지 구분할 수 있어야 했고, 양방향으로 동작해야 했습니다. 그래서 아예 Go로 이 저장소 전용의 작은 도구를 만들었고, 이름은 envctl입니다.
envctl
envctl의 유일한 임무는 매니페스트에 따라 home 디렉터리와 저장소 사이에서 파일을 복사하는 것입니다.
- 방향은 명령이 결정.
home backup은 항상 home 디렉터리에서 저장소로 복사하고,home restore은 항상 저장소에서 home 디렉터리로 복사하며, 프로그램 스스로는 어느 쪽으로 동기화할지 판단하지 않습니다. - 병합하지 않음. 숨겨진 병합 기준도, 로컬 동기화 데이터베이스도 없습니다. 양쪽이 모두 바뀌었다면 먼저
diff로 차이를 확인한 뒤 어느 쪽을 쓸지 정하고, 필요하면 직접 수정한 다음 동기화합니다. - 템플릿 없음. 머신마다 다른 파일은 그냥 각각 따로 저장합니다.
- 수정 전에 먼저 백업. 매번
backup또는restore가 파일을 덮어쓰기 전에, 덮어쓸 대상을 먼저/tmp/envctl/아래에 복사해 두므로, 잘못 고쳐도 언제든 되돌릴 수 있습니다.
매니페스트
어떤 파일을 동기화할지는 모두 sync/manifest.toml 에 적혀 있습니다. 각 규칙에는 id, 경로, 단일 파일인지 전체 디렉터리인지를 나타내는 타입, 그리고 가장 중요한 소속, 즉 shared 또는 host.
version = 1
ignore = [
"**/.DS_Store",
]
[[rules]]
id = "fish"
path = "~/.config/fish"
type = "tree"
ownership = "shared"
ignore = [
"fish_variables",
"completions/**",
"themes/**",
]
[[rules]]
id = "fish-env"
path = "~/.config/fish/conf.d/05-env.fish"
type = "file"
ownership = "host"
[[rules]]
id = "mise-config"
path = "~/.config/mise/config.toml"
type = "file"
ownership = "host"
[[rules]]
id = "zed"
path = "~/.config/zed"
type = "tree"
ownership = "host"
ignore = [
"conversations/**",
"embeddings/**",
"prompts/**",
]
[[rules]]
id = "zed-themes"
path = "~/.config/zed/themes"
type = "tree"
ownership = "shared"
shared 파일은 저장소에 한 사본만 저장하며, home/shared 아래에 두고 모든 머신이 공유합니다. host 파일은 머신마다 각각 한 사본씩 저장하며, home/hosts/<host> 아래에 둡니다. 예를 들어 fish 설정은 전체적으로 공용이지만, 그중 05-env.fish 는 머신마다 다른 환경 변수이므로 따로 빼서 host로 설정합니다. Zed도 마찬가지로 설정은 머신마다 다르지만 테마는 공용입니다.
규칙은 서로 중첩될 수 있으며, 더 구체적인 하위 규칙이 바깥 규칙을 덮어씁니다. ignore 는 플러그인이 생성한 파일이나 애플리케이션 자체의 상태 파일을 제외하는 데 사용합니다. 예를 들어 fisher가 설치한 플러그인 파일, Zed의 대화 기록처럼 동기화할 필요가 없는 것들입니다.
현재 어떤 머신인지는 envctl이 scutil --get LocalHostName 로 자동 판단하며, nix-darwin의 host 이름과 동일합니다.
일상적인 사용
평소 어느 컴퓨터에서 설정을 바꾸면, 먼저 무엇이 바뀌었는지 확인한 다음 저장소로 백업합니다.
go run ./cmd/envctl home diff
go run ./cmd/envctl home backup --all --yes
git commit -am "update fish config"
git push
home diff 는 git diff 처럼 통일된 형식의 차이를 출력합니다. 텍스트 파일은 어느 줄이 바뀌었는지 구체적으로 보여 주고, 바이너리 파일은 크기와 해시 변화를 보여 줍니다. 일부만 보거나 일부만 백업할 수도 있습니다. 예를 들어 --id zed 또는 --path ~/.config/fish.
다른 컴퓨터로 가서 최신 저장소를 당겨온 뒤, 공용 부분을 복구합니다.
git pull
go run ./cmd/envctl home restore --ownership shared --yes
restore 는 반드시 어느 부분을 복구할지 지정해야 하며, 인자 없이 실행하면 바로 오류가 납니다. 저장소의 내용을 home 디렉터리에 통째로 덮어쓰는 작업은 매번 충분히 생각한 뒤에만 하고 싶었기 때문입니다.
시스템 계층도 함께 관리
이런 도구가 생겼으니 시스템 계층의 작업도 함께 넣어, 저장소 전체가 하나의 진입점만 갖도록 했습니다.
go run ./cmd/envctl system dry-run # 只构建,不切换
go run ./cmd/envctl system apply # sudo darwin-rebuild switch --flake ~/env#<host>
go run ./cmd/envctl fisher apply # 按 fish_plugins 安装缺少的插件
go run ./cmd/envctl doctor # 打印系统、shell、Homebrew、mise 的状态
system apply 는 darwin-rebuild switch 가 성공한 뒤 로그인 shell도 함께 확인합니다. nix 설정에서 system.primaryUser를 읽어, fish가 설치되어 있고 /etc/shells 에 있는지 확인한 다음, 이 사용자의 현재 로그인 shell이 무엇인지 봅니다. fish가 아니라면 dscl 로 바꿉니다. 그래서 새 컴퓨터에서 system apply를 실행하고 나면 기본 shell이 이미 fish입니다.
새 컴퓨터
이 모든 단계를 이어 붙인 것이 bootstrap입니다. 완전히 새로운 Mac이라면 먼저 Nix와 Homebrew를 설치한 뒤 저장소를 클론하고, Nix로 Go를 임시로 가져와 실행하기만 하면 됩니다.
git clone <私有仓库> ~/env
cd ~/env
nix shell nixpkgs#go
go run ./cmd/envctl bootstrap
bootstrap 는 다음 일을 차례로 합니다. 먼저 nix-darwin을 적용해 명령줄 도구, cask, 시스템 설정, fish를 모두 설치한 다음, 공용 설정 파일과 이 머신 전용 설정 파일을 복구하고, fish 플러그인을 설치한 뒤 마지막으로 mise install 로 언어 환경을 설치합니다. 남은 수동 작업은 각 앱 계정에 로그인하는 것뿐입니다.
Mac mini도 이렇게 설정했습니다. 실행을 마치고 터미널을 열면 MacBook과 거의 다르지 않으며, 다만 필요 없는 소프트웨어가 빠져 있을 뿐입니다.
구현
envctl은 대략 3천 줄 정도의 Go 코드이고, 그중 상당수는 테스트입니다. 의존성은 TOML을 파싱하는 라이브러리 하나뿐이고, 나머지는 모두 표준 라이브러리입니다.
망가뜨리지 않기 위해 구현은 아주 신중하게 했습니다. 예를 들어 매니페스트는 사용하기 전에 한 번 검사하며, 규칙의 id와 경로는 중복될 수 없고, 경로는 $HOME 밖으로 나갈 수 없으며, home/shared 아래의 파일이 실제로 host에 속할 수 없고, 저장소에 어떤 규칙으로도 관리되지 않는 파일이 있어서도 안 됩니다. 또한 쓰기 전에 대상 파일을 다시 검사해서 계획과 실행 사이에 파일이 바뀌었다면 바로 중단하고 다시 실행하게 합니다. 심볼릭 링크는 절대 따라가지 않고, 링크 자체만 복사하며 링크 대상은 복사하지 않습니다.