Railsバージョンアップに学ぶフレームワークアップデートの進め方

自己紹介

お久しぶりです。ジモティーで2020年4月からサーバサイドエンジニアをしている水上と申します。 早いもので入社3年目となり、日々案件開発と格闘する日々を送っております。

まえがき

入社3年目となり、大きめの案件開発に携わることも増えてきたので、少し前に実施したRailsのバージョンアップについて記載致します。

Rails バージョンアップ

今回のバージョンアップ前の弊社のRailsは5.2.6でした。 最近Rails 7が発表され5.2系のサポートが今年6月までで切れてしまうとのことで今回のアップデートを実施した運びとなります。

作業内容

  1. バージョンアップ先選定&バージョンアップ
  2. アップデート後Railsが起動する状態にする
  3. raill consoleの起動に成功したらCIを完走させる
  4. CIでは賄いきれない箇所があるので、手動で動作テストを行う
  5. レビュー
  6. 他部署連携テスト
  7. リリース

以下、トピックごとに今回行った詳細を記載します。

1.バージョンアップ先選定

今回はバージョンアップ手順のドキュメントが充実していた5系から6および6系から6.1の最新である6.1.4をターゲットとしました。 RubyのバージョンがボトルネックとなりRails7へのアップデートは見送りになりました。 - Gemfileを修正しつつbundle update - bin/rails app:update 上記2点を実施

2.アップデート後Railsが起動する状態にする

今回の最初の山場がここでした。

Rails6からAutoload方式が変わって、新しくZeitwerkが推奨となりました。バージョンアップ前の記載はClassicモードとして利用可能ですが、非推奨になりました。 そのため上記に対応するのか、非推奨警告を出したままclassicで行くのかの調査などに時間がかかりました。 新Autoload方式に対応するために修正すべきファイルが多いため、今回は対応外とし、classicのままで次回のバージョンアップ時にZeitwerk対応を行う予定です。

3.rails consoleの起動に成功したらCIを完走させる

大きく時間がかかった箇所その2でした。

CIは開始するようになったものの初期完走時でエラー数は1800ほど発生しており、すべての修正に辟易しながらも中身を見てみると同じエラーで失敗しているものが多く、パターンを覚えて適宜対応しました。

その中でも、database cleanerとfeature testの相性が悪く、specファイル内で正しくcreateしているにも関わらず対象テストの実施時にdatabase cleanerが動作してしまい、対象テストに必要なデータまで削除されてしまう事態が発生していました。 factory_botのアップデートによりbuild及びcreateの挙動が変わり、以前までの記載だとdatabase cleanerが動き出してしまう状態に陥っていたようでしたので - buildの箇所をcreateで書き直し - 明示的に.saveを行う

等対処していたところ結果として、上記対応は不必要であり

FactoryBot.use_parent_strategy

の設定を行うことで諸々の挙動が改善しました。 結果として、既存の書き方を踏襲したままアップデートすることができました。

アップデートにおける開発体験は極力変えたくなかったので上記対応にて体験を変えずにアップデートできたのは良かったです。

4.CIでは賄いきれない箇所があるので、手動で動作テストを行う

手動テストを行ったところ、Rails 5->6へのアップデートの際、デフォルトのままだとCookieの互換性が無く、ログイン状態がリセットされてしまう問題が露見しました。 原因は2つ有り - ENV[“SECRET_KEY_BASE”]が未設定だった - Rails6からCookieにメタデータが埋め込まれるようになったため、後方互換性が失われた

ENV[“SECRET_KEY_BASE”]が未設定だった

に関してはRails5のメインブランチに対して、secret_key_baseをAWS Parameter Storeに保存しておき、そこから呼び出す様に変更を適応

Rails6からCookieにメタデータが埋め込まれるようになったため、後方互換性が失われた

に関しては

Rails.application.config.action_dispatch.use_cookies_with_metadata = false

を設定することで対応できました。

他部署へのテスト依頼のためのテストケース作成もこのタイミングで作成しました。 ジモティーというサービス上テストすべき画面が非常に多く、自身の手動テストや、テストケースの作成が大変でした。 同じ画面でも複数のパターン(ログイン済みか非ログイン状態か等)があるため、漏れがないようにしなければ障害発生は避けられません。

5.レビュー

他の作業に比べると大きな時間はかかりませんでしたが、レビュアーがレビューしやすいように工夫はしてくべきだったと悔やんでおります。 コミットの区切り方やメッセージの内容等をきちんと精査し、レビュアーが見やすい状態にしておくことを今後意識します。 また、メソッド名の変更等、使用箇所が多いものの命名変更はレビュー時のノイズとなるため、単なる命名変更は別PRとして切り出しておくとスムーズでした。

6.他部署連携テスト

部署ごとにテストケースの確認と実施したほうがよいテストは無いかを確認してもらいながらテストを実施しました。 CSチーム、アプリチー厶、それぞれの観点からテストケースを拡充して頂き、結果としては良いテストケースが作成できました。

7.リリース

本来の予定ではカナリアリリースを行う予定でありましたが、CSRFトークンの変更等の後方互換性の無い変更が含まれていた事により、Rails5<->Rails 6のPOSTができない問題等があり一斉リリースとなりました。そのため、テストケースの充実が更に重要でした。

おわりに

今回の様な大規模な開発を行うことで業務や、Railsについての知識がさらに充実しました。開発体験も大きく変えずにリリースまで完了できて非常に良かったです。 次回以降にアップデートを行う際は今回学習した点を踏まえてよりスマートにアップデートを行いたいです。 Rails7へのアップデート担当が僕になるとは限らないため、今回の知見をドキュメントとして残しておくことで次回以降に役立てればいいなと思います。

また、弊社ではジモティーを更に成長させる為に一緒に取り組んでいただけるエンジニアを募集しています。 頼りになる先輩方と一緒に開発できればと思います。 (僕も頼りがいのある先輩の一員になれるよう精進中です) もし少しでも気になればお気軽にご応募ください。

ANR調査とその対策のお話

はじめに

お久しぶりです。 Androidチームで活動している阿部です。 前回投稿からおよそ1年、iOSでビルド速度の改善など様々な経験を積んで、Androidへコンバートしています!

今回は、Androidアプリのパフォーマンス指標としてしばしば話題に上がるANR(Application Not Responding) についてお話しできればと思っています。

弊社でもこのANRをAndroidチームが追うべきKPIの一つに設定しているのですが、昨年の10月あたりに上昇トレンドになっており問題になっていました。

調査方法と調査結果からの仮説

調査

Androidのdeveploper向けのドキュメントで紹介されているANRの要因としては以下のものが挙げられています。

  1. メインスレッドのコードが遅い
  2. メインスレッドの IO
  3. ロックの競合
  4. ブロードキャスト レシーバが遅い

参考:https://developer.android.com/topic/performance/vitals/anr?hl=ja

しかし、どの要因がANRの起因になっているかはlogcatを使用したデバッグではなかなか見つけづらくなっています。 さらに端末の性能の差によって発生する可能性があるANRもまちまちです。

そこで、今回はStrictMode(厳格モード)を使用した調査方法を採用していくことにしました。 厳格モードはlog形式で警告を出してくれるため、問題があると思われる違反を容易に判別が可能です。

調査は以下の手順で行いました。

  1. コンソールANRから発生件数が多い順に抽出
  2. 上位五件でActivityが特定可能なものを選択
  3. StrictModeを適用し、上記画面で検証を行う

結果と仮説

まずは一部抜粋した結果をご覧ください。

  • SQLiteに関する違反が表示された

  • SharedPreferencesについても違反が表示
    • これに関しては、1292msもかかっている様です

上記のようにandroid.os.strictmode.DiskReadViolationが多発しており、どうやらローカルDBへのアクセス中に問題がある様です。 AndroidのローカルDBをSQLiteからRoomに置き換えてみたの記事でも書かれているように、ローカルDBへのアクセスはUIスレッドで実行されるという問題点がありました。 また、SharedPreferencesも意識しないとUIスレッドで実行できるようになっているため、ここにも問題がありそうです。

対策

上記で調査した結果、以下の二点の問題があることが判明しました。 1. SQLiteにUIスレッドでアクセスしてる 2. SharedPreferencesにUIスレッドでアクセスしている

1番目の問題に対しては、 AndroidのローカルDBをSQLiteからRoomに置き換えてみたを進めていくことで解決していきそうです。 (現時点で、移行は完了しています)

2番目の問題に関してはどうでしょう? たとえ、現存している全てのSharedPreferencesのアクセスをI/Oスレッドで行なったとしても、 SharedPreferencesがUIスレッドでアクセス可能なためメンバーの入れ替えが発生してしまった場合、 また同じ状態になってしまう恐れがあります。

そこで、確実にスレッドを切り替えることができるJetpack DataStoreを導入していくことにしました。

Jetpack DataStoreの導入

Jetpack DataStoreはKotlin コルーチンとフローを使用して、データを非同期的に、一貫した形で、トランザクションとして保存できます。 また、Preferences DataStore と Proto DataStore の2 種類がありどちらを導入するか検討する必要がありました。

  1. Preferences DataStore では、キーを使用してデータの保存およびアクセスを行います。この実装では、定義済みのスキーマは必要ありませんが、タイプセーフではありません。
  2. Proto DataStore では、カスタムデータ型のインスタンスとしてデータを保存します。この実装では、プロトコル バッファを使用してスキーマを定義する必要がありますが、タイプセーフです。

弊社では、カスタムデータ型のインスタンスとしてデータを保存できるメリットが大きいと判断しProto DataStoreを選択することになりました。

Jetpack DataStoreの実装例

  1. app/src/main/protoの配下にprotoファイル作成
syntax = "proto3";

option java_package = "**.**.**.datastore.entity"; //<- 生成ファイル吐き出す階層を指定
option java_multiple_files = true;  //<- 生成ファイルを別で吐き出すか

message DataStoreExample {
  // 各フィールドの識別子として 1, 2... というフィールド番号(タグ)が必要。
  bool boolean = 1;
  string hoge = 2;
  int32 huga = 3;
}
  1. protoファイルから生成したファイルを使える様にSerializerを追加
    • androidx.datastore.core.Serializerを実装する
object ExampleSerializer : Serializer<DataStoreExample> {
    override val defaultValue: DataStoreExample
        get() = DataStoreExample.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): DataStoreExample {
        try {
            return DataStoreExample.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

    override suspend fun writeTo(t: DataStoreExample, output: OutputStream) {
        t.writeTo(output)
    }
}
  1. DataStoreをラッパするManagerクラスを作成
class DataStoreExampleDataStoreManager @Inject constructor(
    context: Context,
) {
    private val Context.dataStoreExample: DataStore<DataStoreExample>
        by dataStore(
            fileName = "data_store_example.proto",
            serializer = DataStoreExampleSerializer,
        )

    private val dataStore = context.dataStoreExample

    suspend fun getString(): Flow<String> = dataStore.data
        .map { it.hoge }
        .catch { exception ->
            if (exception is IOException) {
                emit("")
            } else {
                throw exception
            }
        }
    }
    companion object {
        private var instance: DataStoreExampleDataStoreManager? = null

        fun getInstance(context: Context): DataStoreExampleDataStoreManager =
            DataStoreExampleDataStoreManager(context)
                .also { instance = it }
    }
}

※ラッパクラスは一貫性を保つためにシングルトン管理にしなければいけない。別のインスタンスからアクセスしようとするとException吐きます。

結果

上記のRoomへの移行とJetpack DataStoreの移行を小規模で行なってみた結果は以下の通りになっています

- Room移行後 -> 30日平均:0.04%減
- Jetpack DataStore移行後 -> 30日平均:0.01%増

Roomの方は効果がありましたが、Jetpack DataStoreの方は目に見えた成果が出ませんでした。 この原因としては以下の二点が考えられます。

  1. 他のリリース物によるパフォーマンスの悪化によるもの
  2. 軽微な箇所の移行のみであったため、パフォーマンスの改善幅が少なかった

また、firebaseなどのsdkもSharedPreferencesを使用しているためにUIスレッドで行なっている箇所も考慮しなければいけません。

そのため、Jetpack DataStoreの移行の改善幅は小さかったと考えられます。 今後は大きなSharedPreferencesも含め移行し、sdk使用箇所の使用スレッドの考慮していくことが重要になっていきそうです。

一方で、Jetpack DataStoreでの移行を行うことでcoroutines flowの考え方や使用スレッドの考え方を深めれるようになったのは大きな収穫です。 また、MVVMでcoroutines flowとLiveData双方向への変換が可能になったためよりスムーズな実装が可能になったと考えています。

まとめ

今回は、ANRの調査からその対策までを簡単にご紹介していきました。 結果としては、大きな改善には至らなかったのですが、Jetpack DataStoreを導入したことによりUIスレッドをブロックしないように作れるためパフォーマンスも良いアプリになっていくだろうと思っております。

またANR自体も減少したとはいえ、ロックの競合やブロードキャストレシーバが遅いなどの警告も存在していたため、まだまだ改善していく必要がありそうです。

最後に

ジモティーでは一緒に課題を解決してくれるエンジニアを募集中です!
jmty.co.jp

ジモティー Android チームの課題について

はじめに

Androidエンジニアの林です。 ジモティーのサービスも10年を超え、昔のコードがまだまだ存在しており、定期的にリファクタを行なっています。 そこで現在チームが抱えている課題をいくつか紹介したいと思います。

まだまだJavaのコードがある

新規画面はKotlinで書くことがルール化されているのですが、昔からあるような主要画面は全てJavaで書かれていました。 2021年はその主要画面を全てリファクタするという目標を掲げほぼKotlin化することができました。 ただあまり改修が入らない画面や、色々な画面から使われている大規模なModelクラスなどは、なかなかKotlin化できずまだ手がつけられていない箇所もあります。 サービスの根幹を担っているクラスがJavaで書かれていると、Kotlinだと簡略化して書ける部分もわざわざJavaでどうやって書くのか調べながらコーディングしなければならず、生産性が上がらない原因となってしまっています。 (現在全体の約65%ほどがKotlinで書かれています。)

デザインシステムがまだ確立していない

デザインシステムがまだ確立しておらず、例えばダイアログ関連のデザインなどが様々でエンジニアも把握しきれず、ダイアログのクラスが肥大化してしまっています。(ダイアログ生成メソッドを新規で作り続けている、1000行超えてます、、) 現在デザイナーチームでデザインシステムの作成を行なっており、確立後にそれに伴うメソッドをまとめたクラスを作成できると考えています。

アーキテクチャーと非同期処理が統一されていない

現在主要な画面はほとんどGoogle推奨のMVVM+Coroutinesになっていますが、

アーキテクチャー 非同期処理
MVVM Coroutines
MVP Coroutines
MVP RX

という画面構成が乱立しており、新しく入った人のキャッチアップが大変になっている要因になっています。

これから順次RXをCoroutinesに置き換える作業もやっていきたいのですが、MVPで割と整理された画面をMVVMにするのか?という議論もありなかなか手がつけられていません。 理想としてはすべての画面でMVVM+Coroutinesであることが望ましいのですが、工数的な部分もあり、MVPとの併存にしばらくはなるのではないかと考えています。

歴史が長いと負のコードも様々残っており、一気にリファクタしたいところもあるのですが、クラッシュが発生するリスクもありと慎重に進めている状況です。 モジュール分割に向けてApp層からData層のクラスを呼べないようにするなど細かなリファクタも粛々と進めています。

※ 弊社のAndroidアーキテクチャー推移についてはこちら jmty-tech.hatenablog.com

DIの方法が二通りある

リファクタの一環として、Daggerを使ってDIしている画面を順次Hiltに置き換える作業も行なっています。 移行期で仕方ないのですが、複数のModuleクラスが存在してコードの見通しが悪くなっており、こちらも新しく入った人が混乱してしまう要因となっています。

外部要因起因のANR

画面のフリーズを検知するANRも日々チェックを行なっています。 広告を入れていることからどうしてもWebVIewなどでANRが発生してしまうことがあります。 SDKのバージョンアップや負荷がかかる処理を非同期で行うようにするなど、原因を調査してなるべく早期に対応するように心がけています。

まとめ

このようにAndroidチームではリファクタを日々行なっています。 また課題の洗い出しや新技術も含めた解決方法なども話し合いながら業務を行なっています。

最後に

ジモティーでは一緒に課題を解決してくれるエンジニアを募集中です!
jmty.co.jp

バッチ処理をEC2からFargateへ移行した

インフラエンジニアの佐藤です。
今回はEC2上で実行していたバッチ処理をコンテナ上で実行させるようにしたのでその話を書いていきます。
コンテナ化するにあたりマネージドサービスを活用してサーバレスに運用していきます。
利用したサービスや移行にあたっての問題、活用事例を紹介していきます。

背景

用途を分けて2台のバッチサーバで運用していましたが、Rubyのアップデートを始め各種ミドルウェアの更新をする度に、新しいAMIを作成してバッチサーバの入れ替え作業を実施していました。
AMIを作成する時間が長かったり、入れ替えに伴う工程が多かったりといくつかの負債がありコンテナを導入することで運用コストを下げようといった狙いからスタートしました。

まずは、入れ替え頻度が高めで運用コストがかかっているサーバロールを選定しました。
主に下記3つが対象となります。

  • ウェブサーバ
  • 非同期処理サーバ
  • バッチサーバ

比較的容易に変更できそうなバッチサーバのコンテナから実施することにしました。

利用したマネージドサービス

  • AWS Fargate
  • AWS Step Functions
  • AWS Lambda
  • CloudWatch Logs Insights

Fargate

バッチ処理を動作させるコンテナ基盤として利用しました。

Cloudwatch Events多重起動問題

タスクを定期実行できるスケジュール機能があり、Cloudwatch Eventsの仕組みが使われています。
こちらをCronの代替として利用することも可能ですが、同じタスクが複数回トリガーされる可能性があります。

バッチ処理では該当時間帯に一度の実行しか許容できない処理も存在するので、安易にこのスケジュール機能を使うことはできませんでした。
同じタスクの多重起動を回避するためタCron専用のサーバを用意することにしました。

※後日、Step Functionsを用いてこの問題を回避できることが分かったので、いずれはそちらに切り替えたいと思っています。
参考: 重複実行を許容しないステートマシンを構築

固定IP対策

EC2インスタンスに固定IPを付与していました。
特定のサーバと通信する際に利用していますが、Fargateタスクとして実行する場合にはランダムな可変IPが付与されてしまいます。

回避策としては下記のようなパターンなどが挙げられると思います。

  • NAT Gatewayを利用する
  • Proxyサーバを経由する
  • 可変IPを許容できる仕組みに切り替える

今回は可変IPを許容できる仕組みへの切り替えました。
固定IPに依存している処理は数が少なかったのでSecurityGroupを利用したり、処理の切り分けをして一部でのみ固定IPで通信するようにしました。
依存している処理が多ければNAT GatewayやProxy経由での通信が良さそうに思いますが、費用や運用コスト面を考えたところ可変IPに対応するようにしました。

Step FunctionsとLambda

コンテナの起動に失敗した際のワークフローを作成するために利用しました。

稀にFargateタスクの起動に失敗することがあります。
失敗したタスクのStopped Reasonを確認すると、下記のようなエラー例を確認することができます。

  • ResourceInitializationError: failed to configure ENI: failed to setup regular eni: context deadline exceeded
  • Timeout waiting for EphemeralStorage provisioning to complete.
  • Timeout waiting for network interface provisioning to complete.

これらの StopCode を確認してみると TaskFailedToStart となっており、タスクの起動自体に失敗していることがわかります。
公式リファレンスからも失敗時は手動対応か再試行の自動化が解決策に上がられているので、Step Functionsを利用した再試行の仕組みを導入しました。

ただStep Functions エラー処理を確認しても TaskFailedToStart のエラーはありません。
そのため、細かいエラー処理は独自のエラーハンドリングを作成することになります。

実装例

Lambda

Lambda Functionで補足したいエラー処理を実装しておきます。
Step FunctionsでRun Fargate Taskからの出力を変数に格納しておくことにより、Lambdaステップへの入力でその変数を使うことができるようになります。
この場合は result に格納しています。

class TaskFailedToStartException(Exception): pass

def error_handler(event, context):
    if event.get("result").get("StopCode") == "TaskFailedToStart":
        raise TaskFailedToStartException("TaskFailedToStart Error")

Step Functions

想定するワークフローを作成しておきます。 この例ではFargateタスクを実行し、その結果をハンドリングするといったフローになります。
Run Fargate Task での出力結果は成否に関わらず全て result に格納します。
result の内容から上記のLambda Functionでエラーを検出し、その結果から次のステップを選定しています。
再試行フローを実装するにはLambda を使用してループを反復するを参考にします。

"Run Fargate Task": {
  "Type": "Task",
  "Resource": "arn:aws:states:::ecs:runTask.sync",
  略
  "Catch": [
    {
      "ErrorEquals": [
        "States.ALL"
      ],
      "ResultPath": "$.result",
      "Next": "Error Handler"
    }
  ],
  "ResultPath": "$.result",
  "Next": "Error Handler"
},
"Error Handler": {
  "Type": "Task",
  略
  "Catch": [
    {
      "ErrorEquals": [
        "TaskFailedToStartException"
      ],
      "ResultPath": "$.result",
      "Next": "捕捉時の処理"
    }
  ],
  "ResultPath": "$.result",
  "Next": "次のステップへ"
},

CloudWatch Logs Insights

Fargate や Step Functions のログを分析用に利用しています。
既存バッチ処理ではEC2上にログを出力していましたが、コンテナ導入に伴い出力先をCloudWatch Logsに変更しました。
AWS上にログを溜め込むことにより一元管理することができたりログの集計がしやすくなります。

実行時間の長い処理などを一目で確認できるので処理の改善などに役立ちます。
CloudWatch Logs Insights を使用したログデータの分析には様々なログを解析できる仕組みが記載されています。

バッチ実行時間の集計の例

今まではEC2上に出力されていたログを確認していましたが、CloudWatch Logs上にログを集約することにより簡単に確認することができます。

下記はAWS Step Functionsの実行単位ごとに実行時間を集計してみた例となります。
f:id:jmty_tech:20220201140500p:plain

サンプルクエリ

対象はStep Functionsから出力されるログです。
クエリ構文に使い方が記載されているので参考になります。

fields @timestamp, execution_arn, details.output
| parse details.output '{"commands":[*]' as commands
| filter details.output like "commands"
| stats earliest(@timestamp) as startTime, latest(@timestamp) as endTime by commands, execution_arn
| fields abs(endTime - startTime) / 1000 / 60 as minutes, datefloor(startTime, 10m) as execute_time
| display execute_time, commands, minutes, execution_arn
| sort minutes desc

execution_arnはStep Functionsの一意の実行単位となるので、execution_arnごとの一番早いタイムスタンプと一番遅いタイムスタンプの差分を出します。
差分はミリ秒になっているので分単位に変換し可読性を上げます。

まとめ

サービス本体基盤へコンテナの導入をしてみました。
サーバレス化していくためには様々なマネージドサービスを利用します。
それぞれ使い方に特徴があって対策や活用事例などを探すのは大変ですが、導入すると運用コストを削減することができるので引き続き改善していきたいと思います。

最後に

ジモティーではエンジニアを募集中です!
jmty.co.jp

データ設計と向き合う

サーバサイドエンジニアの坂根です。

最近某企業から販売されるウイスキーの価格改定が発表されましたね。

国産ウイスキーが転売されることなく、安定供給されることを切に願います。

さて、今回はデータ設計で気をつけていることについて話していきます。

何を大事にしているか

ヒトコトで言ってしまうと、現実に忠実であることです。

システムのデータと現実は、しばしば対になります。 そしてシステムの関心事が増えると、現実に沿ってデータを増やします。 現実に反するマジカルな設計になっていると、データを増やす際に歪な形で積み上げていくことしかできず、最終的にはジェンガのように崩れてしまうのです。

現実に反するマジカルな設計

では、マジカルな設計とは何か?具体例を基に見ていきましょう。

初期

資産管理システムを構築します。 どの社員に液晶モニタを貸与したか、一覧で表示できるようにしましょう。

マジカル設計の初期
マジカル設計の初期

中期

一部の社員からモニタが小さいというクレームがあり、大きめのモニタを導入することになりました。 資産としては別物なので、管理システムでも違いが分からなければなりません。

インチ数が分かればいいんだな!とストレートに付加します。

マジカル設計の中期
マジカル設計の中期

後期

資金が潤沢になり、業務効率を少しでも上げるために予備のモニタを購入できることになりました。 社員に貸し出さない予備資産を登録します。

社員にぶら下げることができなくなってしまったため、やむを得ず在庫として登録することを考えます。 しかし資産を一覧で可視化しようと思うと、社員と在庫の双方を集計しなければならなくなりました。

マジカル設計の後期
マジカル設計の後期

現実に忠実な設計

では、現実をできるだけ忠実に再現するとどのようになるでしょうか?

初期

社員に貸与した液晶モニタを、一覧で表示できるようにします。

現実に忠実な設計の初期
現実に忠実な設計の初期

中期

貸与したモニタのインチ数が区別できるようにします。

現実に忠実な設計の中期
現実に忠実な設計の中期

後期

在庫を登録できるようにします。

現実に忠実な設計の後期
現実に忠実な設計の後期

拡張性

後者の設計では、今後1人に貸与するモニタの数が複数に増えた場合でも無理なく対応できそうです。 それは、データが現実に通じており、似た土台から変化を起こすことができるからです。

つまり、拡張性高く保つためには、現実に忠実であることが重要なのです。

まとめ

検索効率を上げる、特定の何かに特化する、そのために現実を犠牲にすることもあるでしょう。 それでも私は、現実に忠実なモデルを起こした後、犠牲にするもの(リスク)を認識したうえで取捨選択します。

みなさんも良いデータ設計ライフを!

ジモティーではバックエンドに熱い魂を捧ぐ仲間を募集しています。 ご興味のある方はこちらを御覧ください。

ジモティーのフロントエンドをNext.jsに移行していくという話

どうも鈴木です。好きなプレインズウォーカーは初代ガラクです。

最近スタンダードでは緑単が強かったみたいですが、新弾出てどうなるんでしょう。

それはさておき、今回はジモティーのフロントエンドをNext.jsに移行していくという話を書きます。

背景

ジモティーは今年でリリース後10年を迎え、その間、Webブラウザ版のジモティーはモノリシックなRailsアプリケーションとして開発を継続してきました。 当然ながら技術負債の蓄積はさけられず、特にフロントエンドの生産性の低下が顕著でした。

まず、以下の理由でフロントエンド単体で見た時に生産性が悪くなっていました。

  • デザインガイドライン整備前の無秩序なCSS
  • メンテナンス性の低いJavaScriptコード
    • 一定以上の複雑さを持つ機能が無理矢理jQueryで書かれている
    • 一部Vue.jsを採用しているがRailsのview上に実装するためにベターjQuery的な活用に留まっている

また、Railsエンジニアがフロントエンドからバックエンドの実装を担当するため、以下の問題がありました。

  • フロントエンドとバックエンドが密結合しているため分業がしづらく、並行開発できない=リードタイムが長い
  • チームとしてフロントエンドの専門的なスキルが伸びにくい

ということで、これらの負を解決し、以下を実現することを目的にフロントエンドの刷新に着手しました。

  • フロントエンドエンジニアとバックエンドエンジニアが分担してそれぞれの領域に集中しやすくする
  • 技術負債になっていたコードを置き換え、モダンなフロントエンド技術を採用することで、フロントエンド単体での開発生産性を上げる

制約

さて、やると決めたのは良いものの、ジモティーのサービスの特性を考えた時にいくつかの制約がありました。

  • SEO上重要なページはSSR(Server Side Rendering)で実装する
  • 既存ページを移行する際、UX・パス・機能は基本的に維持する
    • (開発生産性のための再構築なので、UXの大幅変更により事業KPIに影響がでるのを避けるため)

これらを踏まえた上で技術選定しました。

技術選定

選択の理由も添えて紹介します。

  • TypeScript: 採用
    • 型安全性の恩恵を受けたいので採用
  • React or Vue.js: Reactを採用
    • 現時点でのTypeScriptの親和性を考えるとReactが優勢と判断
    • 検討時、Vue 3が出てから間がなく知見が溜まっていないということも考慮した
  • フロントエンドフレームワーク: Next.jsを採用
    • SEO上重要なページでSSRを採用するためSSR可能なフレームワークは必須
  • UIフレームワーク: 不採用
    • 新規サービスのこちら ではMaterial UIを採用していたがジモティーでは独自のデザインシステムがあるため既製のCSSフレームワークを導入するとカスタマイズの工数がかえってかさむと判断。
  • BFF(Backend For Frontend): 不採用
    • 今回の目的に照らすと必須ではない
    • 通信速度の悪化や運用・保守コストの増大などのデメリットが見込まれるため不採用
    • BFF で裏側のRESTを束ねて、GraphQL + Apollo Clientでハッピーになるんや、みたいな話もあったけど一旦冷静になった
  • StoryBook: 採用
    • デザイナーとエンジニアのコミュニケーションがスムーズになる
    • 長期的にメンテナンスするような自社サービスなら必須レベルと判断

移行の課題

ジモティーの場合、大量の既存ページがありそれらを一気にNext.jsに移行することは現実的ではないため、ページ単位あるいはまとまった機能単位で移行していきます。 したがって、移行済のページはNext.js、移行前のページとAPIコールはRailsにリクエストを振り開ける必要があります。

そこで問題になるのは以下の点です。

  • リクエストの分配方法
  • ログイン認証
  • CSRF対策

リクエストの分配方法

以下の方法を検討しました。

  1. Next.jsとRailsでドメインを分ける
  2. Nginxをリバースプロキシとして使って振り分ける
  3. ALB(Application Load Balancer)のパスベースのルーティングで振り分ける

ドメインを分ける方法の場合、移行前後でページのURLが変わるデメリットが大きいと考え不採用にしました。

Nginxをリバースプロキシとして運用する場合、スケーリングやメンテナンスの対象となるサーバーが一種類増えるという問題があります。

一方、ALBについては既に使っているため管理対象が増えないという状況でした。

したがって、ALBのパスベースのルーティングを使って、Next.jsとRailsへの振り分けを行うことにしました。

f:id:jmty_tech:20211105201720p:plain

ログイン認証

ブラウザからのリクエスト先がRailsの場合とNext.jsの場合ある、という前提でログイン状態を維持する必要があります。

この問題はSPAのログイン認証をどう実装するか、という問題とよく似ており、 こちらの 記事 が非常に参考になりました。

今回の場合は、Next(フロント)とRails(API)が同一Originのため、参考記事内の「Sessionを用い、SessionのCookieはSameSiteCookie」を採用することができます。

あとはNext.jsからRailsにAPIコールする箇所でクライアントから受け取ったCookieを引き渡すように実装すれば、Next.jsページとRailsページ(API)の間でログイン状態を維持することができます。

※図を挿入

CSRF対策

RailsではフォームにCSRFトークンを埋め込み、セッションに保持しているトークンと同一か検証することでCSRF対策を行っています。

一方、Next.js移行後の画面ではフォームをNext.jsが提供することになります。Railsが発行するCSRFトークンをNext.jsのフォームに埋め込めば従来の方法で検証が可能ですが、フロント〜Rails間でのCSRFトークンの通信が発生するので通信のオーバーヘッドを考慮すると好ましくありません。

そこで、以下の2つの対策を組み合わせて実施することにしました。

  1. SameSite Cookie
  2. Originヘッダの検証

SameSite Cookie

SameSite Cookieとはクッキーに指定できる属性の一つです。SameSite=Laxを指定するとブラウザは別サイトからのPOSTの際にCookieを送信しません。これによってCSRFを防ぐことができます。

2021年時点ではモダンなブラウザでSameSite=Laxがデフォルトになっていますが、まだ未対応のブラウザもあり得るため明示的に指定します。

Rails.application.config.session_store :cookie_store, same_site: :lax

Originヘッダの検証

古いブラウザでSameSite属性をサポートしていないものが存在するため、Rails側でのOriginヘッダの検証も実施します。

ALLOWED_ORIGIN = "https://example.com"

def allowed_origin?
  origin = request.headers["origin"]

  # 同一Originの場合にOriginヘッダを付与しない挙動のブラウザもあるため、空の場合は許可する
  return true if origin.blank?

  ALLOWED_ORIGIN == origin
end

We are hiring !

ジモティーではこの記事で紹介しているフロントエンドの刷新をはじめ、様々な挑戦のために新しい仲間を求めています! ご興味のある方はこちらを御覧ください。

jmty.co.jp

リリース作業をかんたんに! git-pr-release + Google Apps Script + Ruby スクリプト + GitHub Actions によるリリース作業改善の取り組み

ジモティーでサーバサイドとインフラを担当している吉田です。

前回は ISUCON10 に参加した話を投稿しました。今年もちょうど ISUCON11 を終えたばかりですが、残念ながら予選敗退となりました。ISUCON への参加はウェブエンジニアとしてのキャリアを見つめ直すとてもよい機会で、来年こそは決勝に進みたいと思っています。

さて、今回はジモティーのサーバサイドで1年ほど運用している、リリース作業の一部自動化の取り組みについて、背景から実施までをご紹介します。

従来のリリースフローとその課題

まず、弊社サーバサイドのリリースフローについて、当時の概要は次の様になっていました*1

変更前のリリースフロー
変更前のリリースフロー

  1. コードレビューを経て、トピックブランチを master ブランチにマージ(作業者: 各機能担当者
  2. リリース PR を作成(作業者: リリース担当者
  3. 機能担当者にリリース前テスト環境での動作確認依頼(作業者: リリース担当者
  4. 動作確認(作業者: 各機能担当者
  5. Jenkins のジョブを実行してリリース(作業者: リリース担当者

リリース担当者はリリースしたいメンバーによる立候補制で、サーバサイドのメンバーであれば誰でもこの役割になる可能性があります*2

この作業の1番の課題は、マージされた PR の「タイトル」「作成者」を目視で抜き出す事が大変な点です(図の2. および 3. に該当)。

詳細な流れは次の内容になります。

リリースPRの作成と動作確認依頼
リリースPRの作成と動作確認依頼

  1. master ブランチへのコミットログの中からマージコミット (メッセージが Merge pull request #xxxx from jmty/xxxx) を確認
  2. マージコミットの PR から「タイトル」「作成者」を確認
  3. 確認した「タイトル」をリリース PR の本文に入力
  4. 確認した「作成者」にリリース前テスト環境での動作確認を Slack のメンションで依頼

また、PR の「作成者」は GitHub のアカウント名なので、Slack のアカウント名に変換して Slack で本人にメンションする必要があります。そのため、たまに誤って違うメンバーにメンションをしてしまう問題も発生していました。

変換ミスによる誤メンション
変換ミスによる誤メンション

これをマージ済み PR の数だけ繰り返す必要があり、リリースタイミングによっては1度に10件を超える事もあります。

改善

事前準備

まず、課題を明確にします。こちらは前述の通りです。

  • リリース PR 作成時に、マージされた PR の「タイトル」「作成者」を目視で抜き出す事が大変
  • 誤って違うメンバーに動作確認依頼を行ってしまう

次に、その解決方法を調べます。

基本的に手作業で行っていた、マージされた PR の「タイトル」「作成者」を目視で抜き出して作業担当者に依頼する作業を自動化する方針で、かんたんな実装を行いました。これは、次で行う提案のための準備になります。フィードバックの結果、内容を大きく変更する可能性も十分あるため、あまり作り込まず、かつ GO サインをもらえるレベルにします。

KPT での提案

準備した内容をもとに、週1で実施しているウェブチームの KPT で下記の提案をしました。

  • 課題の共有
    • リリース作業がツラい
  • 解決案の提示
  • FAQ
    • リリースフローはどう変わるのか
    • 導入・運用コスト
    • メリットだけではなくデメリットも
    • なぜ CI は既存で利用している CircleCI ではなく GitHub Actions で動作させるのか、など

想定していた実装工数は比較的小さいため、一気に導入する手もありますが、次の観点から提案のステップを挟むことにしました。

  • 共通の課題である事の確認および、ニーズの確認
  • 異動や退職などで技術的な負債となる可能性がある
  • リリースフローの変更により、障害に繋がりやすくなる可能性がある

結果はおおむね期待していた良い反応だったため、具体的な実装に移りました。

変更後

最終的に次の流れになっています。

変更後のリリースフロー
変更後のリリースフロー

  1. コードレビューを経て、トピックブランチを master ブランチにマージ(作業者: 各機能担当者
  2. リリース PR を作成(作業者: リリース担当者
  3. 機能担当者にリリース前テスト環境での動作確認依頼
    1. 本文を自動更新 (git-pr-relase gem)
    2. GitHub と Slack ID のマッピングを取得 (Apps Script)
    3. 動作確認依頼 (Ruby スクリプト)
  4. 動作確認(作業者: 各機能担当者
  5. Jenkins のジョブを実行してリリース(作業者: リリース担当者

「機能担当者にリリース前テスト環境での動作確認依頼」の作業を自動化しています。

それぞれの技術トピックについてご説明します。

git-pr-release

git-pr-release はリリース PR を作成・更新する gem です。マージされた PR からタイトルや担当者を取得して、PR を作成・更新してくれます。

当初自作をしようと考えていたところ、希望の機能を満たすこちらの gem が見つかり、OSS かつ定期的にメンテナンスされていたため採用しました。

最近 GitHub の仕様変更があり、期待する動作をしなくなる事がありましたが、すぐに対応されたものがリリースされました。

もし自作していた場合、同様の対応を行う可能性が高かったはずです。

Google Apps Script

GitHub アカウント名と Slack ID のマッピングを JOSN として返すウェブアプリケーションサービスです。

次はサービスのURLに対して curl からの擬似的なアクセス例になります。

$ curl -H "Authorization: token xxxx" -L \
  --dump-header - \
  "https://script.google.com/xxxxxx"
(省略)
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
(省略)

[
  {
    "slack_member_id"=>"XXXX",
    "slack_display_name"=>"kyoshida", 
    "github_login_name"=>"kyoshidajp"
  },
  {
    "slack_member_id"=>"YYYY",
    "slack_display_name"=>"yyyy",
    "github_login_name"=>"zzzz"
  },
  ...
]

マージされた PR からその作成者(機能担当者)のアカウント名を取得した後、Slack ID に変換する際にこのマッピング情報を参照します。

事前に Google スプレッドシートにそれぞれの対応を記載しておきます。新しいメンバーが加入したら都度こちらに追加します。このシートのメンテナンスが手動になってしまいますが、他の利用用途でも使えるようになります。

Ruby スクリプト

リリース PR の本文から、マージされたPRの「タイトル」「担当者」を取得して Slack に通知します。

Ruby は弊社サーバサイドにてメインで利用しているプログラミング言語です。ユニットテストや JSON の変換など、豊富なライブラリが付属している事に加えて、Slack や GitHub 連携など16万を超える*3サードパーティのライブラリによる素晴らしいエコシステムがあります。

同様の目的には、後述する GitHub Actions のワークフローコマンドから gh や jq を駆使して行うことも可能だと思いますが、上記観点から Ruby スクリプトとしました。

GitHub Actions

git-pr-release および、Slack に通知する Ruby スクリプトの実行環境です。

master から release ブランチへマージするリリース PR が作成されたタイミング、もしくはmaster ブランチにコミットが行われたタイミングで実行されます。後者については、リリース PR が作成された後に、追加でリリース機能を含めるケースに対応するためです。

CI 環境としてすでに利用している CicleCI ではなく、GitHub Actions を採用したのは次の理由によるものです。

  • 世の中の流れとして GitHub Actions がかなり普及してきており、キャッチアップをしておきたい
  • 想定の利用ケースでは無料枠に十分収まる
  • 面白そう

結果

改善により、当初の課題であった、リリース担当者による目視かつ手作業での依頼が不要になりました。

自動で通知される動作確認依頼
自動で通知される動作確認依頼

また、誤って別のメンバーに確認依頼してしまう問題も発生していません。

さらに、工夫したポイントとして、デプロイ時に Slack で通知されるリリースノートの内容が分かりづらかったため、同様の仕組みで整理しました。

整理されたリリースノートの通知
整理されたリリースノートの通知

この結果、エンジニアだけでなく、CS を含む社内メンバーは機能概要機能担当者リリース担当者が明確になりました。

まとめ

日常で感じた課題の解消をモチベーションに、その改善の提案、そして実施までをご紹介しました。

自動化した作業は、以前は1回あたりおよそ5〜15分程度だったので、リターンを時間で換算するとどうしても投資効果を低く感じてしまいがちです。しかし、日々課題に感じていて改善の目処が立っているのであれば、積極的に取り組むべきだと思います。

そもそも、対象の作業は手作業で日々繰り返され、自動化が可能なものでした。こういった、いわゆるトイルの撲滅は(インフラ)エンジニアが価値を発揮しやすい領域ではないでしょうか?

少なくとも、以前の状態には戻りたくないのが率直な気持ちです。

宣伝

ジモティーではエンジニアを募集中です。

jmty.co.jp

日々の不満や課題を一緒に解決していきましょう!

*1:master ブランチへのマージ後にリリース前テスト環境への自動反映など、細かい内容は省略

*2:これは執筆時点も同じ

*3:執筆時点の https://rubygems.org/stats より