Rails 環境別の設定ごと secrets.yml -> credentialへ移行

こんにちは。MUGENUPの林です。

私の担当サービスは、かつてRails 5.1以下を使っていた名残で、secrets.ymlで秘密情報を管理していました。

※現在の バージョンは 5.2.3 です。

secrets.ymldatabase.ymlが手動管理ということは、変更漏れによるエラーが発生してしまう可能性が高く危険です。

credentialへ移行し、変更の必要のないmaster.keyのみ手動管理する方法に切り替えることにしました。

credentialはRails5.2より追加されました。

railsguides.jp

credential作成

EDITOR="vim" bin/rails credentials:edit のコマンドで編集画面が表示されます。

EDITOR="vim"は作業環境でエディター設定をしている場合は省略可能です。

以下、初期の中身です。

# aws:
#   access_key_id: 123
#   secret_access_key: 345

# Used as the base secret for all MessageVerifiers in Rails, including the one protecting cookies.
secret_key_base: 〜シークレットキー〜

secret_key_base は残し、既存のconfig/secrets.yml をそのままコピーしてペーストしましょう。※ 理由は最後に

secret_key_base: 〜シークレットキー〜
default: &default
development:
  <<: *default
test:
  <<: *default
staging:
  <<: *default
production:
  <<: *default

といった形でコピー&ペーストすることで、環境別の設定もそのまま引き継げます。

保存すると、config/credentials.yml.encconfig/master.keyが出来上がります。

.gitignore では

# Ignore master key for decrypting credentials and more.
/config/master.key

が追加されています。

credentialsは、環境別の設定をソースコード側で自動参照してくれないので、 ソースコード内のRails.application.secretsRails.application.credentials[Rails.env.to_sym]と置き換えましょう。

config/database.yml は?

  host: <%= Rails.application.credentials[Rails.env.to_sym][:db][:host] %>
  password: <%= Rails.application.credentials[Rails.env.to_sym][:db][:password] %>

host と password だけ credentials に任せる形にして、秘密の情報を無くし、 Gitの追跡対象に追加しました。

.gitignore から

/config/database.yml

を消すことで、コミットができるようになります。

circleCIは?

f:id:mgnup:20191219164315p:plain 設定のEnvironment VariablesからRAILS_MASTER_KEY環境変数(Name)として追加できるので、 master.keyの中身をValueに入れて追加します。

capistranoは?

手動管理していたconfig/secrets.yml, config/database.ymlに対して貼られていたシンボリックリンクを、master.keyに置き換えます。

先に対象サーバーのシンボリックリンク先になるファイルを設置しましょう。

(例: [Railsソースコードのあるパス]/shared/config/master.key)

config/deploy.rbでデプロイ時の設定が行われているので、こちらを書き換えましょう。

set :linked_files, fetch(:linked_files, []).push('config/database.yml', 'config/secrets.yml')
↓
set :linked_files, fetch(:linked_files, []).push('config/master.key')

他にもsetup等でconfig/secrets.yml, config/database.ymlがある場合は、適宜置き換えてください。

以上で完了です!

secret_key_base は、development / test と staging / production で取得方法が変わる

(development / test はダミーの文字列を自動生成して通ってしまう)

ようなので、慎重にご対応ください。

github.com


※secret_key_baseだけは特別対応をします!

Before

default: &default
development:
  <<: *default
  secret_key_base: 〜今までのシークレットキー〜

のように、環境別に secret_key_base を設定していた場合はそちらを消し、

After

secret_key_base: 〜シークレットキー〜
default: &default
development:
  <<: *default

と、defaultの外に、credential作成時に作られた最新のシークレットキーを書きましょう。

  • Rails本体が secret_key_base を参照するときの Rails.application.credentials.secret_key_baseRails.application.credentials[Rails.env.to_sym].secret_key_baseと置き換えることができない
  • credentialsを初めて作成したときに secret_key_base が自動生成されることから、 credentialsと secret_key_base は1:1で、環境別に用意するものではなくなったと推測

以上の理由から、 secret_key_base は一つに統一し、Rails.application.credentials.secret_key_baseで呼び出せる形に整えることにしました。

ちなみに、環境別の設定に書くと

Missing 'secret_key_base' for 'staging' environment, set this string with 'rails credentials:edit'

というエラーが出てしまいます。


新規に立ち上げるプロジェクトにWebpackじゃなくてParcelにした理由

お疲れ様です。MUGENUP エンジニアの崔です。今回は新規プロジェクトサービスを立ち上げる時にフロントエンドの構成でWebpackじゃなくてParcelにした理由に対して話します。

フロントエンドの話

新規プロジェクトのフロントエンドはVueにするように決まってVueを使うためにはトランスファイラーが必要でJavaScriptと一緒に組み合わせるためにバンドラーが必要でした。

bundler(バンドラー)の話

最近のウェブフロントエンドをやっている方だったら当然のようにパッケージ管理にnpmを使っているかと思います。 JavaScriptのファイルの間をどう繋ぐかはAMDやCommonJS、ESModuleなど色んな標準がありますけどそれの話はまた別に機会があればやりたいと思います。 で、バンドラーがやることとは様々なJavaScriptファイルを「一つの」JavaScriptファイルにすることです。 一つにするだけじゃなくてファイルを圧縮してサイズを減らしたり、標準のJavaScript、Node.jsでは通用しない文法もトランスファイルする(Babelを使って)こともやります。 まるでCとかC++コンパイラーがやるコンパイルみたいな気もしましすね。

Webpackの話

Webpackは今は多分JavaScriptのバンドラーの中では結構使われているかと思います。基本的にはWebpack.config.jsファイルを作成してファイルをマージするところのエントリポイントを選択してそこから参照関係を探って一つのファイルにしてくれます。 もちろんCLIを設置してコマンドでも実行はできますけど、大体の場合はConfigファイルを作成しているかと思います

Webpackを使うためにはどのファイルから探ってパッケージを集めるとかSassやVueなどNode.js側でサーポートしていない言語を入れるためにPluginを設定しなきゃダメです。 下の公式ドキュメントから見ると。。

https://webpack.js.org/concepts/configuration/

var path = require('path');

module.exports = {
  mode: 'development',
  entry: './foo.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'foo.bundle.js'
  }
};

一見、書きやすいように見えますけど、下の記事みたいにPluginとか細かい設定が入るとコードが長くなります。これはConfigファイルを作った人にもある程度責任はありますね。 でも、そもそも特定のファイル(エントリポイント)からパッケージの依存関係を探るだけの話だったらConfigは必須ではないでしょう?と考えた人がありました。

Parcelの話

ここからが本題のParcelの話です。規模にもよりますけど特にプロジェクトが大きくないとか、まだ初期の段階だとしたらすぐバンドリングをやりたくなりませんか? バンドラー自体がいらないケースを外すと大体は今でもすぐプロジェクトを立ち上げたくて我慢できないかと思います。 で、Parcelはnpm install -g parcelをやっただけで使えるようになります。

$ npm i -g parcel
$ # index.htmlにはmain.jsを呼び出していると仮定する
$ parcel --build index.html
Building main.js...
✨  Built in xx.xxs.

dist/main.xxxxxx.js.map             ⚠️  x.xx MB      xxms
dist/main.xxxxxx.js                  xxx.xx KB    xx.xxs
...

賢い可愛いParcelはindex.htmlから繋がっているエントリポイントになるJavaScriptのファイルからまたImportしてくるファイルの拡張子を覗いてPluginも、Babelも勝手に設置してBuildしてくれるんです。

開発モードでリアルタイムトランスファイルもできるし、 parcel index.html

JavaScriptをエントリポイントにして不要なMapファイルをBuild結果物から除外するのも可能です parcel build entry.js --no-source-maps

理由

つまりWebpackじゃなくてParcelにした理由は上でも書いた通り設定ファイルを書かなくてもPluginを入れてくれたり、ファイルを圧縮してくれるなど色々都合いい動きをしてくれたからです。 もちろんこれだけ見るとWebpackオワコンだねとか、Parcelだけ使えばいいじゃんって言えるかも知れませんけど Webpackはファイルを一個にまとめるだけじゃなくて結果対象を複数にできるとか、Configファイルって言ってもJavaScriptのコードなので拡張の可能性が高いと思います。

結論

今のプロジェクトの性格や、チームメンバーなの開発環境を考慮しながらバンドラーを選択するべきかと思います。

DiveでDockerイメージを覗いてサイズを減らす方法

お疲れ様です。MUGENUP エンジニアの崔です。今回はDiveというツールを使ってDockerイメージの中身を見る方法を紹介しようと思います。

なぜ必要?

イメージを作るときにCentOSDebianのイメージ自体も300から600メガになるし、パッケージを設置したり、重いファイルを入れるとすぐギガバイトまで行くかと思います。 開発環境は多少イメージが多くてもdocker image pruneなどの立ち上がっていないイメージを消すコマンドをたたくことで容量を確保しますが、本番環境や他のイメージのベースのなるイメージだったらなるべくイメージのサイズを軽くする必要があると思います。

Diveとは?

Diveは対話型CLIツールでイメージのIDを入れるとイメージのステップごとにその中身のディレクトリー構造が見れるツールです。

https://github.com/wagoodman/dive

まずツールを実行すると二つの窓に別れています。 左の方はステップをキーボードの上下キーで次、前のステップに移動するのができます。 右の方は今選択したステップのイメージレイヤのディレクトリー構造が見れます。 で、お互いの窓をスペースキーで転換できます。

イメージを分析しよう!

ではツールをインストールしたら(MacとかLinuxのメージャーなDistroならパッケージマネジャーから設置できます!)実際これを使って分析しましょう。

まず、ターゲットになるイメージを選択する為にイメージのリストをみましょう。

$ docker images -a
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
***_backend      latest              b9d23b76e550        2 hours ago         333MB
***_console      latest              b9d23b76e550        2 hours ago         333MB
<none>              <none>              b56bd1bd77f4        2 hours ago         333MB
<none>              <none>              3a8d411fa803        2 hours ago         333MB
<none>              <none>              3df2afeb812e        2 hours ago         333MB
<none>              <none>              628311edb63b        2 hours ago         51.2MB
<none>              <none>              f6c2a2f82ff8        2 hours ago         51.2MB
***_frontend     latest              5772f1e7234a        2 hours ago         372MB

で、今回はfrontedイメージの中身を見ましょう

$ dive 5772f1e7234a
Image Source: docker://5772f1e7234a
Fetching image... (this can take a while for large images)

diveの初期画面

まず最初に実行した画面です。

diveの画面2 見ての通り二つの窓があります。タブキーで左右移動して上下で要素に接近できます。 左からは一個下に進んで見て、右画面にファイルの変更が色に表示されます。 ここで一旦ステップを踏んでみましょう。

diveの画面3 distが5メガ

ここでは本来ならいないはずのdistフォルダーのトランスファイルされたファイルたちがイメージに入ってしましましたね。 ここが一旦消すところですね。

diveの画面4 node_modulesが154メガ

ここでもう一個発見しました。node_modulesです。次のステップでどうせnpm installが走るからnode_modulesが生成されるから除外しなくても良いと思うかも知れませんが、、、 node_modulesには開発環境で一回インストールして要らなくなったパッケージとかあるかも知れないので自分は削除する判断をしました。

削除

削除とは言いましたが、ただイメージに先発見したファイルが入れなくなる過程ですね。 Dockerイメージに入れたくないファイルがあったら.dockerignoreで記載できます。 自分の場合は.dockerignoreファイルにnode_modulesdistとを入れます

$ cat .dockerignore
node_modules
dist

後、npm install --only=prodみたいにdevDependencies(開発環境だけ必要なパッケージ、npm install --save-devでインストールしたもの)は場外するようにDockerfileも変えれば良さそうですね。

今回の例はそこまで大きいプロジェクトではなかったので多い変化はないけど、イメージが重い!毎回新しいイメージの生成でディスク足りない!!って方はどこか無駄にコピーしているファイルはないか、確認のときにはぜひ使ってみてください。


DiveのCLI画面ではCtrl+Cで終了、Ctrl+Fでファイル名から探すなど必要な機能としては足りないかと思います しかもDiveは効率度(efficiency)を測定したり、CIモード設定でイメージのサイズが一定以上とか、効率度が低くなるとPass/Failをリターンコードとして返してくれるらしいので色々使えるツールかと思います。

これでDockerイメージの内部を見れる良いツールdiveで楽しいDocker開発になったら嬉しいです。最後まで読んでくれてありがとうございます!

CTO Night and Day 2019備忘録

今年初めての参加となりました。 MUGENUPの前島です。

CTO nightとは

AWSが主催する日本のベンチャー企業CTOが一堂に介し、各セッションやディスカッションを通じ学びとネットワークを得るカンファレンスです。今回は約120名の参加がありました。 初参加企業は3割程度です。MUGENUPは代表の伊藤がCTO時代から通じ全て参加しているようです。 総日程は3日間で朝から晩までスケジュールが組まれており観光の時間はありません。

(なので私は朝会場に向かう際や帰り道を徒歩で移動して少しでも京都にいる実感を得ようと足掻きました)

会場はthe sodoh 東山 京都です。

www.thesodoh.com

web界隈のエンジニアなら多くの人が知っているであろう方々と接することが出来たのは、まだまだ新参者の私にとってとても視座の上がる刺激的な体験でした。

f:id:mgnup:20191015162737j:plain

以下、参加したセッションやキーノートについてつらつら書いていきます

Day 1

CTOのためのamazon カルチャー

ジェフ・ベゾスかかかげるBuilders and Dreamersについての学びや何故Amazonがここまで大きくなったのか?という問いの一部を垣間見た気がしました。

以下はセッションの中で聞いた中で特に自分に取って重要だと感じたトピックです。

  • 誰のための事業なのかを考える
  • イノベーション文化
  • スピードと俊敏性にこだわるための小規模チーム
  • 主体性と自律性を重視
  • 一つのチーム内で完結できるようにする

amazonの行動指針であるOur Leadership Principlesについてはどれだけ多様性のある人間が集まった組織であっても全員が同じ方向に向かうために改めて「行動指針」が必要だと感じました。 ただエンジニア組織を作り上げていくだけではダメで、今後は組織全体にも行動指針の重要性を説いていきたい!!!

keynote

Gremlin IncのKolton Andrus氏による「カオスを起こすのではなく、カオスを手懐けるためのエンジニアリング」というカオスエンジニアリングについての考え方、Snyk LtdのGuy Podjarny氏によるOSS脆弱性データベースの重要性と変遷、活用方法については初めて聴く内容も多く新鮮でした。

f:id:mgnup:20191009141334j:plain

また、ソラコム安川さんのkeynoteで印象に残ったのは「疎結合で非同期なチーム作り」など組織、チーム作りの考え方です。 全てを自社に当てはめることは出来ないけれど、適応出来そうなものがあれば積極的に参考にしていきたいと思いました。 また、AWSにいたご経歴からかソラコムの行動指針にもAWSのOur Leadership Principlesを参考にしている箇所がありこうやって素晴らしい文化が継承されていくのも考え深かったです。

「sansanの今までとこれから」というタイトルでkeynoteを発表した下さったsansan CTO藤倉さんの話ではまさにシード期から現在、そしてこれからのsansanの企業としての在り方をメッセージ性を込めて発信して下さっていると感じました。 印象に残った言葉として、最後の方に仰っていた「技術としては、特別なことを何もしていない」です。

企業として本当に重要なのは技術だけではないということ、足元の課題はなんなのか?誰が使うのか?を徹底して考えテクノロジーをビジネスモデルに順応させ加速させたからこそ今の私にはとても説得力がありました。

Unconference

テーマ別にテーブルが用意され5〜6人でディスカッションする時間です。 私が参加したテーマは「CTOとVPoE」でした。 詳細の議事録をAWSのスタッフの方が取ってくれていたので、 詳細は省きますがCTOとは企業に取って何か?VPoEとの違いや各々の企業の過去の事例を踏まえて議論することが出来たのはとても貴重な時間でした。 今後自分がVPoEとしてやれることと次のステップへ進むための心構えなど、自分の中で方針がいくつか定まりました。

改めて、同じテーブルで拙い私に様々なことを教えて下さり真摯に接してくださった皆様(レクターCEOの松岡さん、グノシーctoの小出さん、テクロスctoの佃さん、LegalForce ctoの時武さん)、本当にありがとうございました!!

CTO MidNight!!!

立食式の交流会の後は、MAHARAJA祇園で毎年恒例Tech二次会とDJブース!!

中でも関西学生エンジニアLT大会は見応えがありました。 今の大学生って凄くスタートアップの世界に入りやすい環境があった羨ましいなと思いつつ、有り余る若い時の熱量を発散できる場所があるのはとてもいいことだなあ・・・

f:id:mgnup:20191009235349j:plain

写真はLT大会後のDJタイムの一幕です。 徐々に人数は減っていき、最後は数えられる人数しかフロアにいませんでした。笑

Day 2

day 2ではオムロン社にお邪魔したSINIC理論のワークショックに参加したりCTO DojoやCTO 1000本ノックなど嗜好を凝らしたセッションが多かったです。 自分が参加したCTO Dojoのセッションは「第二新卒としてのCTOというキャリアと実態」、「CTO は「規制」とどう向き合うべきか」、「エンジニア採用サービスを見てきてわかったエンジニア採用がうまくいっている会社の例」、「エンジニアのモチベーション管理」です。

それぞれ一つ一つのセッションについて1記事書けるくらい内容の濃いものなのですが、自分なりに考えをまとめるとどの会社もチームもそれなりに似通った課題に直面していて、大事なのはその課題とどれだけ真摯に向き合えるか、その課題を解決したり上手く切り抜けるために試行錯誤と実験を繰り返して磨かれていったことを継承していくしかないだなと思いました。当たり前と言えば当たり前ですが・・・

私としてはエンジニアもとい、人間が組織で働く上で成果を出し続けるには「考え方×能力×情熱」がバランス良く揃ってないといけない。 これら一つ一つを成長させて上げたり用意したり、自ら引き出してもらったり出来るマネージャーが世の中にもっともと必要だと感じました。 今自分が出来ることをもっともっと見つめ直す良いきっかけに、この3日間はなったと思います。

まとめ

今回、様々なことを学びました。 普段都内だと物理的に近くにいても中々話す機会がない方と深い話が出来たのも、このCTO nightならではの魅力だと思います。 この機会をくださった周りの方々には本当に感謝してもしきれないほどです。 ここで学んだことを活かした事業の成長でこのご恩を少しずつお返しできたらと思います。

AWS AuroraをR3(旧世代)からR5(新世代)に安全に変更する方法

皆さん。こんにちは。MUGENUP エンジニアの崔です。

今回は、AWS AuroraをR3(旧世代)からR5(新世代)に安全に変更する方法をまとめてみました。

AWS Aurora

Amazon Aurora は、MySQL および PostgreSQL と互換性のあるクラウド向けのリレーショナルデータベースであり、従来のエンタープライズデータベースのパフォーマンスと可用性に加え、オープンソースデータベースのシンプルさとコスト効率性も兼ね備えています。

MUGENUPはMySQLと互換性もあるためAuroraを使っています。

そこで中断させないでインスタンスの変更が可能かどうかを調査し、実際にやってみました!

対象:fujossy

月間PV1200万を超える小説投稿サイト https://fujossy.jp

目的

DBインスタンスの変更をサーバーを停止しないままでやりたい

方法

AWS AuroraにはFailover(フェイルオーバー)機能があるので、活用してみましょう。

Failover(フェイルオーバー)?

Failoverは異常が生じたときの予備システムに自動的に切り替えられる機能

手順

  1. DBクラスターから既存のインスタンスをコピー
  2. 既存のインスタンスにFailoverをかける
  3. コピーしたインスタンスがメイン(書き込み)に切り替えると既存のインスタンスを変更

>>> 簡単! <<<

実際やってみましょう

まず、インスタンスをコピー

f:id:mgnup:20190509142821p:plain f:id:mgnup:20190509152852p:plain

  • xxxxxxx-aurora-clusterにもともと紐づいていたxxxxxxx-aurolaからクローンに作成でコピーします
  • 設定などは既存の設定と同じなのでそのままクローンしましょう
  • クローンに成功したらClusterのページのCloudWatchでもインスタンスの状態を確認できます f:id:mgnup:20190509153018p:plain

Failoverをセット

f:id:mgnup:20190509143152p:plain f:id:mgnup:20190509153116p:plain

インスタンスの変更

f:id:mgnup:20190509153231p:plain f:id:mgnup:20190509153332p:plain

  • インスタンスを変更する時には常時にConnectionを維持するアプリケーション(Sidekiqとか)は一回Offにする必要があります
  • アマゾンによるとインスタンスをライブに変更する時には既存の連結がある時ゆっくりと変更するように現状に発生する問題を最小限にすることをやるのでConnectionをいっぱい持っているアプリケーションは止めておく必要があります

元に戻す

f:id:mgnup:20190509153407p:plain

Failoverで書き込みと読み込みが逆だった新インスタンス(アップデートの為の臨時インスタンス)にまた同じくFailoverを設定しましょう そうなると元インスタンスがまた書き込みに、臨時インスタンスは読み込みになるので臨時インスタンスは消去出来るようになります

インスタンス変更の確認

インスタンスの変更は勿論作業しながらすると思いますが、CloudWatchのデータを参考にする方法をここに記録します

f:id:mgnup:20190509153424p:plain まず、上のグラフはDBに接続しているConnectionの数です。途中にメインのインスタンス(元インスタンス、xxxxxxx-aurola)のConnectionをSidekiqをOffにした影響で減っているのを確認できます。そのあと変更後元に戻したらまたConnectionが復旧されます

注意点

  • 無中断にインスタンスをアップしたとしても途中で何秒くらい接続が切れるのがユーザー側にも影響を受けるので先にメンテナンスのお知らせが必要になります
  • アプリケーションにもDBに接続が切れるように処理されるのでアプリケーションがDBの状態によってパニックが起きて強制的に死ぬシステムなら使えないです
  • この方法の良さは中断時間を時間単位(アプリケーションサーバーも中断)から秒単位(DBホストだけ中断、切り替え)にするにあって完全に0秒でシステムのDBがアップデート出来るのではないです
  • 今回の件とは別ですけど、インスタンスの名前が間違っていて変更したらホストネーム(サーバーのドメインネーム)が変わるので注意する必要があります

募集

弊社は、一緒に働くエンジニアを募集中です。 ビジョン「創ることで生きる人を増やす」の達成、ご一緒しませんか?

recruit.mugenup.com

SavePoint の動き方 (テナント編)

皆さん。こんにちは。MUGENUP の osada です。

5月14日(木) に SavePoint(セーブポイント) という新サービスをリリースしました。

www.savept.com

MUGENUP がクラウドソーシングで培った進行管理プロセス、そしてその集大成である Workstation を リプレースし、どなたでも使えるように SaaS として提供したプロジェクト管理ツールです。

今回は、SavePoint が導入しているマルチテナントについて解説します。

SavePoint はビルディングに似ています。

SavePoint は、savept.com というドメインで運用しています。 しかし実際には、mugenup.savept.com のようなURLでアクセスすることになります。 これは実際にはどうなっているのでしょうか?

f:id:mgnup:20150520011956p:plain

図は、SavePoint の構造をビルに例えたものです。

キーワードは、「フロア、入構証、エレベータ」の3つです。

まず SavePoint ビルがあります。 そこにはフロアがありますが、これがそれぞれのサブドメイン(mugenupの部分)になります *1

次に、ユーザーごとに、入構証が発行されます。 これは、メールアドレスとパスワードで構成され、 1メールアドレスごとに、1つ 発行されます。 これを使って 「SavePoint ビルに入る = ログインする」ことができるようになります。

しかしこれだけでは、フロアに入ることが出来ません。 ビルには入れたけど、エレベータが止まらないのです。

フロアに入るには、その入構証にフロアに入る許可を付与する必要があります。 そうすることで、エレベータがそのフロアに止まることになります。 (許可がないフロアには止まりません = アクセスできません)

逆に言うと、フロア許可証があれば、再度ログインし直す必要がありません。 一つの入構証で、自由にフロアを行き来することができます。

フロア許可証には2段階のレベルあります。

フロア許可証があるので、フロアに降りることはできるようになりました。 しかし今度は、部屋(= プロジェクト・案件)に入ることが出来ません。

部屋ごとにセキュリティがあるため、今度は部屋の許可を貰う必要があります。 この許可は、2種類の人からもらうことができます。

一人は、部屋の管理者(=案件管理者: 詳細は次回に解説します)。

もう一人は、フロア管理者 です。

フロア許可証には、通常レベルとは別に、 全てをコントロールできる管理者権限(Level A)を付与することができます。

これを持つ人が、フロア管理者です。 登録サイトから登録したユーザーは、フロア管理者となります。 *2

このレベルでは、全ての部屋(案件)に自由にアクセスしたり、 フロア管理室(後述)に入ることができるようになります。

ログインはできたけど、何もアクセスできない、というときは、 まだ部屋に入れてもらっていない、ということかもしれません。

フロア管理室でできること

部屋の外側のフロア全体に関わることはフロア管理者しか行うことができません。 例えば、

  • 入フロア許可の登録・剥奪
  • 新しい部屋(案件)の追加・部屋の削除
  • 他の誰かをフロア管理者にすること

などです。

データベースの解説

さて、概念を説明したところで、技術の話に移行します。

SaaS のサービスの場合、マルチテナント、というキーワードが出てきます。 しかし今回は、テナントではなく、フロアという例を出しました。 なぜかというと、「ビル入構証」という概念を伝えたかったからです。

純粋にテナント構造にすると、ユーザーはテナントごとに入構証を持ち歩かなければなりません。 首から3枚も4枚もカードをぶら下げて歩くのは、大変です。

よって SavePoint では、ユーザーのみ、テナントごとのデータベースから切り離して管理しています。

クライアントからサーバにアクセスがあると、Rack ミドルウエアの中で、 リクエストからサブドメインを判別し、データベースが切り替わります。 データベース単位で切り替わるため、他のテナントのデータが混ざることはありません。

しかし、ユーザーテーブルのみ、どのテナントからアクセスしても、 必ず一つのデータベースを見に行くようになっています。

これにより、セキュリティを高めながら、利便性を保つことができるようになっています。

まとめ

  • SavePoint はビルディング構造に似ています
  • 入構証の他に、フロア許可証があります
  • ユーザーは異なるフロアにシームレスに移動することができます。

SavePoint は、「発注者の管理を楽にしよう」 ということを追求して作っているサービスです。

ところが、MUGENUP 自体は、発注元からお仕事をいただく制作側の立場です。

もしSavePoint を100社がご導入いただいて、MUGENUPにお仕事を頂く場合、 通常のサービスでは、一人ごとに100アカウント作ることになるかもしれません。

しかし SavePoint なら何社になっても1人1アカウントです

このように、制作側にとっても使いやすいサービス を目指せるのは、 ドッグフーディングができる弊社の強みだと考えています。

発注者にも、受注者にも使いやすいサービス、を目指して、開発を続けています。

是非お使いください。

募集

弊社は、一緒に SavePoint を作ってくれるエンジニアを募集中です。 無限流開発、ご一緒しませんか?

recruit.mugenup.com

*1:登録サイトではスペースIDと呼んでいます

*2:それ以外で登録したユーザーは、通常レベルです

ActiveJob から見るシリアライズとデシリアライズ

皆さん、こんにちは。プルリクで間違った指摘をして大反省中 の osada です。

プルリクで間違った指摘をして大反省中です。 下記のコードで、何を指摘したか、お分かりになるでしょうか?

class NotificationJob < ActiveJob::Base
  queue_as :default

  def perform(notifiable, user)
    notifiable.notify(user)
  end
end

こんなことを言ってしまったのです。

オブジェクトを丸ごとシリアライズすると、redis の容量を圧迫し、 シリアライズ・デシリアライズにも時間が掛かるので、 クラス名とidを渡して、job の中で取り出して使って下さい。

この発言には2つ、間違いがありました。

  • 1つ目は、オブジェクトが丸ごとシリアライズされると思っていたこと。
  • 2つ目は、ActiveJob は クラス名と id を渡す必要がないことです。

今回は、ActiveJob では、インスタンスをそのまま渡してもよくなったこと、 そして、GlobalID がどのように使われているかを共有します。

話の流れとしては、下記の3つのシリアライズ方法の比較を行います。

  1. python memcache の シリアライズ
  2. resque のシリアライズ
  3. activejob のシリアライズ

Python memcache はインスタンスごとバイナリダンプ

pythonmemcacheclient は、インスタンスを丸ごとダンプします。

In [1]: class A(object):
   ...:     def __init__(self, a):
   ...:         self.a = a
   ...:

In [2]: a = A(1)

In [3]: import memcache

In [5]: mc = memcache.Client(['127.0.0.1:11211'])

In [7]: mc.set("a", a)
Out[7]: True

In [8]: mc.get("a")
Out[8]: <__main__.A at 0x103efbc10>

In [9]: a1 = mc.get("a")

In [10]: a1.a
Out[10]: 1

In [13]: a == a1
Out[13]: False

In [14]: a.__class__ == a1.__class__
Out[14]: True

mc.getで取り出した時に、Aというクラスのインスタンスとして取り出していることがお分かりになるでしょうか。 インスタンスとしては別ものですが、クラスは同じです。

memcache の set では、 オブジェクトをバイナリダンプするpickle というモジュールが動いています。

In [6]: mc._val_to_store_info(a, 0)
Out[6]:
(1,
 89,
 "ccopy_reg\n_reconstructor\np1\n(c__main__\nA\np2\nc__builtin__\nobject\np3\nNtRp4\n(dp5\nS'a'\nI1\nsb.")

12.1. pickle — Python オブジェクトの直列化 — Python 3.4.2 ドキュメント

謎の文字列が格納されていますが、なんとなく読み取れるような? ruby でも marshal を使えば、同じことができると思います。

そしてこれが、一つ目の間違いである、インスタンスを丸ごとダンプしていると考えた理由でした。 きっと rails も、バイナリダンプしているのだろう、と思い込んでしまったのです。

Resque のシリアライズ

では、Rescue はどうしているでしょうか?

[31] pry(main)> class A
[31] pry(main)*   def initialize(a)
[31] pry(main)*     @a = a
[31] pry(main)*   end
[31] pry(main)* end
:initialize
[32] pry(main)> a = A.new 1
#<A:0x007fa192282b80 @a=1>

Aというクラスを定義し、インスタンス化します。

[33] pry(main)> class Q
[33] pry(main)*   @queue = :q
[33] pry(main)*   def self.perform(a)
[33] pry(main)*     p a
[33] pry(main)*   end
[33] pry(main)* end
:perform
[34] pry(main)> Resque.enqueue(Q, a)
true
[35] pry(main)> Resque.keys
[
    [0] "queue:q",
    [1] "queues"
]
[38] pry(main)> Resque.redis.lrange "queue:q", 0, -1
[
    [0] "{\"class\":\"Q\",\"args\":[{\"a\":1}]}"
]

それをQというジョブを作成し、Resque に渡します。 このとき、ジョブQ と、インスタンスaredis に積まれますが、 このとき引数のa が何のクラスだったのか、という情報は消えるようです。 (resque 1.23 です)

redis に積まれた情報だけでは、復元することができません。

なぜかといえば、そもそもインスタンスをダンプしない方針のようです。

If your jobs were run against marshaled objects, they could potentially be operating on a stale record with out-of-date information.

バイナリダンプしてキューに積んだら、 データが更新されてた時に、古いデータのままで使っちゃうかもしれないでしょ?

という感じでしょうか?

resque/resque at 1-x-stable

Resque では、クラスとidを渡して、キューの中で取り出すのが標準です。

さてこれが、2つ目の間違い、クラス名とidを渡して、jobの中で呼び出すという指摘をした原因です。 ActiveJob も同様だろう、と思い込んでしまったのです。

ActiveJob のシリアライズ

ActiveJobでは、GlobalID という機能が入っており、明示的なシリアライズが不要です。 マニュアルに明確に記載がありましたので、確認不足すぎて大反省でした。 Active Job の基礎 — Rails ガイド

バイナリダンプでもないけれども、インスタンスをそのまま渡すことができる、 一体これがどのように動いているのでしょうか?

シリアライズの処理は、activejob-4.2.0/lib/active_job/argument.rb にあります。

def serialize_argument(argument)
  case argument
  when *TYPE_WHITELIST
    argument
  when GlobalID::Identification
    { GLOBALID_KEY => argument.to_global_id.to_s }
  when Array
    argument.map { |arg| serialize_argument(arg) }
  when Hash
    argument.each_with_object({}) do |(key, value), hash|
      hash[serialize_hash_key(key)] = serialize_argument(value)
    end
  else
    raise SerializationError.new("Unsupported argument type: #{argument.class.name}")
  end
end

引数を一つずつ処理しながら、

  1. シリアライズできるもの(TYPE_WHITELIST)
  2. GlobalID::Identification であるもの
  3. 処理できないもの

に分けています(ArrayとHashに対して再帰するのは、引数の処理として勉強になりますね)。

GlobalID::Identification そしてそのメソッドである to_global_id.to_s というのが ActiveRecord を一意に成り立たせているものです。

[1] pry(main)> user = User.first
  User Load (0.5ms)  SELECT  "users".* FROM "users"  ORDER BY "users"."id" ASC LIMIT 1
=> #<User:0x007fd1449c3b88 id: 1, name: "aa", created_at: Sat, 28 Feb 2015 13:12:38 UTC +00:00, updated_at: Sat, 28 Feb 2015 13:12:38 UTC +00:00>
[2] pry(main)> user.to_global_id.to_s
=> "gid://test01/User/1"
[3] pry(main)> user = User.new
=> #<User:0x007fd145d4feb8 id: nil, name: nil, created_at: nil, updated_at: nil>
[4] pry(main)> user.to_global_id.to_s
URI::InvalidURIError: Expected a URI like gid://app/Person/1234: #<URI::Generic gid://test01/User/>

アプリ名、クラス名、id で一意になる文字列を生成しています。 id が無ければ、エラーになります。

そんなわけですから、マニュアルには、ActiveModel にミックスイン、とありますが、 Rails 4.2.0 では、GlobalID というライブラリに変わっており、 ActiveRecord にミックスイン になっていますので、気をつけましょう。

上のコードは、ActiveModel::GlobalIdentificationをミックスインするすべてのクラスで動作します。 このモジュールはActive Modelクラスにデフォルトでミックスインされます。 http://railsguides.jp/active_job_basics.html#globalid

この GlobalID のおかげで、ActiveRecordインスタンスを渡したとき、 global_id の文字列に変換されて積まれる、ということです。

手動でやっていたことを、ライブラリとして組み込んでしまった感じでしょうか。 また、クラス名と、idを渡すのではなく、1つの gid として渡すのも、センスが良いですね。

ActiveJob の デシリアライズ

シリアライズを見てみましょう。

GlobalID::Locator.locate(argument) を使って、 globalid かどうかを確認しています。

def deserialize_argument(argument)
  case argument
  when String
    GlobalID::Locator.locate(argument) || argument
  when *TYPE_WHITELIST
    argument
  when Array
    argument.map { |arg| deserialize_argument(arg) }
  when Hash
    if serialized_global_id?(argument)
      deserialize_global_id argument
    else
      deserialize_hash argument
    end
  else
    raise ArgumentError, "Can only deserialize primitive arguments: #{argument.inspect}"
  end
end

この locate は、下記の手順でデータを取り出します。

  1. string が 妥当なgid かどうか確認
  2. gid があれば locator_for で、適切な finder を取得
  3. finder を使って、データを取得

この finder は、標準で、ActiveRecordFinder が用意されています。 よって、実際は、ActiveRecord.find を使って取り出します。

def locate(gid, options = {})
  if gid = GlobalID.parse(gid)
    locator_for(gid).locate gid if find_allowed?(gid.model_class, options[:only])
  end
end

def locator_for(gid)
  @locators.fetch(normalize_app(gid.app)) { default_locator }
end

@locators = {}

class ActiveRecordFinder
  def locate(gid)
    gid.model_class.find gid.model_id
  end
end

mattr_reader(:default_locator) { ActiveRecordFinder.new }

よって、ActiveRecord 以外でも、自前で locator を用意すれば、使うことが可能です。

custom-app-locator

# Tie a locator to an app.
# Useful when different apps collaborate and reference each others' Global IDs.
#
# The locator can be either a block or a class.
#
# Using a block:
#
#   GlobalID::Locator.use :foo do |gid|
#     FooRemote.const_get(gid.model_name).find(gid.model_id)
#   end
#
# Using a class:
#
#   GlobalID::Locator.use :bar, BarLocator.new
#
#   class BarLocator
#     def locate(gid)
#       @search_client.search name: gid.model_name, id: gid.model_id
#     end
#   end

ただ、実装としては、gid.model_class.find gid.model_id だけですので、 実装したいクラスに、find メソッドid メソッド を用意した方が楽な気がしますね。

まとめ

今回のまとめです。

  1. キューのためにデータをシリアライズするときは、データのズレを考慮し、バイナリダンプしない
  2. 永続化されたモデルは、グローバルIDを発行することで、クラスとidを使うよりも、シンプルに扱える
  3. プルリクを見る時は、謙虚に。しかし臆すること無く。

なお、4.2.0 の時点で、ActiveJob に キーワード引数を使うことはできませんが、 4.2.1 には入るようです。 しばらくは、キーワード引数を使わない実装で、頑張りましょう。 ActiveJob should support passing of keyword arguments to perform method · Issue #18741 · rails/rails

参考

TYPE_WHITELIST はプリミティブ型です

TYPE_WHITELIST = [ NilClass, Fixnum, Float, String, TrueClass, FalseClass, Bignum ]

Resque のダンプは MultiJSON

# Given a Ruby object, returns a string suitable for storage in a
# queue.
def encode(object)
  if MultiJson.respond_to?(:dump) && MultiJson.respond_to?(:load)
    MultiJson.dump object
  else
    MultiJson.encode object
  end

end

[10] pry(main)> MultiJson.dump(a)
"{\"a\":1}"
[11] pry(main)> a1 = MultiJson.load(MultiJson.dump(a))
{
    "a" => 1
}
[12] pry(main)> a.class == a1.class
false

ActiveJob は自前クラスは入れられない

[1] pry(main)> class AJob < ActiveJob::Base
[1] pry(main)*   queue_as :default
[1] pry(main)*   def perform(a)
[1] pry(main)*     p a
[1] pry(main)*   end
[1] pry(main)* end
:perform
[2] pry(main)> class A
[2] pry(main)*   def initialize(a)
[2] pry(main)*     @a = a
[2] pry(main)*   end
[2] pry(main)* end
:initialize
[3] pry(main)> a = A.new 1
#<A:0x007ff4d41b0a50 @a=1>

[4] pry(main)> AJob.perform_later(a)
Enqueued AJob (Job ID: 71481807-dae8-460f-a050-655ab0394bd5) to Resque(default) with arguments: #<A:0x007ff4d41b0a50 @a=1>
ActiveJob::SerializationError: Unsupported argument type: A

[5] pry(main)> AJob.perform_later({a: 1})
Enqueued AJob (Job ID: f9cf4409-f749-4714-81bf-aa309f400b60) to Resque(default) with arguments: {:a=>1}
#<AJob:0x007ff4d2c60128 @arguments=[{:a=>1}], @job_id="f9cf4409-f749-4714-81bf-aa309f400b60", @queue_name="default">

[40] pry(main)> user = User.first
  User Load (0.5ms)  SELECT  "users".* FROM "users"  ORDER BY "users"."id" ASC LIMIT 1
=> #<User:0x007fd142924738 id: 1, name: "aa", created_at: Sat, 28 Feb 2015 13:12:38 UTC +00:00, updated_at: Sat, 28 Feb 2015 13:12:38 UTC +00:00>

[41] pry(main)> AJob.perform_later(user)
Enqueued AJob (Job ID: 016c46b7-4abe-4cae-9605-84c29572faab) to Inline(default) with arguments: gid://test01/User/1
  User Load (1.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT 1  [["id", 1]]
Performing AJob from Inline(default) with arguments: gid://test01/User/1
#<User id: 1, name: "aa", created_at: "2015-02-28 13:12:38", updated_at: "2015-02-28 13:12:38">
Performed AJob from Inline(default) in 0.47ms
=> #<AJob:0x007fd144f6a6b8
 @arguments=[#<User:0x007fd142924738 id: 1, name: "aa", created_at: Sat, 28 Feb 2015 13:12:38 UTC +00:00, updated_at: Sat, 28 Feb 2015 13:12:38 UTC +00:00>],
 @job_id="016c46b7-4abe-4cae-9605-84c29572faab",
 @queue_name="default">

参考文献