EC2 위 Go에서 Fly.io로: 재미는 더하고, 월 9달러 절약
원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기
최근 사이드 프로젝트 두 개를 Amazon EC2 인스턴스에서 Fly.io로 옮겼다. 정말 만족스러운 경험이었다. Fly.io는 그냥 잘 작동했다. 덕분에 Ansible 스크립트와 설정 파일 500줄 정도를 삭제할 수 있었고, 매달 9달러를 아낄 수 있었다.
두 프로젝트 중 더 큰 쪽은 옮기면서 몇 가지 단순화도 함께 진행했다. 정적 파일을 CDN으로 호스팅하던 방식에서 ETag 캐싱과 함께 go:embed를 쓰는 방식으로, cron 작업에서 단순한 백그라운드 고루틴으로, 그리고 설정 파일에서 환경 변수 방식으로 바꿨다.
두 앱의 아키텍처 자체는 그대로 유지했다. 둘 다 Go net/http 서버와 SQLite 데이터베이스, 그리고 HTML 템플릿과 정적 파일을 사용한다.
Fly.io의 기본을 익혀 간단한 프로젝트를 옮기는 데는 한 시간 정도 걸렸고, 더 복잡한 프로젝트를 옮기는 데는 저녁 시간 두어 번이 걸렸다. Fly.io가 귀찮은 리버스 프록시와 SSL 처리를 다 해주고, 배포는 fly deploy 한 줄이면 끝나며, 현황을 볼 수 있는 괜찮은 대시보드도 제공한다.
예전 방식에서 새 방식으로
오랫동안 Amazon Linux를 실행하는 EC2 인스턴스 한 대(인스턴스 타입 t2.micro)로 이 두 애플리케이션을 호스팅해 왔다. 트래픽이 적은 사이트들이라 별문제 없이 잘 돌아갔다. 하지만 좋은 도구를 써도 내가 감당하고 싶었던 것보다 더 많은 설정과 관리가 필요했다.
이 앱들은 작은 Go 웹 애플리케이션인데, Hacker News에서 누군가 지적했듯이, Go 앱 배포는 “실행 파일을 scp로 복사해 실행하면 된다. 별도 설정 없이 어떤 Linux VM에서도 동작한다” 정도로 간단하다.
내가 답글을 달았듯이(실제로 Fly.io로 옮기기 전이었다):
지금 내가 하는 방식이 바로 그거다. 하지만 추가로 설정할 게 한가득 있다:
- Caddy를 설치하고 SSL 종료를 하도록 설정해야 한다. Caddy는 훌륭하지만 그래도 신경 쓸 게 있고 20줄짜리 설정을 파악해야 한다.
- Caddy와 Go 서버를 실행하도록 systemd를 설정해야 한다. 로켓 과학은 아니지만 systemd를 처음부터 익혀야 했고 서버마다 25줄짜리 설정 파일을 만들어야 했다.
- 새 버전이 나올 때마다 Caddy를 업그레이드하는 스크립트(당시에는 apt 저장소에 없었다).
- 저장소를 클론하고 Caddy와 Go 서버용 사용자와 그룹을 만들고, 설정 파일을 복사하고, 백업을 위한 cron 작업을 추가하는 Ansible 설정 스크립트(Ansible YAML 150줄).
Fly.io나 Render를 쓰면 이런 것들 대부분이 필요 없거나, 별도 설정 없이 바로 제공되는 것 같다.
그 뒤에 덧붙였듯이, Fly.io와 EC2 인스턴스로 직접 하는 것의 차이는 Dropbox가 처음 나왔을 때 그 유명한 Hacker News 댓글에서 제안된 방식과 Dropbox의 차이와 비슷하다:
Linux 사용자라면 FTP 계정을 하나 만들고 curlftpfs로 로컬에 마운트한 뒤, 마운트된 파일 시스템에서 SVN이나 CVS를 쓰면 그런 시스템을 아주 간단하게 직접 만들 수 있다. Windows나 Mac에서는 이 FTP 계정을 기본 소프트웨어로 접근할 수 있다.
“아주 간단”할지는 몰라도, 게으른 개발자에게도 여전히 너무 많은 작업이고, 비기술자는 말할 것도 없다.
그래서 호스팅과 SSL 인증서, 배포를 알아서 처리해 주는 간단한 호스팅 서비스를 찾아보고 있었다. 몇 년 전에 Heroku를 써 본 적이 있는데 더 비쌌고, (SQLite용 단순 디스크 볼륨 대신) 상대적으로 비싼 호스팅 데이터베이스를 쓰도록 유도했다. 지금도 여전히 그런 것 같다.
그러다 최근에 Fly.io와 Render를 알게 됐다. Render는 실제로 좀 더 완비된 느낌이었다(예를 들어 cron 작업을 지원한다). 하지만 EC2에 비해 비용을 절약할 수는 없을 것 같아 계속 찾아봤다.
Fly.io는 더 괴짜스럽고 커맨드라인 중심이라 내 취향에 맞았고, 가격도 터무니없이 저렴했다. 작은 가상 머신 세 대까지는 무료(나는 두 대만 필요했다)이고, 그 이후로는 작은 VM당 월 2달러다. 공유 CPU 1개와 RAM 256MB면 트래픽이 적당히 있는 Go 앱에도 충분하다는 사실도 알게 됐다(부하 테스트 참조).
두 앱 모두 SQLite를 쓰는데, Fly.io는 SQLite에 올인하고 있어서 그런 면에서도 잘 맞았다. 게다가 훌륭한 기술 블로그도 운영하고 있다.
Simple Lists

먼저 가족용으로 호스팅하고 있는 작은 할 일 목록 앱에 Fly.io를 시험해 보고 싶었다(내 Simple Lists에 대한 글 참조). Go로 작성됐고, 아주 전통적인 방식으로 만들었다. 서버에서 HTML을 렌더링하고, HTML 폼으로 평범한 GET과 POST만 쓰며, JavaScript는 전혀 없다.
그래서 Fly.io CLI인 flyctl을 설치했는데, 첫인상이 좋았다! 소스 코드를 전혀 바꾸지 않은 채 flyctl launch를 입력해 어떻게 되나 지켜봤다. 몇 분 뒤, 내 앱이 fly.dev 서브도메인에서 실행되고 있었다. 이렇게 간단할 리가 없는데…
하지만 실제로 그랬다. 이 도구가 fly.toml 설정 파일을 자동으로 생성하고, 내 Go 앱을 어떻게 빌드해야 하는지 알아서 파악했으며, 앱이 PORT 환경 변수를 찾는다는 것을 파악해 PORT=8080 항목까지 추가해 줬다. 이 과정에는 Paketo 빌드팩을 쓰는 것 같다. 이전에는 이 프로젝트를 잘 몰랐지만 말이다.
유일한 문제는 가상 머신의 임시 디스크에 있는 SQLite 데이터베이스를 참조하고 있어서, 배포할 때마다 Fly.io가 데이터베이스를 날려버린다는 점이었다.
이를 해결하기 위해 Fly.io는 영구 볼륨이라는 개념을 제공하므로, CLI로 하나를 만들었다:
$ flyctl volumes create simplelists_data --size=1
--size=1은 1GB 볼륨을 의미한다. 할 일 목록 항목으로는 엄청나게 큰 용량이지만 … 어쨌든 허용되는 최소 크기가 그렇다고 한다. Fly.io는 3GB까지 무료로 제공하고, 그 이후에는 GB당 월 0.15달러를 부과한다. 스토리지는 저렴하다!
그다음 fly.toml에 다음 세 줄을 추가했다(최종 버전은 flyctl이 생성한 것에 이 내용이 추가된 형태다):
[mounts]
source = "simplelists_data"
destination = "/data"
그러자 모든 것이 그냥 잘 작동했다. Fly.io는 볼륨을 매일 스냅샷으로 떠 주는데, 이 정도면 이 용도에는 충분한 ‘백업’이다. 어째서인지 스냅샷 크기가 60MB 정도인데, 실제 사용 중인 디스크 공간은 100KB 정도에 불과하지만, 뭐 어쨌든 그건 Fly.io가 신경 쓸 문제다!
직접 Simple Lists 인스턴스를 운영하고 싶다면 저장소를 클론한 뒤 flyctl launch를 입력하면 된다. 먼저 simplelists -genpass로 비밀번호 해시를 생성하고 SIMPLELISTS_PASSHASH 시크릿을 설정해야 한다.
Gifty Weddings
Gifty Weddings는 커플이 특정 매장에 얽매이지 않고 자신만의 웨딩 선물 리스트를 만들 수 있도록 돕는 웹사이트다. Go와 SQLite 백엔드와 Elm 프론트엔드로 이루어진 중간 규모의 웹 애플리케이션이다. 홈페이지 모습은 다음과 같다:

Gifty를 Fly.io에서 동작시키기 위해 몇 가지를 변경해야 했다:
- 설정 옵션을 파일 대신 환경 변수로 받도록 서버를 수정했다.
go:embed와fs.FS를 사용해 HTML 템플릿과 정적 파일을 Go 바이너리에 임베드했다.- cron 작업 대신 두 개의 백그라운드 작업을 위한 고루틴을 추가했다.
환경 변수로 설정 관리
첫 번째 변경은 아주 사소한 것이었다. json.Decoder로 로드하던 Config 구조체가 있었는데, 이를 os.Getenv를 쓰도록 바꿨다. 비교적 작은 프로젝트라 Viper 같은 화려한 라이브러리가 필요 없이 Go 표준 라이브러리로 충분하다.
대략 다음과 같은 모습이다:
func main() {
cfg := Config{
AWSKey: os.Getenv("GIFTY_AWS_KEY"),
AWSSecret: os.Getenv("GIFTY_AWS_SECRET"),
ListenAddress: getEnvOrDefault("GIFTY_LISTEN_ADDRESS", ":8080"),
DatabasePath: getEnvOrDefault("GIFTY_DATABASE_PATH", "/data/gifty.sqlite"),
...
}
// ... use cfg ...
}
func getEnvOrDefault(name, defaultValue string) string {
value, ok := os.LookupEnv(name)
if !ok {
value = defaultValue
}
return value
}
정적 파일 호스팅
여기서 결정해야 할 지점이 있었다. 이전에는 정적 파일 호스팅에 S3 기반의 Amazon CloudFront 같은 CDN을 사용하는 것을 권장해 왔다. 심지어 웹사이트의 정적 파일을 콘텐츠 기반 해시를 파일명에 넣어 S3에 업로드해, 버전 문제를 피하면서도 캐싱을 훌륭하게 해 주는 cdnupload라는 Python 도구를 직접 만들기도 했다. 몇 주 전까지만 해도 Gifty에도 그 방식을 쓰고 있었다.
그 구성은 더 크고 분산된 애플리케이션에는 여전히 좋다 — 그리고 Fly.io가 분산 앱 영역에서 하는 일도 마음에 든다 — 하지만 이 작은 웹사이트에는 과한 느낌이었다. Go 웹 서버도 정적 파일을 서빙하는 데 충분하고, Last-Modified나 ETag 헤더로 캐싱 문제를 해결할 수 있다는 것도 알고 있었다.
그래서 cdnupload와 작별하고 go:embed에 올인했다. 이 기능은 Go 1.16에 들어온 것으로, Go 컴파일러에게 파일을 바이너리에 임베드해 런타임에 fs.FS 파일 시스템 인터페이스로 접근할 수 있게 하는 내장 방식이다.
모습은 다음과 같다:
// This "go:embed" directive tells Go to embed static/* (recursively),
// and make it accessible as the staticFS variable.
//go:embed static/*
var staticFS embed.FS
func main() {
// Tell the HTTP server to serve staticFS at /static/*
hashFS := hashfs.NewFS(staticFS)
http.Handle("/static/", hashfs.FileServer(hashFS))
// This function is passed to the HTML templating engine,
// allowing templates to generate paths to static files.
// In templates, it's used like this:
//
// <link rel="stylesheet" href="{{static "styles/main.css"}}">
funcMap := template.FuncMap{
"static": func(path string) string {
return "/" + hashFS.HashName("static/"+path)
},
}
// ...
}
Go의 http.FileServer는 Last-Modified 헤더를 지원하지만, 안타깝게도 go:embed는 파일 수정 시간을 제공하지 않는다. ETag도 지원하지 않는다.
좀 귀찮은 부분이라 직접 ETag 래퍼를 작성하려던 참이었는데, 그때 Fly.io에서 일하는 Ben Johnson이 만든 200줄짜리 hashfs라는 라이브러리를 발견했다. 이 라이브러리는 fs.FS 파일 시스템을 감싸 ETag 헤더를 생성하는 http.Handler를 제공해 브라우저가 효과적으로 캐시할 수 있게 해준다.
백그라운드 작업
Fly.io로 옮기기 전에는 두 개의 cron 작업이 있었다:
- 결혼식이 지나고 며칠 뒤 고객에게 ‘결혼식 이후’ 이메일을 보내는 작업.
- SQLite 클라이언트의
.backup명령으로 매일 데이터베이스를 백업하고 그 결과를 S3 버킷에 업로드하는 작업.
Fly.io는 cron 작업을 내장 개념으로 지원하지 않으므로, 몇 가지 선택지가 있었다:
- Fly.io에서 Gifty와 cron 작업을 함께 시작하는 서비스 매니저를 실행한다. [업데이트: Ben Johnson이 지적했듯이, Dockerfile을 쓴다면
apt install cron으로 cron을 설치해 그냥 정상적으로 cron을 쓸 수도 있다.] - Fly.io에서 별도의 cron 애플리케이션을 띄우거나 Fly Machines를 사용한다.
- Go 서버에서 단순한 고루틴으로 백그라운드 작업을 수행한다.
옵션 1은 Fly.io를 쓰는 것 자체의 단순함을 일부 해칠 것이다. Dockerfile을 만들고 여러 가지를 설정해야 하는데, 그건 피하고 싶었다.
옵션 2는 더 깔끔하지만, 데이터베이스에 접근하기 위해 cron 앱을 메인 앱과 연결하는 게 귀찮을 수 있다(볼륨이 애플리케이션 간에 공유되는지 잘 모르겠다). 그리고 Fly Machines는 또 새로 배워야 할 것이고(그리고 무엇이 정해진 간격으로 그것들을 실행시킬까?).
옵션 3은 처음에는 다소 꼼수처럼 보이지만, 그 단순함이 마음에 들었다! 새로 배울 것도 없고, 앱을 한 인스턴스 이상으로 운영할 일도 없다는 걸 알고 있었기 때문에 결국 그 방식을 선택했다.
Go에서는 고루틴과 time.Ticker를 이용해 몇 줄 코드로 정해진 간격의 백그라운드 작업을 시작할 수 있다. 내가 하고 있는 것도 대략 다음과 같다(main 내부):
// Start goroutine to send post-wedding emails every so often.
go func() {
ticker := time.NewTicker(time.Hour)
for {
<-ticker.C
err := sendPostWeddingEmails(config, emailRenderer, dbModel)
if err != nil {
emailAdmin("error sending post-wedding email: %v", err)
}
}
}()
// Start goroutine to check if database needs backing up every so often.
// Ticks every 6 hours, but backUpDatabase skips if there's already one today.
go func() {
ticker := time.NewTicker(6 * time.Hour)
for {
<-ticker.C
err := backUpDatabase(config, s3Client)
if err != nil {
emailAdmin("error backing up database: %v", err)
}
}
}()
아주 단순하지만 잘 동작한다. 재시도를 직접 처리하지는 않는다. 그냥 다음 틱에 다시 시도한다는 점을 활용한다(그렇게 에러가 많이 나는 편도 아니고!).
그리고 네, 알고 있다. 서버가 멈출 때 고루틴이 우아하게 종료되지 않는다는 것을. 하지만 작업이 실행 중인 와중에 서버가 종료될 가능성은 매우 낮고, 설령 그렇다 해도 아무 문제도 생기지 않는다. 실제 코드에서 추가로 하는 일은 panic을 잡아 그것도 이메일로 받는 것뿐이다.
backUpDatabase 함수(여기서도 최대한 단순하게 유지한다)는 os/exec를 사용해 .backup <filename> 스크립트로 sqlite3 클라이언트를 실행한 뒤, 결과를 프라이빗 S3 버킷에 업로드한다. 그리고 최신 10개를 제외한 오래된 백업은 삭제한다.
부하 테스트
이미 예전 EC2 서버를 내렸기 때문에 아쉽게도 이전과 이후를 비교할 수는 없다. 하지만 나는 주로 새 서버가 충분히 빠른지 테스트하고 싶었다.
여기(뉴질랜드)에서 Fly.io의 사이트는 더 빠르게 느껴진다, 하지만 주로 Fly.io의 syd 리전(시드니, 바로 건너편)에 호스팅하고 있기 때문인 것 같다. 이전에는 AWS의 us-west-2 리전(오리건)에 호스팅하고 있었는데, 그곳은 나와 내 고객 대부분에게서 훨씬 더 멀다.
게다가 정적 파일도 이제 같은 도메인과 서버에서 호스팅되므로 미국이 아니라 시드니에서 제공되고, 브라우저가 열려 있는 연결을 재사용할 수 있으며 다른 호스트에 대해 TLS 설정을 할 필요가 없다는 의미이기도 하다.
다음은 초기 HTML과 이후 정적 파일들의 네트워크 타임라인 스크린샷이다. 캐시되지 않은 상태다. 홈페이지에서 가져온 것으로, 이미지가 많이 포함돼 가장 무거운 페이지인데 총 용량이 900KB 미만이고 1초 이내에 완전히 로드된다는 점이 자랑스럽다.

Vegeta라는 HTTP 부하 테스트 도구를 이용해 네 개의 URL을 대상으로 간단한 테스트를 실행했다. HTML 템플릿을 렌더링하는 세 페이지(홈페이지, 두 개의 SQL 쿼리를 실행하는 레지스트리 페이지, 문의 페이지)와 중간 크기의 이미지 하나다.
“attack”을 10초 동안 실행했다. 기본 속도는 초당 50요청이지만, 500과 1000으로도 시도해 봤다. 결과는 다음과 같다:
$ cat urls.txt | vegeta attack -duration=10s | vegeta report
Requests [total, rate, throughput] 500, 50.10, 49.84
Duration [total, attack, wait] 10.032s, 9.98s, 51.434ms
Latencies [min, mean, 50, 90, 95, 99, max] 42.805ms, 50.004ms, 45.411ms, 53.643ms, 58.801ms, 146.6ms, 216.559ms
Bytes In [total, mean] 10757125, 21514.25
Bytes Out [total, mean] 0, 0.00
Success [ratio] 100.00%
Status Codes [code:count] 200:500
Error Set:
$ cat urls.txt | vegeta attack -duration=10s -rate=500/s | vegeta report
Requests [total, rate, throughput] 5000, 500.08, 497.69
Duration [total, attack, wait] 10.046s, 9.998s, 47.869ms
Latencies [min, mean, 50, 90, 95, 99, max] 42.615ms, 61.354ms, 49.472ms, 72.032ms, 117.76ms, 304.653ms, 1.177s
Bytes In [total, mean] 107571250, 21514.25
Bytes Out [total, mean] 0, 0.00
Success [ratio] 100.00%
Status Codes [code:count] 200:5000
Error Set:
$ cat urls.txt | vegeta attack -duration=10s -rate=1000/s | vegeta report
Requests [total, rate, throughput] 10000, 1000.11, 994.86
Duration [total, attack, wait] 10.05s, 9.999s, 50.801ms
Latencies [min, mean, 50, 90, 95, 99, max] 42.876ms, 126.907ms, 60.591ms, 254.24ms, 419.47ms, 1.294s, 3.508s
Bytes In [total, mean] 215062995, 21506.30
Bytes Out [total, mean] 0, 0.00
Success [ratio] 99.98%
Status Codes [code:count] 0:2 200:9998
Error Set:
Get "https://giftyweddings.com/": ... connection reset by peer
Get "https://giftyweddings.com/static/images/gifts-b80...38f.jpg": ... connection reset by peer
초당 500요청까지 올려도 문제없이 처리했고, 사이트 탐색도 여전히 빨랐다. 평균은 50ms에서 61ms로, 99번째 백분위수는 147ms에서 305ms로 올라가긴 했지만 말이다.
초당 1000요청까지 끌어올렸을 때 비로소 Fly.io VM이 버거워하기 시작했다. 평균은 127ms로, p99는 1.3초로 올라갔고, 10,000개 요청 중 두 개에서 에러가 발생했으며, 테스트 중 사이트 탐색도 느릿하게 느껴졌다.
결국 가장 작은 공유 1 CPU Fly.io VM도 초당 500요청을 아무 문제 없이 처리할 수 있다. 나는 그 정도면 만족한다! 이게 그다지 과학적인 테스트는 아니라는 걸 알지만, 여기서는 내 목적에 충분히 부합한다.
결론
사이드 프로젝트 호스팅에 Fly.io를 사용한 지는 몇 주밖에 되지 않았지만 지금까지 제품에 매우 만족한다. 500줄에 달하는 Ansible 스크립트와 systemd 유닛 파일, Caddy 설정 파일을 삭제할 수 있어 무척 기뻤다.
마침내 EC2 인스턴스를 종료하고 AWS 요금을 월 9달러에서 월 10센트 정도로 낮출 수 있었던 것도 기분 좋게 했다(사용자가 업로드한 이미지와 백업용으로는 여전히 S3를 쓰고 있다). EC2에 불만이 있는 건 아니고 특정 용도라면 다시 쓸 것이지만, 작은 웹 애플리케이션에는 Fly.io가 아주 잘 맞는 것 같다.
이 정도로 칭찬하면 Fly.io가 돈을 주고 시킨 것처럼 들릴지도 모르지만, 믿어 달라, 그런 건 아니다. 나는 그저 그들의 제품과 아주 작은 VM에 대한 지원, 그리고 SQLite에 대한 애정을 좋아하는 열정적인 괴짜일 뿐이다. 가격 정책은 말할 것도 없고!
관련 논의는 Hacker News와 r/golang에서 확인하세요.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기