라즈베리파이 홈서버에 Garage로 이미지 파일 서버 만들기-hero-image

Tech

라즈베리파이 홈서버에 Garage로 이미지 파일 서버 만들기

회사에서 IIS → Ubuntu로 업로드 파일을 옮기며 겪은 인코딩·권한 지옥을 피하려고 S3 호환 오브젝트 스토리지(Garage)를 Docker로 띄우고, 기존 nginx + Let's Encrypt에 붙이고, USB로 백업까지 걸어둔 기록


팀 프로젝트에 이미지 업로드 기능이 필요해졌다. 클라우드 스토리지를 쓸 수도 있었지만, 이미 집에 프론트·백엔드·GlitchTip을 돌리고 있는 홈서버(라즈베리파이 5 + NVMe SSD)가 있으니 파일 서버도 여기에 두기로 했다.

문제는 예전 회사에서의 기억이었다.

회사에서 겪었던 파일 이전

예전 회사의 레거시 프로젝트들은 파일 처리에 문제가 많았다. Docker 같은 건 없었고, PHP가 사용자가 올린 파일을 원본 파일명 그대로 서버 디스크의 폴더에 저장하는 구조였다.

평소에도 사고가 잦았다. 다른 사람이 같은 이름의 파일(image.jpg, 스크린샷.png 같은)을 올리면 이미 있는 파일과 충돌해서 에러가 나거나, 경우에 따라선 기존 파일을 덮어써 버렸다.

그러다 Windows IIS 서버를 Ubuntu 서버로 옮기게 됐는데, 코드보다 쌓여 있던 업로드 파일을 옮기는 게 훨씬 골치 아팠다.

  • 파일명 인코딩: Windows에 한글 이름으로 저장돼 있던 파일들이 Ubuntu로 넘어오면서 이름이 깨졌다. Windows 쪽과 리눅스(UTF-8) 쪽의 인코딩이 달라서, DB에 저장된 파일명과 실제 파일명이 서로 맞지 않는 일이 생겼다.
  • 권한 설정: Windows엔 없던 리눅스 소유자/퍼미션 개념이 끼어들었다. 복사한 계정 소유로 들어간 파일을 PHP(웹서버 계정)가 못 읽거나, 반대로 PHP가 새로 올린 파일을 내가 SSH로 못 건드리는 일이 반복됐다. chown, chmod를 몇 번이나 했는지 모른다.

돌이켜보면 원인은 두 가지였다.

  1. 여러 주체가 같은 디렉터리를 직접 건드렸다. PHP가 쓰고, 웹서버가 읽고, 나는 SSH로 옮겼다. 각자 계정이 다르니 누가 만든 파일을 누가 못 읽는 일이 생길 수밖에 없다.
  2. 사용자가 올린 원본 파일명을 그대로 디스크에 썼다. 이름이 겹치면 충돌하고, 파일명이 운영체제의 파일 시스템 규칙(인코딩, 금지 문자)에 그대로 묶여 버린다.

이번엔 이 두 가지를 처음부터 피하는 구조로 가고 싶었다.

그래서 오브젝트 스토리지

이번엔 S3 호환 오브젝트 스토리지를 쓰기로 했다. 구조가 이렇게 바뀐다.

  • 디스크를 만지는 건 스토리지 컨테이너 하나뿐이다.
  • 나머지(백엔드, 나)는 전부 HTTP API + 액세스 키로 접근한다.
  • 권한은 리눅스 퍼미션이 아니라 버킷/키 권한으로 관리한다.
  • 파일 이름은 원본 파일명 대신 UUID로 만든 key를 쓴다(뒤에 나오는 경로 규칙). 같은 이름끼리 충돌할 일도, 한글 파일명 인코딩 문제가 생길 여지도 없다.

파일을 옮길 때도 scp가 아니라 S3 API를 쓰니까 소유권이 꼬일 일이 없다. 덤으로 나중에 AWS S3나 Cloudflare R2로 옮기고 싶어지면 endpoint와 키만 바꾸면 된다. 회사 때처럼 서버를 옮기게 되더라도 rclone sync 한 줄이면 끝난다.

왜 Garage인가

후보는 세 개였다.

후보특징
GarageRust로 만든 가벼운 S3 호환 스토리지. 셀프호스팅을 전제로 설계됨
SeaweedFS기능이 많고 작은 파일 대량 처리에 강하지만 설정이 복잡함
MinIO가장 유명하지만 커뮤니티판 정책이 계속 바뀌는 중

라즈베리파이에 올릴 거라 가벼운 게 제일 중요했고, 그래서 Garage를 골랐다.

Garage 띄우기

설정 파일

~/garage/garage.toml을 만든다. 시크릿 3개는 openssl rand -hex 32로 만들었다.

metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "lmdb"
metadata_auto_snapshot_interval = "6h"

replication_factor = 1   # 서버가 1대니까

rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
rpc_secret = "<openssl rand -hex 32>"

[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"

[s3_web]
bind_addr = "[::]:3902"
root_domain = ".example.com"
index = "index.html"

[admin]
api_bind_addr = "[::]:3903"
admin_token = "<openssl rand -hex 32>"
metrics_token = "<openssl rand -hex 32>"

여기서 재밌는 부분은 [s3_web]의 root_domain이다. Garage는 웹 요청의 Host 헤더에서 root_domain을 뗀 나머지를 버킷 이름으로 해석한다. 그래서 버킷 이름을 file-server로 만들면 file-server.example.com으로 들어온 요청이 그대로 그 버킷으로 연결된다.

docker-compose

services:
  garage:
    image: dxflrs/garage:v2.4.1
    restart: unless-stopped
    ports:
      - "127.0.0.1:3900:3900" # S3 API
      - "127.0.0.1:3902:3902" # 웹(공개 조회)
    volumes:
      - ./garage.toml:/etc/garage.toml:ro
      - garage-meta:/var/lib/garage/meta
      - garage-data:/var/lib/garage/data

volumes:
  garage-meta:
  garage-data:

신경 쓴 포인트는 두 가지다.

  • named volume을 썼다. 호스트 디렉터리를 bind mount하면 또 퍼미션을 신경 써야 하는데, named volume은 Docker가 알아서 관리한다.
  • 포트는 127.0.0.1에만 열었다. 외부에서 Garage 포트로 직접 들어오는 길은 아예 없다.

버전 태그는 Docker Hub에서 v로 시작하는 태그 중 최신을 골랐다. 해시처럼 생긴 태그는 개발 빌드라 쓰면 안 되고, latest는 재시작할 때 메이저 버전이 예고 없이 바뀔 수 있어서 피했다.

컨테이너가 꺼지면 사진은?

처음에 제일 궁금했던 부분이다. 결론은 볼륨에 있으니 남아있다. 컨테이너 중지, 재부팅, down 후 up, 이미지 업그레이드 전부 괜찮다. 날아가는 건 docker compose down -v나 docker volume prune을 했을 때뿐이다.

다만 하나 알아둘 게 있다. Garage는 파일을 원본 jpg 그대로 저장하지 않는다. 블록 단위로 쪼개서 data에 넣고, 어떤 파일이 어느 블록인지는 meta에 적는다. 그래서 볼륨 폴더를 열어봐도 photo.jpg 같은 건 안 보이고, meta와 data 중 하나만 살아있으면 복구가 안 된다. 이게 뒤에서 백업을 S3 API로 하는 이유다.

초기 설정

docker compose up -d
alias garage="docker compose -f ~/garage/docker-compose.yml exec garage /garage"

노드에 용량을 할당하고 적용한다. 노드 ID는 첫 실행 로그에 찍혀 있다.

garage layout assign -z home -c 20G <노드ID>
garage layout apply --version 1

assign만 하면 “staged” 상태로 대기하고, apply까지 해야 반영된다. 20G를 줬는데 garage status에 18.6 GiB로 나와서 잠깐 당황했는데, GB(10진)와 GiB(2진)의 단위 차이일 뿐이었다.

그다음 버킷과 키를 만든다.

garage bucket create file-server
garage bucket website --allow file-server
garage key create app-key          # Secret key는 이때 한 번만 보여준다
garage bucket allow --read --write --owner file-server --key app-key

시크릿을 놓쳤다면 garage key info app-key --show-secret으로 다시 볼 수 있다.

로컬에서 도메인 → 버킷 매핑이 되는지 먼저 확인했다.

curl -i -H "Host: file-server.example.com" http://localhost:3902/

NoSuchKey가 나오면 버킷은 찾았는데 파일이 없다는 뜻이라 정상이다. NoSuchBucket이 나오면 root_domain 설정이 틀린 거다.

외부로 공개하기: 기존 nginx에 붙이기

홈서버의 다른 서비스들은 이미 nginx + Let’s Encrypt로 직접 운영하고 있었다. 파일 서버도 같은 nginx 뒤에 붙이기로 했다.

그래서 포트포워딩도 추가로 할 게 없었다. 80/443은 기존 서비스 때문에 이미 열려 있고, nginx가 도메인 이름을 보고 요청을 나눠주니 서비스가 몇 개가 되든 공유기엔 80/443 두 개면 된다. Garage 포트(3900~3903)는 오히려 절대 열면 안 된다.

도메인은 두 개를 쓴다.

  • file-server.example.com → 이미지 공개 조회 (Garage 3902)
  • s3.example.com → S3 API (Garage 3900)

Garage는 조회용과 API가 포트가 달라서, 도메인을 하나로 합치면 요청을 어느 포트로 보낼지 구분하기가 애매하다. 서브도메인 하나 더 쓰는 게 제일 깔끔하다.

nginx도 Docker였다

nginx가 컨테이너로 돌고 있으면 proxy_pass http://127.0.0.1:3902는 안 된다. nginx 컨테이너 안에서 127.0.0.1은 nginx 자기 자신이기 때문이다. 그래서 Garage를 nginx와 같은 Docker 네트워크에 붙였다.

services:
  garage:
    # ...
    networks:
      - default
      - proxy

networks:
  proxy:
    external: true
    name: <nginx가 쓰는 네트워크 이름>

처음엔 맨 아래 networks:만 정의하고 서비스 쪽에 networks를 안 붙여서 연결이 안 됐다. 정의만 하면 아무 일도 안 일어난다. 붙이고 나면 nginx에서 http://garage:3900처럼 서비스 이름으로 접근할 수 있다.

인증서 발급 순서가 중요하다

기존 서비스들은 webroot 방식(/.well-known/acme-challenge/)으로 인증서를 받고 있었다. 여기서 순서를 지켜야 한다.

  1. 80 블록만 먼저 추가하고 reload
  2. certbot으로 인증서 발급
  3. 그다음에 443 블록 추가하고 reload

인증서 파일이 없는 상태에서 443 블록을 먼저 넣으면 nginx -t가 실패하고, 같은 nginx에 물려있던 다른 서비스까지 전부 멈춘다.

443 블록은 이렇게 넣었다.

server {
    listen 443 ssl;
    http2 on;
    server_name file-server.example.com;
    # ssl_certificate ...

    location / {
        proxy_pass http://garage:3902;
        proxy_set_header Host $host;   # 필수: Host로 버킷을 찾는다
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

server {
    listen 443 ssl;
    http2 on;
    server_name s3.example.com;
    # ssl_certificate ...

    client_max_body_size 50M;
    proxy_request_buffering off;

    location / {
        proxy_pass http://garage:3900;
        proxy_set_header Host $host;   # 필수: 바뀌면 서명 검증 실패
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

핵심은 proxy_set_header Host $host다. 웹 쪽은 Host로 버킷을 찾고, S3 API 쪽은 Host가 서명에 포함되기 때문에 바뀌면 SignatureDoesNotMatch가 난다. 그리고 CORS 헤더는 Garage가 처리하니까 nginx에서 add_header Access-Control-...를 넣으면 안 된다. 중복 헤더가 되면 브라우저가 거부한다.

확인해보면 이렇게 나온다.

curl -i https://file-server.example.com/   # 404 NoSuchKey → 정상
curl -i https://s3.example.com/            # 403 AccessDenied, anonymous access 미지원 → 정상

AWS CLI도 컨테이너로

CORS 설정이나 테스트 업로드에 AWS CLI가 필요한데, 가끔 쓰는 도구를 서버에 설치하기 싫어서 공식 이미지를 alias로 걸었다.

# ~/garage/aws.env (chmod 600)
AWS_ACCESS_KEY_ID=GK...
AWS_SECRET_ACCESS_KEY=...
AWS_DEFAULT_REGION=garage
AWS_ENDPOINT_URL=https://s3.example.com
alias aws='docker run --rm -i --env-file ~/garage/aws.env -v "$PWD":/aws amazon/aws-cli'

AWS_ENDPOINT_URL을 넣어두면 매번 --endpoint-url을 안 붙여도 된다. 현재 디렉터리가 컨테이너에 마운트되니까, 올릴 파일이 있는 디렉터리에서 실행해야 한다.

aws s3api put-bucket-cors --bucket file-server --cors-configuration file://cors.json
aws s3 cp test.jpg s3://file-server/test.jpg

브라우저에서 https://file-server.example.com/test.jpg가 열렸을 때 좀 뿌듯했다.

백업: USB + rclone + cron

Garage는 디스크에 원본을 그대로 두지 않으니, 백업은 S3 API로 원본 파일을 뽑아내는 방식으로 했다. 이러면 백업본은 그냥 jpg 폴더라서 어디로든 옮길 수 있다.

USB는 ext4로

백업 대상은 굴러다니던 64GB USB다. 처음엔 Windows에서 exFAT로 포맷하려다 멈췄다. exFAT는 리눅스 소유자/권한을 저장하지 못하고 정전에도 약하다. 라즈베리파이에 꽂고 ext4로 포맷했다.

lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT
sudo mkfs.ext4 -L backup /dev/sda1

lsblk는 반드시 먼저 확인해야 한다. 라즈베리파이가 USB SSD로 부팅하는 구성이면 sda가 시스템 디스크일 수도 있다. 내 경우 시스템은 nvme0n1, USB가 sda였다.

부팅 시 자동 마운트는 fstab에 UUID로 등록했다.

UUID=<blkid로 확인한 값> /mnt/backup ext4 defaults,nofail,noatime 0 2

nofail이 중요하다. 이게 없으면 USB가 빠진 상태로 재부팅했을 때 부팅이 멈출 수 있다.

rclone도 컨테이너로, 대신 --user

rclone 설정은 환경변수 파일로 넘겼다.

# ~/garage/rclone.env (chmod 600)
RCLONE_CONFIG_GARAGE_TYPE=s3
RCLONE_CONFIG_GARAGE_PROVIDER=Other
RCLONE_CONFIG_GARAGE_ACCESS_KEY_ID=GK...
RCLONE_CONFIG_GARAGE_SECRET_ACCESS_KEY=...
RCLONE_CONFIG_GARAGE_ENDPOINT=http://localhost:3900
RCLONE_CONFIG_GARAGE_REGION=garage
RCLONE_CONFIG_GARAGE_FORCE_PATH_STYLE=true
docker run --rm --network host --user 1000:1003 \
  --env-file ~/garage/rclone.env \
  -v /mnt/backup/file-server:/backup \
  rclone/rclone sync garage:file-server /backup

여기서 --user $(id -u):$(id -g)가 이 글의 출발점이었던 권한 문제를 막아주는 한 줄이다. 이게 없으면 컨테이너가 root로 파일을 만들어서, 나중에 백업 파일을 옮기거나 지울 때마다 sudo가 필요해진다.

첫 실행에서 Invalid signature가 났는데, rclone.env에 시크릿 키를 아직 안 넣은 상태였다. 시크릿이 비어 있거나 틀리면 “키가 없다”가 아니라 “서명이 틀렸다”는 에러로 나온다. 채워 넣고 다시 돌리니 바로 됐다.

cron에는 USB가 마운트돼 있을 때만 돌도록 걸었다. 안 그러면 USB가 빠졌을 때 NVMe의 빈 /mnt/backup 폴더에 백업이 쌓인다.

0 4 * * * mountpoint -q /mnt/backup && docker run --rm --network host --user 1000:1003 --env-file /home/<user>/garage/rclone.env -v /mnt/backup/file-server:/backup rclone/rclone sync garage:file-server /backup

cron은 시스템 시간대를 따르니 timedatectl로 Asia/Seoul인지도 확인했다.

백엔드에 넘길 때 정한 것들

업로드 방식은 백엔드가 정한다

프론트는 백엔드 REST API에 multipart로 보내고, 백엔드가 받아서 Garage에 저장하는 평범한 방식이다. 업로드 API는 백엔드가 정하고 프론트는 그 스펙에 맞춘다. 파일 서버 쪽에서는 접속 정보와 규칙만 넘겨주면 된다.

이 방식으로 가면 챙겨야 할 게 하나 있다. 파일이 백엔드 도메인의 nginx를 거치는데, client_max_body_size 기본값이 1MB다. Spring의 multipart 기본 제한도 1MB라서 둘 다 올려줘야 폰으로 찍은 사진이 올라간다.

endpoint는 외부 주소로

배포된 백엔드는 같은 Docker 네트워크에 있어서 http://garage:3900 같은 내부 주소로도 접근할 수 있다. 하지만 백엔드 개발자는 자기 PC에서 Spring을 띄워서 개발하고, 내부 주소는 로컬 개발 환경에서 접근이 안 된다. 그래서 endpoint는 로컬/운영 공통으로 https://s3.example.com을 쓰기로 했다.

저장 경로 규칙

버킷을 여러 프로젝트가 같이 쓸 수 있게, 그리고 나중에 폴더 단위로 옮기기 편하게 경로 규칙을 정했다.

❌ 550e8400-....jpg
✅ my-project/whisky/550e8400-....jpg
  • 루트 저장 금지 → {project}/{category}/{uuid}.{ext}
  • {project}는 환경변수로 분리해서 로컬은 my-project-dev, 운영은 my-project로 쓴다. 로컬 테스트 사진이 운영 사진과 섞이지 않는다.
  • category는 enum으로 고정하고, key는 /로 시작하지 않게 한다. ..이나 원본 파일명도 넣지 않는다. 회사에서 겪었던 파일명 충돌과 한글 인코딩 문제를 여기서 원천 차단한다.
  • DB에는 전체 URL이 아니라 key만 저장한다. 도메인이 바뀌어도 DB는 그대로 둘 수 있다.

S3에는 실제 폴더가 없고 my-project/whisky/abc.jpg 전체가 하나의 이름이다. / 앞부분(prefix)이 폴더처럼 동작할 뿐이라, 폴더를 따로 만들 필요 없이 key에 경로를 넣어서 올리면 된다. 백업 폴더에도 이 구조가 그대로 생긴다.

이미지 파일만 허용

업로드된 파일은 누구나 볼 수 있는 도메인에 공개된다. 타입을 막지 않으면 .html로 우리 도메인에 피싱 페이지를 올리거나, .svg에 스크립트를 넣는 식으로 악용될 수 있다. 그래서 금지 목록이 아니라 허용 목록으로, 이미지 파일만 받기로 했다.

백엔드는 AWS S3 SDK를 그대로 쓰고 endpoint, region(garage), path-style(true)만 바꾸면 된다. region이나 path-style 중 하나라도 틀리면 서명 에러가 난다.

첫 화면 꾸미기

garage.toml에 index = "index.html"을 걸어뒀기 때문에, 버킷 루트에 index.html을 올리면 https://file-server.example.com/에 404 대신 그 페이지가 뜬다. “성호의 파일 서버”라고 간단한 소개 페이지를 만들었다. 서버 사양이나 스토리지 종류 같은 정보는 굳이 공개할 이유가 없어서 뺐다.

파일 서버 첫 화면 도메인으로 접속하면 보이는 첫 화면 — 라이트/다크 모드를 자동으로 따라간다

여기서 또 한 번 헷갈렸다. WinSCP로 ~/garage/에 index.html을 올려놓고 왜 404냐고 한참 봤는데, 서버 디스크에 있는 파일과 버킷 안의 파일은 별개다. Garage는 버킷 안에 있는 것만 보여준다.

aws s3 cp index.html s3://file-server/index.html \
  --content-type "text/html; charset=utf-8" --cache-control "no-cache"

charset을 안 붙이면 한글이 깨질 수 있고, no-cache를 붙여두면 나중에 수정했을 때 바로 반영된다. 이 파일이 “루트 저장 금지” 규칙의 유일한 예외다.

최종 구조

업로드: 브라우저 ─(multipart)─▶ 백엔드 API ─(S3 API)─▶ s3.example.com
조회:   브라우저 ─────────────────────────────────────▶ file-server.example.com
                                                          │
                                         공유기 (80/443만 포워딩)
                                                          ▼
                                                   nginx 컨테이너
                                   (같은 Docker 네트워크) │
                  s3.example.com → garage:3900 ◀──────────┤
         file-server.example.com → garage:3902 ◀──────────┘
                                        │
                  매일 04:00 rclone sync ▼
                              USB (/mnt/backup, ext4)

배운 것 정리

  • 권한 문제의 근본 원인은 “여러 주체가 같은 디렉터리를 직접 만지는 것”이다. 디스크 접근을 컨테이너 하나로 몰고 나머지는 API로 접근하게 하면 문제 자체가 사라진다.
  • 사용자가 올린 원본 파일명을 저장 이름으로 쓰지 말자. UUID로 저장하면 인코딩도, 중복도, 특수문자도 신경 쓸 필요가 없다.
  • 컨테이너 데이터는 볼륨에 있는 한 안전하다. 조심할 건 down -v와 volume prune뿐이다.
  • Docker 안의 localhost와 127.0.0.1은 그 컨테이너 자신이다. 컨테이너끼리는 같은 네트워크에 붙이고 서비스 이름으로 부른다.
  • nginx 뒤에 S3를 둘 때는 Host 헤더를 그대로 넘겨야 한다. 서명에 호스트가 들어간다.
  • 컨테이너로 파일을 만들 땐 --user uid:gid를 붙이자. root 소유 파일이 생기는 걸 막는다.
  • 서버 디스크의 파일과 버킷 안의 객체는 다른 세계다.