KomercoとFirebaseの話【後編】 - Firebase運用の仕組化

Komercoの高橋です。

昨日は前編でFirestoreの設計パターンについてお話しましたが、後編はFirebase運用の仕組化についてです。

前の記事でも述べたように、昨年はWeb版のリリースや送料無料イベントもあり、ユーザ数がさらに増加してきています。 サービス規模が大きくなるにつれて運用コストも大きくなり、その効率化も求められるようになってきました。

ここではKomercoで行っている、運用に関する仕組みについてご紹介します。

Komercoの構成

まず前提としてKomercoの構成について簡単にご紹介します。

f:id:yosuke403:20210421170018p:plain

Komercoは器や料理道具、食材や調味料を扱うECサービスで、商品の販売者から直接購入ができるC2Cサービスとなっています。 商品を購入するユーザを「カスタマー」、販売するユーザを「クリエイター」と呼んでいます。

Komercoではこのカスタマー、クリエイターそれぞれに対してiOS版、Web版のアプリを提供しています。Web版についてはCloud FunctionsでNext.jsを動作させて実装しています。

各アプリはFirestoreやCloud Functionsにアクセスしてデータの取得や更新を行います。

Firestoreのデータやユーザの行動ログはBigQueryに貯まるようになっていて、Google スプレッドシートやTableauで分析しています。

もちろん他にもFirebaseの様々な機能を利用しています。

定期的にFirestoreのデータをBigQueryにエクスポート

Komercoでは毎日FirestoreのデータをBigQueryにエクスポートし、分析に使用しています。 Komerco発足時はエクスポートする手段がなかったので独自でツールを作っていましたが、今はgcloudを使ったエクスポート方式に移行しています。

やり方については次の通りです。

サービスアカウントの作成

ここを参考にロールを設定します。

バッチを動かす

バッチで叩くコマンドは次のようになります。

# サービスアカウントのキーファイルをセット
gcloud auth activate-service-account --key-file key.json

# プロジェクトのセット
gcloud config set project my-firebase-project

# collection groupごとにFirestore→Storageへデータをエクスポート
gcloud firestore export gs://firestore-export-for-bq/2020-06-29T10:17:23.011+09:00 --collection-ids=user,shop,product

# collection groupごとにBigQueryのテーブルに入れる
bq load --source_format=DATASTORE_BACKUP --replace=true --projection_fields=name firestore.user gs://firestore-export-for-bq/2020-06-29T10:17:23.011+09:00/all_namespaces/kind_user/all_namespaces_kind_user.export_metadata
bq load --source_format=DATASTORE_BACKUP --replace=true --projection_fields=name,owners firestore.shop gs://firestore-export-for-bq/2020-06-29T10:17:23.011+09:00/all_namespaces/kind_shop/all_namespaces_kind_shop.export_metadata
bq load --source_format=DATASTORE_BACKUP --replace=true --projection_fields=name,price firestore.product gs://firestore-export-for-bq/2020-06-29T10:17:23.011+09:00/all_namespaces/kind_product/all_namespaces_kind_product.export_metadata

projection_fields オプションがあるおかげで、個人情報など取り込みたくない情報はフィルタ可能です。

Dockerfileを使う場合は、gcloudを使うためのGoogle公式イメージがあるので、これを利用するのが便利です。

今からサービス公開するならFirebase Extension

もし今の時点でサービスが未公開の場合はFirebase Extensionを使う選択肢があるかもしれません。

firebase.google.com

こちらの場合、すでにFirestoreに入っているデータについてはエクスポートされないため、今回はgcloudの手法を選択しています。

AuthのデータをGoogle スプレッドシートにエクスポート

ユーザにメールを送りたいときなどに、Authからメールアドレスを抽出する必要があります。 個人の環境でデータを取得するのは情報の取り扱い上問題があるため、バッチでGoogle スプレッドシートに直接書き込むようにしています。

Google スプレッドシートに書き込むことのメリットとして、アクセス管理が簡単なことや、リストのフィルタなどがその場でできることが挙げられます。

サンプルはこちらで、このプロジェクトをバッチで実行します。 https://github.com/yosan/FirebaseAuth2Sheet

ロールバック

Cloud Functionsにはロールバックの仕組みがないため、デプロイ後に不具合が発覚した場合にはもう一度過去のバージョンをデプロイする必要があります。 Web版のKomercoもNext.jsをCloud Functionsで実行している関係上、Hostingのロールバックだけでは意味がありません。

Komercoの本番環境にデプロイする際は、個人の環境からはできず、クックパッドで利用されているRundeck+hakoの仕組みを使ってデプロイしています。 masterブランチに変更が入ると、JenkinsでCloud FunctionsやWebアプリをビルドし、それが入ったDockerイメージをAmazon ECRにプッシュします。 デプロイ時はRundeckからhakoを使うことで、プッシュされたDockerイメージを取得して firebase deploy コマンドを実行します。 Rundeckは基本的にruboty経由で呼ぶため、Slackと上で ruboty deploy komerco-cloud-functions のように発言して指示します。

f:id:yosuke403:20210420173149p:plain

ECRにプッシュされたDockerイメージにはタグがついていて、hakoではこのタグをオプションで指定することで過去のビルド成果物を使ってデプロイができます。 ロールバック時は前回デプロイした際のDockerイメージのタグを特定して、再度デプロイします。

f:id:yosuke403:20210420173528p:plain

Cloud FunctionsのCI環境改善

サービスの機能が増えるに連れてCloud Functionsの数は増えていき、Komercoでは100を超えるCloud Functionが動いています。 Firebaseの機能拡張が進み、その運用コストも下がってきました。

オフラインテスト

Komercoリリース時点ではFirebaseに関するテスト環境はまだまだ充実していなかったため、CIは実際のFirestoreを使ってオンラインテストを行っていました。 しかしこれにはテスト用のプロジェクトが必要だったり、CIの度にデプロイが必要だったりといろいろ不便な点がありました。 現在ではCIのテストは全てオフラインに切り替わっており、CIにかかる時間は大幅に短縮されています。

テストを実行する際は、firebase emulators:exec コマンドから、FirestoreやCloud Functionsのエミュレータを起動させています。 起動中はAdmin SDKの書き込みは自動的にエミュレータのFirestoreの方を向くため、オンラインテストの実装をほぼそのままオフラインに移行できました。

https://firebase.google.com/docs/functions/local-emulator#interactions_with_other_services

Firebase Admin SDK を使用して Cloud Firestore に書き込む Cloud Functions がある場合、Cloud Firestore エミュレータが実行されていれば、この書き込みはエミュレータに送信されます。この書き込みによってさらに Cloud Functions がトリガーされると、それらは Cloud Functions エミュレータ内で実行されます。

ここで、もともとオンラインで行っていたテストには、

  • 単体のCloud Functionsに対して行うテスト
  • カスタマーが商品を購入するまでの一連のFirestoreへの操作を再現してテストするシナリオテスト

の2種類がありました。

Cloud FunctionsとFirestoreエミュレータが同時に起動している場合、Firestoreへの書き込み時にFirestore Event TriggerのCloud Functionsがエミュレータ上で発火するようになります。 シナリオテストはこのFirestore Event Triggerの動作も含めて、仕様通りに更新が行われるかをチェックします。 一方で単体のCloud FunctionsのテストにおいてはFirestore Event Triggerは不要で、発火してしまうとテストにかかる時間が伸びてしまいます。

そこで、シナリオテストのみ他のテストとフォルダ単位で分離してテストを別々に実行しています。 シナリオテストの場合のみCloud Functionsエミュレータを起動し、Firestore Event Triggerを発火させてテストします。

Cloud Functions名の定義ファイル

KomercoではCloud Function名やその実体のパス、グループなどが書かれた定義ファイルをfunctionDefinitions.ts として用意しています。

この理由は「Cloud Functions実行時のモジュールの動的ロード」と「同時デプロイ数を制限したフルデプロイ」において、このファイルをimportして使用するためです。

定義ファイルの例です。

type FunctionProperty = {
  name: string
  module: string
  implementation: string
}

type FunctionDefinition =
  | ({ type: 'single' } & FunctionProperty)
  | { type: 'group'; groupName: string; functions: FunctionProperty[] }

export const functionDefinitions: FunctionDefinition[] = [
  {
    type: 'single',
    name: 'createShop',
    module: './shop',
    implementation: 'onCreateShopCalled',
  },
  {
    type: 'group',
    groupName: 'product',
    functions: [
      {
        name: 'create',
        module: './product',
        implementation: 'onProductCreated',
      },
      {
        name: 'update',
        module: './product',
        implementation: 'onProductUpdated',
      },
    ],
  },
]

export default functionDefinitions

type はグルーピングされたCloud Functionsか単独かを表します。 name は関数名、 moduleimplementation が実装先を表します。

Cloud Functions実行時のモジュールの動的ロード

Cloud Functionsは実行時に必要なモジュールのみロードするようにしないと、初回のコールドスタート時の実行時間に影響が出ます。 そこでKomercoでは実行されたCloud Functionsによって、動的にロードするモジュールを切り替える仕組みを入れています。 定義ファイルからCloud Function名と実装先を読み取ってロードします。

import functionDefinitions from './functionDefinitions'

const shouldExport = (functionName: string): boolean => {
  const currentFunctionName = process.env.K_SERVICE
  return (
    currentFunctionName === undefined || currentFunctionName === functionName
  )
}

functionDefinitions.forEach((definition) => {
  switch (definition.type) {
    case 'single':
      if (shouldExport(definition.name)) {
        exports[definition.name] = require(definition.module)[
          definition.implementation
        ]
      }
      break

    case 'group': {
      const groupedFuncs = definition.functions.reduce((previous, current) => {
        const functionName = [definition.groupName, current.name].join('-')
        return shouldExport(functionName)
          ? {
              ...previous,
              [current.name]: require(current.module)[current.implementation],
            }
          : previous
      }, {})

      exports[definition.groupName] = groupedFuncs
      break
    }
  }
})
フルデプロイ時の書き込み制限問題

サービスが拡充されるに連れてCloud Functionsの数は増え、現在では100ほどのCloud Functionsが作成されています。 Komercoでは本番にデプロイする際は、基本的にmasterブランチにあるFirebaseプロジェクトの内容をすべてデプロイします。 ここで、Cloud Functionsが多くなるにつれて、デプロイの制限にひっかかって頻繁にデプロイエラーを起こしていました。

https://firebase.google.com/docs/functions/manage-functions

多くの関数をデプロイすると、標準の割り当てを超過し、HTTP 429 または 500 エラー メッセージが表示されることがあります。これを解決するには、10 個以下のグループで関数をデプロイします。

そこで定義ファイルから全関数のリストを取得し、最大10個ずつデプロイする仕組みを作っていました。

次のようなデプロイ用スクリプトをTypescriptで実装を作成し、 ts-node で実行してしました。

import { functions } from './src/functions'

const deploy = async (option: { only?: string; except?: string }) => {  
  let command: string | undefined  
  if (option.only) {   
    command = `yarn run firebase deploy --force --only ${option.only}`   
  } else if (option.except) { 
    command = `yarn run firebase deploy --force --except ${option.except}`   
  }  

  if (command) {   
    const stdout = execSync(command)    
    console.log(stdout.toString())  
  }  
}

const main = async () => {  
  const chunkedFunctions = ... // functionsを最大10個ずつに分割

  await deploy({ except: 'functions' })    // Cloud Functions以外をデプロイ

  for (const funcs of chunkedFunctions) {    
    await deploy({ only: funcs.map(f => `functions:${f}`).join(',') }) // 分割されたfunctionsをデプロイ
  }  
}    

main().catch(e => { 
  console.error(e)  
  process.exit(1)  
})

Cloud Functionがグルーピングされているされている場合、そのグループのCloud Functionsは一度にデプロイするようにしています。 これは、デプロイにONLYオプションを使う場合に、グループ内で減ったCloud Functionがある場合に自動で削除が可能なためです。 グルーピングされていないものについては自動削除できなそうなので、これからCloud Functionが増えることを考えて初期の段階からグルーピングを使った方がいいかもしれません。

これによってデプロイエラーは無くなったのですが、デプロイに非常に時間がかかるようになってしまいました。

firebase-tools v9.9.0でデプロイのリトライが追加

実はこの記事を書いているうちfirebase-tools v9.9.0がリリースされ、先に述べたデプロイエラーが発生したときに自動的にリトライする仕組みが入りました。そのため現状は一度にデプロイする関数を10個以上に指定しています。

ただし今でも、全て一度にデプロイしようとするとエラーになることがあるようで、引き続きこの仕組が必要になりそうです。

まとめ

Komercoで導入しているFirebase運用の仕組みについてご紹介しました。 こういった仕組み化により、エンジニアは少ないながらも高速に開発ができています。 ご興味ある方は、ぜひ弊社に遊びに来てください。

info.cookpad.com

ここで告知

実は本日4/22(木)から5/5(水)まで春のオンライン陶器市を開催しています。 なんと期間中は送料無料です。 これに加えて、本日からお茶・紅茶・珈琲カテゴリが追加されました!

ぜひこの機会にお買い求めください。

komer.co

KomercoとFirebaseの話【前編】 - Firestoreの設計パターン

こんにちは。Komercoの高橋です。

Komercoがリリースされてからもうすぐ3年が経とうとしています。 クックパッドの新規事業「Komerco」ではバックエンドのほぼ全てをFirebaseで運用してします。 新規事業のためエンジニアの数はまだ少ないものの、Firebaseのおかげでエンジニアはサービス開発に専念できている状況で、昨年はWeb版のリリースや送料無料イベントもあり、ユーザ数がさらに増加してきています。

これまで多くの機能を開発してきましたが、その中でFirebaseをより有効に使えるよう試行錯誤してきました。 これからFirebaseを使おうか考えている人にも、今現在Firebaseを使っている人にも参考になるよう、Komercoで得た知見を書いていこうと思います。

前後編となっており、前半はFirestoreの設計についてお話しようと思います。

当たり前の機能だけどFirestoreだと実現しにくいもの

Komercoは器や料理道具、食材や調味料を扱うECサービスです。 そのためFirestoreには「ユーザ」「商品」「ショップ」などのコレクションが存在し、主にこれらを操作しながら買い物の機能を実現しています。 また、ユーザとショップがやり取りを行うメッセージ機能や、Komercoからのお知らせ機能など、サービスとして標準的な機能もFirestoreで実現しています。

開発を続ける中で気づいたのは、一般的なサービスでよく見る機能でも、Firestoreで実現すにはひと工夫必要なことがあることです。 こういった問題にぶつかる度に、開発メンバーでどんな設計ができそうかアイデアを出し合ってきました。

ここでは、どういった機能がFirestoreで実現しにくいのか、それに対してどんな設計パターンがあるのか、それらのメリット・デメリットは何かを書いていこうと思います。

リスト表示に複数のコレクションの情報が必要

最初はよく語られる内容ですが、クライアントサイドジョインの問題です。 あるコレクションのリストを表示したいときに、各リストアイテムごとに別コレクションのデータが必要になるケースです。

例えばKomercoでは商品のリストを表示する画面で、各セルにショップの情報も表示しています。

f:id:yosuke403:20210420151618j:plain

Firestoreでは単一のクエリで単一のコレクションしか取得できない制約上、次のいずれかの手段を取ることになります。

冗長化: 取得するコレクションに別コレクションの情報を含めるようにする。

f:id:yosuke403:20210420151722p:plain:w700

クライアントサイドジョイン: クライアント側でコレクションを別々に取得して結合する。

f:id:yosuke403:20210420151700p:plain:w380

ちなみに、最初は単一コレクションの取得で済むよう設計できていても、その後の拡張で別コレクションのデータが必要になることも多いと思います。 Komercoも最近のアップデートで、商品のセルにショップの情報も載せるようになりました。 冗長化するかクライアントサイドジョインにするかの選択は、サービスを続けている間に必ず経験することになるのではないでしょうか。

実態としてクライアントサイドジョインが多くなりがち

冗長化には次のメリットには、

  • リスト表示に必要なデータの取得が一回のアクセスで済む。
  • クライアント側の実装がシンプルになる。

などがあり、クライアントサイドジョインのメリットは、

  • Firestoreのストレージ消費が少ない。
  • 冗長化の仕組み(Cloud Functionsの実装等)が不要。

などが挙げられると思います。

Komercoはあまり冗長化の選択ができていません。というのも冗長化の仕組みの実装・メンテナンスコストが大きくなりがちだからです。 例えば先ほどの商品にショップの情報を載せる例では、冗長化を行う場合、商品のコレクションにショップの情報の一部をCloud Functionsで付与する訳ですが、

  • ショップ情報更新時にそのショップの商品すべてを更新するCloud Function
  • 商品情報の新規作成時にショップの情報を取得して付与するCloud Function
  • Firestoreセキュリティルールの確認・変更
  • Firestoreインデックスの確認・変更(大きいフィールドを除外するなど)
  • (リリース後の場合)すでにFirestoreに存在する商品に対してショップ情報を付与する作業

等々が必要になります。 うまく管理していかないと、冗長化のCloud Functionsが増え続け、どのドキュメントを更新すると何に影響が出るのか分からなる可能性があります。

クライアントサイドジョインの場合、確かにクライアント側の実装コストは増えますが、大抵の場合はセルが画面に表示される直前にデータ取得する実装をするだけです。 セルに画像を表示する際によく行う実装と一緒です。 そのため、基本的にクライアントサイドジョインの選択を行っている状況です。

冗長化がうまくいった例

Komercoで冗長化がうまくいった例としては、商品一覧画面の画像取得の高速化があり、以前ブログでも書きました。

techlife.cookpad.com

この例のように、

  • クライアントサイドジョインでは処理完了までの時間が遅い
  • 対象の処理速度はサービスにとって重要である

といった場合において、冗長化の仕組みの導入コストに見合うのではないかと思っています。

Cloud Functionsで取得するという選択肢について

Cloud Functionsでジョインしてから返すことも考えたことがありますが、個人的にはあまりよくない手法だと思っています。 理由はFirestoreのセキュリティルールを無視した取得が可能になってしまうからです。

セキュティルールによって誰がクライアントを実装しても想定外の更新はなされないという安心感があり、特に新しいメンバーがジョインしたときになどに有効です。

どうしてもCloud Functionsで実装したい場合はそのようなケースに気をつけつつ、Firestoreと同じリージョンで作成するのがよさそうです。

リスト表示にページャーをつける

f:id:yosuke403:20210420153231p:plain

Firestoreで特定のコレクションのデータを一覧表示していく場合、一番簡単なのはアプリ上で一番下までスクロールしたら続く内容を追加ロードする仕様です。 これはFirestoreのstartAfterなどのカーソル句を使ったクエリと相性がいいためです。

一方で困るのは特定のページを指定して、その範囲のリストを取得することです。 特にPCの場合、追加ロードよりもページャーでリストを見ていく方が一般的かと思います。 ページを指定するということはつまり、取得開始位置と取得個数をクエリに含められばよいのですが、取得開始位置の指定はクライアントライブラリにはありません。

KomercoのWeb版はPCでの利用も想定しており、実現方法についてよく議論しました。

ドキュメントに何番目かを表す番号を付与する

クライアント的に理想的な状態は、何番目かを示すフィールドがドキュメントに追加されることだと思います。 取得範囲を指定が簡単で、全部で何件あるかも最後のドキュメントを取得すれば分かります。

ただこれは一方で、ドキュメントの順序変更や削除が発生した場合に、影響するコレクションの番号を振り直しをCloud Functionsで行う必要があります。 頻繁に更新されるようなコレクションの場合は、書き込み数の上限などに注意が必要です。

f:id:yosuke403:20210420153434p:plain:h400

ページ指定以外の方法を考えてみる

例えば週に1回定期的に更新される記事コンテンツのようなものの場合、ページではなく年月を指定してその範囲の記事を取得する方法が考えられます。 これであればFirestoreのクエリが書けるので、実現は容易です。

f:id:yosuke403:20210420153455p:plain:h400

全件取得

先に挙げた戦略が取れない場合、データ量的に問題ないことを前提に、全件取得の判断をすることもあります。 これは最後の手段なので、仕様を調整するなど、できるだけ別の解は無いか考えるようにしています。

未読にバッジをつける

サービスからのお知らせやメッセージ機能を実装すると必要になるのが未読機能です。 未読の保存方法についてはいくつかパターンがあります。

未読管理対象のドキュメントに保存する

未読管理対象のコレクションに対して、

  • 閲覧するユーザ数が限定されている
  • 各ユーザの未読・既読状況が、他の閲覧可能ユーザに公開されてよい

のであれば、そのコレクション自体に未読・既読を表す情報を入れてしまうのがよいと思います。 未読の有無の確認もクエリひとつで確認できますし、クライアントサイドジョインも不要です。

Komercoのメッセージ機能を例に説明します。 この機能は、クリエイターとカスタマーの1対1チャットを行うものです。 チャットールムに当たるRoomコレクションがあり、そのサブコレクションとして各チャットメッセージに当たるMessageコレクションがあります。

例えばクリエイターがメッセージを送信すると、Messageコレクションにドキュメントが追加されます。 Cloud Functionはそのイベントを受けて、親のRoomのドキュメントを更新し、カスタマーの未読有を示す isCustomerRead フラグを false にします。 カスタマーは isCustomerRead == false で絞り込むことで未読メッセージを見つけられますし、Messageコレクションを取得しなくてもそのチャットルームに未読があることが分かります。

f:id:yosuke403:20210420154207p:plain

既読管理用のサブコレクションを作る

「サービスからのお知らせ」のように、全体向けの情報に対して未読管理したい場合、先の例のようにお知らせのドキュメントに全ユーザの未読情報を書き込むのは現実的ではありません。

そこで例えば、ユーザのサブコレクションに既読管理用コレクションを作成し、お知らせを読んだ場合はそのお知らせのIDを持つドキュメントを既読管理用コレクションに追加します。 お知らせ表示時はそのコレクションを比較し、既読管理用コレクションに存在しなければ未読と判断されます。

f:id:yosuke403:20210420154225p:plain

ただし、「1つでも既読のお知らせがあればバッジを表示したい」といったときに、お知らせも既読管理用コレクションも、最悪全件取得しないと分からないという問題があります。

最後に閲覧した日付より前の記事を未読とする

例えばお知らせで、お知らせごとに個別に未読管理する必要がない場合は、最後にお知らせ一覧を開いた日付を覚えておき、それ以前のお知らせを既読としてしまう方法があります。 この場合、未読の有無はその日付を使ってクエリすることができます。

f:id:yosuke403:20210420154315p:plain

ドキュメントごとの既読管理の重要性が低い場合は、こちらの手法がよいと思います。

まとめ

Komercoの経験をベースに、Firestoreを使った開発で多くの人が悩みそうなケースについて説明しました。 どの設計を選ぶかは結局仕様次第になるので、仕様をよく分析して設計を選んでみてください。

弊社もまだまだ試行錯誤中なので「自分はこうしています!」というアイデアをお持ちの方、ぜひお話聞かせてください。

info.cookpad.com

後編は、Firebaseに関する仕組化を行って、運用コストを下げている話をしようと思います。

クックパッドマートの生鮮食品を SORACOM の IoT デバイスで遠隔温度監視している話

クックパッドマートでサーバーサイドなどのソフトウェアエンジニアをしている石川です。

この記事では、クックパッドマートの物流の一部で SORACOM のサービスを活用して生鮮食品の遠隔温度監視を行っている話について、主にサーバーサイドの取り組みを紹介します。

クックパッドマートの物流

クックパッドマートの物流は、非常に大雑把に言うと以下のような手順になっています。

  1. 販売者さんが集荷場所に置いてあるコンテナへ商品を届ける。
  2. ドライバーさんが集荷場所からコンテナを集めて回る。
  3. 集めたコンテナを、商品の受け取り場所の冷蔵庫に届ける。
  4. 冷蔵庫に届いた商品をユーザーさんが受け取る。

まとめると、(販売者さん)→集荷場所→(ドライバーさん)→ステーション→(ユーザーさん)、という図式になっています。

ここでコンテナと言っているのは物理的な容器の方のコンテナです。以下のようなコンテナに食品を入れて運んでいます。

緑色の折り畳めるコンテナの写真です。
コンテナ

今回注目するのは、ドライバーさんが集荷場所から受け取り場所に届けるまでの間です。集荷場所から受け取り場所の間ではドライバーさんが車両でコンテナを移動させています。お肉やお魚などの生鮮食品を扱っているため移動の間も温度を低く保ちたいのですが、コンテナをそのまま運んでしまうと温度を低く保つのが難しいため、蓄冷剤の入った保冷用のボックスにコンテナを入れて運んでいます。

保冷用のボックスというのは次の写真のような、銀色の箱です。コンテナがいくつか入る程度の大きさになっています。

外側が銀色になっている、直方体の箱です。
保冷用ボックス

この保冷用ボックスの温度を遠隔監視しよう、というのがこの記事のテーマです。

GPS マルチユニット SORACOM Edition

2021 年 4 月現在、私たちはこの保冷ボックスの温度を「GPS マルチユニット SORACOM Edition」を使って計測しています。このセンサーは京セラさんのセンサー「GPS マルチユニット」をベースに SORACOM プラットフォームで利用しやすくするためのカスタムがなされているセンサーです。温度の他、湿度、加速度、位置情報を計測できます。

GPS マルチユニット SORACOM Edition の見た目は下のような感じです。後ろでコンテナの中に入っているものは食品サンプルです。

白くて薄い直方体の形をしたセンサーです。背景には保冷用ボックスが写り込んでいます。
GPS マルチユニット SORACOM Edition

GPS マルチユニット SORACOM Edition は、実装当初候補に挙がっていた他のセンサーと比較しても実装工数が少なく済みそうなことや、今回のユースケースに充分な測定精度があること、充分な数を入手できそうなことなどを理由に採用しました。

SORACOM プラットフォーム上でこの GPS マルチユニットを使う場合、SORACOM Lagoon を使ってダッシュボードを作れる他、SORACOM FunnelSORACOM Funk を使って他のクラウドサービスへデータを送ることができます。今回は SORACOM Funnel を使って AWS へデータを送っています。

温度データの利用

具体的な構成の説明に入る前に、私たちが温度データを使ってどのような機能を作ったかを説明します。

保冷用バッグの中には生鮮食品が入っているため、GPS マルチユニットの示す温度は低く保たれている必要があります。もし GPS マルチユニットの温度が高くなり始めた場合、その GPS マルチユニットの入った保冷用ボックスを運んでいるドライバーさんにご確認いただこうということになりました。

しかし GPS マルチユニット自体には温度を表示する液晶画面の類や異常を知らせるブザーなどはついていないため、何かしら別の方法でドライバーさんに異常を伝えなければいけません。そこで GPS マルチユニットの温度データを元にドライバーさんへ通知をする機能を作りました。

元々ドライバーさんには、当日の配送ルートの表示や集めるコンテナの指示などを行う目的でモバイルアプリを使っていただいていました。これはドライバーさん専用アプリで、販売者さんの商品を販売するアプリとは別のものです。社内では通称「ドライバーアプリ」と呼ばれています。

もし GPS マルチユニットの温度が高くなり始めた場合、このドライバーアプリ越しにプッシュ通知してお知らせするようにしています。以下のような感じです。

プッシュ通知のスクリーンショットです。内容には「シッパー内の温度が高くなっていませんか?」「タップして続ける」と書かれています。
プッシュ通知

※「シッパー」と書かれているのが保冷用ボックスのことです。

また、先述のように GPS マルチユニット自体を目で見ても温度は分からないため、ドライバーアプリから現在の温度が確認できるようにもしています。

ところでこの機能を実装するためには、それぞれの GPS マルチユニットをどのドライバーさんが持っているのか知らなければいけません。この紐付け登録もドライバーアプリを使って行っています。具体的には、それぞれの GPS マルチユニットに二次元コードを貼り付け、ドライバーさんにモバイルアプリで読み取ってもらうことで登録しています。

更にドライバーさん向けだけでなく、弊社の社内オペレーター向けにも温度データが見えるようにもしています。具体的には、Grafana を使ったダッシュボードで温度の時系列変化が確認できるようにしたり、GPS マルチユニットの温度が高い状態を維持した場合に Slack へ通知が来るようにしたりもしています。

構成

以上のような機能を作るため、サーバーサイドでは以下のような構成を採用しました。

各々のサービスを示すアイコンが矢印で結ばれています。まず GPS マルチユニット SORACOM Edition から SORACOM Funnel に矢印が伸びており、SORACOM Funnel から Amazon Kinesis Data Firehose に伸びています。そこから AWS Lambda に伸びており、AWS Lambda からは 2 本の矢印がそれぞれ Amazon CloudWatch と Amazon DynamoDB に伸びています。
構成図

まず、GPS マルチユニットから SORACOM へ送られてきたデータを SORACOM FunnelAmazon Kinesis Data Firehose に転送します。続いてそのデータを AWS Lambda を使って整形しつつ Amazon CloudWatchAmazon DynamoDB に流します。図からは省略していますがモバイルアプリからは API アプリを介して DynamoDB にある温度データにアクセスできるようになっています。なお、データの行き先が CloudWatch と DynamoDB の 2 つになっているのは開発事情の都合です。

構成自体はシンプルで、全体の実装にもそこまで長い時間はかかりませんでした。たとえば AWS Lambda の部分は AWS CDK を使って 1 日かからずに作れています。

なお、自分が不慣れだったこともあり、上記の構成に落ち着くまでには少し時間がかかりました。たとえば論点をひとつ取り上げると、SORACOM Funnel と SORACOM Funk のどちらを使うかというものがありました。

SORACOM Funnel と SORACOM Funk はどちらもクラウド間を結ぶ「アダプタ」であるという意味で似たサービスです。結果的に Lambda を実行するのであれば、SORACOM から別のクラウドへデータを運ぶ仕組みである SORACOM Funnel ではなく、SORACOM から別のクラウドの関数を実行する仕組みである SORACOM Funk を利用する選択肢もありました。しかし今回は SORACOM Funnel を採用しています。

これは、SORACOM Funnel の方がそれぞれの温度データのタイムスタンプをより正確に得られそうであったためです。GPS マルチユニット SORACOM Edition はデータ取得時の時刻を送らないため何かしらの時刻を使ってタイムスタンプを計算する必要があるのですが、SORACOM Funk を使った場合タイムスタンプの誤差が大きくなりうるという問題がありました。

というのも、今回の実装時点の SORACOM Funk は、GPS マルチユニットからのデータを受信した瞬間のタイムスタンプを保持していませんでした。このため SORACOM Funk を使った場合に温度データのタイムスタンプを得るには「SORACOM Funk 経由で実行された Lambda の内部で現在時刻を取得して温度データのタイムスタンプとする」ような実装が必要でした。しかし Lambda の実行が失敗してリトライされる可能性があることを考えると誤差が許容できないと判断しました。SORACOM Funnel ではデータを受信した瞬間のタイムスタンプが保持されており、この問題が起こりませんでした。

ただし SORACOM Funnel にも、GPS マルチユニットがデータを取得するタイミングと Funnel がデータを受信するタイミングにズレがあるという別の問題はあります。

この問題については、今回は温度について秒単位でのリアルタイム性は要求されておらず、またデータ取得時刻とデータ受信時刻のズレは充分小さいらしいことが分かったため、許容することにしています。実際私たちが気にしているのは「温度の低い状態が維持されているか」であり、特定時刻の温度を正確に知りたい訳ではないため、これで充分と判断しました。

このようにいくつかの論点を考えた上で今回の構成に至りました。そして実際今のところサービスが成長しドライバーさんが増えていく中でもこの構成のまま運用を進められており、ベターな選択であったと考えています。

最後に

以上のように、この記事では、GPS マルチユニット SORACOM Edition を使って生鮮食品の温度を遠隔監視している話を紹介しました。

ところで、実際にこの仕組みを使って温度を計測してみると、たとえば車がトンネルに入ると少しのあいだ温度が上手く取得できないことがあるなどの問題が分かっています。現状は安全側に倒してデータ取得失敗時も通知を行い、人力で判断している状態です。このような状況をどうしていくか、ハードウェアとソフトウェアの両面から改善を進めています。

このようにクックパッドマートでは実世界にまつわる様々な課題にハードからもソフトからも切り込み、スピード感のある開発を続けています。弊社ではただいま絶賛エンジニア募集中ですので、ぜひ採用情報をご覧くださいませ。

cookpad-mart-careers.studio.site

info.cookpad.com

※SORACOM は、株式会社ソラコムの登録商標または商標です。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/