AWSフル活用!クッキングLiveアプリ「cookpadLive」を支える技術

メディアプロダクト開発部の長田(@osadake212)です。
私の主な仕事は、CookpadTV 株式会社のサービス開発をすることです。CookpadTV ではたくさんのサービスを同時に開発しており、今回の記事ではそのたくさんのサービスの中の一つである cookpadLive とそれを支える技術について、利用している AWS サービスを中心に紹介します。

cookpadLive チームメンバーによる、過去の発表や記事と被る箇所もあるのですが、この記事では全体を眺めることができるように紹介していきます。

cookpadLive とは

cookpadLive

cookpadLive とは、料理上手な有名人と Live 配信で一緒に料理が楽しめるクッキング Live アプリです。視聴者は Live 配信中にコメント機能を使って、料理のわかりづらいポイントを質問したり、作って欲しい料理のリクエストをすることができます。
普段あまり料理をしないユーザーも楽しめる Live 配信になっていて、 cookpadLive をきっかけに料理を作るユーザーもいます。

Live 配信中にはコメント以外にも多くの機能があります。

  • コメント・ハート・スタンプ(スタンプは有料会員であるゴールド会員限定)
  • Live 配信後半にゴールド会員だけが視聴できる スペシャルTIME
  • 広角カメラによる配信映像で Live を楽しめる スタジオ観覧モード
  • Live 配信中に料理やレシピカードを購入できる スペシャルSHOP
  • Live 配信中に出演者と生電話ができる スペシャルTALK

これらの機能は Live 配信によって取り外しが可能になっていて、企画に合わせて最適な機能を組み合わせることができるようになっています。

また Live 配信以外にも、アーカイブやスペシャル VIDEO のような機能もあります。

cookpadLive の歴史

cookpadLive は 2018年にリリースしました。リリース当時は Live 配信を視聴する機能しか備わっておらず、アプリの UI、バックエンドの構成も現在とは大きく異なっています。

特徴的な変遷を紹介すると

  • コメント配信システムを Firebase から AWS へ乗り換え
  • ユーザーが増えたことによるスパイクに耐えるための仕組みの整備
  • ゴールド会員という月額有料プランを用意し、スペシャル TIME, スタジオ観覧モードのようなゴールド会員向けの機能を実装
  • 都度課金にもチャレンジしてマイクロサービスを作ったが、うまく利用できなかったのでマイクロサービスの撤退
  • Live 配信リソースを効率よく利用するために Live 配信システムを刷新した
  • スペシャル TALK、スペシャル SHOP の機能を実装

こんな感じで、小さくリリースしてから、積極的に新しいことにチャレンジし、失敗しながらも前に進むために継続的に開発をしています。

※こちらの記事にスパイクに耐えるための仕組みの詳細について記載しているので、合わせて読むと分かりやすいです。
cookpadTV ライブ配信サービスの”突貫” Auto Scaling 環境構築

cookpadLive のシステム概要

クックパッドでは AWS を積極的に利用しています。

Rails や go などのアプリケーションサーバーは、社内で Amazon ECS を使って動かす環境が整っているので、 cookpadLive のアプリケーションサーバーもその仕組みを使って動かしています。( Dockerfile と jsonnet を書くと本番環境で動き始める素晴らしい仕組みです。*1
基本的な Web アプリケーションであれば上記の仕組みだけで十分なのですが、 cookpadLive のように映像を扱ったり、リアルタイムでなにかをしたりする場合には工夫が必要です。AWS にはさまざまなサービスがあり、 cookpadLive の新機能を実装するときには、まず AWS のサービスをうまく使うことで実現できないか、という観点で技術調査・設計・実装を進めています。

細かいところは省略しつつも、 cookpadLive システムの構成はだいたい以下のようになっています。

クックパッドには強力な開発基盤があり、さまざまな社内アプリケーションから利用できるように設計されています。
cookpadLive もその開発基盤の上で開発しており、特に認証基盤・決済基盤・DWH はその中でも重要な役割を担ってくれています。

次のセクションでは、AWS を使った特徴的な部分について紹介します。

cookpadLive のシステム的な特徴

以下の3つについて紹介します。

  1. Live 配信
  2. Live 配信中のメッセージ
  3. スペシャル TALK

1. Live 配信

Live 配信・アーカイブ生成などの映像を扱うために AWS メディアサービスを利用しており、その中の AWS Elemental MediaLive*2, AWS Elemental MediaStore*3, AWS Elemental MediaTailor*4 AWS Elemental MediaConvert*5 を利用しています。

ワークフロー

Live 配信は MediaLive -> MediaStore -> MediaTailor -> CloudFront のように構成しています。
MediaLive で配信スタジオからの RTMP Push を受け付けて、 MediaStore を destination に HLS 形式にエンコードしたものを出力させます。
また、CloudFront の手前に MediaTailor を挟んでおくことで、ゴールド会員限定のスペシャル TIME を実現できるようにしています。

アーカイブは MediaLive の Output にアーカイブ用のものを追加しており、 RTMP Push を受け付けた時に S3 を destination に MPEG2-TS 形式で出力し、Live 配信終了時にアーカイブ生成処理を実行します。
出力された ts ファイルは一度1つの ts ファイルに結合された後、 MediaConvert を使って HLS 形式にエンコードし、 CloudFront 経由で配信しています。

MediaTailor の部分が若干特殊になっているものの、一般的なワークフローで Live/アーカイブ配信を実現しています。

配信リソース管理のためのマイクロサービス

cookpadLive では配信リソースを管理するマイクロサービスを用意したのにはいくつか理由があり、その1つを紹介します。
※こちらの発表資料に詳しく記載しているので、合わせて読むと分かりやすいです。
cookpadLiveのライブ配信基盤 Cookpad Tech Kitchen #23

cookpadLive では、1配信1系統毎に MediaLive のインスタンスを立ち上げています。なので通常配信に加え、別カメラの映像を流すスタジオ観覧モードを追加するなど、1つの配信でも別系統の映像を配信したい場合は追加でインスタンスを立ち上げています。つまり、同じ時間帯に3つの配信あり、さらにそれぞれ通常・スタジオ観覧モードの2系統があった場合、6つの MediaLive のインスタンスが必要になります。また、それぞれの配信でリハーサル配信をする場合もあったりして、1日にいくつものインスタンスを必要とする場合があります。

MediaLive のインスタンスは作成してから数分〜数十分経過しないと利用可能にならないので、あらかじめ作成しておく必要があるのですが、作成タイミングによって MediaLive のインスタンス作成上限に引っかかったり、 Live 配信の開始時刻までに利用可能にならないリスクがあります。

リソース確保に伴うこれらの課題をマイクロサービスを用意して抽象化することで、 Live 配信に関するビジネスロジックの実装に集中できる環境を作りました。

2. Live 配信中のメッセージ

cookpadLive の開発では、コメント・ハート・スタンプなどのリアルタイムの情報をまとめてメッセージと呼んでいます。
Live 配信中にリアルタイムにサーバーからクライアントにメッセージを送信するために AWS AppSync を利用しています。

AppSync は GraphQL のインターフェースで AWS DynamoDB などのデータソースにアクセスすることができるサービスです。
GraphQL には3つのオペレーションがあり、クエリ・ミューテーション・サブスクリプションがあります。 cookpadLive のメッセージ配信は、 AppSync のサブスクリプションのオペレーションを使って実現しています。

クライアントが直接 AppSync を利用していないのにはいくつか理由があります。

まず1つ目は、送られたメッセージをそのままファンアウトさせるのではなく、アプリケーションサーバーである程度処理を加えたかったからです。
具体例を挙げると、なりすましが難しい形でユーザー属性をメッセージに付け加えたかったり、不適切なコメントをフィルタリングしたり、といった処理をしたかったからです。
AppSync の Resolver や datasource や Cognito などを工夫することで実現することも可能だとは思うのですが、システム全体の整合性を考えると現実的ではないと判断したので cookpadLive ではアプリケーションサーバーを経由して AppSync にミューテーションする方式を採用しました。

余談ですが、 AppSync はサーバーアプリケーションからオペレーションすることをあまり考慮しておらず、言語によっては SDK が用意されていません。なので cookpadLive では AppSync との通信部分を独自に実装しました。

2つ目の理由は AppSync への書き込み流量をサーバー側で制御したかったからです。
Live 配信は決まった時間にユーザーがいっきに集まりますし、配信の盛り上がりや企画次第ではハート・コメント・スタンプが連打され、リクエストのスパイクが起きやすいという性質があります。

AppSync に対するオペレーションの Rate limit も存在するのですが、例えばデータソースに DynamoDB を指定している場合はテーブルのキャパシティプランニングも合わせて必要になります。
サービスの成長や出演者の人気度の変化に柔軟に対応するためにも、アプリケーションサーバーを経由して AppSync に書き込みする構成にしています。

※こちらの発表資料に詳しく記載しているので、合わせて読むと分かりやすいです。
クックパッドの動画事業での AWS AppSync 活用事例

コメントの永続化

図をみると message サーバーから AppSync へ書き込むフローと、 S3 に書き込むフローにわかれている部分があるのですが、前者は Live 配信中に他ユーザーにファンアウトするためのフローで、後者はコメントデータの永続化のためのフローになっています。
シンプルに考えると、 AppSync のデータソースを DynamoDB にすればいいのではないか、と思われるかもしれないのですが、 負荷試験の結果 cookpadLive のコメントの流量で DynamoDB に書き込みをしようとすると期待するパフォーマンスがでないことがわかりました。
コメントデータはアーカイブの生成が完了したタイミングで準備ができていればよいので、パフォーマンスチューニングをするのではなく、多少遅延してもよいのでパフォーマンスを気にせず永続化できる仕組みを構築しました。(AppSync のデータソースは type: NONE を利用しています。)

※こちらの発表資料に詳しく記載しているので、合わせて読むと分かりやすいです。
アーカイブ配信でもライブ感を味わいたい / cookpad_tech_kitchen#23

余談ですが、この課題は AppSync のデータソースに Amazon Kinesis を指定できるようになると解決するので、是非 Kinesis をサポートしてほしいです。

3. Live 配信中の通話機能(スペシャル TALK)

スペシャル TALK は、Live 配信中にキャストと1対1で生電話ができる機能で、配信スタジオからは映像と音声の両方、ユーザーからは音声だけの通話ができます。
この機能を実現するために Amazon Chime と AWS AppSync を利用しています。

まずユーザーはスペシャル TALK への応募を行います。応募が揃ったら通話するそのユーザーに対して AppSync のサブスクリプションを使って、サーバーからアプリに対してイベントを送信します。アプリはこのイベントを受け取ると API を叩き Amazon Chime の接続情報を取得します。取得した接続情報をもとに Amazon Chime に接続し、接続が完了すると通話が開始される、というフローになります。

配信スタジオとの統合

システム構成ですがこの形に辿り着くまでに多くの議論を重ねました。
今まで開発したことないタイプの機能でしたし、既存の配信スタジオのマイク・スピーカー・カメラ・スイッチャーとどのように統合するのかであったり、 Live 配信の企画として成立させるためには通話以外にどういう機能が必要なのかを検討したりと、エンジニアチームだけではなく、撮影技術チーム・Live 配信ディレクターチームも巻き込んで今の形に仕上げました。

また、開発やデバッグにはとても苦労しました。 通話機能なので相手が居ないと正しく動いているかどうかが判断しづらく、離れたところで音楽を鳴らして擬似通話したり、ある程度完成したら複数人でデバッグしてみたりと、試行錯誤しながら開発をしました。

また、Amazon Chime は他の AWS リソースと違ってコンソールでリソースの操作ができず API でしか操作できなかったので、今どうなっているか、などの状態をパッと把握するのが難しいです。
この課題については現在も残っていて、チームメンバーと議論しながら解決に向けて進めている最中です。

余談ですが、結合テストについては、コロナ禍で全員リモートだったこともあり、実際の利用環境に近い形でテストすることができ、ユーザーの気持ちや問題に早く気づくことができました。

今後の予定

今回の記事では主にサーバーサイドの技術について触れました。cookpadLive では iOS・Android・FireTV のプラットフォームでアプリを展開しており、これらのアプリでも技術的な挑戦をしています。
アプリのチャレンジについては、次の機会で執筆しようと思いますので、乞うご期待ください。

cookpadLive では引き続き技術的なチャレンジをしていきます。
直近では API のパフォーマンス改善と、 JSON API を GraphQL に置き換えることを検討しています。そのうち情報発信していきますので、こちらも乞うご期待ください。

記事では書ききれなかったことも多く「もっと cookpadLive について知りたいよ」という方がいらっしゃいましたら、是非 @osadake212 までご連絡ください。

info.cookpad.com

参考記事

*1:ECS インフラの変遷 https://techlife.cookpad.com/entry/2021/08/05/114810

*2:ライブ動画処理サービスです。公式サイト: https://aws.amazon.com/jp/medialive/

*3:メディア向けに最適化された AWS ストレージサービスです。公式サイト: https://aws.amazon.com/jp/mediastore/

*4:動画ストリームにターゲット広告を個別に挿入できます。公式サイト https://aws.amazon.com/jp/mediatailor/

*5:ファイルベースの動画変換サービスです。 公式サイト: https://aws.amazon.com/jp/mediaconvert/

レガシーとなった TLS 1.0/1.1 廃止までの道のり

SRE 兼よろず屋の id:sora_h です。最近は本社移転プロジェクトをやっています。趣味は Web *1 です。

さて、クックパッドでは 2020 年 12 月に TLS 1.0 および TLS 1.1 (以後 "Legacy TLS") を廃止しました。

Legacy TLS は RFC 7457 でまとめられているような既知の脆弱性の存在などから、Chrome, Firefox といった主要ブラウザを含め各所でのサポートが打ち切られつつあります。また、現在では IETF においても Legacy TLS は deprecated と RFC 8996 にて宣言されました。  

クックパッドでもセキュリティ対策およびレガシーな技術と向き合う一環で廃止を進めました。我々は歴史の長いサービスも提供しているため、古い Android や Internet Explorer などからのアクセスもありましたが、トラフィック傾向や各種ベンダーによるサポート状況を見ながら慎重に進めていきました。

全サービスでの廃止にあたっては関わる人数や影響範囲も大きくなるため、丁寧に進める必要があります。それはモチベーションの整理から新しい TLS 設定の検討といった下準備、design doc の作成とステークホルダーへの提案、そして実際の廃止作業といった短くない道のりを辿りました。本稿ではこの一連のプロセスについて振り返ります。

廃止のモチベーション

まず、Legacy TLS を廃止したいモチベーションとしては下記が挙げられます。

  • セキュリティ上のリスク: Legacy TLS は RFC 7457 に挙げられているように既知の脆弱性が存在する。その上、新たに深刻な脆弱性が発見された場合のリスクは低くない。
  • コスト最適化: 一部サービスは非 SNI サポートに有償オプション *2 を必要としている。これは TLS 1.2 以上を前提とすることで、クライアントの SNI サポートも前提とできるため、Legacy TLS 無効化によりそのオプションを廃止できる。
  • 最新の技術やサービスを利用できない潜在的なリスク: TLS 1.2 以上や SNI を前提とする技術やサービスはこれから増え続けていくと考えられる。

特にセキュリティ上のリスクは現状よりも今後新たな脆弱性が発見された際のリスクを高く評価しました。2017 年に完全 HTTPS 化を行った際の理由でも触れられていますが、我々はユーザーさんにセキュアなサービスを提供したいと考えています。

その一環として、2014 年に公開された SSL 3.0 の POODLE 攻撃に対する対応を振り返ります。クックパッドでは脆弱性情報の公開後、速やかに SSL 3.0 を無効化しました。しかし、当時のトラフィック状況では唐突にサポートを打ち切るには時期尚早で、ユーザーさんへの影響が想定以上に発生してしまいました。そのため、再度有効化の上あらためて無効化に向けて動く対応を行ったことがあります。

この POODLE の教訓から、今後 Legacy TLS に重大な脆弱性が新たに見つかった時、我々は迅速にユーザー影響なしに無効化できるのか、という疑問が発生しました。セキュアなサービスを提供し続けることを考えれば無効化はせざるを得ませんが、一方で脆弱性の対応により突然クックパッドのサービスを利用できなくなってしまうユーザーさんの発生も避けたいです。そのため、早期に準備して Legacy TLS の廃止を進めるべきだと考えました。  

また、最新の技術やサービスが利用できないことも大きな足枷となります。特にクックパッドのグローバル向けサービスでは Fastly をサイト全体の CDN として利用しており、Fastly がサービス全体での Legacy TLS 廃止を進めていたこと、また TLS 1.3 と Legacy TLS が排他だったのは大きな決め手でした *3

そして実トラフィックを確認すると、Legacy TLS の利用率は 2017 年で 3% だったところ廃止を提案した 2020 年 8 月頃では 0.5% まで減少していました。

また、同じ頃にクライアント (ブラウザ) でも Legacy TLS のサポートを打ち切る計画 *4 があり、徐々に無効化され始めていました *5。これらも廃止を後押しする理由となります。

廃止のための準備

まずは前節で述べた理由を元に社内ブログへ所信表明を投稿しました。廃止のためにはエンジニアリングチームを含めビジネスサイドのステークホルダーの説得、またサポートチームの協力を得る必要があります。まずはやっていき宣言を発出した後、準備を整えて協力や意思決定を貰いに行くことにします。

トラフィック状況のモニタツールを用意

一言で廃止すると言っても、クックパッドは複数のサービスを提供していて、システムはその数以上に存在します。

Legacy TLS の利用状況についても、 Internet Explorer や古い Android からのトラフィックが存在しないような歴史の浅いサービスから、そうではないサービス、はたまた toB 向けに提供していて Chrome, Firefox, Edge といったモダンブラウザの割合が少ないサービスまで存在し、全体で一気に廃止するというのは良い判断ではありません。

そこでまずは SRE で状況を把握するため、また最終的にはエンジニアリングチームが各々判断できるようにするため、各システムのトラフィック状況をモニタするための社内ツール TLSitrep *6 を作成しました。

https://twitter.com/sora_h/status/1285189591096410112  TLSitrep のスクリーンショット

TLSitrep では週次で ELB ログを集計し、その結果を閲覧できるようになっています。これはほぼ全てのシステムが AWS 上で稼動しロードバランサーとして ELB を利用していて、また全 ELB のアクセスログを Amazon Athena でクエリできるようテーブルとパーティションを自動で維持してくれる社内システムが存在しており、サクッと実装することができて便利でした。

新しい TLS 設定ポリシーと移行ガイドの作成

次に、廃止後に設定する cipher suite の方針などを決定しました。TLS 1.2/1.3 で利用することができる cipher suite から、Mozilla の Server Side TLS ポリシー, CRYPTREC GL-3001-3.0.1 *7, NIST SP 800-52 Rev. 2 等を参考に検討します。具体的には下記のように定義しました。

  • Modern TLS: TLS 1.2+, AEAD (AES-GCM, ChaCha20-Poly1305), Forward Secrecy (TLS 1.2: ECDHE, 1.3: any)
  • Moderate TLS: TLS 1.2, 非 AEAD, Forward Secrecy
  • Legacy TLS: (Insecure TLS を除く) TLS 1.0/1.1, 非 AEAD, 非 Forward Secrecy
  • Insecure TLS: SSL 3.0 以下 or AES/ChaCha20-Poly1305以外 or AEAD/HMAC-SHA256,384以外

ただし、実際には ALB では規定のポリシーの中から選択することになる他、CloudFront など CDN でも同様となるため、この基準をそれにマッピングすると下記の通りです:

  • Modern: ELBSecurityPolicy-FS-1-2-Res-2019-08
  • Moderate (推奨): ELBSecurityPolicy-TLS-1-2-2017-01
  • Legacy: ELBSecurityPolicy-2016-08

TLSitrep においてもこの基準で集計しています。そして、変更した際のインパクトを把握しつつ移行先のポリシーがサジェストされるようにしました:

f:id:sora_h:20210819052222p:plain
図: 表示される移行先ポリシーの提案
f:id:sora_h:20210819052420p:plain
図: Cipher Suite 単位のアクセス傾向

また、ポリシーの策定にあたっては実際のクライアントからの接続可否と、影響の大きさを調査するため、 testssl.sh を用いて実際に各種 security policy を設定した ALB にテストを行い、念の為に実際の古い Windows や IE からもテストを行います。

実際のところ、ALB デフォルトの ELBSecurityPolicy-2016-08 から ELBSecurityPolicy-TLS-1-2-2017-01 へ移行した場合のインパクトは下記の通りとなりました:

  • Windows: 2009 年以前の PC は接続不可 (Windows Vista 以前)。
  • macOS: 2013 年 10 月以前は接続不可 (macOS 10.8 以前)。ただし同年リリースの Chrome/Firefox から TLS 1.2 が有効なので 10.8 以前でも OK の場合あり。
  • Android: 2013 年以前は接続不可 (Android 4.4.2 以前)。
  • iOS: 2011 年以前は接続不可 (iOS 5)。

iOS/Android 向けアプリ版ともに影響を受ける端末はすでにサポート対象外ですが、やはり実際のトラフィックでは初期の Android 4.x 系や古い Internet Explorer からのアクセスが影響範囲として目立つことが分かりました *8

また、前述のようにシステムによって傾向が違うことも明らかになりました。例えば cookpad storeTV の関連システムでは販売店舗等からのアクセスが多いためか、古い Internet Explorer や OS など Legacy TLS や非 FS/AEAD cipher suite の利用率が他と比べて目立っていたなどが挙げられます。

説明と説得のための design doc の作成

各所へ説明するため、モチベーションや移行手段、新しい TLS 設定について記載した design doc を作成しました *9。この時点で、社内のセキュリティチームからもレビューを貰っています。

筆が乗ってしっかりした文章になってしまった *10 ため、最終的には事業部から「ここまでやってもらったらやるだけですね」などと評価されたようで、ちゃんと書いてよかったなと思っています。

また、国内他社事例などもできる限り調査して現状を記載しました。その際、 Yahoo! JAPAN さんの廃止などはかなり参考にさせていただきました。

f:id:sora_h:20210819052657p:plain
図: 用意した design doc の ToC と冒頭部分

アナウンスと説得

前節で用意した design doc を元に筆者が主体的にエンジニアリングチームやステークホルダーへ提案、意思決定を仰ぎました。

幸いにも Legacy TLS のトラフィックレートが十分低下しており、そして design doc でリスクと利点を説明、その他の準備でサービスチームの作業も最低限にしたことからスムーズに受け入れてもらえました。また、自分が動かずとも doc を抱えて他チームのマネージャが意思決定者まで話を持っていってくれる、事業側のエンジニアリングチームが実際の影響ユーザー数の算出を行ってくれるといった協力を貰う事ができたので、備えて良かったなと感じます。

cookpad.com ドメインでの廃止作業

無事に廃止が決まったため、残るは設定の移行作業となります。ここまでくれば技術的にはやるだけです。

基本的に design doc 上で合意済みの対応方針と移行方法をベースに全社へアナウンスをし、各チームに対応を一任しました。また、新規システムがデフォルトで利用する TLS 設定もこのタイミングで切り替えました。

その結果、速やかに無効化されていったようです (筆者はそれを毎週確認しているだけでした)。TLSitrep が変更先の設定を提案するようにした他、ポリシーも分かりやすく提示したため、変更作業としてはアナウンス通りに ALB 設定変更のデプロイで済むことがほとんどでした。前述のように廃止については受け入れられていたため、スムーズに廃止が進行しました。

そして、関わるチーム数が多い cookpad.com ドメインおよび PC およびスマートフォンブラウザ版クックパッド (以後 "レシピサービス") については筆者が実作業も担当しました。この節では以降、レシピサービスにおける Legacy TLS 廃止について説明します。

対象のユーザーさんへ警告を表示するための仕組み

まず、レシピサービスが他サービスと比較して大きなユーザー影響が見込まれたため、ユーザーさんへの告知が必要でした。

クックパッドのサービスは全て常時 HTTPS となっており、 Legacy TLS 廃止後は非対応の環境から完全に接続できなくなります。そのため、実際に接続できなくなるユーザーさんに対して、サポート外になる警告を表示しました。

具体的には、Legacy TLS 廃止後の SecurityPolicy を設定した ALB と CloudFront distribution を用意して、CloudFront への接続に失敗した場合 ALB へ接続試行、その結果を元に警告を表示する JavaScript を準備しました。

 

f:id:sora_h:20210819052810p:plain
図: 警告のイメージ (実際にリリースした際は廃止日時が記載されていました)

この検証作業や前述の SecurityPolicy 接続テストの一環で Windows XP/IE6 や Vista/IE7 の環境も構築してみたんですが、Windows Update さえ実施できず本当に何もできない感じだったのが記憶に残っています。最新のパッチまで当ててたらもう少し何か出来たりするのかな.........。

サポートチームとの連携

以上の変更を抱えてサポートチームへも相談し、文言や問合せ等の対応方針を検討しました。ここでも最初の design doc で背景を理解してもらうのがスムーズに進み、やはり文章は便利だなという気持ちが強まります。

グローバルチームとの連携

cookpad.com ドメインはクックパッドのグローバル向けサービスと共有しているため、このドメインの Legacy TLS 廃止にあたってはグローバル側のサービスチームとも連携が必要でした。

こちらは歴史も浅く Web フロントエンドが古いプラットフォームでそもそも動かなかったりする他、iOS/Android アプリについても日本国内版に比べ古い OS のサポートを早期に打ち切っている関係で背景の説明をするまでもなく合意を得ることができました。

なお、日本国外からの cookpad.com ドメインへのアクセスは CDN として Fastly を経由するようになっているため、Fastly 側での設定変更を行いました。Fastly は CloudFront ほど柔軟な設定はなく、基本的に cipher suite についてはお任せとなります。TLS 1.0/1.1 を無効化し 1.2/1.3 のみとなった設定を準備しました。

Legacy TLS の無効化

告知から一定期間置いて、順次 Legacy TLS を無効化する設定をデプロイしました。念の為ブラウザ版で表示している告知へ誘導するため  iOS/Android 版クックパッドアプリで利用されている API から先に無効化を進めましたが、幸い大きな混乱はなく廃止完了となりました。

cookpad.com ドメインにおける廃止が終わる頃には、他システムでも既に廃止が完了していたため、cookpad.com での廃止を以てクックパッドが提供するほぼ全てのサービス *11で Legacy TLS の廃止が完了したことになります。

まとめ

f:id:sora_h:20210819052858p:plain
図: Qualys SSL Labs にて A を獲得することができた様子 
  廃止から半年以上経過しますが、社内外どちらも大きな混乱はありませんでした。ビジネスサイド含め複数のステークホルダーやチームと連携する必要がありましたが、丁寧にリスクを説明できるようにし、サービスチームの対応コストも下げることによってスムーズに受け入れてもらうことができました。また、ユーザーさんからの反応も想定より小さいものとなったようです。

そして、このプロジェクトで用意した TLSitrep については維持し、ALB の SecurityPolicy が更新された場合速やかに新しい推奨設定を検討できるようにしています。直近では ALB の TLS 1.3 サポート *12 、非 AEAD/FS を許容する TLS 1.2 only の policy が来ないかな、などど思いながら過ごしています。

最後に繰り返しとなりますが、レガシーな環境や設定の存在はサービス提供者とユーザーさん双方のリスクとなるだけでなく新しい技術の足枷となりかねません。クックパッドは先に述べたように歴史の長いサービスで、サーバーサイドからクライアントサイドまでそれが起きやすい環境です。そんなクックパッドではレガシーな環境に立ち向かいながら新しい技術や industry standard へ積極的に追従する仲間を募集しています。 https://cookpad.jobs

謝辞

本プロジェクトは複数のチームの協力によって成り立ちました。また、特に古い環境向けの JavaScript をサクッと書いてくれた id:hokaccha 、TLS 設定や対応方針のレビューをしてくれたセキュリティチームの id:kani_b と  id:mztnex (@m_mizutani) にこの場を借りて感謝します。

*1:I-DIntent to ship, crbug.com, chromium-review, standards-positions などを眺めること

*2:例: CloudFront の dedicated IP address オプション、プロジェクト当時存在した Fastly の shared/dedicated certificate オプション

*3:Fastly は 2016 年からサービス全体での Legacy TLS 廃止を進めていますが、現時点でもまだ既存のユーザーに対する完全な廃止はされていないようです。

*4:打ち切りの宣言: Chrome, Firefox, IE/Edge, Safari

*5:打ち切りの様子: Chrome, Firefox

*6:TLS + Sitrep; situation report

*7:2020年7月公開、タイムリーにリリースされていて便利

*8:プロジェクト当時、ブラウザ版でも Android 4.x のサポートは終了しているが Internet Explorer は引続きサポートしている状況でした

*9:実際には平行して書いていました

*10:Google Docs A4 で 13 ページ、15,000 文字 / TLS 設定の方針なども記載したため、実際にステークホルダーが読むべきところはその半分くらい

*11:本稿執筆現在、フィーチャフォン向けサービスでの Legacy TLS 提供が残っています...。

*12:CloudFront には来たのに無い...

クックパッドマートにおける宣言的ラベル生成

クックパッドマート流通基盤アプリケーション開発グループのオサ(@s_osa_)です。

少し前にクックパッドマートのラベル生成の仕組みを刷新したので紹介します。

クックパッドマートにおけるラベル

クックパッドマートは「美味しい食材を生産者や市場から直接ユーザーにお届けする」サービスです。

食材をユーザーのもとまで届けるためには流通の仕組みが欠かせません。クックパッドマートでは「1品から送料無料」をはじめとするサービスを実現するために独自の流通網を構築しています。

そんなクックパッドマートですが、流通の現場で実際に商品を運ぶドライバーに対しては、主に2つの手段で情報を提供しています。

ひとつはアプリであり、スマホ向けアプリの画面を通してその日の配送計画を伝えたり配送状況の追跡をおこなったりしています。

そして、もうひとつがラベルです。ラベルは何種類かありますが、たとえば商品に貼り付ける「商品ラベル」は以下のようなものです。

f:id:s_osa:20210817115720j:plain
商品ラベルの様子

流通の現場では多くの物理的な「モノ」を扱う必要がありますが、目の前にあるモノとアプリの画面を見比べながら配送業務をおこなうことは業務効率の観点から現実的ではありません。そこで、目の前のモノについての情報は印刷したラベルシールをモノ自体に貼り付けることによって伝えています。

ラベルとアプリという2種類の情報伝達手段ですが、商品名など目の前のモノに紐付く情報はラベル、ルート情報などの俯瞰的・概念的な情報はアプリ、という適材適所の使い分けをしています。ラベルとアプリの両輪による情報提供があってクックパッドマートの流通オペレーションは成り立っています。

既存のラベル生成が抱えていた問題

そんなラベルですが、重要なだけあってサービスの初期から使われています。一方、サービスを取り巻く環境が当時とは変わってきたこともあり、いくつかつらい点を抱えるようになっていました。

素朴な実装

ラベルプリンタへ送るデータは印字するテキストのほかに制御用のコマンドを含むバイナリなのですが、そのバイナリを素朴で手続き的な文字列操作で生成していました。単純化していますが、イメージとしては以下のようなものです。

binary = ""
binary << "#{item.name}\n"
binary << begin_bold_command
binary << "#{user.name}\n"
binary << end_bold_command

実際には、コードや DB では UTF-8 で扱っている文字列をプリンタが要求する Shift_JIS に変更するなどの処理も必要です。当初は素朴なラベル生成コードでしたが、サービスが成長するにつれて要求が複雑になり、少しずつ見通しの悪いコードになっていきました。

分離されていないデータ生成と印刷

ラベル生成と印刷はバッチや非同期ジョブなどでおこなっていたのですが、そのバッチやジョブの中にラベル生成のロジックがベタ書きされているケースがありました。

ベタ書きされていることによってテストを書きにくいほか、他の箇所で同じラベル生成ロジックを再利用することが困難になっていました。

複数実装の維持

当初はラベルプリンタによるラベル印刷だけをおこなっていましたが、同じ内容を通常のプリンタでも出力したいという要求が出てきました*1

しかし、上記の素朴な実装で生成したバイナリはラベルプリンタ用のデータであって通常のプリンタで印刷できるものではありません。そこで、ラベルと同じ内容を含む HTML を生成してから PDF に変換するという方法を取ることになりましたが、そのための HTML はラベルプリンタ用バイナリとは完全に別の仕組みで生成されることになりました。

結果として、ラベル生成ロジックに変更を加える際には2種類のラベル生成ロジックを不整合なく同時に変更する必要が生まれました。

変更しにくいラベル

流通オペレーションはラベルの存在を前提にして組まれているため、ラベルの生成ができない状況が発生すると流通が止まってしまいます。また、ラベルはその物理的な性質から、一度印刷されて流通に乗ってしまうと修正が(現実的には)不可能になります。

つまり、ラベル生成には不具合が発生した際の影響が大きい上にリカバリが難しいという性質があります。

一方、冒頭でも述べたとおり、クックパッドマートの流通においてラベルによる情報提供は非常に重要です。

これらの性質が複合した結果、ラベルは重要であるにもかかわらず、改善サイクルを回しにくいという状態になってしまっていました。

解決方法

方針

これまでに述べた問題を解決するために、以下の設計目標を立てました。

  • 見通しが良くメンテナンスしやすいラベル構造の定義
  • ラベル生成と印刷の分離
  • 単一実装によるラベルプリンタ用バイナリと HTML 両方の生成

これらの設計目標を満たすため、ラベル構造を表現する木構造のオブジェクトを組み立てて、そのオブジェクトからバイナリや HTML の表現を生成する方針にしました。

ここから先は実装の話なので、サンプルコードを中心に説明します。ただし、わかりやすさのために多少簡略化しています。

ラベル要素の実装

はじめに、ラベルに含まれる要素を表現するためのクラスを定義します。HTML におけるタグを思い浮べてもらうのがわかりやすいと思います*2

また、必要なクラスを定義していく際、すべてのクラスに to_binaryto_html メソッドを持たせます。

たとえば、ラベル中の文字列を表わす要素 Text だとこんな感じになります。下のサンプルコードにあるエンコーディングの変換や HTML のエスケープ処理のほか、実際には UTF-8 から Shift_JIS に変換できない文字の対処などもこのクラスで実行しています。

class LabelElement::Text << LabelElement::Base
  # @param text [String] UTF-8
  def initialize(text)
    @text = text
  end
  
  # @return [String] Binary
  def to_binary
    @text.encode(Encoding::Shift_JIS)
  end
  
  # @return [String] HTML string in UTF-8
  def to_html
    escaped_text = CGI.escapeHTML(@text)
    %Q|<span class="label-element__text">#{escaped_text}</span>|
  end
end 

また、太字を表わす要素 Bold などは基本的に木構造の内部ノードになるので、太字にする対象の子要素を持てるようにします。

class LabelElement::Bold << LabelElement::Base
  attr_reader :children

  def initialize
    @children = []
  end

  # @return [String] Binary
  def to_binary
    [
      begin_bold_command,
      @children.map(&:to_binary).join,
      end_bold_command,
    ].join
  end

  # @return [String] HTML string in UTF-8
  def to_html
    %Q|<span class="label-element__bold">#{@children.map(&:to_html).join}</span>|
  end

  # @param element [LabelElement::Base]
  def <<(element)
    @children << element
  end
end

これらのクラスのほか、クックパッドマートのラベルで利用している要素をそれぞれクラスとして定義しました。具体的には、改行、フォントサイズ変更、文字寄せ(左・中央・右)、上線、下線、白黒反転、QR コード、ラベルのカット、ラベルシートそのものなどです。

また、上のサンプルコードでは省略していますが、オブジェクト同士の値としての等価性を判定する == メソッドなども適切に定義します。

これらのクラスが準備できると以下のような形式でラベル構造を定義できるようになります。to_binary, to_html それぞれのメソッドが木構造の根ノードから葉ノードまで順に呼び出されていくことによって、最終的にバイナリ・HTML それぞれの表現が得られます。

label = LabelElement::Sheet.new
label.children << LabelElement::Text.new(item.name)
label.children << LabelElement::NewLine.new

bold = LabelElement::Bold.new
bold.children << LabelElement::Text.new("#{user.name}")
bold.children << LabelElement::NewLine.new

label.children << bold

label.to_binary # => ラベルのバイナリ表現
label.to_html # => ラベルの HTML 表現

ラベル構造の定義を簡単にする

上に書いたラベル構造の定義はお世辞にも読み書きしやすいものではないので、もう少し人間にやさしいインターフェイスを定義します。React.createElement() に対する JSX のようなイメージです。

今回の実装は Ruby でおこなっているのでブロックを用いて以下のように定義しました。jbuilder などと似た、Ruby ではわりとよく見る記法になっています。

label = LabelBuilder.new.build do |l|
  l.text_line(item.name)
  l.bold do
    l.text_line("#{user.name}")
  end
end

label.to_binary # => ラベルのバイナリ表現
label.to_html # => ラベルの HTML 表現  

要素を直接作るスタイルと比べて、人間が読み書きしやすいインターフェイスになったと思います。

ドメインに基づいたラベルを定義する

ここまでで汎用的なラベル生成の仕組みができました。

実際のアプリケーションではドメイン内のモデルからラベルを作りたいことがほとんどなので、そのためのテンプレートを作成します。テンプレートの中身は上のブロックを用いたインターフェイスで書きます。

商品ラベルであれば主に OrderItem というクラスのインスタンスから生成されるので、テンプレートを使って以下のように生成できるようにします。

template = LabelTemplate::OrderItemLabel.new(order_item)
label = template.build_label

label.to_binary # => ラベルのバイナリ表現
label.to_html # => ラベルの HTML 表現  

このテンプレートができたことにより、ラベル生成ロジックが一元化され、アプリケーションの任意の箇所で簡単にラベルデータを生成できるようになります。また、それぞれのラベルの構造を知りたいときはブロック記法で把握できるようになっています。

効果

上記の実装により以下のような効果が得られました。

宣言的に記述されたラベル構造

ブロック記法を用いてラベルのテンプレートを定義したことにより、文字列とコマンドの羅列ではなく、階層化された構造としてラベルを読み書きできるようになりました。

また、ラベル構造を記述する際には「どういう内容をどういう装飾で表示するか」を記述するだけで良くなり、そのデータを「どのように生成するか」は考える必要がなくなりました。

ラベル生成と印刷の完全な分離

ラベル生成のロジックをテンプレートに切り出して宣言したことにより、ラベル生成は印刷から完全に分離され、アプリケーションの任意の箇所で再利用できるようになりました。

単一実装による複数表現の生成

単一のラベル構造から to_binary, to_html という2つのメソッドを用いて2種類の表現を生成できるようになりました。さらに、他の表現形式としてテストや簡易的なログ用途のプレーンテキスト表現が欲しくなったのですが、各ラベル要素に to_plain_text というメソッドを追加することで簡単に実現できています。

また、すべてのラベルに無料で HTML 表現がついてくるようになったため、管理画面上で印刷プレビューを表示したり印刷ログを HTML でも保存・表示したりといったことが簡単にできるようになりました。これは開発時の簡易的なチェックやデバッグに便利なだけでなく、流通オペレーションやカスタマーサポートなどの運用・調査でも参照する機会が多く、非常に役立っています。

f:id:s_osa:20210817170236p:plain:w300
プレビューの様子

変更しやすいラベル

個々のラベルがテンプレートに切り出されたことによりテストがしやすくなったほか、ラベル生成の仕組み自体を具体的なラベルから切り離してテストすることが可能になりました。

さらに、ラベル生成と印刷が完全に分離されたことにより、今後印刷する予定のラベル生成を dry run で走らせることが可能になりました。dry run の実現によって、万一変更内容に問題があった際にも実際に問題が起こって流通が止まる前に対処できるようになり、ラベル変更にともなうリスクが大きく下がったため、改善サイクルを回しやすくなりました。

厳密な比較ではなく参考程度の情報にはなりますが、刷新の前後で pull request の数を調べてみたところ、同じ期間あたりの pull request 数が2倍以上になっています。

おわりに

サービス開発初期のすべてが不確かな状況で書かれたラベル生成コードを現在の状況に合わせて書き直したという話を書いてきました。当初はこのエントリのサンプルコードにあたる部分を OSS として公開しようと考えていたのですが、絶妙に業務ロジックが絡みついていて公開できる形に抽象化できなかったのでサンプルコードでの紹介になりました。

流通という領域ではここまで書いてきたようなコードによる問題解決のほか、実際にモノを動かす現場のオペレーションも非常に重要です。事実、今回のラベル生成刷新も現場オペレーションの改善サイクルを高速化するための刷新です。

現場のオペレーションは、良い仕組みを考えたと思っても実際には実行が困難だったり、そうでなくても人間は間違えたりします。そういったソフトウェアだけに留まらない問題に対して、ソフトウェアを軸に挑んでいくのは困難であるとともに挑戦的で楽しいことだと感じています。

流通という裏側の仕組みはイメージしにくいところも多いと思いますが、少しでも興味が湧いた方がいたらご連絡ください。採用サイトからの正規ルートでももちろん良いですし、@s_osa_ まで雑に DM していただくなどでも大丈夫です。よろしくお願いします。

info.cookpad.com

*1:万一ラベルプリンタが故障しても出荷・流通が止まらないようにしたいというサービス可用性の観点のほか、販売者数拡大のためにラベルプリンタなしでの出店を可能にしたいといった動機が背景です。

*2:厳密には DOM element のほうがメタファーとして適切だと思います。

/* */ @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;*/ /*}*/