くらしのマーケット開発ブログ

「くらしのマーケット」を運営する、みんなのマーケット株式会社のテックブログ。積極採用中です。

ISUCONの過去問にチャレンジしてみた: Part-1

こんにちは!バックエンドエンジニアのカーキです。

今年の9月に初めてISUCONに挑戦してみたのですが、予選で手も足も出なくて悔しい思いをしました。問題は面白かったので、同じ問題をもう一度本気で解いてみました。

ISUCONとは

ISUCONは8時間で渡されたWebアプリケーションのボトルネックを解消し、パフォーマンスを上げるコンテストです。評価はISUCON側で用意してあるベンチマーカーを実行してから出る最終スコアでされます。

環境準備

ISUCONはGo, Rust, Pythonなどいろんな言語で挑戦できますが、仕事で長くNode.jsを使ってるので言語はJavaScript (Node.js)にしました。

もらえるソースコードには監視ツールがついてないので、ボトルネックを可視化するために、以下のツールをアプリケーションに組みました:

ログ分析ツール: Goaccess

Webアプリケーションを何もいじってない状態でGoaccessでベンチマーク時のリクエストを分析してみたら、こんなレポートが出ました。 f:id:curama-tech:20210922155044p:plain

APMツール: Elastic APM

Elastic APMを使うことでエンドポイント毎にかかってる時間だけではなく、繋がってるシステム全ての細かいtraceまで見れてとても便利でした。 f:id:curama-tech:20210922165441p:plain

試してみたこと

GoaccessElastic apm の結果の基に以下の改善点を試してみました

1. Staticファイルの配信

現行のコードではExpressからstaticファイル (css, jsなど)が配信されています。これによってExpressに無駄に負荷がかかってたので、staticファイルをNginxから配信するようにしました。

location /assets/ {
    access_log  /var/log/nginx/static-access.log;
    error_log  /var/log/nginx/static-error.log;
    root /static/;
}

2. テーブルにIndexの追加

SQLでSELECTしてるクエリをみてみたら絞り込みやソート条件になってるカラムにindexが貼られてなさそうだったので、DB初期化スクリプトにindex作成のクエリを追加しました。

3. INSERTの効率化

椅子の状態を大量にPOSTする /api/condition/:椅子のID APIではリクエストのbodyに複数件のisu_conditionが入っていて、現行のコードではloopで一つずつデータベースにINSERTするようになっています。ここをバルクINSERTに変えたら効率よくINSERTができ、スコアが改善しました。

4. Database設計の見直し

今回の課題では登録済みの椅子が自分の状態 (壊れてるかどうか、汚れてるかどうか、座られてるかどうかなど) を定期的にWebアプリケーションにPOSTする流れになっていて、当然このPOSTのエンドポイントへのリクエストが多いのでボトルネックになりがちです。既存のコードだとPOSTされたデータが以下のテーブルに入ります

CREATE TABLE `isu_condition` (
  `id` bigint AUTO_INCREMENT,
  `jia_isu_uuid` CHAR(36) NOT NULL,
  `timestamp` DATETIME NOT NULL,
  `is_sitting` TINYINT(1) NOT NULL,
  `condition` VARCHAR(255) NOT NULL,
  `message` VARCHAR(255) NOT NULL,
  `created_at` DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY(`id`)
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4;

このテーブルはINSERT onlyなので、椅子の新しい状態のデータがどんどんINSERTされ続ける設計になっていて膨大になりがちです。INSERT only自体は問題がないんですが、別の/api/trendというAPIでは各椅子の最新状態が必要になり、リアルタイムで isu_condition テーブルをクエリして椅子毎の最新の状態を取得にはすごくコストがかかります。また、trend 取得時に isu_conditioncondition カラムのデータからアラートのステータスを毎回作成してるところも効率が悪いです。

改善1: isu_condition テーブルのリファクタリング

/api/trend では condition の値を毎回サーバー側で is_dirty, is_overweight, is_brokencondition_level にparseしてるので、取得時のコストを下げるために、INSERTの時点でそれぞれの値をテーブルに入れるようにしました。

新しいテーブル設計は以下のようにしました:

CREATE TABLE `isu_condition` (
  `id` bigint AUTO_INCREMENT,
  `jia_isu_uuid` CHAR(36) NOT NULL,
  `timestamp` DATETIME NOT NULL,
  `is_sitting` TINYINT(1) NOT NULL,
  `condition` VARCHAR(255) NOT NULL,
  `is_dirty` BOOLEAN,
  `is_overweight` BOOLEAN,
  `is_broken` BOOLEAN,
  `condition_level` VARCHAR(255),
  `message` VARCHAR(255) NOT NULL,
  `created_at` DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY(`id`)
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4;

改善2: 椅子毎の最新の状態を持つテーブルをべつで用意 isu_condition から毎回最新の状態を取得するのは効率が悪いので最新の状態のみを持つ latest_isu_condition という別のテーブルを用意し、isu_conditionINSERT が走った時に毎回MySQLのトリガーでこの新しいテーブルにUPSERTするようにして、latest_isu_condition には常に最新の状態が反映されるようにしました。 また、/api/trend で毎回 isu テーブルとジョインしてnamecharacter を取得していたので、latest_isu_condition に最初から椅子のnamecharacterも入るようにして効率を上げました。

CREATE TABLE `latest_isu_condition` (
  `isu_id` bigint,
  `isu_character` VARCHAR(255),
  `jia_isu_uuid` CHAR(36) NOT NULL,
  `timestamp` DATETIME NOT NULL,
  `is_sitting` TINYINT(1) NOT NULL,
  `condition` VARCHAR(255) NOT NULL,
  `is_dirty` BOOLEAN,
  `is_overweight` BOOLEAN,
  `is_broken` BOOLEAN,
  `condition_level` VARCHAR(10),
  `message` VARCHAR(255) NOT NULL,
  `created_at` DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY(`jia_isu_uuid`)
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4;

5. n+1 問題

現行の GET /api/isu エンドポイントで椅子一覧を取得してからloopで各椅子の最新の isu_condition を取得するようになっていて、 n+1 問題があります。 例:

const [isuList] = await db.query<Isu[]>(
      "SELECT * FROM `isu` WHERE `jia_user_id` = ? ORDER BY `id` DESC",
      [jiaUserId]
    );
    const responseList: Array<GetIsuListResponse> = [];
    for (const isu of isuList) {
      let foundLastCondition = true;
      const [[lastCondition]] = await db.query<IsuCondition[]>(
        "SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = ? ORDER BY `timestamp` DESC LIMIT 1",
        [isu.jia_isu_uuid]
      );

ここは JOIN + latest_isu_condition テーブルを使うことで n+1 問題を解消し、パフォーマンスをかなり上げることができました。

6. Nodeアプリケーションのクラスター化

Node.jsのinstanceはシングルスレッドで動くのでサーバーがマルチコアだとしてもそのままでは最大限に力が発揮できません。そこで、Node.jsのClusterモジュールを使うことで、複数の子processでNode.jsを動かすことができ、マルチコアなサーバーの力を最大限に引き出すことができそうだったので cluster モジュールを使ってみたらさらにスコアを上げることができました。

7. アイコン配信の効率化

Goaccess の分析結果をみたら icon の取得にも数百ms時間かかってそうだったので改善してみました。 現行のコードだとDBに画像をBlobとして保存してるので、DBに無駄に負荷がかかってる状態だったのでiconもNginxから配信するように設計し直しました。

アイコンのファイルを椅子とユーザーIDの組み合わせでファイルシステムに保存するようにして、Expressからではなく、Nginxから配信するようにしたらかなり速度が上がりました。icon取得の認証にはNginxのauth_request機能を初めて触ってみたんですが、かなり役に立ちました。

結果

重たいエンドポイント一覧 (降順)

BEFORE f:id:curama-tech:20210922165112p:plain

AFTER

f:id:curama-tech:20210922165230p:plain /post/initialize はベンチマーク時の初期化のスクリプトで、スコアに影響しないエンドポイントです

エンドポイント毎: /api/isu/<椅子のID>

BEFORE

f:id:curama-tech:20210922165351p:plain

AFTER

f:id:curama-tech:20210922165404p:plain

エンドポイント毎: /api/trend

BEFORE

f:id:curama-tech:20210922165441p:plain

AFTER

f:id:curama-tech:20210922181709p:plain

次に試したいこと

まだアイディアベースですが、次に以下の改善策を試してみようと思っています

  • Endpoint毎の細かい改善
  • Nginxでロードバランス
  • Session storeをRedisにする
  • Queueの導入し、非同期で実行していいものはQueueに入れて、コンシューマーで実行するようにする
  • Kafka Streamsを使ってisu_condition のストリームを作ってTrendやグラフデータの作成を楽にする

まとめ

ISUCONに参加して何もできなかったことで、色々勉強し直す気になり、いい刺激を受けました。くらしのマーケットのシステムにも適用できる部分が沢山あったので、おかげで日々の仕事でもボトルネックを意識するようになり、参加してかなり良かったと思いました。今回のアプリケーションにはまだ見えてない改善点がいっぱいあると思うので継続して改善してみます!

新卒エンジニア向けお仕事紹介資料を公開しました

くらしのマーケットを支えるエンジニアのお仕事

こんにちは。みんなのマーケットでCTOをしている戸澤です。

みんなのマーケットでは、エンジニアの新卒採用を通年で行っています。

新卒の方はコードを書く以外の業務を、入社前の時点で想像することは難しいです。 チーム開発、特にPM、デザイナーなど様々な職種の人と協働する点や、継続的な機能改善などは学習の場では経験できず、業務ではじめての経験になることがほとんどだと思います。

このお仕事紹介資料はコードを書く以外の業務について入社前に理解を深めることを目的に、職種紹介、開発案件に対してチームでどう動くかなどを紹介したものです。

続きを読む

時代に先駆け、EV(電気自動車)コンセント設置カテゴリを追加したお話

f:id:curama-tech:20210331200737p:plain
EV(電気自動車)コンセント設置イメージ

こんにちは。 みんなのマーケットでカテゴリーマネージャーをしている工藤です。

当社が運営しているサービス「くらしのマーケット」では、常に人に必要とされているサービスの模索、展開を行っています。
今や生活には欠かせない「自動車」関連のカテゴリを昨年リリースし、ユーザーの皆様の役に立つサービスの展開を続けています。

僕自身、週末は子供と一緒にアクティブに行動するタイプで、自家用車(愛車のVOXY)で高速道路もよく利用します。
もちろん、ソーシャルディスタンスを保ちながら!

高速道路のサービスエリアや道の駅などで、ここ数年やたら目にするようになったEV(電気自動車)の充電器。

何か聞いたことはあるけど、実際の市場ってどうなんだろう?
何か困ってる人のために展開すべきサービスってあるのかな?

っていう好奇心から、調査を行うことにしました。

現状の電気自動車の普及率がどのくらいなのか調べる

2020年の電気自動車普及率 約17.3%
電気自動車の登録数 約940万台
普通自動車の登録数 約4,500万台
引用
一般財団法人 自動車検査登録情報協会 わが国に自動車保有傾向

まだまだ普通自動車の方が主力ですね。
しかし、小池東京都知事が2030年脱ガソリン車100%する方針を打ち出していることはニュースで知っていました。
(後に菅総理も2035年に新車全て電動車の実現を目指すと発表)

そこで年間の自動車登録台数と、過去の自動車登録数を元に2035年の電気自動車普及割合を算出したところ、普通自動車よりも電気自動車の方が多くなる結果になりました。

電気自動車の充電スポット数を調べる

2020年の電気自動車充電スポット割合 ※ 約37.5%
電気自動車充電スポット数 約18,000箇所
ガソリンスタンド数 約30,000箇所
引用
電気自動車充電スポット数:ゼンリン社調べ
ガソリンスタンド数:経済産業省 資源エネルギー庁 資源・燃料部石油流通課 揮発油販売業者数及び給油所数の推移(登録ベース)

※:燃料補給(ガソリンスタンド+電気自動車充電スポット)に対する、電気自動車充電スポット数の割合

ちなみに、ガソリンスタンド数のピークは平成6年で60,000箇所あったそうです。
わずか30年弱で半分に減っちゃったんですね。

このままの推移をたどると、電気自動車の数同様、2035年にはガソリンスタンドの数 < 電気自動車充電スポット数 になると容易に想像できます。

余談ですが、普通自動車の数5,900万台の燃料補給を、たった30,000箇所のガソリンスタンドでまかなえてるのって、単純に驚きでした

電気自動車が主流になると、わざわざ充電スポットにいかずとも、自宅で夜寝てる間に充電したい。
などのニーズが出てくるのは容易に想像できます。
仮に、スマホの充電が10分でできる!っていう充電器が自宅から100m以内のコンビニにあっても、もわざわざコンビニで充電しないですよね。
これと同じ感覚の未来が、近い将来やってくると思いました。

また、現状だと電気自動車コンセントの取り付けをどこに頼めばいいのか分からない。っていうユーザーの方も多いと思います。
(主に電気自動車購入時に、メーカーから紹介された工事業者で取り付けてる方が多いそうです。)

今すぐに、万人に必要とされるサービスではないと思いますが、近い将来きっと自宅に電気自動車コンセントは標準装備になると考え、カテゴリの新設に至りました。

新設したカテゴリはこちら → EV(電気自動車)コンセントの設置

つい先日リリースしたカテゴリですが、早くも作業予約が入っており、やはり困っているユーザーがいたんだな。と思うと同時に、困っているユーザーの助けになれたな。と思えました。(この業務をやってて嬉しい瞬間です!)

このように、弊社では個人の思いつきや、自分が必要(ってことは、他にも必要としている人がいるはず!)って思うサービスからも、 自社サービスの展開へ繋げることも可能です。

パッと思いついたアイディアを上長に話して、馬鹿にされるなんてこともありません。
いい意味で、思ったことを何でも言え、自分の考えを強くもって仕事をすすめることも可能です。(もちろんメンバーや上長と相談しながら)

与えられた業務に追われ、興味がある分野の業務を行う機会がない方。
常に新しい何かを考えているけど、アウトプットを出す機会がない方。

是非一緒に、世の中のくらしに便利なサービスを展開していきましょう。

Stripe Connect Expressの利用事例を発表しました

くらしのマーケット Stripe Connect Express 利用事例

こんにちは。みんなのマーケットでCTOをしている戸澤です。

くらしのマーケットでは2020年8月に初の決済機能である、オンラインカード決済機能をリリースしています。 オンラインカード決済機能の実装では決済代行としてStripe社のConnectを使用しています。 Connectはくらしのマーケットのような、売り手、買い手、プラットフォーマーの3者がいるマーケットプレイス向けのプロダクトです。

今回、Stripeのユーザーコミュニティである、JP_Stripesさんよりお誘いいただき、 くらしのマーケットでのConnectの選定の背景、リリースの運用面についてお話させていだきました。

発表資料

関連記事

オンラインカード決済の開発に関する過去記事もありますので、ぜひご覧ください。

tech.curama.jp

tech.curama.jp

ヤッホー!シワちゃんだよーん

f:id:curama-tech:20210218182038p:plain

こんにちは、バックエンドエンジニアのキダです。 先日くらしのマーケットでSign In With Apple(SIWA)対応のリリースをしました! SIWA対応でハマった点などを紹介いたしますので、興味がある方は参考にしてください。

背景

AppleのポリシーによりApple IDでログインできるソーシャルログイン機能のSign In With Apple(SIWA)は2020年7月より対応必須となりました。 ※ 元々は2020年4月からとなっていましたが、コロナウィルスの影響もあり延期されていました

対応必須の詳細は以下になります。

  • ソーシャルログイン機能持つアプリはSIWAの対応が必要
  • 対応してない場合、Appleのアプリの審査が通らない(新しいバージョンのアプリをリリースできない)

くらしのマーケットは店舗向けアプリと、ユーザー向けアプリが存在します。 ユーザー向けアプリは『Yahooログイン』『LINEログイン』を対応しているので、SIWAの対応が必須になっていました。 ユーザー向けアプリはAndroid、iOS共にWebViewで作成されていて、アプリネイティブの機能以外は全てWeb側で実装されています。その為それほど優先度は上がっていなかったのですが、iOSユーザー向けアプリにマージ済みでリリースできていない機能が溜まってきた為、2021年1月より対応を進めていました。

SIWAの特殊なところ

さて、そろそろ本題に入ります。 SIWAが他のソーシャルログイン機能と比較して異なる点を箇条書きにしてみます。

  1. iOS端末(mac, iPhone)のSafariでは独自のサインインUIが利用可能で端末にログイン済みのApple IDで簡単にSIWAでの認証が可能(touch ID, face ID利用可)
  2. メールアドレス、ユーザー名を連携するためにはコールバックエンドポイントをPOSTで実装する必要がある
  3. 利用者はメールアドレスの匿名化オプションと、転送停止設定が可能

1.はとても便利な機能なのですが、2,3は開発する際に考慮する必要があり、主にこの点でハマったので詳細を説明します。 この点はdev.toの記事がとても参考になったので、合わせて確認いただけるとよいと思います。

なおAppleのデータ、デザインに関するガイドラインは以下になるので、こちらもご一読する事をお勧めします。

https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple/overview/data-management/

https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple/overview/buttons/

メールアドレス、ユーザー名を連携するためにはコールバックエンドポイントをPOSTで実装する必要がある

例えばYahooやLINEログインは以下のようなフローで Authorization CodeのOAtuh2認証がされています。

  1. ログインボタンをログインや会員登録やアカウント連携ページに配置(/login, /regist, /connect のようなURL)
  2. ボタンを押すと、それぞれのソーシャルアカウントでの認証処理を実行
  3. 認証完了後に、元のページに code, state のパラメータをクエリストリングに付与してリダイレクト
  4. 元ページではcodeの内容を検証して、問題なければ認証完了として、ログインや会員登録が完了

ここの3でのリダイレクト処理はGETでリクエストされます。

さて、SIWAではどうなっているでしょうか。 まずSIWAでの1のボタンに設定するURLは以下のように設定可能です。

https://appleid.apple.com/auth/authorize?response_type=code&scope=email&response_mode=form_post&client_id={{ clientId }}&redirect_uri={{ redirectUrl }}&state={{ state }}&nonce={{ csrfToken }}

それぞれパラメータは以下のようになります。

パラメータ 用途 設定例
response_type Authorizationフロー code(id_tokenも可)
scope 連携するユーザー情報 emailとname
response_mode リダイレクト方法 form_post, query, fragment
client_id Appleで登録する識別子  
redirect_uri リダイレクトURL  https://example.com/login
state リダイレクト先で検証可能なcsrfTokenなど 自由に設定可能
nonce リダイレクト先で検証可能なcsrfTokenなど 自由に設定可能

ユーザー情報を連携するためにはresponse_modeform_postに指定する必要があり、GETで戻す前提の作りになっていたため、リダイレクト先でPOSTを受け取れるように修正する必要がありました。 くらしのマーケットでは先の/login, /regist, /connectのようなページは元々GET,POST存在していましたが、既存の仕組みではcsrfTokenのチェックでリダイレクト時にエラーが出てしまいました。

■ 対応策

こちらの問題の対応として、Appleからリダイレクトする専用のエンドポイントを作って対応しました。

以下のようなredirect_uriを設定して、動作します。

https://example.com/redirect/?/login

  1. ログインボタンをログインや会員登録やアカウント連携ページに配置(/login, /regist, /connect のようなURL)
  2. ボタンを押すと、それぞれのソーシャルアカウントでの認証処理を実行
  3. 認証完了後に、/redirectページに code, stateのパラメータをクエリストリングに付与してリダイレクト
  4. /redirectページで、stateの値を用いてセッションに保持したcsrfTokenチェックをして、問題無ければ元ページにGETでリダイレクト
  5. 元ページではcodeの内容を検証して、問題なければ認証完了として、ログインや会員登録が完了

/redirectを挟む事で元のエンドポイントでの修正を抑えて実装することが可能でした。 これにて一件落着に見えたのですが、もう一つPOSTに起因する問題がありました。

samesite cookieの対応

Google Chromeではこの認証フロー中にapple側の認証前(1.の状態)、apple認証後(4.の状態)でセッションが変わってしまう問題が出る事に気付きました。 具体的にどういうことかというと、セッション情報はcookieに保持していて、長期間同一のセッションとして管理されています。 Google Chromeでは2020年ごろからデフォルトのcookieプロパティがLaxになっており、今回POSTでリダイレクトする際にこのセッション情報を管理するcookieが失われてしまうといった問題が出ました。

こちらの問題は前段で上げたdev.toの記事に紹介されている、リダイレクト先のscopeで有効なsamesite="none"のcookieを短時間で設定する方法で回避ができました。

ちなみにユーザー情報を連携しない場合はGETでリダイレクトすることが可能な為、上記のような対応で懸念点がある場合にはGETで実装することも可能です。他のサイトも参考に見てみましたが、GETで実装されているサイトが多い印象です。

利用者はメールアドレスの匿名化オプションと、転送停止設定が可能

もう一つ特徴的な機能がメールアドレスの匿名化になります。 エンドユーザーとしては、メールアドレスの流出を防いだり、メールマガジンなどの配信停止を利用するサイトにアクセスする必要無く対応できる点で、とても良い機能に見えました。

以下のような画面で非公開設定が可能で、匿名化されたメールアドレスは以下のような形式になります。

<ランダムな文字列>@privaterelay.appleid.com

f:id:curama-tech:20210218182141p:plain

運用する側では以下のような対応をする必要があります。

  1. Appleサイトにて送信元メールアドレスとドメインを登録
  2. 配信停止から配信再開になった場合を考慮して定期的なbounceリストの削除

2.について少し説明します。 くらしのマーケットではメール送信にSendGridを利用しているのですが、以下の操作をした後にメールを送信し配信に失敗した場合bounceリストに入ってしまいます。

  • AppleIDのサイトからメールの転送を無効化
  • AppleIDのサイトからアカウント連携を削除

f:id:curama-tech:20210218182218p:plain

まだ本番ではbounceは発生していないですが、QAテスト時には同じAppleアカウントの連携を削除して、くらしのマーケットのアカウントを削除して、また新しくアカウントを作成してテストなどをしていたので、メールが届かないなどの問題がありました。 こちらはbounceリストから該当のメールアドレスを削除することで対応が可能でした。

最後に

色々とハマりどころがあったのですが、なんとか対応できました。 iOSユーザーにとってはとても良い機能だと思いますので、是非使ってくださいね!