Switching from S3 to Tigris on Fly.io

Ben Hoyt

Fly.io에서 S3에서 Tigris로 갈아타기

원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기

1년 전 사이드 프로젝트인 GiftyWeddings.com의 호스팅을 Amazon EC2에서 Fly.io로 옮겼다. 과정도 재미있었고 매달 몇 달러를 아낄 수 있었다.

지난주 Tigris 측에서 연락이 와 Fly.io 인프라 위에 구축하고 fly CLI와 연동한 S3 호환 스토리지 서비스의 비공개 베타를 테스트해보지 않겠냐고 제안했다. (Tigris에서 베타를 써보고 글을 쓰는 대가로 소액을 받았지만, 글 내용에는 관여하지 않았다.)

사실 Tigris라는 이름은 처음 들어봤지만 제안이 흥미로웠다. Fly.io를 써본 경험이 좋았고, AWS라는 거대 괴물(Bezomoth)에 맞서 경쟁하는 작은 회사들이 늘어나는 건 반가운 일이라고 생각한다.

이미 Gifty 서버(Go로 작성)는 Fly.io에서 돌리고 있었지만, 며칠 전까지만 해도 파일 저장소는 여전히 Amazon S3를 쓰고 있었다. 사용자가 업로드한 이미지와 SQLite 백업 파일을 보관하는 용도였다.

전환 자체는 간단했다. 파일을 옮기고, 서버 설정을 S3 대신 Tigris 엔드포인트를 가리키도록 바꾼 뒤 fly deploy를 실행하면 됐다. Tigris가 인증과 캐싱을 다르게 처리해서 코드를 약간 손봐야 했고 몇 가지 자잘한 문제도 있었지만, 전반적으로는 매우 매끄럽게 진행됐다.

버킷 만들기

첫 단계는 사용자가 업로드한 이미지용 테스트 버킷을 만드는 것이었다. 바로 fly storage create 명령어를 실행했다.

$ cd gifty  # change to app directory
$ fly storage create --public
? Choose a name, use the default, or leave blank to generate one:
    test-gifty-registry-images
Your Tigris project (test-gifty-registry-images) is ready. See details and
next steps with: https://fly.io/docs/reference/tigris/

Setting the following secrets on gifty:
BUCKET_NAME
AWS_ENDPOINT_URL_S3
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
AWS_REGION

Updating existing machines in 'gifty' with rolling strategy

-------
 ✔ Machine 4d89601c6e9487 [app] update succeeded
-------
Checking DNS configuration for gifty.fly.dev

버킷을 만들었을 뿐인데 환경 변수 여러 개가 자동으로 설정되고 앱이 재배포된 점이 놀라웠다. 앱의 fly.toml 설정 파일이 있는 앱 디렉터리 안에서 명령어를 실행했기 때문인데, 이 동작은 예상치 못하고 너무 마법 같다는 느낌이 들었다. 어쨌든 나는 로컬 개발용 테스트 버킷을 만들고 있던 참이었으니까.

이 동작은 문서화되어 있으니 안 읽은 내 잘못이기도 하다. 다만 “그리고 재배포한다”는 문구는 추가해야 할 것 같다.

다음 명령어를 Fly.io 앱 컨텍스트 — 앱 디렉터리 내부에서 실행하거나 -a yourapp을 지정한 경우 — 에서 실행하면 앱에 시크릿이 자동으로 설정됩니다.

이 기능은 자칫 다운타임을 유발할 수도 있으니, 예를 들어 --update-app이나 --add-secrets 같은 커맨드라인 옵션을 통해 선택적으로 동작하도록 해야 한다고 생각한다.

또 자동으로 설정된 시크릿 값을 쉽게 확인할 방법도 없다. 보안상 의도된 설계이긴 하지만, 그 때문에 로컬 테스트용 크레덴셜을 얻으려면 (앱 디렉터리 밖에서) 테스트 버킷을 하나 더 만들어야 했다.

$ cd ..  # change out of app directory
$ fly storage create --public --org ben-hoyt --name test-gifty-registry-images2
Your Tigris project (test-gifty-registry-images2) is ready. See details and
next steps with: https://fly.io/docs/reference/tigris/

Set one or more of the following secrets on your target app.
AWS_REGION: auto
BUCKET_NAME: test-gifty-registry-images2
AWS_ENDPOINT_URL_S3: https://fly.storage.tigris.dev
AWS_ACCESS_KEY_ID: ***hidden***
AWS_SECRET_ACCESS_KEY: ***hidden***

이제 좀 낫다. 그 액세스 키와 시크릿으로 로컬 앱 설정을 업데이트했더니 대부분 잘 동작했다… 일단 아래에 설명할 몇 가지 자잘한 문제를 다듬어야 했지만.

버킷 생성 시 또 하나 자잘한 점이 있는데 — 혹시 fly CLI 개발자가 이 글을 읽는다면 참고하길 바란다 — fly storage create를 실행하면 환경 변수가 정해지지 않은 순서로 출력된다. 위에서 볼 수 있듯 첫 번째 예시에서는 BUCKET_NAME이 먼저 나오고, 두 번째 예시에서는 AWS_REGION이 먼저 나온다. 문제는 아니지만 좀 이상하고, 두 번 다 같은 키를 출력하는 건지 확인하려고 두 번 쳐다보게 됐다.

엔드포인트 변경

당연한 얘기지만 기본 AWS 엔드포인트 대신 새로운 Tigris API 엔드포인트를 사용해야 했다. S3 클라이언트를 생성할 때 원래는 이렇게 하고 있었다.

cfg := aws.NewConfig().WithCredentials(creds).WithRegion(region)

올바른 엔드포인트를 사용하기 위해 다음 코드를 추가했다.

endpoint := os.Getenv("GIFTY_S3_BACKUP_ENDPOINT")
if endpoint != "" {
	cfg = cfg.WithEndpoint(endpoint)
}

참고로 나는 아직 Go용 AWS SDK v1을 쓰고 있다. v2에서는 동작 방식이 조금 다르다.

캐싱 차이

Amazon S3를 쓸 때는 사용자가 웨딩 레지스트리에 새 이미지를 업로드해도 같은 파일명(키)에 덮어쓰기만 하면 됐다. 페이지를 새로고침해 이미지를 다시 불러오면 새 이미지가 나타났다.

원래 S3는 최종적 일관성만 보장했기 때문에 S3를 쓸 때도 이게 좋은 방법이었는지는 잘 모르겠다. 다만 2020년에 강력한 read-after-write 일관성으로 전환되면서, PUT 이후의 모든 GET 요청은 바로 업데이트된 내용을 반환하게 됐다.

Tigris를 쓰기 시작해서는 이미지를 다시 업로드한 뒤 Ctrl-Shift-R(캐시를 무시하고 새로고침)을 해야 새 이미지가 나타났다. Tigris는 캐싱을 좀 더 적극적으로 하는 것 같다. Tigris에 업로드된 이미지의 응답 헤더를 보면, 내가 헤더를 설정하지 않았는데도 기본적으로 Cache-Control: max-age=3600 헤더를 보내고 있었다.

이게 좋은 기본값인지는 잘 모르겠지만, 스토리지 시스템인 동시에 CDN 역할도 하려는 것이니 왜 그렇게 했는지는 이해가 된다. 덕분에 사람들은 콘텐츠 기반 파일명 같은 캐싱 기법을 쓰게 되는데, 그 자체는 좋은 일이다.

업데이트: 이 글을 읽은 뒤 Tigris에서 이 동작과 의도를 문서화한 캐싱에 대한 페이지를 추가했다.

어쨌든 이번 기회에 Gifty의 업로드 처리 방식을 개선할 수 있었다. 파일명에 짧은 콘텐츠 기반 해시를 추가하도록 코드를 바꿨다. 키로 {registryID}.jpg를 쓰는 대신 {registryID}-{contentHash}.jpg를 쓰니 캐싱 문제가 해결됐다.

// Add content-based hash to filename to break caching (12 hex digits).
hash := sha1.New()
_, _ = hash.Write(data)
key := fmt.Sprintf("%d-%x.jpg", registry.ID, hash.Sum(nil)[:6])

또 전체 이미지 URL에서 amazonaws.com을 하드코딩하던 부분을 없애고 URL 형식을 설정 가능하게 바꿨다.

// Old version
path := fmt.Sprintf("https://%s.s3-%s.amazonaws.com/%s",
    h.config.S3ImageBucket, h.config.S3ImageRegion, key)

// New version; under Tigris, I set S3ImageURLFormat to
// "https://fly.storage.tigris.dev/gifty-registry-images/%[1]s"
path := fmt.Sprintf(h.config.S3ImageURLFormat, key)

인증 차이

S3를 쓸 때는 레지스트리 이미지용과 백업용 두 개의 버킷이 있었고, 둘 다 인증에 동일한 AWS 키와 시크릿을 사용했다. 아마 좋은 방식은 아니었을 것이다(백업용은 다른 크레덴셜을 썼어야 했다). 하지만 내 경험상 작은 사이트에서 키 하나를 공유하는 건 꽤 흔한 일이기도 하다.

Tigris에서는 버킷을 만들 때마다 새로운 키와 시크릿을 받게 된다. 그래서 S3 관련 환경 변수를 두 개 추가하는 자잘한 코드 수정이 필요했다. 몇 줄 고치고 나니 백업도 정상 동작했다.

파일 복사, 그리고 “섀도 버킷”

테스트 환경에서 모든 게 잘 동작하게 됐으니, 이제 프로덕션 사이트도 전환하고 싶었다.

S3에서의 마이그레이션을 위해 Tigris에는 섀도 버킷이라는 멋진 기능이 있다. Tigris 버킷이 원본 S3 버킷을 가리키도록 설정하면, Tigris 버킷에 파일이 없을 때 “섀도 버킷”에서 매끄럽게 가져오고(그리고 이후 요청을 위해 Tigris에 복사해 둔다).

이 기능을 써봤어야 했을지도 모르지만, 가장 큰 버킷에도 파일이 몇백 개밖에 없고, 깔끔하게 전환한 뒤에는 S3에 대한 의존성을 완전히 없애고 싶어서 사용하지 않기로 했다.

그래서 aws s3 sync 명령어로 파일을 S3에서 로컬 드라이브로 복사한 뒤, 다시 새 Tigris 버킷으로 복사했다. 사이트 규모가 아주 작아서 전환하는 몇 분 사이에 파일이 누락될 가능성은 극히 낮았다.

새 Tigris 버킷의 크레덴셜로 aws CLI 프로필 두 개를 만들었는데, 이름은 각각 gifty-registry-imagesgifty-backup이다. 기본 프로필은 여전히 Amazon S3를 가리킨다.

S3에서 Tigris로 복사할 때 사용한 명령어는 다음과 같다(먼저 유용한 --dryrun 옵션을 사용했다).

# Copy the user-uploaded images
$ mkdir gifty-registry-images
$ cd gifty-registry-images
$ aws s3 sync s3://gifty-registry-images .
...
$ aws s3 sync . s3://gifty-registry-images --profile gifty-registry-images
...

# Copy the backup files
$ cd ..
$ mkdir gifty-backup
$ cd gifty-backup
$ aws s3 sync s3://gifty-backup .
...
$ aws s3 sync . s3://gifty-backup --profile gifty-backup
...

데이터베이스 마이그레이션

AWS에 계속 요금을 내는 한 기존 S3 URL은 계속 잘 동작할 것이다. 하지만 앞서 말했듯 S3에 대한 의존성을 없애고 싶었다.

사이트의 SQLite 데이터베이스에는 전체 이미지 URL을 저장해 둔다. 그래서 파일을 복사한 뒤 간단한 SQLite 쿼리로 데이터베이스의 이미지 URL을 업데이트했다.

-- First check how many S3 image URLs there are
sqlite> SELECT count(*) FROM registry
        WHERE image_url LIKE 'https://gifty-registry-images.s3%';
389

-- Perform the update
sqlite> UPDATE registry SET image_url = replace(image_url,
        	'https://gifty-registry-images.s3-us-west-1.amazonaws.com/',
        	'https://fly.storage.tigris.dev/gifty-registry-images/')
        WHERE image_url LIKE 'https://gifty-registry-images.s3%';

-- Ensure there are no more S3 URLs
sqlite> SELECT count(*) FROM registry
        WHERE image_url LIKE 'https://gifty-registry-images.s3%';
0

이후 사이트는 완전히 Tigris와 Fly.io 위에서 돌아가게 됐고, 100% AWS에서 벗어났다!

마무리 생각

성능

예전 AWS 버킷과 새 Tigris 버킷에서 이미지를 가져오는 데 걸리는 시간을 대충, 비과학적으로 비교해 봤다. Tigris 버킷은 기본적으로 “전역(global by default)”이라 가장 가까운 Fly.io 리전(시드니)을 사용할 수 있는데, 이는 AWS 버킷이 있던 us-west-1 리전보다 뉴질랜드에서 훨씬 가깝다.

예상대로 Tigris에서 다운로드 시간이 확실히 더 빨랐다. 작은 이미지는 AWS에서 약 1초 걸리던 것이 Tigris에서는 300ms였고, 중간 크기 이미지는 AWS에서 1.3초 정도 걸리던 것이 Tigris에서는 600ms 정도였다.

가격

이 시점에서 Gifty에 대한 내 AWS 요금은 — 오로지 S3 사용료뿐인데 — 한 달에 몇 센트 수준이다. 내가 낸 돈보다 관리 오버헤드로 더 많은 비용이 들었을 것 같다. 그래서 가격은 내게 큰 고려 사항이 아니었지만, 어쨌든 Tigris 가격도 S3와 비슷하다.

  • Tigris: 스토리지는 GB당 월 $0.02, GET 요청은 1000건당 $0.0005.
  • S3: 스토리지는 GB당 월 $0.023, GET 요청은 1000건당 $0.0004.

어쨌든 Tigris는 베타 가입 과정에서 $150 무료 크레딧을 줬다. 7500 GB-월에 해당하거나 GET 요청 3억 건에 해당하는 금액으로, 내 작은 웹사이트가 평생 쓸 양보다 거의 확실히 많다.

면책 조항과 내구성

한 가지 알아둘 점이 있다. 얼리 액세스 베타에 가입했을 때 Tigris에서 “베타 기간 동안에는 프로덕션 워크로드에 이 서비스를 사용하지 말아 달라”는 이메일을 보내왔다.

이게 그냥 자기들을 보호하려는 면책 조항인지, 아니면 “파일이 무작위로 사라지고 베타 기간 동안 매일 밤 모든 데이터를 삭제한다”는 의미인지 물어봤다. 그들은 전자에 가깝다고 했고, Fly.io와 Tigris 모두 이를 프로덕션 플랫폼으로 취급하고 있다고 했다. 분명 위험은 있지만 나는 그 정도로도 괜찮았고, 머지않아 베타에서도 벗어날 예정이라고 했다.

S3가 객체 내구성을 99.999999999%라고 광고하는 것과 비교해 Tigris의 내구성은 어떨지도 궁금했다. Tigris도 분명 아키텍처에 대해 많이 고민한 것 같지만, 그 내구성을 측정해 S3의 터무니없는 수치와 비교할 수 있을지는 의문이다. 애초에 S3의 11개의 9라는 수치가 과연 실제로 의미가 있기는 한 걸까?

끝으로

어쨌든 전환 과정은 매우 만족스러웠고 아직까지 아무 문제도 없었다. 물론 시간만이 말해주겠지만! Tigris와 Fly.io 모두 계속 잘되길 바라며, 거대 클라우드 기업들에 꼭 필요한 경쟁을 불러일으키길 기대한다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글