RNIアドベントカレンダー7日目 Lambda のアップデートをした話

これは RNI 開発部 (SDD) Advent Calendar 2023 最終日の記事です。

本来は 2 日目に入る予定だったのですが、諸事情で最終日になってしまいました。 来年は早めに書いておこうと思います。

Lambda のアップデートをした話

SDD では 3 年ほど前に主要なインフラを AWS に移行しました。 その後、徐々に Lambda 上のスクリプトが増え、現在 RnI で利用しているのは以下言語になっています。

RnI では今年、特に利用している Lambda ランタイムのサポート終了が多く、 3 年前から使っていた多くのスクリプトがアップデートの対象になりました。

参考: Lambda ランタイム - AWS

実際には、以下の通りアップデートを行っています。

  • Ruby 2.7 -> 3.2
  • Go 1.17 -> 1.20
  • Node.js 14.x -> 18.x
  • Python 3.7 -> 3.11

テストコードがない Lambda をアップデートする

RnI では基本的に TDD で開発を進めているため、コアである RailsiOS, Android のコードにはテストコードを書いているのですが、Lambda のコードは AWS への移行期にコードを書いていたこともあり、多くのコードがテストコードを書いていない状態で放置されていました。

今回は時間の制約があり、新たにテストを書く時間が取れなかったことから、以下のように方針を定めました。

  • Linter & Formatter を(VSCode 上でのみ)導入する。
  • 実行時にエラーが出ないように、アップデートで起きる問題を(特に Linter で)事前に解決する。
  • 依存するライブラリはリリースログを確認しながら破壊的な変更がないことを確認し、アップデート後に問題が起きる可能性を低くしておく。

上記の方針を定めた理由として、Lambda 上で動くコードが外部ライブラリをほぼ利用していなかったこと、個別のスクリプトが大きくなかった(100 行未満がほとんど)ため、ソースコードを読むだけで挙動を確認しやすかったことが挙げられます。

Linter の適用で防げた問題

Linter の適用により最も効果があったのは言語レベルでの変更点でした。 わかりやすい例として、以下のようなものがあります。

Python 3.9 で削除された dateutil の可視化

dateutilPython 3.9 以降、 python-dateutil に名称変更されています。 RnI では今回、 Python 3.7 から 3.11 へのアップデートだったため、この問題に対処する必要がありました。 こういった点に対しては、VSCode 上で依存ライブラリの名前解決ができない、といった表示がされるため非常にスムーズに対処していくことができました。

なお、 dateutil についてはタイムゾーンを扱うために導入していたことから、今回は名称変更ではなく、 zoneinfo を使う形で修正を加えています。

3.7 時点でのコード:

from dateutil import tz, zoneinfo
datetime
    .fromtimestamp(int(j['timestamp']) / 1000)
    .replace(tzinfo=tz.tzutc())
    .astimezone(zoneinfo.gettz("Asia/Tokyo"))
    .strftime('%Y-%m-%d %H:%M:%S')

3.11 用に修正したコード:

from zoneinfo import ZoneInfo
datetime
    .fromtimestamp(int(j['timestamp']) / 1000, tz=ZoneInfo('UTC'))
    .astimezone(ZoneInfo("Asia/Tokyo"))
    .strftime('%Y-%m-%d %H:%M:%S')

requirements.txt や Gemfile , package.json などの設置

Lambda デプロイ後に特に問題が発生せずに運用されたスクリプトの中には、ライブラリのバージョン指定が明確にされていない(requirements.txt などが存在しない)ものが少数ながらも存在しました。

これらについては、正確には Linter で防げたわけではないのですが、上記 dateutil の問題を修正する際に併せて発覚したため、標準ライブラリ以外を利用している Lambda スクリプトに対してはバージョン指定を明確に行う修正を行いました。

Linter の導入で防げなかった問題

ここは計画時点での盲点でもあったのですが、RnI では CodeBuild を利用して Lambda スクリプトのビルドを行っています。 そのため、各言語のバージョンアップに伴い、 buildspec ファイルの runtime-versions セクションを書き換えるだけでなく、使用可能なランタイムを確認してアップデートする必要がありました。

参考: 使用可能なランタイム - AWS

複数の言語やバージョンを扱っている buildspec ファイルの分離

RnI では上述の通り複数の言語を使用しているのですが、これら複数の Lambda スクリプトを 1 つの buildspec ファイルで扱っていたため、 CodeBuild 側の Linux イメージのバージョンを合わせることができず、複数の buildspec ファイルに分離する必要が生じました。

x86_64 から arm64 への移行

これは Go でのみ発生した問題ですが、RnI では Mac を使って開発をしているため、ここ数年でローカル開発環境が x86_64 から arm64 になっています。 ローカル環境でビルドのテストを実施する際には arm64 になるため、併せて Lambda 側のランタイム設定でも同様にアーキテクチャx86_64 から arm64 へ変更しました。

しかしながら、これまで Lambda スクリプトは全て x86_64 で動作させていたため、この項目は除外(デフォルト値の通りに設定)しており、 CFn の設定を変更しておらず、デプロイ時に一時的に障害が発生する原因となりました。

本来、 x86_64 から arm64 へのアーキテクチャ変更時には、 CFn ファイルへ以下を追加する必要があります。

参考: AWS::Lambda::Function - AWS CloudFormation User Guide

Resources:
  LambdaResourceName:
    Type: "AWS::Lambda::Function"
    Properties:
      Architectures:
        - arm64

今後 RnI では arm64 で動作させるリソースが増えていくことが想定されるため、必要に応じて追記・修正していく必要があります。

今後対応していくこと

今回のアップデートでは、個々の Lambda スクリプトで利用している外部ライブラリがほとんどなかったことと、スクリプト自体の行数もさほど長くなかったため力業で進めてしまいましたが、今後 Lambda への依存度が高くなってくるとこの方法では難しい事が容易に想像できます。

幸いながら、今回は 1 スクリプトのみ問題が発生する比較的軽微なダメージでアップデートが完了していますが、今後もこの方法で進めていくのは憚られます。 今回のアップデートでは、今後の対応方針を見極めるためにも作業ログを残し、どのような点で躓いたかなどを併記しておくことで、以下の課題及び今後対応していくべきことが改めて確認できました。

Lambda スクリプトもきちんとテストを書いておく

個々のスクリプト自体はさほど難しくない内容であっても、同時に多くの Lambda がアップデート対象になるとそれなりに確認に時間がかかります。 AWS に移行した当初は Lambda スクリプトの数もさほど多くなかったため、手作業で 1 つ 1 つ見ていく方法でも運用上大きな問題はありませんでした。ですが、この 1 年で Lambda スクリプトの数はかなり増えており、今後も増えることが容易に想像できます。

また、外部ライブラリ(pipRubyGems など)を利用しているコードは、ライブラリ側のアップデートも併せて確認していく必要がある他、アップデートのタイミングで言語レベルで名称が変更されているモジュールなどを確認する必要があることから、Rails 同様に、それぞれの言語に適したテストコードを記載し、CI の段階で問題がないことを担保することが必要です。

Lambda にもローカル環境を用意する

本番環境上で動作させている Lambda を一発で修正するのはそれなりに難しい作業になります。しかし、現時点では Lambda スクリプトが layered になっている部分があったり、依存する AWS 上の別サービスがあることから、ローカル環境でスクリプトを実行してテストを行うことが今回はできませんでした。 また、本番環境上でテストを行う際にも、他のシステムに影響が出るスクリプトもあり、実際にスクリプトを動かしてテストできる Lambda スクリプトは限定的になっていました。

従って、今後は LocalStack などを併用し、ローカル環境でインフラ側もある程度テストできる環境を整えるとともに、個々の Lambda スクリプトをより管理しやすい AWS SAM や Serverless Framework の導入を行うことで、実際の AWS 上でも本番同様に準備された複数の環境を利用できるようにしていくことが必要です。

SAM や Serverless Framework には、モックパラメータを使ってテストを回す仕組みも備わっており、モックパラメータがわかっている状態でテスト実行ができるだけでも、アップデート対応時にはかなり助かります。

Lambda のデプロイは自前でコードを書かず、ツールに任せる

AWS に移行した段階で用意した Lambda のデプロイ用コードは、 CodeBuild でビルドしたコードを S3 にコピーし、一括で全 Lambda スクリプトをデプロイする形式になっていました。そのため、1 ファイルの修正であっても、都度全ての Lambda スクリプトがビルド & デプロイされてしまいます。

Lambda スクリプトの数が少ない時にはこの方法でも充分だったのですが、今回のように複数のファイルを随時にアップデートしていきたい場合などには、ビルド & デプロイにかかる時間が積もっていきます。

自前のスクリプトを修正することでも対応は可能ですが、既にあるツールを使わないのも DRY ではないため、 Lambda のデプロイは自前でコードを書かずに Serverless Framework, AWS Serverless Application Model (AWS SAM) のようなツールに任せて行くのが良いでしょう。

特に今回、 CFn ファイルが 1 つにまとまっているメリットがデメリットを上回ってしまう形になったため、AWS SAM は CFn ファイルが増えてしまう問題はあるものの、個別に更新可能なメリットも享受できる形式であると感じました。

まとめ

Lambda スクリプトは気軽に作成することができ非常に便利ですが、アップデートやテストなど、下回りが疎かになっていました。

RnI ではコアの部分で利用している RailsiOS, Androidスクリプトはきちんとテストコードが書かれ、CI/CD が確立されています。 今回 Lambda のアップデートで躓いた部分も、「テストを書きましょう」「CI/CD をきちんと回しましょう」「DRY に作りましょう」といった、よく言われるものの蔑ろにしてしまいがちな部分をおざなりにしてしまったために起きた事故と捉えることもできます。

継続的な開発をしていく上で重要なこれらの要素を、 Lambda アップデートの中で再確認できたのは非常に有意義だったと感じています。また、2024 年にこれらの問題を一緒に解決していけるエンジニアを RnI では引き続き採用しています。興味がある方は是非一度、RnI の採用ページをご覧いただけると幸いです。

Happy holiday!

RNIアドベントカレンダー6日目 2024年に使いたい技術

2024年に使いたい技術

メリークリスマス! リサーチ・アンド・イノベーションの横山です。

RNIアドベントカレンダーの6日目になります。

今年も残り一週間ということで、来年2024年に使いたい技術についてお話しようと思います。

ruby3.3

明日はいよいよクリスマス。例年、rubyは12月25日に新しいバージョンがリリースされます。

恐らく明日にはruby3.3がリリースされることでしょう。 既にRCがリリースされており、何事もなければこのまま正式版がリリースされると思います。

ruby3.3の新機能としてはパーサの置き換え、RJIT(rubyで書かれたJITコンパイラ)の追加、irbとrdbg(デバッガ)の連携強化などがあります。 現在ruby3.2へのアップグレードで苦戦している状態であり、まず解決しなければならない問題がいくつかありますが、ここを乗り越えれば3.2->3.3のアップグレードはさほど難しくないはずなので、頑張ろうと思います。

debug gem

ruby3.xで導入されたデバッグ用のgemです。

アップグレード作業で既に使っていますが、本格的にruby3.2(3.3)に移行すればdebug gem(rdbg)でデバッグが捗るはず。 現時点はターミナルと組み合わせていますが、後述のdevcontainerと組み合わせれば、vscode上で完結するようにもなります。

dev container

vscode上で使うことのできる、dockerを使った開発環境(ローカルサーバ)です。

ずっと秘伝のタレと化したdocker composeを使って開発してきたこともあり、dev containerへの移行をやっていませんでしたが、ruby3.2(3.3)への移行を機にdev container化を図ろうと思います。 昨年あたりまではエディタもまちまちだったので踏み切れませんでしたが、さすがにもうみんなvscodeなので・・・(かく言う私も昨年ごろemacsからvscodeに切り替えました)

TiDB

最近注目されている、mysq互換かつスケーラビリティの高いデータベースエンジンです。

ちょうどmysql8への移行でデータベース周りの見直しが進んでいますが、事例を見るとなかなか良さそうなので是非試してみたいと思っています。

RBS

rubyの静的型解析(型情報ファイル)です。

型なんて要らない、と思っていたんですがirbvscode上でのメソッド補完が効くようになって便利らしい(rubykaigiでめちゃくちゃアピールされた)ので来年は手を出してみようと思います。

passkey/FIDO2/webauthn

公開鍵認証を用いたパスワードレスの認証方式です。

長年クラシカルな認証方式を使ってきましたが、今年はユーザの増加に伴い不正アクセスの試みを受けるなど、セキュリティ上の懸念点が出てきた年でもありました。 不正アクセスのほとんどはメールアドレスとパスワードの使い回しによるものです。 メールアドレスを用いた従来のオンラインサインアップはパスワードの使い回しをする人が多く、どこかのサービスからメールアドレスとパスワードの組が漏れると芋づる式にやられてしまう問題を秘めています。 以前はSNSログインを推奨してきましたが、TwitterAPI有料化騒動でSNSログインも難しくなってきており、一方で今や生体認証のない端末の方が珍しくなってきているため、今後はpasskeyによる認証が解決の糸口になると思っています。

Idempotency-Key Header

ヘッダに一意のキーを持たせることで多重POSTを防ぐ仕組みです。

同じリソースに対するPOSTリクエストが複数回来た場合に多重に更新されることを防げます。 今までこの問題に対する対策はクライアント側の実装によるしかなく、特にネットワークの切断を考慮しなければならないスマホアプリでは設計の複雑化を招いていましたが、この技術でシンプルにすることができるでしょう。 まだドラフトの段階だと思いますが、実装はそれほど難しくないと思います。

自作キーボード関係

今年は2つ(+マクロパッド)作りました。

来年も何か1つは作りたいなぁ。無線化も試してみたい。

rubykaigi2024@那覇

技術ではないですが、来年も必ず行きます。

数年ぶりの沖縄も楽しみです。 毎年参加していますが、今年のkaigiは一人ではなく社内からも数名参加した人がいて、現地で交流したりもできました。 参加した人からは会社で何かしたいという要望も出ているので、一緒に検討していきたいと思います。

それでは皆さん、良いクリスマスを。

RNIアドベントカレンダー2023 4日目 iOS TCAへの移行を行ってみた感想と既存との調整点

RNIアドベントカレンダー2023 4日目

こんにちは。リサーチ・アンド・イノベーションの小川です。 iOSエンジニアとしてCODEアプリの開発を担当しています。



CODE iOSチームの2023年の一番大きかったトピックとしてはTCAへの移行でした。

TCAとは

公式リポジトリ

ここ近年どんどんメジャーになってきているアーキテクチャ

The Composable Architecture の略。pointfree.coが開発。

Reduxとかなり近い構成です。

スマホアプリは画面の生存期間が長く、Webアプリと同等以上にRedux的アーキテクチャの恩恵を受けられると思ったので採用の候補にしました。

移行してみて感じたこと



公式が掲げる 「開発者にとっての使いやすさ」というのを非常に実感しました。

色々利点は感じたのですが特に感じたのは、TCAであれば全ての画面やコンポーネントを同じ形で表現できるということのメリットです。
これにより「驚き最小の原則」が守られると感じました。


また、インターフェースに一貫性があることでコンポーネントを分業で実装することが容易になりました。

実装する際の理解

実装を始めて当初はなかなかその記述の意味が分からず、コピーして試行錯誤していました。

慣れてきた今、以下は改めて認識した事項となっています。

コンポーネントの実装で集中すれば良い点

  • そのコンポーネントが持つ状態を把握し記述する
  • アクションによって状態がどのように変わるかを記述する
    • もし自分の状態が変わらないのであれば何もしないことを記述すれば良い

コンポーネントを結合する際の前提

結合の際、親が子のアクションを利用する部分をEnumで網羅的に記述できるのでどのように連携しているのかの把握もしやすいです。

TCAではEnumを多用しており、associated valueの中身にさらにEnum入れ子にする記述で親と子の関連をシンプルな記述で実現しています。

コンポーネントが状態を持たないシンプルな例

以下のようなカウンターで考えます。

コンポーネントの実装方針は以下になります。

  • 状態
    • なし
  • アクション
    • + ボタン押下
      • 何か変化することはない(ただ実行されることを書くだけ)
    • - ボタン押下
      • 何か変化することはない(ただ実行されることを書くだけ)

具体的には以下のように非常にシンプルなものになります。

public struct ChildViewReducer: Reducer {
    public struct State: Equatable { // 状態はなし

    }

    public enum Action: Equatable { // アクションの定義
        case plus
        case minus
    }

    public var body: some Reducer<State, Action> { // アクションによって状態がどう変化するか
        Reduce { state, action in
            switch action {
            case .plus, .minus: 
                return .none // このコンポーネントの状態は何も変化しない
            }
        }
    }
}

View側の実装は以下です。+ , - ボタンが存在し、アクションを送信するのみです。

public struct ChildView: View {
    public let store: StoreOf<ChildViewReducer>
    public var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            HStack {
                Button("+") {
                    viewStore.send(.plus)
                }
                Button("-") {
                    viewStore.send(.minus)
                }
            }
        }
    }
}

コンポーネントの実装は以下になります。

  • 状態

    • カウントの数字
    • 子供用の状態
  • アクション

    • 子供のアクションを捕捉する
      • + 押下
        • カウントを1増やす
      • - 押下
        • カウントを1減らす

親のアクション定義で子供のアクションを利用する際は case xxx(子供のアクション) と宣言できます。

以下の例では child という名前のアクションに子供のアクションをマッピングしています。

public struct ParentViewReducer: Reducer {
    public struct State: Equatable { // 状態はカウントの数字, 子供用の状態を持つ
        public var count = 0
        public var childState = ChildViewReducer.State()
    }

    public enum Action: Equatable {
        case child(ChildViewReducer.Action) // 子供のアクションを中身とするアクションを定義
    }

    public var body: some Reducer<State, Action> { // 子供のアクションを利用して自分のカウント状態を変更
        Reduce { state, action in
            switch action {
            case .child(.plus):
                state.count += 1
                return .none
            case .child(.minus):
                state.count -= 1
                return .none
            }
        }
    }
}

Viewは以下となります。

コンポーネントを使用する際に scope にて自分が持つ 子供用の状態子供のアクションを中身にとるアクション を指定します。

public struct ParentView: View {
    public let store: StoreOf<ParentViewReducer>
    public var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            VStack {
                Text(String(viewStore.count))
                ChildView(store: store.scope(state: \.childState, action: ParentViewReducer.Action.child))
            }
        }
    }
}

このように実装しますが、各々のコンポーネントを一貫したルールで連結できるのがとてもよかったです。

あと、外界とのやり取りはすべてDependencyとして定義する点も一貫性があって理解しやすく、モックデータの用意の意識も必ずすることになるので動作検証もスムーズに行えました。

既存実装との調整について


リプレースにあたり、なかなか全てをいきなり入れ替えるのは難しかったので一部分をTCAに置き換え、徐々にその領域を広げていく方針を取りました。



非TCA実装からTCAデータの更新を行う場合

データ更新のタイミングでNotificationを送信し、それをTCAのrootのViewが受け取ってReducerのActionを呼び出すことで実現しました。

例えば以下のような形です。

.onReceive(NotificationCenter.default.publisher(for: Notification.Name(rawValue: Constants.NotificationName.updateTCA))) { _ in
    viewStore.send(.updateFromOldData)
}

既存画面(UIKit)からのTCA画面呼び出し

こちらはSwiftUI Viewと同様UIHostingControllerで実現しました。

let view = TCAView(store: Store(initialState: .init()) {
    TCAViewReducer()
})
let hostingVc = UIHostingController(rootView: view)

途中にUIKitが挟まる場合

遷移上、途中のステップに既存画面(UIKit)が挟まる場合と挟まらない場合が生じました。

  • パターン1: TCA画面(A) -> TCA画面(B)
  • パターン2: TCA画面(A) -> UIKit画面(X) -> TCA画面(B)

このパターン2についてですが、UIKitの画面(X)に(B)のStoreをpropertyとして持たせ、(A)から(X)に遷移する際に(A)->(B)にscopeしたStoreを持たせることで実現しました。

ちょっと言葉だと分かりづらいですが、以下な感じです。

// AからXの遷移部分のコード
let xViewController = ... 
xViewController.bStore = store.scope(state: \.b, action: AReducer.Action.b)
// xViewController を UIWindowSceneから取得したViewControllerから表示する

// XからBの遷移部分のコード
let view = BView(store: bStore)
let hostingVc = UIHostingController(rootView: view)
self.navigationController?.pushViewController(hostingVc, animated: true)

まとめ

TCAリプレースにより、開発体験がかなり改善されました。

まだ移行完了しているのは一部なので、引き続きTCAへのリプレースを継続しています。


リサーチ・アンド・イノベーションではエンジニアを募集中です。

興味を持っていただけた方はお気軽にご連絡ください!

採用情報 r-n-i.jp

RNIアドベントカレンダー1日目 railsアップグレードとのび犬問題

リサーチ・アンド・イノベーションの横山です。 アウトプットをさぼってさぼってもう5年ほどになりますが、うっかり「うちもアドベントカレンダーってやらないんですか?」と口走ってしまったため言い出しっぺの法則で記事を書くことになりました。 しばらくの間お付き合いください。

自己紹介

10年以上流しのエンジニアをしております。 いろいろな会社で働いてきましたが、ここRNIはお酒好きな人が多いのでやりたいことをやらせて貰えるのでなかなか居心地が良く、気が付けば5年ほどお世話になっています。 Rails2.0の頃からrailsで仕事をしており、趣味で書いていた頃も含めるとruby歴は20年以上になるでしょうか。 ハチドリ本も持っています。ハチドリだったの知ったのは最近ですけど。

プログラミング言語Ruby(2009)

railsアップグレードへの長い道のり

さて、稼働中のサービスあるあるですが、弊社のサービスも一部古いバージョンのrubyrailsで動いています。 アップグレードしたいと思いつつ、最近まで人手不足で手が出せませんでしたが、余裕ができたので今年大きく動き始めました。 最も懸念となっているのがrails5.1で動いているサービスです。 ruby2.xがEOLとなったので(なる前からアップグレードは始めていたのですが)一刻も早くアップグレードしなくてはなりません。 中断を挟みつつ1年近くかけてようやく終わりが見えてきましたが、途中でぶつかった最も大きな壁について書いてみます。

背景

問題のサービスは(歴史的経緯から)別のサービスと密に結合しており、同じRDBMS上に2つのデータベースを置いて相互に読み書きできるようにしています。 以後はこれをサービスAlpha、サービスBravoとします。 AlphaのActiveRecordのクラスは素直に実装されており、基底クラスでデータベース名としてbravoを付加し、テーブル名にalphaを付加するようにしている以外、特に意識せず利用できるようになっています。

class Bravo < ApplicationRecord
  self.abstract_class = true
  establish_connection :"bravo_#{Rails.env}"

  class << self
    private

    def database_name
      Rails.configuration.database_configuration["bravo_#{Rails.env}"]['database']
    end
  end
end

class Company < Bravo
  self.table_name = "#{database_name}.alpha_company"
  ...
end

BravoからAlphaのクラスを使う時は、NamespaceにAlpha::を付加しています。

module Alpha
  def self.table_name_prefix
    'alpha_'
  end
end

class Alpha::Company < ActiveRecord::Base
  ...
end

これで名前空間を分けた状態で共有するモデルを扱えます。

polymorphic関連

この構造でbelongs_toやhas_manyなどrailsの関連はすべて透過的に使えるのですが、polymorphic関連だけ問題があります。 おさらいになりますが、polymorphic関連とは関連付けられるモデルのクラスを限定せず、規定のカラムを用意することで関連先のモデルを限定せずに関連付けられる仕組みのことです。 例えばMemberというクラスがCompanyまたはPartnerクラスに関連付けられる仕組み場合は以下のようにします。

class Company < ApplicaionRecord
  has_many :members, as: :employer
end

class Partner < ApplicaionRecord
  has_many :members, as: :employer
end

class Member < ApplicaionRecord
  belongs_to :employer, polymorphic: true
end

Memberモデルにemployer_id, employer_typeというカラムを用意し、employer_typeに関連付けるモデルのクラス(CompanyまたはPartner)を入れると、いずれかのクラスに関連付けられ、partner.employerで呼び出すことができます。

名前空間を付与したクラスのpolymorphic関連

polymorphic関連はクラス名を指定する必要があるため、前述のnamespace付加と組み合わさると正しく動作しません。 そこでAlpha側に以下のようなコードが書かれていました。 (concerns以下に置かれ、必要なモデルでincludeしている)

module PolymorphicBravo
  module ClassMethods
    def has_many_polymorphic_bravo(relation_name, as:, **options)
      class_names = [name, "Alpha::#{name}"]
      has_many relation_name, -> { where("#{as}_type" => class_names) },
               options.merge(foreign_key: "#{as}_id")
    end

    def has_one_polymorphic_bravo(relation_name, as:, **options)
      class_names = [name, "Alpha::#{name}"]
      has_one relation_name, -> { where("#{as}_type" => class_names) },
              options.merge(foreign_key: "#{as}_id")
    end
  end
end

has_many_polymorphic_bravo が宣言されると

  • has_manyを定義する
  • has_manyのscopeとしてtypeカラムに 通常のクラス名 or Alpha::のついたクラス名 の条件を加える
  • foreign_keyを設定する

という動作をします。

railsアップグレードで発生した現象

さて、これらを踏まえた上で、rubyrailsを交互にアップグレードして行きましたが、rails6.0にアップグレードしたところで上記のpolymorphic_bravoが動作しなくなりました。 具体的には

class Company
  has_many_polymorphic_bravo :members, as: :employer
end

class Member < ApplicaionRecord
  belongs_to :employer, polymorphic: true
end

このようなモデルで company.members.build した場合に、 employer_id: company.id, employer_type: 'Alpha::Company' なMemberインスタンスが生成されなければなりませんが、rails6では employer_id: company.id, employer_type: nilインスタンスが生成されます。

そもそもなぜtypeカラムに値が入る(入っていた)のか?

問題解決のためにrailsのコードを追っかけます。 has_manyにscopeを指定している場合、buildでscopeに合った値が入りますが、その仕組みは以下のようなものでした。 (以下、rails5.0-stableのコードを例に取ります)

company.members#buildActiveRecord::Associations::CollectionProxy で定義されている。

https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/associations/collection_proxy.rb#L292

def build(attributes = {}, &block)
  @association.build(attributes, &block)
end

@associationActiveRecord::Associations::HasManyAssociationインスタンスです。 ActiveRecord::Associations::HasManyAssociation#buildActiveRecord::Associations::CollectionAssociation で定義されています。

https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/associations/collection_association.rb

def build(attributes = {}, &block)
  if attributes.is_a?(Array)
    attributes.collect { |attr| build(attr, &block) }
  else
    add_to_target(build_record(attributes)) do |record|
      yield(record) if block_given?
    end
  end
end

build_record があからさまに怪しいです。 このメソッドは継承元の ActiveRecord::Associations::Association クラスで定義されています。

https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/associations/association.rb

def build_record(attributes)
  reflection.build_association(attributes) do |record|
    initialize_attributes(record, attributes)
  end
end

initialize_attributes も同クラス。

def initialize_attributes(record, except_from_scope_attributes = nil) #:nodoc:
  except_from_scope_attributes ||= {}
  skip_assign = [reflection.foreign_key, reflection.type].compact
  assigned_keys = record.changed
  assigned_keys += except_from_scope_attributes.keys.map(&:to_s)
  attributes = create_scope.except(*(assigned_keys - skip_assign))
  record.assign_attributes(attributes)
  set_inverse_instance(record)
end

はい、 create_scope が怪しいですね。 こちらは ActiveRecord::Associations::CollectionAssociation で定義されています。

https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/associations/collection_association.rb

def create_scope
  scope.scope_for_create.stringify_keys
end

scopeは同クラスで

def scope
  scope = super
  scope.none! if null_scope?
  scope
end

superActiveRecord::Associations::Association#scope なので

def scope
  target_scope.merge!(association_scope)
end

target_scopeは同クラス。

def target_scope
  AssociationRelation.create(klass, klass.arel_table, klass.predicate_builder, self).merge!(klass.all)
end

なんだか複雑なコードだけども、とりあえず AssociationRelationインスタンスを返していることが分かります。

create_scope に戻って AssociationRelation#scope_for_create を探すと、継承元の Relation クラスに定義されていました。

https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/relation.rb

def scope_for_create
  @scope_for_create ||= where_values_hash.merge(create_with_value)
end

where_values_hash は同クラス。

def where_values_hash(relation_table_name = table_name)
  where_clause.to_h(relation_table_name)
end

where_clause は includeされたモジュール QueryMethodsメタプログラミングされています。

https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/relation/query_methods.rb

Relation::CLAUSE_METHODS.each do |name|
  class_eval <<-CODE, __FILE__, __LINE__ + 1
    def #{name}_clause                           # def where_clause
      @values[:#{name}] || new_#{name}_clause    #   @values[:where] || new_where_clause
    end                                          # end
                                                 #
    def #{name}_clause=(value)                   # def where_clause=(value)
      assert_mutability!                         #   assert_mutability!
      @values[:#{name}] = value                  #   @values[:where] = value
    end                                          # end
  CODE
end

とりあえず置いといて、 to_h は一見組み込みメソッドだけど、requireされた ActiveRecord::Relation::WhereClause でオーバライドされています。

https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/relation/where_clause.rb

def to_h(table_name = nil)
  equalities = predicates.grep(Arel::Nodes::Equality)
  if table_name
    equalities = equalities.select do |node|
      node.left.relation.name == table_name
    end
  end

  binds = self.binds.map { |attr| [attr.name, attr.value] }.to_h

  equalities.map { |node|
    name = node.left.name
    [name, binds.fetch(name.to_s) {
      case node.right
      when Array then node.right.map(&:val)
      when Arel::Nodes::Casted, Arel::Nodes::Quoted
        node.right.val
      end
    }]
  }.to_h
end

詳細は省きますが、ここでscopeに与えられたwhere節がハッシュに変換され、 scope_for_create.stringify_keys に渡ってbuildされるモデルのattributesに追加されています。

rails6で動作しない理由

rails6では上記の scope_for_create のところが少し違って

https://github.com/rails/rails/blob/6-1-stable/activerecord/lib/active_record/relation.rb

def scope_for_create
  hash = where_clause.to_h(klass.table_name, equality_only: true)
  create_with_value.each { |k, v| hash[k.to_s] = v } unless create_with_value.empty?
  hash
end

となっています。 ActiveRecord::Relation::WhereClause#to_h も変更され

def to_h(table_name = nil, equality_only: false)
  equalities(predicates, equality_only).each_with_object({}) do |node, hash|
  next if table_name&.!= node.left.relation.name
  name = node.left.name.to_s
  value = extract_node_value(node.right)
  hash[name] = value
end

となりました。 equalities はメソッドに切り出され

def equalities(predicates, equality_only)
  equalities = []

  predicates.each do |node|
    if equality_only ? Arel::Nodes::Equality === node : equality_node?(node)
      equalities << node
    elsif node.is_a?(Arel::Nodes::And)
      equalities.concat equalities(node.children, equality_only)
    end
  end

  equalities
end

になっています。 詳細は省きますが、where_clauseノードが Arel::Nodes::Equality もしくは Arel::Nodes::And 以外は条件に入らないようになっています。 predicatesArel::Nodes::* クラスの配列で、 クエリを構造体にしたものが入っています。 そして、 通常の(=で比較する)WHERE条件は Arel::Nodes::Equality クラスですが、 IN句Arel::Nodes::Casted クラスになります。

つまり、rails5では Arel::Nodes::Casted クラスもscope_for_createのハッシュに格納されていましたが、 6では弾かれるようになりました。 これがbuildでtypeカラムがnilになる原因でした。

どうしてそうなった?

バグ修正 です。 この修正が入ったのは PR#41319

If a scope has IN cluase, scope_for_create which is passed to assign_attributes will include array values, and it will cause weird behaviors.

大変ごもっともです。だっておかしいもん。 IN句に与えられた配列がハッシュの値として格納された結果、配列の最初の値が初期値として与えられていました。 つまり has_many_polymorphic_bravo はこのバグによって一見正常に動作していたに過ぎなかったのです。 めでたしめでたし。

めでたくない。これを再実装するのは困難を極め、結局データ構造を大幅に変更してrails標準に近づけることになりました。 モンキーパッチはアップグレードの敵なのでやめましょう。

余談1 ところで「のび犬」って?

余談ですが、このように どちらでも動作するように配慮してしまう ことを個人的に のび犬問題 と呼んでいます。 「ドラえもん」で のび太が「太」の漢字の点が上だったか下だったか分からなくなり、両方に打った という故事(てんとう虫コミックス23巻「透視シールで大ピンチ」)に由来します。 多分他に誰も呼んでないと思う。 プログラミングをする時には曖昧さはできるだけ排除するべきである、という教訓でした。

余談2 調査方法

継承やメタプログラミングが多用されるrailsでは、コードを追っていくのが難しいですね。 今回はrails console+pry byebugを活用して追跡しました。 例えばrails console上で以下のようにして、コンテキストを切り替えて深く潜っていくことができます。

> company = company.new
> cd company #=> コンテキストがcompanyに切り替わる
> members #=> company.membersを返す
> @name #=> インスタンス変数の中身も見れる

メソッドの定義を探すには以下のようにします。

> company.members.method(:build).source_location #=> .../versions/2.6.3/lib/ruby/gems/2.6.0/gems/activerecord-5.0.7.2/lib/active_record/associations/collection_association.rb"

これらを上手く使えば、変数やメソッドの戻り値を参照しつつコードを追っていくことができます。

また、debug gemを使えばコードにブレークポイントを埋め込まずとも、コマンドラインオプションで任意の場所にブレークポイントを仕込むことができます。railsの任意の地点に潜っていくことができるので非常に便利です。 が、これを使うためにはruby3.0+(2.7にもバックポートされています)にアップグレードしなければならないので、卵が先か鶏が先か問題になってしまい、今回は使えませんでした。

RNIアドベントカレンダー2023

去年から今年にかけて、弊社リサーチ・アンド・イノベーションは仲間が増え、エンジニアチームも以前では考えられないような大所帯となりました。 せっかくなので、今年はいつもやろうと思いつつできなかったアドベントカレンダーをやってみることにします。 書く人と内容は以下の通りです。

Rubykaigi 2023 参加報告

5月11日から13日にかけて松本で開かれた Rubykaigi 2023 に一般参加者として参加しました。

著者の私は day 2, day 3 の2日間参加しました。参加費用・交通費・宿泊費は会社負担でした。 他の開発部メンバーは 4日間、3日間、2日間などまちまちです。サーバーサイド全員が留守にならないように調整しました。

久しぶりのオフラインイベント

行きは1時間早く仕事を終え、立川駅から松本駅まで特急「あずさ」に乗る予定でしたが、立川駅までの路線で線路に人が立ち入った影響で間に合うかはらはらしました。

あずさ車内では研鑽Rubyプログラミングを読んで気分を盛り上げていくつもりでしたが、電車の揺れに細かい文字を追うのをあきらめ。

夜遅い松本駅は出張帰りのスーツケースを引く人もちらほら。駅前のコンビニで食事を買おうとするとおにぎり・お弁当コーナーは売り切れ。ホテルに向かうと、飲み屋街は帰宅するお客さんを見送る時間でした。(地方の夜は早い)

2日目(12日)と 3日目(13日)の感想

型推論・型検査と開発体験の向上がホットなトピックでした。Visual Studio CODE + 型推論全部のせだと Ruby とは思えない開発環境になりそうです。

YJIT のマージに用意周到な戦略が取られていた話は力の入り方が伝わってきました。とにかく計測する戦略。本当に速くなりそうですね。自社のプロダクトも早く YJIT on にできるようにしたい。

QUIC 実装を Python からポーティングした話は苦労した点もよくわかり、ぜひ WIDE や研究者が QUIC の研究に活用してほしいなと思いました。

ruby の debugger を書く話が印象に残りました。

2年以上英語でしゃべってなかったのですが、Rubykaigi 聴講中に英語ヒアリング力がトレーニングされ、細かいジョーク以外は大筋話をおえてほっとしました。 なお、私の英語力は中学生英語程度です。自転車とネットワークの話をするときだけ単語力でブーストされます。

ブース

会社としてブース出店できるスポンサーになり、業界内で会社の知名度を上げようと計画していたのですが、まずは何をやったらいいのかを見てきました。各社みなさん工夫されていてエンジニアが時間を割いておもしろいことをしているブースが多かったです。ポスターセッション+コーヒー提供ていどでは興味を持ってもらえないなとわかったのは収穫です。来年、なにをやりましょうか。

全体の感想

おもしろかった。スピーカーの皆さんの着目している方向もおもしろかった。

若い方からは内容が難しいとの感想も聞いたのですが、振り返りの会などで解説しあって理解を深めることができるといいのかなと思います。

その場で盛り上がって、セッションの後で会話が始まったり PC を広げて相談が始まったりコードを書き出したり、 YJIT の話を聞いて Rubykaigi 中に勢いで本番環境を Ruby 3.2 + YJIT on へ移行したり、そういうきっかけになるのがよいと思います。

録画を見ようと思っているセッション

AWSサービスをフルに利用したデータ基盤リプレースの実施

こんにちは。リサーチ・アンド・イノベーションの塚田です。 サーバーサイドエンジニアとして弊社サービス CODEの開発やインフラ・データ基盤の整備を担当しています。

その中で今回はデータ基盤のリプレースに関した取り組みをご紹介したいと思います。

リプレースに至った背景

弊社のデータ基盤はAWS上に構築しており以下の理由からリプレースを進めることにしました。

処理時間の長期化

一番の理由はデータ量増大に伴う処理時間の増加で以下のような問題がありました。

  • DBに保存されているデータ量の増加に伴い集計処理時間も増加傾向にある
    • データ基盤利用者が業務でデータを利用する午前10時までに処理が完了するようにしていたが、ギリギリになってきてしまっている
  • リトライ機構を入れているが、リトライすると想定以上の時間がかかってしまう
  • データ基盤構築当時のデータ増加量を元に余裕を持ったリソースを用意していたが、枯渇するようになってきた
  • 処理の長時間化に比例してAWSリソースのコストも上がっている

AWS各サービスのアップデート

データ基盤構築時には使うことができなかった以下の機能がリリースされたため 組み合わせて使用することによって処理時間の高速化が期待できると考えました。

  • RDSスナップショットのS3エクスポート
  • Athenaエンジンバージョン2
    • リリース記事
    • 2022年12月現在エンジンバージョン3がリリースされていますが、リプレース当時はなかった機能なので、こちらは使用せずに進めます
    • エンジンバージョン2で使用するPrestoのバージョンが上がったことにより、使用可能なTimestampの精度が上がりリプレースが可能になりました

リプレースの方針

リプレースの方針を記載する前にリプレース前の大まかな処理の流れを記載します。

リプレース前

  1. データエクスポート処理
    1. AWS Batchを利用し以下の処理をテーブルごとに並列実行
      1. AuroraからCSVエクスポート実行
      2. Athenaで利用可能なようにCSVファイルのフォーマット処理を実行
      3. 2で作成したCSVをS3に配置しGlueのテーブル情報を更新
  2. 1次集計
    • データエクスポート処理で作成したファイルを利用してベースとなる各種データをAthenaで生成
  3. 2次集計
    • 1次集計のデータをもとにデータ基盤利用者がよく利用する情報をAthenaで生成

方針

ここで問題になっているのはデータエクスポート処理になるので、 1次・2次集計は手をつけずデータエクスポート処理のみを変更する方針としました。

リプレース後

リプレース後の処理は以下のようになりました。

  1. データエクスポート処理
    1. AuroraのDBスナップショットを生成
    2. DBスナップショットからS3エクスポートを実行
    3. Lambdaを利用しAthenaで利用可能なS3パスへPaquetファイルをコピー
    4. 3で作成したPerquetファイルをもとにGlueのテーブル情報を更新
  2. 1次集計(変更なし)
  3. 2次集計(変更なし)

実際には各処理の実行ステータスのチェックやエラーハンドリングをStep Functionsを利用して実施しています。

S3間のオブジェクトコピーのLambda以外はStep FunctionsのAWS SDK統合を積極的に利用し処理全体の見通しが良くなるように配慮しています。

実際にStep Functionsで実行しているデータエクスポート処理のフローが以下になります。

リプレースの成果

一番の問題点であった処理時間についてはリプレース前10時間程度かかっていたものが 2時間以内に処理が完了するようになり、リプレース効果は非常に高いものになりました。 また、リプレースしてから半年以上経過しその間にもデータ量は増大していますが、 処理時間はほとんど変化がなく安定的に利用ができています。

AWSの利用料の観点ではBatchがスナップショットエクスポートにおきかわったので、 劇的な変化はありませんでしたが、コストダウンも併せて達成できました。

構成変更により、スナップショットからのエクスポート処理の実行になったためAuroraの状況に影響せず独立した処理になり可用性も上がりました。

まとめ

課題・問題点に対してAWSの新しい機能を組み合わせることで改善した事例をご紹介しました。

実際にはリプレース前後の2環境を運用し利用者からのフィードバックをもらうようにしていましたが、 集計処理は変更していないため利用者側への大きな問題は発生せず運用スタートができました。

AWSは日々新しいサービス・機能を出していますので、改善につながるものは積極的に導入していきたいと考えています。


リサーチ・アンド・イノベーションではエンジニアを募集中です。

興味を持っていただけた方はお気軽にご連絡ください!

採用情報