From Go on EC2 to Fly.io: +fun, −$9/mo

Ben Hoyt

EC2上のGoからFly.ioへ:楽しさアップ、月9ドルダウン

原文は Ben Hoyt により に公開されました。 このブログを購読する

最近、2つのサイドプロジェクトのホスティング先をAmazon EC2インスタンスからFly.ioに移行しました。とても良い経験でした。Fly.ioは本当にただ動くのです。おかげで500行ほどあったAnsibleのスクリプトや設定ファイルを削除でき、月に9ドル節約できるようになりました。

ついでに、2つのうち規模の大きい方のプロジェクトではいくつか簡素化も行いました。静的ファイルのホスティングをCDNからETagキャッシュ付きのgo:embedに、cronジョブをシンプルなバックグラウンドのgoroutineに、設定ファイルを環境変数にそれぞれ置き換えました。

両方のアプリのアーキテクチャ自体は変えていません。どちらもGoのnet/httpサーバー、SQLiteデータベース、HTMLテンプレートと静的ファイルという構成です。

Fly.ioの基本を把握してシンプルな方のプロジェクトを移行するのに1時間ほど、もう少し複雑な方を移行するのに2、3日の夜の作業で済みました。面倒なリバースプロキシやSSL周りはFly.ioがやってくれますし、デプロイはfly deploy一発で完了します。Fly.ioのダッシュボードも見やすくて、何が起きているかがすぐに分かります。

旧環境から新環境へ

長い間、この2つのアプリケーションはAmazon Linuxが動く1台のEC2インスタンス(インスタンスタイプはt2.micro)でホストしていました。トラフィックの少ないサイトなので、これでも問題なく動いていました。しかし、良いツールを使っても、セットアップや運用の手間が自分が望む以上にかかっていました。

これらは小規模なGoのウェブアプリケーションで、Hacker Newsで誰かが指摘していたように、Goアプリのデプロイは「実行ファイルをscpして実行するだけ。どんなLinux VMでもセットアップなしで動く」ほどシンプルです。

私がこう返信したのは、実際にFly.ioへ切り替える前のことでした:

今まさにそうやっています。でも他にもいろいろとセットアップが必要なんです:

  • SSLを終端させるためにCaddyをインストールして設定する。Caddyは素晴らしいですが、それでも考えることはありますし、20行の設定を理解する必要があります。
  • CaddyとGoサーバーを動かすためにsystemdを設定する。ロケット工学ではありませんが、初めてsystemdを理解し、サーバーごとに25行の設定ファイルを書く必要がありました。
  • 新バージョンが出たときにCaddyをアップグレードするスクリプト(当時はaptリポジトリにありませんでした)。
  • リポジトリをクローンし、CaddyとGoサーバー用のユーザーとグループを作成し、設定ファイルをコピーし、バックアップ用のcronジョブを追加するためのAnsibleセットアップスクリプト(150行のAnsible YAML)。

Fly.ioやRenderなら、このほとんどは不要か、追加の設定なしで手に入るようです。

その後で私が指摘したように、Fly.ioとEC2インスタンスで自分でやることの違いは、Dropboxと、Dropboxが出たばかりの頃のあの有名なHacker Newsのコメントで提案されていたこととの違いに似ています:

Linuxユーザーなら、FTPアカウントを取得してcurlftpfsでローカルにマウントし、マウントしたファイルシステム上でSVNやCVSを使えば、そうしたシステムはごく簡単に自作できます。WindowsやMacからも、このFTPアカウントは標準のソフトウェアでアクセスできます。

「ごく簡単」かもしれませんが、怠け者の開発者にとってさえ手間がかかりすぎますし、ましてや非エンジニアならなおさらです。

そこで私は、ホスティングやSSL証明書、デプロイを任せられるシンプルなホスティングサービスを探していました。数年前にHerokuを試したことがありますが、より高価でしたし、(SQLite用のシンプルなディスクボリュームではなく)割高なホスト型データベースを使う方向に誘導されるのが気になりました。今でも状況は変わっていないようです

それから最近になってFly.ioRenderに出会いました。Renderの方がむしろ高機能に見えます(例えばcronジョブをサポートしているなど)が、EC2と比べて節約にはならなさそうだったので、探し続けました。

Fly.ioはよりギーク向けでコマンドライン中心なのが自分に合っていましたし、料金も驚くほど安いです。3台までの小さな仮想マシンなら無料(私は2台しか必要ありません)、それ以降も小型VMは月2ドルです。1共有CPUと256MBのRAMでも、そこそこのトラフィックがあるGoアプリには十分だとわかりました(負荷テストを参照)。

どちらのアプリでもSQLiteを使っているのですが、Fly.ioはSQLiteに全力で取り組んでいるので、その点でも相性が良さそうでした。彼らのテックブログも素晴らしいです。

Simple Lists

Simple Listsのスクリーンショット

まずは家族向けにホストしている小さなToDoリストアプリでFly.ioを試してみることにしました(Simple Listsについての記事を参照)。Goで書かれており、昔ながらの作りです。サーバー側でHTMLをレンダリングし、HTMLフォームによる素朴なGETとPOSTだけで、JavaScriptは使っていません。

そこでflyctl(Fly.ioのCLI)をインストールしてみたところ、第一印象は上々でした!ソースコードを一切変えずにflyctl launchと打ってどうなるか試してみると、数分後にはアプリがfly.devのサブドメインで動き始めていました。まさかこんなに簡単なはずがない……と思いました。

でも、実際それくらい簡単でした。ツールがfly.toml設定ファイルを自動生成し、Goアプリのビルド方法を自動で判別し、アプリがPORT環境変数を探していることを見抜いてPORT=8080のエントリを追加してくれたのです。どうやらPaketoの「build packs」を使っているようです ― 以前は知らないプロジェクトでしたが。

唯一の問題は、仮想マシンの一時的なディスク上にあるSQLiteデータベースを参照していたため、デプロイするたびにFly.ioがデータベースを消してしまうことでした。

これを解決するため、Fly.ioには永続ボリュームという概念があるので、CLIで一つ作成しました:

$ flyctl volumes create simplelists_data --size=1

--size=1は1GBのボリュームを意味します。ToDoリストのエントリとしてはかなりの容量ですが……どうやらこれが許容される最小サイズのようです。Fly.ioは3GBまで無料で、それを超えると1GBあたり月0.15ドルかかります。ストレージは安いですね!

次に、fly.tomlに以下の3行を追加しました(最終的なバージョンは、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 Weddingsウェブサイトのスクリーンショット

GiftyをFly.ioで動かすために、いくつか変更が必要でした:

  • 設定オプションをファイルではなく環境変数でサーバーに渡すようにする。
  • HTMLテンプレートと静的ファイルをgo:embedfs.FSを使ってGoバイナリに埋め込む。
  • cronジョブの代わりに、2つのバックグラウンドタスク用にgoroutineを追加する。

環境変数での設定

最初の変更はごく些細なものでした。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
}

静的ファイルのホスティング

ここで判断を迫られました。以前は静的ファイルのホスティングにAmazon Cloudfront(S3をバックエンドにしたもの)のようなCDNを使うことを推奨していました。ウェブサイトの静的ファイルを、バージョン管理の問題を避けつつ優れたキャッシュを実現するために、ファイル名にコンテンツベースのハッシュを付けてS3にアップロードするcdnuploadというPythonツールまで作ったほどです。数週間前まで、Giftyでもそれを使っていました。

その構成は大規模で分散したアプリケーションには今でも有効です ― Fly.ioが分散アプリでやっていることも気に入っています ― が、この小さなウェブサイトには大げさに思えました。Goのウェブサーバーは静的ファイルの配信でも十分ですし、Last-ModifiedETagヘッダーでキャッシュの問題は解決できると分かっていました。

そこで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.FileServerLast-Modifiedヘッダーをサポートしていますが、残念ながらgo:embedファイルの更新時刻を提供しませんETagもサポートしていません。

これは少し厄介で、自分でETagラッパーを書こうかと思っていたところ、Fly.ioで働くBen Johnson氏によるhashfsという200行ほどのライブラリを見つけました。このライブラリはfs.FSファイルシステムをラップし、ETagヘッダーを生成するhttp.Handlerを提供してくれるため、ブラウザが効率的にキャッシュできるようになります。

バックグラウンドジョブ

Fly.ioに切り替える前は、2つのcronジョブがありました:

  • 結婚式の日付から数日後に顧客へ「結婚式後」のメールを送信するジョブ。
  • SQLiteクライアントの.backupコマンドを使って毎日データベースをバックアップし、結果をS3バケットにアップロードするジョブ。

Fly.ioはcronジョブを組み込みの概念としてサポートしていないので、いくつか選択肢がありました:

  1. Fly.ioでサービスマネージャーを動かし、Giftyとcronジョブの両方を起動する。[追記:Ben Johnson氏が指摘したように、Dockerfileを使っているならapt install cronして普通にcronを使えます。]
  2. Fly.ioで別のcron用アプリケーションを立ち上げるか、Fly Machinesを使う。
  3. Goサーバー内でシンプルなgoroutineを使ってバックグラウンドタスクを実行する。

選択肢1は、そもそもFly.ioを使うシンプルさを損なってしまいます。Dockerfileを作成し、いろいろな設定をしなければならず、それは避けたかったのです。

選択肢2はすっきりしていますが、データベースにアクセスするためにcron用アプリをメインアプリに接続するのが面倒かもしれません(ボリュームはアプリケーションをまたいで使えるのか、よく分かりません)。それにFly Machinesはまた新たに学ぶことが増えます(そして何が一定間隔でそれらを起動するのでしょうか?)。

選択肢3は一見乱暴に見えますが、そのシンプルさが気に入りました!新しく学ぶ必要もありませんし、アプリを複数インスタンスで動かすつもりもなかったので、結局これを選びました。

Goでは、goroutineと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)
        }
    }
}()

単純な作りですが、うまく動いています。リトライは直接は扱っていません ― 次のtickで再試行されることを利用しているだけです(そもそもエラーはあまり出ません!)。

ええ、わかっています ― サーバーが停止してもgoroutineはgracefulにシャットダウンされません。でも、万が一タスク実行中にサーバーが終了しても、何も問題は起きません。実際のコードで追加でやっているのは、panicを捕捉してそれもメールで自分に送ることくらいです。

backUpDatabase関数(これも徹底的にシンプルにしています)はos/execを使ってsqlite3クライアントを.backup <filename>というスクリプトで実行し、結果をプライベートなS3バケットにアップロードします。さらに、最新10件より古いバックアップは削除します。

負荷テスト

すでに古いEC2サーバーは停止してしまったので、残念ながら前後の速度を比較することはできません。ただ、主に新しいサーバーが十分速いかどうかをテストしたかったのです。

Fly.io上のサイトはここ(ニュージーランド)からは速く感じますが、これは主にFly.ioのsydリージョン(シドニー、海を挟んですぐ向こう)でホストしているからだと思います。以前はAWSのus-west-2リージョン(オレゴン)にホストしており、そこは私や多くの顧客からはかなり遠かったのです。

さらに、静的ファイルも今は同じドメイン・同じサーバーでホストされているため、米国ではなくシドニーから配信されますし、ブラウザが既存の接続を再利用でき、別のホストのためにTLSのセットアップをする必要がなくなります。

以下は、初期HTMLとそれに続く静的ファイルのネットワークタイムラインのスクリーンショットです ― キャッシュなしの状態です。ホームページのもので、多くの画像を含むため最も重いページですが、合計で900KB未満、1秒以内に完全に読み込まれるのは自慢です。

GiftyWeddings.comのネットワークタイムライン

HTTP負荷テストツールのVegetaを使って、4つのURLを叩く小さなテストを実行しました。HTMLテンプレートをレンダリングする3つのページ(ホームページ、2つのSQLクエリを実行するレジストリページ、お問い合わせページ)と、中サイズの画像1つです。

「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に上がりましたが。

Fly.ioのVMが苦しくなり始めたのは、レートを毎秒1000リクエストまで上げたときだけでした。平均は127ms、p99は1.3秒まで上がり、10,000リクエスト中2件でエラーが発生し、テスト中にサイトを閲覧するともっさり感じました。

というわけで、最も小さい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 Newsr/golangをご覧ください。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント