every Tech Blog

株式会社エブリーのTech Blogです。

Next.jsのPages RouterからApp Routerへの移行に挑戦してみた

初めまして,トモニテ開発部でSoftware Engineer(SE)をしている鈴木です.
SEチームはAPI開発からそのAPIを利用したweb開発まで幅広い領域を担当しており,トモニテ開発部のweb開発には Next.js を採用しています.
また,エブリーの開発部では定期的に挑戦week(※)なるものを開催し,技術的観点から事業貢献を行う1週間を設けており,その中でNext.jsのPages RouterApp Routerに移行する機会を頂けたので紹介させていただきます.

※ 挑戦weekについては以下のオウンドメディアで記事にしています.今回の挑戦weekは事業部の垣根を超えたチーム編成で行い,大変面白いものでした!

エンジニアが楽しみながら開発体験を向上させる「挑戦week」を実施しました!

背景

Next.jsでルーティングを行う際, v13以前はPages Routerを利用する必要がありましたが,v13以降はApp Routerという仕組みが選択肢に加わりました.
App RouterはReactの最新機能(React Server ComponentsSuspenseなど)に追従したルーティングシステムであることから,Next.js公式には新しいアプリケーションを作成する際にはApp Routerの利用が推奨されており,既にApp Routerでしか利用出来ない機能の開発も見受けられます.
これらのことから,幾つかのメジャーアップデートを経てApp RouterがNext.jsのルーティングシステムの主流になることが予想されるため,長期にわたりNext.jsの最新機能に追従していくためにはPages RouterからApp Routerへの移行が必要だと判断し挑戦する運びになりました.

移行対象

影響の大きさを鑑みて,移行対象にはトモニテで利用しているdashboardを採用しました.

移行の流れ

1. Pages Routerの各ページをApp Routerのページに変換するスクリプトの作成

当然ですが,App Routerへ移行するためにはPages Routerのページを移行することが必須です.
Pages RouterとApp Routerは共存が可能なため,1ページずつ移行していくことが出来るのですが,一つずつ置換していくと移行を終えるまでにかなりの時間を要してしまうことや,dashboard以外にもApp Routerに移行したい対象があるため,一括で置換するスクリプトを作成することになりました.
公式からはCodemodsの置換スクリプトは提供されていないため,Pages Routerの各ページに対し以下のルールに基づいた変換をするスクリプトを自作しました(※).
手間は要しますが実装に困難な点はそれほど無いかと思います.

  • use clientの付与
  • [xxxx].tsx[xxxx]/page.tsに変換
  • xxxx/index.tsxxxxx/page.tsxに変換
  • xxxx.tsxxxxx/page.tsxに変換

※参考程度になりますがGistで公開しています

2. ログイン前後のレイアウトの作成

トモニテのdashboardはログイン前後でレイアウトが異なるため,それぞれのレイアウトを作成しました.
dashboardはログイン前にしかアクセス出来ないページとログイン後にしかアクセス出来ないページに二分されていたため, Route Groups という機能を用いることで比較的簡単にレイアウトの出し分けを実現出来ました.
以下のように括弧で囲まれたディレクトリ名をつけることでRoute Groupsを利用出来ます.

app
├── (loggined)
│   ├── layout.tsx
│   ...
└── (unloggined)
    ├── layout.tsx
    ...

3. _app.tsx及び_document.tsxと等価な処理の再現

App Routerでは_app.tsx_document.tsxが廃止されたため,それらが行っていたことと等価なことをfile conventionsmiddlewareを用いて再現する必要があります.
トモニテのdashboardでは_app.tsxでsessionの管理やレイアウトの出し分けなどを,_document.tsxではヘッダーの管理などを行っていたため,middlewareとlayout.tsxを用いて再現しました.
注意点として,App RouterではHeaderコンポーネントが廃止されており,代わりにlayout.tsxからMetadataオブジェクトをexportするようになっています.このMetadataオブジェクトですが,Headerコンポーネントを用いて実現していたこと全てが再現出来る訳でなく,例えばCDNからstylesheetを読み込むことが出来なくなっています.この点については,closeはされていますがissueにもなっており解決するのには時間がかかりそうであることと,幸いなことにファイルのimportで解決できるもの以外は影響が小さかったため後回しにすることになりました.

4. ルーティングに関するhooksの移行

App Routerではルーティングに関するhooksをnext/routerではなくnext/navigationからimportするように変更されており,useRouterhookが持っていた役割もuseRouterusePathnameuseSearchParamsの3つのhooksに分解されています.従って,画像のように,next/routerを用いてrouter.pushと記述していたコードはnext/navigationのuseRouterを,router.queryと記述していたコードはuseSearchParamsを用いて置換する必要があります.
useRouterの利用箇所自体は多岐に渡っていたため,修正にはだいぶ手間が必要となりました.
また,この際,従来のuseRouterが持っていたevents等の一部の機能がnext/navigationでは実装されていないことに注意が必要です.eventsが実装されていないことから,Pages Routerで簡単に実現出来ていたページ遷移中の処理をApp Routerでは簡単には実現出来なくなりました.
公式からはusePathnameとuseSearchParamsを利用したコンポーネントを作成することによる再現方法が提示されており,これを用いれば再現可能かと思われますが,トモニテのdashboardではページ遷移の進捗を表示するためのプログレスバーにしか用いておらず,進捗を確認するニーズの有る程重いページが存在しなかったため,再現せずに先に進むことになりました.

ルーティングに関するhookの遷移
ルーティングに関するhookの遷移

5. その他

React Server ComponentsではRuntime Configが利用出来ないため,環境変数で置き換えるなどの移行作業をしました.

移行作業を通して感じたポイント

移行作業を通して,_app.tsx及び_document.tsxの大きさが移行コストの大部分を決定するように感じました.
これらのファイルで行っている事が大きいほどApp Routerのfile conventionsやmiddlewareに対する知識と実装経験が求められるためです.
また出来なくなることや,再現に一手間が必要になることを整理した上で移行に望むと見通しがつきやすいように思います.

まとめ

「Next.jsのPages RouterからApp Routerへの移行に挑戦してみた」と題し,実際に挑戦week中に行った移行作業について紹介させていただきました.
後回しにしたものなど一部やり残しはありつつも,主要部の移行自体は出来たのではないかなと思っております.
何より,移行作業を通してfile conventionsやRoute GroupsなどのApp Routerでのみ利用出来る機能を学び,利用することが出来,知的好奇心が刺激された一週間でした.
アプリケーションの規模や利用している機能に依存しますが,移行する際にはおおよそ同様の流れを取るのではないかと感じており,この記事がPages RouterからApp Routerへの移行を検討されている開発者の方々のお役に立てたら大変嬉しいです.
ここまでお読みいただきありがとうございました!

DELISH KITCHEN の Android アプリに記事を追加した話

DELISH KITCHEN はレシピを動画でわかりやすく基本的な料理からアレンジまで様々なレシピを公開しています。
実はレシピ動画以外にも、季節にそったおすすめレシピ、素材についての解説、料理に役立つ情報などが記事にまとめられ公開されています。ご存知でしたか?

例えばこういった記事です。

今回は、この記事を Android アプリで閲覧できるようにした話をしたいと思います。

記事とは

まずは一例を見ていただければと思います。

目次 本文
記事の概要と見出し
おいしそうな出汁…

レシピ動画は料理の手順を簡単に把握しやすいのですが、一方で素材の魅力にふれるにはあまり適していない表現方法かと思います。記事では、そういった素材の魅力であったり、より詳細な解説や料理に役立つ情報などを紹介しています。

粉末だしとはその名の通り、鰹節や昆布などの素材を乾燥させて粉末状に加工したものです。製造過程で熱を加えていないため、より素材の自然な風味を感じられます。

なるほど…ふだんは顆粒だしを使っているのですが、今度は粉末だしを使ってみようかな?

記事のデータ

早速ですが記事について技術面の話をしたいと思います。
まずは記事を構成するデータの構造についてです。

※ 以降の内容は具体的な処理については割愛し、掲載するデータもイメージとなります

先に紹介した記事の内容は記事 API を介して JSON 形式でデータを取得します。

{
  "article_id": 123456,
  "title": "だし汁とは?簡単なだしの作り方もご紹介!",
  "description": "和食におけるだし汁の役割はとても大きく…",
  "thumbnail": "https://tech.blog.com/images/header.png",
  "created_at": "2022-06-02T16:00:00Z",
  "contents": [
      {
        "type": "image",
        "image_url": "https://tech.blog.com/images/main.jpeg",
      },
      {
        "type": "header1",
        "text": "和食に欠かせないだし汁とは?"
      },
      {
        "type": "paragraph",
        "text": "だしにはさまざまな種類がありますが、中でも一般的によく使われている素材が鰹節や昆布…"
      },
      ...
}

記事は titledescription など記事共通の要素の他に、本文などが含まれる contents で構成されています。 contents は記事の内容を柔軟に表現できるよう可変長になっています。その中身は、要素を区別する type と内容(テキストや URL など)の組み合わせで構成されたオブジェクトです。

これらのデータはアプリ側で扱いやすいように type で区別して各要素に変換します。

sealed class Content {
    // 見出しの属性として
    sealed class Headline : Content() {
        abstract val headline: String
        abstract val subHeadline: String?
    }

    // 見出し1
    data class Headline1(
        override val headline: String,
        override val subHeadline: String?,
    ) : Headline()

    // 見出し2
    data class Headline2(
        override val headline: String,
        override val subHeadline: String?,
    ) : Headline()

    // 段落
    data class Paragraph(
        val text: String,
    ) : Content()

    // 画像
    data class Image(
        val imageUrl: String,
    ) : Content()

    ...
}

パースされた要素はリスト(RecyclerView)に追加します。

リストへの追加

各要素を RycyclerView に add するタイミングで type に応じた ViewHolder に変換して追加します。
以下、Groupie を使って作成した Adapter と ViewHolder の一部です。

※ Groupie に関する詳細は github を参照してください

class ArticleAdapter(
    private val listener: Listener
) : GroupAdapter<GroupieViewHolder>() {
    ...
    interface ContentItem
    interface SectionItem : ContentItem
    interface TitleItem : ContentItem
    interface SubTitleItem : ContentItem
    interface DescriptionItem : ContentItem
    interface OtherItem : ContentItem

    private class ContentHeadline1Item(
        private val item: Content.Headline1
    ) : BindableItem<ItemContentHeadline1Binding>(), TitleItem {
        override fun getLayout() =
            R.layout.item_content_headline1

        override fun initializeViewBinding(view: View) =
            ItemContentHeadline1Binding.bind(view)

        override fun bind(viewBinding: ItemContentHeadline1Binding, position: Int) {
            viewBinding.title.text = item.headline
        }
    }
    ...
}

ContentHeadline1Item は ViewHolder です。このように、要素に合わせた ViewHolder を用意して各要素を変換します。

ここで ContentItemSectionItem などの interface が気になった方がいらっしゃるかもしれません。この interface については後ほど触れたいと思います。

要素間のマージン

ViewHolder に変換した要素は ViewHolder 内で指定したレイアウトが適用されます。下図のようにレイアウト内であれば目次のタイトルやリストなどマージンの調整は容易ですね。では、要素間のマージンはどうでしょうか?

目次は通常の layout.xml 内で完結できるので簡単です

見出しなら上部に 24 dp、画像なら上下に 16 dp のように要素ごとに固定のマージンなら単純だったのですが、記事の場合は前の要素によってマージンを変更する必要がありました。ひと手間かける必要がありそうです。

結論から言えば、今回の要素間のマージンは RecyclerView の ItemDecoration を使って解決しました。ItemDecoration はリストに追加されるアイテムにオフセットを追加する便利な仕組みを持っています。

この ItemDecoration の実装で先ほど触れた ContentItem などの interface が役に立ちます。
準備として、各 ViewHolder に適した interface を関連付けておきます。

以下、実装の一部です。

class MarginDecoration(
    resources: Resources,
    @IdRes private val space: Int
) : RecyclerView.ItemDecoration() {
    // space を元に必要な marginXX を定義
    ...

    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        val itemCount = parent.adapter?.itemCount?.let { it - 1 } ?: 0
        val position = parent.getChildAdapterPosition(view)

        // 最後の要素は下にもマージンを設ける
        val bottom = if (itemCount == position) margin40 else 0

        // 最初の要素は上のマージンを固定
        val top = if (position == 0) margin24 else null

        // 上のマージンは前の要素との関係によって決定するため、一つ前の ViewHolder を取得する
        val previousHolder =
            if (position > 0) (parent.adapter as? ArticleAdapter)?.findContentItemBy(position - 1) else null
        val currentHolder = parent.getChildViewHolder(view) as? GroupieViewHolder

        // 前の要素との関係でマージンを変える判定をここで行う
        when (val item = currentHolder?.item) {
            is TitleItem ->
                outRect.set(0, top ?: margin40, 0, bottom)
            is SubTitleItem ->
                when (previousHolder) {
                    is SectionItem -> margin40
                    else -> margin24
                }.let {
                    outRect.set(0, top ?: it, 0, bottom)
                }
            ...
            is OtherItem ->
                outRect.set(0, top ?: margin24, 0, bottom)
            else -> {
                // nothing
            }
...
}

getItemOffsets() はリストに要素が追加されるタイミングで要素にオフセットを追加することが可能です。パラメータの outRectset(left, top, right, bottom) することで要素にマージンを適用します。

outRect.set() する際に現在の要素が SubTitleItem で前の要素が SectionItem なら 40 dp …と考えられる組み合わせを条件で定義して適切なマージンを設定しました。

以下、記事を実装した結果です。

目次 本文
アプリ版の目次部分
アプリ版の本文

ContentItem などの interface を用意したことで5種類の interface といくつかの要素を考慮するだけで済みました。新しく要素を追加する場合も適した interface を関連付けるだけで済みます。記事の要素として用意された type は10種類以上あるので、個別に対応することを考えると…脳が震えます。

最後に

こうして DELISH KITCHEN の Android アプリでも記事が閲覧できるようになりました。一生懸命作った機能ですし、読み物としても面白いと思うので、ぜひ色々な記事を読んでみてほしいです。

iOSのヘルスケアアプリ連携について

はじめに

iOSにはデフォルトで「ヘルスケア」というアプリが存在することをご存知でしょうか。
弊社のDELISH KITCHENアプリでは昨年ヘルスケアという新機能をリリースしましたが、日々改修を重ねていく中でヘルスケアアプリにも着目し、色々と調査を行いました。

今回はその調査内容について纏めていきたいと思います。

ヘルスケアアプリとは

赤丸で囲ったものがヘルスケアアプリです。

こちらのアプリでは、歩数などのアクティビティ情報や、血圧などのバイタル情報といった健康・医療情報を一つのアプリで管理できるものとなっており、簡単に情報を閲覧・編集することができます。

私の場合、Apple Watchで歩数を記録し、ヘルスケアアプリで一週間の平均歩数を確認する、といった使い方をしています。
まだ使用されたことがない方は一度触れてみてはいかがでしょうか。

HealthKitについて

https://developer.apple.com/jp/health-fitness/

プログラムからヘルスケアアプリの情報にアクセスする場合、HealthKitを使用します。
HealthKitを使用することで容易にヘルスケアアプリの情報にアクセスすることができます。

https://www.proofpoint.com/jp/threat-reference/hipaa-compliance

余談にはなりますが、医療分野で業務を行う立場の場合はHIPAAという法律に従うことになります。
今回はHIPAAについて深くは触れませんが、健康情報を記録するアプリ(非HIPAAアプリ)と医療行為に関わるアプリ(HIPAA対象アプリ)で作成するプライバシーポリシーやアプリ申請内容が異なりますので、よく内容を理解してから実装することをお勧めします。

ヘルスケア連携手順

ここからは実際にプログラムからヘルスケアに連携する方法を纏めていきたいと思います。
今回はHealthKitを使って歩数の情報を取得する方法を纏めます。

開発環境

  • Xcode Version 14.3.1 (14E300c)
  • 開発言語 : swift

Capability追加

TARGETS > Signing & Capabilitiesにて、HealthKitを追加

今回、臨床記録のデータにアクセス、およびバックグラウンド配信は行わないため、以下のチェックは不要です。

info.plist更新

info.plistにて、以下2項目を追加
- Privacy - Health Update Usage Description … ヘルスケアのデータを更新するための許可を求める時に表示される文言
- Privacy - Health Share Usage Description … ヘルスケアのデータを取得するための許可を求める時に表示される文言

Valueに書かれた文字列が、後述する権限確認ダイアログに表示されます。

認証処理実装

ここからは実装に入ります。

    let healthStore: HKHealthStore
    
    init() {
        healthStore = HKHealthStore()
    }

HKHealthStoreのインスタンスを介して認証、取得、更新を行いますので、まずはインスタンスの生成をしておきます。

    func auth() {
        // 更新したいデータ
        let shareTypes = Set([
            // 歩数
            HKQuantityType.quantityType(forIdentifier: HKQuantityTypeIdentifier.stepCount)!
        ])
        
        // 取得したいデータ
        let readTypes = Set([
            // 歩数
            HKQuantityType.quantityType(forIdentifier: HKQuantityTypeIdentifier.stepCount)!
        ])
        
        // 認証実施
        healthStore.requestAuthorization(
            toShare: shareTypes,
            read: readTypes,
            completion: { success, error in
                if success { print("許可されました") }
                else { print("却下されました") }
            }
        )
    }

次に認証処理となります。

requestAuthorizationでユーザに対してアクセスする情報の権限確認ダイアログを表示します。
toShareには更新したいデータを設定、readには取得したいデータを設定します。
※plistで設定する情報とshareの意味が異なるので注意が必要です。

実行すると上記のような権限確認ダイアログが表示されます。
このダイアログには前述したplistに設定した文言が表示されます。

取得処理実装

ヘルスケアアプリからデータを取得する処理を実装します。

    func getStepCount(fromDate: Date, endDate: Date) {
        let query = HKSampleQuery(
            // 取得したいデータの種別
            sampleType: HKSampleType.quantityType(forIdentifier: .stepCount)!,
            // データの期間
            predicate: HKQuery.predicateForSamples(withStart: fromDate, end: endDate),
            // 取得件数の上限
            limit: HKObjectQueryNoLimit,
            // ソート
            sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: true)],
            resultsHandler: { (query, results, error) in
                guard error == nil else { return }
                
                // 取得した結果をサンプリングデータの型に変換
                if let itemList = results as? [HKQuantitySample] {
                    for item in itemList {
                        print("記録日時 : \(item.endDate)")
                        print("歩数 : \(String(item.quantity.doubleValue(for: .count())))")
                    }
                }
            }
        )
        healthStore.execute(query)
    }

HKSampleQueryでクエリの情報を作成し、executeメソッドに流すという実装となります。

更新処理実装

ヘルスケアアプリからデータを更新する処理を実装します。

    func updateStepCount(targetData: Date, stepCount: Double) {
        // 更新したいデータの種別
        let type = HKObjectType.quantityType(forIdentifier: .stepCount)!
        // 設定するデータ
        let quantity = HKQuantity(unit: .count(), doubleValue: stepCount)
        // サンプリングデータ作成
        let sample = HKQuantitySample(type: type, quantity: quantity, start: targetData, end: targetData)
        healthStore.save(sample, withCompletion: { (success, error) in if success {
            print("成功")
        } else {
            print("失敗") }
        })
    }

HKQuantitySampleでサンプリングデータを作成し、saveメソッドに流すという実装となります。

取得・更新時の注意点

取得・更新処理を前述しましたが、データを取り扱う際の注意点があります。

        // 設定するデータ
        let quantity = HKQuantity(unit: .count(), doubleValue: stepCount)

更新処理のこちらの部分ですが、インスタンスを生成する際に第1引数にHKUnitを設定しています。

https://developer.apple.com/documentation/healthkit/hkunit

HKUnitは「単位」のクラスになりますが、実装する際は設定するデータと単位が一致しないとExceptionが発生してしまいます。
今回は歩数のため、カウントの単位を返す.count()を設定していますが、

        let quantity = HKQuantity(unit: .kilocalorie(), doubleValue: stepCount)

のように、歩数にキロカロリーの単位を返す.kilocalorie()を設定してもビルド時にはエラーを検知できず、実行できてしまいます。
ヘルスケアアプリでは複数の情報を管理する都合上、単位の種類も多いのですが、適切な単位を設定する必要がある点に注意してください。

おわりに

HealthKitを使用したヘルスケアアプリとの連携方法を纏めましたが、前述の設定、実装のみでアクセスできるため、敷居は低く、簡単に実装ができました。

近年、UIUXを向上させるためには簡単にデータを取得・編集ができる、別アプリと連携できることが必須だと感じていますが、健康志向の方も多くなり、またスマートウォッチが普及したこともあり、ヘルスケアアプリでデータを記録しているユーザも増えている印象なので、健康情報などヘルスケアアプリでも管理している情報を扱う際はHealthKitを導入することで十分な効果が見込めると思います。

今回はHealthKitの触り部分の紹介となりますが、ヘルスケア連携の実装を考えている方にとって少しでも参考になれば幸いです。

Pyroscope の Continuous Profiling により Go サーバーのメモリリークを調査・改善した話

Eye-catching image

はじめに

子育てメディア「トモニテ」でバックエンドやフロントエンドの設計・開発を担当している桝村です。

2023年8月1日、MAMADAYSはトモニテに生まれかわりました。

tomonite.com

アプリのメイン機能である「育児記録」「妊娠週数管理」「食材リスト」を軸として、家族やパートナー、家族以外の人や社会との接点を作るためのシェア機能やコミュニティ機能などの拡充をめざしていきます。

今回は、Continuous Profiling を実施することができる Pyroscope を使用して、トモニテで運用している Go サーバーのメモリリークを調査・改善した話をしたいと思います。

以前、トモニテでEKSからECSに移行した話という記事において、EKS on EC2 から ECS Fargate への移行に伴い、厳密なリソース管理の必要性が生じ、メモリリークを検出した件の対応になります。

tech.every.tv

Pyroscope について

Pyroscope とは

Continuous Profiling を実施することができるオープンソースのプラットフォームです。

Continuous Profiling とは、ソフトウェアやアプリケーションが実行されている間、リアルタイムでパフォーマンスデータや実行情報を収集し、CPUやメモリ等のリソースをどこで多く消費しているか分析・チューニングする手法です。

2023年3月にデータ可視化ツール Grafana などを開発している Grafana Labs が Pyroscope を買収し、 オープンソース Grafana Pyroscope として統合されました。

本記事では Grafana Pyroscope の情報にも触れつつ、Pyroscope で Go サーバーのメモリリークを調査・改善した話となります。

Pyroscope でできること

  • コード内のパフォーマンスに関連する問題やボトルネックを見つけることができます。例えば、CPU使用率の上昇やメモリリークの発生等です。

pyroscope single view
pyroscope の Single View 画面

  • タグ機能 Tags や時間指定により、UI上のプロファイリングデータを絞り込みできます。

pyroscope tagged view
pyroscope の Tags でのフィルタリング画面

  • ビュー機能 Comparison View により、2つの時間区間を並べて比較分析できます。

pyroscope comparison view
pyroscope の Comparison View 画面

  • ビュー機能 Diff View により、2つの時間区間の差分を取得してヒートマップのように色付けして比較分析できます。

pyroscope diff view
pyroscope の Diff View 画面

参考: demo.pyroscope.io

Pyroscope の仕組み

Pyroscope Agent という言語ごとに用意されているプロセスが、アプリケーションの動作を定期的に記録・集計し、そのデータを Pyroscope Server に送信します。

Pyroscope Server がそのデータを処理・集約することで、ユーザーがプロファイリングの結果を WEB UI から Flame Graph (フレームグラフ)を閲覧することが可能になります。

なので、Pyroscope で Continuous Profiling を実施するには、Pyroscope AgentPyroscope Server の設定が必要になります。

how does pyroscope work
pyroscope の全体像

参考: pyroscope.io

Grafana Pyroscope について

2023年3月にデータ可視化ツール Grafana などを開発している Grafana Labs が Pyroscope を買収し、 オープンソース Grafana Phlare と統合され、 Grafana Pyroscope になりました。

grafana pyroscope log

参考: grafana.com

また、2023年8月末に Grafana Pyroscope として version 1.0 が公開されました。

このバージョンでは、以下をはじめとした改善が行われました。

  • Grafana と完全に統合され、Grafana ダッシュボードでメトリクスやログ、トレースなど他の可観測性の指標と一緒に Profiling のデータを表示可能になりました。

  • 様々なオブジェクトストレージサービス (ex. AWS S3, Google Cloud Storage) との統合がサポートされ、プロファイルデータをストレージサービスに保存可能になりました。

  • 水平方向のスケールアウトがサポートされ、あらゆる規模のプロジェクトで最適なパフォーマンスを出すことが可能になりました。

参考: github.com

Pyroscope を活用したトラブルシューティング

問題だったこと

トモニテで利用している Go サーバーの基盤である ECS のメモリ使用率が時間の経過とともに上昇し続けるという問題がありました。

もしサーバーのメモリ解放せず長時間経過した場合、サーバーの一時停止といったリスクがあったため、調査・改善が求められていました。

memory usage before
ECS メモリ使用率 改善前

Pyroscope の導入

Pyroscope Server の起動

Pyroscope Server をサーバー上で Docker 経由で起動しました。

Pyroscope によるリソース消費の最適化のため、データの保持期間 retentionexemplars-retention を調整し、それ以外の設定値は、デフォルトの値を利用しました。

Pyroscope Server と Go サーバーのネットワーク設定

それぞれでAWSアカウントが異なる構成だったのもあり、ネットワークの疎通のため、VPCピアリング接続やルーティングテーブルへのルートの設定をしました。

Pyroscope Agent の起動

Profiling したいサーバーは Go 製なので、同様に Go の Pyroscope Agent を利用しました。

Pyroscope Server 側のリソース消費の最適化のため、SampleRate を調整しました。

また、アプリケーションサーバー側の基盤が ECS なのもあり、バージョン単位でより詳細に分析できるように、Tags にタスク定義のバージョンを設定しました。

// Init initialize
func Init() {
    address := conf.PyroscopeAddress()
    if address == "" {
        return
    }

    pyroscopeConfig := pyroscope.Config{
        ApplicationName: "tomonite-server",
    ServerAddress: address,
    SampleRate:    conf.PyroscopeSampleRate(),
    
        Tags: map[string]string{
            "taskArn":               ecs.TaskARN(),
            "taskDefinitionVersion": ecs.TaskDefinitionVersion(),
        },
    }

    if _, err := pyroscope.Start(pyroscopeConfig); err != nil {
        log.Alert(fmt.Errorf("failed to start profiling for pyroscope. err: %w", err))
    }
}

Profiling の結果

時間の経過とともに Go サーバー全体に対してメモリの使用率が著しく上昇している処理を特定することができ、その処理は、grpc-go というモジュールであることが分かりました。

  • ある時点A

pyroscope before view
grpc-go のメモリ使用率はサーバー全体の15%ほど

  • ある時点Aから12時間後

grpc-go のメモリ使用率はサーバー全体の30%ほど

さらなる調査・改修対応

grpc-go は Go サーバー側で明示的に利用されている実装箇所を発見できなかったため、メインのモジュールとの依存関係を調査しました。

すると、Cloud Firestore データベースの読み取りと書き込みのためのクライアントを提供する cloud.google.com/go/firestore が依存していたことが判明しました。

$ go mod why -m google.golang.org/grpc

github.com/everytv/tomonite-server/db
cloud.google.com/go/firestore
google.golang.org/grpc

実際に firestore 周りのコネクションが保持されてそのままになっている処理があったので、コネクションを解放するため、(*firestore.Client).Close() を追記してリリースしました。

pr connection release
firestore のコネクションを解放する修正

その結果、Go サーバー全体に対するメモリの使用率の増加が顕著に緩やかになり、無事にメモリリークの問題を解決することができました。

memory usage after
ECS メモリ使用率 改善後

おわりに

今回は Pyroscope と Continuous Profiling 、Pyroscopeを活用したトラブルシューティングの事例について紹介しました。

Pyroscope を利用するメリットとして、WEB UIで直感的に操作できたり、ビュー機能である Comparison ViewDiff View でより効率的な分析が可能なところだと考えてます。

Continuous Profilingをしておくことで、問題発生後に Profiling 結果を得て、即座に調査が可能になります。今後も Pyroscope をうまく活用してサービスの安定稼働を実現していきます。

また、Grafana Labs による買収・統合に伴い、さらなる機能開発が見込まれるため、今後の開発の動向を注視しつつ最新バージョンへのアップグレードも検討できればと考えています。

Xcode Cloudを活用してDELISH KITCHEN iOSのCI/CD環境を更新しました

はじめに

DELISH KITCHENで主にiOSの開発やマネジメントを担当している久保です。

以前、DELISH KITCHEN iOSアプリ開発のCI環境についてという記事でCI環境を紹介しました。

今回は、Xcode Cloudの導入経緯とCI/CD環境の変化についてご紹介します。

Xcode Cloudへ移行した理由

Xcode Cloudの発表以降、さまざまな試行を行ってきましたが、特に以下の理由から全面的な導入を決定しました。

  • 他のサービスと比較して機能が少ないため、学習コストが低い
  • TestFlightやApp Store Connectへのアップロード時に証明書の管理が不要
  • XcodeやmacOSのアップデートへの追従が速い
  • ビルド番号の管理が不要

Firebase App DistributionからTestFlightに移行した理由

アプリ配布に使うサービスも、Firebase App DistributionからTestFlightに移行しました。App Distributionを用いてAdHoc配信をしていた際には、以下の作業が発生していました。

  • インストールしたい端末のUUIDを登録
  • Provisioning profileの更新

これらの作業はfastlaneを使って半自動化していたのでそこまで手間ではなかったのですが、TestFlightを利用することによってこれらの雑務から解放されました。

Xcode Cloud以外を利用している場合は、App Store Connect APIを利用するようなスクリプトを組むことになると思うのですが、Xcode Cloudはここもよしなに処理してくれるというメリットがあります。

構成

Xcode Cloud導入後のアプリの社内向け配布およびApp Store Connectへ提出を行う全体のフロー図を以下に示します。

前回の記事ではfastlaneが色々な役割を果たしていたのですが、今回の用途に限るとfastlaneは不要になりました。

Release版のワークフロー

App Store Connectへの提出と最終的な動作確認のための配布を行います。

特定のプレフィックスを含むタグがGitHubにプッシュされると、次のワークフローが実行されます。

  • TestFlightで内部テスターに配布
  • App Store Connectへの提出
  • 成功または失敗をSlackチャンネルに通知

Develop版のワークフロー

主に社内関係者への配布を行います。別アプリとして扱いたいため、Release版とは別のバンドルIDを設定しています。

こちらも同様に、特定のプレフィックスを含むタグがGitHubにプッシュされると、次のワークフローが実行されます。

  • TestFlightで内部・外部テスターに配布する
  • 成功または失敗をSlackチャンネルに通知

内部・外部テスターの使い分け

内部テスターと外部テスターの使い分けに関して、以下に簡単な違いをまとめました。

内部テスター (Internal Testers) 外部テスター (External Testers)
Apple ID App Store Connectで管理されたApple IDが必要 特に制限なし
配布までの時間 アップロード後即時 審査通過後

デザイナーやプロダクトマネージャーなど、すぐに確認してもらいたい場合は内部テスターを、手軽に確認したい利用者向けには外部テスターを利用してもらっています。

まとめ

Xcode Cloudの導入により、特にデプロイに関連する作業がシンプルになりました。

あえて気になる点を挙げると

  • 設定がGUI
  • App Store Connectに存在しないアプリに対してワークフローが設定できない
  • 任意のスクリプトを実行する機構はあるが、実行タイミングが限定的
  • キャッシュの設定などが不明瞭

などが考えられます。

今回はデプロイに焦点を当てた内容になりましたが、iOS開発の取り組みに関して少しでも知っていただければ幸いです。