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

Real World Exception Handling

奇数(odd)がだめなら偶数(even)にすればいいじゃない

スプレッドシートからデータをインポート処理を書いています。指定地点 s から指定された h[:x], h[:y] の座標分、セルを切り取って配列(matrix) とハッシュ(kv_pair_matrix) のどちらからでもデータを利用可能にしてエクスポート処理につなげようと考えていました。

配列からハッシュへの変換に self[*key_and_value] -> Hash を利用したところ、配列の個数 (x - s) が奇数だとハッシュに変換するときに odd number of arguments for Hash (ArgumentError) の例外が発生しました。言われてみればその通りなのですが、とりあえず、対象範囲の読み込みを済ませるために、この場合は例外処理としてお尻に nil を足して偶数にしてリトライさせてしまいます。奇数(odd)がだめなら偶数(even)に(以下略)の、アントワネット的発想です。あとで nil が入った配列をチェックする処理を書けばいいですし。

(Hash.[] (Ruby 3.1 リファレンスマニュアル))

(... snip ...)
    @matrix = CSV.read(@in, headers: false).drop(1).map do |a| a[h[:s], h[:x]] end

    @matrix.each do |e| e.each do |x| if x.is_a? String then x.delete!("\n") end end end
    @kv_pair_matrix = @matrix.map do |e|
      begin
        Hash[*e]
      rescue ArgumentError
        # This is for the error: `odd number of arguments for Hash (ArgumentError)`
        e.push(nil) # make it even now!
        retry
      end
    end
(... snip ...)

Real world ではこういったインターフェイス部分の violation はよく発生しがちですね (´・ω・`)

ansible の command module を使用して複数ホストのログを確認する

複数ホストへコマンドを一括実行したい時

複数ホストに一括してコマンドを実行したい時がたまにあります。繰り返す作業ではなく、ざっとログにエラーメッセージが出ていないかなどをアドホックに確認したい時など。これは ansible の command module を利用することで実現できます。

$ pyenv exec ansible -i inventory webservers -u ec2-user --become -K --become-user root -a 'bash -c "grep slow_flush_log_threshold /var/log/td-agent/td-agent.log"'

$ cat inventory
[webservers]
web001
web002
web003

ansible.builtin.command の他にも ansible.builtin.shell モジュールもあります。パイプやリダイレクトはじめちょっと凝ったことをやりたい場合はこちらのほうがいいかもしれません。

So far all our examples have used the default ‘command’ module. To use a different module, 
pass -m for module name. For example, to use the ansible.builtin.shell module:

$ ansible raleigh -m ansible.builtin.shell -a 'echo $TERM'

https://docs.ansible.com/ansible/latest/user_guide/intro_adhoc.html

同様の作業(run commands on multiple hosts via ssh) を昔は pdshcapistranocap shell を使用して実行していました。対象ホストが3桁を超えるとさすがに煩雑になりますが、問題解決にあたってログの標本を抽出して確認したい場合なのには有用だと思います。

参考情報

ひとつの入力語に対して複数カラムを対象にして検索を行い結果をまとめる

「空気を読む」検索

Rails の検索には Ransack が重用されます。Ransack では基本的にモデルの単一カラムに対して cont(contain) や eq(equal) といった条件で検索が可能です。この場合、ユーザーがひとつの用語で複数のカラムを検索したい場合、別々の入力フォームで複数回の入力を求めることになります。ここで、ひとつの検索語で複数カラムを一気に実行できたらどうでしょうか。これはいわゆる「空気を読む」検索と言えるかもしれません。

ゲームでの例を挙げます。例えば「龍飛」という入力をして検索をするユーザーはアイテム名「龍飛剣」を探しているのかもしれませんし、スキル名「龍飛の心得」を探しているのかもしれません。もしくは両方かもしれません。

Ransack でこの「空気を読む」検索を実現するためには入力語に対してモデルの複数カラムで検索を行い、結果の集約(と重複排除やソート)を行う必要があります。ここで Ruby の Runnable Object(Proc) を使用すると便利です。この実装で The Well-Grounded Rubyist, Third Edition の Chapter 14 で紹介されている Callable and runnable objects をはじめて有効活用できた気がします。

class EndpointsController < ApplicationController
  def search
    @input = params[:input]

    if (@input.empty?) then
      @input = "nothing"
    end

    @items = Item.search(@input)
    @skills = Skill.search(@input)

    @outcome = empty?([@items, @skills])
  end

  def empty?(arg)
    (... snip ...)
  end
class Item < ApplicationRecord

  class << self

    def search(arg)
      result = {}
      result[:by_name] = search_by_name(arg)
      result[:by_description] = search_by_description(arg)
      result[:by_parameter] = search_by_parameter(arg)
      result
    end

    def search_by_name(arg)
      p = Proc.new do |x|
        { name: x.name, level: x.level, description: x.description,
          rarity: x.rarity.name, skills: x.skills.map(&:name) }
      end
      Item.ransack(name_cont: arg).result.map(&p) end
    end

    def search_by_description(arg)
      (... snip ...)
    end

(... snip ...)

参考情報

www.manning.com

AWS CDK で EC2 を構築する時の地雷処理

古い AWS アカウントの呪い(?)

私の AWS アカウントでは Tokyo リージョン(ap-northeast-1) の Availability Zone の a が使用できません。使用できるのは b と c だけです。これはアカウントを作成した時期(2011年)に関係しているようです。比較的新しい時期にアカウントを作成した人は Availability Zone の a が利用可能なようです。

この初期勢にまつわるアカウント呪い(?)によって、公開されている CFn や CDK のサンプルで「他の人のアカウント(新しめ)では動く」のに「自分のアカウント(古め)では動かない(古めのアカウント)」ことが稀によくあります。

解決方法として AWS CDK のドキュメント Control over availability zones を参照したところ、コンストラクタが使用可能な Availability Zone の getter を設定してこのアカウントの呪いを教えてあげる必要があることが判明しました。

$ basename $PWD
aws-cdk-examples

$ git diff
diff --git a/typescript/ec2-instance/lib/ec2-cdk-stack.ts b/typescript/ec2-instance/lib/ec2-cdk-stack.ts
index 4cfdec0..bc5b5f9 100644
--- a/typescript/ec2-instance/lib/ec2-cdk-stack.ts
+++ b/typescript/ec2-instance/lib/ec2-cdk-stack.ts
@@ -45,13 +45,13 @@ export class Ec2CdkStack extends cdk.Stack {
     // Use Latest Amazon Linux Image - CPU Type ARM64
     const ami = new ec2.AmazonLinuxImage({
       generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2,
-      cpuType: ec2.AmazonLinuxCpuType.ARM_64
+      cpuType: ec2.AmazonLinuxCpuType.X86_64
     });

     // Create the instance using the Security Group, AMI, and KeyPair defined in the VPC created
     const ec2Instance = new ec2.Instance(this, 'Instance', {
       vpc,
-      instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.MICRO),
+      instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.NANO),
       machineImage: ami,
       securityGroup: securityGroup,
       // keyName: key.keyPairName,
@@ -77,4 +77,9 @@ export class Ec2CdkStack extends cdk.Stack {
     new cdk.CfnOutput(this, 'Download Key Command', { value: 'aws secretsmanager get-secret-value --secret-id ec2-ssh-key/cdk-keypair/private --query SecretString --output text > cdk-key.pem && chmod 400 cdk-key.pem' })
     new cdk.CfnOutput(this, 'ssh command', { value: 'ssh -i cdk-key.pem -o IdentitiesOnly=yes ec2-user@' + ec2Instance.instancePublicIp })
   }
+
+  get availabilityZones(): string[] {
+    return ['ap-northeast-1b', 'ap-northeast-1c'];
+  }
+
 }

Getting started with Amazon EKS の地雷処理 もそうですが、Availability Zone 関係は地雷が多い印象です。

AWS CDK の印象

編集 > 実行のサイクルが自動化される watch 機能 や try&error のサイクルを早める --hotswap オプション があります。API の動作確認と実装作業を並行して進められ 各リソースのテスト も可能です。

Imperative approach な IaC の急先鋒として

個人的に Declarative approach(what) な CloudFormationImperative approach(how) な CDK を比べると後者は YAML から解放され、「書いていても鬱にならない」 IaC だなと思いました(高度に抽象化されていてやりたいことの書き方が見つからないなど短所もありますが...)

参考情報

Tenets of SRE を Github Issues/Pull Requests のラベルにする

Tenets of SRE を Github Issues/Pull Requests のラベルにする

Github にデフォルトで用意されているラベル(bug, duplicate, enhancement, ...) はアプリ開発向けです。インフラ開発向けの課題管理には SRE Book で定義されている Tenets of SRE(availability, latency, performance, efficiency, change management, monitoring, emergency response, and capacity planning) をラベルにすると管理しやすいと思います。どのラベルでの課題が最も多いのかも、意思決定に活用できます。

SRE(= Site Reliability Engineering) という言葉を生み出した Benjamin Treynor Sloss 自身が こちら で SRE Book を引用しつつ各 tenets を分解してわかりやすく説明しています。例えば、provisioning を change management と capacity planning と結びつけて説明するだけでなく、管理コスト/調達時間がかかる capacity (expensive と表現) と対比して「必要な時に素早く行えること」と定義しています。 その他 demand forecasting を organic growth と inorganic growth に分類して予測するなど各 tenet を実際にどうやって実装していくかについてヒントとなるものが多いのではないでしょうか。

参考情報