AWS明細データの歩き方

こんにちは、AWS担当のwakです。今年もよろしくお願いいたします。ずいぶん間が空いてしまいましたが、今年はペースアップしていこうと思います。

f:id:nurenezumi:20190110101608j:plain
今年は猫年(だったはず)

AWS料金明細データ

さて、今回はAWSの利用料金明細データの話をします。AWSの現時点での料金、過去数ヶ月の料金などは 請求ダッシュボードAWS Cost Explorer などで参照することができますが、この元となる詳細なデータは、事前に「リソースとタグを含む詳細な請求レポート」を出力するように 設定 しておくことでS3にCSVファイルとして出力することができます。

このファイルは

999999999999-aws-billing-detailed-line-items-with-resources-and-tags-2019-01.csv.zip

という書式の名前のzipファイル*1で、費目・1時間ごとに1行記載される形となっています。たとえばEC2を1ヶ月起動していると、1時間ごとに

  1. EC2の利用料金
  2. EC2のネットワークI/O料金(IN)
  3. EC2のネットワークI/O料金(OUT)
  4. EBSの利用料金

の4行*2が発生し、1ヶ月=720時間では数千行に達します。今回はこのファイルを細かく集計してみたい人に向けて明細データの読み方について書きます。

ファイル概要

CSV形式

標準的なカンマ区切りのテキストファイル(UTF-8エンコーディング、改行はLF)です。数値を含め、全てのフィールドはダブルクオートでくくられています。また "$0.0037 per 10,000 GET and all other requests" のようにカンマを含むフィールドが多数存在するため、ダブルクオートを無視してカンマ区切りで読み込もうとすると悲しいことになります。awkなどで処理したい場合は "," を区切り文字とすれば良いでしょう。

記載されている内容

毎月月初にAWSから請求書(PDF形式)がメールで届きますが、明細データにはこの1ヶ月分の請求書の内容がまとめて出力されています。RIを購入した場合は別途請求が発生するのでこちらには含まれません。

カラム数は固定(ただし設定次第)

カラム数は固定です。ただし一括請求を有効にすると Rate, Cost カラムは BlendedRate, BlendedCost, UnBlendedRate, UnBlendedCost カラムになります(後述)。

また、「コスト配分タグ」でタグ(たとえば IsDev タグと Project タグ)を追加すると、行の末尾に user:IsDev, user:Project カラムが追加されます(アルファベット順にソートされるようです)。このタグの値をもとに AWS Cost Explorer などで料金を集計することができます。設定を変更するとカラム数も変わってしまいます。

請求書番号

明細データの最初のカラムは InvoiceID で、請求書の上部に記載されているInvoice Numberが書いてあります。月の途中の場合は請求書がまだ発行されていない(=Invoice Numberが確定していない)ため全ての行で "Estimated" になります。

レコードID

各行には RecordId というフィールドがあり、行ごとにユニークな値が振られています。IDは整数形式ですが、 155050012134381272590339883 (=1.55*1026、287) のように非常に大きな値で、かつレコード間で差が10~20前後しかないものがあるので、大抵の場合は文字列として扱う必要があります(64bitのfloat型などではまるで精度が足りません)。

末尾には特殊な行がある

ファイルの末尾には

を記載した行があります。集計する場合はここを考慮する必要があります。(料金総額は除いておく、サポート・消費税は利用料金に含めておくなど)

レコードの内容

具体的に1レコード(1行)の内容を見ていきます。

#カラム名概要
1InvoiceID前述。請求書に書いてあるInvoice Number
2PayerAccountId料金を支払うアカウントのアカウント番号
3LinkedAccountId料金を発生させたアカウントのアカウント番号。一括請求を使わない場合は(2)と同じ
4RecordTypeほとんどが"LineItem"(後述)
5RecordId前述。レコードに振られたユニークなID
6ProductNameプロダクト名。 "Amazon Simple Storage Service"、"AWS Lambda" など
7RateId-
8SubscriptionId-
9PricingPlanId-
10UsageType費目1
11Operation費目2
12AvailabilityZoneAZ名
13ReservedInstanceRIが適用されているなら"Y"、そうでないなら"N"
14ItemDescriptionこの行の説明
15UsageStartDateこの行のタイムスタンプ("2019-01-02 03:00:00"の書式。1時間単位)
16UsageEndDate同上(その1時間後)
17UsageQuantity使用量
18BlendedRate単価1。後述
19BlendedCost料金1((17)×(18)に等しい)
20UnBlendedRate単価2。後述
21UnBlendedCost料金2((17)×(20)に等しい)
22ResourceIdリソースIDまたはARN
23以降user:XXXX出力するように設定したタグの値

RecordType について

実際の料金が記載された行(ほとんどがこれ)は LineItem となっています。集計に使う場合は LineItem の行だけ抜き出せばいいです。それ以外の行はファイル末尾にしかありません。

UsageType について

あまり綺麗なデータではありません。たとえばEC2に限っても、 APN1-BoxUsage, APN1-BoxUsage:m5.large, APN1-EBSOptimized:m5.large のようにインスタンスサイズがあったりなかったりします。

末尾のタグについて

たとえば Project というタグを指定した場合、23カラム目にはその行のリソース(EC2インスタンス、RDSインスタンス、S3バケット……)の Project タグの値が入ってきます。

すべてのレコードにこのタグが出力されるわけではありません。タグを付け忘れたリソースの行にはもちろん空文字が入ります。また(徐々に対応するサービスが増えてはいますが)2019年1月現在、CloudWatchやAPI Gatewayにはタグを付けることができないため、ここは空文字が入ります。別途 ProductName, ResourceId で集計するなどの工夫が必要になります。

(Un)BlendedCost, (Un)BlendedRate について

AWSの料金のほとんどは使えば使うほど割安になっていきます。たとえば2019年1月現在、東京リージョンのS3標準ストレージの料金(ストレージ使用分)は次の通りです。

  • 最初の50TBまでは $0.025/GB・月
  • 次の450TBまでは $0.024/GB・月
  • 500TB以上は $0.023/GB・月

ではアカウントが2個(A・Bとします)あり、それぞれS3を100TBずつ利用しており、さらに一括請求を利用している場合の料金はどうなるでしょうか。 公式ドキュメント によれば、1個のアカウントで200TBを使ったときと同じ料金になります。つまり、

  • 最初の50TB分($0.025×50,000=$1,250)
  • 残りの150TB分($0.024×150,000=$3,600)

で合計 $4,850 ということになります。

ところが明細データはアカウント別にレコードが出力されますので、これを無理やりアカウントAとBに分けなければなりません。そこで、明細データの上では

  • 便宜上 アカウントAで最初の50TBを利用($0.025×50,000=$1,250)
  • アカウントAで残りの50TBを利用($0.024×50,000=$1,200)
  • アカウントBでさらに100TBを利用($0.024×100,000=$2,400)

というデータが出力されます(AとBどちらが選ばれるかは謎です)。ここで適用される単価が UnBlendedRate で、それをもとに算出された料金が UnBlendedCost です。しかし、まったく同じようにS3を使っているのにアカウントAとBで料金が違う(ように算出される)のでは困ります。そこで、一括請求を前提としてこれを平準化した単価が BlendedRate 、それをもとに算出された料金が BlendedCost です。

UnBlendedRate, UnBlendedCost の良いところは、単価がAWSの公表しているものと一致しているので検証しやすいことです。悪いところは実態と合わない(アカウント・リソース間で割引の有無の差異が発生して料金に差が出てしまう)ことです。総額は一致するので、どちらか使いやすい方を選びましょう。

EC2 RIについて

EC2 RIについても触れておきます(以下、OSはすべてLinuxとします)。

サポート利用料は月の請求に載る

RIを購入するたびに別途請求書が発行され、支払いはその都度行います。しかしサポート料金*3は月の料金に応じて決まるため、サポート利用料は月ごとの請求書の方に載ります。ここを忘れていると、RIで高額な支払いを済ませた翌月に料金が跳ね上がっていてびっくりすることになります。

RIは特定のインスタンスに紐付くわけではない

RIはあくまで「特定のインスタンスタイプのインスタンスを安く利用できる仕組み」です。さらに(ありがたいながらも)ややこしいことに、 RIの柔軟性 という仕組みがあります。これがどういうことが例示します。

まず m3.2xlarge の全額前払いRIを4個購入したとします。また、

を起動していたとします。

RIは1時間ごとにA~Gの いずれか に適用されます。したがって、

  • A~Dに適用される(A~Dが無料になる)

というケースもありますが、

  • A~CにRIが3個分適用される(A, B, Cは無料になる)
  • 残る1個のRIが次のように分配される
    • Dには25%分適用される(Dの料金は25%引きになる)
    • E には50%分適用される(Eの料金は m3.2xlarge の50%なのでEは無料になる)
    • F, G にはそれぞれ12.5%分適用される(F, Gの料金は m3.2xlarge の12.5%なのでF, Gは無料になる)

ということもあり得ます(いずれも料金の総額は変わりません)。後者の場合、次のようなレコードが出力されます。

ResourceIdItemDescription(概要)ReservedInstanceUsageQuantityUnBlendedRate
Am3.2xlargeのRI適用Y10
Bm3.2xlargeのRI適用Y10
Cm3.2xlargeのRI適用Y10
Dm3.2xlargeのRI適用Y0.250
D標準料金 ($.077/h)N0.750.77
Em3.2xlargeのRI適用Y10
Fm3.2xlargeのRI適用Y10
Gm3.2xlargeのRI適用Y10

インスタンスE, F, Gのインスタンスタイプは m3.xlarge, m3.medium なのに( UsageType カラムにはそれぞれ APN1-BoxUsage:m3.xlarge, APN1-BoxUsage:m3.medium と書いてあります)、 ItemDescription には「m3.2xlargeのRIを適用した」と書いてあるのがポイントです。

BlendedRate, BlendedCost はRIでも働いていて、こういった料金を平準化した料金が記載されています。

調整したい場合は?

とはいえ、「自分はこのインスタンスの料金を前払いしたんだ」という気持ちでRIを購入することもあるでしょう(たとえば上のケースですとインスタンスA~Dの料金を常に0とし、E~Gの ReservedInstanceY となっている行に標準料金を加算したいといった感じです)。そのような場合は、

  • RIの柔軟性 に記載されているインスタンスサイズと係数のテーブル
  • UsageType カラムに記載されているそのインスタンスのサイズやOSから求めた標準料金
  • ItemDescription カラムに記載されている適用されたRIのタイプとサイズ

などを組み合わせて求める形に修正してやる必要があります。非常に面倒なのでオススメはしません……

スナップショットの料金

EBSのスナップショットはS3に保存され、1時間ごとにストレージ利用料金が加算されていきます。 ResourceId カラムにはスナップショットのARNが記載されています:

arn:aws:ec2:ap-northeast-1:999999999999:snapshot/snap-0123456789abcdef0

2019年1月時点では、AMIを作成した際に生成されるスナップショットにはタグが付きません(手動で追加することはできます)。タグの値が空ですとこのスナップショットの正体が分かりません。料金がどのEBSから発生したものかを知りたければ、このARNからスナップショットを取得して作成元のEBSを特定する必要があります。しかし、AMIやスナップショットを定期的に作成・削除するような運用をしている場合、料金集計をしようとした時点では既にそのスナップショットが存在しないということがあり得ます。AMI/スナップショットを作るたびにタグを追加するか、Snapshot IDとEBS Volume IDの対応表を定期的に取得して保存しておくような処理を仕込んでおくと良いでしょう(LambdaとDynamoDBを使うと楽です)。

RDSのストレージ利用料金

RDSのバックアップに利用しているストレージ利用料金は、(なぜか)それがどのインスタンスのものかは分からず、 ResourceId が空文字になります。これはどうしようもないので、

  • ResourceId が空文字以外のものについてRDSの料金を集計する
  • RDSの利用料金に応じてストレージ利用料金を按分する

という手で振り分けてしまうのがいいと思います。

集計!

というわけで、たとえば Project というタグの値に基づいて明細データの料金集計をしたい場合、次のような流れになります。

  • zipファイルをS3から取得、解凍
  • RDSやRなどに読み込む
  • RecordTypeLineItem の行だけ抜き出す
  • Cost または BlendedCost の値を user:Project の値に応じて合算する
    • user:Project の値が空文字のレコードについては2パターンがある(タグを付けるのを忘れていたもの、タグを付けられないもの)
    • タグを付け忘れていたものについてはデータ修正を行って再集計する
    • それ以外のものは ProductName, ResourceId の値に応じて Cost または BlendedCost の値を合算する

以上、明細データの見方についてハマりやすいところを簡単にまとめてみました。正しくコストを把握して正しくAWSを利用していきましょう。今年もよろしくお願いします。

*1:展開すると.zipが取れる

*2:VPCをまたいだ通信があったりEIPを使ったりするとネットワークI/Oの行はそれぞれ2行ずつ増えます。EBSが複数存在すればその分も増えます

*3:ビジネスサポート以上の場合

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