jQuery
AWS
CloudFront
lambda
APIGateway

サーバレスで短縮URLサービスを作ってみる(2018年度版)

みなさんこんにちは。
もう何年も前のことです。私は趣味で短縮URLサービスを運営していました。
でも、普通のレンタルサーバーで mod_rewrite 使って動かしていたら、ちょっとアクセスが集中しただけで落ちてしまい、利用者のみなさまに迷惑をかけまくっていました。

今であれば、AWSなんかを使って、もっと簡単にスケーラブルなサービスを作れていたんでしょうね・・・

・・・ 本当に?

ということで、やってみることにしました。

tanshukujp.png

準備

なんと、 短縮.jp がまだ未取得だったので、衝動買いしました。
日本語ドメインが流行っていないのか、短縮URLがオワコンなのか、それとも両方か。

設計

最初は、こんな案を考えていました。

  • S3にトップページを配置、短縮URLはAPIゲートウエイでLambdaを呼んで、DBから元のURLを持ってきてrefreshを返す  

なんですが、「AWS 短縮URL」で検索したところ、すでに事例がいくつか見つかりました。

先達がおはしますれば、あえて自分がやることも無いのではとも思ったのですが、短縮.jp を取ってしまった以上、とりあえずやってみることに。
なんとなく、S3を使う方が楽な気がしたので、今回はS3を使ってみることにしました。アクセス集中した時も耐えられそうだし。

ということで、作ったものはこんな感じです。

  • Route53
    • お名前.comで取得したドメインのNS書き換えなど
    • API Gatewayのカスタムドメイン設定
    • Cloudfrontのカスタムドメイン設定
  • トップページ
    • s3にバケットを作って、Web公開
    • jqueryでAPI Gatewayに接続
  • Lambda
    • 移転先URLをrefreshタグに乗せてs3に保存
  • API Gateway
    • トップページからPOSTされたらLambdaに渡して、戻ってきたランダム文字列を返す
    • カスタムドメイン設定
  • Cloudfront
    • s3のカスタムドメイン設定

ひとつずつ少しずづやったことをまとめます。

DNS (Route53)の設定

お名前.comのドメインをAWSで使用する4つの方法 - Qiita
の、「2.お名前.comにRoute53のDNSを登録してサブドメインを委任する」を参考に設定しました。
特に難しいところはなく。

トップページの作成

index.htmlやらfaviconやらを作って、s3のバケットにアップロードします。
中身は、短縮.jpのソースを見てください。超簡単です。
やっていることは、jqueryでinputをpostして、帰ってきたランダム英数字を表に追加して、localStorageに保存しているだけです。

Lambda関数の作成

今回は Node.js で作りました。特に理由はありません。それがあとで苦労することに。
やっていることは、

  1. refreshヘッダーだけのhtml文字列を作成
  2. ランダムの英数字8桁を生成
  3. s3に同名のファイルが無いことを確認、あったらランダム再作成
  4. s3に保存

Node.jsのs3操作系の関数は、全て非同期で帰ってきます。なので頼れるPromiseを使っているのですが、慣れておらず、頭を混乱させながら書きました。正直正しいのかもわからないですが、一応ソース置いておきますね。
参考にさせていただいたサイトは、
Javascriptでランダムな文字列を生成する方法 - Qiita
Promiseを使った非同期ループ処理の書き方について | hifive開発者ブログ
Promiseについて0から勉強してみた - Qiita

あと、s3のputObjectには、WebsiteRedirectLocation って引数があって、自分でHTML書く必要すらないことに後で気づきました。残念。

index.js
var aws = require('aws-sdk');
aws.config.region = 'ap-northeast-1';
var s3 = new aws.S3();
var bucketName = 'xn--s7y86k.jp';

class Response {
    constructor() {
        this.statusCode = 200;
        this.headers = {};
        this.body = "";
    }
}

function crateRandom() {
    // https://qiita.com/ryounagaoka/items/4736c225bdd86a74d59c
    // 生成する文字列の長さ
    var l = 8;
    // 生成する文字列に含める文字セット
    var c = "abcdefghijklmnopqrstuvwxyz0123456789";
    var cl = c.length;
    var r = "";
    for (var i = 0; i < l; i++) {
        r += c[Math.floor(Math.random() * cl)];
    }
    return r
}


exports.handler = function(event, context, callback) {
    // 引数の確認
    var longUrl = JSON.parse(event.body);
    var longUrl = longUrl.url;
    console.log("input URL: " + longUrl);
    if(longUrl.length > 900 || !/^(https?)(:\/\/[-_.!~*\'()a-zA-Z0-9;\/?:\@&=+\$,%#]+)$/.test(longUrl) ){        
        const response = new Response();
        response.statusCode = 500;
        response.headers = {"Access-Control-Allow-Origin" : "*"};
        response.body = JSON.stringify({"error":"invalid URL"});
        response.isBase64Encoded = false;
        callback(null, response);
    }

    // ループ処理の完了を受け取るPromise
    new Promise(function(res, rej) {
        // ループ処理(再帰的に呼び出し)
        function loop(i) {
            var urlString = ""
            // 非同期処理なのでPromiseを利用
            return new Promise(function(resolve, reject) {
                    // 非同期処理部分
                    var r = crateRandom()
                    var params = {
                        Bucket: bucketName,
                        Key: r
                    };
                    s3.headObject(params, function(err, data) {
                        if (err) urlString = r; // unique key
                        else urlString = "" // already exists
                        // resolveを呼び出し
                        resolve(i + 1);
                    });
                })
                .then(function(count) {
                    // ループを抜けるかどうかの判定
                    if (urlString) {
                        res(urlString);
                    }
                    else if (count > 10) {
                        // 抜ける(外側のPromiseのresolve判定を実行)
                        rej();
                    }
                    else {
                        // 再帰的に実行
                        loop(count);
                    }
                });
        }
        // 初回実行
        loop(0);
    }).then(function(urlString) {
        // ループ処理が終わったらここにくる
        // 非同期処理なのでPromiseを利用
        return new Promise(function(resolve, reject) {
            var params = {
                Body: "<html><head><meta http-equiv=\"refresh\" content=\"0;URL=\'"+longUrl+"'\" /></head></html>",
                Bucket: bucketName,
                Key: urlString,
                ContentType: "text/html"
            };
            s3.putObject(params, function(err, data) {
                if (err) {
                    console.log(err, err.stack); // an error occurred
                    reject(err);
                }
                else{
                    console.log(data); // successful response
                    resolve(urlString);
                }
            });
        })
    }).then(function(urlString) {

        console.log("Finish");
        const response = new Response();
        response.headers = {"Access-Control-Allow-Origin" : "*"};
        response.body = JSON.stringify({"surl":urlString});
        response.isBase64Encoded = false;
        callback(null, response);
    }).catch(function(error) {
        // 非同期処理失敗
        console.log(error);
        const response = new Response();
        response.statusCode = 500;
        response.headers = {"Access-Control-Allow-Origin" : "*"};
        response.body = JSON.stringify({"error":error.toString()});
        response.isBase64Encoded = false;
        callback(null, response);

    });

}

ちなみに、実行時間なのですが、なぜか1.6秒もかかっています。デプロイしたあとWebブラウザから呼ぶと、もう少し早いのですが、それでももっと早くならないのかしら。

lambdatime.png

スクリーンショット 2018-02-21 21.34.51.png

API Gatewayの設定

API Gatewayは、
AWS Lambda Proxy Integrationを試してみた - Qiita
がめちゃめちゃ参考になりました。

あと、カスタムドメインの設定も必要です。
https://qiita.com/keita-nishimoto/items/b56a8e9abeaf6724a28b

ちなみに、CORSの設定がうまくいかず、3時間ぐらいフリーズしていました。正解は、Lambdaからレスポンスを返すときに、ヘッダーを入れないと行けなかったようです。(上のソースご参照)

Cloudfrontの設定

API GatewayをAPIキーなしで呼ぶにはHTTPSが必要なので、サイトも必然とHTTPSで立てる必要があると思いました。なのでS3直接ではなく、TLS化するためにCloudfrontを使いました。
CloudFrontでS3のウェブサイトをSSL化する - Qiita
を参考にさせてもらいました。
なぜか詰まったのが、証明書の部分。事前に AWS Certificate Manager で証明書を作ったのですが、初回設定時は、なぜか、Custom SSL Certificate を選択できませんでした。Default CloudFront Certificateを選択してます設定を完了させ、もう一度編集画面に戻ってきたら、設定変更できました。

スクリーンショット 2018-02-21 22.07.27.png

利用料金

数年前に使っていた某国のレンタルサーバは、転送量・保存容量実質無制限でそこそこ自由が効いて、年間1万円ほどでした。
AWSだといくらで運営できるでしょうか。
index.htmlの表示は誤差ということにして、短縮URLの作成、表示それぞれ100万回ごとの値段を計算してみます。Cloudfrontのキャッシュも面倒なので、キャッシュミス100%という非現実的な前提をおいて見ます。贅沢に東京リージョンです。

URLの作成

  • API Gateway
    • 100 万回の API 呼び出しの受信につき 4.25 USD
  • Lambda
    • 1,000,000 件のリクエストは無料です
    • 最初の月間 400,000 GB 秒、最大 3,200,000 秒のコンピューティング時間は無料です。
    • Lambda の無料利用枠は、12 か月間の AWS 無料利用枠の期間が終了しても自動的に期限切れになることはありません。既存および新規の AWS のお客様は、無期限にご利用いただけます。
      • 1回のリクエストで 600ms, 128MB
      • 月間100万回は無料枠の範囲
  • s3
    • 0.025USD/GB (最小128KB)
    • PUT、COPY、POST、または LIST リクエスト : リクエスト 1,000 件あたり 0.0047USD
    • GET および他のすべてのリクエスト : リクエスト 1,000 件あたり 0.00037USD
      • 128KB * 100万 = 128GB = USD 32
      • 100万リクエスト = USD 5.07 (PutとGet一回ずつ叩くから)
  • Cloudfront
    • HTTPSリクエスト1万回あたり 0.0120 USD
      • 100万回でUSD 1.2
    • オリジンへのデータ転送 0.060 USD/GB
    • インターネットへのデータ転送 最初の 10 TB につき、0.14 USD/GB
      • 1回のリクエストで戻ってくるのが、ヘッダー込みで340Byteぐらい。
      • だから、往復同じデータ量と仮定して、100万回で、340MB * (0.14 + 0.060 ) = USD 0.068

だから、合計 42.588ドルぐらい。うげ。意外とかかる。ほとんどs3のコストだけど。

URLの展開

  • s3
    • GET および他のすべてのリクエスト : リクエスト 1,000 件あたり 0.00037USD
      • 100万回で USB 0.37
  • Cloudfront
    • HTTPSリクエスト1万回あたり 0.0120 USD
      • 100万回でUSD 1.2
    • インターネットへのデータ転送 最初の 10 TB につき、0.14 USD/GB
      • 1回のリクエストで戻ってくるのが、ヘッダー込みで340Byteぐらい。
      • 100万回で、340MB * 0.14 = USD 0.0476

合計 1.7376ドル。お安い御用。

すでに短縮済みのurlを返すだけなら、非常にお安く運用できます。
月100万回も短縮されるぐらい流行るようになったら、その時にまたどうするか考えましょうか。

ということで、短縮.jp は、DDoSでも喰らわない限り、しばらくこのまま放置しておきますので、楽しくご活用ください。転んでも泣かない。