職業プログラマの休日出勤

職業プログラマによる日曜自宅プログラミングや思考実験の成果たち。リアル休日出勤が発生すると更新が滞りがちになる。記事の内容は個人の意見であり、所属している(いた)組織の意見ではない。

AWSのS3を使った疑似push型配信

Webページ内でpush型配信をやろうと思ったとき、どのように実現しますか?

  1. WebSocket
  2. Comet
  3. Ajaxで定期的にpolling(ポーリング)

ここ数年ではWebSocketを使う事例が多いかとは思いますが、やっぱり、色々と面倒臭いというのが正直なところです。かと言って、Comet的な手法を使うのもTCPコネクションの累積のことを考えると、ユーザ数が増えてきたらサーバをどんどん増やしていかなければなりません。最後のAjaxで定期的にpollingする方法も似たようなもんです。

じゃあ、どうしましょう?

せっかく AWS を使うんだったら、TCPコネクションの累積や大量のHTTP(S)リクエストでもビクともしない(とは言っても限度はあるでしょうけど)、S3を活用してみることにしましょう。

警告

ブログ全般に言えることですが、改めて明記しておきます。
※警告1:この記事の内容を参考にした結果何らかの損害が発生したとしても、当方では一切責任を負いません。十分に検証した上で利用して下さい。下の方にあるサンプルコードについても同様です。
※警告2:誤った設計やコード、設定などはS3に過剰な負荷をかけたり、高額の利用料を請求されるなどの可能性も考えられます。十分に気を付けて下さい。

原理

本当ならシーケンス図を書くべきところかもしれませんが、文字だけで頑張ります。

  1. 予め、S3内のどこかのバケットに、jsonファイルを置いておく
  2. Webブラウザが、EC2インスタンスからページ本体をロードしてくる
  3. 何かをきっかけにして、(1)で準備したjsonファイルのURLへのpollingを開始する。Ajaxで。
  4. EC2インスタンス側で、何かブラウザ側に通知したいものがあったら、(1)で準備したjsonファイルを上書きする
  5. Webブラウザ側は上書きされたjsonファイルを検知して、情報を取得することができる (゚д゚)ウマー

事前準備

勘のいい方は既にお気付きだと思いますが、pollingのためのAjaxは(普通は)cross-domainになってしまいます。このままだと動作しない(Webブラウザのセキュリティ機構に引っ掛かって、S3からのデータを読み取れない)ので、 CORS の設定をしなければなりません。
AWS の S3 で CORS の設定をするには、これらの記事を参考に。(英語)

簡単に言うと、S3 Management Console でバケット一覧を見たときに、右側のpropertyで使用するバケットの「Edit CORS Configuration」を使って設定を変更すれば良い、ということになります。設定はXMLで記述することになりますが、上記のドキュメントがあれば楽勝でしょう。きっと。

レイテンシ

どれくらいのレイテンシ(遅延)があるのか、計測してみました。

  • EC2(micro instance)から、同じregion内のS3にputObjectする : 50msから100ms程度
  • 検証環境(我が家)から、S3へのpolling HTTPS RequestのRTT(Round Trip Time) : 600ms程度

※検証には東京リージョンを用いました。
※上記RTTのうち大部分はTCPコネクションを張る動作と、SSL関連であると思われます。pollingのHTTPS Requestを投げてから約500ms後にS3上のファイルが書き変わるように仕込んだところ、書き換え後の値を取得することに成功しているケースがありましたので。

検証用サンプルコード

ブラウザ側(HTML / JavaScript

jQueryが必要です。

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>polling test</title>

<script type="text/javascript" src="./jquery-1.11.0.min.js"></script>
<script type="text/javascript">

// テスト開始後、秒数を画面表示させるためのタイマー(setIntervalの結果)
var timeCountTimer = null;

// polling中止ボタンが押されたらtrueになってpollingが止まる
var pollStopRequested = false;

// テスト開始ボタンで動作
function startTest() {
	// 秒数の画面表示タイマーが動いていれば止める
	if(timeCountTimer) {
		clearInterval(timeCountTimer);
		timeCountTimer = null;
	}
	
	// 表示されている秒数をクリア
	var timerResultSpan = jQuery('#elapsed_time');
	timerResultSpan.text('');
	
	// サーバ(EC2)に乱数生成を依頼し、できた乱数を取得
	jQuery.ajax({
		url: './pollingtest.php',
		type: 'POST',
		dataType: 'text',
		error: function(jqXHR, textStatus, errorThrown) {
			alert('error on startTest');
		},
		success: function(data, textStatus, jqXHR) {
			jQuery('#rand_from_php').text(data);
			var countStartTime = (new Date()).getTime();
			
			// 秒数のカウント開始
			timeCountTimer = setInterval(function() {
				var currentTime = (new Date()).getTime();
				var elapsedTime = currentTime - countStartTime;
				timerResultSpan.text(elapsedTime);
			}, 1);
			
		}
		
	});
}

function poll() {
	var timeBeforePoll = (new Date()).getTime();
	
	jQuery.ajax({
		url: 'https://S3内のどこか/pollingtest.json',
		type: 'GET',
		dataType: 'json',
		error: function(jqXHR, textStatus, errorThrown) {
			// polling失敗したらタイマー停止
			if(timeCountTimer) {
				clearInterval(timeCountTimer);
				timeCountTimer = null;
			}
			alert('error on poll : polling stopped. ' + errorThrown);
		},
		success: function(data, textStatus, jqXHR) {
			// S3から取得した乱数を画面に表示
			jQuery('#rand_from_s3').text(data.num);
			
			// 乱数生成依頼した時点でもらった乱数と同じだったら、pollingとして機能している証拠!
			if(data.num == jQuery('#rand_from_php').text()) {
				// タイマー停止
				if(timeCountTimer) {
					clearInterval(timeCountTimer);
					timeCountTimer = null;
				}
			}
			
			if(!pollStopRequested) {
				// 次回のpollingを予約
				setTimeout(poll, 1000);
			}
			else {
				// 停止要求されてたらpolling停止
				console.log('stopped polling by request');
				if(timeCountTimer) {
					clearInterval(timeCountTimer);
					timeCountTimer = null;
				}
			}
		}
	});

}

// start polling
poll();

</script>

</head>
<body>
<h1>polling test</h1>

<input type="button" value="テスト開始" onclick="startTest();"/>
<br/>
PHPで生成された乱数:<span id="rand_from_php"></span><br/>
polling先(S3)から得た数:<span id="rand_from_s3"></span><br/>
PHPから乱数を取得してからの経過時間:<span id="elapsed_time"></span>ms<br/>

<input type="button" value="polling中止" onclick="pollStopRequested=true;"/>

</body>
</html>
サーバ側(PHP

AWS SDK for PHP が必要です。
※乱数(実態はPHPuniqid())を生成して、JSONに詰め込んでS3に置きます。

<?php

require('aws-autoloader.php');

use Aws\Common\Aws;
use Aws\S3\Exception\S3Exception;
use Aws\Common\Enum\Region;

header('Content-Type: text/plain');

// テストで使うバケットと、オブジェクトのキー(ファイル名)
$bucketName = 'どこか.example.com';
$objKey = 'pollingtest.json';

// S3クライアントのインスタンス化
$aws = Aws::factory(array(
	'key' => 'アクセスキー',
	'secret' => 'シークレットキー',
	'region' => Region::TOKYO
));
$s3 = $aws->get('s3');

// 乱数と、それを格納するJSON
$rand = uniqid();
$json = '{"num":"' . $rand . '"}';

try {
	// S3にオブジェクトを置きにいく
	$s3->putObject(array(
		'Bucket' => $bucketName,
		'Key'	=> $objKey,
		'Body'	=> $json,
		'ACL'	=> 'public-read',
		'ContentType'	=> 'application/json',
		'CacheControl'	=> 'no-cache'
	));
	
	// S3で実際にオブジェクトが生成されるまで待つ(上書きするときは速攻で返ってくることに注意)
	$s3->waitUntilObjectExists(array(
		'Bucket' => $bucketName,
		'Key' => $objKey
	));
	
	// 乱数をブラウザに返す
	echo $rand;

}
catch (S3Exception $e) {
	echo "failed to put an object in S3 : ";
	echo $e->getMessage();
}

まとめ:この方法はこんな環境に適しているはず

  • 大量のユーザ宛に同一のコンテンツを配信したいとき
  • 1秒や2秒程度のレイテンシは気にならないとき
  • EC2インスタンスを無駄に増やしたくないとき

考えられる応用事例:大量のユーザ宛に同一の情報を配信する訳ではない場合

S3内に置くファイルの命名規則あたりをしっかり設計すれば、きっと上手く行くでしょう(未検証)。

最後に:なぜ CloudFront じゃないのか?

CloudFrontに対するS3の優位性は、金銭的コストです。
HTTP(S)を使ったpollingというのは一般的に

  • 非常に大量のHTTP Request/Responseが行き来する
  • 1 Request/Response あたりのデータ量は非常に小さい(1KB - 3KB程度)

という特性があります。
S3はCloudFrontに比べると、GETリクエスト単価は半額くらい、転送データ量に基づく料金はほぼ同じという設定になっていますので、やっぱりS3の方が安いです。

しかし、CloudFrontであればHTTPの keep-alive を使えますので、polling動作のレイテンシを下げることができる可能性はあります。暇があったらぜひ実験してみたいところです。
※S3でkeep-aliveする方法って無いんですかねぇ?

参考図書

Amazon Web Services クラウドデザインパターン 設計ガイド

Amazon Web Services クラウドデザインパターン 設計ガイド

Amazon Web Services クラウドデザインパターン実装ガイド

Amazon Web Services クラウドデザインパターン実装ガイド

AWSを初めて触る前に、この2つの本のうち片方を読んで勉強しました。どっちだったかは忘れましたw
たぶん前者ですが自信が無い。。
サーバ構築/管理の経験がそれなりにある人であれば、AWS初めてでもスラスラ読める内容になっていると思います。