[Heroku]10種類のWebフレームワークでCloud Native Buildpacks(ActivityPub)
Intro
Herokuの無償プランが廃止になり、Fly.ioやRailwayのような類似サービスへ移行した人も多いのではないのでしょうか。
私はFly.ioに移行するだけではなく、公開している10種類のWebフレームワークでActivityPub実装を行うプロジェクト「StrawberryFields」がHerokuに対応しているため、無償プランが廃止になった後もHerokuに対応するサポートを続けようか悩みました。
しかしながらそんな悩みも簡単に解決する素晴らしいツールがあります。
Cloud Native Buildpacksです。
コマンド一つで簡単にHerokuのDynoに近いBuilderでコンテナを作成できるこのCloud Native Buildpacks(本記事ではBuildpacksと呼びます)、CNCFのインキュベーションプロジェクトで本当に便利なのですが完全に対応するためにはHerokuを完全に理解する必要がありました。
結果、めちゃくちゃしんどかったです。
今回どのように10種類のWebフレームワークをHeroku対応したのか一つずつ紹介していきたいと思います。
StrawberryFields
まず全てのWebフレームワークをDocker対応する必要がありました。
特にdocker run
コマンドを使い動かすときに--env-file
オプションを使い.envファイルを渡す場合、"\n"を含む複数行(本記事ではMultilineと呼びます)の環境変数に対応する必要がありました。
ローカル環境で動かすケースと大きく異なり、またdocker run
コマンドで解決する方法を探しても見つからなかったため、コードを修正して対応することにしました。
また、Dockerで動かすにあたりhostを0.0.0.0にする必要があり、こちらも必要に応じて環境変数で渡しましょう。
その他、HerokuはPORTが環境変数で動的に割り当てられるため、PORTも環境変数で渡せるようにしておきましょう。
ではやっていきます。
Express
まずはpackage.jsonを用意します。
package.json内でNode.jsなどのバージョンを指定することもできます。
他の言語と異なり、JavaScriptの場合はpackage-lock.jsonのようなLockfileが必要なかったのですが、あっても構わないので用意しておきます。
yarn.lockでは試していませんが多分同じだと思います。
let private_key_pem = process.env.PRIVATE_KEY;
if (private_key_pem.startsWith('"')) private_key_pem = private_key_pem.slice(1);
if (private_key_pem.endsWith('"')) private_key_pem = private_key_pem.slice(0, -1);
private_key_pem = private_key_pem.split("\\n").join("\n");
こんな感じでMultiline対応します。
Procfile
web: npm start
Expressはコード内でPOSTを環境変数で変更できるようにしておけば起動コマンドは同じもので問題ないようです。
Fastify
こちらもJavaScriptで動かすためpackage.jsonとLockfileのpackage-lock.jsonを用意しておきます。
let private_key_pem = process.env.PRIVATE_KEY;
if (private_key_pem.startsWith('"')) private_key_pem = private_key_pem.slice(1);
if (private_key_pem.endsWith('"')) private_key_pem = private_key_pem.slice(0, -1);
private_key_pem = private_key_pem.split("\\n").join("\n");
Expressと同じです。
Procfile
web: HOSTS=0.0.0.0 npm start
こちらはExpressと異なり、Fastifyではhostがデフォルトでlocalhostが設定されるため、Dockerで動かすために環境変数で書き換えるようにし、Procfileで環境変数を渡します。
Flask
とりあえずrequirements.txtを用意しておくとパッケージをインストールしてくれます。
また、runtime.txtを用意しておけばPythonのバージョンを指定することもできますが、ある程度最新のバージョンじゃないとエラーが出るようです。
PipfileとPipfile.lockでは試していませんが多分同じだと思います。
private_key_pem = os.getenv("PRIVATE_KEY")
if private_key_pem.startswith('"'):
private_key_pem = private_key_pem[1:]
if private_key_pem.endswith('"'):
private_key_pem = private_key_pem[:-1]
private_key_pem = "\n".join(private_key_pem.split("\\n"))
こんな感じでMultiline対応します。
Procfile
web: gunicorn app:app
Gunicornを使いアプリケーションサーバーを起動します。
hostもportもDockerにあわせて自動的に割り当てられるようです。
FastAPI
こちらもPythonで動かすためrequirements.txtとruntime.txtを用意しておきます。
private_key_pem = os.getenv("PRIVATE_KEY")
if private_key_pem.startswith('"'):
private_key_pem = private_key_pem[1:]
if private_key_pem.endswith('"'):
private_key_pem = private_key_pem[:-1]
private_key_pem = "\n".join(private_key_pem.split("\\n"))
Flaskと同じです。
Procfile
web: uvicorn app:app --host 0.0.0.0 --port "$PORT"
Uvicornを使いアプリケーションサーバーを起動します。Gunicornと異なりhostとportを設定する必要があります。
Lumen
composer.jsonだけではなく、Lockfileのcomposer.lockも一緒に用意しておかないと動かないです。
他のWebフレームワークを異なりLumenと後述するLaravelは環境変数が多いため注意しましょう。
$private_key_pem = getenv('PRIVATE_KEY');
if (substr($private_key_pem, 0, 1) === '"') $private_key_pem = substr($private_key_pem, 1);
if (substr($private_key_pem, -1) === '"') $private_key_pem = substr($private_key_pem, 0, -1);
$private_key_pem = implode("\n", explode("\\n", $private_key_pem));
こんな感じでMultiline対応します。
location / {
try_files $uri @rewriteapp;
}
location ^~ /.well-known/ {
try_files $uri @rewriteapp;
allow all;
}
location @rewriteapp {
rewrite ^(.*)$ /index.php/$1 last;
}
location ~ ^/index\.php(/|$) {
try_files @heroku-fcgi @heroku-fcgi;
internal;
}
また、PHPの場合は別途Nginxのコンフィグファイルが必要になります。
Herokuのドキュメントでコンフィグファイルを紹介していますが、.well-knownパスをPHPで操作する場合は追加でパスを設定しallow allを付与する必要があります。
Procfile
web: vendor/bin/heroku-php-nginx -C nginx_app.conf public/
Heroku用に最適化されたNginxに先程書いたコンフィグファイルと、ドキュメントルートとしてpublic/
を渡して起動します。
Laravel
こちらもPHPで動かすためcomposer.jsonとLockfileのcomposer.lockを用意しておきます。
$ php artisan key:generate --ansi
非常に多い環境変数を設定する他、上記コマンドを叩いてAPP_KEYを新たに用意します。
$private_key_pem = getenv('PRIVATE_KEY');
if (substr($private_key_pem, 0, 1) === '"') $private_key_pem = substr($private_key_pem, 1);
if (substr($private_key_pem, -1) === '"') $private_key_pem = substr($private_key_pem, 0, -1);
$private_key_pem = implode("\n", explode("\\n", $private_key_pem));
Lumenと同じです。
location / {
try_files $uri @rewriteapp;
}
location ^~ /.well-known/ {
try_files $uri @rewriteapp;
allow all;
}
location @rewriteapp {
rewrite ^(.*)$ /index.php/$1 last;
}
location ~ ^/index\.php(/|$) {
try_files @heroku-fcgi @heroku-fcgi;
internal;
}
Lumenと同じコンフィグファイルです。
Procfile
web: vendor/bin/heroku-php-nginx -C nginx_app.conf public/
こちらもLumenと同じです。
Sinatra
Gemfileだけではなく、LockfileのGemfile.lockも一緒に用意しておかないと動かないです。
Gemfile内だけではなく、RVMと同様に.ruby-versionでRubyのバージョンを指定できます。
private_key_pem = ENV['PRIVATE_KEY']
private_key_pem = private_key_pem[1..-1] if private_key_pem.start_with?('"')
private_key_pem = private_key_pem[0..-2] if private_key_pem.end_with?('"')
private_key_pem = private_key_pem.split("\\n").join("\n")
こんな感じでMultiline対応します。
LANG=en_US.UTF-8
RACK_ENV=production
この環境変数はBuildpacksの機能ではなく、Heroku上でビルドすると自動的に追加されるものです。
Procfile
web: bundle exec rackup config.ru -o 0.0.0.0 -p "$PORT"
コード内やProcfileでPumaを指定していませんが、GemfileでPumaをインストールしていればPumaを使いアプリケーションサーバーが起動します。
hostとportを設定してあげましょう。
Rails
RailsもGemfileとLockfileのGemfile.lockを用意しておきます。
$ bundle exec rake secret
Railsでは上記コマンドを叩いてSECRET_KEY_BASEを新たに用意します。
private_key_pem = ENV['PRIVATE_KEY']
private_key_pem = private_key_pem[1..-1] if private_key_pem.start_with?('"')
private_key_pem = private_key_pem[0..-2] if private_key_pem.end_with?('"')
private_key_pem = private_key_pem.split("\\n").join("\n")
Sinatraと同じです。
LANG=en_US.UTF-8
RACK_ENV=production
RAILS_ENV=production
RAILS_LOG_TO_STDOUT=enabled
RAILS_SERVE_STATIC_FILES=enabled
この環境変数もBuildpacksの機能ではなく、Heroku上でビルドすると自動的に追加されるものです。Sinatraより多いですね。
$ RAILS_ENV=production bundle exec rake assets:precompile
またRailsのBuildpacksでは自動的にprecompileコマンドが実行されますが、sass-railsというパッケージをインストールする必要があり、このパッケージのインストール自体遅いので先にインストールを行いprecompileを行ってGitにコミットしてしまいます。
こうすればBuildpacksで自動的に行われるprecompileコマンドをスキップされ、sass-railsをGemfileに書く手間も減ります。
public/assets/.sprockets-manifest-*.json
public/assets/*.css.gz
public/assets/*.js.gz
precompileコマンドが実行すると上記ディレクトリにファイルが生成されます。
この中の.sprockets-manifest-*.jsonファイルを検知しprecompileコマンド実行有無の判断を行うようです。
Procfile
web: bundle exec puma -C config/puma.rb
PumaにRailsで用意したコンフィグファイルを渡してアプリケーションサーバーを起動します。
Gin
go.modだけではなく、go.sumも一緒に用意しておかないと動かないです。
x = strings.TrimPrefix(x, "\"")
x = strings.TrimSuffix(x, "\"")
x = strings.Join(strings.Split(x, "\\n"), "\n")
こんな感じでMultiline対応します。
意外とシンプルに書けますね。
Procfile
web: bin/strawberryfields-gin
ビルドするとバイナリがbin/ディレクトリに生成されるので実行します。
Django
こちらもPythonで動かすためrequirements.txtとruntime.txtを用意しておきます。
$ python -c 'from django.core.management import utils; utils.get_random_secret_key()'
Djangoでは上記コマンドを叩いてSECRET_KEYを新たに用意します。
private_key_pem = os.getenv("PRIVATE_KEY")
if private_key_pem.startswith('"'):
private_key_pem = private_key_pem[1:]
if private_key_pem.endswith('"'):
private_key_pem = private_key_pem[:-1]
private_key_pem = "\n".join(private_key_pem.split("\\n"))
Flask, FastAPIと同じです。
...
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
...
STATIC_ROOT = os.path.join(BASE_DIR, "staticfile")
STATICFILES_DIRS = [os.path.join(BASE_DIR, "static")]
Djangoの場合は静的ファイルが特殊な振る舞いをするため、別途WhiteNoiseというパッケージをインストールする他、settings.pyのMIDDLEWAREを追加し、STATIC_ROOTにパスを代入する必要があります。
Procfile
web: gunicorn strawberryfields_django.wsgi
Flaskと同様、Gunicornを使いアプリケーションサーバーを起動します。
Buildpacks
$ pack build strawberryfields-express -p strawberryfields-express -B heroku/builder-classic:22
$ docker run -d -p 8080:8080 --env-file=.env -e PORT=8080 strawberryfields-express:latest
最後にコンテナイメージを作成するためにpack build
してみると、どうにも.envファイルもコンテナイメージにコピーされているように見えます。
どうやら.dockerignoreはBuildpacksで使えず、Buildpacksを使うプロジェクトファイルで設定を行うproject.tomlで機密ファイルを除外する必要があるようです。
[build]
exclude = [
".git",
"tmp",
".env",
"id_ecdsa",
"id_ecdsa.pub",
"id_ed25519",
"id_ed25519.pub",
"id_rsa",
"id_rsa.pub",
"secret.txt",
".env.example",
"env.sh",
"fly.toml"
]
とりあえず共通して除外したいファイル、ディレクトリを書きました。
Ginでは更にビルドしたバイナリファイルも除外しています。
node_modules, vendorディレクトリも除外したかったのですが言語ごとにあったりなかったりしますし、除外してもpack build
コマンドでコンテナイメージ内に再生成されるのでとりあえず残しておくことにします。
#!/bin/bash
strawberryfields=( express fastify flask fastapi lumen laravel sinatra rails gin django )
for i in "${strawberryfields[@]}"; do
git clone "https://gitlab.com/acefed/strawberryfields-$i.git"
done
最後に実際に使用したシェルスクリプトを紹介します。
まずはGitでリポジトリを取得します。
#!/bin/bash
echo "$(cat /proc/sys/kernel/random/uuid)$(cat /proc/sys/kernel/random/uuid)" | tr -d '-' >> secret.txt
echo "SECRET=$(tail -n1 secret.txt)" > .env
ssh-keygen -b 4096 -m PKCS8 -t rsa -N '' -f id_rsa
echo "PRIVATE_KEY=\"$(cat id_rsa | sed -z 's/\n/\\n/g')\"" >> .env
続いて.envファイルを作成します。
各Webフレームワークで紹介したように、Lumen, Laravel, Rails, Djangoでは更に環境変数が必要になるため.envに追加してから動かしましょう。
#!/bin/bash
brew install --ignore-dependencies buildpacks/tap/pack
chmod 666 /var/run/docker.sock
docker kill $(docker ps -aq)
docker rm $(docker ps -aq)
docker rmi $(docker images -aq)
strawberryfields=( express fastify flask fastapi lumen laravel sinatra rails gin django )
for i in "${strawberryfields[@]}"; do
pack build "strawberryfields-$i" -p "strawberryfields-$i" -B heroku/builder-classic:22
done
for i in "${!strawberryfields[@]}"; do
gp ports visibility "808$i:public"
docker run -d -p "808$i:808$i" --env-file=.env -e "PORT=808$i" "strawberryfields-${strawberryfields[i]}:latest"
done
docker ps
packでビルドしてdockerで動かします。
今回gitpod.nww
で動かしましたが、ビルドする際docker.sock connect permission deniedが表示されるため一般ユーザーで動かせるよう権限を変更します。
10種類まとめて動く姿は圧巻です。
Go and Java
ちなみにGoのシンプルなWebアプリケーションと埋め込みJettyを使用したJavaのシンプルなWebアプリケーションもBuildpacksで動かすことができました。
残念がらSparkJavaやSpringBootをHerokuで動かすことはできませんでしたが、Buildpacksを使えばHerokuで動かすJavaも問題ないように思えます。多分。
Outro
11/27のHeroku Dashboardです。
ありがとうHeroku。さようならHeroku。
でも今後はFly.ioやRailwayだけではなく、VPSや自宅サーバーにインストールしたDokkuでも動きますし、Google Cloud's buildpacksに対応すればCloud Run, GKE, Anthosなどなど様々なサービスで動かすことができるようになります。
すごいぞBuildpacks!!
Ref
- https://fly.io/
- https://railway.app/
- https://github.com/DmitryScaletta/free-heroku-alternatives
- https://devcenter.heroku.com/articles/buildpacks
- https://github.com/heroku/builder
- https://www.publickey1.jp/blog/18/buildpackscloud_native_computing_foundationherokucloud_funndry.html
- https://www.publickey1.jp/blog/20/googlebuildpacksdockerfilejavagonodejs.html
- https://www.cncf.io/
Discussion