「ビックトラフィックCAMP」にメンターとして参加してみた | サイバーエージェント 公式エンジニアブログ
 はじめまして、15新卒エンジニアの菊地です。

 本ブログでは、5月15、16日に新卒エンジニア採用イベントの一環として開催された「ビッグトラフィックCAMP」へメンターとして参加した事についてレポートします。

ビッグトラフィックCAMPとは?
 自社が運営するサービスをテーマに、大規模トラフィックにおけるサービス開発を体験する、学生の方向けのイベントです。

タイトル


 今回はテレビCMでもおなじみの「755」をテーマに、100万ユーザーの同時アクセスにも耐えられるサーバサイドアプリケーション開発を目指します。1日目はサーバサイドDay、2日目はチューニングDayと計2日間行われました。

 1日目のサーバサイドDayは、参加者がそれぞれお好みの言語を選び、開発環境構築やAPIの開発スピードや品質を競います。2日目のチューニングDayは、1日目のサーバサイドDayで開発したAPIに対し、大規模なトラフィックに耐えうるチューニングを施し実行速度を競います。参加者には今回のイベント用に、AWSのインスタンスが各々に1つ提供されました。

 このイベントにおけるメンターの役割は、参加者同士の意見のやりとりを支援することが主な役割となっています。

 それではここから先は具体的なレポートに入っていきます。

1日目サーバサイドDay
 1日目は環境構築、時間が余れば仕様書にのっとったAPI作成に取り掛かるという流れでした。私の使用言語はJavaということで、まずApache+Tomcatを使用して実装することに決めました。AWS環境にSSHでログインすると、以下のファイルが設置されていました。

user.tsv ユーザ基礎データ 100,000件
userId:ユーザーID
userCreateDataTime:ユーザアカウントの生成日時

box.tsv 箱基礎データ 100,000件
boxId:箱のID
boxCategory:箱のカテゴリ名
boxPriority:箱の優先順位

card.tsv カード基礎データ 1,000,000件
cardId:カードのID
cardMessage:カードに書かれたメッセージ
cardType:カードの種別
cardTags:カードに付与されるタグ
cardMetrics:カードに与えられた数値

user2card.tsv ユーザ対カード対応データ 200,000件
userId:ユーザのID
cardId:カードID

box2card.tsv 箱対カード対応データ 800,000件
boxId:箱のID
cardId:カードのID

 次にAPI仕様書について説明します。以下の説明は、カードの条件、検索のルール、戻り値、検索シナリオという順になっています。

カード条件


 カードのメトリクスはint型では入りきらないものもでてくるので、long int型でテーブルを作成することにしました。

検索のルール
【プロトコル】
 HTTP
【メソッド】
 GET
【検索対象】
 listCardsInBoxページ(path:/listCardsInBox)
【検索条件】
 クエリストリングスにより指定する。key1=value1&key2=value2形式。パラメータ内容は下記「検索シナリオ」を参照。また、検索上限数は常に指定されるものとする。
【評価方法】
 試験プログラムが下記「検索シナリオ」に沿ったリクエストをランダムに生成し、正しい結果を返した場合のみ評価(加点)する。各条件には既定の点数があり、それに応答時間が加味される。

戻り値
【型】
 JSON形式で返す。
【項目】
 ・result
  →結果に1枚以上のカードがある場合には「true」、結果が0枚の場合には
   「false」を返す。
 ・data(配列)
  →カードID、メッセージ、カードタイプ、タグ(配列)、メトリクス、オー  
   ナーを格納して返す。
【ステータスコード】
 resultが「true」の場合は「200 OK」を、resultが「false」の場合は「404 Not Found」を返す。

検索シナリオ(検索のみ)
・箱で検索(1点)
  →指定のIDの箱をオーナーにもつすべてのカードを検索。
・カテゴリで検索(1点)
  →指定のカテゴリの箱をオーナーにもつすべてのカードを検索。
・タグ以外全部で検索する(5点)
  →1.指定の箱、指定の箱カテゴリ、指定域内の優先度の箱をオーナーにもち、
    2.指定のカードタイプ、指定域内のメトリクスをもつすべてのカードを検索。
・全部で検索(8点)
  →タグも含むすべての条件に合致するすべてのカードを検索。

検索シナリオ(検索+ソート)
・タグ以外全部で検索+ソート(10点)
  →「タグ以外全部で検索」の結果を1個以上のソート条件を適用。
・全部で検索+ソート(20点)
  →すべての条件で検索し、すべてのソート条件を適用。
以上がAPI仕様書の説明になります。

 サンプルリクエストとレスポンスはこの様になります。

【リクエスト】
http://IPアドレス/listCardsInBox?findByBoxCategoryEqual=長月&findByBoxPriorityLTE=90125&findByCardTypeEqual=北海道&sortByCardMetrics=descend&limit=10

【レスポンス】
{"result":true,"data":[
{"cardId":"Cd3y0e8i","message":"馬は速い。","type":"北海道","tags":["社外秘","CAPEX","業務委託","GCE"],"metrics":2171841991,"owner":"Bxspq03w"},
{"cardId":"Cd0yjqh9","message":"猿はかわいい。","type":"北海道","tags":["社外秘","OPEX","製造請負"],"metrics":2131759284,"owner":"Bxivh03x"},
{"cardId":"Cd9fasf6","message":"大きい羊。","type":"北海道","tags":["社外秘","関係者のみ","SAKURA"],"metrics":2126427314,"owner":"Bxvf03tk"},
{"cardId":"Cd3eb7d1","message":"遅い牛。","type":"北海道","tags":["2Q案件","Cloudn"],"metrics":2126158748,"owner":"Bx0xmee0"},
{"cardId":"Cdcpttjj","message":"兎は遅い。","type":"北海道","tags":["オンプレ","AWS","NiftyC"],"metrics":2111111115,"owner":"Bx74aawt"},
{"cardId":"Cd979aec","message":"かわいい猪。","type":"北海道","tags":["製造請負"],"metrics":2105876373,"owner":"Bxgsmbef"},
{"cardId":"Cdee0y8m","message":"大きい馬。","type":"北海道","tags":["4Q案件","AWS","GCE","MSAzure"],"metrics":2073393330,"owner":"Bxofa943"},
{"cardId":"Cd6v9wbv","message":"かわいい兎。","type":"北海道","tags":["4Q案件"],"metrics":2028819480,"owner":"Bxrzi2fu"},
{"cardId":"Cdcgrkjh","message":"猿は遅い。","type":"北海道","tags":["CAPEX","主任決裁済み","1Q案件","4Q案件","IDCFC"],"metrics":1974042405,"owner":"Bx6esf54"},
{"cardId":"Cdfj9f7k","message":"蛇は大きい。","type":"北海道","tags":["OPEX","社長決裁済み","GCE","IDCFC"],"metrics":1963100827,"owner":"Bx3soecb"}
]
}

 ではここから、私が実際に行った環境構築について話します。
 MySQLでcardテーブルと、boxテーブルをそれぞれ作成しました。テーブルの詳細は以下の通りです。

テーブル


 まず、データベース(以下、DB)を作成し、card.tsvとbox.tsvの中に含まれるデータをMySQLのテーブルに全て格納していきます。mysqlを選んだ理由は提供された環境に元々mysqlが入っていたので、それを使用することにしました。ファイルの中のデータはタブ区切りで保存されていましたので、格納は下記のmysqlコマンドで行いました。


LOAD DATA LOCAL INFILE 'インポートしたいファイル' INTO TABLE テーブル名 FIELDS TERMINATED BY '\t';

 card.tsvはcardテーブルに、box.tsvはboxテーブルに、もそれぞれデータを格納しました。

 次に、cardテーブルのowner列にデータを格納します。APIの仕様書を見た時、cardIdに紐づいたboxIdをowner列に格納しておけば解けることが分かったので、box2card.tsvを読み込み、boxIdをowner列に格納するコードを書きました。(ちなみにここには載せていませんが、チュートリアルを解く場合は、user2card.tsvからcardIdに紐づいたuserIdもowner列に格納しなければなりません。)

 あと今回、Apache+Tomcatを使用するということで、Tomcatをまずインストールしました。インストールしたTomcatはTomcat7で、ApacheとTomcatを紐づける設定を行いました。

 環境構築以外は、板敷さんによる「755の年末年始CM対応」に関するプレゼンが行われました。

いたしきさん


 「755」とは、藤田社長と堀江貴文さんが立ち上げたサービスで、著名人のトークをのぞいたり、直接やりとりすることを特徴とした、トークライブアプリのことです。開発、運営は株式会社7gogoで行われています。

 板敷さんの発表は755のCMを行うことによりユーザが急増して、システムの負荷が上がるという問題に対し、どのように対処していったのかについて焦点が当てられたものでした。

 CMにより発生する負荷対策に対して、藤田社長はこのように言いました。
「CMやるんだし、100万同時接続ぐらいはさばけないとね」
板敷さんはCM特別対策チームを発足し、5つの手順を明確にして対策を進めていきました。

対応方針検討
実装、アーキテクチャ変更
負荷試験実施
チューニング
本番環境に適用

1.対応方針検討
 以下の観点で対応方針を検討しました。
キャパシティが常に検証可能であること
100万同時接続以降もスケールアウトできること
キャパシティがあふれた場合のセーフティネットがあること
最小工数で最大の効果が期待できること

2.実装、アーキテクチャ変更
 対応前アーキテクチャはAPIが一か所にまとまっていますが、対応後のアーキテクチャはAPIを機能ごとに分散させています。これにより、API/DBのスケールやチューニングが最適化しやすいというメリットがあります。マイクロサービス化したのはコメント機能のみですが、これはコメント機能が一番リクエスト数が多くて負荷対応が必要だったからだそうです。

アーキテクチャ対応前


アーキテクチャ対応後


3.負荷試験実施
 通常時のアクセスパターンとアクセスが多い時のアクセスパターンを組み合わせて実施していました。そしてチューニングの繰り返しです。

 これらの取り組みにより、負荷が上昇する正月のあけおめイベントを乗り切ったそうです。

2日目チューニングDay
 2日目は、運営エンジニアの方からチューニングポイントについての発表が行われました。が、その前にまず1日目に作成したAPIの作成の続きを行いました。

・API作成
 私のTomcatの直下に存在するROOTディレクトリ以下のディレクリ構成はこのようになっています。今回は検索シナリオの中の箱で検索の機能をもったAPIの実装に取り組み、チューニング前後でどれくらいパフォーマンスが変化するか試してみることにしました。

tomcat直下


 servlet-apiはversion3.0のものを配置。作成したAPIは、SELECT * FROM card WHERE owner = (URLクエリパラメータから受け取ったboxId);というSQL文をたたき、cardテーブルの中身を表示させるコードを書きました。また、limitにも対応しなければならないということで、limitにも対応できるようにしました。

 2日目はAPI作成の途中、運営エンジニアによるチューニングポイントのプレゼンが行われました。チューニングポイントは、カーネル編、WebServer編、DB編、キャッシュ編の4つに関してそれぞれ説明がありました。

・カーネル編
 カーネルのパラメータの設定をいじります。カーネルのパラメータを調節するファイルは、sysctl.confです。
このファイルにTIME_WAITの発生を抑えるために以下の設定を試みます。

カーネルパラメータ設定


 サーバーリソースを使いきれていない場合に、Defalut値を見直します。
※カーネルパラメータの設定変更はサーバ全体に影響を及ぼすので、十分な検討と検証を勧めます

 sysctl.confに記載したものを反映する方法と反映されているか確かめる方法を以下の通りです。

反映:sysctl -p
確認:sysctl -a

・WebServer編
 Apacheの設定(httpd.conf)をいじります。同時接続数が増え、error_logに同時接続数上限までアクセスが来ており、サーバのリソースを使いきっていない場合はMaxClientsを増やします。逆にサーバのリソースがいっぱいの場合、その数を減らします。
もう一つ紹介されていたものが、PHPと組み合わせたチューニング方法です。
ApacheとPHPのメモリの割り当てです。Apacheにおいて、MaxClientsなどで子プロセスの数を調節します。PHPにおいてはmemory_limitを調節し、1プロセスあたりのメモリ使用量を調節します。
MaxClients = 使用可能なメモリ量/Apacheの1プロセスが使用するメモリ量となります。
※memory_limitやMaxClients変更後はApacheを再起動する必要があります。

httpdconf設定


・DB編
 MySQLのパラメータチューニングです。チューニングでポイントとなるのは、同時接続数の増減と割り当てメモリの増減です。
同時接続数:同時に100万というサービスに対し、DBが同時に100しかアクセスを受け付けない設定だった場合、サービスがダウンします。サーバの能力に応じて適切な値を設定する必要があります。
 割り当てメモリ:MySQLやORACLE、PostgresSQLなどの多くのRDBMSはクエリーの結果をメモリ内にキャッシュします。このサイズが多ければ多いほど、多くのクエリをキャッシュして、INDEXが効かないクエリにも高速で応答できるようになります。ただし、マシンスペックより多くのメモリを割り当てると、DISK Readがかかって、パフォーマンスが悪化します。
※同時接続数などの変更はmysqlコマンドで行うか、設定ファイル(通常はmy.cnf)に記載後、mysqldを再起動する必要があります。

INDEXについて(ポイント!)
・カーディナリティの高いものだけにINDEXをはる
・極力INDEXが使われるSQLを書く(EXPLAINで確認する)
・無理にSQLに負荷をかけさせないで済ませる(大規模アクセスを前提としたシステムの考え方の場合)
・遅いSQLの調査の仕方は、MySQLではslowlogを設定しておけば抽出可能。
※ただし、設定すると若干遅くなるため、調査終了後はslowlog書き出しをOFFに

 私はINDEXをどう貼ればパフォーマンスが上昇するのかわからなかったため、参考になるサイトを紹介してもらいました。こちらにも載せておきます。とりあえずチューニングの講義の際は、INDEXは中身にバラつきの多い列に貼るのがよいと聞いたので、ばらつきの多かったownerに貼ることにしました。

MySQLインデックスの基礎 : ひとつのテーブルに対するクエリの最適化法
http://yakst.com/ja/posts/2462
MySQLインデックスの基礎 その2 : 2つのクエリの違いとオプティマイザの判断
http://yakst.com/ja/posts/2385

・キャッシュ編
 プロキシサーバを導入し、レスポンスをキャッシュする手法です。
昨年のTOTEC2014インフラチューニングにおいて、上位は皆Varnish Cacheを導入して、その上で他のチューニング観点で勝負するという取り組みが行われていたようです。

キャッシュの図


 ちなみにチューニング前後でスコアに大きな変化が現れたので、載せておきます。inCorreountはチューニング後の方が多いですが、明らかにスコア自体は高いことが分かります。

チューニング前後


最後に
 今回のハッカソンにメンターとして参加し、渡されたAPI使用仕様書を見てどのようにDBを設計し、どういう手順でAPIを実装していくのかといった方針を明確にしておく大切さを実感しました。今後DBと連携して何かを実装していく機会があれば、パフォーマンス向上を意識してINDEXの貼り方にも気を配っていきたいと思いました。
 ちなみに参加している学生の使用言語は全体的にPHPが多く、優勝者はGoLangを使用していました。上位の学生はほとんどINDEXを上手く駆使することで高得点を獲得していたようです。
サイバーエージェントでは、毎年このようなイベントが採用の一貫として定期的に開催されているので、興味のある学生のみなさん、ぜひ参加してみてはいかがでしょうか?

集合写真