Docker Compose 파일 하나로 arr 스택 구축하기
VPS에서 Prowlarr, Sonarr, Radarr, qBittorrent를 한 Compose 파일로 실행합니다. 공유 PUID와 PGID, 하드링크가 유지되는 단일 volume 레이아웃과 40 GB 시즌의 복사 문제를 설명합니다.
구축할 항목
Docker Compose arr 스택은 미디어 라이브러리를 관리하는 4개의 컨테이너로 구성됩니다. Prowlarr는 인덱서 설정을 관리하고, Sonarr는 시리즈를 관리하며, Radarr는 영화를 관리하고, qBittorrent는 다운로드 클라이언트로 사용됩니다. 이들은 Compose 네트워크에서 서비스 이름으로 서로 통신하며, 호스트에서 하나의 폴더 트리를 공유합니다. 설치 과정은 짧습니다. 스택이 수년간 안정적으로 작동할지, 아니면 매주 문제를 일으킬지는 volume 레이아웃에 달려 있습니다. 따라서 이 가이드의 대부분은 volume 레이아웃을 설명합니다.
이 스택이 콘텐츠를 자동으로 찾아 주는 것은 아닙니다. Prowlarr에는 사용자가 추가한 인덱서가 저장되며, 어떤 인덱서를 사용할지는 사용자의 결정이자 법적 책임입니다. 이 가이드에서는 사용자, 경로, 권한, 컨테이너 네트워킹 및 작동 여부를 확인하는 점검 방법을 다룹니다.
Compose 파일을 작성해 본 적이 없다면 먼저 VPS용 Docker Compose 기본 사항을 읽으십시오. 이 글에서는 docker compose version이 이미 서버에서 무언가를 출력한다고 가정합니다.
하드링크가 중단되는 이유와 이것이 핵심인 이유
Sonarr가 다운로드를 완료하면 파일을 라이브러리로 가져옵니다. 다운로드 폴더와 라이브러리 폴더가 같은 파일 시스템에 있으면 가져오기는 하드링크로 처리됩니다. 하드링크는 디스크의 동일한 데이터를 가리키는 두 번째 이름입니다. 추가 공간과 시간이 필요하지 않습니다. 토렌트는 기존 이름으로 계속 시드하고, 미디어 서버는 새 이름으로 파일을 읽습니다.
두 폴더가 서로 다른 파일 시스템에 있으면 커널은 해당 링크를 만들 수 없습니다. Sonarr는 복사 작업으로 대체합니다. 이제 40 GB 시즌 하나에 디스크 공간 80 GB와 여러 분 동안의 입력 및 출력 작업이 필요합니다. 가져오기 로그에는 하드링크에 실패했으며 대신 파일을 복사했다는 내용이 기록됩니다. 디스크 할당량이 고정된 VPS에서는 이런 방식으로 일주일 만에 공간이 부족해집니다.
여기서 문제가 발생합니다. 컨테이너 내부에서 bind mount는 파일 시스템 경계입니다. /mnt/data/torrents를 /downloads으로 마운트하고 /mnt/data/media를 /tv로 마운트하면 두 경로가 하나의 호스트 디스크에 있더라도 Sonarr는 이를 서로 다른 마운트로 인식하고 두 경로 사이의 링크 생성을 거부합니다. 공식 LinuxServer.io 이미지 문서에도 이 내용이 명시되어 있습니다. 별도의 /downloads 및 /tv 경로를 사용하면 하드링크 기능을 사용할 수 없습니다.
해결 방법은 마운트 하나를 사용하는 것입니다. 미디어에 접근하는 모든 컨테이너에 동일한 단일 볼륨인 /mnt/data:/data을 제공하고, 컨테이너가 사용하는 모든 경로를 그 안의 폴더로 지정합니다. 마운트 지점 하나와 파일 시스템 하나를 사용하면 하드링크가 정상적으로 작동합니다.
사용자, 그룹 및 디렉터리 생성
컨테이너는 PUID 및 PGID으로 설정된 숫자 사용자 ID를 사용하여 파일을 기록합니다. sudo 없이 SSH를 통해 해당 파일을 읽고 편집할 수 있도록 자신의 계정을 사용합니다.
id -u
id -g새 Ubuntu VPS에서는 일반적으로 두 명령 모두 1000를 출력합니다. 이제 디렉터리 트리를 생성합니다. 미디어가 저장된 디스크에 배치하고 전체 트리를 해당 디스크 하나에 유지합니다.
sudo mkdir -p /mnt/data/torrents/movies /mnt/data/torrents/tv
sudo mkdir -p /mnt/data/media/Movies /mnt/data/media/Shows
sudo chown -R 1000:1000 /mnt/data
sudo chmod -R 775 /mnt/data계속 진행하기 전에 실제로 하나의 파일 시스템인지 확인합니다.
df --output=source,target /mnt/data/torrents /mnt/data/media두 줄 모두 동일한 원본 장치를 표시해야 합니다. 서로 다른 장치이면 컨테이너 설정에서 무엇을 지정하더라도 하드 링크가 작동하지 않습니다.
라이브러리 디렉터리 이름은 의도적으로 Movies 및 Shows로 지정합니다. 이미 미디어 서버로 Jellyfin 사용을 실행 중이라면 /mnt/data/media를 Jellyfin에 /media으로 마운트합니다. 그러면 해당 라이브러리가 /media/Movies 및 /media/Shows에 배치되며, 이는 해당 가이드에서 지정하는 위치와 정확히 같습니다.
환경 파일
서버마다 달라지는 값은 Compose 파일 옆의 .env에 보관합니다.
mkdir -p ~/arr && cd ~/arr~/arr/.env을 작성합니다.
PUID=1000
PGID=1000
TZ=Etc/UTC
DATA_ROOT=/mnt/dataTZ을 자체 시간대로 설정합니다. 예를 들어 Europe/Berlin를 사용할 수 있습니다. arr 애플리케이션은 해당 시간대에 작업을 예약하고 로그 행에 시간대를 기록하므로, 값이 잘못되면 나중에 모든 로그를 해석하기 어려워집니다.
Compose 파일
~/arr/docker-compose.yml을 작성합니다.
services:
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
container_name: prowlarr
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
volumes:
- ./config/prowlarr:/config
ports:
- 127.0.0.1:9696:9696
restart: unless-stopped
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: sonarr
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
volumes:
- ./config/sonarr:/config
- ${DATA_ROOT}:/data
ports:
- 127.0.0.1:8989:8989
restart: unless-stopped
radarr:
image: lscr.io/linuxserver/radarr:latest
container_name: radarr
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
volumes:
- ./config/radarr:/config
- ${DATA_ROOT}:/data
ports:
- 127.0.0.1:7878:7878
restart: unless-stopped
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
- WEBUI_PORT=8080
- TORRENTING_PORT=6881
volumes:
- ./config/qbittorrent:/config
- ${DATA_ROOT}:/data
ports:
- 127.0.0.1:8080:8080
- 6881:6881
- 6881:6881/udp
stop_grace_period: "10s"
restart: unless-stopped이 파일에서는 4가지 설정이 실제로 작동합니다.
${DATA_ROOT}:/data은 미디어에 접근하는 3개 컨테이너에서 동일합니다. Prowlarr는 미디어 파일을 열지 않으므로 이 설정을 받지 않습니다.
모든 웹 포트는 127.0.0.1에 바인딩되므로 Docker는 loopback 주소에서만 해당 포트를 게시합니다. 일반적인 8989:8989은 모든 인터페이스에서 포트를 게시합니다. 그러면 Docker 자체의 방화벽 규칙이 해당 트래픽을 ufw의 deny 규칙을 그대로 우회시킵니다. 이 동작은 자주 문제를 일으키며, Docker가 ufw를 거쳐 포트를 직접 게시하는 이유에서 설명합니다.
포트 6881은 의도적으로 모든 인터페이스에 게시합니다. 이 포트는 torrent 수신 포트이므로 들어오는 peer 연결에서 접근할 수 있어야 합니다. sudo ufw allow 6881으로 포트를 허용합니다. 이 명령이 처음이라면 VPS의 ufw 방화벽 기본 사항을 읽어 보십시오.
애플리케이션별 config 디렉터리는 서로 분리하고, media 볼륨만 공유합니다. 첫 시작 전에 디렉터리를 생성하여 root가 아니라 사용자 소유로 만드십시오.
mkdir -p ~/arr/config/prowlarr ~/arr/config/sonarr ~/arr/config/radarr ~/arr/config/qbittorrent
docker compose up -d
docker compose ps4개 서비스 모두 running를 읽어야 합니다. 2026년 7월 기준으로 이 이미지는 lscr.io에 게시되어 있으며 latest 태그는 현재 stable release를 따릅니다. 업그레이드를 예기치 않은 변경이 아니라 직접 결정할 사항으로 만들려면 대신 버전 태그를 고정하십시오.
웹 인터페이스에 안전하게 접속하기
포트가 loopback에 바인딩되어 있으므로 아직 외부에 노출되지 않았습니다. 자신의 컴퓨터에서 SSH를 통해 포트를 전달합니다.
ssh -L 9696:127.0.0.1:9696 -L 8989:127.0.0.1:8989 \
-L 7878:127.0.0.1:7878 -L 8080:127.0.0.1:8080 you@your-server이제 브라우저에서 http://127.0.0.1:8989에 접속하면 서버의 Sonarr에 연결됩니다. 영구적으로 접속하려면 여러 앱에 TLS 인증서를 제공하는 Traefik 뒤에 스택을 배치하거나, 직접 호스팅하는 WireGuard VPN을 통해 서버에 접속합니다. 이러한 애플리케이션을 자체 로그인 페이지만으로 보호한 상태로 공용 인터넷에 노출해서는 안 됩니다.
qBittorrent는 처음 시작할 때 임의의 관리자 비밀번호를 생성하고 컨테이너 로그에 출력합니다. 비밀번호를 확인한 후 웹 인터페이스에서 변경합니다.
docker compose logs qbittorrent | grep -i password변경하지 않으면 재시작할 때마다 새로운 임의의 비밀번호가 생성됩니다. 그러면 매번 로그에서 비밀번호를 확인해야 합니다.
각 애플리케이션 내부에서 경로 설정
qBittorrent에서 Options를 연 다음 Downloads로 이동하고 기본 저장 경로를 /data/torrents로 설정합니다. 미완료 다운로드 폴더도 /data/torrents/incomplete와 같이 동일한 트리 안에 둡니다. /data 외부에서 완료된 다운로드는 라이브러리에 hardlink로 연결할 수 없습니다.
Sonarr에서 Settings를 연 다음 Media Management로 이동하고 루트 폴더 /data/media/Shows을 추가합니다. Radarr의 루트 폴더는 /data/media/Movies입니다. 이 경로들은 컨테이너 내부의 경로입니다. 호스트 경로 /mnt/data/media/Shows은 거부됩니다. 컨테이너 관점에서는 해당 디렉터리가 존재하지 않기 때문입니다.
Sonarr과 Radarr 모두에서 Settings를 연 다음 Download Clients로 이동하고 qBittorrent를 추가합니다. 호스트는 qbittorrent이고 포트는 8080입니다. Compose가 내부 DNS(domain name system) 서비스가 제공하는 하나의 네트워크에 4개 컨테이너를 모두 연결하므로 서비스 이름을 호스트 이름으로 사용할 수 있습니다. 여기에서는 localhost을 사용하지 마십시오. Sonarr 컨테이너 내부에서 localhost는 Sonarr입니다.
Remote Path Mappings는 비워 둡니다. 이 기능은 다운로드 클라이언트가 보고하는 경로를 arr 애플리케이션이 볼 수 있는 경로로 변환할 때 사용합니다. 하나의 공유 /data 마운트를 사용하면 두 컨테이너가 이미 모든 경로를 동일하게 인식합니다. 이것이 이 구성을 적용할 가치가 있는 두 번째 이유입니다.
Prowlarr를 Sonarr 및 Radarr에 연결
Prowlarr는 인덱서 정의를 다른 애플리케이션에 전달하므로 인덱서를 두 번 구성하지 않고 한 번만 구성하면 됩니다. 각 애플리케이션에서 API(application programming interface) 키가 필요합니다.
Sonarr에서 Settings를 연 다음 General을 열고 API 키를 복사합니다. Prowlarr에서 Settings를 연 다음 Apps를 열고 Sonarr 애플리케이션을 추가한 뒤 3개의 필드를 입력합니다. Prowlarr Server는 http://prowlarr:9696입니다. Sonarr Server는 http://sonarr:8989입니다. API Key에는 복사한 값을 입력합니다. Test를 누릅니다. 녹색 결과가 표시되면 Prowlarr가 Compose 네트워크를 통해 Sonarr에 연결된 것입니다. http://radarr:7878에서 Radarr에도 같은 작업을 반복합니다.
연결이 거부되었다는 빨간색 결과는 거의 항상 서비스 이름이 잘못되었거나 http:// 접두사가 누락되었음을 의미합니다. 컨테이너 내부에서 이름이 해석되는지 확인합니다.
docker compose exec prowlarr curl -sS -o /dev/null -w '%{http_code}\n' http://sonarr:8989HTTP 상태 코드는 네트워크 경로가 정상임을 증명합니다. 이름 해석 오류는 서비스 이름이 잘못되었음을 증명합니다.
하드링크가 실제로 생성되었는지 확인
링크 수를 확인하기 전에는 설정을 신뢰하지 마십시오. 항목 하나를 가져온 후 다운로드한 파일과 라이브러리 파일을 비교합니다.
stat -c '%i %h %n' /mnt/data/torrents/tv/*/*.mkv
stat -c '%i %h %n' /mnt/data/media/Shows/*/*/*.mkv첫 번째 숫자는 inode이고 두 번째 숫자는 링크 수입니다. 하드링크된 파일은 두 위치에서 동일한 inode와 2의 링크 수를 표시합니다. 서로 다른 inode가 각각 1의 링크 수와 함께 표시되면 Sonarr가 파일을 복사한 것입니다. 이 경우 가져오기 로그에 하드링크가 실패했다고 기록됩니다.
디스크 사용량도 확인합니다. 하드링크는 데이터가 아니라 이름을 추가하므로 가져오기가 수행되어도 df -h /mnt/data은 거의 변하지 않아야 합니다.
실제로 문제가 발생하는 원인
가져오기 작업의 권한 오류는 컨테이너의 사용자 ID로 라이브러리 폴더에 쓸 수 없다는 뜻입니다. 메시지는 Access to the path ... is denied입니다. ls -ln /mnt/data/media로 소유자 ID가 PUID과 일치하는지 확인합니다. 컨테이너가 디렉터리에 들어가려면 디렉터리에 실행 비트가 필요하다는 점도 기억합니다.
root이 소유자로 표시되는 파일은 호스트 디렉터리가 존재하기 전에 컨테이너가 시작되어 Docker가 해당 디렉터리를 root로 생성했다는 뜻입니다. 스택을 중지하고 디렉터리를 chown한 다음 다시 시작합니다.
qBittorrent에서 토렌트를 삭제한 뒤 라이브러리 파일도 사라진다면, 가져오기 작업이 나중에 삭제된 복사본을 만든 것이거나 토렌트 항목이 아니라 데이터를 삭제한 것입니다. 실제 hardlink를 사용하면 한 이름을 삭제해도 다른 이름은 그대로 남습니다. 링크 수가 0이 될 때만 데이터가 해제되기 때문입니다.
추가한 미디어보다 디스크가 더 빠르게 가득 차는 것은 복사 문제의 비용이 가장 큰 형태입니다. 스토리지를 추가로 구매하기 전에 위의 stat 검사를 실행합니다.
이 스택에 VPS가 제공해야 하는 항목
세 가지 arr 애플리케이션은 가볍습니다. 이 애플리케이션은 indexer를 폴링하고, 작은 SQLite 데이터베이스에 쓰고, 파일 이름을 변경합니다. RAM이 2 GB인 서버에서 네 개의 컨테이너를 모두 안정적으로 실행할 수 있습니다. 부하는 다른 곳에서 발생합니다. 대용량 torrent에서는 download client가 디스크 입력 및 출력을 포화시키고, 같은 서버에서 동영상을 트랜스코딩하는 media server는 CPU를 사용합니다. 실제 처리량이 보장되는 볼륨에 미디어를 저장하고, 서버에서 다른 중요한 작업도 수행한다면 download client에 대역폭 제한을 설정합니다.
FAQ
Sonarr가 파일을 hardlink하지 않고 복사하는 이유는 무엇입니까?
컨테이너 관점에서 소스와 대상이 서로 다른 파일시스템에 있기 때문입니다. /downloads 및 /tv과 같은 서로 다른 2개의 bind mount는 호스트의 동일한 디스크에서 가져온 경우에도 서로 다른 파일시스템으로 인식됩니다. 모든 컨테이너에서 하나의 상위 디렉터리를 /data로 마운트하고, 그 안에 다운로드 디렉터리와 라이브러리 디렉터리를 배치하면 link를 생성할 수 있습니다. 두 파일에서 stat -c '%i %h %n'을 실행하여 결과를 확인합니다. 동일한 inode와 2의 link count가 표시되어야 합니다.
어떤 PUID와 PGID를 사용해야 합니까?
미디어 트리를 소유한 호스트 계정의 숫자 id를 사용합니다. 이 값은 id -u 및 id -g으로 확인할 수 있습니다. 새로 설치한 Ubuntu VPS에서는 일반적으로 두 값 모두 1000입니다. 스택의 모든 컨테이너에서 동일한 두 값을 사용해야 합니다. 그렇지 않으면 한 애플리케이션이 다른 애플리케이션에서 수정할 수 없는 파일을 생성합니다. 값을 변경한 후 docker compose up -d --force-recreate로 컨테이너를 다시 생성하고 chown -R으로 기존 파일의 소유권을 수정합니다.
이러한 웹 인터페이스를 인터넷에 공개해야 합니까?
아니요. 공개해서는 안 됩니다. Compose 파일에서 공개하는 각 포트를 127.0.0.1에 bind한 다음 SSH tunnel, VPN 또는 TLS(transport layer security)를 종료하고 자체 인증을 추가하는 reverse proxy를 통해 인터페이스에 접속합니다. 인터페이스를 직접 공개하면 예상보다 위험합니다. Docker가 자체 firewall rule을 삽입하므로 ufw의 deny rule만으로는 해당 traffic을 차단할 수 없습니다.
qBittorrent password는 어디에서 확인합니까?
LinuxServer.io image는 startup log에 admin user의 임시 password를 출력합니다. docker compose logs qbittorrent | grep -i password을 실행하여 password를 확인한 다음 Options 및 Web UI에서 영구 password를 설정합니다. 직접 password를 설정할 때까지 재시작할 때마다 새로운 임시 password가 생성됩니다.
Jellyfin에서 동일한 폴더를 사용할 수 있습니까?
예. 이것이 이 layout의 목적입니다. /mnt/data/media을 미디어 서버에 /media로 mount합니다. 그러면 미디어 서버의 library는 /media/Movies 및 /media/Shows에 배치되고, Sonarr와 Radarr는 /data/media를 통해 동일한 디렉터리에 파일을 기록합니다. 미디어 서버에 동일한 PUID 및 PGID을 지정해야 arr stack이 기록한 파일을 읽을 수 있습니다.