🚂

[Heroku]10種類のWebフレームワークでCloud Native Buildpacks(ActivityPub)

2022/12/03に公開

Intro

Herokuの無償プランが廃止になり、Fly.ioやRailwayのような類似サービスへ移行した人も多いのではないのでしょうか。
私はFly.ioに移行するだけではなく、公開している10種類のWebフレームワークでActivityPub実装を行うプロジェクト「StrawberryFields」がHerokuに対応しているため、無償プランが廃止になった後もHerokuに対応するサポートを続けようか悩みました。

https://acefed.gitlab.io/strawberryfields
https://zenn.dev/tkithrta/articles/78b203b30f689f

しかしながらそんな悩みも簡単に解決する素晴らしいツールがあります。
Cloud Native Buildpacksです。

https://buildpacks.io/
https://github.com/buildpacks/pack

コマンド一つで簡単にHerokuのDynoに近いBuilderでコンテナを作成できるこのCloud Native Buildpacks(本記事ではBuildpacksと呼びます)、CNCFのインキュベーションプロジェクトで本当に便利なのですが完全に対応するためにはHerokuを完全に理解する必要がありました。

結果、めちゃくちゃしんどかったです。
今回どのように10種類のWebフレームワークをHeroku対応したのか一つずつ紹介していきたいと思います。

StrawberryFields

まず全てのWebフレームワークをDocker対応する必要がありました。
特にdocker runコマンドを使い動かすときに--env-fileオプションを使い.envファイルを渡す場合、"\n"を含む複数行(本記事ではMultilineと呼びます)の環境変数に対応する必要がありました。

https://github.com/moby/moby/issues/12997

ローカル環境で動かすケースと大きく異なり、またdocker runコマンドで解決する方法を探しても見つからなかったため、コードを修正して対応することにしました。

また、Dockerで動かすにあたりhostを0.0.0.0にする必要があり、こちらも必要に応じて環境変数で渡しましょう。

その他、HerokuはPORTが環境変数で動的に割り当てられるため、PORTも環境変数で渡せるようにしておきましょう。

ではやっていきます。

Express

https://gitlab.com/acefed/strawberryfields-express
https://devcenter.heroku.com/articles/nodejs-support

まずは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

https://gitlab.com/acefed/strawberryfields-fastify
https://devcenter.heroku.com/articles/nodejs-support

こちらも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

https://gitlab.com/acefed/strawberryfields-flask
https://devcenter.heroku.com/articles/python-support

とりあえず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

https://gitlab.com/acefed/strawberryfields-fastapi
https://devcenter.heroku.com/articles/python-support

こちらも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

https://gitlab.com/acefed/strawberryfields-lumen
https://devcenter.heroku.com/articles/php-support

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を付与する必要があります。

https://devcenter.heroku.com/articles/custom-php-settings

Procfile

web: vendor/bin/heroku-php-nginx -C nginx_app.conf public/

Heroku用に最適化されたNginxに先程書いたコンフィグファイルと、ドキュメントルートとしてpublic/を渡して起動します。

Laravel

https://gitlab.com/acefed/strawberryfields-laravel
https://devcenter.heroku.com/articles/php-support

こちらも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

https://gitlab.com/acefed/strawberryfields-sinatra
https://devcenter.heroku.com/articles/ruby-support

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

https://gitlab.com/acefed/strawberryfields-rails
https://devcenter.heroku.com/articles/ruby-support

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コマンド実行有無の判断を行うようです。

https://devcenter.heroku.com/articles/rails-asset-pipeline

Procfile

web: bundle exec puma -C config/puma.rb

PumaにRailsで用意したコンフィグファイルを渡してアプリケーションサーバーを起動します。

Gin

https://gitlab.com/acefed/strawberryfields-gin
https://devcenter.heroku.com/articles/go-support

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

https://gitlab.com/acefed/strawberryfields-django
https://devcenter.heroku.com/articles/python-support

こちらも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にパスを代入する必要があります。

https://devcenter.heroku.com/articles/django-assets

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で機密ファイルを除外する必要があるようです。

https://buildpacks.io/docs/reference/config/project-descriptor/

[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が表示されるため一般ユーザーで動かせるよう権限を変更します。

docker

10種類まとめて動く姿は圧巻です。

Go and Java

https://gitlab.com/acefed/heroku-go
https://gitlab.com/acefed/heroku-servlet

ちなみにGoのシンプルなWebアプリケーションと埋め込みJettyを使用したJavaのシンプルなWebアプリケーションもBuildpacksで動かすことができました。

https://devcenter.heroku.com/articles/create-a-java-web-application-using-embedded-tomcat

残念がらSparkJavaやSpringBootをHerokuで動かすことはできませんでしたが、Buildpacksを使えばHerokuで動かすJavaも問題ないように思えます。多分。

Outro

11/27のHeroku Dashboardです。

heroku

ありがとうHeroku。さようならHeroku。

mastodon

でも今後はFly.ioやRailwayだけではなく、VPSや自宅サーバーにインストールしたDokkuでも動きますし、Google Cloud's buildpacksに対応すればCloud Run, GKE, Anthosなどなど様々なサービスで動かすことができるようになります。

https://github.com/GoogleCloudPlatform/buildpacks

すごいぞBuildpacks!!

Ref

Discussion