akfm.dev

Rustで作ったAPIのDocker on Heroku

November 7th, 2020

Introduction

RustでRest APIを作ったんですが、Herokuにデプロイしようとしたら結構いろいろハマって大変な思いをしました・・・。 もともとインフラ自体結構苦手意識があってちょっと避けてたんですが、せっかく作ったAPIをまたローカルで動かすだけで満足するのはもったいない!と思い、意を決してちゃんとデプロイすることにしたんですが、やっぱ初めてだと苦労するものですよね。

今回はそんな自分への備忘録かねて、苦労した部分のポイントについて詳細残しておこうと思います。

作ったもの

今回作ったAPIのリポジトリはこちらです。 https://github.com/AkifumiSato/at-api/tree/45ed837339dc754942821b6b1b6a6700092ef646

認証部分は別にマイクロサービス化してるので、このAPIは勤怠情報の永続化だけ担当してます。 まだ制作途中なのでいろいろ変更してくと思いますが、今日時点のコミット時点でリンク貼っときます。

デプロイするAPIの技術選定

このAPIの技術選定については以下になります。

  • language: Rust
  • framework: actix-web
  • ORM: diesel
  • DB: postgreSQL

本当Rust楽しい。。。

デプロイ概要

やりたきこと

当然APIを動かすことではあるんですが、加えて「Dockerのイメージサイズを下げること」を今回目標としました。 Docker理解がまだまだ未熟だし、本番環境で動かす経験してみたい+せっかくRust使って極小なバイナリにできるのにでっかいイメージサイズで運用するのなんか嫌だな・・・ってことで、わからないなりにやってみることにしました。

ちなみに開発中に使ってたイメージは1.35GBほどでした。

インフラ

無料で使えるし今回はHerokuにしました。 Netlifyとか使う前は静的サイトの確認用にNodeでサーバー立ててよく使ってたんですが、Dockerで動かしたことはなかったです。

デプロイフロー

ORMもあるので当然マイグレーションしなきゃいけません。 ざっくりやるべきことを羅列するとこんな感じになります。

  • Rustのコンパイルのマルチステージビルド
  • DBのマイグレーション
  • Rustのコンパイル結果のバイナリだけ持ってきたイメージを動かす

最終形のDockerfileとheroku.yml

Dockerfileはこんな感じです。 databaseとdevステージはローカル用なので、デプロイ時はproductionをターゲットにしてdocker buildしてます。

ちなみにわざわざ一回Cargo.tomlとかだけ持ってきてcargo newしたりcargo buildしてるかというと、こうすることでCargo.tomlに変更がない場合イメージのキャッシュが利用されるのでbuildが早くなります。 Heroku上のbuildでも早くなるのかはわかりませんが、ローカルでbuildする時毎回依存関係解決しに行くと結構長くなっちゃうので・・・。

# build-stage
FROM rust:1.44.1 AS build-stage

WORKDIR /app

RUN USER=root cargo new at-api
WORKDIR /app/at-api

COPY Cargo.toml Cargo.lock ./
RUN cargo build --release
COPY . .
RUN rm ./target/release/deps/at_api*
RUN cargo build --release
RUN cargo install diesel_cli

# production
FROM debian:buster-slim AS production
RUN apt-get update
RUN apt-get install libpq-dev -y
COPY --from=build-stage /app/at-api/target/release/at-api .
CMD ["./at-api"]

# database
FROM postgres:11-alpine AS db
ENV LANG ja_JP.utf8

# dev
FROM rust:1.44.1 AS develop
WORKDIR /app
RUN cargo install cargo-watch
RUN cargo install diesel_cli
COPY . .

続いて、heroku.ymlは以下です。

build:
  docker:
    web:
      dockerfile: Dockerfile
      target: production
    migration:
      dockerfile: Dockerfile
      target: build-stage
release:
  image: migration
  command:
    - diesel setup

Herokuでハマったこと

ビルドステージに環境変数が渡せない

まずこれがかなり困りました。。。 結論、マルチステージビルドにおいては解決策が見つかりませんでした。

これで何が困るってDBのマイグレーションをビルドステージでできないんですよ。 今回使ってるRustのDieselってCLIをインストールしてマイグレーション実施しなきゃなのですが、「Dockerのイメージサイズを可能な限り下げること」を目標としてるわけだからマイグレーション用のCLIなんて稼働するイメージに入れたくないですよね。 (そもそもCargo installが動く環境にしたらまた1GB超えちゃう・・・)

そこでRailsとかのマイグレーションをHerokuで実施するときとかいろいろ調べてたら、Herokuにはheroku.ymlでrelease時のみに走らせるコンテナを指定できると言うではないですか。 と言うことでheroku.ymlでrelease指定すれば、環境変数渡せるのではって思ったんですが・・・

heroku.ymlの仕様の説明がわかりづらい+足りない

heroku.ymlの仕様はここにだいたい書いてあるんですが、説明が簡素でいろいろわからず戸惑いました。。。 最初イメージ名書けばいいならDockerfileで宣言した名前でいいのかと以下のように書いたら「そんなイメージないよ?」って怒られました。

release:
  image: build-stage
  command:
    - diesel setup

まぁこの辺は冗長な気がするけど、サンプル同様にすればとりあえず動くだろうと修正したらとりあえずこけなくなりました。

Rustのバイナリ周りでこまったこと

軽量イメージでバイナリが動かない

調べてた限り、scratchとかbusyboxとかでもRustのバイナリは動くはずなのにどういうわけか動かない・・・。 これは動的リンクを解決できないことが原因で、Linux muslというターゲットを設定する+muslをコンパイルできる環境構築をすれば解決します。

この辺がとても参考になりました。 https://dev.to/sergeyzenchenko/actix-web-in-docker-how-to-build-small-and-secure-images-2mjd

FROM rust:1.43.1 as build

RUN apt-get update
RUN apt-get install musl-tools -y
RUN rustup target add x86_64-unknown-linux-musl

WORKDIR /usr/src/api-service
COPY . .

RUN RUSTFLAGS=-Clinker=musl-gcc cargo install -—release —target=x86_64-unknown-linux-musl

FROM alpine:latest

COPY --from=build /usr/local/cargo/bin/api-service /usr/local/bin/api-service

CMD ["api-service"]

もしくはdistrolessを使うとmuslすら不要でポータブルなバイナリにできます。 イメージサイズは少し大きくなりますが、まぁ10MB前後から50MBくらいになるだけでDockerfileがシンプルになるならそれに越したことはないかなーって気がしますね。

FROM rust:1.43.1 as build
ENV PKG_CONFIG_ALLOW_CROSS=1

WORKDIR /usr/src/api-service
COPY . .

RUN cargo install --path .

FROM gcr.io/distroless/cc-debian10

COPY --from=build /usr/local/cargo/bin/api-service /usr/local/bin/api-service

CMD ["api-service"]

これで動くはず、って思ったら僕のAPIはこれらどっちでも動かなかったんですよね。。。

postgreSQLを使うならlibpq-devが必要

前述の状態で動かなかったのはなんとpostgreSQL使ってるせいでした。 dieselに含まれるpostgreSQL接続部分のバイナリモジュールに含まれる動的リンクが原因だったので、Rustのターゲットと変えても解決できず動かない模様でした。

これを動かすにはどうやらlibpq-devが必要とのことだったので、ベースイメージをdebianに変更して、インストールしてみました。

# production
FROM debian:buster-slim AS production
RUN apt-get update
RUN apt-get install libpq-dev -y
COPY --from=build-stage /app/at-api/target/release/at-api .
CMD ["./at-api"]

これでようやくAPIがHeroku上で動きました! ちょっと記憶なんで間違ってるかもしれませんが、distrolessだとapt-getできずdebianのイメージにした気がします。

この辺はまたmuslをターゲットにしてalpineで動かせばもっとイメージサイズは小さくなるかもしれません。 が、これでもイメージサイズは105MBだったのでまぁ及第点ってことで満足することにしました。 (というかここまででかなり疲弊した・・・)

まとめ

開発用のステージ(1.35GB)と比べたら言わずもがな、RailsやLaravelのイメージは小さくしても400~500MBあたりが割と限界っぽいからそれに比べったらかなり小さくできました。 いろいろハマったものの、今回いろいろ学びはあったので今後はもう少しスムーズに構築できる気がします。

あとHerokuって、結構遅い印象だったのでactix-web(世界で今2番目に早いフレームワーク)使ってるとはいえレスポンス速度不安だったのですが、だいたいスリープしてなきゃ200msくらいで帰ってきてたので実用レベルで問題ないように思えました。 比較用にNodeで全く同じ仕様のAPIデプロイしてみて速度検証してみたいですが、まぁわざわざ作るのも面倒なので気が向いたらですかね・・・。

RustでAPI作ろうって需要がまだほぼない気もするんですが、もし同じようなことで困っている方がいたら、この記事が参考になれば幸いです。