
AI와 인간 모두 접근 가능한 NAS형 S3를 Sakura Rental Server에 연결한 이야기 #1
요약
Synology NAS에 Garage 오브젝트 스토리지를 구축하고 Cloudflare Tunnel과 Access를 활용해 보안을 강화하는 방법을 다룹니다. 앱 자체의 인증 기능에 의존하지 않고 Zero Trust를 통해 외부 인증을 부여하는 설계 방식을 제안합니다.
핵심 포인트
- Cloudflare Tunnel을 사용하여 포트 개방 없이 외부 서비스 공개
- Cloudflare Access를 활용한 관리 UI의 이중 보안 체계 구축
- MinIO의 정책 변화에 대응하는 가벼운 Garage 스토리지 활용
- 앱의 인증 사양 변화에 영향받지 않는 Zero Trust 설계 원칙
Synology DS918+에 S3 호환 오브젝트 스토리지(Object Storage)인 Garage를 Docker Compose로 구축하고, **Cloudflare Tunnel(cloudflared)**로 외부 공개를 하며, Cloudflare Access로 액세스 제어를 수행한 구성을 정리합니다. 복사하여 붙여넣는 것만으로 재현할 수 있는 것을 목표로 했습니다.
이 기사의 핵심은 마지막의 "방어 방법"입니다.
-
자택 NAS의 포트를 라우터에 전혀 개방하지 않음 (Tunnel의 outbound 연결만으로 공개)
-
S3 API와 관리 UI를 동일한 터널에서 서로 다른 호스트 이름으로 분리하여 제공 - 그리고 양쪽에서 Cloudflare Access의 역할을 의도적으로 다르게 설정:
- S3 API는 Garage 자체가 S3 서명(SigV4) 인증을 가짐 → Access는 허가된 IP를 **바이패스(Bypass)**시키고, 인증은 Garage에 맡김
- **관리 UI(garage-webui)**는 앱 자체에 인증 기능이 없음 → Access가 인증의 본체가 되어, **허가된 IP이면서 동시에 SSO 로그인(Allow)**을 요구하는 이중 보안을 적용
특히 관리 UI와 같이 인증 메커니즘을 갖추지 않은 셀프 호스트(Self-hosted) 앱을, 소스 코드를 수정하지 않고 Zero Trust를 통해 외부 인증을 부여하는 것은 Cloudflare Access의 가장 교과서적인 사용법입니다. 반면 S3 API 측은 Garage 스스로 서명 인증을 가지고 있으므로, Access는 "도달 원천 IP를 제한하는 문지기" 역할에 집중합니다(상세 내용은 후술).
스테디셀러인 MinIO가 아닌 Garage를 선택한 데에는 가벼움 이상의 이유가 있습니다.
2025년 5월 릴리스(2025-05-24 계열) 이후, MinIO는 커뮤니티 버전의 Web Console에서 관리 기능을 정리했습니다. 계정·정책 관리, 설정, 버킷 관리 등이 브라우저 UI에서 제외되었으며, 콘솔은 실질적으로 "오브젝트 브라우저"에 가까운 형태가 되었습니다. 이러한 조작은 mc(커맨드 라인 클라이언트)를 통해 수행하는 흐름으로 바뀌었습니다. 이와 함께, 콘솔의 외부 IDP 로그인(LDAP / OIDC)은 커뮤니티 버전에서 제외되어 유상 서비스인 AIStor 측의 기능으로 정리되었습니다(STS API 등 프로그램으로 구현할 여지는 남아 있는 것으로 알려져 있습니다). GUI로 관리하고 싶은 경우의 선택지는 AIStor로 이행하거나, 관리 UI가 남아 있는 마지막 버전(2025-04-22 계열)에 고정하여 업데이트를 중단하거나, OpenMaxIO와 같은 커뮤니티 포크(Fork) 또는 서드파티 UI에 의존하는 것 중 하나가 되는 상황입니다.
출처: MinIO 공식 리포지토리의 논의(discussion #21326, #21316) 및 각종 보도. 버전이나 동작은 변경될 수 있으므로 채택 전에 최신 상황을 확인하시기 바랍니다.
이러한 "관리 기능이나 인증이 제품 제공 측의 사정에 따라 넣고 빼지는" 흐름은 본 기사의 설계 방향과 맞지 않습니다. 후술하겠지만, 본 구성은 앱 자체의 인증 기능에 의존하지 않고, 인증을 Cloudflare Access(Zero Trust) 측에서 제어한다는 방침을 가지고 있기 때문입니다. 앱의 UI나 인증 사양이 바뀌더라도 전단의 방어 체계는 흔들리지 않습니다.
그럼에도 Garage의 장점은 다음과 같습니다:
- Rust 제작, 단일 바이너리로 가벼워 NAS 규모의 셀프 호스트에 적합
- 설정이 TOML 파일 하나로 완결
- 분산 지향적이지만 단일 노드에서도 안정적으로 동작
- 관리는 CLI가 기본(GUI는 서드파티인 garage-webui를 사용) — 원래부터 "UI에 의존하지 않는" 설계이므로, UI 사양 변경에 휘둘리지 않음
┌─────────────────────────── Cloudflare ───────────────────────────┐
[클라이언트] │ │
│ https://s3.example.com │ s3.example.com ── Access: 허가 IP를 Bypass ──┐ │
...
포인트는 두 가지입니다.
-
cloudflared는 1개의 컨테이너. 하나의 터널(Tunnel)에 2개의 Public Hostname(S3용 / 관리용)을 생성하여 분기합니다.
-
방어 단계:
- Cloudflare Access (S3 = 허가된 IP를 Bypass 하는 문지기 / 관리 = 허가된 IP + SSO 인증 본체)
- Garage의 S3 서명 (private bucket, 익명은 403) ← S3 API를 실제로 보호하는 주체
- (응용) 자체 Portal API가 Bearer 인증 이후에 presigned URL을 발행 → 클라이언트에 생키(raw key)를 전달하지 않음
-
DSM 7.x, Container Manager 설치 완료
-
SSH 활성화 완료
-
자체 도메인을 Cloudflare로 관리 중 (proxied)
-
Cloudflare Zero Trust (무료 플랜으로 가능)
-
고정 글로벌 IP (허가 IP 방식을 영구적으로 운영하기 위함. 동적 IP일 경우 후술할 문제 발생)
-
외부 Docker 네트워크
qi-net생성 완료
sudo docker network create qi-net
sudo mkdir -p /volume1/portal/garage/{meta,data,conf}
sudo mkdir -p /volume1/portal/garage-container
sudo mkdir -p /volume1/portal/cloudflared-container
RPC 시크릿(Secret)과 관리 API 토큰을 생성합니다.
openssl rand -hex 32 # rpc_secret 용
openssl rand -hex 32 # admin_token 용
/volume1/portal/garage/conf/garage.toml:
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "lmdb"
...
[admin]의 admin_token은 garage-webui가 관리 API(3903)에 접속하는 데 사용합니다.
이 점은 Garage 공식 측에서도 "single-node는 중복성(redundancy)이 없어 production 환경에는 권장하지 않는다"라고 주의를 주는 부분입니다. NAS의 RAID / 스냅샷 / Hyper Backup / 별도 거점 백업과는 별개의 문제입니다. 본 기사는 "외부에서 안전하게 S3 호환 API와 관리 UI에 도달하는 구성"을 다루는 것이지, 데이터 보존 설계 그 자체를 다루는 것이 아님을 이해해 주시기 바랍니다. replication_factor = 1은 단일 노드 구성이므로, Garage 자체에 의한 중복화는 없습니다.
/volume1/portal/garage-container/docker-compose.yml:
version: "3.7"
services:
garage:
...
LAN 측 공개에 주의하십시오. ports:로 publish한 포트는 인터넷에는 노출되지 않더라도 Synology의 LAN 측에서는 보입니다. RPC(3901) / S3 web(3902) / admin API(3903)는 expose (Docker 네트워크 내부에서만 사용)로 제한하고, LAN에 publish하지 않는 것이 안전합니다. webui는 동일한 qi-net 내에 있으므로 http://garage:3903으로 admin API에 도달할 수 있으며, 호스트 측에 3903 포트를 노출할 필요가 없습니다.
더욱 견고하게 구성하려면, cloudflared도 qi-net에 포함시켜 Public Hostname의 Service를 http://garage:3900 / http://garage-webui:3909로 향하게 하면, S3 API의 3900 포트조차 host publish 없이 사용할 수 있습니다 (이 경우 §6의 Service를 LAN IP에서 컨테이너 이름으로 변경).
주의할 점: API_ADMIN_KEY는 garage.toml의 [admin] admin_token과 완전히 일치시켜야 합니다. 일치하지 않으면 webui가 401 에러와 함께 관리 API에 연결되지 않습니다.
실행:
cd /volume1/portal/garage-container
sudo docker compose up -d
sudo docker exec -it garage /garage status
표시된 노드 ID(node ID)에 레이아웃(layout)을 할당합니다.
sudo docker exec -it garage /garage layout assign -z dc1 -c 1G <node_id>
sudo docker exec -it garage /garage layout apply --version 1
CLI의 서브 커맨드(sub-command)나 인자(argument)(특히 layout assign의 옵션)는 Garage의 버전에 따라 달라집니다. 본 기사는 v2.1.0 계열의 예시입니다. 사용 중인 버전의 garage --help / garage layout --help로 확인하시기 바랍니다.
CLI로도 가능하지만, 후술할 garage-webui를 통해 GUI로도 관리할 수 있습니다.
sudo docker exec -it garage /garage key create my-key
sudo docker exec -it garage /garage bucket create my-bucket
sudo docker exec -it garage /garage bucket allow \
...
key create에서 나오는 Key ID / Secret key를 기록해 둡니다 (Secret은 다시 표시할 수 없습니다). 이 키가 S3 액세스 제한의 실체입니다.
/volume1/portal/cloudflared-container/docker-compose.yml:
services:
cloudflare-tunnel:
image: cloudflare/cloudflared:latest
...
.env:
echo "CF_TUNNEL_TOKEN=(대시보드의 토큰)" > .env
chmod 600 .env
토큰 방식에서는 **인그레스(ingress) 루트(어떤 호스트 이름 → 어떤 서비스)**가 대시보드 측에 저장됩니다. compose 파일에는 작성하지 않습니다.
Cloudflare Zero Trust → Networks → Tunnels → 해당 터널 → Public Hostname.
이 터널 하나에 루트를 두 개 등록합니다.
| # | Hostname | Service |
|---|---|---|
| 1 | s3-admin.example.com | http://192.0.2.10:3909 |
| 2 | s3.example.com | http://192.0.2.10:3900 |
Service는 192.0.2.10 (= Synology의 LAN IP)를 직접 지정했습니다. 컨테이너 이름(http://garage:3900)으로 해결해도 동작하지만, 그 경우에는 cloudflared를 동일한 qi-net에 올려야 합니다. 호스트 IP를 직접 지정하면 cloudflared를 별도의 네트워크 상태로 운용할 수 있습니다 (본 구성은 이 방식입니다).
.env를 배치한 후 실행:
cd /volume1/portal/cloudflared-container
sudo docker compose up -d
대시보드에서 터널이 HEALTHY 상태가 되면 OK입니다.
여기서부터가 본론입니다. S3 API와 관리 UI에서 Access의 역할을 바꿉니다.
S3 API 측은 Garage 자신이 S3 서명(SigV4) 인증을 가지고 있으므로, Access를 통해 인증시키지 않습니다. 할 일은 "허가된 IP만 통과시키고, 그 이후는 Garage의 서명에 맡긴다"뿐입니다.
Zero Trust → Access → Applications → Add an application → Self-hosted.
- 대상(Public Hostname):
s3.example.com - 정책(Policy): Action = Bypass, 규칙(Rule) = 출발지 IP(
203.0.113.10/32등 고정 IP)
이 부분이 이번에 가장 고생했던 지점입니다. 정책(Policy)의 Action을 Allow로 설정하면, IP가 일치하더라도 로그인(SSO)이 요구되어 curl이나 presigned URL을 통한 직접 다운로드(Direct DL) 시 302 응답과 함께 로그인 화면으로 리다이렉트됩니다.
- Allow: 조건에 맞는 사용자를 통과시키되 로그인은 필요함
- Bypass: 조건(IP/CIDR)에 맞으면 로그인 없이 그대로 통과
presigned URL을 통한 자동 다운로드(Auto DL)를 허용하고 싶다면, S3 측은 Bypass가 정답입니다. Bypass는 Include 항목에 네트워크 셀렉터(IP/CIDR) 등만 섞을 수 있다는 제약이 있으므로, IP 단독 규칙으로 설정합니다.
이렇게 설정하면 동작은 다음과 같습니다 (실측 결과):
| 액세스 | 결과 |
|---|---|
| 허가된 IP · 인증 없는 일반 경로 | 403 AccessDenied (Garage의 서명 게이트웨이가 작동함. 익명 거부) |
| 허가된 IP · presigned URL | 200 (정상 다운로드) |
| 허가되지 않은 IP | 302 → Cloudflare Access의 SSO 로그인으로 이동 |
"Access의 302가 사라지고 Garage의 403이 반환된다"는 것은 "Access를 통과하여 Garage 본체까지 도달했다"는 신호입니다. 동작 확인의 판정 기준으로 사용할 수 있습니다.
Bypass의 정확한 의미를 파악해 두어야 합니다. Cloudflare 공식 문서에서는 Bypass에 대해 "Access의 강제 적용(enforcement)을 무효화한다", "Access의 보안 제어를 적용하지 않는다", "요청은 로그에 기록되지 않는다"라고 설명합니다. 즉, Bypass로 설정된 S3 측은 Access를 통해 "인증"하고 있는 것이 아닙니다. 허가된 IP를 Access의 인증 흐름에서 제외했을 뿐이며, Access의 인증·감사 로그(Audit Log)·사용자 식별은 작동하지 않습니다.
이 구성에서 S3 API를 보호하는 주체는 어디까지나 Garage의 SigV4 서명과 private bucket입니다. Access의 Bypass는 "도달 원점 IP를 제한하는 외부의 거친 문지기"로서 사용하고 있을 뿐입니다. Cloudflare 공식 측에서도 내부 앱에 영구적으로 직접 액세스를 부여하는 용도로 Bypass를 적극 권장하지 않으므로, "Access로 보호하고 있다"고 과신하지 않는 것이 중요합니다.
로그인 없이 "제어"와 "감사 로그"를 유지하고 싶다면, Bypass가 아닌 Service Auth(서비스 토큰)나 mTLS를 검토하십시오. 서비스 토큰(CF-Access-Client-Id / CF-Access-Client-Secret 헤더)을 사용하면 IP에 의존하지 않으면서도 Access 로그를 남기며 자동화 클라이언트를 통과시킬 수 있습니다. 고정 IP를 사용할 수 없거나 감사 로그가 필요한 영구 운영 환경에서는 이 방식이 더 적절할 수 있습니다.
Garage의 관리 UI(garage-webui)는 설정하지 않은 상태라면 인증 없이 실행될 수 있습니다. 그대로 터널을 통해 노출하면 URL을 아는 누구나 버킷이나 키를 조작할 수 있게 됩니다.
따라서 인증 계층을 Cloudflare Access가 대신 수행하도록 합니다. (garage-webui 자체도 AUTH_USER_PASS로 간이 인증을 추가할 수 있으므로, 심층 방어(Defense in Depth) 관점에서 두 가지를 병용하는 것이 이상적입니다. 후술할 compose 참조)
- 대상:
s3-admin.example.com - 정책: Action = Allow, 규칙 = 송신원 IP(허가 IP) 및 특정 사용자(이메일 주소 등으로 고정) - 인증: ID 프로바이더(SSO)를 활성화하며, 세션 기간은 적절히 설정 (예: 24h)
S3 측과 달리, 여기는 Bypass가 아닌 Allow로 설정합니다. 이유는 명확합니다.
- webui는 키 자체를 발행·열람·삭제할 수 있는 화면이므로 가장 강력하게 보호해야 함
- IP만 사용하는 Bypass 방식이라면, 허가된 IP 대역 내에 있는 누구나 로그인 없이 관리 화면에 접속할 수 있음
- "허가된 IP 및 SSO를 통한 특정 사용자"라는 이중 보안을 갖추어야 비로소 안심할 수 있음
| S3 API (3900) | 관리 UI (3909) |
|---|---|
| 앱 자체의 인증 | 있음 (S3 서명) |
| Cloudflare Access의 역할 | 통과 역할 (허가된 IP를 통과시킴만 함) |
| Action | Bypass |
| 방어의 주체 | Garage의 SigV4 |
"앱이 인증을 갖는지 여부에 따라 Access의 사용법을 바꾼다" — 이것이 본 구성의 설계 판단입니다. 인증이 없는 셀프 호스트(Self-hosted) 앱이라도, 소스에 일절 손을 대지 않고 Access의 정책만으로 안전하게 공개할 수 있습니다.
aws configure set aws_access_key_id <Key ID>
aws configure set aws_secret_access_key <Secret>
aws configure set default.s3.addressing_style path
...
허가된 IP에서 올바른 키로 조작할 수 있고, 키가 없거나 잘못된 키를 사용했을 때 403 에러가 발생하는 것을 확인하면 완성입니다.
여기까지로 "허가된 IP + 서명"을 통한 공개 S3는 완성되었습니다. 여기서 한 걸음 더 나아가, 클라이언트에 access/secret key를 일절 전달하지 않는 운영 방식으로 전환할 수 있습니다.
별도의 호스트(클라우드 측)에 자체 API 서버를 두고,
- 클라이언트는 API에 Bearer TOKEN으로 인증하여 파일 ID를 요청
- API는 Garage의 access/secret key를 서버 측에서만 보유하고, **presigned URL (유효기간 10분)**을 생성하여 반환
- 클라이언트는 해당 presigned URL을 사용하여 Garage로 직접 다운로드 (
s3.example.com)
[클라이언트] ──Bearer──▶ [portal API (별도 호스트)] ──access/secret key 보유──▶ presigned URL 발행
│ │
└────────────── 받은 presigned URL(10분)로 Garage에 직접 DL ─────────────┘
- 생키(Raw key)가 클라이언트에 노출되지 않음 (유출되어도 곤란한 것은 10분간 유효한 서명 URL뿐)
- portal의 입구는 Bearer로 보호되며, Garage 본체는 서명 필수 및 익명 접속 시 403 에러 발생
중요한 함정 — presigned 직접 다운로드는 "클라이언트 자신의 IP"가 Access 조건을 통과해야 합니다.
S3 측의 Access 정책이 "허가된 IP만 Bypass"하도록 설정된 경우, presigned URL을 받아 실제로 액세스하는 클라이언트의 송신원 IP가 허가된 IP에 포함되어 있지 않으면, s3.example.com으로의 직접 액세스는 Cloudflare 측에서 302(SSO)로 차단됩니다.
즉, API 서버의 고정 IP를 허가하는 것만으로는 "클라이언트 직접 다운로드"가 성립하지 않습니다. API 서버가 허가된 IP라는 것은 API 서버 자체가 Garage에 도달할 수 있다(presigned 발행 검증이나 대리 취득이 가능하다)는 의미이지, 별도의 IP를 가진 외부 클라이언트가 직접 다운로드할 수 있다는 의미가 아닙니다. 외부 클라이언트에게도 배포하려면 선택지는 다음 중 하나입니다:
- portal API가 파일을 프록시(proxy) 배포한다 (클라이언트는 Garage에 직접 액세스하지 않고 API를 통해 받음)
- 클라이언트 측을 VPN / 고정 IP / Access 인증 완료 상태로 만들어 Access 조건을 충족시킨다
- S3 엔드포인트를 SigV4로만 공개한다 (Access의 IP 제한을 해제하고 Garage의 서명만으로 보호한다)
본 구성에서는 "허가된 IP 범위 내의 클라이언트(자택·거점·API 서버)"를 전제로 합니다. 불특정 다수의 외부로 배포하려면 프록시 배포를 선택합니다.
주의: presigned URL은 수명이 짧다고는 하나, 그 자체로 bearer credential(가지고 있는 것만으로 사용할 수 있는 키)입니다. 다만 본 구성에서는 Cloudflare Access의 Bypass 조건(허가된 IP)도 동시에 충족해야 하므로, "URL을 가지면 누구나 DL할 수 있다"가 아니라, "허가된 IP·Access 인증 완료 브라우저·proxy API 등 Cloudflare 측을 통과할 수 있는 상대라면 DL할 수 있다"라고 이해해야 합니다. 어찌 되었든 채팅이나 로그에 붙여넣지 마십시오.
- Access의 Allow와 Bypass 혼동: S3의 자동 다운로드(presigned/curl)를 통과시키려면 Bypass를 사용해야 합니다. Allow를 사용하면 IP가 일치하더라도 SSO 로그인을 요구하며 302 리다이렉트가 발생합니다. 이번 작업에서 가장 큰 함정이었습니다.
- 관리 UI를 Bypass로 설정하지 말 것: WebUI는 자체 인증이 없으므로, IP 기반의 Bypass만 설정하는 것은 위험합니다. Allow + SSO + 사용자 고정 방식으로 보안을 강화해야 합니다.
- WebUI가 3903 포트에 연결되지 않는 주요 원인:
API_ADMIN_KEY와admin_token의 불일치. - path-style 강제: 가상 호스트(Virtual Host) 형식은 와일드카드 인증서와 연관되므로, 클라이언트는 path-style을 사용하는 것이 무난합니다 (
default.s3.addressing_style path). - Cloudflare의 업로드 제한: Cloudflare를 경유하는 요청에는 플랜별로 바디(Body) 크기 제한이 있습니다. Free / Pro 플랜은 1회 요청당 약 100MB(Business 200MB, Enterprise 500MB+)가 기준입니다. 이를 초과하는 PUT 요청은 앞단에서 413 오류로 차단되므로, 대용량 파일은 **S3 멀티파트 업로드 (Multipart Upload)**를 사용하여 각 파트(part)를 이 제한 미만으로 나누어야 합니다 (다운로드는 이 제한과 별개의 문제입니다).
- 동적 IP 사용 시 허용 IP 방식의 붕괴: 허용 IP 방식은 고정 IP를 전제로 합니다. 동적 IP를 사용하면 재연결 시 IP가 변경되어 Bypass 규칙과 어긋나게 되고, 다시 302 리다이렉트로 돌아갑니다. 영구적인 운영을 위해서는 고정 IP, 서비스 토큰, 또는 API 프록시 중 하나를 사용해야 합니다.
- RPC(3901)/admin(3903)을 외부에 노출하지 말 것: 터널을 통해 노출할 것은 S3(3900)와 WebUI(3909)뿐입니다. admin/RPC는 내부 네트워크로 한정해야 합니다.
DS918+의 라우터 포트를 전혀 개방하지 않고, 단 하나의 cloudflared 컨테이너 터널을 통해 S3 API와 관리 UI를 서로 다른 호스트 이름으로 분리하여 제공하였으며, Cloudflare Access를 통해 다음과 같은 역할 분담으로 보안을 유지했습니다.
- S3 API는 허용 IP를 Bypass (인증은 Garage의 서명에 맡김)
- 인증이 없는 관리 UI는 Allow + SSO (Access가 인증의 본체 역할)
"애플리케이션이 자체 인증을 가지고 있는지에 따라 Zero Trust의 사용법을 바꾼다"는 설계가 그대로 재사용 가능한 형태로 완성되었습니다. restic / rclone의 백업 대상으로서도 실용적입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기