Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

DropboxのStoreで通信量を削減しました

こんにちは、モバイルクライアントグループの隅山です。 前回は両OS開発についてのブログを書きましたが、今回はDropboxのStoreを用いてAndroidアプリの通信量を削減した話をしていきます。

Storeについて

まず、DropboxのStoreとはアプリ内のデータ操作(取得、共有、保存、検索)を簡素化するライブラリです。 ネットワーク経由でデータをいつ取得するか、メモリとディスクのどっちにキャッシュするか、データをKotlinのFlowで返却するかなどを簡単に実装することができます。

最近ではネットワークを最適化することが推奨されており、データをキャッシュしてオフラインでも使用できるようにしたり、不要なネットワークリクエストを防ぐ必要があります。 今回はネットワークを最適化するための第一歩として、Storeを用いて不要なネットワークリクエストを防ぐ対応を行ったのでその話をしていきます。

導入について

ライブラリをアプリへ導入する方法はStoreのREADME.mdに記載されているので、ここでは割愛させていただきます。

github.com

導入方法

不要な通信を防ぐために導入する目的であれば、実装は非常に簡単です。 弊社のコードを具体例に説明していきます。

弊社のアプリではユーザーが自分の所属している高校を設定することができます。 その高校一覧をサーバから取得してUI上に表示している箇所が下記のコードとなります。

Repository層

class HighSchoolsRepository(private val service: HighSchoolsService) {
    suspend fun index(): List<HighSchool> = service.index()
}

ViewModel層

class StudyGoalHighSchoolViewModel(private val repository: HighSchoolsRepository) : ViewModel() {
    val highSchoolList = MutableLiveData<List<HighSchool>>()

    init {
        viewModelScope.launch {
            runCatching {
                repository.index()
            }.onSuccess {
                highSchoolList.value = it
            }
        }
    }
}

Activity層

class StudyGoalHighSchoolActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        viewModel.highSchoolList.observe(this) {
            // リストとして表示
        }
    }
}

上記のコードでStoreを導入する場合、変更するのはRepository層のみです。 導入後のRepository層が下記となります。

class HighSchoolsRepository(private val service: HighSchoolsService) {

    private val store =
        StoreBuilder
            .from(Fetcher.of { service.index() })
            .build()

    suspend fun index(): List<HighSchool> = store.get()
}

APIを叩く際に引数を設定しませんでしたが、高校一覧取得APIに都道府県コードを設定する場合も簡単に実装することができます。

class HighSchoolsRepository(private val service: HighSchoolsService) {

    private val store =
        StoreBuilder
            .from(Fetcher.of { locationCode: Int -> service.index(locationCode) })
            .build()

    suspend fun index(locationCode: Int): List<HighSchool> = store.get(locationCode)
}

導入解説

Repository層からAPIを叩く際にStoreを導入しましたが、何故これで通信が削減されるか説明していきます。

まず、Storeとはアプリ内のデータ操作を簡素化するライブラリです。 APIを叩く箇所にStoreを導入するだけでは実装前後で差分は生まれません。 差分が出るのはStoreからどのようにデータを取り出すかによって決まります。

Storeからデータを取り出す方法は、現状では下記の3通りあります。

  • Store.get(key: Key):メモリ内のキャッシュかsourceOfTruthからデータ取得、取得できない場合はネットワークからのデータ取得
  • Store.fresh(key: Key):ネットワークからデータ取得
  • Store.stream():Storeのデータが更新されるとFlowでデータ返却

Storeを導入してもfresh(key: Key)でデータ取得を行えば、導入前のコードと同様の動作となります。 しかし、get(key: Key)でデータ取得を行うことでメモリ内のキャッシュかsourceOfTruthからデータを取得するため、ネットワークからデータ取得する回数を削減することができます。 メモリ内のキャッシュの生存期間はデフォルトで100個のアイテムを24時間に設定されています。*1

まとめると、弊社の導入後のコードではget(key: Key)を用いているため、24時間以内であれば再び通信することはなく通信量が削減されています。

メリット・デメリット

導入解説で細かく説明しましたが、メリットデメリットを簡単にまとめたいと思います。

メリット:

  • メモリ内のキャッシュに保存できるため通信量が削減できる
  • 端末がオフラインでもデータを表示することができる
  • Flowでデータをリアクティブに流したい場合やローカルDBに保存したい場合など拡張しやすい
  • 導入が非常に簡単

デメリット:

  • サーバのデータが頻繁に変わるAPIは導入に向かない
  • APIごとにStoreBuilderからStoreを作成するためコード量がだいぶ増える

まとめ

今回は通信量を削減することに絞ってStore導入を説明しましたが、拡張性が高く他にも多くのことができます。 弊社のアプリでは14箇所のAPIでStoreを導入し通信量の削減をしました。

今後はFlowを用いてデータをリアルタイムに更新したり、ローカルDBとのデータ操作部分に導入していきたいと思います。 他の導入方法で発見があったらまたブログでまとめていきます。

*1:生存期間はMemoryPolicyを用いて設定変更することもできます

週1回30分のフロントエンドミーティングを始めました

こんにちは。ForSchool事業部の石上です。ハライチのターンというラジオが好きです。ぜひRadikoで聴いてみてください。

今日は、ForSchool事業部で行っているフロントエンドミーティングという取り組みについて紹介します。

3行で

  • フロントエンドミーティングを始めました
  • いろいろ課題が出てきました
  • やっていくぞ

フロントエンドミーティングとはなにか

ForSchool事業部におけるフロントエンドミーティングとは、週1回チーム内でフロントエンドに話題を絞って振り返りや相談を行うミーティングのことです1

なぜやっているのか

Studyplus for Schoolのフロントエンドには、日常の業務で各人が課題を感じているのにそれを整理できていないという問題がありました。そこで、フロントエンド技術改善に絞ったロードマップを作り直すことにして、昨年の11〜12月の間にMTGを重ねて、ロードマップを作成しました。

ところが、このロードマップには少し不安が残りました。「作り直すことにしました」と書いたとおり、実はこういったものを作るのは初めてではなく、昨年の4月にも同じようなものを作っていました。ここでまた作り直しが発生してるということは、問題だと感じる点や改善案などがその当時から変わっているということです。ということは、今回つくったものも、半年後にはどうなっているかわかりません。このロードマップは、なるべく日常的に見直す必要があると考えました。

そこで、週1回、30分時間をとってこのロードマップを見直す会を設けることにしました。そして、ただ真面目に見直しだけをしても面白くないので、気になっているフロントエンド技術についての雑談などをアジェンダに盛り込みました。これが今ForSchool事業部で行っているフロントエンドミーティングです。

フロントエンドミーティングで整理できた問題

f:id:shgam:20210205092705p:plain
フロントエンドミーティングはこれまで4回開催

このフロントエンドミーティングは12月から始まり、これまでに4回開催しています。たとえば以下のような相談や問題解決がこの場でできています。

  • コーディングガイドラインを定めて、今後どのようにコードを書くかを明記していく。
  • typed-css-modulesというライブラリを利用してclassNameの型定義を生成していたが、これはwebpack設定のメンテが大変になる影響があるのでやめる。
  • 新たに必要になるウェブアプリケーションのインフラについて相談(Next.jsとVercelを採用)
  • 使いまわしにくいコンポーネントをどう整理していくか、タスクとしてどこをゴールにして進めていくか

こうやって見ると、毎週30分にしてはだいぶ実りがあるように思えます(これは昨年8月に入社してくれた@okuparaさんの専門性があってこそできていることではあるのですが2)。

もしこのミーティングがなければこういったことを整理する場もなく、問題が横たわったままだったかもしれません。やってよかった。

ちょっとしたキャッチアップの場に

フロントエンドは流れが速いとよく言われますが、フロントエンドミーティングのような場でそれぞれが気になっている技術を紹介すれば、ちょっとしたキャッチアップの場にはなると思います。これまでのフロントエンドミーティングで出てきた話題はReact Server Components, blitz, snowpackなどです。

発表資料をつくるわけではなく、「こんなのあるらしいんですけど、どう思います?」みたいな雑な話ができるので、とても楽しいです。

今後

まだまだ大小いろんな問題があるのですが、コツコツとやっていけたらなと思います。採用も積極的にしているので、手伝ってくれるエンジニアはぜひ応募してください。

www.wantedly.com


  1. フロントエンドミーティングという呼び名は、フィードフォースさんが過去にその名でフロントエンド技術の共有会を行っていた記憶があり、それを真似しました。

  2. 入社早々、脱enzyme->RTLの導入をしてくれたりしてます。すごい。https://tech.studyplus.co.jp/entry/2020/10/05/090000

Studyplus iOSアプリでWidgetに対応しました

初めまして、モバイルクライアントグループの上原です。昨年11月からiOSアプリ開発を担当しています。 最近は、Apex Legendsで目標だったランクのダイヤ4に到達し、ランクのモチベーションが下がりカジュアルをずっと回す日常になりました。

さて、本題に入ります。Studyplusでは、iOS 14から実装されたWidgetに対応し、昨年11月30日にカウントダウンWidgetをリリースしました。
今回はどのようなWidgetを作成したのか、導入経緯やTipsなどを紹介していこうと思います。

カウントダウンWidget

Studyplusでは、アプリ内でユーザが設定したイベント(模試や期末テストなど)までの日数を表示するイベントカウントダウン機能を提供しています。
上記の機能を、ホーム画面でも確認できるようにしたのがカウントダウンWidgetです。

f:id:nappannda:20210120155037p:plain:w200
カウントダウンWidget画像

Widgetの設定画面からアプリ内で設定しているカウントダウンを選択したりシンプルモードといった形で端末の外観モードに合わせた表示ができるようになっています。

f:id:nappannda:20210118072955p:plain:w200
Widget設定画面画像

f:id:nappannda:20210118073957p:plain:w200
カウントダウンWidget ライトモード
f:id:nappannda:20210118073912p:plain:w200
カウントダウンWidget ダークモード

Widget実装経緯

iOS 14から実装されたWidgetですが、Studyplusで実装に至った経緯は下記になります。

エンジニアからiOS 14の新機能のどれかを作りたい意見が出る
WidgetがSwiftUIのみで書くものだったので今後のSwiftUI環境に向けての勉強になりそう、Widgetを作りたい旨を伝える
ディレクターやデザイナーとユーザへの新しいアプローチでユーザ価値が出せるものがないかを検討
ユーザの学習に対して緊張感・危機感を高めるものとしてカウントダウンWidgetを実装

アプリを触っていただいた方には伝わるかなと思うのですが、イベントまでの日数がカウントダウンWidgetに表示されていると、スマホを開くたびに緊張感が高まり学習の習慣化を促すことができ新しい価値を提供することができたと思います。 しかし、SwiftUIの学習にWidgetが利用できたかというとViewの少しの実装に関しては利用できましたが、やはり@Stateや@ObservedObjectなどで値が更新されたらViewを更新するなどSwiftUIの肝となる部分などはWidgetではサポートされておらずSwiftUIの学習面では少し微妙だなと感じました。

実装Tips

Widgetで実装した機能のなかでどうやって実装したかどうかなどを紹介していきます。

シンプルモード ON/OFF時のviewへのShadowオンオフ

カウントダウンWidgetでは、シンプルモードではない時に特定のViewにShadowを付け、シンプルモードではShadowを付けないといった仕様がありました。 何も考えずにSwiftUIで愚直にやろうとすると下記のようなコードになります。 条件によってほぼ同じViewが存在してしまったり、見た目をカスタマイズしようとすると分岐が複雑化してしまったりと、見にくいコードになってしまいます。

let isSimpleMode: Bool
var body: some View {
    if !simpleMode {
        Text("タイトル").shadow(color: .init(red: 0, green: 0, blue: 0, opacity: 0.75), radius: 0.5, x: 0.5, y: 0.5)
    } else {
        Text("タイトル")
    }
}

上記をもっとスマートにある特定の条件式の場合であればShadowを付けたいですよね?
下記のブログで紹介されているViewにExtensionで条件に適していればクロージャーを実行、適していなければそのままViewを返すコードを実装すればこのコードがすっきりします。

extension View {
    @ViewBuilder
    func `if`<Content: View>(_ condition: Bool, content: (Self) -> Content) -> some View {
        if condition {
            content(self)
        }
        else {
            self
        }
    }
}

blog.kaltoun.cz

上記を適用したコードが下記になります。同じようなViewが複数定義されることなく条件によって何が適用されるかが分かりやすくなりました。

let isSimpleMode: Bool
var body: some View {
    Text("タイトル")
        .if(!isSimpleMode) {
            $0.shadow(color: .init(red: 0, green: 0, blue: 0, opacity: 0.75), radius: 0.5, x: 0.5, y: 0.5)
        }
}

Widgetを押した際にアプリの特定画面に飛ばしたり、Widgetからの起動を計測する

Widgetは要素を押した際にURLを渡すことができます。 この機能を利用するとURLを解析しアプリの特定画面を開いたり、Widgetからの起動を計測したりすることができます。
具体的には、widgetURLにURLを渡すことで要素を押した時にそのURLが開くことになります。
注意事項としてWidgetのサイズがSmallでは、一つしか遷移に利用できません。Medium以上だと複数のwidgetURLを定義して利用することができます。

var body: some View {
    VStack {
        Text("タイトル")
        Text("サブタイトル")
    }.widgetURL(URL("app://countdown"))
}

実装ではまった&困惑したところ

TextのfontSizeを48以上に指定するとSimulator上で一瞬表示された後、消える

Xcode 12.1 ~ 12.3時点でビルドしたSimulatorで発生することを確認した挙動です。 Simulatorのみで起きており実機では再現しないのでfontSize 48以上の指定で実装した場合は、実機で確認する必要があります。

Widgetの処理がブレークポイントで止まらない

ビルド後に上部メニューからDebug->Attach to Processを選択しその中からWidgetを選択すると止まるようになります。
時々止まらないこともあるので、その時は端末からWidgetを削除したりXcode再起動を試すと上手くいくと思います。

最後に

Widgetはいろいろ制約がありますが、その制約が強いことでユーザにシンプルな情報を提供できるように感じました。 そして、制約の強さがWidgetの実装が複雑化しないようになっているのかなと実装していて感じました。
また、新しい機能ということもあり実装情報が少なかったり、予期せぬ動作が起きたり実装していくなかで様々なことがありましたが新しいものに触るのは大変面白くいい経験でした。

Kubernetes輪講会を開催しました

こんにちは、Studyplus事業部 サーバーサイドエンジニアの葉坂です。

以前弊社の「Kubernetesを本番導入しました」という記事でも紹介していますが、スタディプラスでは2020年9月にKubernetes本番導入を果たしました。

tech.studyplus.co.jp

それに伴いサーバーチームもKubernetesでの運用をしていく上で必要な知見を高められるようSREチームと合同でKubernetes輪講会を開催しました。今回はこの輪講会について紹介したいと思います。

はじめに

輪講とは一般的に、

「一つの書物の範囲を決め、その範囲の担当者が事前に内容を調べて、参加者に対して説明をする」

というものです。

そもそもなぜ輪講会形式にしたのか

当初はSREチームの有識者による講義形式の開催も検討されていましたが、講義形式だと理解が浅くなってしまうことも多いので、輪講会形式で開催されました。

そうすることで担当者は担った範囲に関して、最低限人に説明できるレベルにまで理解を深める必要が出てきます。実際、「Kubernetesはコンテナ・オーケストレーション・ツールのあれだな」くらいの知識しかなかった私でも、人に説明できるレベルにまで理解を深められました。

使用した参考書

輪講会では、「Kubernetes完全ガイド」を使用しました。Kubernetesは本体の技術要素が膨大かつ、周辺のエコシステムも充実しているため、学習コストはかなり高いように感じるかと思います。

ですが、「Kubernetes完全ガイド」は網羅性が高く、マニフェストのサンプルも充実しています。記載内容のレベルも全般的に一段深いため、Kubernetesだけでなくインフラ周りの知見に乏しい私でも非常に分かりやすい参考書に感じました。 book.impress.co.jp

また GitHub にも、「Kubernetes完全ガイド」で紹介されているマニフェストが公開されているため、試したり写経したりと無限に学ぶことができます。 github.com

運営ポリシー

進め方

  • 事前に範囲と担当者を決める。
  • 担当者は範囲を事前に調べておき、当日参加者が内容を理解できるように説明をする。
    • 説明の仕方は担当者に委ねる。スライド を作ったり、ホワイトボードに書いて説明したり、口頭で済ます等々。
    • 本に書いてあることを全て説明する必要はなく、自分で重要だと思うポイントをピックアップし説明するのでもよい。
    • 担当者が内容を調べる際、不明点やうまくいかない部分があれば slack で質問を投げる。
  • 参加者は事前に範囲を読み、当日に疑問点を担当者に聞いたり、参加者同士でディスカッションを行う。

上記のような流れで輪講会を開催しました。

ちなみに輪講会の準備に関しては業務に関係のあることなので、業務時間内に行っています。

Kubernetes環境

Kubernetesのほとんどの機能は試せるため、簡単にローカルKubernetes環境を構築できる「minikube」を使用しました。ただ、マルチノードクラスタの作成時は「kind」を使用しました。

参加人数

5人(サーバーサイドエンジニアとSREの全員)で開催しました。

開催場所

Zoomで開催しました。

開催回数

週1回、業務時間内で1時間半の枠を確保し、全9回に分けて開催しました。

ちなみに

現在はKubernetes輪講会の開催枠で、もくもく会形式のGolang勉強会を行っています。それはサーバーチームが担当している10あるマイクロサービスのうち1つがGolangで書かれていることや、今後新たなマイクロサービス作成の際の言語の選択肢を増やすためです。

輪講会に参加して分かったメリット

実際に開催してみると、冒頭にて説明した以外にも、輪講会形式には下記のような様々なメリットがあることがわかりました。

  • 分担し合うことで、独学では時間のかかる内容&ボリュームの書籍でも、メンバーの助けを借りて理解することができた。
  • 自分の担当範囲に関しては、内容の説明だけでなく質問を受けた際も想定し疑問点を潰していくため知識も増えた。
  • 参加者への伝え方を考える上で、資料構成や表現方法、アウトプット手法の学びにも繋がった。
  • 同じ内容からでも、自分とは違う解釈を知れたり、そこから派生した知見共有があったりと、独学や講義形式で知識を受けるよりも学びの幅がかなり広がった。

Kubernetesを学んでみて

私がインフラ周りの知見に乏しいこともあり、初めはKubernetesに対してかなり苦手意識がありました。ただ、実際学んでみると、ServiceとIngressの理解には苦しめられましたが、Kubernetesに登場する概念が多いだけで、1つ1つ丁寧に学んでいけば、そこまで難しいと感じることはありませんでした。今では、弊社で運用しているKubernetesマニフェストを難なく読むことができるくらいに成長しました。輪講会様様です。

さいごに

輪講会は開催してみると良いことずくめでした。 題材書籍への学習意欲向上、吸い上げた知識のアウトプット方法の学び、メンバーとの盛んなコミュニケーション等々…。

特に、昨今コロナでのリモートワークが増え、メンバー同士のコミュニケーションが減っていることに懸念や一抹の寂しさを感じている方に大変オススメです。

興味のある方は是非、輪講会を開催してみてはいかがでしょうか!!

We Are Hiring

現在スタディプラスでは、サーバーサイドエンジニアを募集しています! open.talentio.com

Studyplus AndroidアプリでMAD Scoreを計測してみました

新年あけましておめでとうございます。

モバイルクライアントチームの若宮(id:D_R_1009)です。 お正月にようやくポケモンシールドのチャンピオンを倒しました。 本当に強かった……。

さて、昨年末にMAD Scoreが登場しました。

今回はMAD Scoreの紹介をしつつ、弊社Studyplus Androidアプリの計測結果をお見せしたいと思います。

MAD Scoreとは?

developer.android.com

Modern Android Development から MAD を取っているようです。 Androidも世に出てから10年以上経っているので Modern を強調しているのかなと思います。

Android 11では、ついに AsyncTask を非推奨にすると明言されました。 AndroidX Fragment 1.3.0からは onActivityCreatedstartActivityForResult/onActivityResult が非推奨になっています。 いつの間にかKotlinは1.4系がリリースされ、1.4系の機能を利用したJetpack Composeはalpha版になりました。

Kotlin、Jetpack、Android Studio、Android App Bundle など、最新の Android 開発(MAD)は優れたアプリを構築するための基礎となります。

挙げられている4つの項目も、それぞれ10年前とは様変わりしています。 見比べてみると、確かに Modern なアプリ開発な気がしてきませんか?

  • JavaからKotlin
  • SupportライブラリからJetpack
  • EclipseからAndroid Studio
  • Android application package(apk)からAndroid App Bundle(aab)

次からStudyplus Androidアプリのスコアを見つつ、それぞれの項目がどう評価されるのかを見てみます。

Kotlin

f:id:D_R_1009:20201225084805p:plain

Kotlinは、アプリを構成しているKotlinのパーセントが表示されます。 そのほか、Kotlinのバージョンや利用しているライブラリが掲載されていますね。

tech.studyplus.co.jp

上記ブログのように2019年9月末に86%程度だったKotlin率は、2020年6月頭に99%となり、その後はKotlinのみ増減している状態になります。 1ファイルのみ、Paging 2のutilクラスをJavaで利用しているので、Paging 3のリリースと同時に100%となる見込みです。 リリースが待ち遠しい……!

そのほか、KTXやFlowの利用が表示されています。 FlowはRoomの他、Storeライブラリなどで利用しています。Cold Streamを手軽に扱えるのは、本当に重宝しますね。

tech.studyplus.co.jp

github.com

Jetpack

f:id:D_R_1009:20201225085802p:plain

Jetpackは、利用しているライブラリの数が表示されるようです。 思っていた以上に利用していてびっくりしました。

tech.studyplus.co.jp

Studyplusアプリは開発しているエンジニアの数が(利用ユーザーに対して)少なめなこともあり、Jetpackライブラリを活用した開発を重視しています。 機能性が高い、安定したライブラリが提供されていることは、とても幸運なことだと思っています。 今後も活用できそうなライブラリがあれば、積極的に利用していきたいところです!

Android Studio

f:id:D_R_1009:20201225090753p:plain

ちょっとコメントが難しいのですが、利用しているAndroid Studioのバージョンが表示されます。 現時点ではAndroid StudioとAGPのアップデートが一致してしまっているので、Android Studioを更新しにくい環境もあるかもしれません。

AGP 7.0からはAndroid Studioのバージョンと切り離されることになります。 そうするとこの問題も解決しますね。個人的にはR8のバージョンをいい感じにアップデートできるので嬉しいアップデートです。

android-developers.googleblog.com

なお、Android Studioのスコアを取得できるのは今だけ! (かも)

Android App Bundle

f:id:D_R_1009:20201225090833p:plain

1TB と圧のある数字が出ていますが、aabにすることで100万人がDLしたときに削減される容量のようです。 ただGoogle Play Storeをみてみると、Studyplus Androidは 1,000,000+ のDLとのことなので、数年規模で見ると的外れな数字ではないような気もしてきます。

もともとのアプリサイズ、そしてNDKを利用しているかどうかでこの値は変動する気がするので、大小を一概に良い悪いとは言えない値だと思います。 ですが、日々のアプリサイズを小さくする試みの成果をわかりやすく見ることができるのは、気分も軽くなるのではないでしょうか。

まとめ

f:id:D_R_1009:20201225090848p:plain

トータルの評価は G.O.A.T でした! 日々のAndroidチームの頑張りが評価されたようで、とても嬉しいです。

madscorecard.withgoogle.com

Android Studioにpluginを入れると数分で計測することができます。 ぜひ、計測してみてください!

スタディプラスを支えるインフラ技術(2020年)

こんにちは、SREの菅原です。

あっという間に2020年も年末ですね。時が過ぎるのが早い...

今回は今年の振り返りも兼ねて、2020年でSREチームが行ったインフラのリニューアルについて記事にしたいと思います。

以前スタディプラスを支えるインフラ技術(2019年)を投稿したのですが、2020年版という形でインフラ技術を紹介します。

なぜインフラのリニューアルをしているかという理由については、「Kubernetesを本番導入しました」という記事で「スタディプラスのインフラの現状の課題」を説明しているので、気になる方は読んでみてください。

tech.studyplus.co.jp

はじめに

弊社には大きく分けて以下3つのサービスがあります。

  • 学習管理SNS「Studyplus」
  • 教育機関向け学習管理サービス「Studyplus for School」
  • 参考書読み放題アプリ「ポルト」

今回も2019年度の記事に引き続き「Studyplus」のインフラを紹介します。

システム構成

Studyplusは一番大きなメインシステムと複数のサブシステムによって構成されています。

2020年のリニューアルでは、EKSを新規導入しました🎉

現時点では開発環境と本番環境、ステージング環境(本番DBに接続しており、主にリリース前にアプリケーションの最終チェックをするQA環境)の3種類のKubernetesクラスタがあり、マルチテナント構成でいくつかのサブシステムを移行した状態です。

多少簡略化した図になりますが以下のようなシステム構成となっております。

f:id:ksugahara08:20201218183226p:plain
サーバー構成の概要図

将来的にはメインシステムを含む複数のシステムをEKSに移行する予定です。

利用中の主なAWSリソース

弊社で利用しているAWSリソースは以下になります。2019年と比べるとEKS等コンテナ関連リソースを使うようになりました。 ※簡略名称で記載しております。

  • EKS
  • ECR
  • EC2(ALB、Auto Scaling等含む)
  • Lambda
  • VPC
  • RDS for MySQL
  • Aurora MySQL
  • ElastiCache(Redis)
  • S3
  • CloudSearch
  • SQS
  • SES
  • Glue
  • Athena
  • CloudFront
  • IAM
  • KMS
  • ACM
  • Route53

構成管理

2019年ではAnsibleを使ってAWSリソースの構成管理を行っていました。

しかし、以下のような点からAnsibleではなくTerraformを使って構成管理するように変更しました。

  • AnsibleのAWSモジュールはあまりメンテナンスされていない
  • AnsibleはTerraformと違い、現在の構成との差分が取れないため実行時に何が変更されるかわかりづらい(チェックモードが有効でないモジュールが多く存在する)
  • AWSモジュールを使っている人が少ないため情報が少ない

現在はTerraformの導入を進め、EKSに移行したシステムからTerraformで構成管理するようにしています。TerraformであればAWSの新機能にもすぐに対応が入り、情報も多いためTerraformに移行して良かったと考えています。

Kubernetesのバージョンアップ時にもTerraformで簡単に切り替えられるようにしています。 EKSクラスタ自体を新規作成して、新旧バージョンのクラスタ2つを平行運用後切り替えるのですが、多少のパラメータ変更で済むので本当に助かっています。

www.terraform.io

CI/CD

以前はCircleCIでCIを行い、JenkinsでBuildやDeployを行っていました。 インフラのCI/CDはPacker + Ansibleで実施しており、温もりあふれる手動実行でEC2を起動したりする場面もありました。

2020年ではKubernetes化に合わせてシステムのソースコードのCI/CDをCircleCI + Skaffoldで行うように変更をしました。パイプラインの概要図は以下のようになっています。

f:id:ksugahara08:20210104130059p:plain
デプロイのイメージ図

今回は使い慣れたCircleCIを選択しましたが、今後はGitOpsにしたいのでArgo CD等のツールを採用することも検討しています。

インフラのソースコードのCI/CDはTerraform + Kubernetesに移行したことで、変更点を確認した上でapplyできるようになりました。しかし、terraform applyに関して手動実行なので、今後はGitHub ActionsやTerraform Cloudを使って自動化していきたいと考えています。

監視・検知

メトリクス監視

Kubernetes移行を機にDatadogへ監視機能を移行しています。理由としてはDatadogは高機能で、AutoDiscoveryに対応しているためKubernetesの監視にフィットすると判断したためです。

Prometheus + GrafanaやElastic Cloudも当時検討しましたが、少人数のSREチームで運用していくコストであったり料金面を比較した結果Datadogを選択しました。

アプリケーションのエラー検知

Sentryを利用しています。エラーはSlackに通知され、タスク管理ツールのmonday.comに自動登録されるように設定してあります。

ログ収集

ログはFluentdを使ってS3に保存、Athenaで確認する方法を取っています。以前はElasticsearch + Kibanaを使っていたのですが、検索の柔軟性や運用コスト、料金的にメリットを得難かったため廃止しました。

OnCall

DatadogにTwilioを設定して、担当者に連絡が飛ぶようになっています。

現在の課題

EKSの運用も始まったのですが、SREチームではまだまだやりたいことがたくさんあります。箇条書きですが簡単に紹介したいと思います。

システム構成

現在既存のシステムをKubernetesへ順次移行しています。今後はメインシステムだけでなく他のサブシステムやStudyplus for Schoolの移行など横展開させていきたいと考えています。

それだけでなく以下も検討していきたいと考えています。

  • スポットインスタンスの活用
  • サービスメッシュの導入
  • Progressive Deliveryの導入

etc

また開発環境と本番環境が同じAWSアカウント内に混在してしまっているため、間違って本番リソースを変更/削除してしまうリスクがあります。それだけでなく権限の複雑化、Terraform構成管理の複雑化を招いてしまっています。今後はAWSアカウントの分割を進めて、複雑性の解消を行っていきたいと考えてます。

構成管理

Terraformでの構成管理を進めて来ましたが今後は以下に取り組みたいです。

  • Terraformへ移行できていないシステムへの横展開
  • GitHub ActionsやTerraform CloudによるCI/CDの検討
  • TFLint,​checkov,tfsec等の導入検討
  • GCPリソースのTerraform管理
  • SREチーム以外のメンバーにもTerraformで構成変更を行ってもらえるように社内勉強会を開催

CI/CD

今回CircleCIを選択した理由が

  • ファーストリリースはできるだけミニマムの構成にしたかった
  • チームの学習コストの関係で現在使っているCIツールにした

というものなので今後はGitOpsができるArgo CD等のツールを検討・導入していきたいと考えています。また、開発環境をGitのbranchごとに作成したいという要望もあるため、実現にむけて検討していく予定です

監視・検知

Datadogへの移行を行っていますが、今後は以下に取り組みたいです。

  • Datadogへ移行できていないシステムへの横展開
  • APMによるパフォーマンス監視の設定
  • SLOの社内導入とDatadogのモニタリング設定
  • Deployした日時をDatadog上からわかるようにする

最後に

2020年はKubernetesやTerraform、Datadogなど新しいツールの導入を行い、インフラ技術の刷新を行ってきました。社内に知見が無い状態からのスタートでしたが、チーム内で話し合いながら進められたことは個人的にも大きな学びがあった1年間になりました。

2021年も2020年同様にStudyplusユーザーが期待するサービスの信頼性向上や価値提供のスピードを上げるためのインフラ技術をBlogで紹介できるようにしたいです。

LambdaTestでスモークテストをはじめました

こんにちは、Studyplus for School事業部エンジニアの島田です。

もうすぐ2020年も終わりになりますね。

はじめに

皆さんはスモークテストをしていますか?

スモークテストとは元々「電子機器での発煙がないかをテストしていたこと」を起源とし、そこから転じて「ソースコードの開発・追加・修正を終えたソフトウェアが動作する状態にあるかを確認するテストのこと」となったようです。

Studyplus For School(以下FS)の開発チームでは、こちらの記事でも少しふれているStatic vs Unit vs Integration vs E2E Testing for Frontend Apps にあるEnd to End(E2E)をスモークテストとして位置付けています。

www.itmedia.co.jp

testingjavascript.com

tech.studyplus.co.jp

なぜ導入したか

これまでE2Eテストの導入を検討した事はあったのですが、

  • どこまでをテストすべきか
  • 運用負荷が高そう。UIの変更に追随することが大変ではないか
  • UI変更に対応できなくなるとテストが落ちても気にしなくなり、テストが狼少年になってしまうのではないか

といった懸念がありましたが、上記の記事にあるIntegration testsの線引きを決め、E2Eテストではサービスの重要な機能に絞った最低限のテストにする事を決めました。

また、こちらの記事で少し触れているログイン関連の改修ではリリース後に一部機能の不具合が発覚しました。そのためログインなどのクリティカルな機能ではE2Eによる退行テストが必要だと強く感じるようになりました。

tech.studyplus.co.jp

ツール選定

E2Eテストを検討するにあたっては、まずサービス、テストフレームワークの選定をしました。

様々な候補が出たのですが、それぞれを調査・検討するには数が多過ぎるので、実績や知名度や特性から以下に絞りました。

SaaSとOSS(課金へのアップグレードもある)のそれぞれ2つをリストアップし、4つのサービス、フレームワークの調査・比較をする事にしました。

autify.com www.lambdatest.com playwright.dev www.cypress.io

それぞれについて以下の内容を中心に調査をしました。

  • 選定にあたって
    • 初期コスト(導入、学習等)
    • 運用コスト(課金有無、自前で構成する場合)
    • テストの実装方法

各メンバーで分担して調査した結果を元にチームで検討した概略が以下になります。

  • Autify
    • メリット
      • コードを書かなくて良い。エンジニア以外でも出来る
      • テスト実行までの最初の設定が手軽そう
      • 日本語ドキュメント、サポートがある
    • デメリット
      • 課金が比較的高い
      • テストコードという資産が残らない(他への乗り換えが難しくなる)
  • LambdaTest
    • メリット
      • ローカルで簡単に様々なブラウザでの実行が確認できる
      • ダッシュボードで様々な結果(動画、リクエスト内容)が確認出来る(年間契約: $99/月)
      • 既存の言語・テストフレームワークで書ける
      • 自動テスト以外の機能も充実している
      • Integrationが多い
    • デメリット
      • Seleniumの学習コスト
      • 海外での導入実績はそれなりにあるが、サービスとしての信頼性は未知数
  • Playwright
    • メリット
      • 無料
    • デメリット
      • 学習コスト高そう。実装に慣れるのが大変そうな印象
      • 実行環境の準備・運用
      • 実行結果(キャプチャ)の保存をしたい場合に、自分たちで考える必要がある
  • Cypress
    • メリット
      • ダッシュボードで様々な結果(動画、リクエスト内容)が確認出来る(年間契約: $99/月)
    • デメリット
      • 学習コスト。Playwrightと比べれば低そうではある

これらの内容から、

  • 導入コスト:Playwright > Cypress > LambdaTest > Autify
  • 運用コスト:Autify > LambdaTest > Cypress > Playwright
  • 金額: Autify > LambdaTest , Cypress > Playwright

と判断し、LambdaTest か Cypress が争点となりました。スモークテストのみをするのであれば両者とも大きな差異はないと判断しました。そのため同じ金額を課金するのであれば+α(クロスブラウザチェックなど)が出来るLambdaTestを採用しました。

※ 上記はあくまでFSチーム内での簡易的に調査した内容なので、もしかしたら認識が違っている箇所もあるかと思いますので、もし修正点があればご指摘いただければと思います。

LambdaTestでE2Eテスト(自動テスト)

LambdaTestでE2Eテストをするには、SeleniumのリモートWebDriver経由でLambdaTestのSelenium Automation Gridを利用して様々なブラウザのテストを実行する事ができます。

そのため様々な言語・フレームワークで実現をすることができます。こちらに詳しく記載されています。

ドキュメント以外にもこちらに実装のサンプルがあります。

FSチームでは学習コスト等を考えて、RSpecでテストを実装することにしました。

www.lambdatest.com

github.com

構成とテストの実行タイミング

FSのインフラ構成とデプロイについてはこちらの記事に詳しい説明があります。アプリケーションはSPAでサーバー(API)とクライアント(Web)で構成され、デプロイにはJenkinsを利用しています。

サーバーの環境には、開発・ステージング・本番の3つがあります。

E2Eテストの実行タイミング、フローの概要は、

  1. 各リポジトリ(サーバー or クライアント)にてmasterへマージ
  2. Jenkinsで開発環境にデプロイ
  3. JenkinsからCircleCI経由(APIでpipelineを実行)でE2Eテストを実行(SeleniumのリモートWebDriverでLambdaTestに接続)
  4. 問題なければ、ステージング、本番へリリース

という感じになります。

f:id:yo-shimada:20201214101413j:plain
E2Eテスト概要図

E2Eテストについてはアプリケーションとは別のリポジトリで管理し、通常のbuild(単体テスト、Lint)とは分けてデプロイ後に実行する事としました。そうした理由としては、

  • アプリケーションがサーバー(API)とクライアント(Web)でGithubのリポジトリが分かれて管理しているため、それぞれのデプロイに対してE2Eテストを実行したい
  • E2Eテストは、実行環境(サーバー)にデプロイしてからでないと確認が出来ない
  • スモークテストの位置付けとしては、(本番にリリースされなければ)masterのbranchマージ後に問題が発覚すれば良い

といった事が上げられます。

circleci.com

tech.studyplus.co.jp

E2Eテストコード(RSpec)の実装例

RSpecで実装概略です。

リポジトリ構成

$ tree -L 2 -a
.
├── .circleci
│   └── config.yml
├── .envrc
├── Gemfile
├── Gemfile.lock
├── spec
│   ├── login_spec.rb
│   └── spec_helper.rb
└── vendor
    └── bundle

Gemfile

source 'https://rubygems.org'

git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }

gem 'retriable'
gem 'rspec'
gem 'rubocop', require: false
gem 'selenium-webdriver'

spec/spec_helper.rb

  • @driver.execute_script "lambda-status=#{lambda_status}" はLamdaTestからSlackで通知をするために、LamdaTestにRSpecの成功可否を知らせるために設定しています。
  • @driver = Retriable.retriable(on: Net::ReadTimeout) do はLamdaTestにてタイムアウトする事が稀にあるので、その際にリトライをするようにしています。

Slack通知成功
Slackエラー通知

require 'selenium-webdriver'
require 'retriable'
require 'net/protocol'
require 'net/http'

RSpec.configure do |config|
  config.expect_with :rspec do |expectations|
    expectations.include_chain_clauses_in_custom_matcher_descriptions = true
  end

  config.mock_with :rspec do |mocks|
    mocks.verify_partial_doubles = true
  end

  config.shared_context_metadata_behavior = :apply_to_host_groups

  # Selenium
  config.after(:example) do |example|
    lambda_status = example.exception ? 'failed' : 'passed'
    @driver.execute_script "lambda-status=#{lambda_status}"
  end

  config.around(:example) do |example|
    caps = {
      browserName: ENV['LT_BROWSER'],
      version: ENV.fetch('LT_BROWSER_VERSION') { 'latest' },
      platform: ENV['LT_OPERATING_SYSTEM'],
      name: example.metadata[:description] || example.metadata[:location] || 'RSpec Sample Test',
      build: 'RSpec Selenium Boron',
      network: true,
      visual: true,
      video: true,
      console: true,
      tags: [example.metadata[:file_path].split('/').last&.split('.')&.first]
    }

    @driver = Retriable.retriable(on: Net::ReadTimeout) do
      client = Selenium::WebDriver::Remote::Http::Default.new
      client.read_timeout = 120 # seconds
      Selenium::WebDriver.for(
        :remote,
        http_client: client,
        url: "https://#{ENV['LT_USERNAME']}:#{ENV['LT_APPKEY']}@hub.lambdatest.com/wd/hub",
        desired_capabilities: caps
      )
    end
    begin
      example.run
    ensure
      @driver.quit
    end
  end
end

spec/login_spec.rb

クリティカルなテストケースのみ(今回はログイン)として、なるべくUIの状態に依存しないシンプルな実装(最低限のxpath)を心がけました。

RSpec.describe 'login' do
  describe 'ログイン・ログアウト' do
    let(:email) { 'test@example.com' }
    let(:password) { 'sample' }

    context '未ログインの場合' do
      before do
        @driver.manage.window.maximize
        @driver.get("#{ENV['WEB_URL']}/login")

        @driver.find_element(:xpath, "//button[contains(text(), 'ログイン')]").click

        email_element = @driver.find_element(:name, 'operator[email]')
        email_element.send_keys(email)
        password_element = @driver.find_element(:name, 'operator[password]')
        password_element.send_keys(password)
      end

      subject { @driver.find_element(:xpath, "//button[contains(text(), 'ログイン')]").click }

      context '正しいメールアドレス、パスワード' do
        it 'ログイン出来る' do
          subject
          expect(@driver.current_url).to eq "#{ENV['WEB_URL']}/?login=success"
        end
      end

     ...

    end
  end
end

.circleci/config.yml

実行結果についてはSlackで通知されます。

f:id:yo-shimada:20201203232355p:plain
CircleCIのSlack通知

version: 2.1
orbs:
  slack: circleci/slack@4.1.1
executors:
  default:
    working_directory: ~/test-e2e
    docker:
      - image: cimg/ruby:2.6-browsers
        environment:
          LT_OPERATING_SYSTEM: win10
          LT_BROWSER: chrome
          WEB_URL: https://example.com

commands:
  install_dependencies:
    steps:
      - run:
          name: gem install bundler v1.17.2
          command: |
            gem install bundler:1.17.2
      - run:
          name: bundle install
          command: |
            bundle install -j4 --path vendor/bundle
  notify_failed:
    steps:
      - slack/notify:
          event: fail
          mentions: '@engineer'
          template: basic_fail_1
  notify_success:
    steps:
      - slack/notify:
          event: pass
          custom: |
            {
              "blocks": [
                {
                  "type": "header",
                  "text": {
                    "type": "plain_text",
                    "text": "E2E Test Successful! :tada:",
                    "emoji": true
                  }
                },
                {
                  "type": "section",
                  "fields": [
                    {
                      "type": "mrkdwn",
                      "text": "*Project*:$CIRCLE_PROJECT_REPONAME"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*When*:$(date +'%m/%d/%Y %T')"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Tag*:$CIRCLE_TAG"
                    }
                  ],
                  "accessory": {
                    "type": "image",
                    "image_url": "https://assets.brandfolder.com/otz5mn-bw4j2w-6jzqo8/original/circle-logo-badge-black.png",
                    "alt_text": "CircleCI logo"
                  }
                },
                {
                  "type": "actions",
                  "elements": [
                    {
                      "type": "button",
                      "text": {
                        "type": "plain_text",
                        "text": "View Job"
                      },
                      "url": "${CIRCLE_BUILD_URL}"
                    }
                  ]
                }
              ]
            }
jobs:
  build:
    executor: default
    steps:
      - checkout
      - restore_cache:
          keys:
            - v2-bundler-{{ arch }}-{{ checksum "Gemfile.lock" }}
            - v2-bundler-{{ arch }}-
      - install_dependencies
      - save_cache:
          key: v2-bundler-{{ arch }}-{{ checksum "Gemfile.lock" }}
          paths:
            - vendor/bundle
      - persist_to_workspace:
          root: ~/test-e2e
          paths:
            - ./*
      - notify_failed
  rspec:
    executor: default
    steps:
      - attach_workspace:
          at: ~/test-e2e
      - restore_cache:
          keys:
            - v2-bundler-{{ arch }}-{{ checksum "Gemfile.lock" }}
            - v2-bundler-{{ arch }}-
      - install_dependencies
      - run:
          name: run test
          command: |
            bundle exec rspec
          when: always
      - notify_failed
  notify_success:
    executor: default
    steps:
      - notify_success

workflows:
  build:
    jobs:
      - build
      - rspec:
          requires:
            - build
      - rubocop:
          requires:
            - build
      - notify_success:
          requires:
            - rspec

JenkinsからCircleCIのジョブを実行する際のAPI呼び出し例

curl --request POST \
-u '${CIRCLECI_TOKEN}:' \
--header 'content-type: application/json' \
--data '{"branch": "main"}' \
--url 'https://circleci.com/api/v2/project/${vcs-slug}/${org-name}/${repo-name}/pipeline'

LambdaTestのダッシュボード

Seleniumによる自動テストの経過・結果はLambdaTestのダッシュボードで確認することができます。Seleniumのvideo オプション等を有効にしておくと、テストの動画やリクエスト内容などブラウザでの実行を確認することが出来ます。

f:id:yo-shimada:20201203232900j:plain
Lambdatest Automation

さいごに

LambdaTestを導入するまでにかかったコストは低く、実行結果が簡単に分かりやすく確認出来る事は大きなメリットだと感じています。

また自動テスト以外にも、様々なブラウザで検証できるクロスブラウザテスト等の機能が充実しており自動テスト以外でも導入の恩恵は大きいと感じています。

ただ、自動テストで時々タイムアウトになるなど不安定な時がありました。あまりに常態化するとテストが落ちても気にしなくなりテストをしている意味がなくなるので、そこが心配ではありますが現時点ではほぼ発生しないので気になってはいません。

今後はE2Eによるクリティカルな機能のテストケースの実装を追加し、より保守性を向上させると共に手動で実施するテストを減らし生産性を高めていきたいと考えています。