docker buildx でマルチプラットフォームのイメージを作成する

いつのまにかビルド時にランタイム時のターゲットプラットフォームを選択できるようになっていた

昨今は ARM 系(graviton) の EC2 を使用することが多いので、これは助かります。:-)

➜ cat Dockerfile
# syntax=docker/dockerfile:1
FROM alpine:3.16
RUN apk add curl

➜ docker buildx build --platform linux/amd64 -t sample:amd64 .
(... snip ...)
➜ docker buildx build --platform linux/arm64 -t sample:arm64 .
(... snip ...)

➜ docker images | rg sample
sample                                                      arm64     92ff9bc76d35   6 minutes ago   9.88MB
sample                                                      amd64     66864ae4d60a   7 minutes ago   10.2MB

➜ docker inspect 66864ae4d60a --format='{{.Os}}/{{.Architecture}}'
linux/amd64

➜ docker inspect 92ff9bc76d35 --format='{{.Os}}/{{.Architecture}}'
linux/arm64
➜ docker buildx ls
NAME/NODE       DRIVER/ENDPOINT STATUS  BUILDKIT PLATFORMS
default *       docker
  default       default         running 20.10.24 linux/amd64, linux/arm64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/arm/v7, linux/arm/v6
desktop-linux   docker
  desktop-linux desktop-linux   running 20.10.24 linux/amd64, linux/arm64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/arm/v7, linux/arm/v6

macOS Ventura へ homebrew 経由で docker desktop for Mac をインストールする場合は順番に注意

macOS Ventura 13.3.1 での docker desktop for Mac は 1. cask 経由で GUI などのコンポーネントを先行してインストールしてから 2. コマンドツールの docker をインストールする必要があります。

➜ brew install --cask docker
➜ brew install docker
➜ brew install docker-buildx
➜ rm '/opt/homebrew/etc/bash_completion.d/docker'
➜ brew link --overwrite docker 
➜ docker --version
Docker version 20.10.24, build 297e128

cask を先行させずコマンドラインツールの docker を先にインストールすると 23.0.0 がインストールされてしまいます。その場合 docker image build 時に以下のようなメッセージで docker-buildx のインストールを要求されます。将来的には docker-buildx は必須のコンポーネントになってくるようです。

DEPRECATED: The legacy builder is deprecated and will be removed in a future release.
            Install the buildx component to build images with BuildKit:
            https://docs.docker.com/go/buildx/

参考情報

フォームの入力を一部記憶してユーザーの入力作業を省力化する

英語レッスンで知らない単語がひとつ以上出てきた場合

Rails で個人用の英単語帳アプリを開発しています。開発環境は Rails 7.0.4 + Ruby 3.2.2 です。

オンライン英会話レッスンではインターネットの記事を教材にすることが多いです。その場合、同記事中に出現する複数の英単語を連続で単語帳に登録することになります。復習時に使用されていた文脈を辿れるようにするため、登録時にその単語の「出自(source)」となる記事の URL を保存するようにしています。

1 つ目の単語の登録時のフォーム画面

2 つ目の単語の登録時のフォーム画面(URL は入力する必要がない)

ユーザーが単語に出会った場所 入力例
レアジョブの教材の場合 https://www.rarejob.com/dna/2023/04/14/paris-aims-to-keep-olympians-cool-without-air-conditioners/
オフラインの小説や書籍の場合 Harry Potter and the Philosopher's Stone p.68

記事 URL や書籍のページ情報はどこに保存するべきか

HTTP はステートレスなので、どこかに情報(この場合は Source フィールドに入力する URL) を保存する必要があります。Rails では ActionDispatch::Session Module 配下のいくつかのクラスを 利用して保存先を選択できます。CookieStore, CasheStore, ActiveRecordStore, MemCacheStore のどれかを選択可能ですが、ここではデフォルトの CookieStore クラスを利用して Cookie に保存します。

RailsGuides > Action Controller Overview > 5. Session

5 Session

Your application has a session for each user in which you can store small amounts of data that will be persisted between requests. The session is only available in the controller and the view and can use one of several of different storage mechanisms:

  • ActionDispatch::Session::CookieStore - Stores everything on the client.
  • ActionDispatch::Session::CacheStore - Stores the data in the Rails cache.
  • ActionDispatch::Session::ActiveRecordStore - Stores the data in a database using Active Record (requires the activerecord-session_store gem).
  • ActionDispatch::Session::MemCacheStore - Stores the data in a memcached cluster (this is a legacy implementation; consider using CacheStore instead).

All session stores use a cookie to store a unique ID for each session (you must use a cookie, Rails will not allow you to pass the session ID in the URL as this is less secure).

ただ、今回の記事の URL や書籍のページ情報といった情報であれば CookieStore ではなく CacheStore を使うという選択肢もあるかもしれません。変更は config/initializers/assets.rb で Rails.application.config.session_store でクラスを指定するだけで、コード上の session (ハッシュのようにも見えるがインスタンスメソッド) は変わらず内部で切り替わるようです。このへんはフレームワーク様々でしょうか。

If your user sessions don't store critical data or don't need to be around for long periods (for instance if you just use the flash for messaging), you can consider using ActionDispatch::Session::CacheStore.

なお、JavaScript を使う場合は localStorage への保存も選択肢にあがりそうです。localStorage は セキュリティやパフォーマンス上からは使用を控えるべき ですが、今回のようなセンシティブではない情報では検討してもよさそうです。

よりよい実装を求めて

同じことを JavaScript やフレームワークの機能を使ってもっとスマートに実装できる方法がありそうですが... フロントエンドのスキルが低いためこのような実装をしています (^_^;

参考情報

Ec2 Instance Connect にはリージョンごとの特定の IP 範囲からの接続をセキュリティグループで許可する必要がある

AWS Management Console からブラウザベースのターミナルで EC2 に接続する

AWS Management Console 内から任意の EC2 インスタンスに SSH する機能が EC2 Instance Connect です。CloudShell 同様、ブラウザベースのターミナルで接続することになります。EC2 および IAM の設定以外にも Security Group の Inbound Rules を設定する必要があるのですが、公開されているドキュメントからはやや辿りにくいので、紹介します。

ドキュメント によると 4 つのタスクがありますが、すでに公開鍵/秘密鍵のペアで EC2 に接続できる状態であれば Task1 のネットワークアクセスは問題ないはずで、Task 2 の ec2-instance-connnect パッケージと sshd 設定も Amazon Linux 2023 であればデフォルトで設定済みなので必要ありません。Task 3 の pip install ec2instanceconnectcli は Optional なので必要ありません。

問題は Task 4 でインスタンス ID ごともしくはインスタンス全体に "Action": "ec2-instance-connect:SendSSHPublicKey" を許可する必要があります。ここでは AWS CloudShell を使用して awscli を実行しました(なお CloudShell 上の vi でコピー&ペーストはインデントがおかしくなるので json はローカルマシンで編集してアップロードしています (^_^; )

[cloudshell-user@ip-10-12-34-56 ~]$ aws iam create-policy --policy-name Ec2InstanceConnectAccess --policy-document file://./Ec2InstanceConnectAccess.json

インスタンスIDごとにポリシーを設定している場合、別のインスタンスへの接続許可を与えるには配列を修正した後にポリシーを更新する必要があります(初回の新規作成は create-policy で 2 回目の更新からは create-policy-version)

[cloudshell-user@ip-10-12-34-56 ~]$ aws iam create-policy-version --policy-arn arn:aws:iam::__your_account_id__:policy/Ec2InstanceConnectAccess --policy-document file://./Ec2InstanceConnectAccess.json

ドキュメントには上記 4 つのタスクしか掲載されていませんが、これだけではまだ接続できず、Security Group の Inbound Rules にリージョンごとの特定の IP 範囲を指定してあげる必要があります。これは トラブルシューティング から確認できます。

参考情報

ゲストユーザ用のデータを実データから用意する

SignUp 以前のユーザーに、実際に「動いている様子」を体感してもらう

Rails で個人用の英単語帳アプリを開発しています。開発環境は Rails 7.0.4 + Ruby 3.2.2 です。

ごくごく限られた知り合いに実際にアプリを触ってもらってフィードバックをもらうために、ゲストユーザを作成することにしました。アカウントは guest@example.com です。

アプリの性質上、一定量のデータをストックした時に効果が実感できる機能があります。このため、ゲストユーザ用のアカウントにある程度の実データを入れておくことで潜在的なユーザにアプリをよりよく知ってもらうことができると考えています。一定量のデータがあってはじめて体感できる要素を以下に挙げます。

  • 機能
    • 「今日学んだ単語」「最近1週間で学んだ英単語」「最近1ヶ月で学んだ英単語」
    • ページネーションの UI
    • 検索(対象、範囲、パフォーマンス)
  • パフォーマンス

ゲストユーザでのサービス体験のもう一つの目的は登録に関連した心理的抵抗を減らすことです。メールアドレスや OAuth アカウントを必要としないので、セキュリティ上の不安を払拭できます。まずはサービスを体験してもらって、気に入ったら登録すればいい「登録の自由」を確保することで、本当にサービスを必要とするユーザーのみに登録してもらうことができるのではないかと考えています。

sqlite3 で自分の英単語データをゲストユーザ用にコピーする

自分自身がサービスの最初のユーザであるので、実データは自分の英単語帳を使用します (^_^; 自分の user_id が 1 でゲストユーザの user_id が 6 である場合、CREATE TEMPORARY TABLE で作成した一時的なテーブルを経由して user_id:1 が持つレコードを user_id:6 にコピーしています。

実データのコピー元を user_id:1, コピー先を user_id: 6 とする

irb(main):001:0> User.all
  User Load (0.6ms)  SELECT "users".* FROM "users"
=>                                                              
[#<User:0x000000010d2aa630 id: 1, firebase_local_id: "ABC....", created_at: Fri, 28 Oct 2022 15:33:11.535747000 UTC +00:00, updated_at: Sun, 09 Apr 2023 14:51:21.497815000 UTC +00:00>,
 #<User:0x000000010d8ad168 id: 6, firebase_local_id: "XYZ...", created_at: Sun, 05 Feb 2023 15:15:26.484686000 UTC +00:00, updated_at: Sun, 05 Feb 2023 15:15:26.484686000 UTC +00:00>]
irb(main):002:0> 

CREATE TEMPORARY TABLE で作成した一時的なテーブルを経由して user_id:1 が持つレコードを user_id:6 にコピーする

➜ sqlite3 development.sqlite3
SQLite version 3.39.5 2022-10-14 20:58:05
Enter ".help" for usage hints.

sqlite> .tables
ar_internal_metadata  schema_migrations
flashcards            users

sqlite> CREATE TEMPORARY TABLE ephemeral AS SELECT * FROM flashcards;
sqlite> .tables
ar_internal_metadata  schema_migrations     users
flashcards            temp.ephemeral

sqlite> UPDATE temp.ephemeral set user_id = 6;

sqlite> SELECT user_id, COUNT(*) FROM flashcards GROUP BY user_id;
1|273

sqlite> SELECT user_id, COUNT(*) FROM temp.ephemeral GROUP BY user_id;
6|273

sqlite> INSERT INTO flashcards (item, example, source, created_at, updated_at, user_id) SELECT item, example, source, created_at, updated_at, user_id FROM temp.ephemeral;

sqlite> SELECT user_id, COUNT(*) FROM flashcards GROUP BY user_id;
1|273
6|273

sqlite> SELECT user_id, COUNT(*) FROM temp.ephemeral GROUP BY user_id;
6|273

docker buildx build を使用して Rails の master.key をコンテナイメージから除外する

master.key とは

Rails の master.key とは認証情報を暗号化する時に使用されるキーファイルです。rails credentials:edit で認証情報を credentials.yml.enc に暗号化(ENCrypt) して保存する時に使用されます。認証機能に Firebase authentication を利用している場合、Firebase の API キーを格納している方が多いのではないでしょうか。

10.1 Custom Credentials

Rails stores secrets in config/credentials.yml.enc, which is encrypted and hence cannot be edited directly. Rails uses config/master.key or alternatively looks for the environment variable ENV["RAILS_MASTER_KEY"] to encrypt the credentials file. Because the credentials file is encrypted, it can be stored in version control, as long as the master key is kept safe.

master.key は .dockerignore でコンテナイメージに入れないようにしておく

私の場合 fly.io のデプロイ手順で Dockerfile を生成したので、master.key はデフォルトで除外する設定になっていました。スクラッチから作る時は Github などから 参考となる .dockerignore をいくつか見て回るのがよいかもしれません。

➜ grep master.key .dockerignore 
config/master.key

master.key がない場合に Rails を失敗させる config.require_master_key 設定

config.require_master_key を true に設定することで、キーファイル master.key もしくは環境変数 RAILS_MASTER_KEY が存在せず Rails が認証情報を取得できない場合に、Rails コマンドを異常終了させることができます。

config/environments/development.rb

  # Causes the app to not boot if a master key hasn't been made available through
  # ENV["RAILS_MASTER_KEY"] or the config/master.key file.
  config.require_master_key = true

コンテイメージビルド失敗: 抱える矛盾

この設定を有効にした場合、認証情報を取得できない場合は rails db:migrate も失敗するようになります。コンテナイメージを作成する場合に必要となる rails db:migrate が失敗するのでこのままではコンテナイメージを作成することができません。

Dockerfile

RUN bundle install
RUN bundle exec rails db:migrate
➜ docker buildx build -t camelot:latest .
[+] Building 27.8s (13/14)
 => [internal] load build definition from Dockerfile                                                                                                 0.0s
 => => transferring dockerfile: 626B                                                                                                                 0.0s

(... snip ...)

 => [ 8/10] RUN bundle install                                                                                                                      23.8s
 => ERROR [ 9/10] RUN bundle exec rails db:migrate                                                                                                   2.8s
------
 > [ 9/10] RUN bundle exec rails db:migrate:
#0 2.753 Missing encryption key to decrypt file with. Ask your team for your master key and write it to /camelot/config/master.key or put it in the ENV['RAILS_MASTER_KEY'].
------
ERROR: failed to solve: executor failed running [/bin/sh -c bundle exec rails db:migrate]: exit code: 1

➜ 

コンテナイメージビルド成功: docker buildx build の Secret to expose the build (--secret) を利用して矛盾を解決する

コンテナイメージに master.key は含めたくないがビルドには必要、という矛盾を解決するために docker buildx build が提供している機能が Secret to expose the build (--secret) です。この機能を利用することで、master.key をビルド時に「一時的に」渡すことが可能になります。

Dockerfile 側で「一時的に」渡すがイメージには含めたくないファイルを RUN --mount ... で指定し、コマンド側で docker buildx build --secret ... で指定して受け渡しをそれぞれ設定します。

Dockerfile

RUN bundle install
RUN --mount=type=secret,id=master_key,target=config/master.key,required=true bundle exec rails db:migrate
➜ docker buildx build --secret id=master_key,src=./config/master.key -t camelot:latest .
[+] Building 29.4s (15/15) FINISHED
 => [internal] load build definition from Dockerfile                                                                                                 0.0s
 => => transferring dockerfile: 626B                                                                                                                 0.0s

(... snip ...)

 => [ 8/10] RUN bundle install                                                                                                                      23.1s
 => [ 9/10] RUN --mount=type=secret,id=master_key,target=config/master.key,required=true bundle exec rails db:migrate                                3.6s
 => [10/10] COPY ./db/development.sqlite3 /camelot/db/                                                                                               0.0s
 => exporting to image                                                                                                                               1.7s
 => => exporting layers                                                                                                                              1.6s
 => => writing image sha256:900504dca3fe2761332f919e0526baa016c053722466844412efdacf0a371d37                                                         0.0s
 => => naming to docker.io/library/camelot:latest                                                                                                    0.0s

➜ 

Secret to expose the build (--secret) 機能を使用することで、先ほどはコンテナイメージビルド時に失敗した rails db:migrate が「一時的に」ファイルを渡すことで無事成功するようになりました。ビルドしたイメージの中にファイルは含まれていません。

➜ docker run -it camelot:latest ls -l config/master.key
ls: cannot access 'config/master.key': No such file or directory

ランタイム時に master.key を環境変数でコンテナに渡す: ローカルホストの場合

ビルドタイム時には「一時的に」渡すことができましたが、master.key がコンテナイメージに含まれていないため、ランタイム時は環境変数を使用して渡すことが必要になります。ローカルマシンであれば -e オプションが使えます。

➜ docker run -p 3000:3000 --name camelot-on-docker -e RAILS_MASTER_KEY='__THIS_IS_SECRET__' -it camelot:latest

ランタイム時に master.key を環境変数でコンテナに渡す: AWS AppRunner の場合

AWS AppRunner の場合は AWS Management Console から App Runner > Service > [Service name] > Configuration > Configure Service > Service Settings > Environment variables から設定可能です。master.key の内容を Plain text として貼り付けてもいいですが Secrets Manager や SSM Parameter Store 経由で設定するとより安全かもしれません。

参考情報

https://guides.rubyonrails.org から特定の Rails のバージョンに絞って検索する場合、site 検索が便利です。

AWS AppRunner で Rails アプリを動かす場合のベストプラクティス

AppRunner から割り当てられるドメインと DNS rebinding atttacks

AppRunner にアプリをデプロイした場合、アプリに割り当てられるドメインは ランダム文字列.リージョン.awsapprunner.com になります。このドメインは Rails の DNS rebinding attacks を防ぐためのガードレールにひっかかってしまうため、設定で解除する必要があります。

config/environments/your_environment.rb

AppRunner はお手軽さとコストの面で優秀

以下の記事の比較で「AppRunner はスケールをゼロにできる(Scaling down to 0)」が評価されているのは Action: Pause がメニューにあるからです。Pause 中は課金はされないので、動かす場面が限られている場合、この点で ECS よりも費用を抑えられます。

cloudonaut.io

コンテナベースにしておいたほうがいろいろと楽

元となるレポジトリに ECR(コンテナレジストリ) ではなく Github(ソースコードレポジトリ) も指定できますが、その場合は AppRunner がサポートしているランタイムのバージョンに合わせる必要があります。Ruby だと現在サポートされているのは 3.1.2 です。コンテナベースで運用するほうが、ランタイムのバージョンを気にする必要がなく fly.io や ECS/EKS にも移行しやすいです。

他の SaaS とどう使い分けていくべきか

個人開発アプリを完全無料で公開したいのであれば fly.io ですが、社内用にアクセスをパブリックではなく VPC 内に制限して 公開する場合などは AppRunner のほうがよさそうです。また RDS など既存の AWS のデータストア/リソースを活用できる点も利点となります。CloudWatch を使えるところも嬉しいかも (^_^;

参考情報

Fly.io のファーストインプレッション

Heroku の料金体系改定

Heroku’s Next Chapter | Heroku でアナウンスされている通り 11/28 から heroku の料金体系が改定されます。無料枠の FreeHobby プランは 11/28 で廃止されます。そのため、多くのエンジニアが無料枠を求めて fly.io, railway, render などの PaaS を候補に引っ越し準備をはじめているようです。

個人的には heroku の 新プランEcoBasic もかなり良心的だと思います。無料で使えなくなるのが残念な反面、価値のあるものにはきちんと対価を払いたいとも考えています。また、数ドルを惜しんで各 PaaS のコマンドや手順で自分のツールボックを書き換えるのも少し手間だな... といったようにベンダーロックイン脳になっているところもあります。(^_^;

今まで他の PaaS を使ったことがなかったため、Heroku への「刷り込み」も少なからずあります。このバイアスを是正するためには比較対象となる PaaS を使ってみるのがよいと考えました。今回は CDN とは別軸での Edge Computing の考え方と LiteFS に積極的な姿勢で、 Fly.io を使ってみます。

Fly.io の特徴

Fly.io を使って個人の Rails アプリをデプロイしてみました。まだ不明な部分も多いですが概要と気になった特徴を以下にまとめておきます。

  • デプロイコマンド(fly launch + fly deploy)すると Dockerfile の作成とイメージビルドが行われ、app 用と db 用(postgresql) のコンテナが 2 つ作成される。
  • db 用のコンテナの内部アドレスとパスワードは提供されるが外部アドレスは提供されない。ただし fly proxyで db 用コンテナまで ssh tunnel を開通することができる。
  • drop database や drop table ができない ため sequel での流し込みはできない? db はその都度作り直す流儀らしい。
  • 最初から grafana で各コンテナ用に詳細なメトリクスが用意されているのはよい。

作成されるファイル

how can I drop and re-create a production database attached to my app? - Questions / Help - Fly.io

$ git bn deploy/add-flyio
.dockerignore
Dockerfile
fly.toml
journal/20221104165758_add_flyio.md
journal/commands
lib/tasks/fly.rake

$ grep bn ~/.gitconfig
  bn = "!f() { top=$(git log --oneline --walk-reflogs ${1} | head -1 | awk '{print $1}'); bottom=$(git log --oneline --walk-reflogs ${1} | tail -1 | awk '{print $1}'); git diff ${bottom}^ ${top} --name-only; }; f"
  • grafana

開発環境の sqlite3@mac とステージング環境の postgresql@heroku をデータベースツールキット sequel とカスタム rake タスクで同期する

sqlite3 DB(on Mac) と postgresql DB(on Heroku) を同期する

私の場合 Rails の開発環境とステージング環境をそれぞれ以下のように構築しています。開発に伴う動作確認や実際の英単語の入力は開発環境(Mac mini)で行うことがほとんどなので、自然と両環境での DB に差分が発生します。

環境 ホスト DB
開発環境 Mac mini sqlite3
ステージング環境 Heroku postgresql

ここではデータベースツールキットの sequel とカスタム rake タスクを使用して両環境の DB を同期する方法を紹介します。同様の環境で開発している方の参考になれば幸いです。

実装内容

rails コマンド(rake タスク) に独自のネームスペースとして custom を用意し、その配下に db:sync タスクを定義します。タスク自体は heroku コマンドと sequel を組み合わせた単純なものです。

$ bundle exec rails -T | grep custom
rails custom:db:sync                     # Synchronize db between development and staging

lib/tasks/custom.rake

namespace :custom do
  namespace :db do
    desc "Synchronize db between development and staging"
    task :sync do
      app = `heroku apps | sed '/^$/d' | tail -1`.chomp
      postgresql_credentials_url = `heroku pg:credentials:url | tail -1`

      sh "heroku pg:reset -a #{app} --confirm #{app}"
      sh "bundle exec sequel -C sqlite://db/development.sqlite3 #{postgresql_credentials_url}"
      sh "heroku run rake db:migrate"
    end
  end
end

Gemfile

group :development do
(... snip ...)

  # Use the database toolkit for ruby [https://github.com/jeremyevans/sequel]
  gem "sequel"

(... snip ...)
end

group :staging do
  # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
  gem "pg", platforms: %i[ mri mingw x64_mingw ]
end

カスタム rake タスクから heroku コマンドと sequel を呼び出した様子

LiteFS

SQLite 推しなので LiteFS の今後に注目しています (^_^)

参考情報

github.com

Connecting to Heroku Postgres | Heroku Dev Center

SQLite3からPostgreSQLへのデータ移行

fly.io

Rails で Firebase Authentication を使う場合のユーザーモデルとテスト

Firebase Authentication を使う場合、ユーザーモデルとテストはどうする?

Rails で個人用の英単語帳アプリを開発しています。開発環境は Rails 7.0.4 + Ruby 3.1.2 です。

ユーザ認証に Firebase Authentication を導入すると、フォームに入力した email と password を Firebase Authentication API に渡して返り値を受け取るだけで認証が実装できます。認証のためにユーザーモデルを作成する必要はありません。

ただし、ユーザごとのデータを保存するテーブルの関連付けとしてユーザーモデルは必要になります。私の場合、英単語帳を flaschcards というテーブルに保存しているため、「誰が登録した英単語か」を識別するためにユーザーモデルを作成することにしました。ユーザーを作成するために外部の Firebase Authentication API を実行する必要があるため、(Devise 利用時など内部で完結する場合と違い)テストの方法も一工夫する必要が出てきました。

以下は Firebase Authentication を使った場合のユーザーモデルとテストの実装についてまとめたものです。同様の構成を考えている方の参考になれば幸いです。最期に Devise との個人的な比較と所感を載せています。

実装の流れ

大きな流れとしては以下になります。Firebase Authentication API を叩いた時に返ってくる localId (Firebase の管理画面上では User UID と表記される) をユーザーモデルに格納して利用します。

  1. FIrebase authentication を利用して認証機能を実装する(省略)
  2. ユーザーモデル(User)を作成する
  3. Firebase authentication の API で返される localId で User を作成する
  4. ユーザごとのデータを持つフォームを修正する(省略)
  5. テストを作成する(ユーザ作成のために外部の Firebase Authentication API の利用が必要ため、モデルやコントローラーのテストではなくシステムテストで行う)

ユーザーモデル(User)を作成する

$ bundle exec rails g model User firebase_local_id:string
$ bundle exec rails g migration AddUserIdToFlashcards user:references

app/models/user.rb

class User < ApplicationRecord
  has_many :flashcards
(... snip...)

app/models/flashcard.rb

class Flashcard < ApplicationRecord
  belongs_to :user
(... snip ...)

Firebase authentication の API で返される localId で User を作成する

app/controllers/home_controller.rb

class HomeController < ApplicationController
  before_action :set_user_data, only: %i[signup login]

  def signup
    uri = URI("https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=#{Rails.application.credentials.firebase_api_key}")
    response = Net::HTTP.post_form(uri, "email": @email, "password": @password)
    data = JSON.parse(response.body)
    session[:data] = data
    session[:firebase_local_id] = data["localId"]

    if response.is_a?(Net::HTTPSuccess)
      User.create(firebase_local_id: data["localId"])
      redirect_to flashcards_path, notice: "Signed up successfully"
    end
  end

  def login
(... snip ...)

app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  helper_method %i[current_user authenticate_user]

  def authenticate_user
    redirect_to home_login_path, notice: 'You must be logged in to view your data.' unless current_user
  end

  def current_user
    @current_user ||= session[:firebase_local_id]
    @current_user_id ||= User.find_by(firebase_local_id: session[:firebase_local_id]).id
  end
end

テストを作成する(ユーザ作成のために外部の Firebase Authentication API の利用が必要ため、モデルやコントローラーのテストではなくシステムテストで行う)

test/system/flashcards_test.rb

require "application_system_test_case"

class FlashcardsTest < ApplicationSystemTestCase
  setup do
    @user = FactoryBot.create(:user)
    @flashcard = FactoryBot.create(:flashcard)

    visit home_signup_url

    fill_in "email", with: "foo@example.com"
    fill_in "password", with: "abc123"

    click_on "Submit"

  end

(... snip ...)

Devise と Firebase Authentication の比較

個人的な結論

Firebase Authentication のほうが扱いやすいように感じました。理由としては API を叩くだけで完結するのと、自分で情報を持たないからです。Devise を利用したユーザーモデル(User)は内部にメールアドレス、暗号化されたパスワード、その他接続時の情報を抱えるようです。接続時の情報など(例: current_sign_in_ip)は安易に流出しないようにデフォルトでは Trackable が無効になっています。こうしたガードレールがあるので、基本的には安全だと思いますが Devise の実装をきちんと理解していない限り、ブラックボックスの部分が残ってしまうように思います。内部に情報を持たず API だけで完結する Firebase のほうが扱いやすい、と感じたのはこうした理由からです。

個人的な比較

Devise Firebase Authentication
透明性 きちんと理解していないとブラックボックスな点が残る 基本的に API を使うだけなので単純明快に感じる
情報の見つけやすさ Deviseのドキュメントやネットの記事はあふれている 記事は多くないのでモデルやテストは自作する必要がある(簡単)
心理的安全性 内部に情報を抱えるので不安な点が残る(理解していれば大丈夫) 内部に情報を抱えないので不安にならない
ユーザーモデル(User)の大きさ 肥大化しがち 使い方にもよるが他テーブルへユーザIDを関連付けるだけなら Firebase authentication が返す locaIId だけでいい

比較時のスクリーンショット

Devise

Firebase Authentication

参考情報

Firebase authentication

Rails7 で Firebase authentication を使う方法

www.youtube.com

Devise と Firebase Authentication のどちらを使うべきか?

unbuffer と tee の組み合わせで標準出力とログのカラーを保持する

tee に渡すとカラー出力が打ち消されて色なしになってしまう

開発環境の構築などの定型処理は Rakefile でタスクを定義して、バッチ処理として実行しています。 この際にログも保持するようにしているのですが、単純に tee に渡すだけでは標準出力もログもカラー出力が打ち消されてしまうことに気がつきました。

$ rake init 2>&1 | tee -a init.log

これを解決するためには unbuffer を利用すればよいことがわかりました。本来の unbuffer の使い方とは少し違いますが... ログもエスケープシーケンスつきの色つきで保存されているので、less -R で色つきのまま確認することができます。

$ unbuffer rake init 2>&1 | tee -a init.log

参考情報

superuser.com