TECH PLAY

タイミー

タイミー の技術ブログ

273

はじめに プロダクトチームの克海です。PdMの補佐をしながらプロダクトのデータアナリストをしています。 本記事ではアプリでのABを始めようといしている方に向けてのABテストの実施の流れと事例についてまとめた記事になります。 ABテストとは? ABテストとはランダム化比較試験ともいれる実験手法です。検証対象をランダムにグループ化して別々の介入をすることで「介入の効果」を図る手法の一つです。 メール マーケティング や広告、ウェブページの改善などのデジタル マーケティング や医学現場でも使われている手法で、多くの実験では変更を加えない「コン トロール グループ」と、変更を加える「テストグループ」を作り、実験を行います。実験を開始して2つのグループの違いを検証します。2つのグループに対して介入以外同一条件な状態を作ることで、バイアスがない状態で比較することができます。 実験を繰り返すことで、ユーザーが求めている価値の知見をため、サービスやアプリの最適化していくことができます。今回の記事では、ABテストした流れと結果について記載します。 目次 はじめに ABテストとは? 目次 なぜABテストを始めるのか? 取り組みの流れ 課題の洗い出し 実装のアイデア出し 実装 計測・振り返り タイミーでの実際のやり方 事例1 日付移動のスワイプを追加する 課題の洗い出し 実装のアイデア出し 実装 計測・振り返り 事例2 市区町村に関連する情報を一覧に追加する 課題の洗い出し 実装のアイデア出し 実装 計測・振り返り まとめ なぜABテストを始めるのか? もしデータがあるなら、データを見る。 もし意見しかないのなら、私の意見で行く。— Jim Barksdale ( Netscape 社の元CEO) この引用はデータ分析においてすきな名言です。 タイミーでは、サービスの認知度が増えたことや全国展開により一定以上のユーザーの利用がある状態になりました。 ユーザーの利用データも集まるようになり、開発チームも 定量 的データの分析を利用した開発の意思決定や開発の効果測定をし開発へフィードバックを利用して、開発価値を最大化していく必要がありました。 取り組みの流れ 改善を始める前に、取れているデータを整理したり、必要に応じて追加のデータを集める作業を行いました またチームとして現状の数字の感覚を掴んでもらうためにも、 ダッシュ ボードを作成し、毎日主要な数字を見てもらえる状態を作りました。数値の理解度が上がった段階で下記の流れで、ABテストを開始しました。 課題の洗い出し 定性的なユーザーからのお問合せや、 定量 的なデータを分析しユーザーの課題をいくつか発見します。 実装のア イデア 出し 重要そうな課題に対してチームで解決のア イデア のブレストを行いア イデア を出します。 実装 影響力、 工数 を考慮しながらプロダクトオーナーと相談しながら実装する内容を確定します。 プロダクトオーナーと相談し、実装しています。 計測・振り返り FirebaseAB Testingを利用してABテストを実行し、 Google Analytics を利用して計測しています。 有意差については今回深くは書きませんがABテストの結果が偶然によって起こる割合を出し、結果が誤差ではないことを判断します。 有意差の検定にはDataStudio や Python を使っています。弊社にはDRE(Data Reliability Engineering)チームが存在し、GoogleAnalyticsやプロダクトのデータをBigQuery上から分析する仕組みが整っています。様々な数字の変化をレポートにまとめ、振り返りを行い次の開発やアクションを決めています。 タイミーでの実際のやり方 以降ではタイミーで実際にABテストを行った際に各工程で行ったことをお伝えします。 課題の洗い出しは業務の中で複数並行して調査しています。また、実装のア イデア 出しは効率化のため複数のテーマの ブレインストーミング を同時に行っております。事例の中では関連のある部分を抜粋して取り上げます。 事例1 日付移動のスワイプを追加する 課題の洗い出し アプリのファネル分析をする中で登録から求人を見るまでに大きくワーカーが離脱する箇所がありました。 深堀りしていく中でワーカーが求人を探す段階で、より未来の求人を見るユーザーの割合が少ないことがわかりました。 ユーザーが日付移動に課題がありそうだとと分析しました。 実装のア イデア 出し 過去のUIでは、画面上部のボタンでしか移動することができないため気軽に移動することができませんでした。 その際に考えたア イデア が以下のようなものです。 画面内の任意の位置の横スワイプで日付を変更する カレンダーの右端に右矢印をつける カレンダーの使い方を説明する チュートリアル を作成する 実装 プロダクトオーナーと相談し、標準カレンダーにあるような横スワイプで移動する実装が直感的で効果が高く、かつ 工数 が低いと推定しこのア イデア を採用しました。 計測・振り返り リリース後は今までの数字との大幅な数字のブレが起きていないか数字で確認しながら、有意差を確認できるまで各種数字を見ながら計測していきます。今回追加した機能はアクティブユーザーの約3割が利用しており、複数の日程も見られるようになりました。しかしながら申込み人数の増加は増えず、登録から稼働までのファネルは改善しませんでした。知見としてレポートにまとめ、マッチングを増やすための、別の手段を検討しました。 事例2 市区町村に関連する情報を一覧に追加する 課題の洗い出し 求人の数も増え始めユーザーは多くの求人から、自分に合った求人を選ぶ必要性が出てきました。 改善要望にも、より詳しい勤務地を用いた検索がほしいという声が多くありました。 実装のア イデア 出し 過去のデザインでは 都道 府県に関する検索はできるものの、求人のより詳細な勤務地は詳細画面に進まないと確認することができませんでした。 距離を利用した順番にする エリア指定で検索できる 市区郡に関連する情報を一覧に追加する 実装 勤務地が遠い求人が一覧画面からわかることの影響力を高く評価し、 工数 との比較から市区町村の情報を一覧画面に追加する実装を行いました。 計測・振り返り 申込数が増え案件表示数が減る結果になりました。また市区町村の情報を見つけやすくなったことでお気に入り関しても微増しました。申し込み率に対して有意差ありで約10%も改善しました。レポートにしてチームの知見として蓄積しています。 まとめ 今回は2つのABテストについて紹介しました。弊社では様々なABテストを行っています。仮説通りユーザーに受け入れられるのか、ユーザーが求めているものの解像度を上げ知見を増やしていくためにもぜひ、ABテストに挑戦してみてはいかがでしょうか?是非参考にしてみてください。
アバター
こんにちは、 iOS チームの阿久津( @sky_83325 )です。 今回も 前回の記事 に引き続き、マルチモジュール開発についての記事です。 タイミーでは2019年7月より、機能単位でFrameworkを分割するマルチモジュール開発に取り組んできました。 現在では全体の約8割がモジュール分割され、27個のモジュールよりアプリが構成されています。 ここ数年で iOS におけるマルチモジュール開発に対する関心が高まり、多くのプロジェクトで導入され始めているのではないでしょうか。 マルチモジュール化することで次のような恩恵を受けることが出来ます。 ビルド時間の軽減 影響範囲の限定 ミニアプリを用いた機能単体での動作確認 このような大きな恩恵がある一方で、なんとなく難しそうなイメージがあったり、大規模な開発チームでのみ採用されてる印象があり、導入を見送られているチームも多いのではないでしょうか。 タイミーでは、1~3名という小規模なチームでマルチモジュール開発を続けてきました。 この記事では、実際にそのような小さなチームで運用してみて感じたこと、学んだことを共有したいと思います。 マルチモジュール開発に興味のある方や、実際に導入を検討されている方の参考になれば幸いです。 マルチモジュール開発とは 取り組んだ背景・経緯 目的① ビルド時間の短縮 目的② ミニアプリの導入による開発時のデバッグ効率の向上 どのように移行していったのか 構成はFeature x Layer形式を採用 課題① 既存の密結合な実装 課題② 各機能間の画面遷移をどうするか 実際に運用してみてどうだったのか ビルド時間が短縮された 開発時の動作確認が容易になった 実装が矯正された 仕組みとして疎結合な実装が保証された 1. 動作確認(QA)のコストが減った 2. 実装のキャッチアップが容易になった 3. 負債が限定された 終わりに マルチモジュール開発とは マルチモジュール開発とは、ある機能や責務を持ったレイヤーを独立したモジュールとして実装し、それらを組み合わせて1つの iOS アプリを開発することをいいます。そして、それらのモジュールはFrameworkやLibrary、Swift Packageといった形で管理されます。 他方モノリシックな開発では、ある機能や責務を持ったレイヤー全てが1つのモジュールで実装されます。そして、それらの機能や責務は1つの ディレクト リとして管理されることが多いです。 言い換えれば、マルチモジュール開発ではある責務を持ったコードを ディレクト リとしてではなくSwift PackageやFramework、Libraryといった形式で管理することになります。 そして、モジュール化することでネームスペースが完全に分かれそれぞれの実装を蘇結合に保つことが出来たり、必要な部分のみビルドを実行できたり、特定の機能/役割を再利用したりすることが可能になります。 取り組んだ背景・経緯 当時のタイミーはリリースから1年が経ち、主要機能の開発が落ち着き始めていたタイミングでした。 同時に、ビルド時間の増加などいくつかの問題に悩まされており、それを解決する手段としてマルチモジュール開発に興味を持ちました。 当時考えていた、マルチモジュール化の目的は以下の2つでした。 ビルド時間の短縮 ミニアプリの導入による開発時の デバッグ 効率の向上 目的① ビルド時間の短縮 機能の増加に比例して、ビルド時間が継続的に伸びてしまっていました。 細かなViewの調整に対しても一定のビルド時間がかかってしまうことは時間的にも、開発体験的にもかなり辛いものになっていました。そのような状況の中、必要なモジュール単位でビルドをし、動作確認を出来るマルチモジュール開発に大きな魅力を感じました。 目的② ミニアプリの導入による開発時の デバッグ 効率の向上 またマルチモジュール化に伴い、機能単位で デバッグ を可能にするミニアプリも導入も検討しました。 機能によっては、その画面へたどり着く条件が限られており、動作確認が難しくなってしまっている場合があります。 例えば、ウォークスルーの場合は初回起動時にしか表示されないため、毎度アプリを削除し、再インストールする必要があります。その他にも、商品購入後にしか辿り着けない画面があった場合は、 デバッグ の度に商品の作成&購入をする必要があります。 ミニアプリを作り、特定の機能に直接アクセス出来るようにすることでこのような障壁を取り除けることに魅力を感じました。 この2つが主なマルチモジュール化へ移行した理由ですが、それに加えて 純粋に楽しそうだったから と言うのも1つの大きな理由です。 どのように移行していったのか 構成はFeature x Layer形式を採用 アプリをモジュール分割する際に、どのような構成で分割するかを決める必要があります。 機能(Feature)単位でモジュールを分割する方法や、Domain LayerやData Layer、Presentation Layerというような一定の責務を持ったレイヤー単位で分割する方法がありますが、タイミーでは「ミニアプリの導入による開発時の デバッグ 効率の向上」をマルチモジュール化の1つの目的としていたため、大きな方針としては機能単位でモジュールを分けることにしました。また、既存のモノリシックなアプリ構成も機能単位でフォルダ分けをしていたため、機能単位で分割する方が適していたということも理由の1つです。 基本的には機能単位でモジュールを分割しつつ、機能を横断して共通で使いたい部分は別途モジュールとして切り出しました。 例えば、通信を担当する箇所や、アプリ全体で利用するモデル(Entity)などです。 具体的には次のような構成を目指しました。 課題① 既存の密結合な実装 既存のアプリ構成も機能単位でフォルダを分けていたため、モジュール分割も容易かと思われましたが実際は違いました。 機能間でシングルトンなオブジェクトを共有していたりなど、所々密結合な実装が存在していました。 そのため、既存のプロジェクトを一気にモジュール化することは出来ませんでした。 以下のような手順で徐々にモジュール分割を進めていく方針を取りました。 各機能で共通して使う部分をモジュールに切り出す どこか1つの機能をモジュールに切り出す 以降は新規実装や リファクタリング のタイミングで継続的にモジュール分離する はじめに、機能横断で利用したい共通部分をモジュール化し、その後1つの機能を実際にモジュール化してみました。 ここまでを最初の段階とし、それ以降は リファクタリング のタイミングであったり、新規開発の際に徐々にモジュール移行するようにしました。 そのように順番に移行することでモジュール化された部分には、既存の負債が混ざらないようにしました。 課題② 各機能間の画面遷移をどうするか 徐々に機能をモジュール分割する中で、各機能間の画面遷移をどう実装するかが問題になりました。 一番シンプルな解決策としては、遷移先のモジュールをimportし、遷移先のViewControllerを直接呼び出すことです。機能Aから機能Bへ一方向にしか遷移しないのであればこのような実装方法でも大丈夫かもしれませんが、必ずしもそれが保証されるとは限りません。また、各機能を 疎結合 に保つためにも、機能モジュール間で依存することは望ましくありません。 そのため、全てのモジュールを知っているAppに画面遷移を委ねる必要があります。 画面遷移のロジックを抽象化したインターフェースを定義し、それをAppから各機能モジュールへ注入することで、機能間の画面遷移を実現しました。 具体的には次のように行いました。 Routerのインターフェースを全ての機能モジュールが依存している箇所へ定義する 各機能モジュールではそのインターフェースを呼び出して画面遷移を実行する Appでそのインターフェースの実装をする。 各機能モジュールへそれを注入する。 このようにマルチモジュール開発における依存関係は、うまく抽象に依存させながら解決していくことになります。 実際に運用してみてどうだったのか マルチモジュール化を運用してみて、当初想像していた以上に様々な恩恵を受けることが出来たのでそれらを紹介していきます。 ビルド時間が短縮された ある機能を動かすために必要最低限のモジュールのみをリンクしたミニアプリを用意することで、開発時に都度アプリ全体をビルドする必要がなくなりました。その結果、開発時のビルド時間が大幅に削減されました。 また、テストの実行もモジュール単位で行えるため、時間が大幅に短縮されました。 開発時の動作確認が容易になった ミニアプリを用いることでウォークスルーなどの特定の条件下でしか表示されない画面などにも直接アクセスできるようになったため、簡単に動作確認を行えるようになりました。 さらに、 コンポーネント 化されたViewを確認する開発時専用のアプリを作ることで、より開発効率が向上されました。 実装が矯正された モジュール化することで、より一層レイヤーや依存を意識した開発をする必要が出てきます。 全ての機能を単一モジュールで実装している場合、意図せず、もしくは妥協の末、密結合な実装をすることが出来てしまいます。例えば、シングルトンな インスタンス を作成し機能を跨いで共有することなどです。 しかし、 モジュール分割することでそのようなスコープを超えた実装を仕組みとして防ぐ ことが出来ます。 さらに、そのように「実装の自由」を制限することで、自ずと各モジュールの責務を深く検討するように(検討せざるを得なく)なりました。結果として、プロジェクト全体のコード品質が上がりました。 仕組みとして 疎結合 な実装が保証された 前述したように、モジュール分割することで 疎結合 な実装が仕組みとして強制されます。その結果、変更の影響範囲ががそのモジュール内に限定されます。それによりいくつかの恩恵を得ることが出来ました。 1. 動作確認(QA)のコストが減った 基本的に、あるモジュールを変更した場合は、そこを部分的に動作確認するのみでよくなりました。毎回のリリース時の動作確認コストが減った結果、より継続的に高頻度でリリースすることが可能になりました。 2. 実装のキャッチアップが容易になった 新しくメンバーを採用した場合、そのメンバーが初めての開発の際にキャッチアップすべき領域も限られるため、効率的にオンボーディングを行うことが出来るようになりました。 3. 負債が限定された また、一部の機能や、実装に負債があったとしても、影響範囲がそのモジュール内に限られているため、ほかに負債が伝播することもありません。また、1つ1つのモジュールは小さく出来ているため、最悪そのモジュールを切り捨て、再実装することも可能です。 このように 疎結合 な実装が仕組みとして求められるようになり、様々な恩恵を得られるようになりました。 終わりに 今まではEmbeded Frameworkを利用したマルチモジュール化が主流でしたが、最近だとSwift Package Managerを利用しSwift Packageとしてモジュールを管理することが可能になっています。Swift Packageとしてモジュールを管理する場合、 Package.swift に各モジュールの依存関係を記載していくことになりますが、それ以外は通常のモノリシックな アプリ開発 でフォルダを分割するのとほぼ似たような感覚でモジュール分割を行えるため、今まで以上にマルチモジュール化のコストが下がりました。 Swift Packageを利用したマルチモジュール開発に関しては以下の記事にもまとめてありますので、是非参考にしてみてください。 tech.timee.co.jp
アバター
はじめまして!フロントエンドエンジニアの樫福 @cashfooooou です。 タイミーでは Next.js × TypeScript で toB 向け管理画面を作成しています。 この記事は、 toB 向けの管理画面の開発時に筆者が気づいた コンポーネント 間の責務の明確化の必要性と、 TypeScript の型を用いて責務の分割をサポートする方法の紹介しています。 背景 利用者の様々なニーズに応えるために、 toB 向け管理画面には様々なページが実装されています。 2つ以上のページを実装していると、それぞれのページで実装の粒度がバラバラになることがあります。 一方ではフックの中で実装していたようなロジックが、他方では コンポーネント で実装されている あるページの コンポーネント は複数のファイルに分割しているけど、こちらのページでは巨大な一つのファイルで実装が完結している 属人的な責務の分割が失敗した様子 Next.js は pages/ 配下にファイルを追加すると自動的にルーティングが作成されますが、 ディレクト リ構造についてのそれ以外の制約はありません。 実装やレイヤ分けは実装者に委ねられており、責務の分割をしっかりやるためには実装もレビューも "人が頑張ってやる" ことが求められます。 例えば、 Atomic Design という コンポーネント の粒度とその依存関係を定義したデザイン フレームワーク が存在します。 Atomic Design のように コンポーネント を細かく分割する際には実装者やレビュー担当者によって責務が精査される必要があります。 責務の分割を適当にやったり人によって判断がぶれたりすると、システム全体を見た時に一貫性が失われてしまいます。 コンポーネント の責務が揃っていると次のようないいことがあります。 設計思想に則ってロジックが分割されるので コンポーネント が単一責任を担うようになる 抽象化、具体化をレイヤを意識して行うので結果的に再利用性が高くなる Unit Test や Visual Regression Test の実装や管理のコストが小さくなる コーディング ガイドライン が整い、実装者が迷うことなく書くべきコードに集中できる しかし、新しい コンポーネント を実装するたびに人の判断で コンポーネント の責務が適切に分割されているかを判断するのは簡単ではなく、実装やレビュー担当者の力量や考え方に依存します。 結果として、システムが入退社等に伴うエンジニアの交替に弱かったり考え方の変化によってシステム全体で方針が統一できなかったりします。 責務の境界の曖昧さを何か 機械的 な方法でなくせると、属人的ではなくなり一度決めた方針を貫けるので嬉しいです。 実現方法 コンポーネント の受け取るデータの型を基準にして、適切な コンポーネント の責務の分割を実現します。 適切な型を用いて責務を表現し、型を中心にして一貫性のある責務が明確になっている状態を目指します。 型をもとに責務の分割を行い、属人的ではなくなった様子 責務を表現する型を作るために、 コンポーネント が「何をすべきか」と「何をしてはいけないか」を明確にしてみましょう。 例として、 Atomic Design で 何かのデータをリスト形式で表示する UI を実装することを考えてみましょう。 Atomic Design は 5段階の要素(atoms、molecules、organisms、templates、pages)のうち、 organisms - molecules 間の責務について取り上げてみます。 リストの形式のデータを持つ時はそれぞれの責務のうち、とくに「何をすべきか」は次のようになります。 organisms リストの繰り返しの扱いを担うべき *1 molecules リスト中の単一のデータについて、そのデータの描画を担うべき 一方の コンポーネント の「何をすべきか」がわかると、 他方の「何をしてはいけないか」も見えてきます。 organisms 単一データの描画について担ってはいけない molecules リストの繰り返しを扱ってはいけない 「何をしてはいけないか」という制約を明確にすることは、責務の分割にとても有用です。 実装レベルで 、 molecules が受け取るデータの型が何かのリストの形式 Array<T> になっているならば責務の分割がうまくできていない ことを疑うことができます。 以降では、責務の分割のための具体的な型とそれを用いた コンポーネント の実装まで提示していきます。 具体的な例: API のレスポンスを表示する コンポーネント 一般的なアプリケーションの例として、 RESTful API のレスポンスを表示するページの実装を挙げます。 こちらのデモ と併せて読んでいただけると、より理解が深まると思います。 以下に定義するコードは src 以下に配置されています。 API クライアントはモックを使用しており、取得成功と取得失敗(とそれぞれの取得中)の動作を確認できるようになっています。 API クライアントの定義 次のようなユーザの投稿を扱う型 Post と API クライアント apiClient を使用します。 以降では、ユーザの投稿を一覧表示する PostPage の実装を考えます。 type Post = { postId: number ; content: string ; postedAt: string ; } ; const apiClient = { fetchPosts: async ( userId: number ) => { const { data } = await axios. get< Post [] >( `https://xxx.com/api/{userId}/posts` , { params: { userId } } ); return data ; } , } ; ディレクト リの責務の定義 コンポーネント の ディレクト リとして「 pages 」「 domains 」というものを作ることとします。 それぞれの責務として「何をすべきか」と「何をしてはいけないか」は次のように定めます。 pages コンポーネント 内で API レスポンスを受け取り、状態に応じて表示を変える データの具体的な描画の実装をしてはいけない domains データを描画するテーブルなどの コンポーネント を実装する 渡されたデータを扱うだけで API レスポンスの状態に依存してはいけない ここでいう「 API レスポンスの状態」というのは、取得時の待ち状態やエラーを指しています。 責務を明確にすること、とくに「してはいけない」を明確にすることによって、本来の責務に集中して実装することができます。 型の定義 API レスポンスの状態を扱うために、次の ジェネリック 型 ApiState を定義します。 type ApiState < T > = | { type : 'loading' ; } | { type : 'error' ; errorMessage: string ; } | { type : 'success' ; result: T ; } ; domains の責務の 2つ目である「あくまでデータを扱うだけで API レスポンスの状態に依存してはいけない」 を満たすことはとても簡単で、 ApiState 型のデータを受け取らない とするだけで良いです。 これだけで、実装者もレビュー担当者も domains の コンポーネント のプロパティに注目することで不適切な責務の分割が行われていないか確認できます。 さらに ESLint などで 「 domains の ディレクト リ配下での ApiState のインポートを禁止」というルールを定めれば、 機械的 に判断することすら可能です。 *2 apiClient を用いて Post[] 型のデータを取得するフック、 usePostLists を次のように実装します。 // usePosts は userId を受け取って ApiState<Post[]> を返すフック interface IUsePosts { ( userId: number ) : { postsState: ApiState < Post [] >; } ; } export const usePosts: IUsePosts = ( userId ) => { const [ posts , setPosts ] = useState < Post [] | null >( null ); const [ error , setError ] = useState < string | null >( null ); // エラーになったとき、かつエラーの時のみ string型のエラー文になる useEffect (() => { (async () => { try { const result = await apiClient.fetchPosts ( userId ); setPosts ( result ); } catch ( e ) { setError ( errorToString ( e )); } } )(); } , [ userId ] ); const postsState: ApiState < Post [] > = error ? { type : 'error' , errorMessage: error } : posts === null ? { type : 'loading' } // エラーでなく、かつ取得前なので取得中 : { type : 'success' , result: posts } ; return { postsState } ; } ; フックについても、 API を扱うフックの返り値を「ある型 T に対する ApiState<T> 」とすることで、 API レスポンスを扱うという責務を明確にすることができています。 この時点で、 pages の責務を満たす実装はとても楽になっています。 Post[] 型のデータを受け取って投稿一覧を表示する domains の コンポーネント PostList 、エラーメッセージを受け取ってエラー状態を表示する コンポーネント ErrorView 、読み込み状態を表示する コンポーネント LoadingView という 3つの コンポーネント (実装例は省略)を用いて、 pages/PageListPage は次のように実装できます。 const PostListPage: React.FC < { userId: number } > = ( props ) => { const { postsState } = usePosts ( props.userId ); if ( postsState. type === 'success' ) return < PostList data = { postsState.result } / >; // postsState.result の型は Post[] if ( postsState. type === 'error' ) return < ErrorView errorMessage = { postsState.errorMessage } / >; return < LoadingView / >; } ; たったこれだけのコードで pages の「 コンポーネント 内で API レスポンスを受け取り、状態に応じて表示を変える」という責務を満たしています。 また、 domains である PostsList の「 API レスポンスの状態に依存してはいけない」という制約も満たされていることがわかります。 型を導入することで、その型を中心に責務が明確になっています。 実装者もレビュー担当者も、この型の情報を責務の分割の指標として使うことができます。 ライブラリを使用する際の利点 責務が明確になっている状態であれば、実装の一部をライブラリに差し替えや使用するライブラリを置き換えが影響範囲を小さくすることで容易になります。 たとえば、React 16.6 で実験的に追加された機能の Suspense は、 コンポーネント が API レスポンスの状態に依存しないようにしているという点において上記の実装とよく似た用途で使用することができます。 紹介したような実装方法をとっていて Suspense を使った実装に切り替える場合、 pages と一部のフックを修正するだけで他への影響はほぼ発生しないです。 実装時の注意 これまでに出した例は非常にシンプルなものなので簡単な実装で済みました。 しかし、現実にエンジニアリングで向き合う問題は必ずしもシンプルではないです。 あるタイミングで責務を満たす型を作成したとしても、他の機能の実装中にエッジケース(例外)が見つかることがあります。 エッジケースに対して アドホック に型の修正を行うと、責務の本質から大きく逸れた歪な型になってしまいます。 他方でエッジケースを例外的に扱う癖がついてしまうと、責務を担う型自体が形骸化してしまいます。 型自体を常に精査し続け必要に応じて更新していくことで責務が明確な状態を保つことが、システムの質を高い状態で保つには不可欠です。 しかしながら、無自覚に責務から逸脱するコードを書く心配がなくなり、型の修正がそのまま責務の再定義になる点は、型の恩恵を受けていることを強く実感します。 まとめ この記事では、型を用いた コンポーネント の責務の明確化の方法について紹介しました。 責務を表現する型を用いて、属人的でない責務の明確化が可能になります。 実務で実装をしていると、UI や UX、 ビジネスロジック など、集中しなければならないことがたくさんあって大変だなと感じます。 紹介したような取り組みは本当に集中しなければならないことに集中することにとても役に立ちます。 今後も、システムの品質や開発効率を考えながら開発に取り組んでいきます。 また面白い取り組みができれば紹介したいです。 *1 : いかなるケースでも organisms が Array のデータを持つわけではありません *2 : ESLint で自作ルールを作る上で、こちらのページがとても参考になりました https://zenn.dev/nus3/articles/b2bc110efd0887442c11
アバター
はじめまして、 iOS エンジニアの阿久津 @sky_83325 です。 タイミーでは、機能ごとにEmbedded Frameworkに分割して開発するマルチモジュール開発に取り組んでいます。 現在では、本体AppやAppExtensionの他に7つの共通Framework、そして16個の機能Frameworkという規模になってきました。 今回は、そのマルチモジュール開発をEmbedded Frameworkではなく、Swift Packageを利用した方法に乗り換えてみたので、その成果や学びについて共有できればと思います。 取り組んだ経緯・背景 タイミーでは、技術顧問の @d_date さんと隔週で「ツバメの会」という情報共有の場を設けています。そこでは、直近タイミーで取り組んでいることの共有や相談をしたり、Swiftや iOS 、その他エンジニアリングの最近の話題について議論したりしています。 そのツバメの会で、pointfreeが OSS として提供している isowords というアプリが取り上げられました。 www.notion.so そのisowordsは86ものモジュールから構成されていますが、そのモジュールをSwift Packageとして管理しています。 Swift Packageとして管理することで、ファイルの追加や変更のたびに project.pbxproj が変更される辛みからも解放され、結果としてXcodeGenのようなツールを利用してプロジェクトファイルを生成しなくても、十分に管理することが可能となります。 この構成に大きな魅力と可能性を感じたため、タイミーでもSwift Packageを利用したプロジェクト構成への変更に舵を取りました。 この記事では、次の内容を共有します。 Swift Packageに移行したことで得られた成果 どのように移行していったのか 実際に運用してみた感想 Swift Packageに移行したことで得られた成果 1. XcodeGenを取り除くことができた タイミーでは次の2つの目的のために、XcodeGenを導入していました。 project.pbxproj のコンフリクトを防ぐ フレームワーク 追加の処理をテンプレート化する 特に前者は、多くのプロジェクトでXcodeGenが導入されている理由ではないでしょうか。 複数人で開発を行なっていると、ファイルや ディレクト リの追加に伴い、プロジェクトファイルがコンフリクトしがちです。常にプロジェクトファイルを生成するようにし、プロジェクトファイルを.gitignoreに追加することでコンフリクトの発生を抑えることができます。そういった目的でタイミーではXcodeGenを利用していました。 また、Frameworkターゲットを追加するたびに、毎回同じ作業が必要でしたが、その作業を誰でも同じように行えるようにテンプレート化するためにXcodeGenを利用していました。 XcodeGenの利用に問題があったわけではありません。従来のプロジェクト構成においては、XcodeGenを利用してプロジェクトファイルは常に生成する運用で特別な問題は発生していませんでした。 しかし、Swift Package Manager(以下SwiftPM)を利用し、 ソースコード をSwift Packageとして管理することで、 Xcode Projectにはファイルやフォルダへの参照が発生しません。これにより、Build Settingsの変更やTargetへの依存の追加を除いては、 .pbxproj に変更差分は発生しません。 そして、新しくモジュールを追加する際の処理も、 Package.swift を少し編集するだけとなったので、テンプレートなども不要になりました。 こうして、XcodeGenを取り除くこととなりました。 project.pbxproj は16416行から2295行へなり、XcodeGenのymlも削除されました。 // Before I'm working on timee-ios $ wc -l timee-ios.xcodeproj/project.pbxproj 16416 timee-ios.xcodeproj/project.pbxproj // After I'm working on timee-ios $ wc -l App/timee-ios.xcodeproj/project.pbxproj 2295 App/taimee-ios.xcodeproj/project.pbxproj // Before I'm working on timee-ios $ wc -l XcodeGen/* 102 XcodeGen/common-targets.yml 189 XcodeGen/project.yml 47 XcodeGen/sandbox-targets.yml 179 XcodeGen/scene-targets.yml 58 XcodeGen/setting-groups.yml 73 XcodeGen/templates.yml 178 XcodeGen/unit-test-targets.yml 826 total // After I'm working on timee-ios $ wc -l Package.swift 272 Package.swift 2. 本体Appのビルド時間が90秒から10秒になった Swift Packageに移行した結果、ビルド時間が大幅に改善されました。 これはSwift Package化したことによるものなのか、従来のプロジェクト構成に無駄があったのかはわかりませんが、思わぬ恩恵を受けることになりました。 ビルド時間のBefore/After 追記(2021年9月1日) SwiftPM対応した際に、BuildScriptで実行していたSwiftLintが動かなくなっていました。 そのSwiftLintの実行時間が40秒ほどかかっていたため、それが外れたことによりビルド時間が大幅に削減されていました。 3. アプリのサイズが30.3MBから26.9MBになった こちらも当初は期待してなかった成果でした。 アプリサイズのBefore/After 4. アプリの起動時間 Embedded Frameworkは Dynamic Link されていたので、今回Static Frameworkとしてリンクされることで起動時間が早くなるかもと思っていました。 今回実測はしていませんが、肌感では初回起動含め特に変化は感じていません。 5. その他 プロジェクトのセットアップがかなり容易になりました。まだCocoaPodsを併用していたり、Swift製の コマンドライン ツールの管理にMintを利用しているため、Git Clone後に Xcode を立ち上げそのまま開発を始められるという状況にはなっていません。 ですが、都度XcodeGenのscriptを走らせる必要がなくなったのは大きな開発体験の向上だと感じています。引き続き、MintのMintのSwiftPM移行などを進めていき、 Xcode のみで開発できる状況を目指していければと思います。 どのように移行していったのか それでは、どのようにプロジェクト構成を移行していったのかを見ていきます。 既存のタイミーの構成は次の通りでした。 外部ライブラリの管理は基本的にSwiftPMを使用して行っているが、一部CocoaPodsを利用している 1つのプロジェクトで、Appや複数のEmbedded Frameworkを管理している xcworkspaceを用いて、 Pods とメインprojectを紐づけている。 移行後のゴールとしては isowords を参考に以下のように定めました。 XcodeGenを取り除く ローカルライブラリ及びリ モートラ イブラリはSwift Packageとして管理する(メインprojectではSwift Packageのビルド成果物であるFrameworkをリンクして利用する) 一部SwiftPM未対応のものはCocoaPodsを利用する xcworkspaceでメインproject、 Pods 、Swift Packageを編集可能にする このゴールを達成するために、次の手順で移行を行いました。 XcodeGenを取り除く xcworkspaceの構成を整える Embedded FrameworkをSwift Packageに切り出す CocoaPodsを再度導入する 1. XcodeGenを取り除く まず、XcodeGenを取り除くということを大きな目的の1つにしていたので、この作業から始めました。具体的にはgitignoreの整理と、XcodeGenに関する記述の削除のみです。 .pbxproj はXcodeGenを利用し都度生成していたため、Gitでは管理していませんでしたが、管理するように変更しまし、差分を確認できるようにしました。 2. xcworkspaceの構成を整える 従来の構成は、次の画像のようになっていましたが isowords によせるため、Rootにおいていた各種ファイルを /App 以下に配置しました。 それにより、xcworkspaceからxcodeprojに対する参照がなくなってしまうため、 ***.xcworkspace/contents.xcworkspacedata でpathを更新します。 ワークスペース のインスペクタに何を表示するかは、この contents.xcworkspacedata によって管理されています。 <?xml version="1.0" encoding="UTF-8"?> <Workspace version = "1.0"> <FileRef location = "group:App/taimee-ios.xcodeproj"> // ここのpathを更新する </FileRef> <FileRef location = "group:Pods/Pods.xcodeproj"> </FileRef> </Workspace> ここまでの変更は、SwiftPMなどは一切関係なく単に ディレクト リ構造を変更しただけです。 ここで、一度ビルドが通ることを確認し、前準備を完了とします。 その後、プロジェクトのRootで swift package init を実行し、Swift Package導入の準備を進めます。 I'm working on timee-ios $ swift package init Creating library package: timee-ios Creating Package.swift Creating Sources/ Creating Sources/timee-ios/timee-ios.swift Creating Tests/ Creating Tests/timee-iosTests/ Creating Tests/timee-iosTests/timee-iosTests.swift 同様に、こちらもworkspaceで管理したいため、 contents.xcworkspacedata を編集していきます。 今回は、root ディレクト リの内容を全て表示したいため、 group: を指定します。 <FileRef location = "group:"> </FileRef> ここまでで、Swift Packageを導入する準備が整いました。 この時点での構成は次の通りです。 補足 group: を指定しただけでは App/ の内容もSwift Package側に表示されてしまいます。非表示にするには、非表示にしたい ディレクト リの中に空の Package.swift を作成することで防げます 参考 App/ 以下に本体projectを移行したことで、各種ツールのPathが壊れるかもしれないので適宜修正します。 以降の作業では一時的にCocoaPodsで導入しているライブラリの参照を外して作業を進めています。CocoaPodsがプロジェクトにリンクされている状態で移行を進めると、ある不具合が発生した場合にSwiftPM依存の不具合なのかCocoaPodsによる不具合なのか判断しにくいと考えました。 3. Embedded FrameworkをSwift Packageに切り出す ここまで来れば、あと一息です。 Swift Packageに対応する準備は整ったので、Embedded FrameworkをひとつひとつSwift Packageに移行していきます。 具体的には以下の作業を順に繰り返していくことになります。 Sources/ Tests/ にFrameworkのコードを移行する Package.swift に移行したFrameworkを定義する Package.targets にtargetとtestTargetを追加する 外部ライブラリが必要であれば Package.dependencies を定義し、 Package.target の dependency に追加する Package.products に .library を定義する。これによりアプリターゲットにFrameworkをリンクすることが可能となる。 Xcode ProjectのTargetから不要なものを取り除く Xcode ProjectのTargetに2で定義したFrameworkをリンクする 例として、共通コードをまとめたFrameworkである Common の場合を次に示します。 各種 dependency を指定しtargetを作成したあとに、productsでlibraryを提供することで、本体projectで Common というモジュールとFrameworkを使えるようになります。 // swift-tools-version:5.3 import PackageDescription let package = Package( name: "Frameworks", platforms: [ .iOS(.v13) ], products: [ .library(name: "Common", targets: ["Common"]) ], dependencies: [ .package(name: "Lottie", url: "https://github.com/airbnb/lottie-ios", from: "3.0.0"), .package(name: "RxSwift", url: "https://github.com/ReactiveX/RxSwift.git", from: "6.0.0"), .package(name: "Kingfisher", url: "https://github.com/onevcat/Kingfisher", from: "6.0.0"), .package(name: "ImageViewer", url: "https://github.com/Taimee/ImageViewer", from: "0.2.0"), ], targets: [ .target( name: "Common", dependencies: [ "Umbrella" ]), .target( name: "Umbrella", dependencies: [ .product(name: "ImageViewer", package: "ImageViewer"), .product(name: "Lottie", package: "Lottie"), .product(name: "Kingfisher", package: "Kingfisher"), .product(name: "RxSwift", package: "RxSwift"), .product(name: "RxRelay", package: "RxSwift"), .product(name: "RxCocoa", package: "RxSwift") ]), .testTarget( name: "CommonTests", dependencies: [ "Common" ]) .testTarget( name: "UmbrellaTests", dependencies: ["Umbrella"]), ] ) 同様に、全てのFrameworkを Package.swift を編集しながら移行していくことになります。 単調な作業が進みますが、 .pbxproj がどんどん軽量になっていくことを実感できます。 補足 import UIKit や import Foundation などの記述が漏れているとビルドに失敗するので適宜修正しましょう BundleはPackageごとに作成されるので Bundle.module を利用しましょう 4. CocoaPodsを再度導入する 最後にCocoaPodsを再導入していきます。既にxcworkspaceは作成済みであるため、既存のxcworkspaceを使うようにPodfileで指定します。それ以外は、特に気をつけることはありません。 workspace 'timee-ios' project './App/timee-ios.xcodeproj' これで無事以降が完了しました🎉 実際に運用してみた感想 実際にSwiftPMを中心としたプロジェクト構成にして、1ヶ月弱運用してみてメリット/デメリットが分かってきました。 メリットに関しては、この記事の前半で述べたように、基本的に Xcode を開くだけで開発を始められる体制になったということです。ビルド時間の高速化もかなり大きなメリットでした。 他方、良い面ばかりではなく、比較的新しい技術であるため、いくつかの不具合なども観測されました。具体的には以下の通りです。 不具合だと思われるもの Swift Packageにファイルを新規追加するのが少し辛い CocoaTouchClass を新規追加する際、 UIViewController などを指定することが出来ません。通常は Next を押した後の画面で、親Classなどを指定できると思うのですが、 Next を押すとすぐに Objective-C 用のファイルが作成されます😇 一時的な不具合だと思うので、次の Xcode に期待です。 AppExtensionを利用している場合、binaryTargetを利用することが出来ない AppExtensionを利用しているアプリで、SwiftPMを利用してbinaryFrameworkを利用する場合にAppStoreConnectにアップロード出来ない不具合があります。AppとAppExtensionの両方に対してFrameworkがコピーされてしまうため、AppStoreConnectへアップロードする際に、Bundleの重複でTransporterErrorが出ます。手動で片方を削除するなど対応可能かもしれませんが、CIなどでリリースプロセスを組んでることが多いと思うので、現時点では利用不可と考えるのが無難かもしれません。タイミーでも、これらはCocoaPodsで導入することで対応しました。 forums.swift.org InterfaceBuilderから ソースコード にIBOutlet/IBActionを接続しようとするとクラッシュする これは少し開発体験を損ねています😢 ソースコード 側からIB側に接続することは可能なので、そのようにして対応しています。次の Xcode で修正されると期待します。 これらの不具合はSwiftUIを使えば解決出来ることでもあるので、早めに乗り換えていきたい気持ちが高まりました。 その他 Swift Package内でCustomBuildConfigurationを利用することが出来ない Swift Packageでは、標準の RELEASE DEBUG 以外は利用できません。そのため、なんらかの対応が必要です。環境ごとにプロジェクトを分けてしまうのが良いかもしれません。 forums.swift.org 新作です https://t.co/gQJgGK0i9Q — Date (@d_date) 2021年4月21日 最後に Swift Package中心のプロジェクトに移行しましたが、まだまだやりたいことが沢山あります。 タイミーでは、技術的挑戦を一緒にしていけるメンバーを常に募集していますので、少しでも興味がある方はぜひご連絡ください。 その他参考文献 A Tour of isowords: Part 1 A Tour of isowords: Part 2 Libraries, frameworks, swift packages… What’s the difference?
アバター
こんにちは、サーバサイドエンジニアの @Juju_62q です。 今回は年末から仕込んでいたタイミーのSLOについてと、その時に得た学びを紹介したいと思います。 概略 結論としてタイミーのSLOで大事にしているのは以下の3つです。 プロダクトの緩やかな品質低下を検知できるものであること プロダクトの健全性を大局的に把握できるものであること 罰則はSLOを消費する行為に相反する行動を取ること また、組織で新たなものをやる時は熱量/知識/経験のいずれか2つ以上が必要になることも学びとなりました。 概略 SLO(Service Level Objective)とは なぜSLOを作ったのか SLO導入前の状態 SLOの役割 導入のために行ったこと タイミーで定義しているSLOについて 利用チームについて SLOでどの範囲を扱うかについて 違反時について SLOの見直しについて SLOを導入してよかったこと 失敗と学び 小さく始める やっていきするときに必要なもの 終わりに SLO(Service Level Objective)とは 運用サービスが守るべきと考えられる品質と解釈しています。 SLOはサービスが健全に動いていることを知るための基準であり、これを破るとユーザの期待に応えられてないということを意味します。 また、SLOは観測可能な指標から 定量 判断可能な基準であり、SLOを利用した健全性判断では定性観点が入りません。 なぜSLOを作ったのか サービスが一定の品質を保てないのであれば開発者はサービスの品質を高めることに注力すべきです。 一方で、品質が市場要求に対して過多なのであればもう少し攻めたリリースをしても良いでしょう(質とスピードの話はここでは置いておくことにします)。 この辺りの匙加減は一般に開発者のよしな力に依存しています。 その辺りの記述が SRE本 にあるため引用して紹介します。 通常、リスクと労力の線引きが必要な部分について、既存チームは非公式になんらかのバランスを見出しているものです。ただし残念ながらそういったバランスが最適なものであるかは自明ではなく、関わりのあるエンジニアの交渉スキルだけで下された決定だったりします。 中略 それに対して私たちのゴールは、交渉を生産的な方向へ導くために利用できる客観的なメトリクスを定義し、両者に合意してもらうことです。判断はデータに基づくほど良いのです 品質をチームが意識しやすい形に落とし込み、攻めたリリースを許容しつつ高い品質を維持するためにタイミーではSLOを導入しました。 SLO導入前の状態 良くも悪くもPdMを主導に無邪気に開発をしていたという側面は否めません。 例えば「パフォーマンスは早い方がいいよね」という話をみんなが意識しつつも、それと機能開発で一体どっちが大きなROI(Return on investment)なのか説明するのは簡単ではありませんでした。 サーバのエラーレート、モバイルアプリのフラッシュフリーレートなども同様です。利用者にとっては一定以上待たなくていいこと、エラーが起きないことは当たり前なのにも関わらず機能の追加・改修を差し置いてそれを改善する改善することが妥当であると説明するのは困難です。 この辺りはみんなが暗黙的にやりたいと思いつつも実行に落とすのは少し困難な状態でした。改善に関しても意識が高い人の隙間時間に依存しており、組織としてはかなり脆弱な状態だったと思います。また、パフォーマンスやエラーレートって改善するのが基本的に正義という状況ではあるのでA/Bテストをするという話にもなりませんでした。 SLOの役割 タイミーにおけるSLOの役割はなんでしょう? タイミーではSLOではサービスの当たり前品質を 定量 的に観測するためのものであり、基準として扱っています。 ここで"当たり前品質とは何か?"という話ですが例えば以下のようなものを扱っています(いますべて扱っているか?と言われるとそうではありません)。 モバイルアプリが落ちない お仕事を探す上で、くりかえしお仕事画面と検索画面を行き来してもストレスがない サーバが理不尽なエラーを返さない ユーザが期待する動作をする これらに該当する内容をSLIとして観測しつつ、初動ではチームを締めすぎない範囲で数値を設定、少しずつ絞めていきました。 タイミーのSLOの役割は 当たり前品質と攻めたリリースのバランスを取る 、 当たり前品質の低下をいち早く検知する というものになります。 導入のために行ったこと SLOを導入する下準備以下のようなことをやっています PdM、CSM、カスタマーサポートに話を聞いてビジネス的に守って欲しい部分、品質の ヒアリ ングをする SLI, SLOを作ってPdMとすり合わせる SLOを一覧可能にする SLOについて説明する資料を作り、チームに共有する SLOを改善していく会議体をとりあえず設計する 大きく区別すると ビジネス的に間違ってなさそうなSLOを設計する 運用に携わる人にSLOの説明をする SLOをとりあえず運用・改善可能にする という感じになります。 タイミーで定義しているSLOについて 現在タイミーのワーカーチームでは以下の4つのSLOが運用されています。 全体のアクセスのうちA%が正常なレスポンスを返す(2xx, 3xx, 4xx) 検索 API (ファーストビューの95%ile)がB[msec]を切っている期間がC%以上 モバイルアプリケーションのクラッシュフリーレートがD%以上 定めた API の書き込みアクセスのバリデーションエラーに対する成功率がE%以上 ルールとして、チームが意識するSLOは最大5つとしています。 過去にはタイミーの ビジネスロジック として特に重要な API のエラーレート等も扱っていましたが、その部分が壊れると即障害として扱われるためやめることにしました。 今残っているSLOは 定めたAPIの書き込みアクセスのバリデーションエラーに対する成功率がE%以上 のようにアプリケーションの品質の緩やかな低下を検知できるようになっています。 利用チームについて 前述の通りタイミーのSLOは現在ワーカーに価値を提供するワーカーチームでのみ運用されています。 タイミーは Ruby on Rails で書かれたモノリシックなWebアプリケーションを中心としつつ、Reactで書かれた店舗向け画面、 iOS , Android で提供されるモバイルアプリケーションでサービスを展開しています。 チームについては価値の提供先で分かれています。現在は就労先となる店舗の体験を改善するクライアントチーム、働き手となるワーカーの体験を改善するワーカーチームが存在します。 初めは(構想時点でのチーム分割技術スタックに依存していたこともあり)、サーバサイドエンジニア全体をSLOの影響下に置いていました。 しかしながら、そうした場合に価値提供を意識したSLOの策定が困難だったため自分が所属しているワーカーチームのみを対象としました。 結果としてクラッシュフリーレートの導入が決まったり、サーバサイドのレスポンスタイムでなく レンダリング 速度でパフォーマンスを見ていこうというような価値の対象を強く意識したアグレッシブな意思決定がスムーズにできるようになりました。 まだまだ運用が安定しないので展開はしていませんが、プ ラク ティスがまとまってきた際にはクライアントチームにもSLOを展開できればと思っています。 SLOでどの範囲を扱うかについて 1つのSLOのもつ影響範囲が大きくなるほど、そこに対するアクションは非自明になります。 例えば 検索API(ファーストビューの95%ile)がB[msec]を切っている期間がC%以上 と 全体のアクセスのうちA%が正常なレスポンスを返す(2xx, 3xx, 4xx) で比較した時にその差は明らかです。 前者であれば、検索 API のパフォーマンスの ボトルネック を ボトルネック をあされば良いのに対して、後者はどこで問題が起きているのか?から始める必要があります。 結果としてSLOに違反した、ないししそうなりそうな場合に何をやっていいかがわかりにくくなります。 一方で細かく全ての領域にSLOを用意するとチームにわかりやすいかというとそうではありません。SLOの数が増えると管理するのも意識するのも対応するのも難しくなります。 この トレードオフ にはいろんな考えがあるかと思いますが、タイミーではSLOを大局的な情報を知るためのツールとして扱い、問題が起きているという事実を示唆するために利用しています(調査に時間がかかっても良いという判断)。 違反時について 違反時のアクションはそれほど正確には定めていません。 基本的な決まり事としてはエラーバジェットの消費と相反する行動をしましょうという話をしています。 例えば「A/Bテストを検索部分に入れた結果検索 API のパフォーマンスが低下した。」という事象が過去に発生しました。 この際にした意思決定は、「A/Bテストの期間を十分なデータを貯めるための最小の期間として決めてしまいバジェットの消耗を最小限に抑える。」というものでした。 SLO違反時の振る舞いについてはデプロイを止めるというようなことがよく語られる印象がありますが、デプロイを止めるだけだと悪化の一途を辿るものも多いです。よって基本的に違反時については具体的な行動を定めず、場合によって機能開発や仮説検証を止めて改善に向かうということだけを共通認識として持っています。 違反時の決まり事をふわっとしたものを動きにした結果 定めた API の書き込みアクセスのバリデーションエラーに対する成功率がE%以上 のような短期間では改善できなさそうな項目もSLOに取り込むことができました。 これについては違反時には次の3,4ヶ月でPdMに改善の筋道を立ててもらうということで合意しています。 SLOの見直しについて 現状SLOの評価期間を月次にし、見直しも毎月話して行っています。参加者はCTO, PdM, 希望者としています。 月次の会についてはまだ話すことがたくさんあるので引き続き行う予定です。 もう少し枯れてきた場合、頻度を減らしていくかもしれません。 現在具体的には、以下のような内容を話しています。 先月のSLOについて 数値のおさらい SLOに対するアクションのおさらいと振り返り SLOの過不足について 違反時のアクションについて 話し合い自体のの反省 時間としては1hかけています。また、チーム全体としてはレトロスペクティブ等でSLOの扱い方をチューニングしています。 SLOを導入してよかったこと SLO導入による明らかな変化として"チームが数字の変化に反応して勝手に改善をするようになった"というものがあります。 SLOを定義したことによって、正常、危険、異常というラインが明示され、チーム内での数値的な危機感も同期することができました。 また、SLOをある程度みやすく管理することによって日々の数値の変化にも敏感になりました。 結果として、エラーバジェット減少の兆候を早く汲み取り、自律的にプロダクトの当たり前品質が改善できるようになりました。 失敗と学び SLOをやる中で幾らかの学びがありました。SLOに直接関わる学びについては幾らか前述してきました。 一方でSLOをやっていく中で感じた組織的な学びについて記していこうと思います。 小さく始める 散々言われていますが、小さく始めることは重要です。 ちらっと話しましたが今回僕がした失敗として、チーム分割が行われた時にそれに合わせてスコープを変更しなかったというものがあります。 SLOを元々作り始めた時はタイミーではモバイルアプリを開発するチームとサーバサイドを開発するチームがありました。現在は価値の提供対象で割っていてワーカーチームとクライアントチームとなっています。SLOはチーム分割のすぐ後くらいに実運用を開始しました。 元々サーバサイドチームを見ながら作ったものだったので疑問を持たずにワーカーチームとクライアントチームの2チームを影響範囲にして運用しようとしました。結果として以下のようなデメリットが発生していました。 SLO運用、改善のオーナーシップが曖昧 サービス全体が対象なので何かに特化したSLOを採用しづらい 関係者が多く合意形成が少し煩雑(当時エンジニア20人程度で大したことないにせよ) もちろん一定の強制力を持って推し進めるという手法を使えば、全社に対して一度に適用することができるかもしれません。 しかしながら個人的に自律性を大事にしてSLOを導入していきたかったためその方法を取りませんでした。 最終的には自分の所属するワーカーチームのみをSLOの影響範囲とすることにし、ワーカーに特化したSLOを策定するに至りました。 やっていきするときに必要なもの ここでいうやっていきは以下のようなものです。 やっていき = 枯れていない何かの考え方・技術を導入すること 弊社にとってSLOという概念はこの"やっていき"にあたるものであったと思います。 SLOについて社内では確立された知見はありません。また、SLOはサービス特性や会社のカルチャーによってその形がかなり異なるため、他社の事例も100%踏襲することができません。 ゆえに、会社やチームには経験がありません。 また、導入時は自分が引っ張って諸々の設計等をしていたため、全員に大きな熱量があるわけではありませんでした。 自分がやっていくぞ!!となっている一方、良い感じの方向に倒れるなら乗っかりたいと思う人もいたわけです(これは自然なことだと思います)。 やった方がいいという意識があってもそこに今自分の時間を使うことが妥当でないという方もいました(優先順位の問題)。ゆえに全員に熱量があったわけでもありません。 知識についてはある程度勉強していたり、資料を作ったりしていたのである程度あったのですが、経験=プ ラク ティスが固まっていない以上熱量をかけないと物事が進んでいきません。そこで熱量のある自分がボールを持ちやすいように自身のチームに持たせるという選択をしました。 ただし、全社的なスケールをもくろむという意味で試した結果を経験としておき、必要なタイミングで他のチームが時間をかけずとも始められる準備をしようと話をしています。知識と経験があり、労力が小さいのであれば大きな熱量がなくともSLOを導入できます。 "やっていき"を自律的に進めるためには熱量/知識/経験のいずれか2つ以上が必要になることを今回の件から学びました。 終わりに 今回はSLOの導入に伴った学びや、タイミーでのSLO活用事例を紹介しました。 今後もSLOだけでなくプロダクトやチームの数値を 定量 、定性的に観測し、事実に基づいた改善を素早く行えるように頑張っていきたいと思います。 プロダクトや組織状況の継続的な観測や改善に興味がある人はぜひ声をかけてください!
アバター
こんにちは タイミー CTOの kameike です 昨日発売された、 『ユニコーン企業のひみつ――Spotifyで学んだソフトウェアづくりと働き方』 のレビューです。 書籍の発売前に抽選でもらえるキャンペーンに当選し、献本をいただきました。「謹呈」ののし紙がついた本をいただいたのは初めてでテンションが上がりました。 タイミーも現在の20名のプロダクト組織からスケールしていくフェーズに入ってきており、これは Spotify が組織モデルを樹立しはじめたタイミングと同じであるため非常に参考になりました。 TL;DR 『 ユニコーン 企業のひみつ』には Spotify の組織の マインドセット ・ルールが書かれており、自律的なチームのヒントが多く含まれています 『 ユニコーン 企業のひみつ』に書かれているような組織の実現は継続的な計測と改善がとても大切です 『 ユニコーン 企業のひみつ』はどのようなことが書かれている本なの? 著者であるJonathan Rasmussonは、2014/8/30に Spotify に入社されていて、 Agile-Coachとして入社されたようです 。2017年辺りに退職されており、現在Jonathan Rasmussonは、( 彼のyoutubeチャンネル 見る限り) iOS エンジニアをされている雰囲気を感じています。この本はJonathan Rasmussonが Spotify で経験された4年の経験をしたためた本になっています。 『 ユニコーン 企業の秘密』に書かれているモデルは、2012年頃、30人から250人にスケールした際に樹立されたモデルであり、その後10年程度運用され2600人規模までこの仕組みで組織が運用されているようです。数千人規模までエンジニアを増やしつつ、「デリバリー」を主軸にどう組織 サステイナブル な組織を組み立てるか?という試行錯誤の上に到達した組織のモデル・ マインドセット が書かれています。 英語が大丈夫な人で購入を迷っている人は是非以下のビデオを見てもらうのがとても良いと思います。以下のような組織構造をベースにするとどのようなカルチャーが育つのか?ということがわかります。2本立てになっていますので2本目は関連動画からご覧ください。 www.youtube.com 『 ユニコーン 企業のひみつ』は誰の役に立つの? エンジニア・ ビジネスパーソン の皆様 Spotify の例をとってもこのような組織の考え方は10年近く運用されているので、一定「枯れた技術」になったものかなと思っています。そのため「自律的な組織」的な考え方は、今後10年ぐらいで「gitで開発しています」程度にはある程度スタンダードになると思っています。 この体制は「メンバー/チーム」の裁量が大きくなる仕組みの話なので、より大きな責任でよりクリエイティブに働くことが求められていくと思っています。このカルチャーにキャッチアップすることが、今後のキャリアとして ユニコーン 企業のような企業で就労を希望する場合には非常に大きな鍵になってくると思っています。カルチャーのサンプルとして是非この本をお勧めしたいです。 技術責任者の皆様 他組織の ユースケース が詰まった本として非常に参考になると思っています。自分の組織に展開するヒントが散りばめられています。 経営者やマネージャーの皆様 「ソフトウェアデリバリー」などわかりにくい単語があると思いますがほとんどの章は基本的にググればわかる言葉で構成されています。 ユニコーン 企業と戦う心づもりがある方は是非に読んでほしいなと思っています。 エンジニア組織に限らずメンテナンスし続けるモデルに対してチームで立ち向かうために この本はサービス開発における Spotify の価値観が書かれている書籍です。これは「永遠にメンテナンスしないといけないモデル」に向き合う話でもあると思っています。 近年 CRM が浸透する中、営業や マーケティング のモデル化が進んでいると感じています。このモデルに対して「 銀の弾丸 (唯一の正解)」はなく、自社にフィットさせる改善の繰り返しが必要です。この改善を繰り返すためのヒントが多く書かれている本です。 流動性 の高い優秀な人と一緒に働くために エンジニアは人材の 流動性 がかなり高いため、マッチングが悪いとすぐに転職していく、という経営からのコン トロール が難しいセグメントの人材だと思っています。ただ、これはエンジニアが性質上わかりやすく先行しただけで、あらゆる職種で人材の 流動性 が上がっていくと思っています。 人材の 流動性 が高まり、 自己実現 のために働いている方にとって魅力的な組織とはどのような組織なのか?のヒントが多く書かれている本です。 『 ユニコーン 企業のひみつ』の実現に向けてどう取り組むのか? 『 ユニコーン 企業のひみつ』には「こうするといいよ」だったり、「こういう価値観だよ」という内容が中心に書かれています。 Spotify の良さに関しては『 ユニコーン 企業の秘密』や⬆️の youtube の動画にお任せするとして、このモデルが作られた経緯や時期などを調べてみました。 このモデルは2012年に外部に発信されています。 * このモデルは仕組み含めての設計から展開まで「 アジャイル コーチ」という存在が強く関わってきているようで、このモデルの樹立にはフルタイムの アジャイル コーチであるAnders Ivarssonさんと外部 コンサルタント であるHenrik Knibergさん1名でモデルの樹立をしているようです。 日本のスタートアップでは、私の観測範囲においてあまりフルタイムの アジャイル コーチというのは一般的ではないように感じており。おそらく「VPofE」や「EM」のようなロールの方が担う責務になるのかな?と思っています。大切なのは肩書きではなく、組織の改善に対するフルタイムのコミットメントできるロールであり、その責務を担うロールが本書でも言われている「あらゆるレベルでの継続的改善」継続していくことが大切だと感じました。 サービスの開発のように組織も計測しながら進むことが大切だと感じた Spotify の組織モデルを調べる中で、 Spotify のデータに基づく改善のカルチャーが組織にも適応されているように感じました。 データとサービスの意思決定の関わりは、『 ユニコーン 企業のひみつ』内でも多く述べられてお、2章の経営の意思決定をどう扱うか?のBIDDモデル、第8章にはプロダクトの改善でいかにデータを利用するか書かれています。 このデータに対する姿勢は組織運用に対しても展開されているようです。2012年に公開されたペーパーである、 Scaling Agile @ Spotify にてその様子を窺い知ることがでます。「サービス開発のように組織をデザインしているなぁ」と特に印象に残っている2つの観点を書きます。 ①自律的なチームであることを計測する。改善要求ではなくバックアップする 「Squad(チーム)は自律していること」が実現できているかどうか?は掲げるだけではなく計測が必要になります。 Spotify ではチームが自律的であることを、以下の7つの基軸でモニタリングしていたようです。 プロダクトオーナー アジャイル コーチ 仕事の影響度の強さ リリース容易性 チームに適合するプロセス ミッション 組織的なサポート 調査した内容は以下の図のように、クライテリアごとチームごとのメッシュで健康度がわかるようにモニタリングされます。 https://blog.crisp.se/wp-content/uploads/2012/11/SpotifyScaling.pdf 指標が下がっているチームに対しては、各セグメントにエキスパートを アサイ ンして、改善を行なっていきます。「自律的なチーム」であるSquadが実態として自律的に振る舞えているか?を指標化し定常的にモニタリングし、改善を行なっていたようです。 ②組織の依存性を調査し改善していく Spotify の「Squad(チーム)は自律していること」が実現できているのであれば、何かを実現するにあたって、チームとチームの間に「依存関係」はなく折衝が発生したり、 ブロッキング になることはないはずです。特にTribe(150名程度の部)を跨ぐような依存は自律性を大きく損なってしまいます。 これを防ぐために、Squad(チーム)やTribe(部)の依存関係は以下のシートのように定期的に調査されるとのことです。 この サーベイ によりチームや組織の依存を検知して依存を切るような責務の分離やシステムの改修を進めていきます。 https://blog.crisp.se/wp-content/uploads/2012/11/SpotifyScaling.pdf これはまさしくシステムを 疎結合 にする リファクタリング のような行為で、組織の健全性を保つ良いアプローチのように感じました。 で、タイミーとどう交わるの? 「Squadは自律したチームであること」という方針を定め。その方針を指標として計測し、上記ののような観測と実行のサイクルが実行されていることが Spotify の組織の強さだと感じています。 タイミーも現在の20名のプロダクト組織からスケールしていくフェーズに入ってきており、これは Spotify が組織モデルを樹立しはじめたタイミングと同じであります。そのためタイミーも何かしらモデルを作り 言語化 し、計測し改善していくフェーズに入ってきていると思っています。 大きな組織においては「How」を統一して押し付けるのではなく、指標計測を組織としてバックアップし、改善の主体はチームに委ねることはチームの自律性において非常に大切な要素だとこの本を通して改めて感じました。 タイミーはこれから一年程度かけて、組織の拡充を進めるとともに、チームの指標を継続的にFBすることでチームの状態の改善が行える状態を目指していきます。この変遷を一緒に作っていくことに興味がある方は是非、 こちら などからエントリーなどいただければ嬉しいです...! we are hiring...! 蛇足🐍 日本語訳と英語名の差分に感じた気持ち 英語名『Competing with unicorns』に対して、日本語名『 ユニコーン 企業のひみつ』になっているのもだいぶ マーケティング 上の性質の違いを感じます。 英語名だと ユニコーン 企業が身近な脅威になっている人に響くタイトル、日本語名だと雲の上の存在をの覗く人に響くタイトルになっています。文化の違いというのもあると思いますが ユニコーン 企業との距離感の違いでもあるのかな?と感じて挑んでいかねばという気持ちになりました。また調べている時に感じたことですが、このスタイル自体2012年に発信されており、Edgeのナレッジを得るという意味だと日本語でトレンドをフォローすること自体かなり速度が遅いなぁという気持ちになっています。
アバター
こんにちは、 タイミーデリバリー 開発チームの宮城です。 この記事は JP_Stripes Advent Calendar 2020 の10日目の記事です。 タイミーデリバリーはデリバリーを頼みたい人が安い価格で注文でき、飲食店も安い利用料で注文を受けられるデリバリープラットフォームです。 その決済機能として今回は Stripe を導入しました。 この記事では、決済基盤の技術選定/Stripeを活用したクレジットカード決済と各事業者への入金までの流れ/ Rails での具体的な実装内容 をそれぞれタイミーデリバリーでの活用事例として紹介します。 導入にあたった背景 決済基盤の技術選定基準 Stripeでできること PCI DSSについて 利用したStripeの機能 Custom Account Stripe SDKを利用したRails/Swiftでの実装内容 PaymentIntent Customer Account Connected Accountが決済を行えるようになるまで 決済の全体像 DBに保存する情報とStripeで保持する情報の整合性担保 仮登録フェーズ(Try) 確定フェーズ(Confirm/Cancel) stripe-ruby-mockを使ったTesting 返金処理・経理業務 Stripeダッシュボードでの返金処理 API経由による返金処理 返金のタイミング 入金について よかったこと 決済に関するトラブルが非常に少なかった ドキュメント、サポート、開発ツールが手厚い 失敗したこと 事業者に直接Stripeダッシュボードを見てもらった方が楽なケースが多かった 事業者のStripeへの登録における実装がかなり重く、Expressで提供されているAccountLinkを使った方が良さそう 今アカウントを選ぶとした場合の判断基準 終わりに 導入にあたった背景 タイミーデリバリーでは、 Rails による API サーバーと、Web管理画面としてVue.jsによるSPA、ユーザー向け iOS アプリとしてSwiftを採用しています。 1つの モノリス な Rails アプリで利用者別にネームスペースを区切り、それぞれ JSON を返す API を提供しています。 タイミーデリバリーはプラットフォームビジネスであり、注文者はアプリ上で複数の事業者の商品を閲覧し商品を購入します。 注文者が決済した金額にはプラットフォーム利用料が含まれており、タイミーと事業者双方に分配する必要があります。 まだ弊社には決済機能を導入するノウハウがなかったため、 Rails を使ってどのようにこのビジネスモデルを実現するのかや、そもそも決済機能に求められる通常の要件をどのように達成するのかもわからない状態からスタートしました。 まずは決済基盤に求められる技術選定の基準を作るところから始めました。 決済基盤の技術選定基準 弊社 経理 チームやCSチーム、法務チームと相談しながら要件をまとめ技術選定基準を作り、以下の要件が達成できる状態を目指すことにしました。 注文者の体験 注文者がクレジットカードを使ってアプリ上から商品を事前購入することができる 決済情報(クレジットカードなど)はタイミーが保持せず、ログにも残らない 不正利用や マネーロンダリング の対策ができている 注文者が操作に迷わない(UXが高い) 弊社 経理 チーム、導入事業者の体験 決済された金額から事業者とタイミーに分配できる、またはタイミーから事業者に請求できる 事業者の導入にあたって、契約書の受領からアプリ上に商品を掲載し決済できるようになるまでが簡単で短い 特定の期間で「締め」て、締めたデータは変更されなくなる 必要なデータを後から取り出すことができる 注文者への領収書や店舗ごとの利用明細が表示できる サポートチームの体験 返金のオペレーションが容易にできる 必要なデータを後から取り出すことができる 開発チームの体験 実装コストができるだけ低い サービスが利用できない時間が可能な限り短い(可用性が高い) この時点で「決済を行う」とはここまで考えることがあるのか…と深い闇に迷い込んだ気がしましたが、ここで丁寧に要件をまとめたことで各 ステークホルダー と認識のズレを減らすことができたように思います。 Stripeでできること 上記の技術選定基準をStripeで達成できるか当てはめたのが以下です。 利用者 項目 結果 注文者の体験 注文者がクレジットカードを使ってアプリ上から商品を事前購入することができる クレジットカード、ApplePay、GooglePayなどが可能。 決済情報(クレジットカードなど)はタイミーが保持せず、ログにも残らず、セキュアに管理される API で問い合わせ、決済情報の登録・閲覧・削除が可能。 秘匿情報は全てStripe側で管理し、タイミーが保持するものは結果のみ。 不正利用や マネーロンダリング の対策ができている Stripeに蓄積されているデータを用い、クレジットカードのリスク審査ができる Radarと呼ばれる不正行為検知の機能がある(別料金) 注文者が操作に迷わない(UXが高い) StripeのSwift向け SDK が優秀 カードの入力は1度きりでよく、次回決済時にシームレスに利用できる 弊社 経理 チーム、導入事業者の体験 決済された金額から事業者とタイミーに分配できる、またはタイミーから事業者に請求できる 可能。方法は後述 導入にあたって、契約書の受領からアプリ上で決済できるようになるまでが簡単で短い アカウントを登録するための情報は多いが、入力次第すぐに決済が可能。Stripeが並行で審査を進めている 特定の期間で「締め」て、締めたデータは変更されなくなる これはできなさそうだった。だが 経理 上のオペレーションとしては問題なく処理できたので方法を後述 必要なデータを後から取り出すことができる Stripeの ダッシュ ボード上で可能 注文者への領収書や事業者ごとの利用明細が表示できる 注文者向けの領収書は発行可能。デザインも良い。 事業者ごとの利用明細も可能ではあるが、少々面倒だった。これも後述 サポートチームの体験 返金のオペレーションが容易にできる 返金処理自体は簡単だった。むしろ ダッシュ ボード上でいつでもできてしまうので、弊社アプリケーション側で制限する仕組みを用意した。 必要なデータを後から取り出すことができる Stripeの ダッシュ ボード上で可能 開発チームの体験 実装コストができるだけ低い 開発者フレンドリーを謳っており、 API 経由での利用や拡張が簡単にできる。 サービスが利用できない時間が可能な限り短い(可用性が高い) SLA , SLOは特に公開しているわけではないようだったのですが、担当してくれていたStripeの営業の方に確認したところ「ほぼ落ちないようなもの」とのこと。 プロダクトとしては単一障害点になってしまっていることを ステークホルダー との合意を得ています。 このように概ね要件を満たせていたのと、開発者体験が良いという噂は聞いていたため自分もエンジニアとして興味もあり、今回はStripeを利用することを決めました。 PCI DSSについて ここに書いてある内容は正確性に欠けている可能性があるので、実際に対応を進める場合は必ず専門家に相談しながら進めてください。 決済機能を提供する上で避けて通れないのが PCI DSSへの対応です。 PCI DSSとは、クレジットカード情報を事業者がどのように扱うべきかを定めた情報セキュリティ基準です。クレジットカード情報を自社で保持する場合は300 件を超える PCI DSSの各セキュリティ制御要件を満たさなければなりませんが、カード情報の処理をStripeなどの外部 SaaS に任せる事で事業者に求められるセキュリティ制御要件は22件にまで減ります。そのためにカード情報の「非保持・非通過」を目指します。 その上で PCI に準拠していることの検証を進めていく事になりますが、求められる要件は年間のクレジットカード取引量によってレベルが変わります。新規事業であるタイミーデリバリーは最も低いレベルであるレベル4だったため、Stripe上での本番環境のセットアップを進める最中に要求される自己問診だけで済みました。 PCI DSSについて全てを理解することは困難であり、今回は担当してくださったStripeの営業の方に何度も相談させていただきました。 Stripeを利用する上でどのように PCI に準拠していくかはまずこの資料を読むことをオススメします。 stripe.com 利用したStripeの機能 今回は Stripe Connect を利用しました。 Stripe Connectはプラットフォームビジネスに最適なサービスであり、1人の注文者が任意の事業者の商品を購入し、事業者の口座へ入金しつつプラットフォーム手数料の徴収を行うことができます。 Stripe Connectの詳細や主な利用方法についてはこの記事が参考になりました。一部情報が古い部分があるものの、全体像を掴みやすい記事です。 qiita.com Custom Account Stripe Connectを利用する上で重要なのはアカウントタイプを決定することです。現在はStandard/Express/Customの3タイプが提供されています。詳細な説明はこの記事では割愛します。 アカウントの選び方は公式のドキュメントを見るのが良いかと思います。 stripe.com 今回タイミーでは以下の理由からCustomアカウントを選定しました。 導入事業者に課すプラットフォーム手数料の計算をアプリケーションロジックで行いたかったこと 弊社 経理 チームの都合や導入事業者の 経理 面からの要望により、返金等の請求内容の変更が発生するタイミングを制御したかったこと ネットで検索したところ、Expressアカウントの採用事例がまだ少なかったこと しかし、運用フェーズに入った今思えば、Expressアカウントを採用した方がよかったのではないかと考えています。こちらについては後述する失敗したことで記します。 Stripe SDK を利用した Rails /Swiftでの実装内容 ここからは実際にどのように実装したのかを紹介します。Stripeは REST API で決済を行える事に加えて、各言語ごとに SDK を提供しています。タイミーデリバリーはフロントエンドとしてSwift、バックエンドとして Ruby を利用しており、どちらも SDK が対応していました。 github.com github.com また公式ドキュメントには SDK を利用してPaymentIntentを作成する決済の流れが紹介されているので、ここを熟読することをオススメします。(PaymentIntentについては次項で説明します。) stripe.com PaymentIntent Stripeで決済を行う場合、それぞれの決済を表すのが PaymentIntent API です。ネット上の記事ではCharge API を利用した記事も多く存在していますが、現在はPaymentIntentを利用することが 推奨されています 。 とはいえCharge API にしかサポートしていない決済方法もあるため、自身の ユースケース と照らし合わせて対応しているか確認しつつ、どちらを選択しても問題ない場合はPaymentIntentを利用するのが妥当でしょう。 タイミーデリバリーの場合はファーストリリースではクレジットカードしかサポートしないことを決めていたためPaymentIntent API を選択しました。 PaymentIntentを作成しただけでは決済処理は行われず、その後「承認」を行う事によって決済処理が行われます。 stripe.com Customer 決済を行う主体を指します。タイミーデリバリーの場合は「注文者」と呼んでいます。 Customerオブジェクトには数多くのプロパティがありますが、タイミーデリバリーの ユースケース では、注文者の一意な特定ができかつ領収書の送信ができさえすればよかったため、メールアドレスのプロパティしか利用していません。 stripe.com Account プラットフォームを利用してお金を受け取る主体です。タイミーデリバリーでは、プラットフォームであるタイミーが親アカウント、タイミーデリバリー上で商品を公開する事業者が子アカウント(Connected Accountとも呼ばれます)にあたります。 上述したとおり、タイミーデリバリーではアカウントタイプとしてCustomアカウントを選択しました。 Stripe Connectでは、「プラットフォーム上での売り上げはまず親アカウントの残高に集まり、その後子アカウントの残高へ送金する」という形を取っています。事業者がプラットフォームを利用する際の手数料( ApplicationFee )はこの送金のタイミングで差し引くことが可能です。 Connected Accountが決済を行えるようになるまで 導入した企業が契約から決済を行えるようになるまでのリードタイムは、熱量を下げないためにも重要な観点です。 Stripeの場合、Accountに事業者の各種情報を登録すればその瞬間から決済を行うことができます。まずは本人確認中ステータスとなりますが、本人確認が済む前から利用可能です。 しかしこの情報を入力する項目がかなり多く、事業者ごとに情報を用意していただき入力してもらうのがかなり大変だったので注意しておいた方が良いかと思います。法人の場合は企業情報だけではなく代表者の身分証明書画像のアップロードまで必要でした。 stripe.com 決済の全体像 商品を選択し決済するまでのシーケンス図がこちらです。 決済の流れ ここで考慮すべきは 決済情報をどのようにサーバーで保持せずにStripeに送信するか ですが、そこはStripeのSwift SDK に全て任せることができます。クレジットカード情報の登録、決済の承認の処理前には API からStripeに向けて一時キーを要求し、Swift SDK に返します。Swift SDK のViewControllerからStripeへ直接リク エス トすることで処理を確定します。 DBに保存する情報とStripeで保持する情報の整合性担保 上記の全体シーケンス図から、注文情報の確定とPaymentIntentの作成、承認処理をより細かく記載したのが下記のシーケンス図です。 注文の確定処理のシーケンス図 アプリケーション側では注文情報をデータベースに保存し、Stripe側にはPaymentIntentを作成するリク エス トを送ります。複数サービスを跨いだ トランザクション では、個々のデータの整合性を担保することが大きな課題となります。注文情報は保存できたがStripeへのリク エス トが タイムアウト などで失敗した場合、注文情報が ロールバック されなければ注文者は引き落としがないまま商品を受け取ることができてしまいます。 そのため、今回は仮登録フェーズと確定フェーズの2段階の トランザクション を行う設計にしました。分散 トランザクション における デザインパターン の TCC (Try/Confirm/Cancel)パターンに近いです。 仮登録フェーズ(Try) 仮登録フェーズの トランザクション では、フロントエンドから送られてきた注文情報をもとにPaymentIntentを作成するリク エス トを行い、その後データベースにpayment_intent_idを含めた注文情報を保存します。 PaymentIntentは作成しただけでは決済は行われないため、現時点では仮登録の状態といえます。 アプリケーション側ではOrderモデルを作成し、各種注文情報を保存します。payment_succeeded_atというnullableのdatetime型のカラムを用意しておき、現時点ではnullにしておきます。このカラムがnullの場合は注文者のアプリや店舗側の管理画面には表示しないロジックにしておきます。 実際のコードに近いサービスクラスがこちらです。 class User :: Order :: PaymentPrepareService def initialize (user, params) @user = user @params = params end def run! ActiveRecord :: Base .transaction do find_resources validate! create_payment_intent! create_order! end @payment_intent .client_secret end private ~ 省略 ~ PaymentIntentの作成リク エス トが失敗した場合はordersのレコードは作成されません。一方ordersの保存処理が失敗した場合は作成したPaymentIntentはキャンセル処理をするべきですが、上述した通りPaymentIntentは承認処理をしなければ実際に決済が行われないため、存在したままでも特に支障もないためそのままにしています。 仮登録フェーズでやるべきことは、PaymentItentとordersのどちらの登録処理も成功していれば確定フェーズでConfirm処理が確実に成功する もしくはCancel処理による ロールバック ができる という状態にしておくことがポイントです。 確定フェーズ(Confirm/Cancel) 仮登録フェーズのレスポンスで返した一時キーを利用し、フロントエンドのStripe SDK が確定処理を実行します。Stripeでは各イベントの発火ごとにWebhookを送信することができるため、確定処理の成功/失敗のイベントにおけるWebhook処理を実装します。 ほぼそのままのコードのサービスクラスの実装がこちらです。 class PaymentIntent :: Webhook :: UpdateOrderService VALID_EVENT_TYPE = %w[ payment_intent.succeeded payment_intent.payment_failed ] .freeze # @param [String] payload # @param [String] sig_header # @param [String] endpoint_secret def initialize (payload, sig_header, endpoint_secret) # 全てStripeのWebhookが送信してくる内容。ドキュメントに従えば良い @payload = payload @sig_header = sig_header @endpoint_secret = endpoint_secret end # @raise [Service::ValidationError] # @raise [ActiveRecord::RecordInvalid] def run! validate! ActiveRecord :: Base .transaction do @order = Order .lock.find_by( stripe_payment_intent_code : payment_intent_code) return logging_order_not_presence if @order .nil? send( :"update_order_ #{ event.type.split( ' . ' ).last } !" ) end end private def event @event ||= Stripe :: Webhook .construct_event( @payload , @sig_header , @endpoint_secret ) rescue JSON :: ParserError raise Service :: ValidationError .new([ :invalid_payload ], self ) # アプリケーションで定義している独自の例外クラス rescue Stripe :: SignatureVerificationError raise Service :: ValidationError .new([ :invalid_signature ], self ) end def update_order_succeeded! @order .update!( payment_succeeded_at : Time .zone.now) NotifySlack :: Order :: PaymentSucceededNotifyJob .perform_later( @order .id) end def update_order_payment_failed! @order .discard! # 論理削除 end def validate! check_valid_event_type! end def check_valid_event_type! raise Service :: ValidationError .new([ :unexpected_event_type ], self ) unless VALID_EVENT_TYPE .include?(event.type) end def logging_order_not_presence return if @order .present? Rails .logger.warn( " couldn't find Order with webhook, stripe_payment_intent_code= #{ payment_intent_code }" ) Rails .logger.warn(event) end def payment_intent_code event.data.object.id end end 成功時はOrderモデルをpayment_succeeded_atに現在時刻を入れて更新し、失敗時はOrderモデルを論理削除しています。 決済処理の トランザクション についてはメルカリさんの記事を参考にしています。 engineering.mercari.com stripe- ruby -mockを使ったTesting 外部 API を利用する上では RSpec やローカル環境ではリク エス トにモックを差し込めるようにしたくなります。WebMockなどを使って自前実装する手もありますが、Stripeの場合はモック化のためのgemがいくつか サードパーティ で開発されており、今回はその中でもstripe- ruby -mockを利用することにしました。 github.com 利用方法の詳細はここでは省略しますが、それっぽいモックデータを返してくれることはもちろんのこと、リク エス トで送ったパラメータをインメモリに保持し、その値をレスポンスとして返してくれるので非常に使い勝手が良いです。 しかし入力値バリデーションは完璧ではないため、あくまでレスポンスの型の検証として使うのが良さそうです。 モックデータはこの辺りのコードにまとまっています。 stripe-ruby-mock/data.rb at master · stripe-ruby-mock/stripe-ruby-mock · GitHub 返金処理・ 経理 業務 返金はStripeの ダッシュ ボード上、または API 経由でも行うことができます。 Stripe ダッシュ ボードでの返金処理 ダッシュ ボード上での返金処理 全額返金することも一部返金することもできます。 「関連する送金を差戻す」とは、決済で発生した金額のうち、子アカウントの残高に送金した金額を差戻すことを指します。チェックがない場合子アカウントの残高は売り上げが立ったまま減らないため、この返金はプラットフォーム側が立て替えることになります。 「プラットフォーム手数料を返金」とは、決済で発生した金額のうち、子アカウントから徴収したプラットフォーム手数料を子アカウントに返金することを指します。「関連する送金を差戻す」にチェックを入れつつ「プラットフォーム手数料を返金」にチェックを入れなかった場合、プラットフォーム側の利益が残ったまま子アカウントの残高が減るので、この返金は子アカウントが立て替えることになります。 少しややこしいですが、この二つの チェックボックス にチェックを入れて返金することで注文者が支払いをする前の状態に戻ることになります。テスト環境で返金を複数パターン試してみて理解するのが良いと思います。 API 経由による返金処理 API 経由であっても、 ダッシュ ボードと同じような制御をしつつ返金することが可能です。 Refundオブジェクトを利用します。 stripe.com 返金のタイミング Stripeでは返金はいつでも可能です。いつでもというのは子アカウントの銀行口座に残高を振り込んだ後でも可能であり、アカウントの残高はマイナスになることも許容されます。 しかし 経理 チームからの要求としては「特定の期間で"締め"て、締めたデータは変更されなくなる」ことが望まれていました。月次決算として売り上げを計上した後に返金され、計上された売り上げが変わってしまうと困ります。 そのためサービス方針として返金は24時間以内のみ可能であることを明記し、オペレーションとしてもStripeの ダッシュ ボードからの返金は行わずに基本的に API 経由で行う方針としました。 Stripeの決済金額は最短4日後に利益として確定されます(この件については次節で解説します)。そのため毎月5日には前月の売り上げが確定されることになる形で 経理 チームと合意しました。5日よりも早い方が 経理 としては嬉しいものの、Stripeの仕様上ここまでしかできないと結論づけました。 入金について 上記でStripeの仕様上と書きましたが、これはStripeの自動入金スケジュールが関わってきます。日本の口座の場合「週ごと」と「月ごと」が選択でき、スケジュール予定日時点で振込可能な金額が自動で入金されます。 「振込可能な金額」になるのが最短4日かかるため、例えば5/30が振込予定日だった場合は5/29の決済金額は含まれないことになります。 このStripeの入金の仕様は弊社 経理 チームとして辛く、導入企業からも問い合わせが多かった箇所でもありました。 経理 業務として考えると月内の売上がまとめて入金されることが望ましいですが、Stripeの入金を毎月5日に設定していた場合は月初め1日の売上も含まれてしまうことになります。これを4日にしていたとしても、Stripeの入金処理が始まるのはおそらく UTC 時間の0時ごろであり(Stripeのサポートに確認しましたが実際の時間は答えられないという回答でした。)、完璧な しきい値 で入金対象を選ぶことは難しいです。 経理 チームと相談の上、タイミーデリバリーでは毎月5日の入金とし、 ダッシュ ボード上でダウンロードできる利用明細と入金額を突合して処理してもらう形とすることにしました。 一応この問題を解決する方法はあるにはあり、Stripeの自動入金ではなく API による制御での手動入金を行うことで解決できます。他社さんでStripeを利用しつつ、月末締め翌月末払いを実現しているサービスは手動入金を活用しているようです。 しかし手動入金をしようとなるとアプリケーション側のロジックで入金金額の計算と API リク エス トなどの完全性を担保しなければならず、 PMF していない状態で自前で実装するのはコストやリスクが高いと判断し、今回は利用しませんでした。 よかったこと ここまででStripeの導入について説明してきました。Stripeを利用していてよかったことを説明します。 決済に関するトラブルが非常に少なかった 運用していて半年程度経ちますが、決済にまつわるトラブルや問い合わせはかなり少ないです。特に「クレジットカード決済ができない」ような問い合わせは0でした。Stripeの SDK のUXが高いのもそうですし、カード情報の間違いなどによるエラーはそれぞれ適切にメッセージを返してくれるからだと言えます。 ドキュメント、サポート、開発ツールが手厚い 開発者フレンドリーを謳っている通り、実装していて迷った場合は基本的に全て公式ドキュメントを読むことで解決できました。 ドキュメントを読んでも分からないことはサポートへ問い合わせていましたが、こちらもかなり助けられました。チャット、電話、メールによる問い合わせを受け付けており、メールでの問い合わせは日本語でやり取りすることができます。24時間以内の返信が保証されているのも安心できます。 開発ツールとして管理画面から API のリク エス トログやWebHookの結果を閲覧することができ、こちらも非常に見やすく便利でした。 失敗したこと 一方失敗したなと思えたことはいくつかあり、Customアカウントを選ばない方がよかったかもしれない、と今では思っています。理由は以下の2点です。 事業者に直接Stripe ダッシュ ボードを見てもらった方が楽なケースが多かった CustomアカウントではStripeを利用していることをほぼ完全に隠蔽できるほどにカスタマイズできますが、裏を返せば自身で実装しなければならない箇所がグンと増えます。CustomアカウントではStripeの ダッシュ ボードを顧客に提供することができないため、 ダッシュ ボードで提供されている売り上げや各種決済情報の明細、 CSV エクスポートなどの便利な機能を丸々使うことができず、自身で実装しアプリケーション内で提供する必要がありました。これは最小限の実装で済ませたいスタートアップとしては非常に重く、そもそもStripeを利用していることを隠したいモチベーションもないため、Expressアカウントの方がよかったのではないかと考えています。 事業者のStripeへの登録における実装がかなり重く、Expressで提供されているAccountLinkを使った方が良さそう 上述した通りStripeではAccountに事業者の各種情報を登録すればその瞬間から決済を行うことができますが、その情報が会社情報、代表者情報(身分証明証も含む)など多岐にわたり、導入事業者にその情報を収集してもらうのがかなり大変でした。加えてCustomアカウントではその情報を登録するフォームを自身で実装しなければならず、それぞれの情報のバリデーションまで含めるとかなりの 工数 がかかってしまい、リリース前に想定していた 工数 を想定以上に上回ってしまいました。 Expressアカウントの場合この登録フォームを提供できるAccountLinkと呼ばれる機能が提供されているので、それを使えば上記の 工数 は必要なかったのに…と後悔しています。 今アカウントを選ぶとした場合の判断基準 元々Customアカウントを選んだ理由は以下の3点でした。 導入事業者に課すプラットフォーム手数料の計算をアプリケーションロジックで行いたかったこと 弊社 経理 チームの都合や導入事業者の 経理 面からの要望により、返金等の請求内容の変更が発生するタイミングを制御したかったこと ネットで検索したところ、Expressアカウントの採用事例がまだ少なかったこと Standardアカウントでは返金の判断も事業者に任せてしまうため採用は難しいですが、上2つに関してはExpressアカウントでも可能です。 それに加えて事業者向け利用明細や事業者情報の登録フォームなどの SaaS に任せられる機能もExpressアカウントでは利用できるため、今からStripeを導入するとしたらExpressアカウントを利用すると思います。 どの機能は自身のアプリケーションで実装しなければならず、どの機能を SaaS に任せられるのか、を判断軸として持っておいた方が良いと書き残しこの記事を締めくくりたいと思います。 終わりに いかがだったでしょうか。新規事業でStripeを利用する際に考えたことのほぼ全てをこの記事に集約したつもりです。ぜひ参考にしていただければ幸いです。 15000字を超える超長文になってしまいましたが、ここまでお読みくださり誠にありがとうございました。
アバター
コーポレートエンジニア(兼いろいろ)をやっている @sion_cojp です。 本当はやり遂げた状態を発表したかったのですが、道のりも長かったため、今やっていこうとしてる内容を記事にしてみました。 この記事を見てタイミーのコーポレートエンジニアに興味を持っていただけたら幸いです。 TL;DR 増え続けるXaaS、ユーザ管理、複雑化 残されない手順書 我々がやりたいこと なぜterraform? terraformで管理していくための課題 課題を解決してみる さらにソフトウェア化できそうなところ APIがないXaaS 最後に TL;DR XaaSユーザ管理を全てterraformにしていく 名前とroleさえ設定してapplyすれば権限付与できる形式にしたい 増え続けるXaaS、ユーザ管理、複雑化 GSuite( SaaS )/ AWS や GCP (IaaS) / CircleCI(PaaS)など便利なツールが増えている時代。 弊社では全てのXaaSを網羅するカオスマップが github で管理されており、例えばエンジニア組織が主に使うものはこれだけあります。 オン/オフボーディングの際、社員に権限を手動で追加/削除してる必要がありますが、おそらくこれらのXaaSにログインし手動でポチポチやってる企業が多いかと思います。 「ちゃんと追加した?退職時にremoveしてる?」 追加権限が忙しいマネージャークラスしか無く、追加に時間かかって仕事がスムーズにできなかったり。 removeしてないとセキュリティ的に問題発生します。またユーザ単位課金だと無駄な費用も増えます。 残されない手順書 「ユーザをどう追加/削除したか、またそれはどういう基準か。」 ポチポチは手順や意図が残しづらいです。 どこをどうクリックして...のように全て手順書で管理されてる企業は少ないかと思います。 我々がやりたいこと XaaSのアカウント作成/削除を、オン/オフボーディングの一環としてコーポレート側で一限管理できると良さそうです。 ユーザ名に対し、role(部署やチーム)単位で紐づけて CRUD できるソフトウェアがあれば解決できます。 それらをどう管理するか...基本terraformで管理して、最悪なにかしらの独自ソフトウェアで解決していく方向で考えてみます。 なぜterraform? terraform自体は調べてもらうにして、私なりに今回のメリットは3つ。 1. 今ある状態をstateに残せる 2. providerさえあれば、いろんなXaaSを操作できるフレームワーク 3. 脱属人化を図れそう 1について、後ほど説明します。 2, 3について、 AWS でterraformが書ける人なら他のprovider...例えば GCP /datadogなども扱えることが多いです。 これはとても大事なことで、世の中にいる優秀なコーポレートエンジニアは極少数で、採用が難しいですし、弊社でも私1人です。 特定の言語で独自ソフトウェアを書くよりは、terraformの フレームワーク に落とし込むことで、脱属人化もしやすいです。 最悪なにかしらの独自ソフトウェアで解決しようと思いました。 この文言と矛盾してる理由は、今のところterraformに落とし込むことで問題はなさそうですが、将来どうなるか全く読めない。その場合は作るしかないかなぁと思い、この一文を添えました。 terraformで管理していくための課題 デメリットは2つ。 1. XaaSに対して、providerがない場合がある 2. community providerの場合、開発が滞ってる可能性がある AWS / GCP など有名なものはterraform-providerがあり、活発に開発されてます。 弊社では日本のXaaSも利用してるので、それらのterraform-providerを作る必要があります。 またGSuiteは有名なIaaSですが、 community provider です。 community providerの場合は、活発に開発されてない事が多いです。 1passwordに至っては registry.terraform.io に上がってないため、terraform 0.13で使うには自身で github releaseからproviderをインストールしないといけません。 これらを解決するには、自分たちで OSS でproviderを作成/コミットする課題があります。 課題を解決してみる 下記を試していけそうか実際にチャレンジしてみました。 1. 自分自身でterraform-providerを作ってみる(上述、2,3の課題解決) 2. 一番難しそうなGSuiteのterraform化をしてみる(上述、1の課題解決) 1について、jamfのterraform-providerを書いてみました。 https://github.com/sioncojp/go-jamf-api https://github.com/sioncojp/terraform-provider-jamf https://registry.terraform.io/providers/sioncojp/jamf/latest?pollNotifications=true jamf trial期間中に作成しため、若干バグがありますが、実際にterraform apply->Createができたのは確認しました。 解決は出来ましたが、コーポレートエンジニアが最低限 Golang を扱える必要があります。 「 Golang は扱えるが、terraform-providerの開発が難しいのでは?」というと、どのproviderも作り方は簡単で同じなので勉強すれば...最悪私が教えれば書けると思います。 (hashicorpがtutorialも出してます Setup and Implement Read | Terraform - HashiCorp Learn ) 2について、GSuiteのgroup部分をterraform化することが出来ました。 ただ、documentはあまり整備されておらず、issueも若干たまっており、 OSS コミットしていく必要があります。 1,2 を踏まえて、「 Golang が書けて、あとは OSS 開発/コミットをやっていく気持ちがあれば出来る」というのが所感です。 さらにソフトウェア化できそうなところ > 1. 今ある状態をstateに残せる これを利用して、GSuiteのterraform state情報より組織図の自動generateができそうですね。 またterraform-providerを開発した知識を踏まえて、先ほど出てきたjamfや meraki などコーポレートで管理してるツールのterraform化も出来て似たような課題を解決できそうです。 API がないXaaS API がないXaaSもあります。そこは手順書で管理するしかないと思います。 最後に 現段階では、gsuite/1passwordはterraform importすれば管理出来る状態です。 jamfに関しては、少しお時間かかりますが引き続き開発していく気持ちですので、ご協力よろしくお願いします。 そしてこれらを1人で成し遂げようとしたら多くの時間が必要です。 この記事見てterraform/ Golang を駆使して、コーポレートを自動化することに興味がある方がいらっしゃれば是非一度お話しましょう!
アバター
こんにちは、サーバサイドエンジニアの @Juju_62q です。 今回はタイミーで実践しているECSのオートスケール戦略についてお話ししようと思います。 TL;DR タイミーではTarget Tracking ScalingとStep Scalingを組み合わせてオートスケールをしています Target Tracking Scaling -> 通常のスケールアウト・スケールイン Step Scaling -> スパイク時のスケールアウト 2つを組み合わせることで、様々なリクエストに対し適切なリソースを用意しています タイミーのアクセス量の変化とビジネス要求 タイミーのアクセス量の変化とこれまでのオートスケール タイミー は空いた時間に働きたい人とすぐに人手が欲しい店舗・企業をつなぐスキマバイトアプリです。 したがって、仕事の募集数や働いてくださるワーカーさんの数は世の中の動向に大きく左右されます。 例えば昨年2019年の12月で言えば、忘年会シーズンということもあり飲食店を中心に仕事の募集が大幅に増えました。 重ねてそのタイミングでテレビCMを行いました。伴ってメディア露出も急増しサービスへのアクセスは波があるとは言え常時圧倒的に多かったです。 一方で4月以降はCOVID-19の影響もあり一時的に仕事の募集数、アクセス数共に減少しました。 近頃は職種を多様化させることにより再び盛り上がっていますが、アクセスの傾向は昨年の12月とは異なるものになっています。 具体的には弊社のマーケティングチームが通知やキャンペーンの施策を行ったタイミングでアクセス数が急増するようになりました 1 。 これまでのタイミーのオートスケールではアクセスの特性上滑らかなアクセス量の増減に対応とすることを目的としてきましたが、アクセスの変化量が大きくなったことにより従来のオートスケールでは対応が難しくなりました。 特に7/20に通知を行ったキャンペーンではその影響は顕著で、アクセスの増加からサーバ増加までのラグが10-15分ありました。 ユーザとしては通知が来たからアプリを開いているので、表示が適切にされなかったり待ち時間が長いのは体験がよくないです。 ビジネス要求の変化 前述した通り、タイミーの仕事の募集数、アクセス数はCOVID-19の影響もあり一時的に減少しました。 しかしながらセールスチーム、サポートチーム、マーケティングチームの必死の努力が実り今現在かなり盛り返してきています。 そうした努力の一部にワーカーさんにむけたPush通知やキャンペーンの実施があります。 メディア露出などの明らかに予期できるスパイクについては事前共有をいただいた上でサーバの増強を行ってきましたが、マーケティング施策となるとスピード感も頻度も異なります。 全てのPush通知やキャンペーンを実施する前に開発チームの承認を取るのは非効率です 2 。 一方でプロダクトの技術的な都合でビジネスチームが必死で考えた施策の効果を低下させるのは言語道断です。 以上を踏まえ、スピード感を持ってビジネス施策を実施でき、その効果をなるべく減少させない環境を作るためにオートスケール設定を見直しました。 ECSのオートスケールポリシー ECSのオートスケールポリシーはAWSが提供しているものとしては2種類存在します 3 。 なお、記事内ではオートスケールをコンテナの増減を指す言葉として扱います。VMのインスタンス数を増減させるオートスケールは扱いませんのでご了承ください。 Step Scaling Policy Target Tracking Scaling Policy Step Scaling Policy 指定した閾値に基づいてスケールアウト/インを行うオートスケールです。 スケールアウト/インを段階的に定義できるのが特徴で、例えば以下のような設定が可能です。 CPUの平均使用率が61-70% -> コンテナを1つ増やす CPUの平均使用率が71-80% -> コンテナを3つ増やす CPUの平均使用率が81%以上 -> コンテナを5つ増やす CPUの平均使用率が50%以下 -> コンテナを1つ減らす 適切な値を設定する難易度は低くないですが、うまく使うとリソースを急激に変化させることができます。 Target Tracking Scaling Policy 指定したメトリクスが指定した数値になるようにスケールアウト/インを行うオートスケールです。 イメージとしてはKubernetesの Horizontal Pod Autoscaler が近いと思います。 例えばCPUの平均使用率が60%となるように指定した場合 CPUの平均使用率が70% -> スケールアウト CPUの平均使用率が50% -> スケールイン というような振る舞いをし、CPUの平均使用率が60%になるように努めてくれます 4 。 オートスケールの設定をする時にみなさん頭を悩ませるのがスケールインの閾値だと思いますが、これをある程度いい感じにやってもらえるのが便利です。 タイミーでは12月時点からTarget Tracking Scaling Policyを利用しています。 いい感じにオートスケールしつつスパイクに抗うAutoScale Policyを設定する 以上を鑑みると、リソース使用量が滑らかに変化するオートスケールをTarget Tracking Scalingに任せ、スパイクが発生した場合にはStep Scalingを利用してどかっとリソースを増やすのが良さそうです。 Step Scalingで増やしたコンテナは、Target Tracking ScalingでスケールインしていくためStep Scalingにはスケールアウトの設定だけすればたりそうです。 タイミーのサービスで設定している実際の数値は出しませんが、Terraformでの実装例を載せておきます。 厳密には正確な記述ではありませんが、雰囲気は掴んでいただけると思います。 Target Tracking Scaling resource "aws_appautoscaling_target" "target" { max_capacity = 50 min_capacity = 5 resource_id = "service/${cluster_name}/${service_name}" role_arn = ${iam_role_arn} scalable_dimension = "ecs:service:DesiredCount" service_namespace = "ecs" } resource "aws_appautoscaling_policy" "scale" { name = "${service_name}-scale" resource_id = "service/${cluster_name}/${service_name}" scalable_dimension = "ecs:service:DesiredCount" service_namespace = "ecs" policy_type = "TargetTrackingScaling" target_tracking_scaling_policy_configuration { predefined_metric_specification { predefined_metric_type = "ECSServiceAverageCPUUtilization" } // CPUの平均使用率が60%になるように維持する target_value = 60 // スケールインの間隔は60秒空ける scale_in_cooldown = 60 // スケールアウトの間隔は30秒空ける scale_out_cooldown = 30 } depends_on = [aws_appautoscaling_target.target] } Step Scaling resource "aws_appautoscaling_policy" "spike_scale_out" { name = "${service_name}-spike-scale-out" policy_type = "StepScaling" resource_id = "service/${cluster_name}/${service_name}" scalable_dimension = "ecs:service:DesiredCount" service_namespace = "ecs" step_scaling_policy_configuration { adjustment_type = "ChangeInCapacity" // スケールアウトの間隔は30秒空ける(測定間隔は60秒ですが、バッファを持つため小さい値にする) cooldown = 30 metric_aggregation_type = "Maximum" // CPUの平均使用率が70%-80%の場合コンテナを3つ増やす step_adjustment { metric_interval_lower_bound = 0 metric_interval_upper_bound = 10 scaling_adjustment = 3 } // CPUの平均使用率が80%-90%の場合コンテナを5つ増やす step_adjustment { metric_interval_lower_bound = 10 metric_interval_upper_bound = 20 scaling_adjustment = 5 } // CPUの平均使用率が90%-の場合コンテナを10増やす step_adjustment { metric_interval_lower_bound = 20 scaling_adjustment = 10 } } depends_on = [aws_appautoscaling_target.main] } resource "aws_cloudwatch_metric_alarm" "spike_scale_out_alerm" { alarm_name = "${service_name}-spike-scale-out" comparison_operator = "GreaterThanOrEqualToThreshold" evaluation_periods = "1" metric_name = "CPUUtilization" namespace = "AWS/ECS" period = "60" statistic = "Average" // Step ScalingのCPU平均使用率の閾値の基準は70%とする threshold = 70 dimensions = { ClusterName = ${cluster_name} ServiceName = ${service_name} } alarm_actions = [aws_appautoscaling_policy.spike_scale_out.arn] } この2つの組み合わせで、CPU使用率を普段は60%に維持するようにオートスケールをしつつ、CPU使用率が80%や90%など跳ね上がった際に大きくスケールアウトできるようになります 結果 画像のグラフですが横軸のスケールを合わせるのを失念しておりました。失礼いたしました。 アクセスの増加から、1,2分で大幅なスケールアウトをしています。 結果的にレスポンスタイムの悪化も最小限に止めることができました。 終わりに 複数のスケールポリシーを組み合わせることで、滑らかなアクセス数の増減とスパイクの2つの状況に対応ができるようになりました。 とはいえまだまだ完璧なオートスケールには程遠く、リソースを最適に使えているとはいえない状況です。 より高いコストパフォーマンスを発揮できるようにこれからも勉強していきたいと思います。 アクセスの絶対量の減少により、施策によるアクセス変化がもたらす影響が大きくなった。また、新規登録は継続的にあるので通知対象ユーザ数も増加している。 ↩ 想定影響に応じて事前に連絡をいただいたり、単位時間あたりの流量制御もやってます。 ↩ APIやSDK経由でコンテナ数を増減させることでもっと柔軟な設定もできます。 ↩ 厳密には異なりますが、イメージはできると思います。 ↩
アバター
はじめましてこんにちは。 夏が本気を出してきて最近麺類しか口にしていないサーバサイドエンジニアのかしまです。 この度 API にてHTTP Status Codeとは別に、例外に対応するエラーコードを返すよう奮闘したのでその知見を共有したいと思います。 やりたいこと API にて例外が発生した場合、以下の形式でレスポンスを返すようにします。 { "errors": [ { "code": "code1", "message": "message1", }, { "code": "code2", "message": "message2", }, ] } Why? 今回追加した API は外部のアプリケーションとの連携に使うため、以下の要件を満たす必要がありました。 クライアントサイドがハンドリングしやすいような設計にする エラーの識別子は不変であること そのためエラーメッセージとは別に、コードを返すことにしました。 次の章から実際にどのように実装したかを紹介したいと思います。 rescue_fromにて例外をキャッチする rescue_fromとは例外をキャッチして指定したメソッドを実行してくれるという大変便利なものです。 railsguides.jp これを API の基盤となるcontrollerに埋め込みます。 class APIController < ActionController :: Base rescue_from StandardError , with : :error500 def error500 (error) render json : { errors : [{ code : ' E9999 ' , message : ' 例外が発生しました ' }] }, status : :internal_server_error end end このクラスを継承したcontrollerにてStandardErrorが発生するとerror500メソッドが呼び出され、期待するエラーレスポンスを返すことができます。簡単ですね。 他の例外も同じように実装していけば良さそうです。 Modelのvalidationエラーの場合 さてここからが本題なのですが ActiveRecord::RecordInvalid (modelのvalidationエラー)が発生した場合、例外の原因に対応したエラーコードを動的に取得して返すようにします。 独自で定義したModelのvalidationはサービスの ドメイン に紐づくものも多いため、同じ例外でも原因をコードで識別できるようにするためです。 class Offering < ApplicationRecord # Rails標準の汎用的なvalidation validates :start_at , presence : true validates :end_at , presence : true # 独自のvalidation # これらが発生した場合はそれぞれ定義したエラーコードを返したい validate :check_minimum_hourly_wage validate :check_rests_presence_and_minutes private def check_minimum_hourly_wage # 処理 errors.add( :hourly_wage , :greater_than_prefecture_minimum_wage ) end def check_rests_presence_and_minutes # 処理 errors.add( :base , :greater_then_or_eq_default_rest_minutes ) end end どうやってエラーコードを取得するか ActiveRecord::RecordInvalid をrescue_fromにてキャッチすると、以下のように ActiveModel::Errors の インスタンス にアクセスできます。 def error404 (error) error.record.errors.class # => ActiveModel::Errors end ActiveModel::Errors の インスタンス は details というattributesを持っています。 details 内にエラーが発生したattribute名をkey、エラーメッセージkeyを value *1 として配列で持っています。 error.record.errors.details => { :base =>[{ :error => :greater_then_or_eq_default_rest_minutes }]} # 配列内ではerrorをkey、エラーメッセージkeyをvalueとして持っている これらの情報からエラーコードを取得すれば良さそうです。 エラーコードの管理について タイミーでは定数管理に Config を使っているのでエラーコードもymlで管理することにしました。 error_codes: bad_request: E0400 unauthorized: E0401 forbidden: E0403 not_found: E0404 unprocessable_entity: E0422 internal_server_error: E9999 models: offering: base: greater_then_or_eq_default_rest_minutes: E2000 hourly_wage: greater_than_prefecture_minimum_wage: E3000 これにより、以下のようにエラーコードを取得できます。 Settings .error_codes.models.user.base.hoge => E2000 エラーメッセージとエラーコードを対応させる エラーコードの取得方法が決まったのであとはエラーメッセージとペアにすれば良さそうです。 ActiveModel::Errors の インスタンス は messages というattributes名でエラーが発生したattribute名をkey、エラーメッセージkeyからlocaleに対応したエラーメッセージを value として配列で持っています。 error.record.errors.messages => { :base =>[ " 法的休憩時間を満たしていません " ]} しかしこれではどのメッセージがどのエラーメッセージkeyに対応するのかわかりません。 そのため messages の情報を使わず、 details の情報からエラーメッセージも取得する必要があります。 エラーメッセージを取得するため、どのようにエラーメッセージが格納されているのかを把握するため、 ActiveModel::Errors#add メソッドをソースを読んでみると、 https://github.com/rails/rails/blob/3f470a7e3a22304f095535b8cc730b4926828175/activemodel/lib/active_model/errors.rb#L311 https://github.com/rails/rails/blob/3f470a7e3a22304f095535b8cc730b4926828175/activemodel/lib/active_model/errors.rb#L535 def add (attribute, message = :invalid , options = {}) message = message.call if message.respond_to?( :call ) detail = normalize_detail(message, options) message = normalize_message(attribute, message, options) if exception = options[ :strict ] exception = ActiveModel :: StrictValidationFailed if exception == true raise exception, full_message(attribute, message) end details[attribute.to_sym] << detail messages[attribute.to_sym] << message end ~ 略 ~ private def normalize_message (attribute, message, options) case message when Symbol generate_message(attribute, message, options.except(* CALLBACKS_OPTIONS )) else message end end どうやら generate_message をいうメソッドを使えば messages に格納されているメッセージを生成できそうです。 以上を踏まえた上で完成したメソッドがこちら def error422 (err) class_name = err.record.class.name.underscore active_model_errors = err.record.errors errors = [] active_model_errors.details.each do | attribute , attribute_errors | attribute_errors.each do | error_info | # エラーコードとエラーメッセージをセットにするため、full_messagesではなく # generate_messageとfull_messageを使ってエラーメッセージを生成している error_key = error_info.delete( :error ) message = active_model_errors.generate_message( attribute, error_key, error_info.except(* ActiveModel :: Errors :: CALLBACKS_OPTIONS ), ) errors << { code : Settings .error_codes.models.send(class_name)&.send(attribute)&.send(error_key) || Settings .error_codes.unprocessable_entity, # error_codesにて定義のないエラーの場合、汎用的なエラーコードを返す message : active_model_errors.full_message(attribute, message), } end end render json : { errors : errors }, status : :unprocessable_entity end 試しにvalidationエラーを起こしてみた結果以下のレスポンスが返りました🎉 { "errors": [ { "code": "E2000", "message": "法的休憩時間を満たしていません", } ] } 問題点 一見良さそうですが、以下の問題が発覚しました。 1. errors.add時にoptionでerrorという名のkeyを渡されると、エラーメッセージkeyが上書きされる errors.addメソッドでは、以下のようにoptionを渡せます。 errors.add( :base , :hoge , reason : ' reason ' ) 渡したoptionはlocaleファイル内で参照できたりします。 ja: activerecord: errors: models: user: attributes: base: hoge: %{reason} のため登録できません そして ActiveModel::Errors の details の内容は以下のようになります。 error.record.errors.details => { :base =>[{ :error => :hoge , :reason => ' reason ' }]} では errors.add時にerrorというオプションを渡したらどうなるかというと。。。 errors.add( :base , :hoge , error : ' error ' ) errors.details => { :base =>[{ :error => ' error ' }]} はい。エラーメッセージkeyが上書きされてますね。 以下の処理でoptionのmergeを行った結果を details に格納しているのでこのような結果になります。 https://github.com/rails/rails/blob/3f470a7e3a22304f095535b8cc730b4926828175/activemodel/lib/active_model/errors.rb#L545 def normalize_detail (message, options) { error : message }.merge(options.except(* CALLBACKS_OPTIONS + MESSAGE_OPTIONS )) end 対応策 幸い現時点でerrorオプションを使用しているコードはありませんでしたが、今後もしerrorオプションを渡された場合、 エラーメッセージkeyの取得は不可能です。 そのため、エラーメッセージkeyが上書きされgenerate_messageにて適切なメッセージが取得できなかった場合は汎用的なメッセージを返すと共に適切なメッセージを返せなかったことを検知 *2 できるようにしました。 def error422 (err) class_name = err.record.class.name.underscore active_model_errors = err.record.errors errors = [] active_model_errors.details.each do | attribute , attribute_errors | attribute_errors.each do | error_info | # エラーコードとエラーメッセージをセットにするため、full_messagesではなく # generate_messageとfull_messageを使ってエラーメッセージを生成している error_key = error_info.delete( :error ) message = active_model_errors.generate_message( attribute, error_key, error_info.except(* ActiveModel :: Errors :: CALLBACKS_OPTIONS ), ) if message.match?( /\A translation missing: / ) message = I18n .t( ' errors.messages.exceptions.unprocessable_entity ' ) # Sentry通知 LogService .error_with_sentry(error, ' 外部向けAPIにて適切なエラーメッセージを返せませんでした。 ' ) end errors << { code : Settings .error_codes.models.send(class_name)&.send(attribute)&.send(error_key) || Settings .error_codes.unprocessable_entity, # error_codesにて定義のないエラーの場合、汎用的なエラーコードを返す message : active_model_errors.full_message(attribute, message), } end end render json : { errors : errors }, status : :unprocessable_entity end 追加した処理は以下の部分です。 if message.match?( /\A translation missing: / ) message = I18n .t( ' errors.messages.exceptions.unprocessable_entity ' ) # Sentry通知 LogService .error_with_sentry(error, ' 外部向けAPIにて適切なエラーメッセージを返せませんでした。 ' ) end generate_message内部では I18n を使用しています。 オプションで渡す場合、locale keyが存在する確率は低いだろうという思想のもと、戻り値に translation missing: が含まれる場合、汎用的なメッセージを返すと共にSentryに通知するようにしました。 *3 2. 関連テーブルのvalidationに引っかかった場合、generate_messageで例外を吐く ActiveRecord では関連テーブルを含めたデータ保存を以下のように行うことができます。 offering = Offering .new(offering_params) offering.rests.new(rests_params) offering.save! 上記の例で、restsの保存処理にてvalidationエラーが発生すると、generate_messageメソッドの内部でエラーが発生しました。 NoMethodError : undefined method ` rests.start_at' for #<Offering:0x00005570d93a1de0> Did you mean? rests_attributes= from /usr/local/bundle/gems/activemodel-6.0.3.2/lib/active_model/attribute_methods.rb:432:in ` method_missing ' Caused by ActiveRecord::RecordInvalid: 休憩開始時間は休憩終了時間より大きい値にしてください from /usr/local/bundle/gems/activerecord-6.0.3.2/lib/active_record/validations.rb:80:in `raise_validation_error ' なんだこれは。。ということでgenerate_messageメソッドのソースを読んでみると https://github.com/rails/rails/blob/3f470a7e3a22304f095535b8cc730b4926828175/activemodel/lib/active_model/errors.rb#L482 def generate_message (attribute, type = :invalid , options = {}) type = options.delete( :message ) if options[ :message ].is_a?( Symbol ) value = (attribute != :base ? @base .send( :read_attribute_for_validation , attribute) : nil ) attribute名がbaseではない場合、レコードに対してattributeのメソッド呼び出しを行なっています。 *4 つまり details に格納されているattribute名が rests.start_at になっているため、そんなメソッドはねぇ!と怒られているようです。 対応策 どうやら関連テーブルのデータにてvalidationエラーが起きた場合、attribute名は #{relation名}.#{attribute名} になるということがわかりました。 *5 そのためattribute名に . が存在したらrelation名とattribute名に分割し、関連レコードをチェックして、エラーが発生していたらコードを取得してメッセージを生成を行うようにしました。 def error422 (err) errors = :: ExternalCoordinationAPI :: ErrorResponse .new(err).create.errors render json : { errors : errors }, status : :unprocessable_entity end module ExternalCoordinationAPI class ErrorResponse attr_reader :record , :error , :errors , :active_model_errors def initialize (error) @error = error @record = error.record @errors = [] @active_model_errors = record.errors end def create active_model_errors.details.each do | attribute , options | if attribute.to_s.include?( ' . ' ) relation_name, _attribute_name = attribute.to_s.split( ' . ' ) record.send(relation_name).each do | relation_record | next if relation_record.errors.empty? relation_record_active_model_errors = relation_record.errors relation_record_active_model_errors.details.each do | relation_record_attribute , relation_options | relation_options.each do | option | error_key = option.delete( :error ) message = generate_message(relation_record_attribute, error_key, option) add_error(relation_record.class.name.underscore, relation_record_attribute, error_key, message) end end end else options.each do | option | error_key = option.delete( :error ) message = generate_message(attribute, error_key, option) add_error(record.class.name.underscore, attribute, error_key, message) end end end self rescue StandardError => e LogService .error_with_sentry(e, ' 外部向けAPIにて422エラー生成時にエラーが発生しました。 ' ) errors << { code : Settings .error_codes.unprocessable_entity, message : I18n .t( ' errors.messages.exceptions.unprocessable_entity ' ), } self end private def generate_message (attribute, error_key, options) # エラーコードとエラーメッセージをセットにするため、full_messagesではなく # generate_messageとfull_messageを使ってエラーメッセージを生成している message = active_model_errors.generate_message( attribute, error_key, options.except(* ActiveModel :: Errors :: CALLBACKS_OPTIONS ), ) if message.match?( /\A translation missing: / ) message = I18n .t( ' errors.messages.exceptions.unprocessable_entity ' ) LogService .error_with_sentry(error, ' 外部向けAPIにて適切なエラーメッセージを返せませんでした。 ' ) end message end def add_error (class_name, attribute, error_key, message) errors << { code : Settings .error_codes.models.send(class_name)&.send(attribute)&.send(error_key) || Settings .error_codes.unprocessable_entity, message : active_model_errors.full_message(attribute, message), } end end end もしgenerate_messageにてまだ予想できていないエラーが発生した場合に備え、StandardErrorをキャッチして汎用的なメッセージを返すようにしました。 終わりに 正直かなり力技に頼った実装になってしまっており、もっとうまくできるのではないかと思っています。 特にerrorオプションを渡せないなど本来の機能を制限してしまっている箇所はどうにかしたい気持ちが強いです。 もし良い方法をご存知の方がいらっしゃったら是非アド バイス をいただけたらと思います。 *1 : errors.add時に第二引数に渡した値が入ります。タイミーではエラーメッセージは全てlocaleで定義しているので、errors.add(:base, 'エラーメッセージ')というように文字列を渡さないようにしています。 *2 : タイミーではSentryを使ってます *3 : 対応するlocale keyが存在したら。。。オプションkeyにerrorは使用しないというルール化をするほうが良さそうです。 *4 : read_attribute_for_validationはsendのaliasでした。 https://github.com/rails/rails/blob/e6c6f1b4115495b27a2d32a4bd5c95256db695b1/activemodel/lib/active_model/validations.rb#L402 *5 : どの箇所でこのようなattribute名をセットしてるのかまで追いたかったのですが、力及ばすでした。無念。
アバター
SREとコーポレートエンジニアをやっている @sion_cojp です。 今回は新オフィスのネットワーク構築を実施したので、こちらについてお話します。 私自身、学生時代の研究や、10年前にデータセンターのネットワーク構築しか経験がないため、所々おかしな点があるかもしれませんが、ご了承ください。 また今回相談に乗ってくださった @kajinari さんに感謝の意を表します。 TL;DR 旧オフィスはとても不安定なネットワークでしたが coltの回線と meraki のネットワーク機器で 快適なネットワークができました 予算問題で達成できなかったこと: L3/L2機器 + ネットワーク回線の 冗長化 なんで今オフィス移転? 旧オフィスの契約終了が7月末だったため、新しいオフィスはコロナが流行する前に契約しました。 違約金など考慮した結果、オフィス移転の判断をしております。 もちろん新オフィスを構えましたが、会社としては現在もリモートワーク推奨です。 旧オフィスの課題 回線が家庭用だったため、数百人規模には耐えれませんでした。 docker pullしてネットワークが落ちたこともありますし、日常的に遅くなったりとても不安定でした。 またネットワーク構築をアウトソースしているため、ネットワーク機器にアクセス出来ず、設定変更するにも連絡しないとダメでした。 これを踏まえて、自分たちでネットワーク構築する判断をしました。 設計 こちらの図はdraw.ioで書いて、 png + drawioファイルを GitHub で管理しております。 L3, L2ですが、予算の兼ね合いで一旦シングル構成にしております。APに関しても工事と予算の関係上、半分の台数のみ購入となっております。 そのため弊社で根幹となっている、カスタマーサポートと「ネットワークが止まった場合のフロー」を相談し合意の上でシングル構成にしました。 いずれ冗長構成にする予定です。 配線 天井と床下があり、それぞれ図面から長さを測って作成しました。 同じように GitHub で管理しております。 こちらをオフィス移転の工事業者に提出して配線依頼しました。 また年内は諸事情により、図の右半分だけのオフィス運用になっておりますが、配線だけは全部完了させて、使ってないLANケーブルはネットワークポートのほうでshutdownしております。 業者を呼ぶ手間と料金を省きたかったからです。 L3, L2 コンソールケーブルや SSH で設定...私は可能性ですが、今時代だと管理コストが高いため、全て cisco meraki にしました。 選定理由はオフィスネットワークで安定した結果を出しておりますし、 dashboard から機器の管理, 設定が可能なのである程度ネットワーク知識さえあれば設定できると思ったからです。 また meraki だとWLC(ワイヤレスコントローラー)が不要なのもメリットです。 こちらはCTCから下記を購入しております。 L3(MX100) ×1台, L2(MS120-48) ×1台, AP(MR36) ×3台(1台は今の所予備機) 機種の選定理由は下記。 MX100...数年後見据えた人員計画を元に、最大収容人数と合致したのがこれでした。 MS120-48...1000Base対応 + 要件的に48ポート欲しかったのでこちらに。 MR36...将来クライアントで利用できるWiFi6(802.11ax)対応 + PoE。ある程度長く使えそうだと思いました。 VLANや設定周り 有線LANは MACアドレス フィルタリングと固定IPで管理するようにしております。これにより第 三者 が有線LANを物理的に奪ってからの クラッキング リスクを防ぎます。 WiFi に関しては、ゲスト用はプリンタに接続出来ないようにしてます。プリンタを踏み台にした クラッキング リスクを防いでます。 また弊社にはネットワークカメラがあるので、そちらにはどの端末からもLocal IPアクセス出来ないようVLANを切ってます。 AP APの配置は柱などで電波が届かない場所を考慮しながら設置します。 そしてAPの台数を決めるのにクライアント数の計算は必要です。 社員1人あたり、2~3クライアント(PC, スマフォ,検証端末)* 100 = 200~300クライアント。 また、いずれできるオープンスペースの収容人数も追加計算しました。 一般的には1APあたり20~25らしいですがあくまで目安なので、もっと多くのクライアントを捌くことも出来ます。 そこら辺は経験の上、台数を決めて、もし足りなくなったら増やす方針にしました。 またLANケーブルから電力供給するため、PoE対応のものが必須です。 ラック 摂津金属工業のサイトから購入しました。 選定理由はデータセンターでも実績があり、安心感がありました。 ポイントは ONU (実際は回線業者から提供されたスイッチ)を置く棚板 + L3 ×2台 + L2 ×2台分のU数は確保したいですね。 またLANケーブルを地面に置いたりする可能性があるので、その分のスペースも確保すると良いです。 最終的に購入したのは下記となります。 - SKR-16U60A0VB - 背面マウントのSKRO-16UPF-B(黒色)追加、 - コインロックのSKRO-3CL追加 - 棚板(D600㎜)1セット、耐サージ機能付OAタップ(8口)2本 電源 電源の 冗長化 のために、オフィスのビルに2系統引いてます。 (と言っても今はシングル構成なので、意味ないですが将来のため。。) 回線 Colt 300M Fixed(帯域保証)の回線を利用してます。 選定理由は金融機関でも使われてるため安心して利用出来そうだったからです。 社員が増えれば1Gにupgradeも良いでしょう。 またネットワーク冗長で、Nuroあたりの従量課金もサブで利用したかったのですが、予算のため今回はやっておりません。 ラック内配線のポイント 実際のラックです。 配線のポイントとしては、ラックの外側にLANケーブルを這わせることです。 もし面倒くさがってラックの内側からケーブリングしてしまうと、機器を追加マウントする時にケーブルが邪魔になる可能性があります。 また今後追加予定の機器を想定してスペースを確保してます。 GitHub で管理してるもの ネットワーク設計図 天井、床下の配線図 固定IP/VLAN/ SSID のリストとdescription 購入->初期設定の手順やこの構成になった経緯 各機器の問い合わせ先と契約内容 などを管理してます。余談ですが、ネットワーク以外にもプリンタ周りや、セキュリティソフト関連も GitHub で管理しております。 どれくらい期間かかるの? 基本的にネットワーク機器、ラック、回線の手配で2ヶ月はみた方がいいでしょう 特に回線ですが、コロナ禍やオリンピックの影響で手配が遅れる可能性が高いです。 参考にした資料は? 出たエラーに関して google で検索すれば大抵の内容は https://community.meraki.com/ で議論されてるので、それを読めば解決しました。 アウトソースしなかったメリットについて ネットワーク機器のアクセス権があるので、好きなタイミングで自分たちで設定出来るのが嬉しいですね。 ある程度ネットワークの知識があるならアウトソースしないほうがコストと運用面でメリットが大きいと思います。 まとめ 素人ながらネットワーク設計をしっかり調べてやった結果、旧オフィスと比べ物にならないくらい快適なネットワーク環境になりました。 移転後、ネットワーク周りの問い合わせが来ないかとても不安でしたがそれもなかったです。 meraki には様々な設定があるみたいなので、もっと学んで強化していきたいと思います。
アバター
こんにちは、 タイミーデリバリー 開発チームの宮城です。 今回は弊社のOpenAPI3ベースの スキーマ 駆動開発の運用方法を紹介します。 TL;DR 技術スタックは OpenAPI3, Swagger UI, Committee, ActiveModelSerializers Committeeを利用してOpenAPI準拠のRequest Specを行う OpenAPI3のrequiredキーワードに注意する 背景 タイミーデリバリーでは、 Rails による API サーバーと、Web管理画面としてVue.jsによるSPA、ユーザー向け iOS アプリとしてSwiftを採用しています。 1つの モノリス な Rails で利用者別にネームスペースを区切り、それぞれエンドポイントを提供しています。 サーバーサイドとクライアントサイドを分離し並行して開発を進めるために スキーマ 駆動開発を導入しました。 スキーマ 駆動開発の詳しい説明やメリットについては既に多くの記事が存在するため、ここでは参考にさせていただいた記事の紹介までとさせていただきます。 RubyKaigi 2019でOpenAPI 3について登壇しました - おおたの物置 スキーマファースト開発のススメ - onk.ninja スキーマ駆動開発のススメ - Studyplus Engineering Blog この記事では、実際に スキーマ 駆動開発を開発フローに導入する際のTipsを記したいと思います。 技術スタックは ActiveModelSerializers, OpenAPI3, Swagger UI, Committee Rails で API サーバーを構築するにあたって、 json の生成には ActiveModelSerializers を採用しました。 スキーマ 定義には OpenAPI3 を採用し、 Swagger UI でドキュメントを閲覧できるようにしています。 この3つに関しては近年では割とよくある一般的な技術スタックかなと思っています。 スキーマ 定義を記述する YAML ファイルは Rails の リポジトリ に配置しています。 . ├── app/ ... アプリケーションのメインのソースコード ├── bin/ ├── config/ ├── db/ ├── etc/ │ └── docs/ │    ├── docker-compose.yml │    ├── README.md │    └── swagger/ │    └── swagger.yml ルート直下のetc ディレクト リにはアプリケーションの実行には直接は関係ないものをまとめており、Dockerイメージのビルド時にdockerignoreしています。 スキーマ 定義以外ではデプロイの設定が入ってたりします。 スキーマ 定義は分割していない スキーマ 定義は swagger.yml に全て記載しており、 YAML の分割などは一旦していません。 別のプロジェクトで細かく分割して ディレクト リを分け、jsで ディレクト リを監視しマージする運用をしていたことがありましたが、マージする仕組みを新規開発者に理解してもらうコストがかなり高く、運用しづらかった経験がありました。 今回は新規プロジェクトで API の数も少ないため、一旦1つの YAML ファイルのみで運用してみています。今の所チームメンバーからの不満は少ないですが、 ios とvueでネームスペースを切っているのでそれくらいは分けてもいいかなと思っています。 Docker ComposeでSwagger UIを立ち上げる Swagger UIを利用したドキュメントの閲覧方法は、どこかに ホスティング したりCircleCIのartifactsを使ったりはせず、Docker Composeで開発者自身のローカルで立ち上げるようにしています。 チームメンバーがDockerでの開発に慣れていたためこの選択をしました。 docker-compose.ymlはこんな感じです。 Swagger UIを立ち上げるdocker-compose.yml version : '3' services : redoc : image : redocly/redoc:latest container_name : redoc volumes : - ./swagger:/usr/share/nginx/html/swagger environment : SPEC_URL : swagger/swagger.yml ports : - "8081:80" doc : image : swaggerapi/swagger-ui:latest container_name : doc volumes : - ./swagger:/usr/share/nginx/html/swagger environment : API_URL : swagger/swagger.yml ports : - "8080:8080" Swagger UIと Redoc が立ち上がります。 RedocもOpenAPIベースのドキュメント表示ツールで、開発者が自由にどちらでも見ていいことにしています。 Committeeを利用してOpenAPI準拠のRequest Specを行う スキーマ 駆動開発では、開発を始める前にまず スキーマ を定義しそれを信頼することが求められます。 しかし起こりうる課題として スキーマ と実装の乖離があります。 スキーマ の信頼性が失われると結局サーバーサイド開発者とクライアントサイド開発者の無駄なコミュニケーションが発生し、 スキーマ は形骸化してしまいます。 Rails の開発においてこの問題を防ぐために、committeeとcommittee- rails というGemを採用しました。 github.com github.com committeeは、OpenAPIで定義した スキーマ を利用してリク エス トとレスポンスの検証を行う ミドルウェア を提供してくれます。 committee- rails はラッパーライブラリで、 rails での導入を簡単にしてくれます。 committee- rails は rails_helper.rb に以下の記述を追記することで導入できます。 # configured for committee-rails config.add_setting :committee_options config.committee_options = { schema_path : Rails .root.join( ' etc ' , ' docs ' , ' swagger ' , ' swagger.yml ' ).to_s, } include Committee :: Rails :: Test :: Methods スキーマ が配置されているパスを設定しています。 committeeを導入する前は、元々OpenAPIの定義は Rails とは別の リポジトリ として用意していました。実装のコミットと スキーマ 定義のコミットは分けた方が見通しがいいだろうと思ってのことです。 しかしcommitteeを導入するとなるとprivateの別 リポジトリ の ソースコード を読みに行くのは非常に面倒になってしまうので、上記のメリットだけであるならば Rails の リポジトリ にOpenAPI定義まで含めた方が楽だろうということで、 Rails の リポジトリ に統合することにしました。運用上困ることは特に起きていません。 committeeを導入することで、 rspec のrequest specで assert_response_schema_confirm が使えるようになります。 例えばUser自身のリソースを返すエンドポイントがあったとします。(認証などは除いています) paths : '/users' : get : tags : - user summary : User自身の情報 description : 名前とメールアドレスの取得 responses : '200' : content : application/json : schema : $ref : '#/components/schemas/UserSchema' components : schemas : UserSchema : type : object required : - user_id - family_name - given_name - email properties : user_id : type : integer example : 1 family_name : type : string example : 田中 maxLength : 255 given_name : type : string example : 太郎 maxLength : 255 email : example : hoge@example.com type : string maxLength : 255 Rails 側で対応するserializerとcontrollerはこんな感じになります。 # app/serializers/user_serializer.rb class UserSerializer < ApplicationSerializer attribute :id , key : :user_id attributes :family_name , :given_name , :email end # app/controllers/users_controllers.rb class UsersController < ApplicationController def show render json : current_user, serializer : UserSerializer end end request specはこんな感じです。 RSpec .describe ' Users ' , type : :request do describe ' #show GET ' do let( :user ) { FactoryBot .create( :user ) } subject { get(user_v1_users_me_path) } before { subject } context ' when success ' do let( :return_http_status ) { :ok } it ' return expected status ' do expect(response).to have_http_status return_http_status end it ' return expected body schema ' do assert_response_schema_confirm end end end end assert_response_schema_confirm を利用することで、OpenAPI定義に準拠しているかをチェックすることができます。 この時点ではテストは通ります。 ここから、例えばUserSchemaに誕生日が追加されたとします。 components : schemas : UserSchema : type : object required : - userId - family_name - given_name - email - birthday properties : user_id : type : integer example : 1 family_name : type : string example : 田中 maxLength : 255 given_name : type : string example : 太郎 maxLength : 255 email : example : hoge@example.com type : string maxLength : 255 birthday : example : 1990-01-01 type : string format : date Serializerにbirthdayは定義していないため、このまま再度Request Specを実行するとテストは通らなくなります。 .F Failures : 1 ) Users #show GET when success return expected body schema Failure / Error : assert_response_schema_confirm Committee :: InvalidResponse : #/components/schemas/UserApi::UserSchema missing required parameters: birthday # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3/operation_wrapper.rb:35:in `rescue in validate_response_params' # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3/operation_wrapper.rb:30:in `validate_response_params' # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3/response_validator.rb:20:in `call' # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3.rb:38:in `response_validate' # /usr/local/bundle/gems/committee-4.0.0/lib/committee/test/methods.rb:27:in `assert_response_schema_confirm' # ./spec/requests/user/v1/users/me_request_spec.rb:32:in `block (4 levels) in <main>' # /usr/local/bundle/gems/bootsnap-1.4.6/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:55:in `load' # /usr/local/bundle/gems/bootsnap-1.4.6/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:55:in `load' # /usr/local/bundle/gems/spring-commands-rspec-1.0.4/lib/spring/commands/rspec.rb:18:in `call' # -e:1:in `<main>' # ------------------ # --- Caused by: --- # OpenAPIParser::NotExistRequiredKey: # #/components/schemas/UserApi::UserSchema missing required parameters: birthday # /usr/local/bundle/gems/openapi_parser-0.11.2/lib/openapi_parser/schema_validator.rb:62:in `validate_data' Finished in 1.33 seconds (files took 0.46133 seconds to load ) 2 examples, 1 failure Failed examples: rspec ./spec/requests/users_request_spec.rb: 31 # Users #show GET when success return expected body schema このように、OpenAPI定義に準拠していなかった場合はテストを落とすことができます。導入も簡単で非常に便利です。 ちなみに assert_response_schema_confirm を全てのエンドポイントで記述するのは面倒なので、実際はshared_exampleにまとめています。 # spec/support/response_helper.rb module ResponseHelper shared_examples ' response status & body compare to swagger ' do it ' return expected status ' do expect(response).to have_http_status return_http_status end it ' return expected body schema ' do assert_response_schema_confirm end end end OpenAPI3のrequiredに注意する CommitteeによってOpenAPI定義に準拠していることを確認していますが、これはOpenAPI定義でrequiredを設定しなければプロパティの有無の検証ができず、テストが全て通ってしまいます。 components : schemas : UserSchema : type : object required : <- ここに必須とするpropertiesを設定しなければ、テストが全て通ってしまう - user_id - family_name - given_name - email properties : # 省略 type: object を指定する場合は、基本的に併せてrequiredを設定するものと考えたほうが良いかと思います。 その他にもcommitteeが検証してくれる項目は多岐にわたりますが、それぞれOpenAPI定義で指定しなければ検証はされません。(当たり前ですが) 配列のminItemsを指定することで、空配列をバリデーションする リク エス トのContent-Type リク エス トのパスの存在可否 レスポンスの ステータスコード の存在可否 Committeeでテストが落ちて欲しいシチュエーションでテストが通ってしまった場合、OpenAPIで定義していないだけということがよくありました。 同様の問題として、OpenAPI定義でよく悩むのはnullableの扱いです。 qiita.com OpenAPI3になってからはpropertiesに nullable: true フラグを設定することができるようになりましたが、Swagger UIでは表示はまだされません。 Committeeの検証においては、requiredを指定しpropertiesで型を定義すればデフォルトで nullable: false となり、 value がnullの場合はテストが落ちるようになります。 ですので基本的にはキーとバリューが確実に存在する場合はrequiredのみを指定しておき、 value のnullを許容したいにしたい場合のみ nullable: true , nullable: false は指定しなくてもよい、みたいな運用をしています。 もう一つ、例えばOpenAPIで定義していないcreated_atやupdated_atをレスポンスとして返していたとしても、Committeeは過分に関してはチェックをしてくれません。 スキーマ 駆動開発を実践しているならばOpenAPIに定義されているpropertiesがあればクライアントサイドの開発は進められるはずなので、過分があったとしても特に問題はないはずではありますが… この運用でよかったこと API ドキュメントの信頼性が高く、保守し続けられることが仕組み化できた スキーマ 駆動開発は何度か経験しているものの、今回のプロジェクトで初めてCommitteeを導入しました。Committeeによって スキーマ に準拠していることを検証できるようになったため、 API ドキュメントの信頼性がより高められました。 Committeeの導入により、バリデーションを意識して YAML を書くようになった requiredやminItemsなどバリデーションを意識して適切なpropertiesを設定するようになり、より詳細なドキュメントが残せるようになりました。 まとめ 今回は スキーマ 駆動開発を実際にどのように運用しているのかを紹介しました。今回では紹介しきれませんでしたが、リソース指向の スキーマ 定義についてのチームの試行錯誤や、snake_caseからcamelCaseへの変換をコントローラー層で行っている例、コントローラー層での汎用的なエラーハンドリングなど、 API 開発のTipsを今後も紹介していこうと思っています。 質問や指摘お待ちしております!
アバター
こんにちは、タイミーSREチームの宮城です。 今回は弊社が Redash をFargateで構築/運用している話を紹介します。 背景 タイミーでは、CSやセールスのKPI策定から毎月の事業数値に至るまで、Redashが様々な用途で活用されています。 Fargateで構築する以前はEC2上のdocker-composeで運用されていましたが、以下の課題がありました。 オートスケールできないため、クエリが詰まってCPUが100%になってサービスが停止する。 その度slack上から再起動していた セットアップしたエンジニアが退社しており、インフラ構成図やノウハウの共有、IaCによる管理ができていない。 クエリや ダッシュ ボードなどのデータの定期的なバックアップができていない。 v7系からv8系へのアップデートがしたいが、アップデートによる影響範囲がわからず恐怖感がある。 事業に大きく関わるサービスなのにも関わらず、モニタリングやアラートができていない。 上記をFargateに移行することで解決することができました。 移行後の アーキテクチャ Redashで利用する ミドルウェア に関しては下記 コンポーネント を使い、全てをterraformで管理しています。 - PostgreSQL -> RDS - Redis -> ElastiCache それぞれの構成の紹介 ここからは、それぞれの構成をTerraformの ソースコード やタスク定義の JSON などを交えつつ説明していきます。 RDS/Elasticache ダッシュ ボードなどのデータが定期的なバックアップが行われていない問題は、RDSでsnapshotを取得することで解決しました。 それぞれ一番小さい インスタンス タイプのシングルAZ構成で構築しています。 実際に運用してみて負荷が大きければスペックを上げるつもりでしたが、現状問題なく捌けています。 将来、可用性を高めるためマルチAZにすることも容易であり、こういった柔軟なサーバーリソースの活用も クラウド の利点といえるでしょう。 RDSのTerraform privateサブネットに置いたシンプルな構成です。 applyが完了したらrootユーザーのパスワードを AWS コンソール上から変更し、接続情報をSecretsManagerに保管しています。 resource "aws_db_subnet_group" "redash" { name = "redash" subnet_ids = [ data.aws_subnet.private-subnet-1a.id, data.aws_subnet.private-subnet-1c.id, data.aws_subnet.private-subnet-1d.id, ] } /* Postgresの5432ポートを開くセキュリティグループ */ resource "aws_security_group" "rds-redash" { name = "rds-postgres-redash" description = "for redash" vpc_id = data.aws_vpc.vpc.id } resource "aws_security_group_rule" "rds-redash-ingress" { type = "ingress" from_port = 5432 to_port = 5432 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.rds-redash.id } resource "aws_security_group_rule" "rds-redash-egress" { type = "egress" from_port = 0 to_port = 0 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.rds-redash.id } /* defaultはよくないので追加。必要があればパラメータを増やしていく */ resource "aws_db_parameter_group" "redash" { name = "redash" family = "aurora-postgresql11" parameter { name = "log_min_duration_statement" value = "100" } } resource "aws_rds_cluster_parameter_group" "redash" { name = "redash" family = "aurora-postgresql11" parameter { name = "log_min_duration_statement" value = "100" } } /* DBクラスター */ resource "aws_rds_cluster" "redash" { cluster_identifier = "redash" engine = "aurora-postgresql" engine_version = "11.6" master_username = "postgres" master_password = "password" // 仮の値 backup_retention_period = 5 preferred_backup_window = "07:00-09:00" db_cluster_parameter_group_name = aws_rds_cluster_parameter_group.redash.name db_subnet_group_name = aws_db_subnet_group.redash.name skip_final_snapshot = true availability_zones = [ "ap-northeast-1a", "ap-northeast-1c", "ap-northeast-1d", ] vpc_security_group_ids = [ aws_security_group.rds-redash.id ] lifecycle { ignore_changes = [ master_password, // passwordはsecrets managerで管理しています。 ] } } /* プライマリDB */ resource "aws_rds_cluster_instance" "redash" { identifier = "redash-1" cluster_identifier = aws_rds_cluster.redash.id instance_class = "db.t3.medium" engine = "aurora-postgresql" engine_version = "11.6" } ElastiCacheのTerraform RDSとほぼ同じです。 /* defaultはよくないので追加。必要があればパラメータを増やしていく */ resource "aws_elasticache_parameter_group" "redash" { name = "redash" family = "redis5.0" } resource "aws_elasticache_subnet_group" "redash" { name = "redash" subnet_ids = [ data.aws_subnet.private-subnet-1a.id, data.aws_subnet.private-subnet-1c.id, data.aws_subnet.private-subnet-1d.id, ] } /* Redashの6379ポートを開くセキュリティグループ */ resource "aws_security_group" "redis-redash" { name = "redis-redash" description = "for redash" vpc_id = data.aws_vpc.vpc.id } resource "aws_security_group_rule" "redis-redash-ingress" { type = "ingress" from_port = 6379 to_port = 6379 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.redis-redash.id } resource "aws_security_group_rule" "redis-redash-egress" { type = "egress" from_port = 0 to_port = 0 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.redis-redash.id } /* Redashでジョブのキューイングを行うRedis */ resource "aws_elasticache_cluster" "redash" { cluster_id = "redash" engine = "redis" node_type = "cache.t2.micro" num_cache_nodes = 1 parameter_group_name = aws_elasticache_parameter_group.redash.name subnet_group_name = aws_elasticache_parameter_group.redash.name security_group_ids = [ aws_security_group.redis-redash.id ] engine_version = "5.0.6" port = 6379 } ECS Fargate タイミーではFargate Serviceを構築するためのTerraform Moduleがあり、Redash構築でも利用しています。 CPUやメモリを 閾値 としたオートスケーリングや、firelensを利用したDatadog Logsへのログ配信が容易に行えるようになっています。 この説明については後日記事にしたいと思います。 FargateではRedashの 公式Dockerイメージ をコンテナで実行しています。 ECS Cluster内に4つのECS Serviceが動いており、それぞれの役割は以下です。 Server ... WebUIを提供するサービス Scheduled Worker ... スケジューリングされたクエリを処理する Adhoc Worker ... 都度実行されるクエリを処理する Scheduler ... Redisにjobをキューイングする Redashは実行するコマンドを変更することによって、それぞれの役割を振る舞うことができます。 さらに 環境変数 を設定することで柔軟に設定を変更することができます。 redash.io 環境変数 がどのように設定されているかを知ることで、それぞれのサービスの理解がしやすくなるかと思います。 ここではそれぞれのサービスで利用するタスク定義から、実行コマンドと設定した 環境変数 を抜粋して説明します。 Server ServerはALBに紐付けられWeb UIを提供します。 ユーザーが実行するクエリの処理はこのサービスでは行いません。 クエリはWorkerが処理し、 PostgreSQL に書き込まれた結果をWebUIが表示します。 " command ": [ " server " ] , " environment ": [ { " name ": " PYTHONUNBUFFERED ", " value ": " 0 " } , { " name ": " REDASH_ADDITIONAL_QUERY_RUNNERS ", " value ": " redash.query_runner.python " } , { " name ": " REDASH_HOST ", " value ": " https://example.com " } , { " name ": " REDASH_LOG_LEVEL ", " value ": " INFO " } , { " name ": " REDASH_MAIL_DEFAULT_SENDER ", " value ": " example@example.com " } , { " name ": " REDASH_MAIL_PORT ", " value ": " 587 " } , { " name ": " REDASH_MAIL_SERVER ", " value ": " smtp.sendgrid.net " } , { " name ": " REDASH_MAIL_USE_TLS ", " value ": " true " } , { " name ": " REDASH_MAIL_USERNAME ", " value ": " apikey " } , { " name ": " REDASH_REDIS_URL ", " value ": " redis://XXXXX.apne1.cache.amazonaws.com:6379 " } , { " name ": " REDASH_THROTTLE_LOGIN_PATTERN ", " value ": " 1000/minute " } , { " name ": " REDASH_WEB_WORKERS ", " value ": " 4 " } ] , " secrets ": [ { " valueFrom ": " /fargate/redash/REDASH_COOKIE_SECRET ", " name ": " REDASH_COOKIE_SECRET " } , { " valueFrom ": " /fargate/redash/REDASH_MAIL_PASSWORD ", " name ": " REDASH_MAIL_PASSWORD " } , { " valueFrom ": " /fargate/redash/REDASH_DATABASE_URL ", " name ": " REDASH_DATABASE_URL " } ] , REDASH_DATABASE_URL REDASH_COOKIE_SECRET REDASH_MAIL_PASSWORD は秘匿情報のためParameter Storeに保管し、値をコンテナ起動時に注入しています。 REDASH_DATABASE_URL が秘匿情報な理由はパスワードも含めた接続情報なためです。 postgres://<ユーザー名>:<パスワード>@ホスト名 といった文字列が格納されています。 注意すべき点は REDASH_THROTTLE_LOGIN_PATTERN です。これは /login エンドポイントへのレートリミットが設定されており、デフォルトで "50/hour" が設定されています。 FargateにおいてALBのヘルスチェックは有効にしておきたいところですが、Redashにはヘルスチェック用のパスが用意されておらず、ログインせずとも見られるページは /login だけでした。そのためレートリミットを緩和することでヘルスチェックができるようにしています。 メールの送信にはタイミーではSendGridを利用しています。 Adhoc Worker, Scheduled Worker Adhoc Workerはユーザーが都度実行するクエリを処理し、Scheduled Workerは定期実行されるクエリを処理します。 Redisのキューを受け取って処理を開始し、データソースに問い合わせた結果を PostgreSQL に保存します。 " command ": [ " worker " ] , " environment ": [ { " name ": " PYTHONUNBUFFERED ", " value ": " 0 " } , { " name ": " QUEUES ", " value ": " queries " } , { " name ": " REDASH_ADDITIONAL_QUERY_RUNNERS ", " value ": " redash.query_runner.python " } , { " name ": " REDASH_HOST ", " value ": " https://example.com " } , { " name ": " REDASH_LOG_LEVEL ", " value ": " INFO " } , { " name ": " REDASH_MAIL_DEFAULT_SENDER ", " value ": " example@example.com " } , { " name ": " REDASH_MAIL_PORT ", " value ": " 587 " } , { " name ": " REDASH_MAIL_SERVER ", " value ": " smtp.sendgrid.net " } , { " name ": " REDASH_MAIL_USE_TLS ", " value ": " true " } , { " name ": " REDASH_MAIL_USERNAME ", " value ": " apikey " } , { " name ": " REDASH_REDIS_URL ", " value ": " redis://XXXXX.apne1.cache.amazonaws.com:6379 " } , { " name ": " WORKERS_COUNT ", " value ": " 2 " } ] , " secrets ": [ { " valueFrom ": " /fargate/redash/REDASH_DATABASE_URL ", " name ": " REDASH_DATABASE_URL " } , { " valueFrom ": " /fargate/redash/REDASH_COOKIE_SECRET ", " name ": " REDASH_COOKIE_SECRET " } , { " valueFrom ": " /fargate/redash/REDASH_MAIL_PASSWORD ", " name ": " REDASH_MAIL_PASSWORD " } ] , 上記はAdhoc Workerのタスク定義ですが、 Scheduled Workerとの違いは QUEUES がqueriesかscheduled_queriesかどうかのみです。 カンマで区切って両方指定することで、1つのworkerで両方の責務を担うこともできます。 EC2の頃に インスタンス のCPUを押し上げていたのはこのAdhoc Workerでした。非エンジニアで SQL に慣れていないメンバーが多いため、パフォーマンスを考慮できず数分以上かかる重いクエリがたくさん叩かれることが原因でした。 そのためAdhoc WorkerのサービスのみCPUとメモリを増やし、コンテナの最低数/最大数を増やすことで解決しました。 サービスを分割したことで、特定のコンテナのみスペックを増強することができるようになったのもFargate化の利点です。 Scheduler Redash Schedulerは Python のライブラリ RQ Scheduler を利用し、Redisにキューを追加します。 " command ": [ " scheduler " ] , " environment ": [ { " name ": " PYTHONUNBUFFERED ", " value ": " 0 " } , { " name ": " QUEUES ", " value ": " celery " } , { " name ": " REDASH_ADDITIONAL_QUERY_RUNNERS ", " value ": " redash.query_runner.python " } , { " name ": " REDASH_HOST ", " value ": " https://example.com " } , { " name ": " REDASH_LOG_LEVEL ", " value ": " INFO " } , { " name ": " REDASH_MAIL_DEFAULT_SENDER ", " value ": " example@example.com " } , { " name ": " REDASH_MAIL_PORT ", " value ": " 587 " } , { " name ": " REDASH_MAIL_SERVER ", " value ": " smtp.sendgrid.net " } , { " name ": " REDASH_MAIL_USE_TLS ", " value ": " true " } , { " name ": " REDASH_MAIL_USERNAME ", " value ": " apikey " } , { " name ": " REDASH_REDIS_URL ", " value ": " redis://XXXXX.apne1.cache.amazonaws.com:6379 " } , { " name ": " WORKERS_COUNT ", " value ": " 5 " } ] , " secrets ": [ { " valueFrom ": " /fargate/redash/REDASH_DATABASE_URL ", " name ": " REDASH_DATABASE_URL " } , { " valueFrom ": " /fargate/redash/REDASH_COOKIE_SECRET ", " name ": " REDASH_COOKIE_SECRET " } , { " valueFrom ": " /fargate/redash/REDASH_MAIL_PASSWORD ", " name ": " REDASH_MAIL_PASSWORD " } ] , QUEUES を celery に指定することで、Schedulerとして振る舞います。 サービスを分割して運用しているものの負荷が全然かからないため、Scheduled Workerとの統合も検討しています。 firelensを利用した、Datadog Logsへのログ転送 上記で紹介した4つのECS Serviceのログは、firelensを通してDatadog LogsとS3に転送されています。 id:sion_cojp のこちらの記事で詳しく紹介しています。 sioncojp.hateblo.jp Datadog Dashboard による監視 ALB, ECS Service, RDSを ダッシュ ボードで一覧できるようにしました。 DatadogもTerraformで管理しており、 ダッシュ ボード作成作業はコピペで済むようになって楽です。Datadogはapplyが早いのもよいです。 ダッシュ ボードのTerraform resource "datadog_dashboard" "redash" { title = "[${local.env}] ${var.service_name}" description = "Created using the Datadog provider in Terraform" layout_type = "ordered" widget { group_definition { layout_type = "ordered" title = "ALB: ${var.service_name}" widget { timeseries_definition { title = "リクエスト数" request { q = "sum:aws.applicationelb.request_count{name:${var.service_name},env:${local.env},account:${local.name}} by {availability-zone}.as_count()" display_type = "area" } } } widget { timeseries_definition { title = "4xxリクエスト数" request { q = "sum:aws.applicationelb.httpcode_elb_4xx{name:${var.service_name},env:${local.env},account:${local.name}} by {availability-zone}.as_count()" display_type = "area" } } } widget { timeseries_definition { title = "5xxリクエスト数" request { q = "sum:aws.applicationelb.httpcode_elb_5xx{name:${var.service_name},env:${local.env},account:${local.name}} by {availability-zone}.as_count()" display_type = "area" } } } widget { timeseries_definition { title = "コネクション数" request { q = "sum:aws.applicationelb.active_connection_count{name:${var.service_name},env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "ALBに紐づいてる正常なコンテナ数" request { q = "sum:aws.applicationelb.healthy_host_count{name:${var.service_name},env:${local.env},account:${local.name}} by {availability-zone}.as_count()" display_type = "area" } } } widget { timeseries_definition { title = "ALBに紐づいてる異常なコンテナ数" request { q = "sum:aws.applicationelb.un_healthy_host_count{name:${var.service_name},env:${local.env},account:${local.name}} by {availability-zone}.as_count()" display_type = "area" } } } } } widget { group_definition { layout_type = "ordered" title = "ECS: redash-server" widget { timeseries_definition { title = "起動タスク数" request { q = "sum:aws.ecs.service.running{servicename:redash-server,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "sum:aws.ecs.service.pending{servicename:redash-server,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "CPU Utilization(MAX)" request { q = "max:aws.ecs.service.cpuutilization.maximum{servicename:redash-server,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "max:aws.ecs.service.cpuutilization{servicename:redash-server,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "Memory Utilization(MAX)" request { q = "max:aws.ecs.service.memory_utilization.maximum{servicename:redash-server,env:${local.env},account:${local.name}}" display_type = "area" } } } } } widget { group_definition { layout_type = "ordered" title = "ECS: redash-scheduler" widget { timeseries_definition { title = "起動タスク数" request { q = "sum:aws.ecs.service.running{servicename:redash-scheduler,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "sum:aws.ecs.service.pending{servicename:redash-scheduler,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "CPU Utilization(MAX)" request { q = "max:aws.ecs.service.cpuutilization.maximum{servicename:redash-scheduler,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "max:aws.ecs.service.cpuutilization{servicename:redash-scheduler,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "Memory Utilization(MAX)" request { q = "max:aws.ecs.service.memory_utilization.maximum{servicename:redash-scheduler,env:${local.env},account:${local.name}}" display_type = "area" } } } } } widget { group_definition { layout_type = "ordered" title = "ECS: redash-scheduled-worker" widget { timeseries_definition { title = "起動タスク数" request { q = "sum:aws.ecs.service.running{servicename:redash-scheduled-worker,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "sum:aws.ecs.service.pending{servicename:redash-scheduled-worker,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "CPU Utilization(MAX)" request { q = "max:aws.ecs.service.cpuutilization.maximum{servicename:redash-scheduled-worker,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "max:aws.ecs.service.cpuutilization{servicename:redash-scheduled-worker,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "Memory Utilization(MAX)" request { q = "max:aws.ecs.service.memory_utilization.maximum{servicename:redash-scheduled-worker,env:${local.env},account:${local.name}}" display_type = "area" } } } } } widget { group_definition { layout_type = "ordered" title = "ECS: redash-adhoc-worker" widget { timeseries_definition { title = "起動タスク数" request { q = "sum:aws.ecs.service.running{servicename:redash-adhoc-worker,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "sum:aws.ecs.service.pending{servicename:redash-adhoc-worker,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "CPU Utilization(MAX)" request { q = "max:aws.ecs.service.cpuutilization.maximum{servicename:redash-adhoc-worker,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "max:aws.ecs.service.cpuutilization{servicename:redash-adhoc-worker,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "Memory Utilization(MAX)" request { q = "max:aws.ecs.service.memory_utilization.maximum{servicename:redash-adhoc-worker,env:${local.env},account:${local.name}}" display_type = "area" } } } } } widget { group_definition { layout_type = "ordered" title = "RDS: ${var.service_name}" widget { timeseries_definition { title = "CPU Utilization(MAX)" request { q = "max:aws.rds.cpuutilization{account:${local.name},dbclusteridentifier:${var.service_name}}" display_type = "area" } } } widget { timeseries_definition { title = "DBコネクション数(MAX)" request { q = "max:aws.rds.database_connections{account:${local.name},dbclusteridentifier:${var.service_name}}" display_type = "area" } } } widget { timeseries_definition { title = "空きストレージ容量 (MB)" request { q = "max:aws.rds.free_storage_space{account:${local.name},dbclusteridentifier:${var.service_name}}" display_type = "area" } } } widget { timeseries_definition { title = "使用可能なメモリ(MB)" request { q = "max:aws.rds.freeable_memory{account:${local.name},dbclusteridentifier:${var.service_name}}" display_type = "area" } } } } } } その他 Redashを AWS で構築するにあたって、Route53や ACM , WAFなどを使用しましたが、今回は記事が長くなってしまうため割愛します。 また、 stg 環境とprod環境を AWS アカウント単位分けており、 stg 環境として全く同じ構成のRedashを立てています。 理由はredashのバージョンアップや saml を使ったSSO認証の検証のためです。 今はコンテナ数を0にして寝かせています。 Fargateに移行した利点 抱えていた課題がほぼ解消できた 1. オートスケールできないため、クエリが詰まってCPUが100%になってサービスが停止する → オートスケールができるようになり、サービスが停止することはなくなりました。 2. セットアップしたエンジニアが退社しており、インフラ構成図やノウハウ、IaCによる管理ができていない。 → 全てコードで管理されている。構成図や wiki も残すことで、後任者がキャッチアップできるようになりました。 3. ダッシュ ボードなどのデータの定期的なバックアップができていない。 → AWS マネージドサービスに移行し、RDSのスナップショットで解決しました。 4. v7系からv8系へのアップデートがしたいが、アップデートによる影響範囲がわからず恐怖感がある。 → stg 環境があるので、本番環境で実施する前に試すことができるようになりました。 5. 事業に大きく関わるサービスなのにも関わらず、モニタリングやアラートができていない。 → datadogでモニタリングができるようになりました。まだコンテナ数などの調整中のため、アラートは保留としています。 また移行前は週に数回オンコールが発生していたが、移行してからほぼ0になりました。 サービスをきちんと分離したことで、負荷がかかることが多いAdhoc Workerのみスペックを上げる事が可能になった それまでは重いクエリを実行するとEC2 インスタンス のCPUが100%に達して他のユーザーにも影響を与えてしまっていたのが、サービスを分離したことでAdhoc Worker以外のサービスへの影響を減らすことができるようになりました。かつAdhoc Workerのみスペックを上げることができるようになりました。コンテナとサーバレスの特性をうまく活かすことができたと思っています。 まだ残っている課題 Redashのバージョンをv7からv8に上げる v8にアップデートできるとクエリ名を日本語で正しく検索できるようになるため、社内からアップデートしてほしいと要望があります。しかし今回のFargate移行でアップデートしやすくなったため、近いうちに着手します。 ログイン認証をSSOで行う タイミーでは従業員に発行する各種アカウントをGSuiteでのSSOでできるよう移行を進めています。Redashも SAML 認証によるSSOに対応しているので、次にやっていきたいと思っています。 まとめ 今回EC2で動いていたRedashをFargateに移行することによって、Redashにまつわる事柄全てをマネージドサービスとIaCで管理することができるようになりました。タイミーのSREがアプリケーションをどのように運用しているかも紹介できたかと思っています。 また今回よりタイミーのプロダクトチームのブログを開設することになりました。SRE/サーバーサイドエンジニア/フロントエンドエンジニア/デザイナーそれぞれの、タイミーのプロダクトにまつわる記事を投稿していきたいと思っております。ぜひお楽しみに!
アバター