Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: anyway_config gemでRubyの設定を正しく整理しよう(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Rails: anyway_config gemでRubyの設定を正しく整理しよう(翻訳)

よく育ったRubyプロジェクト、特にRailsプロジェクトにおける「コンフィグ」「各種設定」「秘密情報」「credential」「環境変数」というものをそろそろ真面目に考えるときがやってまいりました。本記事では、お節介ついでにAnyway Configというgemもご紹介します。Evil Martiansで使われているこのgemは私が設計したもので、プロジェクトの各種設定を正常に保ちます。皆さまを「ENV地獄」から救済するお役に立つことを願っています。

コンフィグは、コードベースの健康状態を最も如実に表すマーカーのひとつです。アプリケーションが成長して成熟すればするほど、APIキーだのenvファイルだのといった設定を扱うのが面倒になってきます。私がRailsConf 2019でお話しした「Terraforming legacy Rails applications」というセッションで、いわゆる「ENV地獄」についても触れました。大規模Rubyアプリを扱ってきた読者の皆さまのほとんどが、何らかの形でENV地獄の因縁にまとわりつかれていると確信しています。

ENV Hell example

RailsConf 2019でお目にかけた「ENV地獄」

皆さんもENV地獄行きですか?

Railsアプリで最も広く使われている設定パターンは、すべての値を.envに保存して、アプリケーションの起動時に(dotenv gemやdotenv-rails gemなどを用いて)プロセス環境に読み込み、ENV["KEY"]でコードからアクセスできるようにするという手法です。

このパターン自身の由来は真っ当です。かの有名なTwelve Factorの原則にも『設定は環境に保存すべし』と述べられています。

しかしここでひとつシンプルな実験をしてみましょう。シェルを開いて.envのあるプロジェクトディレクトリのルートディレクトリに移動し、以下のコマンドを打ってください。

cat .env | grep '[^\s]' | wc -l
  52 # これは私たちが目にした成熟プロジェクトの平均の数値です

この数値はいったい何でしょう?あなたの数値が数十個のオーダーに達していたら、残念ながらあなたは今「ENV地獄」の真っ只中です。私たちもENV地獄にいたので、それはまあいいとしましょう。

ENV地獄にハマるとだいたい以下のようなことになります。

  • .envファイルが肥大化し、ほぼ理解不能になる
  • .env.sampleファイルの同期が取れなくなって、デバッグしづらい失敗がローカル開発中に発生するようになる
  • ENV外の世界を表すグローバルステートなので、デバッグ、特にテストがつらくなってくる
RailsアプリケーションにおけるENV利用の治安を保つために、私たちはLint/EnvというRuboCopのcopを書きました。グローバルステートを設定ファイルの外に漏らさないためにお使いいただけます。

こうした問題は、たいてい開発環境のセットアップ中に忍び寄ってきます。たとえばHerokuアプリのコンフィグは、数百個もの環境変数であふれがちです。以下を実行してクイックチェックできます。

$ heroku config -a legacy-project | wc -l

  131

今度は、皆さんのRailsプロジェクトの現状を取り急ぎ調査しましょう。

  1. プロジェクトを(bin/setupなどで)セットアップした直後にrails sで起動できますか?
  2. 続いてテストを実行するとパスしますか?
  3. アプリケーションのcredentialがない場合に、credentialの置き場所がすぐわかるようになっていますか?
  4. 設定に新しい値を追加するときのワークフローは明確になっていますか?
  5. サードパーティAPIトークンなどの固有の秘密情報は、アプリケーションのコードを変更せずに利用できるようになっていますか?

「いいえ」の数が多いほど、設定のアプローチを見直す作業をその分急ぐべきです


調査の方法については次をご覧ください。

設定パラメータは「秘密情報」と「設定」の2種類ある

大量の卵(設定パラメータ)を1つの籠(.envファイル)に押し込める方法には、ひとつ大きな欠点があります。つまり、その値がそもそも何であるかという「本質」の情報が失われてしまうのです。機密情報と機密でない情報や、ビジネスロジック情報とフレームワーク関連の情報がまぜこぜになってしまいます。

設定値のトラッキングを改善するために、「設定」と「秘密情報」を心の中で分けます。両者の具体的な違いについて見ていきましょう。

設定は「内部に属する」

WEB_CONCURRENCYRAILS_MAX_THREADSRAILS_SERVE_STATIC_FILESといった設定は、技術上の特性やフレームワーク設定を改変します。これらを「フレームワーク設定」と呼ぶことにします。ここで言うフレームワークはRailsに限らず、PumaやSidekiqなどスタック上のあらゆるものを指します。

もうひとつは「アプリケーション設定」です。これは、グローバルな機能をオンオフするCHAT_ENABLED=1や、開発ツールを有効にするGRAPHIQL_ENABLED=1フラグなどが該当します。

理想的な設定とは、以下の要素を満たすものです。

  • development環境やtest環境の常識的なデフォルト設定は必ず完了していなければならない
  • production環境では必要に応じてそれらの設定を環境変数で上書きできる(HerokuではRAILS_SERVE_STATIC_FILESが自動的に設定されます)
  • 設定をプレーンテキストでリポジトリに保存できる
  • 必要なら環境固有の設定ファイル(config/environments/development.rb)にハードコードすることも可能

秘密情報は「外部に属する」

秘密情報は、他のシステムやサービスとのやりとりで必要な情報を指します。秘密情報はさらに以下のの2種類に分けられます。

  • システム秘密情報
  • サービス秘密情報
システム秘密情報
インフラの必要不可欠な部分にアクセスするためのcredential
(データベースDATABASE_URL、キャッシュサーバーREDIS_URL、その他)

たとえばHerokuのアドオンは、productionで使うそれらの値を設定するので、気にする必要があるのはdevelopment環境だけです(私たちの場合は、そうした設定をdocker-compose.ymlに保存しています)。

サービス秘密情報
サードパーティサービスのcredential
(APIキー、トークン、その他)

秘密情報に該当する情報であっても、必ずしも隠さなければならないとは限りません。APIの「ホスト名」や「上限値」のことを考えればおわかりでしょう。

システム秘密情報」と「サービス秘密情報」には、技術的に重大な違いがひとつあります。


システム秘密情報がない場合や無効な場合、アプリケーションは必ず起動に失敗しなければなりません


これは、サービス秘密情報の場合には必ずしも真ではありません。サービス秘密情報は、アプリケーションが起動するために必要不可欠な設定とは限らないからです。

Active Storageの例

たとえば、ファイルアップロード機能を実現するのにActive StorageとAWS S3バックエンドを用いることに決めたとしましょう。以下はmasterブランチにマージされたstorage.ymlと設定ファイルです(なおコードは実際のコミットからの引用です)。

# config/application.rb
config.active_storage.service = :s3

# config/environments/test.rb
config.active_storage.service = :local
# config/storage.yml
local:
  service: Disk
  root: <%= Rails.root.join("tmp/storage") %>

s3:
  service: S3
  access_key_id:
  secret_access_key:
  region: us-east-1
  bucket: ENV["AWS_BUCKET"]

こうすることで、AWS関連の環境変数が設定されてないチームメンバーは、たとえdevelopment環境であってもアプリの実行は不可能になります。言ってみれば、私たちは玄関に人工的なバリアーを構築しています(たとえAWS SDKをローカルで直接利用することが必須でないとしてもです)。Active Storageでは、:local設定をtest環境のみならず、development環境でも利用できるようになっています。もし誰かが「自分のローカル環境をproductionになるべく近づけたいんです!」と言ったとしましょう。お気持ちはありがたいのですが、そんな厳しい要件が必要でしょうか?

ここで仮に、development環境で以下のように本物のAWSバケットを利用できる機能をオプトインとして導入したと想像してみましょう。

# config/application.rb
config.active_storage.service =
  if AWSConfig.storage_configured?
    $stdout.puts "Using :s3 service for Active Storage"
    :s3
  else
    :local
  end

おやおや、こんな見慣れない書き方をRailsで手軽に使えるものでしょうか。そもそもAWSConfigが何なのか、それがどんなふうに設定されているのかをどうやって知ればよいのでしょう。そこで、本記事の冒頭からチラ見せしていたAnyway Config gemの出番です。

Anyway Configの概要

今どきのRailsでは、環境変数に保存した設定データ以外にもさまざまな選択肢が使えます。config/initializersのパラメータを直接編集する方法、昔ながらのyamlファイル編集による方法、そしてRails 5.2からは暗号化済みcredentialを用いてソースコード管理に安全にチェックインもできます。

Rails 6.0では、環境ごとにcredentialを使い分ける機能も追加されました。なお、このgistを使えばそれらをRails 5.2にバックポートできます。

私たちEvil Martiansが今試している、設定における鉄の規律は以下のとおりです。

  • 重要な情報は、Railsのcredentialに保存する(環境ごとに*.encファイルを作る)
  • 重要でない情報は、名前を付けてyaml設定に保存する
  • あらゆる値を環境変数で上書きできるようにする
  • ローカル(development環境)の秘密情報や設定は、*.local.ymlcredentials/local.yml.encに保存する
  • 取り扱いに特別な注意を要するcredentialをチームメンバー全員で共有する必要がある場合は、暗号化されたストレージで集中管理する。Keybaseはこれを一手に引き受けます。

「選択肢が多すぎる問題」を避けるのは困難です。各開発者が思い思いの設定方法で、設定を片っ端からアプリにぶちこむと、開発者の認知能力にずしりと負担がのしかかります。そこで私たちは、ピュアなRubyインターフェイスを標準としてすべての設定に提供するツールを使うという手法にたどり着いたのです。


Anyway Config gemは、さまざまな場所に散在するデータを透過的に管理できます。さらに、コンフィグ専用のクラスを導入することで、設定をコードとは独立に保存します。
もうRails.credentialsだのRails.application.config_forだのENVを呼び出す必要はありません。必要なのはRubyのクラスを扱うことだけです。

このgemができるまでに長い話があります。当初は、私が最初に作ったInfluxerというgemから抽出しました。なおInfluxerはほとんどの場合ライブラリ(AnyCableなど)で使われてきました。最近リリースしたAnyway Config 2.0は、ここ数年Evil Martians社内のアプリケーション開発のユースケースから大いなるヒントを得ています。

それではActive Storageの例に戻って、Anyway Configでどんなふうに設定できるかを見ていきましょう。

RailsのGemfileにanyway_configを追加すると、新しいコンフィグクラスを作成する手軽なジェネレータにアクセスできるようになります。

$ rails generate anyway:config aws access_key_id secret_access_key region storage_bucket
    generate  anyway:install
       rails  generate anyway:install
      create  config/configs/application_config.rb
      append  .gitignore
      insert  config/application.rb
      create  config/configs/aws_config.rb
Would you like to generate a aws.yml file? (Y/n) n

ジェネレータを実行すると、以下の2つのファイルがプロジェクトに追加されます。

  • config/configs/application_config.rb: すべてのコンフィグクラスのベースクラス(存在しない場合にのみ作成される)
app/configsではなくconfig/configsを使っている理由は、Railsのオートロードやオートリロードに関連します。詳しくはREADMEの"Organizing configs"をご覧ください。
# ファイルを設定するための抽象ベースクラス
# 設定のデフォルトインスタンスを返す`instance`メソッドを提供する
#
# また、missingメソッドはすべてインスタンスに委譲されるので
# このクラス自身をシングルトンのコンフィグインターフェースとして使える
class ApplicationConfig < Anyway::Config
  class << self
    delegate_missing_to :instance

    def instance
      @instance ||= new
    end
  end
end
  • config/configs/aws_config.rb: AWS設定用のクラス
class AWSConfig < ApplicationConfig
  # attr_configはパラメータ設定用の
  # リーダーとライターを定義する
  attr_config :access_key_id, :secret_access_key,
              :region, :storage_bucket
end

なお、以下のようにconfig/initializers/inflections.rbに略語を追加することで、「AWS」という語をRailsのinflectorで認識できるよう設定する必要があります。

ActiveSupport::Inflector.inflections do |inflect|
  # ...
  inflect.acronym "AWS"
end

ジェネレータのプロンプトでyesを入力すると、config/aws.ymlファイルも追加されます。今は情報をプレーンテキストに保存したくないので、credentialを使うことにします。

作成したコンフィグクラスを編集してregion:にデフォルト値を追加し、#storage_configured?メソッドも追加します。

class AWSConfig < ApplicationConfig
  # ハッシュをひとつ渡すことでデフォルト値を指定できる
  attr_config :access_key_id, :secret_access_key,
              :storage_bucket, region: "us-east-1"

  def storage_configured?
    access_key_id.present? &&
      secret_access_key.present? &&
      storage_bucket.present?
  end
end

続いて、production用の値も渡せるようにする必要があります。credentialファイルを開いて以下の値を定義しましょう。

$ RAILS_MASTER_KEY=<production key> rails credentials:edit -e production

aws:
  access_key_id: "secret"
  secret_access_key: "very-very-secret"
  storage_bucket: "also-could-be-a-secret"

storage_bucketは、ユースケースによっては重要な情報の一部とみなす必要はないかもしれません。その場合は値をconfig/aws.ymlに設定できます。

# config/aws.yml
production:
  aws:
    storage_bucket: my-public-bucket

対応する環境変数に値を設定すれば、いつでも値をオーバーライドできます。

AWS_STORAGE_BUCKET=another-bucket rails s

ところで、私たちのコードでは設定値がどこにあるかを気にする必要がないことにお気づきでしょうか?コードが認識しているのはAWSConfigクラスだけです。このアプローチの主なメリットが、まさにこれです。

今後AWSをローカルで使うことが決まったら、config/aws.local.ymlに個人用のcredentialを書き込めば済みます。秘密情報を自分のコンピュータ上にプレーンテキストで保存するのがイヤな場合は、Railsのローカルcredentialを使えばよいのです。

rails credentials:edit -e local

Anyway Configの設定は、ローカルデータよりも優先して代入されます。

さらにうれしいオマケとして、あらゆる値の出どころをトラッキングできます。これは特にproduction環境で便利です。

AWSConfig.to_source_trace
# =>
# {
#  "access_key_id" => {value: "XYZ", source: {type: :credentials, store: "config/credentials/production.yml.enc"}},
#  "secret_access_key" => {value: "123KLM", source: {type: :credentials, store: "config/credentials/production.yml.enc"}},
#  "region" => {value: "us-east-1", source: {type: :defaults}},
#  "storage_bucket" => {value: "example-bucket", source: {type: :yml, path: "config/aws.yml"}}
#  }

ppで人間にとって読みやすい出力を得ることもできます。

pp AWSConfig.new
# =>
# #<AWSConfig
#  config_name="aws"
#  env_prefix="AWS"
#  values:
#    access_key_id => "XYZ" (type=credentials store=config/credentials/production.yml.enc)
#    secret_access_key => "123KLM" (type=credentials store=config/credentials/production.yml.enc)
#    region => "us-east-1" (type=defaults)
#    storage_bucket => "my-public-bucket" (type=yml path=config/aws.yml)

まとめると、Anyway Configでは以下を行えます。

  • さまざまなデータソースラッパーの代わりにコンフィグ用クラスを使える
  • ローカルの秘密情報や設定もサポートされている
  • 設定ファイルが分かれているので、単一の.envapplication.ymlに設定をパンパンに押し込める必要がない

Rubyアプリケーションが成長するに連れて、設定の管理はたちまち悪夢と化します。設定ファイルの編成方法や、さまざまな種類の値の扱い方に注意することで、設定の治安を維持できるようになります。

あらゆる個別の設定データに関する知識をコードベースから切り離すことで、プロジェクトの健全性も保てます。Anyway Config gemは、あらゆるユースケースに合う共通の抽象化を提供しているので、開発者の認知能力に負担をかけることなく、さまざまなところからやって来るさまざまな設定値を自由自在にミックス、比較、オーバーライドできるようになります。

ENVは責任を持って使いましょう!

ところで、皆さんのよく育ったRailsアプリケーションを「テラフォーミング」して、以下のような最新のベストプラクティスを導入することにご興味はおありですか?

私どもEvil Martiansが皆さまにお力添えいたします。

本記事の翻訳や転載についてのご相談は、まずメールにてお願いします。

Evil Martiansでは、外宇宙流の製品開発およびご相談を承ります。

おたより発掘

関連記事

クジラに乗ったRuby: Evil Martians流Docker+Ruby/Rails開発環境構築(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。