집에 있던 장비들을 모아 홈랩을 구축했다. 구성은 Proxmox VE를 올린 G3 미니PC(N100, RAM 16GB, NVMe 256GB), 중앙 저장소 역할의 시놀리지 NAS, 경량 앱을 담당하는 라즈베리파이 5, 그리고 이들을 묶는 아이피타임 공유기와 TP-Link 8포트 기가비트 스위치다. 현재는 이 위에서 컨테이너 십수 개가 돌아가고, 외부에서 도메인으로 접속하며, 매일 새벽 자동 백업이 동작한다.
이 글은 그 첫 번째 절반으로, 인프라의 뼈대에 해당하는 부분을 다룬다. 구체적으로는 다음 네 가지다.
- 도커를 LXC에 올렸다가 VM으로 재구축한 판단 근거
- 시놀리지 NFS 설정에서 밟기 쉬운 함정들
- NAS 폴더 구조를 장비 기준이 아닌 용도 기준으로 설계한 이유
- rsync
--delete미러링이 만들어낸 백업 설계 결함과 그 수정
도커는 왜 LXC가 아니라 VM인가
Proxmox에서 도커를 운용할 때 첫 번째 선택지는 LXC 컨테이너와 VM 중 무엇 위에 올릴 것인가다. LXC는 호스트 커널을 공유하는 경량 가상화라서 부팅이 빠르고 메모리 오버헤드가 작다는 장점이 있고, 이런 이유로 처음에는 LXC 안에 도커와 Dockge를 설치해 운용을 시작했다. 앱을 띄우고 관리하는 데에는 아무 문제가 없었다.
문제는 NAS를 NFS로 연결하는 단계에서 드러났다.
$ sudo mount -t nfs4 192.168.0.10:/volume1/homelab/apps /mnt/nas/apps
mount.nfs4: Operation not permitted
처음에는 시놀리지 쪽 설정을 의심해서 NFS 프로토콜 버전과 권한 규칙을 여러 차례 손봤지만 결과는 같았다. 원인의 단서는 오히려 진단 과정에서 나왔는데, 로그를 확인하려고 실행한 dmesg가 root 권한으로도 거부되었다.
$ sudo dmesg | tail -5
dmesg: read kernel buffer failed: Operation not permitted
root가 커널 버퍼를 읽지 못한다는 것은 일반적인 VM에서는 나올 수 없는 증상이다. systemd-detect-virt로 확인해 보니 실행 환경은 예상대로 lxc였다. LXC는 호스트와 커널을 공유하는 구조이기 때문에, 보안상 unprivileged 컨테이너에서는 NFS 마운트를 비롯한 커널 레벨 작업이 기본적으로 차단된다. 설정이 틀린 것이 아니라 애초에 허용되지 않는 환경에서 시도하고 있었던 것이다.
우회 방법이 없는 것은 아니다. Proxmox 호스트가 NFS를 마운트한 뒤 pct set ... -mp0으로 컨테이너에 바인드해 주는 방식이 대표적이고, privileged 컨테이너로 전환해 권한을 여는 방법도 있다. 그러나 LXC 안에 도커를 넣는 구조는 결국 컨테이너 안에 컨테이너를 두는 것이고, 이번에는 NFS였지만 다음에는 VPN 모듈이나 또 다른 커널 기능에서 같은 종류의 벽을 만나게 된다. 그때마다 문제의 원인이 내 설정인지 LXC의 제약인지 구분하는 작업부터 시작해야 한다면, 문제 해결 비용이 구조적으로 한 층 더 붙는 셈이다. Proxmox 공식 문서 역시 도커 워크로드에는 VM을 권장한다.
결국 LXC를 폐기하고 Ubuntu 24.04 VM을 새로 구성했다. 이후 NFS는 아무런 우회 없이 정상 마운트되었고, 이 글에 등장하는 나머지 문제들 중 LXC에서 기인한 것은 하나도 없다.
VM 생성 시 확인할 만한 설정 몇 가지를 기록해 둔다. Qemu 에이전트를 활성화하면 Proxmox 웹 UI에서 VM의 IP가 표시되고 종료와 백업이 안정적으로 동작한다. “부팅 시 시작” 옵션은 호스트 재부팅 후 VM이 자동으로 올라오게 하는 설정인데, 이것을 빠뜨리면 정전이나 유지보수 후 VM이 꺼진 채로 방치되는 상황이 생긴다. 디스크는 대용량 데이터를 NAS로 보내는 전제라면 64GB 정도로 충분하며, Discard 옵션을 켜서 삭제된 공간이 회수되도록 한다. 한 가지 주의할 점은 우분투 설치 과정의 스토리지 구성 단계에서 기본값이 ubuntu-lv 논리 볼륨을 디스크의 절반 크기로만 잡는다는 것이다. 이 단계에서 크기를 최대로 조정하지 않으면 할당한 64GB 중 31GB만 사용하는 상태로 설치가 끝난다.
시놀리지 NFS 설정의 함정들
VM에서 NFS를 연결하며 확인한, 시놀리지 특유의 설정 지점들이다.
최대 프로토콜 버전. 시놀리지는 NFS 서비스를 활성화해도 기본 최대 프로토콜이 NFSv3이다. 이 상태에서 mount -t nfs4를 실행하면 위와 동일한 Operation not permitted가 반환되기 때문에, LXC 문제와 증상이 겹쳐 원인 파악을 어렵게 만든다. 제어판 > 파일 서비스 > NFS에서 최대 프로토콜을 NFSv4.1로 변경해야 하며, 변경 후에도 반영되지 않는 경우가 있어 NFS 서비스를 한 번 껐다 켜는 것이 확실하다.
Squash 설정. 공유 폴더의 NFS 권한 규칙에서 Squash를 “매핑 없음”으로 지정해야 한다. root squash가 적용된 상태에서는 도커 컨테이너들이 마운트 지점에 쓰기를 시도할 때 권한 문제가 연쇄적으로 발생한다.
하위 폴더 액세스. 같은 규칙 화면의 “마운팅된 하위 폴더 액세스 허용” 옵션을 켜지 않으면 공유 폴더 루트는 마운트되지만 하위 폴더의 직접 마운트가 실패한다. 별도의 에러 안내가 친절하지 않아서, 폴더 단위로 나눠 마운트하는 구성에서는 이 옵션 누락이 원인 불명의 실패처럼 보이기 쉽다.
이 세 가지를 통과한 뒤에도 권한 문제가 한 번 더 남았다. 마운트는 성공했는데 라즈베리파이의 일반 사용자로 디렉터리를 읽으면 Permission denied가 발생하고 root로는 정상 동작하는 상태였다. NFS의 “매핑 없음” 모드는 사용자를 이름이 아니라 UID 값으로 판정하는데, 장비마다 사용자 UID가 다르면 이런 불일치가 생긴다. 해당 폴더는 여러 장비의 여러 계정과 UID가 제각각인 컨테이너들이 함께 사용하는 내부망 전용 공간이므로, 개별 UID를 맞추는 대신 chmod -R 777로 정리했다. 보안 요구사항이 다른 환경이라면 UID 통일이나 그룹 기반 권한이 정석이겠지만, 단일 사용자 홈랩에서는 이쪽이 관리 비용 대비 합리적인 타협이다.
fstab 등록은 다음 형태를 사용했다.
192.168.0.10:/volume1/homelab/apps /mnt/nas/apps nfs4 nofail,noatime,_netdev,x-systemd.automount 0 0
핵심 옵션은 두 가지다. nofail은 NAS가 꺼져 있어도 VM 부팅이 마운트 대기에서 멈추지 않게 하고, x-systemd.automount는 실제 접근 시점에 마운트를 수행해 부팅 순서 의존성을 제거한다. 이 두 옵션이 없으면 NAS와 VM의 기동 순서를 사람이 관리해야 하는 상황이 된다.
폴더 구조: 장비 기준이 아니라 용도 기준
NAS 폴더 구조를 설계할 때 직관적으로 떠오르는 방식은 장비 기준이다. g3-data, pi-data처럼 장비마다 폴더를 만드는 것인데, 검토 결과 이 방식에는 두 가지 구조적 문제가 있다.
첫째, 장비는 교체된다. G3를 다른 미니PC로 바꾸는 순간 g3-data라는 이름은 실제와 어긋나기 시작하고, 이름을 바로잡으려면 그 폴더를 참조하는 모든 마운트 설정을 함께 수정해야 한다. 둘째, 앱은 장비 사이를 이동한다. 홈랩 운영에서는 앱을 라즈베리파이에서 VM으로 옮기는 식의 재배치가 빈번하게 일어나는데, 장비 기준 구조에서는 그때마다 데이터도 폴더 간에 이사해야 한다. 용도 기준 구조라면 새 장비에서 같은 폴더를 마운트하는 것으로 끝난다. 데이터의 위치를 결정하는 것은 그 데이터를 소유한 장비가 아니라 데이터의 역할이어야 한다는 것이 결론이었다.
최종 구조는 다음과 같다.
/volume1/homelab/
├── apps/ # 앱별 데이터 (업로드, 대용량 파일). 모든 장비가 공유
├── media/ # 미디어 원본
└── backup/ # 백업 (여기만 예외적으로 장비 이름을 사용 — 다음 절에서 설명)
apps를 모든 장비가 공유하면 쓰기 충돌을 우려할 수 있으나, 같은 이름의 앱을 두 장비에서 동시에 실행하지 않는다는 운영 규칙 하나로 방지된다. 충돌은 자동으로 발생하는 것이 아니라 동일 폴더를 쓰는 앱을 사람이 중복 배치할 때만 생기므로, 구조가 아니라 규칙으로 다루는 것이 적절하다.
데이터 배치 원칙
폴더 구조와 함께 데이터 배치 원칙을 정했다. 이후 모든 앱은 이 기준으로 배치했다.
| 데이터 | 위치 | 근거 |
|---|---|---|
compose 파일 (/opt/stacks) |
각 장비 로컬 | NAS 장애 시에도 컨테이너 관리 기능은 동작해야 함 |
| DB (Postgres, SQLite 등) | 로컬 SSD | NFS의 파일 잠금 시맨틱이 DB 요구사항과 불일치. SQLite는 corruption 위험 |
| 업로드·미디어 등 대용량 | NAS | 용량, RAID 보호, 장비 간 공유 |
| 백업 산출물 | NAS | 저장소 본연의 역할 |
이 중 “DB를 NFS에 올리지 않는다”는 항목은 선호의 문제가 아니다. NFS의 잠금 처리와 fsync 동작은 DB 엔진이 가정하는 로컬 파일시스템의 그것과 다르며, 특히 SQLite는 NFS 위에서 파일 손상이 발생하는 사례가 잘 알려져 있다. compose 작성 시에는 다음 패턴이 반복된다.
volumes:
- ./data:/app/db # DB, 설정 → 로컬
- /mnt/nas/apps/myapp:/app/uploads # 대용량 → NAS
rsync –delete가 만든 백업 설계 결함
compose 파일은 각 장비의 로컬에 있으므로 장비 장애 시 함께 소실된다. 이를 대비해 매일 새벽 NAS로 동기화하는 크론을 VM과 라즈베리파이 양쪽에 등록했다.
0 3 * * * root rsync -a --delete /opt/stacks/ /mnt/nas/backup/stacks/
두 장비에 같은 명령을 걸어둔 이 구성에는 결함이 있고, 다행히 실제 데이터 손실이 나기 전 설계 검토 단계에서 발견했다. --delete는 원본에 없는 파일을 목적지에서 삭제하는, 즉 목적지를 원본의 거울로 만드는 옵션이다. 문제는 서로 다른 원본을 가진 두 장비가 동일한 목적지를 거울로 사용한다는 점이다.
03:00 VM의 rsync: 자신의 스택 복사 후, 원본에 없는 파이의 스택을 목적지에서 삭제
03:00 파이의 rsync: 자신의 스택 복사 후, 원본에 없는 VM의 스택을 목적지에서 삭제
이 과정에서 에러는 한 줄도 발생하지 않는다. rsync는 지정된 동작을 정확히 수행할 뿐이다. 실행 순서에 따라 마지막에 동작한 쪽의 데이터만 남고, 다음 날 새벽 그 결과가 다시 뒤집힌다. 백업 폴더 자체는 항상 존재하고 매일 갱신되므로 모니터링만으로는 이상을 감지하기 어렵다. 백업이 실패하는 것보다 성공한 것처럼 보이면서 불완전한 쪽이 훨씬 위험한데, 이 구성이 정확히 그 경우였다.
수정은 목적지를 장비별로 분리하는 것이다.
backup/
├── stacks-dockge-vm/
└── stacks-rpi-01/
앞 절에서 폴더 구조를 용도 기준으로 설계한다고 해놓고 백업에만 장비 이름을 쓰는 것이 모순처럼 보일 수 있지만, 판단 기준을 동일하게 적용한 결과다. 백업 설계에서 던져야 할 질문은 “무엇을 저장하는가”가 아니라 “무엇 단위로 복원하는가”이며, compose 백업의 복원 단위는 장비다. 백업의 본질이 “어느 원본의 사본인가”인 이상, 이 영역에서는 장비가 곧 용도가 된다.
여기에 시놀리지 스냅샷을 한 층 더 추가했다. 매일 05:00에 스냅샷을 생성하고 7일간 보존하는 구성으로, 03:00 백업이 완료된 시점의 상태를 매일 기록하는 순서다. --delete 미러링은 원본의 실수까지 충실히 복제한다는 태생적 약점이 있는데 — 원본에서 스택을 잘못 삭제하면 다음 백업에서 백업본도 함께 사라진다 — 스냅샷의 버전 이력이 이 지점을 보완한다. 미러는 최신 상태를, 스냅샷은 과거로의 복귀를 담당하는 이중 구조다.
Proxmox의 VM 전체 백업(vzdump)은 이 시점에는 적용하지 않기로 결정했다. compose 파일과 앱 데이터가 모두 NAS에 보존되므로, VM 자체를 잃더라도 OS와 도커 재설치 후 복원하는 경로가 확보되어 있다는 판단이었다. 이 결정은 이후 별도의 백업 디스크를 도입하면서 재검토하게 되는데, 그 내용은 이 시리즈의 범위를 넘어가므로 생략한다.
정리
여기까지 구성된 뼈대는 다음과 같다.
G3 (Proxmox VE)
└── VM: dockge-vm — Docker + Dockge
라즈베리파이 5 — Dockge Agent
시놀리지 — apps / media / backup (NFSv4.1)
백업 — stacks 매일 03:00 장비별 미러 + 스냅샷 05:00 7일 보존
이번 편의 결론을 정리하면 다음과 같다.
- 컨테이너 안에 컨테이너를 두는 구조는 문제 발생 시 원인 규명에 앞서 문제가 속한 계층의 판별부터 요구한다. 그 비용이 반복된다면 계층을 줄이는 것이 맞다.
- 폴더 이름에 장비를 넣으면 장비 교체와 앱 재배치 때마다 구조가 흔들린다. 데이터의 위치는 장비가 아니라 역할이 결정해야 한다.
--delete미러링에서 서로 다른 원본이 하나의 목적지를 공유하면, 에러 없이 서로의 백업을 삭제하는 구성이 된다.- 백업은 저장 단위가 아니라 복원 단위로 설계한다.
다음 편에서는 이 기반 위에 서비스를 올리고 외부에 공개하는 과정을 다룬다. Cloudflare Tunnel과 Caddy로 포트포워딩 없이 도메인 접속을 구성하는 방법, 그리고 각각 원인이 달랐던 세 번의 502 장애를 추적한 기록이다.