自社で使えるURL短縮サービスを低コストにサーバーレスで構築した話

はじめに

こんにちは、エンジニアの櫻井です。 昨今URLって長くなってますよね?
長いURLで困ったときに役に立つURL短縮サービスは色々ありますが、いちいち登録するの面倒だったり、無料で使えるものは短縮URLの消滅期限などあって面倒ですよね?
大手どころのGoogleさんがURL短縮サービスの提供を終了したことで困っている方もいるのではないでしょうか?

そんなわけで今回は自社独自のURL短縮サービスを完全サーバレスで作った話という内容になります。

TL;DR

  • 自社で使えるURL短縮サービスを完全サーバレスで作った
    • IP制限により、社内IPからは簡易的なURL短縮画面にもアクセス可能
  • 自社サービスからはAPIで利用可能
  • お手軽にできる割にはけっこう便利

自社で使えるURL短縮サービスを低コストにサーバーレスで構築した話

背景

Rettyの予約システムでは予約が入ったときにユーザさんやお店さんに予約URLをメールやSMSで送るのですが、SMSの送信においてURLと文章が長くてメッセージがURLの途中で分割されてしまう、という問題が起きていました。
そのため、短縮URLを使って文字数を減らそう!という話になりました。

なぜ自前で作るのか?

短縮URLについては一番有名なGoogleのURL Shortenerがありますがサービス停止により乗っかることはできなくなってしまいました(´・ω・`)
そのため、候補としては bit.ly があがったのですが、無料プランだと5,000URL/月までしか短縮できなくて、有料プランだと12万円/月払って50,000URL/月という内容でした。
bit.lyなどは単純にURLを短縮するだけではなく、短縮URLへのアクセス解析などもセットで行ったりしていることから上記のようなURLの上限数を設定しているのではと考えており、今回のような兎に角URLを短縮したいだけでアクセス解析だとかプロモーションだとかはどうでもいいんだ!と機能がToo muchであることや現在の予約数を考えたときに有料プランでないと対応できないだろうな、ということから自前で作成することを検討し始めました。

既存のアプリケーション上で作る道は?

最初は既存のアプリケーション上のシステムでの実装を考えました。
テーブルもハッシュと短縮したいURLの2つのカラムという単純なテーブル構造でいけるというのもあり実装イメージはすぐにわきました。
ただ、他のシステムからも使いたくなるかも、といったことや巨大なデータ(場合によっては億単位のレコード)がテーブルに蓄積されてしまうことを考えるとそのような実装はイマイチかな、と思いやめました。

AWS CloudFormationのテンプレート

そこで色々Webで調べたところ下記のようなAWSの記事が見つかりました。 https://aws.amazon.com/jp/blogs/compute/build-a-serverless-private-url-shortener/

上記の記事には下図のようなボタンがあり、このボタンを押すとなんとCloudFormationの画面に遷移し、一発でこの構成を作れてしまいます。

f:id:rettydev:20190517221439p:plain
CloudFormation画面

ただ注意が必要なのが、このボタンを押したときにはデフォルトのリージョンが異なっている場合があります。
今回自分は 東京リージョン で作成したかったので、変更してから作成しました。

f:id:rettydev:20190524102502p:plain
CloudFormation画面

次へを押すと今度はテンプレートに定義されたパラメータを入力する画面となります。
この場合にはS3バケットの名前と短縮URLのライフタイムの入力になります。
(このときS3バケットの名前は他と重複しない名前かつS3バケットの名前として有効な文字列を指定しましょう)

f:id:rettydev:20190524102744p:plain
CloudFormationのパラメータ設定画面

次にオプションの入力となります。
この画面で入力が必要なのはモニタリング時間のみで今回は0を指定しました。

f:id:rettydev:20190524102902p:plain
CloudFormationのオプション設定画面

最後に確認画面を見て、必要なIAMの作成に関しての同意にチェックをいれます。

f:id:rettydev:20190524103731p:plain
CloudFormationの確認画面

作成を押すとサーバ構築が始まります。
CloudFrontの作成に割と時間がかかるため大体20〜30分程度かかります。
作成が終わると https://{CloudFront のURL}/admin/index.html というURLにアクセスすると短縮URL作成画面が表示されるようになります。

追加実装

ここまでで割ともう便利なのですが、以下のようなポイントを実現してもう少し工夫したいところです。

  • 短縮URLの有効期限を無期限にして、短縮URLの最大パターン数をあげたい
  • 自社ドメインを使いたい
  • デフォルトで作成されるURL短縮用の管理画面はIP制限して社内からのみアクセスできるようにしたい
  • APIで呼び出せるURL短縮機能にAPI Keyを設定したい

短縮URLの有効期限を無期限にして、短縮URLの最大パターン数をあげたい

今回は、Rettyの予約システムでユーザさんやお店さんに送る予約URLを短縮しようという動機でURL短縮サービスを考えました。
それでいくと有効期限はデフォルトの7日では当然短いですし、現状は3ヶ月先の予約の予約もすることができますし、今後は1年先の予約とかもできるようにするかもしれません。
そうなったときにいちいち有効期限を考えていては面倒ですし、今回作るURL短縮サービスは社内で使うドキュメントのURLを短くしてSlackのトピックに表示させやすくするような時にも使うことを考えていました。
そうしたときに1年後とかにドキュメントを開こうとしても開けなくなって、もとのURLも忘れる、みたいなことが起こっては問題だなと思い有効期限をなくすことにしました。

このシステムにおいてはURL短縮のレコードはS3上にメタデータを持ったファイルが置かれるだけなので1つの短縮URLあたりのデータサイズ自体は正直無視できるくらい小さいです。
仮に増えてきたとしてもGBあたりの課金であるS3としてはコスト自体も無視できるほど小さいと思い有効期限を無くしても大丈夫である、と判断しました。

また短縮URLの有効期限を無期限としたため、利用状況によっては数年後くらいでURLが衝突するパターンが発生してしまうかもしれません。
デフォルトのテンプレートでは短縮URLを数字と小文字の英字の36パターンが7文字ということで 36 ^ 7 ≒ 780億URL とやや少ないかもなーと思ったので1文字長くすることにしました。
8文字にすることで 36 ^ 8 ≒ 3兆URL まで作れることになります。

では実際にどのようにすればよいのかというと、CloudFormationのスタックの更新を行いLambdaのコードを変更します。
具体的には下記の3箇所となります。

1. CloudFormationの有効期限自体のパラメータ URLExpiration のブロックを削除します。

Parameters:
  S3BucketName:
    Type: String
    Description: Enter the Amazon S3 bucket to use for the URL shortener, or leave empty to create a new bucket with automatically generated name. The S3 bucket is kept after you delete this template.
  URLExpiration:
    Type: Number
    Default: 7
    Description: Expiration in days for short URLs. After this delay, short URLs will be automatically deleted.

2. S3バケットの設定項目におけるライフサイクル設定 LifecycleConfiguration のブロックを削除します

Resources:
  S3BucketForURLs:
    Type: "AWS::S3::Bucket"
    DeletionPolicy: Delete
    Properties:
      BucketName: !If [ "CreateNewBucket", !Ref "AWS::NoValue", !Ref S3BucketName ]
      WebsiteConfiguration:
        IndexDocument: "index.html"
      LifecycleConfiguration:
        Rules:
          -
            Id: DisposeShortUrls
            ExpirationInDays: !Ref URLExpiration
            Prefix: "u"
            Status: Enabled

3. 短縮URLの文字数の変更

Before)

          // generate a 7 char shortid
          const shortid = () => {
            return 'xxxxxxx'.replace(/x/g, (c) => {
              return (Math.random()*36|0).toString(36);
            });
          }

After)

          // generate a 8 char shortid
          const shortid = () => {
            return 'xxxxxxxx'.replace(/x/g, (c) => {
              return (Math.random()*36|0).toString(36);
            });
          }

CloudFormationでのスタックの更新はいくつか方法がありますが、既存の設定がある場合には デザイナーで表示 のリンクから変更します。

f:id:rettydev:20190524180326p:plain
スタックの更新

template という箇所にCloudFormationの設定があるので、上記の変更を行ったあと右上の更新ボタンを押して記述に問題ないことを確認したら左上のクラウドアイコンのボタンをクリックして変更を終了します。

f:id:rettydev:20190524180622p:plain
デザイナー表示によるスタック更新

変更を終了すると先程のテンプレートの選択画面に戻り、変更内容を反映したS3テンプレートが指定された状態になっているので次へボタンを押して進みます。
その後スタックのパラメータ設定画面になりますが、最初に存在した短縮URLの有効期限設定の入力欄が消えたことがわかります。

f:id:rettydev:20190524180900p:plain
CloudFormationのパラメータ設定画面

そのまま進むと最終確認画面となり、再度IAMの変更チェックと定義の更新によるスタックの更新内容のリストが出ます。
確認して問題なければ更新ボタンを押しましょう。

f:id:rettydev:20190524181022p:plain
CloudFormationの確認画面

以上で、短縮URLの有効期限変更と短縮URLのパターン数の変更は完了です。

自社ドメインを使いたい

デフォルトの https://{CloudFront のURL}/admin/index.html というURLもいいのですがせっかくなのでCNAMEを設定してURLをより短くしたくなりますよね。
Route53でのCNAME作成し、CloudFrontの設定をすることでさらに短いURLにすることができます。

まずはRoute53で短いホスト名を確保します。
今回は s.retty.me としました ( s は shortener の s )

その後にCloudFrontのページを開き、このリソースで使っているCloudFrontにCNAMEの設定とSSL証明書の設定を行います。
まずは対象のCloudFrontを開き、Editボタンを押して枠線の内容を設定します。

f:id:rettydev:20190517220034p:plain
CloudFront設定

CNAMEに s.retty.me を、 SSLにチームで使っているワイルドカード証明書を設定しました(場合によってはAWSの証明書マネージャーでもOK)

f:id:rettydev:20190517220111p:plain
CloudFront設定

しばらくするとCloudFrontの設定が終わり、CNAMEでアクセスできるようになります。

デフォルトで作成されるURL短縮用の管理画面はIP制限して社内からのみアクセスできるようにしたい

デフォルトの状態では、URLさえわかれば誰でも短縮URLを作成するための画面にアクセスできる状況になっているため、特定IP(社内IP)からのアクセスに限定します。

今回はCloudFrontを使っているためAWS WAFを使うのがラクです。
[小ネタ]AWS WAFを使って特定のURLにIP制限を設定する。 の記事を参考に /admin/index.html のURLだけにアクセス制限をかけます。
AWS WAFの設定は下記の順番にて行っていきます。

  1. 社内IPアドレスを定義する
  2. アクセス制限したいURLの条件を定義する
  3. アクセス許可ルールとアクセス拒否ルールを作成する
  4. ACL設定を作成する

まずは社内IPアドレスの定義を行いましょう。
IP addresses というところをクリックして適当に office という名前の条件を作成し、社内IPとなるIP群を指定します。 IPは xxx.xxx.xxx.xxx/xx というようにサブネットマスクを使った書き方ができるため、範囲指定したい場合にはサブネットマスクをいい感じに指定しましょう。

f:id:rettydev:20190517175439p:plain
社内IPの指定

次にアクセス制限したいURLの条件を定義します。
String and regex matching というところをクリックして、 URLShortnener という定義を作成します。
今回は /admin 配下すべてのアクセスを制限したいため admin/.* というような正規表現ルールを作成します。

f:id:rettydev:20190517181404p:plain
URI正規表現ルールの作成

次にアクセス許可ルールとアクセス拒否ルールを作成します。
Rules というところをクリックし、 Allow_URLShortener というルールと Deny_URLShortener というルールを作成します。

Allow_URLShortener では社内IPに一致 かつ URIがURLShortnenerの内容に一致するという条件を作り、 Deny_URLShortener ではURIがURLShortnenerの内容に一致するという条件を作ります。

最後にACL設定を作成します。
Web ACLs というところをクリックし、ACLを作成します。

Step1では InternalURLShortenerAdmin という名前を指定し、 AWS resource to associate ではアクセス制限をしたいCloudFront(今回であれば s.retty.me )を選択します。
Step2は特に設定することはないためスキップします。
Step3では今までに作ったルールを設定します。具体的には Allow_URLShortener を Order1 で Allow, Deny_URLShortener を Order2 で Block の設定とし、Default actionAllow all requests that don't match any rules を選択します。
これにより、 社内IPからの管理画面へのアクセスを許可し、それ以外のIPは管理画面のアクセスをブロック、短縮URLの展開のアクセスについては全IPから許可 というACLが作成できました。

f:id:rettydev:20190517183605p:plain
ACLの設定

Step4 では今までの設定内容を確認し、問題なければそのまま作成します。

APIで呼び出せるURL短縮機能にAPI Keyを設定したい

次にAPIも外部から自由に利用されるのもよろしくないため、API GatewayAPI Keyを設定します。
このあたりは Amazon API GatewayでAPIキー認証を設定する を参考に設定します。
最終的にはこちらを参考にしてもらいつつ設定します。

f:id:rettydev:20190517184122p:plain
API Keyの設定

  1. API Keyを作成する
  2. 使用量プランを作成する
  3. 対象のAPIに使用量プランを紐付ける

上記の設定が終わったら、curlAPIによるURL短縮のテストを行います。
APIのエンドポイントは https://{Route53で定義したCNAME}/prod/ となります。

$ curl -X POST -H 'Content-Type: application/json' -H 'X-API-KEY: {上記で作成したAPI Key}' -d '{ "url_long" : "https://retty.me", "cdn_prefix": "{Route53で定義したCNAME}" }' https://{Route53で定義したCNAME}/prod/
{"url_long":"https://retty.me","url_short":"短縮されたURL","error":""} 

おわりに

元々LambdaのようなServerlessアーキテクチャ大好きだったので楽しかったです!😁
あと初体験のCloudFormationは最初はなんだよこれみたいになりかけたけどやってみたら意外といいやつで良かったです👍
API GatewayAWS WAFなども割と初見で、こんなこともできるのかという勉強に多いになりました✨