Amazon S3の機能まとめ

こんにちは、AWS担当のwakです。そろそろAWSソリューションアーキテクトのプロフェッショナルを取らないといけなくなったので、S3について勉強がてらまとめました。

堅牢性

S3は「99.999999999%の耐久性」(eleven 9’s of durability)を売りにしています。S3に1万個のオブジェクトを保存したとき、1000万年に1個消える可能性があるということです。

レプリケーション

S3のオブジェクトは最低3つのAZ(Availability Zone、物理的に離れたデータセンター)にレプリケーションされます。(ドキュメント) ただし以下に述べるOne Zone-IAとRRSは例外です。

クロスリージョンレプリケーション (CRR)

バケット単位で指定可能な設定で、S3に保存したオブジェクトを自動的に別のリージョンのバケットレプリケーションしてくれます。別アカウントのバケットレプリケーションすることも可能です。バージョニング(後述。上書き・削除しても過去の履歴が全て残る機能)を有効にしておく必要があります。(ドキュメント

一部の暗号化されたオブジェクト、バージョンIDを指定した削除リクエストはレプリケーションされません*1。(ドキュメント) 通常レプリケーション先のリージョン内ではさらに別AZへレプリケーションが行われますが、これが無駄だと思う場合は後述のOne Zone-IAが利用できます。

ストレージクラス

オブジェクトを保存する種別のことで、オブジェクトごとに個別に指定できます。(ドキュメント

STANDARD (標準)

デフォルトで使われるストレージクラスです。最も高価で最も堅牢です。料金は$0.025/GB(最初の50TBまで)となっています。*2

STANDARD_IA (標準・低頻度アクセス)

滅多に使わないオブジェクトはSTANDARD_IAとして保存すると料金が安くなります。IAはInfrequent Accessの略で低頻度アクセスを意味します。堅牢さはSTANDARDと同等で、料金は$0.019/GBと24%も安くなりますが、30日以内に削除すると30日分の料金がかかる、取り出しに転送料とは別の料金がかかる等、料金体系が異なります。アップロード時にSTANDARD_IAを指定することもできますし、ライフサイクルポリシーも利用できます(後述)。

One Zone-IA (1ゾーン・低頻度アクセス)

ドキュメントではONEZONE_IAとも表記されています。STANDARD_IAは3つのAZにオブジェクトがレプリケーションされますが、One Zone-IAではレプリケーションが行われません。災害や障害で運悪くオブジェクトを保存しているAZが壊れるとオブジェクトが消えます。料金はSTANDARD_IAよりもさらに安く、$0.0152/GBとなります。こちらもSTANDARD_IA同様、アップロード時にOne Zone-IAを指定することもできますし、ライフサイクルポリシーも利用できます(後述)。

RRS (低冗長化ストレージ)

RRSはReduced Redundancy Storageの略で、2つのAZにしかレプリケーションされない代わりに料金が安くなります。この記事を書いている時点で料金表からも消えており、ドキュメントには利用が推奨されていないと明記してあるのでそのうち使えなくなるかもしれません。(ドキュメント

GLACIER

アーカイブ用のストレージクラスです。データを直接GLACIERにアップロードすることはできません*3。一度他のストレージクラスでデータをS3に保存し、ライフサイクルポリシーでGLACIERへ移動することになります。また、直接データを取り出すこともできず、事前に復元作業を行う必要があります。料金は$0.005/GBと極めて安価ですが、他に取り出し料がかかります。

ライフサイクルポリシー

アップロードされたオブジェクトがN日過ぎたとき、ストレージクラスを自動的にSTANDARD_IA/One Zone-IA/GLACIERのいずれかに移動するか、または削除するというルールが設定できます。また、途中でマルチパートアップロードが止まって残っているゴミ(後述)を自動削除することもできます。

HTTPを通じたアップロード

サイズ制約

1回のPUTでアップロード可能なサイズは5GBです。マルチパートアップロードを使うと5TBになります。(ドキュメント

マルチパートアップロード

大きなサイズのオブジェクトをアップロードする際、最大1万個にオブジェクトを分割して転送することができます。並行してアップロードすることもできるので時間の節約になり、リトライもやりやすくなります。なおアップロードが終わったら(または中断したら)その旨を通知して削除しないとアップロードは完了せず、転送済みのゴミがS3に残って無駄な課金が発生します。(ドキュメント

MD5での検証

確実にアップロードが成功したことを確認するため、リクエストに Content-MD5 ヘッダでオブジェクトのMD5を付加することができます。このヘッダがある場合、AWSはアップロード完了時に受け取ったデータのMD5を算出し、両者が一致しなかったらエラーを返してくれます。なお、この処理はAWS CLIaws s3 cp コマンドでは自動的に行われます。(ドキュメント*4

また、マルチパートアップロードが行われなかった場合はMD5の値がETagタグにセットされるので、こちらをチェックすることでも確認ができます。(ドキュメント

結果整合性

書き込み・上書き更新・削除を行い、その直後に読み取りをすると、それらの操作の前の状態が取得できてしまうことがあります。また、同じキーで同時にアップロードを行うとタイムスタンプが遅い方が勝ちます。これはどうしようもありません。(ドキュメント

Amazon S3 Transfer Acceleration

バケット単位での設定で、追加料金がかかる代わりにデータ転送が速くなるエンドポイントが利用できます。通常のエンドポイントと変わらないと判断された場合は追加料金がかからないというサービスがあります。このエンドポイントでもマルチパートアップロードは利用可能です。(ドキュメント

署名付きURLを使用したオブジェクトのアップロード

S3のオブジェクトには「署名付き(Pre-Signed) URL」を発行することができます。このURLにはバケット・キー・有効期限・URL作成者の権限がセットで含まれており、このURLに対してHTTP PUTでデータを送信すると、認証なしでオブジェクトをアップロード(または上書きアップロード)することができます。(ドキュメント) ダウンロード用の署名付きURLを作成することもできます(後述)。

VPN/AWS Direct Connect

オンプレミス環境からVPN, Direct Connectを通じてVPCエンドポイントを経由しS3を利用するような構成も可能です。この場合、データはインターネットを通りません。

HTTP以外のアップロード&ダウンロード

Snowball

AWS謹製のストレージデバイス(1個最大80GB)をAWSのデータセンターと直接やり取りする方法です。デバイスが届いたらネットワーク接続(10Gbpsに対応)してデータを転送します。S3へのデータのインポートとエクスポートに利用できます。(ドキュメント) 受け取りからデータ転送完了まではだいたい1週間程度です。

Snowball Edge

Snowballの強いやつです。容量は100GBで、ただのストレージデバイスではなくLambdaを実行することもできます(インターネット接続は不要です)。また、クラスタを組んで容量・処理速度を増大させることができます。Snowballと同様データのインポートとエクスポートに利用できます。(ドキュメント) かかる時間はSnowballと同じぐらいです。

Snowmobile

AWSの巨大なトレーラー(長さ14m)が直接ユーザーのところにやってきます。データを吸い出したらトレーラーはAWSのデータセンターに戻り、S3またはGLACIERにデータを転送します。100PBまでのデータのインポートに利用できます。(ドキュメント) 必要な時間は数週間になります。

Storage Gateway

オンプレミスのサーバーとS3を接続し、サーバーに配置したファイルを裏でS3と同期するサービスです(転送は非同期で行われます)。(ドキュメント

AWS Import/Export

今ではSnowballシリーズに取って代わられており、 利用できなくなりました。 USBメモリeSATAのHDDなどをAWSのデータセンターと直接やり取りするものでした。(ドキュメント

ダウンロード

署名付きURLを使用したオブジェクトのダウンロード

オブジェクトをダウンロードするための「署名付き(Pre-Signed) URL」を発行することができます(アップロードについては前述)。同じくプライベートなオブジェクトに認証なしでアクセスできるようになるので、一時的に(有効期限を付けて)ファイルを公開してダウンロードさせるために利用することができます。

CloudFrontとの連携

S3のオブジェクトを直接Webに公開するのではなく、CloudFront経由で配信することができます。 (ドキュメント

フェデレーション

ユーザーごとにIAMユーザーを作成するのではなく、他の認証システム(IdP: IDプロバイダー)のIDとIAMロールとをマッピングすることができます。AWS Security Token Service (AWS STS)が ユーザーに一時的なAWS証明書を与え、一般には非公開としているS3のオブジェクトにアクセスさせたり、DynamoDB*5の読み書きをさせたりできます。(ドキュメント

Web IDフェデレーション

Amazon, Facebook, GoogleなどのアカウントとIAMロールをマッピングするものです。Cognitoを使うと楽です。(ドキュメント)実際にCognitoでGoogleアカウントとロールをマッピングする関連記事も書きました。

tech.sanwasystem.com

SAMLベースのフェデレーション

Google G Suite, SalesforceなどのSAML 2.0に対応したIdPAWSに登録し、それらのアカウントとIAMロールとをマッピングするものです。(ドキュメント

バージョニング

バケットのバージョニングを有効に設定すると、上書き・削除してもオブジェクトの過去の履歴が全て残るようにできます。バケットのバージョニングは一度有効にしたら無効にはできず、停止のみが可能です。(ドキュメント

MFA Delete

MFA Deleteを有効にすると、バケットのバージョニング状態を変更する・オブジェクトを完全に削除する際にMFAが必要になります。

パフォーマンス

S3のオブジェクトはキー(=ただの文字列)で識別されるため、大量のオブジェクトに同じようなキー(たとえば連番、日付など)が振られていると、S3のオブジェクト検索速度が落ちることがあります。大量のアクセスを受け付けてこれが問題になる場合、適当な工夫をしてキーを分散させてやる必要があります。逆に管理は面倒になるため、場合によってはDynamoDBや自前のDBでオブジェクトを別途管理する必要があるかもしれません。(ドキュメント

暗号化

サーバーサイド暗号化

AWS側で暗号化を行う(SSE: Server-Side Encryption)には3つのオプションがあります。いずれにしてもメタデータは暗号化されず、オブジェクト一覧取得リクエストからオブジェクトの存在を隠すこともできません。また ETag の値はオブジェクトのMD5ではなくなります。 (ドキュメント

S3で管理された暗号化キーによるSSE (SSE-S3)

S3側で暗号化キーを勝手に用意してくれます。アップロード時に自動的に暗号化が、ダウンロード時に自動的に復号が行われます。公開した場合、オブジェクトはそのまま外部からアクセス可能になります。(ドキュメント

AWS KMSで管理された暗号化キーによるSSE (SSE-KMS)

アップロード時にAWS KMS(Key Management Service)で管理されたキーを使って暗号化が行われます。ダウンロード時にも同じくKMSで管理されたキーで復号が行われてから転送されます。したがってアップロード時・ダウンロード時ともにKMSのキーへのアクセス権限が必要になります。公開しても部外者はデータを取得できません。(ドキュメント

カスタマーが用意した暗号化キーによるSSE (SSE-C)

アップロード時、HTTPヘッダに暗号化キーを指定すると、S3にそのキーで暗号化されたオブジェクトが格納されます。ダウンロード時にも同様に同じキーを指定する必要があります。公開しても部外者はデータを取得できません。もしキーを失うとオブジェクトにアクセスできなくなるということになります。(ドキュメント

クライアントサイド暗号化

S3にアップロードする前にクライアント側で暗号化します。AWS KMSを使うことも、独自のマスターキーを使うこともできます。いずれにしても、1個のマスターキーのみで全てのオブジェクト(ファイル)を暗号化するのではなく、オブジェクトごとに異なる鍵で暗号化を行い、その鍵をマスターキーで暗号化してオブジェクトと一緒に保存しておくという流れになります。(ドキュメント

AWS KMSを使った暗号化

AWS KMSのCMK ID(Customer Master Key ID)だけで事を済ませることができます。まずAWS KMSにCMK IDを送り、ランダムに生成される1回限り使い捨ての鍵(AES)を取得します。このとき取得できる鍵は

  • (A) 暗号化に使える平文の鍵
  • (B) 上の鍵そのものが暗号化されたもの。CMK IDが含まれている。KMSでしか復号できない

のセットです。この(A)でオブジェクト(X)を暗号化し、(A)を削除し(もう不要なので)、暗号化されたオブジェクト(X')と(B)とをセットでS3にアップロードします。

復号時にはこのセットをダウンロードし、KMSに(B)の復号を依頼します。権限があれば復号が成功して(A)が入手できるので、暗号化されたオブジェクトを復号することができます。つまり、仮にS3のアクセス設定が誤っていて(B)や(X')が漏洩したとしても、対応するCMK IDの利用権限がなければKMSにアクセスできず、(A)が得られないため、オブジェクトは守られます。

KMSを使わない暗号化

AWS SDKはKMSを使わないクライアントサイド暗号化にも対応しています。AWS KMSを使う場合と流れは同じで、envelope key(データの暗号化・復号の両方に利用する1回限り使い捨ての鍵)を生成し、そのenvelope keyでファイルを暗号化し、マスターキーでenvelope keyを暗号化し、その両者をS3にアップロードします。復号時はこの逆の操作を行います。マスターキーはいかなる形でもAWSに送信されません。

イベント通知

S3のオブジェクトが更新・削除された際、その通知を送ることができます。通知先はSNS、SQS、Lambdaが指定できます。キーに対してプレフィックス・サフィックスで絞り込みを行うこともできます。たとえば画像ファイルがアップロードされたとき、自動的にLambdaを起動してそのサムネイル画像を生成するなどの使い方が考えられます。

データ処理

S3に保存したオブジェクト(CSV, JSON形式など。GZIP圧縮ファイルにも対応)に対して、直接SQLを発行することができます。(ドキュメント

まとめ

ざっくりS3の機能についてまとめました。S3はAWSの様々なサービスを仲介するハブの役割を果たす重要な存在であり、AWSソリューションアーキテクトを取るのであればその仕組みを正しく知っておく必要があります。頑張りましょう。

*1:これは悪意のある削除から守るためです。バージョンIDを指定しない削除リクエストはレプリケーションされます(つまりレプリケーション先でも削除されます)が、通常、バージョニングが有効になったバケットでオブジェクトを丸ごと削除するには別の権限が必要です

*2:2018/6/20現在、東京リージョンの料金。データ転送料、リクエストごとにかかる料金は別。以下同様です

*3:引っかけ問題を何度か見ました。サードパーティーのツールでは可能なようです

*4:この記事を書いている時点で日本語ドキュメントには「aws s3api put-objectで低レベルAPIを呼び出せ」という古い記述が残っていますが、これは不要です。サポートに確認済み

*5:よくセットで出題されます

S3 + AWS Cognito + Google認証でドメイン制限のついた非公開サイトを作る

こんにちは、AWS担当のwakです。

弊社では全社的にG Suiteを導入しています。そこで社内向けWebサイトをS3+Lambda+API Gatewayで構築し、G Suiteのアカウント+AWS Cognitoで弊社社員のみ利用できるような認証をかけようとしたところ、何点かハマるポイントがあったので手順を書いておきます。

f:id:nurenezumi:20180607105132j:plain

鋭い視線で部外者のアクセスを監視する猫

実現したこと

  • S3に静的なページ(HTML+CSS+JavaScript)を配置した
    • JavaScriptAWS APIを叩いてS3とDynamoDBからデータを取得し、画面に表示する
  • ページを利用する(=AWS APIを叩く)にはGoogleアカウント認証が必須
    • 認証ができたらCognito経由でユーザーにIAM Roleを与え、S3とDynamoDBへのアクセス権限を付与する
  • Googleアカウントのドメインは sanwasystem.com に限定する
    • それ以外のアカウントではHTMLは見られてもS3とDynamoDBにはアクセスできないようにする

前置き: AWS Cognitoって

Cognitoの機能は主に2つあります。

  • ユーザー認証を取り扱う
  • Google, FacebookなどのOpenIDプロバイダーからの認証を受け付けてIAMロールを付与する

今回はこの後者の機能だけを使っています。

Google側の準備

プロジェクト作成

Google側で認証を行うので、 Firebase console よりプロジェクトを新規作成します。既存のプロジェクトでも構いません。 f:id:nurenezumi:20180605112932p:plain

プロジェクト初期化用コード取得

プロジェクトが作成できたら、「ウェブアプリに Firebase を追加」ボタンを押すとプロジェクト初期化用のJavaScriptのコードが取得できます。これを保存しておきます。 f:id:nurenezumi:20180605113123p:plain

Google認証をONに

コンソールがずいぶん親切になっています。Authentication→「ログイン方法を選択」と進み、GoogleログインをONにします。 f:id:nurenezumi:20180605112053p:plain

承認済みドメインを追加

認証済みドメインにWebサイトを配置するドメインを入力します。 sanwa.local のようなGoogle側では名前解決できない値も受け付けてくれます。

OAuthクライアントID確認

旧来のプロジェクト管理画面 に移動します。 Credentials を見ると既にOAuthクライアントID(以下、単に「クライアントID」と呼びます)が自動生成されているので、このクライアントIDをメモしておきます。なお、このIDは認証時に暗黙的に利用される値なので、削除すると大変面倒なことになります*1

AWS側の準備

IDプール作成

Cognitoの管理画面 でIDプールを作成します。認証プロバイダにはGoogle+を選択し、GoogleクライアントIDには先ほどメモっておいたクライアントIDを入力します。 f:id:nurenezumi:20180605131003p:plain

ロール作成

認証時・非認証時に対応するロールの作成画面が表示されるので、そのまま作成してもらいます。

IDプールID(Identity pool ID)をメモする

サンプルコードが表示されます。その中のIdentity pool IDをメモしておきます。

ドメイン制限をかける

画面右上のリンクからIDプール編集画面を開きます。再度Authentication providersのGoogle+タブを見ると、今度はAuthenticated role selectionという項目が出現しています。ここで下記のような設定をすることで、Google認証されたユーザー情報のhd(Hosted Domain)プロパティに対して制限をかけることができます(このhdという名前はOpenIDで決められています)。

f:id:nurenezumi:20180605131956p:plain

素直に考えるとロールのTrust relationshipsの方に accounts.google.com:hd = sanwasystem.com となるような条件を書き込みたくなるのですが、こちらにはhd(Hosted Domain), emailなどの情報は渡されてこないようです。*2

ロールに権限を追加する

S3に対して読み書きしたりLambdaを実行したりしたいので、認証時に対応するロールに権限を適宜追加します。

Web側の準備

あとはHTMLとJavaScriptを書くだけです。Google認証には2通りの方法があります。

ポップアップまたはリダイレクトで認証を行うパターン

Firebaseを使うやり方です。とりあえずCDNを読み込んでJavaScriptをベタ書きしています。

<!DOCTYPE html>
<html>
<head>
  <script src="https://www.gstatic.com/firebasejs/5.0.4/firebase.js"></script>
  <script src="https://apis.google.com/js/platform.js" async defer></script>
  <script src="https://sdk.amazonaws.com/js/aws-sdk-2.1.12.min.js"></script>
  <link type="text/css" rel="stylesheet" href="https://cdn.firebase.com/libs/firebaseui/2.5.1/firebaseui.css" />
  <meta charset="utf-8">
  <title>HELLO COGNITO</title>
  <script>
  // Firebaseの管理画面から取得可能
  const config = {
    apiKey: "AIzaSyDWKx_xxyCTYiGjQt0wODTLMuXy7JOSbXk",
    authDomain: "sscaccountauthenticator.firebaseapp.com",
    databaseURL: "https://sscaccountauthenticator.firebaseio.com",
    projectId: "sscaccountauthenticator",
    storageBucket: "sscaccountauthenticator.appspot.com",
    messagingSenderId: "963005312419"
  };
  firebase.initializeApp(config);

  const provider = new firebase.auth.GoogleAuthProvider();
  provider.addScope("profile email");

  const init = async () => {
    await new Promise((resolve, reject) => { setTimeout(resolve, 2000); }); // 初期化を待つための手抜き
    const result = await firebase.auth().signInWithPopup(provider);

    // Googleでログインできたら、トークンをAWSに渡して認証を行う
    AWS.config.region = "ap-northeast-1";
    AWS.config.credentials = new AWS.CognitoIdentityCredentials({
      IdentityPoolId: 'ap-northeast-1:ffffffff-ffff-ffff-ffff-ffffffffffff', // IdentityプールID
      Logins: {'accounts.google.com': result.credential.idToken}
    });

    // 認証できたら呼び出される
    AWS.config.credentials.get(function() {
      const s3 = new AWS.S3();
      const url = s3.getSignedUrl('getObject', {Bucket: "YOUR-BUCKET-NAME", Key: "PATH/TO/YOUR/FILE/neko.jpg"});
      console.log(url);
    });
  };
  init();

  function signOut() {
    firebase.auth().signOut();
  }
  </script>
</head>
<body>
  <a href="#" onclick="signOut();">Sign out</a>
</body>
</html>

このコードには一つ問題があり、リロードするたびにポップアップが表示されてしまいます。既にログインしているときにはトークンIDが firebase.auth().currentUser.getIdToken(true)取得できるので本当は再ログインは不要なのですが、このトークンIDはProviderIdが firebase の固定値となっており、Cognitoが受け付けてくれません。認証直後に result.credential.idToken で取得できるトークンはProviderIdが google.com なので問題は起きません。

サインインボタンを表示するパターン

Firebaseではなく旧来のフレームワークに沿って実装します。JavaScript を使った Google ログインで認証する  |  Firebase に従いますが、Firebaseで作成したプロジェクトは互換性がイマイチなので前準備が必要です。

OAuthクライアントID再作成

旧来のプロジェクト管理画面 に移動し、自動作成されているクライアントIDを無視(もうこのクライアントIDは使いません)して新規にクライアントIDを作成し、Originの設定を済ませます。この新しいクライアントIDでCognitoの設定を上書きします。

f:id:nurenezumi:20180605131135p:plain

この手順がなぜ必要なのかは謎ですが、自動生成されたクライアントIDをそのまま使ってOriginの設定を行っても反映されず、認証しようとしたときに Not a valid origin for the client というエラーが発生します(6/5現在)。

最後にHTML+JavaScriptを書きます。 meta 要素にクライアントIDを書くのを忘れないでください。

<!DOCTYPE html>
<html>
<head>
  <script src="https://apis.google.com/js/platform.js" async defer></script>
  <script src="https://sdk.amazonaws.com/js/aws-sdk-2.1.12.min.js"></script>
  <meta name="google-signin-client_id" content="999999999999-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com">
  <meta name="google-signin-cookiepolicy" content="single_host_origin">
  <meta name="google-signin-scope" content="profile email">
  <meta charset="utf-8">
  <title>HELLO COGNITO</title>
  <script>
  function onSignIn(googleUser) {
    console.log("サインインが成功したか、または既にサインインしていました");
    const profile = googleUser.getBasicProfile(); // profileにはユーザー情報が入っている。 getName() で名前が取れたりする

    // Googleでログインできたら、トークンをAWSに渡して認証を行う
    AWS.config.region = "ap-northeast-1";
    AWS.config.credentials = new AWS.CognitoIdentityCredentials({
      IdentityPoolId: 'ap-northeast-1:ffffffff-ffff-ffff-ffff-ffffffffffff', // IdentityプールID
      Logins: {'accounts.google.com': googleUser.getAuthResponse().id_token}
    });

    // 認証できたら呼び出される
    AWS.config.credentials.get(function() {
      const s3 = new AWS.S3();
      const url = s3.getSignedUrl('getObject', {Bucket: "YOUR-BUCKET-NAME", Key: "PATH/TO/YOUR/FILE/neko.jpg"});
      console.log(url);
    });
  }

  function signOut() {
    var auth2 = gapi.auth2.getAuthInstance();
    auth2.signOut();
  }
  </script>
</head>
<body>
  <div class="g-signin2" data-onsuccess="onSignIn" data-theme="dark"></div>
  <a href="#" onclick="signOut();">Sign out</a>
</body>
</html>

GoogleAPIキーが丸見えだけど……?

静的ファイルで実現しようとしている以上仕方がありません*3。ただ、今回のような利用用途ですと、誰かがこのAPIキーを悪用しようとしてもそのシナリオが思い付きません。問題ないと判断しました。

まとめ

CognitoのIDプールは作成は簡単なのですができることはかなり限定的です。ただ、今回のように使いどころがうまくハマれば非常に手軽に(ほぼコードを書かずに)AWSリソースへの権限を与えられます。これはバグやセキュリティホールも発生しづらいということでもあり、上手に活用していけると良いと感じました。

*1:プロジェクトを作り直すしか方法が見つかりませんでした

*2:https://forums.aws.amazon.com/thread.jspa?messageID=527303

*3:以前AndroidTwitterクライアントからConsumer Keyを取り出す競技が流行したことがありましたが、本質的にはこれと同じです

日本語は1文字何バイト?

こんにちは、wakです。秋ですね。寒いですね。

さて、今日もどこかから「英語は1文字1バイト、日本語は2バイト」といった雑な話が耳に入ってきて、「UTF-8で日本語はだいたい1文字3バイト!」と抗議していたのですが、エンジニアとして「だいたい」という言葉を使うのもまた雑な話です。どんな例外があるのかをまとめておくことにしました。

f:id:nurenezumi:20171109173329j:plain

1匹あたり数兆個の細胞からなる猫

基礎知識

コードポイント

Unicodeでは世界中全ての文字に個別のコードを振っています(これをコードポイントと呼びます)。アルファベットでもひらがな・漢字でも、絵文字でもヒエログリフでも全部です。このコードポイントは通常16進数で表し、 U+FFFF の形式で書きます。たとえば「A」なら 0x41 なので U+0041*1、「あ」なら U+3042 です。JavaScriptでは "\u0041", "\u3042" などと書けば直接文字リテラルを書いたのと同じことになります。

let hoge = "\u3042"; // let hoge = "あ"; と書くのと同じ

UTF-8の文字エンコーディング

Unicode以前の文字コードSJISEUCなどでは文字コードと文字エンコーディング(=バイト列として表現する方法)とが同一でした。たとえばSJISで「ソ」の文字コード0x835C ですので、ファイルに 0x83 0x5C と書けばSJISで「ソ」と書いたことになります*2

UTF-8では、この文字エンコーディング方法にちょっと面倒な方法を採用しています。つまり、コードポイントの範囲によってバイト数が変わるのです。

  • 1バイト: U+0000U+007F (ASCII文字。例: 「A」)
  • 2バイト: U+0080U+07FF (主にギリシャ文字アラビア文字など。例: 「¶」「Ψ」)
  • 3バイト: U+0800U+FFFF (日常的に使うほとんどの文字はここ)
  • 4バイト: U+10000U+1FFFFF (その他)
  • 5バイト: U+200000U+3FFFFFF (未使用)
  • 6バイト: U+4000000U+7FFFFFFF (未使用)

U+0000-U+FFFF までの文字を基本多言語面(BMP)と呼びますが、このBMPに入っていない文字は全てUTF-8で4バイトになります*3。また、ちょっと盲点になることもあるのですが、上に示したようにギリシャ文字などは2バイトになります。

これを踏まえた上で、さらに2つのUnicodeの仕様を見ていきましょう。

異体字セレクタ

「ワタナベ」さんの「邊」(U+908A)には細かいバリエーションがあることはご存知でしょう。たとえば二点しんにょうが一点しんにょうになった「邊󠄄」(U+908A U+E0104)、右上の「自」が「白」になった「邊󠄆」(U+908A U+E0106)などです(環境によっては全く同じように表示されるため、手持ちのAndroidで表示したスクリーンショットを貼っておきます)。

f:id:nurenezumi:20171109163959p:plain

これら異体字の数は数十種類にもおよび、別々のコードポイントを振ることは現実的ではありません。そこで、まず基底文字(基本となる文字)「邊󠄆」(U+908A)を書き、続けて見えない制御文字をもう1文字書くと字体が変わるという仕組みが作られました。これが異体字セレクタです。

つまり、「邊󠄄」(U+908A U+E0104)は1文字に見えますが実際は2文字分で、しかも2文字目はBMPからはみ出していて4バイトになるので、計7バイトになります。原理的には1文字8バイトまで行きます。

結合文字

Unicodeには、単独では使われず、他の文字とくっつけるための結合文字があります。たとえばひらがなの「か」(U+304B)に結合文字の半濁点(U+309A)を繋げると「か゚」(U+304B U+309A)という見慣れない文字になります。これは1文字ですが、実体としては2文字なので、こちらもやはりUTF-8で6バイトになります。

しかも結合文字には「何文字まで」という限度がありません。「か゚」の後ろに結合文字の「○」(U+20DD)を続けて書くと、1文字9バイトのこんな文字になります(今度はWindowsのWordpadで表示した画面キャプチャを示します)。

f:id:nurenezumi:20171109164024p:plain

こうなってくると「1文字は何バイトか」という議論自体がナンセンスになってきますね。

絵文字

泥沼に踏み込むことになるのでここでは触れませんが、絵文字ではこの異体字セレクタと結合文字がふんだんに使われています。絵文字をパーツごとに組み立てていくような仕組みが採られているため、「1文字」で何バイトになるか見当も付きません。この記事が綺麗にまとまっていました。

qiita.com

結論

というわけで、UTF-8ではどのような文字が3バイト以外になるかをまとめます。

ASCII文字

いわゆる半角英数字と記号は1バイトです。これはいいでしょう。

ギリシャ文字アラビア文字など

Wikipedia一覧がありました。この U+0080U+07FF の間の文字は2バイトになります。

第3・第4水準漢字の一部

JIS X 0213に含まれる第3・第4水準漢字の大半はBMP外に収録されています。具体的には「𠀋」(U+2000B) はBMPに入らない文字の一つで、UTF-8では4バイトになります。

異体字

「邊󠄄」(U+908A U+E0104)、「邊󠄆」(U+908A U+E0106)などの異体字です。1文字に見えますが実体は2文字なので最大8バイトになります。

結合文字

いくらでも文字がくっつくため、1文字何バイトになるか分かりません。「1文字」扱いすべきかどうかは要件によります。

まとめ

これですっきりしました。「日本語のほとんどはUTF-8で3バイトになる。ただし第3・第4水準漢字の大半は4バイト。記号・結合文字は最低3バイト。あとギリシャ文字とかは2バイトだよ」と言えばいいのですね。分かっているつもりのことでもきちんと調べると気持ちが良いものです。それでは。

*1:ASCIIコードと同じ

*2:この2バイト目は偶然にもSJISの「\」(0x5C)と一致しているため、様々な問題を引き起こしていました。興味のある人は「ダメ文字」で検索しましょう

*3:5バイト・6バイトになる領域にはまだ文字が定義されていません

Fusion Tablesを使ってGoogleの環境だけでデータ分析する

サーバーを用意せずに、お金もかけずにさくっとデータ分析をする。データベース、分析実行、結果出力まで、全てGoogleのサービスだけを使って実現する方法を紹介します。

はじめに

前提条件

G Suiteユーザー

こんな人向け

データ分析をするために、例えばBIツールを入れるのはコストがかかりますよね。そこまでしたくない場合、自前で管理画面に作ったり。それもエンジニアが開発してリリースして…というので柔軟性とスピードに欠ける。とはいえ、Excelでやるにはデータ量が多すぎてできない。今回のFusion Tablesを利用した方法は手軽にやりたい時にピッタリです。

環境構築がいらない、コストがかからない、外部からアクセスできてブラウザだけで全て構築できる、権限設定もGoogleアカウントでできる、かなりメリットあります。特に出張や外出の多い私には最適だったりします。ただし、もちろんダメなところもたくさんあるので、それも最後に紹介しておきます。

G Suiteで使うアプリケーション

ものづくり

分析対象のデータをFusion Tablesに格納する

まずはこの辺を読んでFusion Tablesの基礎を。
qiita.com

分析対象のデータを突っ込みましょう。

本当はここでSQL書いたりView作ったりできれば良いんですけど、SQL書けないし、View作るにしてもものすごく単純な条件でしか作れない。ということで、Tableにデータを格納する、くらいしか使えません。

ということで、Google Apps Scriptを使います。

Google Apps Scriptで分析処理を書く

スプレッドシートでコードエディタを開いても良いんですけど、今回はドライブにスクリプトを配置します。分析元のデータや出力先のスプレッドシートが複数でも管理しやすいためです。また、出力用のスプレッドシートも作成しておきます。
f:id:jabe20:20171108104901p:plain

Google Apps ScriptでFusion Tablesを操作できるように、リソースを追加します。
qiita.com
またまたこの人のブログに頼っちゃいます。笑 分かりやすい。

これで、スクリプトからデータを取り出すところまでできました。ただ、使えるSQLはかなり限定されていて、例えば、、、

  • CONCATに当たる構文が無いため、文字列操作がほとんどできない。
  • CASEが使えないため、条件に応じた抽出ができない。
  • INSERT INTO ~ SELECTができないため、一時テーブルのようなものは作成できない。

などなど。詳細は下記リファレンス参照。
Row and Query SQL Reference  |  Fusion Tables REST API  |  Google Developers

なので、SQLで色々やって効率よく処理ができないのがけっこう辛いです。データを全部取得して、スクリプトの中でぐるぐる回して処理することになります。

スプレッドシートに出力する

下記ブログの後半部分参照。
sitest.jp
(というか、これFusion Tables作成のところから一通り書かれてるからこのブログ見たら一発かもしれないけど…笑)

デバッグについて

Google Apps Scriptでは、ブレークポイントを置いてデバッグもできます。
f:id:jabe20:20171108111021p:plain
こんな風に変数の中身チェックしたり、
f:id:jabe20:20171108111107p:plain
ステップイン、ステップオーバー、ステップアウトも。

実行時エラーはこんな感じで。
f:id:jabe20:20171108111532p:plain

SQLエラーも一応出ます。分かりづらいけど…
f:id:jabe20:20171108111711p:plain

ダメなところ

データベースから大量データを取得できない
一度に大量データを取得しようとすると実行時エラーになります。そうなるとデータを複数回に分けて取得しなければなりません。ちゃんとサイズは測っていませんが、1レコード150byteくらいのデータ約15万件は2分割でもダメで、3分割しました。

Google Apps Scriptの処理時間は6分まで
なんか無理矢理やってる人もいますが、強引すぎる…笑
kido0617.github.io
処理時間が短くなるよう、普通に処理を分けましょう。

一度に大量のデータを出力しようとすると動作が不安定になるらしい
分析結果や、分析途中のデータをFusion Tablesのテーブルに出力することもできますが、これはお勧めできません。一度に大量のデータを出力しようとすると動作が不安定になるらしいので。
qiita.com
この人、よく試してる~

制限
リファレンスにあるとおり、Fusion Tablesには以下の制限があります。
f:id:jabe20:20171108113557p:plain
※一応Google Apps Script関連の制限はここで。
Quotas for Google Services  |  Apps Script  |  Google Developers

こんな感じでデメリットも多く感じますが、メリットの方が大きいので、けっこう活躍できるんじゃないかなーと思います。あとは、Fusion Tablesの今後には期待したいところ。

Node.jsからmultipart/form-dataでデータをPOSTする

お久しぶりです。最近Node.jsばかり書いているwakです。

さて、最近こういうことがありました。

  1. Slackにスニペットをアップロードするするスクリプトを書きたい
  2. Slack botトークンをスクリプトには埋め込みたくない
  3. そうだ! Lambdaの中にトークンを書くことにしよう!

というわけで、こういう流れになりました。

  1. スクリプトはLambdaにパラメーターを渡して実行する
  2. LambdaはSlackのエンドポイントをHTTP POSTで叩いてスニペットをアップロードする

つまり、Node.jsからPOSTでファイルアップロードを行いたいのですが、ちょっと探した範囲では 素の https.request でこれを行っているサンプルが見つかりませんでした。各種ライブラリが何をしているか分からないのも気持ちが悪いので、自分で書いたコードを残しておきます。

f:id:nurenezumi:20171102092624j:plain

猫パンチを送信しようとしている猫

やりたいこと

export token="xoxb-000000000000-xxxxxxxxxxxxxxxxxxxxxxxx"
export channel="channel_name"
export filename="filename"
curl -F file=@test.zip -F channels=$channel -F token=$token https://slack.com/api/files.upload -F title=$filename

と同じデータを送信したい。内容はバイナリかもしれない。

結論

コードは次の通りです。

"use strict"

const https = require("https");

let binaryArray = [0x00, 0x40, 0x01, 0x41, 0x02, 0x42, 0x03, 0x43]; // ファイルの内容のバイト配列
let buffer = Buffer.from(binaryArray); // バイト配列をそのままBufferに
let content = buffer.toString("ascii"); // 1バイトずつstringへ変換

let options = {
    host: "slack.com",
    port: 443,
    path: "/api/files.upload",
    method: "POST",
    headers: {
        "Content-Type": "multipart/form-data; boundary=------------------------deadbeefdeadbeef"
    }
};

let req = https.request(options, res => {
  res.setEncoding("utf8");
  res.on("data", (chunk) => {
    // レスポンスを受け取って何か処理をしたいならここでやる
  });
});
req.on("error", (e) => {
  console.error(e.message);
});
req.write(`--------------------------deadbeefdeadbeef\r
Content-Disposition: form-data; name="file"; filename="filename.dat"\r
Content-Type: application/octet-stream\r
\r
${content}\r
--------------------------deadbeefdeadbeef\r
Content-Disposition: form-data; name="channels"\r
\r
channel_name\r
--------------------------deadbeefdeadbeef\r
Content-Disposition: form-data; name="token"\r
\r
xoxb-000000000000-xxxxxxxxxxxxxxxxxxxxxxxx\r
--------------------------deadbeefdeadbeef\r
Content-Disposition: form-data; name="title"\r
\r
filename\r
--------------------------deadbeefdeadbeef--\r
`);
req.end();

ここではあえてパラメーターの名前や値を直接ベタ書きしていますが、動的にやるのも別に難しくないでしょう。簡単ですね。

なお、

  • テンプレートリテラルの改行コードはプラットフォームによらずLF(\n)に統一される
  • multipartでデータを送信する際に使用する改行コードはCRLF(\r\n)とされている

という違いから、面倒だとは思いながらも各行の末尾に \r を書いています*1replace(/\n/g, "\r\n") などとやって一括置換をかけると content の中身まで置換される可能性があるのでやめましょう。

説明

上で示したように

curl -F file=@test.zip -F channels=$channel -F token=$token https://slack.com/api/files.upload -F title=$filename

こんなPOSTを送信すると、サーバーには以下に示すようなデータが送信されます。

ヘッダ部

重要な値として Content-Type があります(というか重要なのはこれだけです)。

Content-Type: multipart/form-data; boundary=------------------------deadbeefdeadbeef

これは、ボディ部にmultipartで複数のデータ(ファイルの内容、その他のパラメーター)が送信されること、そしてそれぞれの区切り目(境界区切子)が何かを示しています(RFC 2046)。 boundary の書式にはややこしい制約があるようなのですが、ハイフンをたくさん並べた後ろにランダムな英数字*2を書けば間違いはありません。

ボディ部

前半はファイル名とその内容、後半はその他のパラメーターです。それぞれの値は「ハイフン2個 + boundary の値 + CRLF」で区切ることとされているので、よく見ると boundary の値とはハイフンの数が違います。また、一番最後にはハイフンを2個付けます。

--------------------------deadbeefdeadbeef
Content-Disposition: form-data; name="file"; filename="test.zip"
Content-Type: application/octet-stream

<バイナリデータ>
--------------------------deadbeefdeadbeef
Content-Disposition: form-data; name="channels"

channel_name
--------------------------deadbeefdeadbeef
Content-Disposition: form-data; name="token"

xoxb-000000000000-xxxxxxxxxxxxxxxxxxxxxxxx
--------------------------deadbeefdeadbeef
Content-Disposition: form-data; name="title"

filename
--------------------------deadbeefdeadbeef--

テキストファイルの場合は text/plain にできます。文字エンコーディングはコード中で指定している通り(この場合はUTF-8)です。

--------------------------deadbeefdeadbeef
Content-Disposition: form-data; name="file"; filename="test.txt"
Content-Type: text/plain

テキストデータ

オチ

Lambdaの環境には curl コマンドも入っているので、ファイルを /tmp あたりに作ってそのまま実行すれば済んだりします。ただしコンテナは使い回される可能性が高いため、作成したファイルは必ず削除しておきましょう。

*1:実際にはSlackはLFのみでも受け付けてくれましたが

*2:ファイルやパラメーターの内容とかぶらないように

Analytics の内容を Slack でグラフ表示

こんにちは、久しぶりに和朗です。

サービススタート時など、Google アナリティクスの情報を見ていると面白い!!とは思いつつも、他の仕事をしているとなかなか毎日の変化に気づけません。 アナリティクスの情報を定期的にかつ視覚的に見る方法はないものかと考えました。 そこで、毎日使っているSlackとAnalyticsの間にspreadsheetsとapps scriptをいれてグラフ表示を実現しました。

f:id:teradak:20170519165901p:plain

Google Analytics の情報を取り込む

アドオンの入手

f:id:teradak:20170517193648p:plain スプレッドからGoogle Analyticsのアドオンを利用できるように設定します。

GAの情報をスプレッドに取込み

f:id:teradak:20170517193644p:plain

Create new reports を選択

f:id:teradak:20170517193650p:plain

ログインアカウントでGAのデータが参照できる状態になっている必要があります。 参照したいGAの情報がなければ、共有してもらいましょう!

f:id:teradak:20170517194620p:plain

レポート取得のための設定情報シートが作成されます。 ※複数サイトの情報を横並びに設定できます。 上記の設定は、過去2週間分のメトリクスを日ごとに取得してくるようになっています。

日毎に最新情報を取り込むようにスケジュール

f:id:teradak:20170517194935p:plain

Schedule reports を選択

f:id:teradak:20170517195107p:plain

毎日4-5時に実行 これで常に最新の値が見れるようになります。

グラフ作成

f:id:teradak:20170517195538p:plain

これは簡単ですね!データ探索を使うと面白いグラフが見れるかもしれませんw

グラフをSlackへポストする

Slackにアクセスするためのトークンを用意

f:id:teradak:20170519171727p:plain

スプレッドのグラフをファイルデータにする

スプレッドの「ツール>スクリプトエディタ」を利用してグラフをファイルデータにします。

var sheet = book.getSheetByName('[シート名]');
var chartImage = sheet.getCharts()[0]
    .getBlob().getAs('image/png')
    .setName("chart.png");

Slackにポスト

var filesUpload = 'https://slack.com/api/files.upload';
var token = '[トークン]';
var payload = {
'token' : token,
'channels': channel,
'file':chart,
'filename': name,
'initial_comment':url
};

var params = {
'method' : 'post',
'payload' : payload
};

var response = UrlFetchApp.fetch(filesUpload, params);

日毎に最新情報をポストするようにスケジュール

f:id:teradak:20170517202815p:plain

f:id:teradak:20170517202944p:plain

完成

f:id:teradak:20170518150021p:plain

まとめ

今回はじめてSlackへの画像送信を使ってみました。画像を都度作るのが大変な場合もあるかもしれませんが、視覚的に判断できる色や形で情報を共有できるということはメリットだと感じました。

参考サイト

web-tan.forum.impressrd.jp

engineer.crowdworks.jp

Class Blob  |  Apps Script  |  Google Developers

files.upload method | Slack

API Key認証付きのAPI GatewayからS3へリダイレクトしたい(できない)

こんにちは、AWS担当のwakです。

概要

表題の通りです。できない理由を書きます。また、ついでにSame-Origin PolicyとCORS (Cross-Origin Resource Sharing)についても簡単に解説します。

f:id:nurenezumi:20170310124638j:plain

猫もおてあげ

やりたかったこと

とあるデータ(刻々と追加されていきます。更新はなし)をDynamoDBに溜め込んでいます。そのデータを検索して返すWeb APIAPI Gateway + Lambdaで実装しました。

ところで、これらのデータは1日単位でS3にJSON形式で書き出してアーカイブとして保存してあります。もし検索条件が日付だけなら、わざわざDynamoDBを見るまでのこともありません。「S3のこのURLを見てね」と言えばいいだけです。つまり、こういうことができると都合がいいわけです。

  1. API Gateway経由でLambdaを起動する
  2. 条件が複雑な場合は普通にDynamoDBを検索して返す
  3. 条件が日付だけの場合はHTTP 303 See OtherでS3にリダイレクトする

3番目は無理でした、というのが今回の記事の趣旨になります。以下、その説明をします。

Same-Origin Policy

ブラウザには、RFC 6454で定められたSame-Origin Policy(同一オリジンポリシー、同一生成源ポリシー)というルールがあります。ここでは「オリジン」が何かはちょっと後回しにして、次のようなシナリオを考えてみます。

  • mycompany.local という社内Webサーバーがあるとします。このWebサーバーは外部には公開されていません
  • 私はここなら安心だと思って、秘密のデータを http://mycompany.local/himitsu.json として置いておきます。
  • 悪い人がいて、どこからかこのURLを入手したとします。
  • 悪い人は http://evil.com/dorobou.html というWebページをインターネット上に作り、何らかの方法*1で私にアクセスさせます。
  • このWebページにはJavaScriptが書いてあります。スクリプトはWebページを開くと同時に勝手に上記のURLにXHRでアクセスし、取得したデータを全て外部に送信してしまいます。
  • 私は死にます。
f:id:nurenezumi:20170310123858p:plain

このようなことではいけません(私は死にたくありません)。もちろんこの秘密のデータを他のWebサイトから参照したいような状況もありますが、少なくとも全ての外部Webサイトのスクリプトからアクセスできる必要はまったくありません。そこで、スクリプトは原則として「同一のオリジン(場所)」にあるリソースにしかアクセスできないというルールができました。これが同一オリジンポリシーです。

オリジンとは

オリジンという言葉が突然出てきましたが、これは大ざっぱに言えばドメインのことだと理解すればいいです。厳密にはRFC 6454で定義されていて、

  • HTTP/HTTPSの区分
  • FQDNexample.comとか、www.example.comとか)
  • ポート番号

をセットにしたものです*2。つまりhttp://evil.com上にあるスクリプトは、次のような場所にあるリソースにはアクセスできないわけです。

  • http://mycompany.localドメインが全然違う)
  • https://evil.com(同じに見えるけどHTTPとHTTPSが違う)
  • http://evil.com:8080(ポート番号が80番と8080番で違う)
  • http://www.evil.com(これも別扱い)

これだけ厳密なチェックが入るなら安心ですね。めでたしめでたし。

CORS (Cross-Origin Resource Sharing)

ところが、異なるオリジンをまたいで外部リソースを参照することが一切許されないのでは不都合なことも多々出てきます。そこで、リソースが置いてある側、つまりmycompany.local側で事前に設定を行い、どのようなタイプのリクエストなら許可するかをホワイトリスト形式で指定することになりました。これがCORS (Cross-Origin Resource Sharing)です。

具体的には、ブラウザからのリクエストに応じ、リソースを置いてあるサーバーがHTTPヘッダで「どこからのアクセスなら許可するか」「リクエスト時に含まれていても良いHTTPヘッダ」のような項目を回答します。ブラウザはこの値をチェックし、リクエストの内容がそれとマッチするときにだけアクセスを許可します(そうでないならアクセスをブロックしてネットワークエラーを発生させます)。この制御により、リソースの所有者の意図、ブラウザの利用者の意図に反してアクセスが行われてしまうことを防ぐわけです。

余談:CORSは何でないか

CROSはあくまでブラウザが(いわば)自主規制をするだけのもので、ユーザーが意図的に発生させたリクエストをブロックするようなものではありません。悪意を持って作成されたスクリプトを実行してしまっても、リソースの所有者やユーザーが意図しないうちに勝手にリクエストが発生してしまう(発生させられてしまう)ことを防ぐための仕組みです。セキュリティについてはサーバーサイドできちんと考える必要があります。

2種類のクロスオリジンリクエスト

このように、アクセス元のオリジン(上記の例ですとhttp://evil.com)、リソースのあるオリジン(同じくhttp://mycompany.local)とが異なる場合のアクセスはクロスオリジンリクエストと呼ばれます。ブラウザはこのリクエストを2種類に分けて扱います。

シンプルなクロスオリジンリクエスト

シンプルなクロスオリジンリクエスト(Simple cross-origin request)とは、特定のHTTPヘッダのみ・特定のContent-Typeの値のみを持つGET/POST/HEADリクエストです(詳しくはリンク先を読んでください)。jQueryでこのようなコードを書いた場合がこれに該当します*3

$.ajax({
    url: "https://...",
    type: "GET",
    headers: {}
}).done(function(data) {
    console.log(data);
});

スクリプトが「シンプルなクロスオリジンリクエスト」を送信した場合、ブラウザはまずレスポンスヘッダの中からAccess-Control-Allow-Originヘッダの値を確認します。その値が元のオリジンと一致すれば(*https://*.example.comなどのワイルドカードも使用可)そのアクセスは認められることになります。逆に一致しないか、そもそもAccess-Control-Allow-Originヘッダがなかった場合、そのアクセスはブロックされてしまいます。

シンプルではないリクエスト

前項のシンプルなクロスオリジンリクエストではないリクエストは全てこれです。認証のためにAuthorizationヘッダやX-Api-Keyヘッダを付加する場合、PATCHリクエストやDELETEリクエストを送信する場合などが該当します。たとえばBASIC認証が必要なURLにアクセスするため、jQueryで次のようなコードを書いたとします。

$.ajax({
    url: "https://...",
    type: "GET",
    headers: {"Authorization": "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="}
}).done(function(data) {
    console.log(data);
});

この場合、ブラウザはGETリクエストに先立ち、同じURLに対して自動的にOPTIONSリクエストを送信します。これはプリフライト(preflight)と呼ばれ、スクリプト側からは制御できず強制的に行われます。クロスオリジンリクエストを受け付けたいWebサーバーは、このOPTIONSリクエストに対して「どのようなリクエストなら受け付けても構わないか」を返答しなければなりません。返答は全てレスポンスヘッダで行います*4

API Gateway

以下、API GatwayからLambda (Node.js)のコードを呼び出し、そのまま結果を返すか、またはS3へリダイレクトすることを考えます。また、API Gateway側は「Enable CORS」の設定を済ませていて、さらにLambda Proxy integrationを使うことを前提にします。リクエストはGETを利用します。

f:id:nurenezumi:20170224071730p:plain

A. シンプルなクロスオリジンリクエスト&リダイレクトを行わない場合

一番単純なパターンです。ブラウザが前述の「シンプルなクロスオリジンリクエスト」を送信してきて、かつリダイレクトを行わない場合です。Lambda側の処理はこうなります。

callback(null, {
    statusCode: 200,
    body: JSON.stringify({message: "nya-!"}),
    headers: {
        "Access-Control-Allow-Origin": "http://example.com",
        "Content-Type": "application/json; charset=utf-8"
    }
});

ブラウザはここでセットしたAccess-Control-Allow-Originヘッダの値を確認し、アクセス元のオリジンと一致するかどうかを調べて、問題がなければアクセスを許可します。API Gateway側のCORS設定は使用されません。

B. シンプルではないクロスオリジンリクエスト&リダイレクトを行わない場合

最初にブラウザがプリフライトリクエストを送ってきますが、これにはAPI Gatewayが勝手に応答します*5。レスポンスヘッダにはAccess-Control-Allow-Origin: *という内容が含まれていて*6、ブラウザは最初のチェックを行います。

このチェックをパスしたら、ブラウザは改めてGETリクエストを送りますが、後の処理は前項(A)と同じです。再度Access-Control-Allow-Originのチェックがかかるところも変わりません。

C. シンプルなクロスオリジンリクエスト&リダイレクトを行う場合

可能ですが制約が厳しくなります。

callback(null, {
    statusCode: 303, // See Other
    body: "",
    headers: {
        "Access-Control-Allow-Origin": "http://example.com",
        Location: `https://s3-ap-northeast-1.amazonaws.com/BUCKET-NAME/path/name.json`
    }
});

最初のアクセスに対してAPI Gatwayは303を返します。ブラウザはAccess-Control-Allow-Originをチェックした上でリダイレクト、つまり指定されたS3へ再度GETを送ります。

ところがこの2回目のGETでは、ブラウザはオリジンとしてnullを使用します*7。この挙動はRFCで規定されています*8。したがって、リダイレクト先のS3側ではAccess-Control-Allow-Origin: *を返す必要があります。CORSでのアクセス制御がかけられないことを意味していて、重要なデータを扱うには非現実的になります。

cors - Are there any browsers that set the origin header to "null" for privacy-sensitive contexts? - Stack Overflow

D. シンプルではないクロスオリジンリクエスト&リダイレクトを行う場合

ダメです。

Chrome cancels CORS XHR upon HTTP 302 redirect - Stack Overflow

こちらで紹介されているW3C Recommendationにはこのような記述があります。

If the response has an HTTP status code of 301, 302, 303, 307, or 308 Apply the cache and network error steps.

つまり、このような処理が行われます。

  1. 最初にブラウザはプリフライトリクエストとしてOPTIONSリクエストを送信します。
  2. API GatewayはHTTP 200 OKを返します。レスポンスにはAccess-Control-Allow-Originも含まれます。
  3. 次にブラウザはGETリクエストを送信します。
  4. API GatewayはLambdaを実行し、上記のコードに従ってHTTP 303 See Otherを返します。
  5. ブラウザは無条件で通信をキャンセルします。

API GatewayAPI Key認証を行うためにはリクエスト時にX-Api-Keyヘッダを含める必要があります。このヘッダを含めた場合、リクエストは「シンプルなクロスオリジンリクエスト」ではなくなってしまいます。するとCではなく)のパターンになり、ブラウザは仕様上通信を行うことができません。

結論

お疲れ様でした。

*1:同僚のSlackのアカウントを乗っ取って「これを見てください」と言ってURLを送るとか

*2:データURIなどについても記述がありますが省略します

*3:本当は jquery.get() で書けますが比較のために jquery.ajax() で書いています

*4:OPTIONSリクエストですからボディ部はありません

*5:「Enable CORS」の設定を行ったときに追加されたOPTIONSメソッドの設定に従ってレスポンスが送信されます

*6:“*"はデフォルト値です。もちろん変更も可能です

*7:Chrome 56.0.2924.87, Firefox 51.0.1で確認

*8:ただし「リダイレクトのときはこうなる」と明示されているわけではなく、ブラウザの実装に依存します