読者です 読者をやめる 読者になる 読者になる

でこてっくろぐ ねお

でこてっくろぐ(http://dekokun.github.io/)から進化しました。でこらいふろぐ(http://dekolife.hatenablog.com/)の姉妹版。デコテックログ(deko tech log)である

CloudFormationのスタックポリシーでUpdate:*をDenyしててもやはり消える時は消える、という話及び削除への対処

このエントリで伝えたいこと

  • CloudFormationのスタックポリシーでUpdate:*をDenyしてても安心はできない。消える時は消える
  • 上記はある程度皆知ってるかもしれませんが、一応試してみた、及び考えられる削除に対する保護の方法を考えるという感じのエントリ

スタックポリシーについて

CloudFormationにおいて、一度作成したリソースが変更されたり削除されたり置換されたりするのを防ぐ方法があります。 スタックポリシーですね。例えば、以下のようなJSONhoge_policy.json というファイルに保存し aws cloudformation set-stack-policy --stack-name hoge-stack --stack-policy-body=file://./hoge_policy.json のようなコマンドで適用すると、CloudFormation経由では対象のstackのリソースに対しては更新が一切できないようになります

{
  "Statement" : [
    {
      "Effect" : "Deny",
      "Action" : "Update:*",
      "Principal": "*",
      "Resource" : "*"
    }
  ]
}

実際に上記のようなポリシーを適用したスタックに対してCloudFormation経由で変更を加えようとすると、以下のようなエラーが出力されて変更ができません。

Action denied by stack policy: Statement [#1] does not allow [Update:*] for resource [*];

ではこれで安心かというと、もちろんそうではないんですよね。スタックポリシーについては以下ドキュメントにあるように、スタックの更新時にしか適用されません。 逆に、スタックの更新時以外の場面では無力ということになります。

スタックポリシーは、スタックの更新時のみ適用されます

参照: スタックのリソースが更新されないようにする - AWS CloudFormation

それは具体的にどのような時かというと、パッと思いつく範囲では以下のような時になります。一応以下両方とも試してみて、スタックポリシーで Update:*Deny されている状態で対象のリソースが削除できることを確認しました。

  • 対象のstack自体を削除
  • CloudFormationを経由せずに手(もしくはAPI)で削除

対処法

stackの削除への対処

stackが削除された際でも対象のリソースを残しておきたい場合は、以下のようにDeletionPolicyを指定する方法が有効です。 docs.aws.amazon.com

具体的には、CloudFormationにおいては以下のように DeletionPolicy : Retain を指定しておく、という感じになります。こうしておくと、Stackが何かによって削除されても、そのリソース自体は残り続けます(一応試しました)

exampleSecurityGroup:
  Type: "AWS::EC2::SecurityGroup"
  Properties: 
     ... 中略
  DeletionPolicy : Retain # これ

このエントリを公開した後に、以下のような反響をもらいました。 確かに、上記の場合、対象のセキュリティグループにDeletionPolicyをつけた状態でVPCごとstackを削除したりするとどうなるのか、大変気になりますね。また試してみたらエントリにしてみます。

手動やAPI等の、CloudFormation以外での対象のリソースの削除

こちらの対策については、CloudFormation関係なく、一般的なAWSのリソースの保護方法を採ることになるかと思います。 と言ってもこちらは私はあまり詳しくなく、EC2の場合はterminateion protectionを付ける、くらいしか知りません。 他のリソースでも削除から保護することはできるのでしょうか。是非教えていただきたい。

CloudFormationでEC2のtermination protectionを付ける方法は以下のとおりです

  SampleEC2: 
    Type: "AWS::EC2::Instance"
    Properties: 
      DisableApiTermination: true # これ

雑感

今回は、かなりわかりきった内容、という感じではありますが、一応テストしてみたのでブログにまとめてみたという感じでした。 実際CloudFormationにおいては重要なリソースの保護をどのように行うかについてはある程度考えておく必要があるなというのが最近の思いであり、そのあたりはまた別エントリにまとめたいなと思っています。

AWSのIPリストを取得してCloudFormationで使えるセキュリティグループの定義を出力する(golang編)

やりたいこと

  • 全世界のAWSのEC2からだけ許可するsecurity groupを作りたいとかそんな感じのやつ
    • それにどれくらいの嬉しさがあるかと言うと、まぁ人それぞれだよね
  • 以下のテンプレート部分を少し変えると、AWS WAFの定義で全世界のAWSのEC2からのアクセスを拒否するとかそういうのにも使えるよね

方法

以下のスクリプト使うとSecurity Groupの定義が一個ペロッと出力されるので後は勝手に使うといいと思います。

最大の問題は、このIPレンジは当然そのうち変わるので、本来はJSONファイルの変更を検知してlambdaを動かして最新の情報に追随させるみたいなことをやる必要があるんですが、まぁ何か雑にやりたい時はこういうのでもいいんじゃないですかね。

ちゃんとやりたい場合は以下クラスメソッドさんのエントリ参照

dev.classmethod.jp

プログラム

GitHub - bhendo/awsipranges: A simple Golang library for parsing and searching https://ip-ranges.amazonaws.com/ip-ranges.json 使わせていただいてます。 github.com/bhendo/awsiprangesインストールして以下を同じディレクトリにおいてボンっと叩くといいと思います。あまりにも単純。 glideとか使いたい人は使ってくださいね。 - package: github.com/bhendo/awsipranges みたいな感じで。

“EC2” の部分を色々変えれば、CLOUDFRONTのIPだけ許可するセキュリティグループとかそういうのもできますね。 tmpl内の IpProtocol とか FromPort, ToPort とかも好きな感じに変えてね。 めでたしめでたし。

セキュリティグループ内のIPの数は制限があるので、制限にあたったら制限緩和するなり、複数のセキュリティグループに分割するなりいい感じにしてみてね。このテンプレートなら簡単にできるはず。

gist.github.com

雑感

上記に書いてある通り、ちゃんとしたい場合はlambdaでいい感じにやるべきだし、そもそもAWSのIP範囲だけに絞って許可する、という設定にどれほどの意味があるのか、というのは色々あるでしょうが、まぁ雑にこういうことしたい場合もありますよね。 こういうことやってると、CloudFormationをそのまま使うのは間違いなのではないか、という気分にはなる。

以下のようなブログも書いたことですし、CloudFormation周りのことを色々ブログに書いていきたい。 dekotech.dekokun.info

AWSで作業する時は今後はずっとCloudFormationだけ使っていこうと思った

最近結構CloudFormation触っていて楽しい。

普通にボタンポチポチより便利だし、一回作ってしまうと同じ環境をコピーするのが大変容易だし、ブログとか書く際はtemplateファイルを共有するだけで同じ環境を再現してもらえるし、どういう設定があるかを設定のタイミングで一覧して知ることができるし、公式のsnipetも充実しててそこから学べる部分も多いしという感じで、とにかくCloudFormation最高、という気分になっている。

副作用のある操作はCLIもWeb Consoleもsshも使わずひたすらCloudFormationで今後はいこう、という気持ちである。

やっていくぞ!

以下、「人生はCloudをFormationするものと得たり」は最近の私の名台詞の一つ

Varnishによる一貫性を考慮した積極的キャッシュ戦略実験

この記事は、はてなエンジニアアドベントカレンダー2016の9日目の記事です。昨日は id:cockscomb による Swift 3 の Data とポインタ使いこなし術 - Hatena Developer Blogでした。今日はISUCON 6の予選問題とVarnishを組み合わせてみた話を書きます。

はじめに

みなさんは今年の夏に行われたISUCON6には参加しましたか?私は参加しましたが予選落ちでした。また、最近、私は業務でVarnishというHTTPキャッシュサーバに触ることが多いです。 今回、ISUCON6が終わった後、Varnishで何ができるかの検証がてらISUCON6の予選問題で少しトリッキーな実装をした話を書きます。

実装の方針は以下のとおりです。

  • キャッシュに必要な実装以外は出来る限りアプリケーションには手を入れない
  • 全てのリクエストをキャッシュサーバを通す
  • 更新は即座に反映させつつ出来る限りキャッシュヒット率を高くする

本記事では、どのようにすればその作戦が可能になるのか、実際に行ってみてどうだったかなどをまとめます。

ISUCON6 予選問題について

どのようなアプリケーションかについて詳しくは ISUCON6 予選問題の解説と講評 を読むと分かるのですが、Wikipediaのようなアプリケーションでした。 複数のキーワードとその解説が載っており、キーワードの解説内に別のキーワード名が出現している場合は、そのキーワードへのリンクが自動的に貼られます(以下この記事では"キーワードの自動リンク機能"と呼びます)。 また、ユーザはログインすればキーワードとその解説の投稿が可能であり、更にはてなスターのような、投稿に対してスターをつけるような機能がありました。

ISUCONで最初に用意された実装ではこのキーワードの自動リンク機能が非常に遅く、ここをどのように対処するかが勝負の分かれ道でした。

リポジトリ

運営が用意したリポジトリをforkして以下で作業をしていました。

GitHub - dekokun/isucon6-qualify

今回の記事で扱うのは以下ファイルです。

  • アプリケーションコードはwebapp/go以下
  • キャッシュサーバの設定はwebapp/config/default.vcl

作戦

最初にも書きましたが、以下3点の作戦で進めました

  • キャッシュに必要な実装以外は出来る限りアプリケーションには手を入れない
  • 全てのリクエストをキャッシュサーバを通す
  • 更新は即座に反映させつつ出来る限りキャッシュヒット率を高くする

proxyサーバアプリケーションサーバの間にVarnishを入れ、全ての参照リクエストについてキャッシュが存在すればそれを返し、存在しなければVarnishがアプリケーションサーバにリクエストしてその結果を返します。 なお、この方針ではISUCONのベンチマークの得点は極めて低いです。理由なども以下に記載しています

なぜキャッシュで進めようとするのか

  • 最近仕事でよくVarnishに触れていることもあり、Varnishを活用すると何がどこまで出来るのかを実際に実装して試してみたくなったため
  • 上記に関連して、一度、"参照リクエストをキャッシュしつつキャッシュデータを適切に破棄して更新結果は即座に反映されるようにする"というのをやってみたかったから
    • “いつかはやるぞ"と思いつつ、なかなか始められていなかったところにちょうど手頃そうな規模感なISUCONの予選問題がふってきたから

構成

以下のような3段構成になっています。実際のISUCON予選問題はマイクロサービス化しておりもう少し複雑なのですが、この記事を読む上では以下の構成を認識していれば十分です。ISUCON予選を解いた人のためにもう少し解説すると、今回は説明の単純化のためにIsudaの話しかしません。この記事を読む上ではIsutarやIsupamはなかったこととして考えて頂いて問題ありません。 nginxからのリクエストは全てVarnishを通り、VarnishはリクエストがGETリクエストかつキャッシュがあればそれを返し、それ以外の場合はAppにリクエストを渡すという構成です。

     |
  [nginx]
     |
 [Varnish]
     |
 [App(golang)]

正確にはESIでのVarnishからAppの呼び出しがあるので上記以外のHTTPの流れもあるのですが、そちらについては以下で説明します。

レスポンスをキャッシュさせるとどのような問題が起きるか

動的なサービスにおいてレスポンスをキャッシュさせるには色々と考えなくてはいけないことがあります。今回のISUCON予選のアプリケーションでは以下2つの大きな問題を解決する必要がありました。

  • ページが更新された時にどのように即座に新しいデータを反映させるか?
  • ページ全てをキャッシュできない

以下で問題の詳細及び解決策の案、今回はどのように解決したかについて記述します

ページが更新された時にどのように新しいデータを反映させるか?

ページをキャッシュするということは何もしなければキャッシュ期間中は同じページが出続けることになります。今回のISUCONでは、データの更新が反映されていないと点数が下がってしまう作りになっていたため、キーワードの更新があった場合はそのページのキャッシュを破棄することが必要です。 実際のサービスでも、更新直後に更新対象のページにアクセスした際に更新結果が表示されていないとユーザが違和感を覚える場合が多いかと思います。

更新内容の反映は更新があったキーワードのページのキャッシュを破棄するだけでいいかというとそれだけではありません。 今回のISUCON予選問題では新たにキーワードが投稿されたタイミングで既存のページでもそのキーワード文字列を含むページでは自動的にリンクが貼られる必要があるため、適切にそれらを探し出してキャッシュデータを破棄する必要があります。

ページが更新された時にどのように新しいデータを反映させるか?解決策案

  1. キャッシュ期間を短めにしてデータを更新してもキャッシュは破棄しない
  2. 更新があった際に全キャッシュを破棄する
  3. 更新があった際に更新に関係するページのキャッシュを破棄する

1. については、キャッシュ期間を短くするとダイレクトにキャッシュヒット率が低下するため今回は採用しません。実際のサービスにキャッシュを入れることを考えた場合はこの方式が実装は一番簡単になるので、必要な要件とキャッシュヒット率とを考えた上でこの方式を採用する場合は多いかなと思います。 2. は更新が多いとキャッシュヒット率が極端に低下するため、あまりうれしくはなさそうです。更新頻度の低いサービスであれば採用する余地はありそうです。 3. は、適切にこれを実装出来るのであればこの方式が一番キャッシュヒット率が上がり嬉しそうです。今回はこちらを採用しています。

ページ全てをキャッシュできない

ISUCON予選のアプリケーションにはログイン機能が存在し、ログインすると全ページにおいて"Hello, ○○(ユーザ名)“という表記が出ます。 これにより普通にページ全てをキャッシュしてしまうと"ログインした際に別のユーザ名が表示される"等の問題が起きることになります。 このような問題は一般的なサービスでも、ログイン機能がある場合よく起きるのではないかなと思います。

f:id:dekokun:20161209112806p:plain

ページ全てをキャッシュできない 解決案

  1. ユーザ名の表示したさとキャッシュのしたさとその他の実装の難易度を天秤にかけて各ページのユーザ名は表示しなくて良いことに決める
  2. ユーザ名などはJS等で別途読み込む
  3. Cookieを見てログインしていたらキャッシュを返さない
  4. Cookie毎にキャッシュを保持
  5. VarnishのESIによるテンプレート分割(ISUCON予選問題だと、"Hello, ○○さん" の部分だけ別のテンプレートとして扱い、メインのページでそのテンプレートを呼び出す記述を書きVarnishがそれぞれを合体させる)してそれぞれキャッシュ

1.2. はHTMLの構造が変わってしまいISUCONのベンチマーカのチェックが通らなくなるので今回は採用できません。実際にこの方針が採用できる場合、キャッシュ周りの実装は極めてシンプルになるのでお薦めです。 3.についてはログインユーザにキャッシュが返せずキャッシュヒット率が下がってしまいます。が、全参照ユーザの中でログインユーザの割合の少ないようなサービスでは有効です。 4. は、1ページ1ユーザ毎に1つのキャッシュを用意する必要が出てきて、キャッシュヒット率が下がってしまいます。こちらも 3. と同様にログインユーザの割合が少ない場合や、ログインユーザが非常に高頻度に同じページにアクセスするような状況では有効な手立てになります。 5. については、複雑な構成になってしまい運用に難が出る、アプリケーションサーバへのリクエスト数が増えがちではありますが、とにかく必要な部分を必要な分だけキャッシュするという戦略が取れるため、今回はこの方式を選択しています。

実装

今回、キャッシュサーバとしてVarnishを使用しているため、Varnishではどのような実装で上記戦略を実施することができるのかを解説していきます。

必要な部分でキャッシュを行い、それ以外ではキャッシュを行わない

リクエストの全てがVarnishを通る構成であるため、どのページはキャッシュ可能でどのページはキャッシュしないかをアプリケーションサーバが指示する必要があります。

今回は、キャッシュするかどうかだけ指定したい状態でエンドポイント毎の細かなキャッシュの生存期間制御などは行わず、キャッシュさせたくないエンドポイントではHTTPヘッダに Cache-Control: no-cache を付与し、キャッシュさせたいエンドポイントでは Cache-Control: public, max-age=86400 を付与しています

https://github.com/dekokun/isucon6-qualify/blob/dba81a180ca7b722e45727f11278c19587e9d103/webapp/go/isuda.go#L49-L55

更新時の適切なキャッシュの破棄

更新時のキャッシュの破棄のためには、以下が必要です。

  1. キャッシュに対するタグ付け
  2. データの更新時にタグを指定してのキャッシュの破棄

キャッシュに対するタグ付け

Varnishはアプリケーションサーバからのレスポンスヘッダをタグとして使用することができます。今回は、KEYWORDという名前のHTTPヘッダでそのキーワードのIDを指定しています。

https://github.com/dekokun/isucon6-qualify/blob/dba81a180ca7b722e45727f11278c19587e9d103/webapp/go/isuda.go#L145

   w.Header().Set("KEYWORD", strconv.Itoa(e.ID))

データの更新時にタグを指定してのキャッシュの破棄

データの更新が走った際にタグを指定して関連するページのキャッシュの破棄を行います。 今回は、関連するページはMySQLのFULLTEXT インデックスを使用して取得しています。この記事の本筋とは関係ないため説明は省略しますが、詳しくはこのあたりを見るといいかなと思います。 https://github.com/dekokun/isucon6-qualify/blob/dba81a180ca7b722e45727f11278c19587e9d103/webapp/go/isuda.go#L58

rows, err := db.Query(`select id from entry where match(description) against(?);`, keyword)

FULLTEXT インデックス使用のためのALTER文は以下のとおりです。

alter table entry add fulltext( description ) WITH PARSER ngram;

キャッシュの削除は、VarnishのBans という機能を利用しています。VarnishのVCLに以下のように書いておくと、VarnishにINVALIDATEメソッドでHTTPリクエストを送ることで必要なタグをBANすることができます。

https://github.com/dekokun/isucon6-qualify/blob/dba81a180ca7b722e45727f11278c19587e9d103/webapp/config/default.vcl#L32-L33

  if (req.method == "INVALIDATE") {
ban("obj.http.KEYWORD = " + req.http.X-INVALIDATED-KEYWORD);

例えば、以下のようなcurlリクエストを送ることでで、アプリケーションサーバがレスポンスを返す際に Keyword: 1 というHTTPヘッダを付与していたキャッシュが消えることになります。

curl varnishhost -XINVALIDATE --header 'X-INVALIDATED-KEYWORD: 1'

後は、以下のようにデータの更新時に必要なタグを付与してVarnishにリクエストを送るようにアプリケーションを改修すればOKです。現在はキャッシュの破棄が必要なタグの数だけHTTPリクエストを送っていますが、VarnishのVCLを操作して複数のタグを許容するようにすれば1リクエストで済むと思います(未検証)

https://github.com/dekokun/isucon6-qualify/blob/dba81a180ca7b722e45727f11278c19587e9d103/webapp/go/isuda.go#L72-L78

VarnishのESIによるテンプレート分割

ESI とはEdge Side Includesの略で、VarnishでのESIとは、キャッシュサーバがアプリケーションサーバから返されるデータを読んでその中のタグを解釈し、必要に応じて別のデータを取りに行くような機能のことです。これだけでは意味がわからないと思いますので以下でもう少し簡単に説明します。

ESIの簡単な例

今回のISUCON予選の問題を簡略化して説明します。

/ のアプリケーションサーバのレスポンス

(前略)
    <div class="container">
      <p>Hello <span class="isu-account-name">dekokun</span></p>


<esi:include src="/index_keyword?page=1" /> (<-ここに注目!ここでESIを使用してます)

    </div>
(後略)

/index_keyword?page=1アプリケーションサーバのレスポンス

<article>
  <h1><a href="/keyword/%e3%81%a7%e3%81%93%e3%81%8f%e3%82%93">でこくん</a></h1>
  <div><a href="http://13.78.91.40/keyword/%E3%81%A7%E3%81%93%E3%81%8F%E3%82%93">でこくん</a>は、いつも元気に働いている社会人です。
(後略)

/ のVarnishのレスポンス。まずアプリケーションの/を叩き、その後 /index_keyword?page=1 のキャッシュでesi:includeの部分を埋めて返す。これにより、 Hello dekokun の部分は動的に生成しつつ重いキーワードの自動リンク機能はキャッシュを返すことが可能。

(前略)
    <div class="container">
      <p>Hello <span class="isu-account-name">dekokun</span></p>


<article>
  <h1><a href="/keyword/%e3%81%a7%e3%81%93%e3%81%8f%e3%82%93">でこくん</a></h1>
  <div><a href="http://13.78.91.40/keyword/%E3%81%A7%E3%81%93%E3%81%8F%E3%82%93">でこくん</a>は、いつも元気に働いている社会人です。
(中略)

    </div>
(後略)

ESIについて詳しくは以下をご参照下さい。

Content composition with Edge Side Includes — Varnish version 4.0.4 documentation

ESIの設定

VarnishはデフォルトではESIが有効になっていないので、VCLで以下のように有効にする必要があります。

https://github.com/dekokun/isucon6-qualify/blob/dba81a180ca7b722e45727f11278c19587e9d103/webapp/config/default.vcl#L67

set beresp.do_esi = true;

ISUCON予選問題でのESIの実装

上記例に出したようにユーザによって動的に表示が変わる部分と、キャッシュをしたいキーワード部分を別テンプレートに切り出す作業を行います。

基本的にキャッシュしつつ動的な部分は正確にキャッシュしないようにしたところテンプレートを非常に細かく分割する必要が発生し、ESIで呼び出されたHTMLの中から更にESIで別のHTMLが呼び出されるような多段のESIがいたるところに出現し、最高で4段のネストしたESI呼び出しが発生しました。

以下の赤枠で囲った1つ1つが、テンプレートを分割してESIでVarnishから呼び出されている部分になります。つまり、Varnishへの1リクエストが4リクエストに増幅しているわけで、今回のISUCON問題のように、"とてつもなく重い機能(キーワードの自動リンク機能)を徹底的にキャッシュしたい。そのためには同時リクエスト数は増えても構わない" という場合以外はこの方式はむしろ性能が劣化する原因になるので注意です。

f:id:dekokun:20161209114759p:plain

結果

上記の対応を行うことで全てのページにおいて一番重いキーワード生成部分は完全にキャッシュされ、データの更新時には必要なキャッシュだけ破棄されるような環境は整ったのですが、如何せんそもそもキーワードの自動リンク機能部分のキャッシュの生成にかかるコストが高すぎて、"キャッシュに必要な実装以外は出来る限りアプリケーションには手を入れずに" という制約の上ではISUCONのベンチマークをかけても全くスコアが出ませんでした。 ISUCONのベンチマーカーはPOSTリクエストの割合が高く、キャッシュがかなり頻繁に破棄される状態だというのも点数が低い原因になっています。

今後行いたいこと

以下行いたいですね。

  • ISUCONアプリケーションをFastlyで配信する
    • VarnishからFastlyに載せ替える みたいなブログを書きたい
    • 実際は既に配信はできる状態になっているのですが、ここに書くには余白が足りない
  • アプリケーションに手を入れてキーワード生成部分を高速化し、どれくらいの点数が出るのか見てみる
  • 今回はデータの更新で即座にキャッシュを破棄していたため、その次にアクセスするユーザには長時間待たせることになってしまっていたが、、Varnishに古いデータを返させつつアプリケーションサーバにリクエストを送り裏でキャッシュを生成させるような実装にしたい
    • その場合は新しいキャッシュができるまで古いデータが返ることになるのでISUCONのベンチマークツールにはエラーとして認識されそうではある

今回使ったVarnishの各機能に対する感想

キャッシュにタグ付けを行いタグ指定でのPurge

非常に夢がある機能です。これによって動的なアプリケーションでも出来る限りVarnishにキャッシュさせつつ更新は即座に反映することができます。タグ付けを適切に行えば、更新データに関連するキャッシュを全て綺麗に瞬時に消し去ることができます。

ESI

ESIによって実現される、"ページの一部だけキャッシュされて残りはキャッシュされていない"というような状況は、ただでさえ人間の頭に負担になる"キャッシュ"というものが更に複雑なものになってわけがわからなくなるなと強く感じました。基本的には使うべき機能ではないと感じます。開発時にVarnishがないとアプリケーションが動かないという状況にもなり、動作確認が行いづらくそちらでもあまり好ましくないなと感じます。 ESIを使うのと同等のことは、JSでAPIを叩いてDOM書き換えを行うことでできる(ただし、ESIと比較してブラウザからアプリケーションサーバまでのレイテンシは余計にかかりますが…)ので、そちらを採用したほうがアプリケーションを書く側としても一般的で分かりやすく良さそうです。

最後に

ここ1年ほど仕事でVarnishを触ることが多く、VarnishのタグPurge機能やESL機能に触れれば触れるほど"これなら全ての参照リクエストへのレスポンスをキャッシュさせつつリアルタイムなアプリケーションが作れる!そういうコンセプト実装をしたいなぁ"と感じていたところにちょうどまさにそういう構成が力を発揮しそうなアプリケーションがISUCONという形で与えられたため、是非やってみようと思ってやってみました。 実際にやってみたところで、そのような構成が実際に可能なこと、ELSの難解さやタグPurgeの強力さを肌で感じることができ、とても良い経験になったなと思います。

最近はFastly等のキャッシュPurgeが高速なCDNも登場しており、全てをCDNにキャッシュさせるような動的ページも作っていきたいなと夢が広がりますね。

明日のアドベントカレンダーid:Windymelt です!!!

最近AWSのENIの付け替え速度がチョッパヤになってる気がしたので測ってみた

AWSにおいてサーバの冗長構成をとるための様々な手法の中の一つとして、ENIを移動させることでIPを移動させる方法もあるかと思います。

そのENIの付け替えにかかる時間が最近異様に素早くなってきている気がしたので、すごく雑に測ってみました。及び素早く付け外しするコツも記載してあります。

手動でENIを付け替えたバージョンと、grabeniでENIを付け替えたバージョンの両方があります。

なお、grabeniとは、ちょうど今日書かれた以下エントリにも登場している、go製のENI操作CLIツールです。keepalivedとgrabeniを組み合わせて冗長構成をとるなど、最近便利に使ってます。 developer.hatenastaff.com

結論

  • 手でもツールを使っても、30秒以内くらいでENIを付け替えられそうな感じ
  • 昔はもっと時間がかかっていた気がするけど昔のデータはないので比較できない。次回に期待
    • 昔は2〜3分かかってた気がするという証言が得られた
  • grabeni便利

測り方や環境等

  • pingを打ちながらENIを外して別サーバにつけ、pingが失敗していた秒数を測る
  • pingが疎通していない時間の長さで時間を測る
  • 「だいたい何秒くらい」という情報がわかっていればそれである程度十分だし、特に手でつけたり外したりするの結構面倒なので、それぞれ1回勝負。平均をとったりはしない
  • allow-hotplugの設定をしているため、ENIを付与するだけで勝手にインターフェースが上がってくる環境

手動バージョンの測定

26秒です。

AWSのマネジメントコンソールから手でつけ外しした場合。 EC2サーバが大量に存在する環境にて素早く付け外しするコツがある。そのコツとは、外したENIを付与する際は、ENIからattachするEC2サーバを探すのではなく(サーバが多い環境だと大量のEC2サーバがリストに表れて非常に探しづらい)、attachするEC2サーバにて"ネットワーキング" -> "ネットワークインターフェース" の順で操作することによって、現在使われていないENI(大抵EC2サーバの数より少ないのではないかと思っているがどうでしょうか)から選べば良くなるため、圧倒的に早く済むというものである。

pingの状況

64 bytes from xx.xx.xx.xx: icmp_seq=98 ttl=59 time=xxxx ms
Request timeout for icmp_seq 99
Request timeout for icmp_seq 100
Request timeout for icmp_seq 101
Request timeout for icmp_seq 102
Request timeout for icmp_seq 103
Request timeout for icmp_seq 104
Request timeout for icmp_seq 105
Request timeout for icmp_seq 106
Request timeout for icmp_seq 107
Request timeout for icmp_seq 108
Request timeout for icmp_seq 109
Request timeout for icmp_seq 110
Request timeout for icmp_seq 111
Request timeout for icmp_seq 112
Request timeout for icmp_seq 113
Request timeout for icmp_seq 114
Request timeout for icmp_seq 115
Request timeout for icmp_seq 116
Request timeout for icmp_seq 117
Request timeout for icmp_seq 118
Request timeout for icmp_seq 119
Request timeout for icmp_seq 120
Request timeout for icmp_seq 121
Request timeout for icmp_seq 122
Request timeout for icmp_seq 123
Request timeout for icmp_seq 124
64 bytes from xx.xx.xx.xx: icmp_seq=125 ttl=59 time=xxxx ms

grabeni使用バージョンの測定

18秒。

1回勝負の測定結果なので、優位な差があるかと言われるとそんなにないと思うが、まぁ、手でやるよりはやはり早いよねという結果になった。

なお、その時に打ったgrabeniコマンドは以下のとおりである。ENIを付与したいサーバ上で以下1コマンドを叩くだけでよく大変便利。オプションの-i 1の部分が重要で、grabeniはループ内でiオプションの秒数だけsleepしながらENIのdetachを待ち、detachが完了したらattachするという動きになっているため、この数を減らすことによって、ENIの付け替えを高速化させることができる。デフォルト値は2となっているが、私はいつも1で使っている。

$ grabeni grab -d 1 -i 1 -n 40 eni-xxxxxxxxx

pingの状況

64 bytes from xx.xx.xx.xx: icmp_seq=10 ttl=59 time=xxxx ms
Request timeout for icmp_seq 11
Request timeout for icmp_seq 12
Request timeout for icmp_seq 13
Request timeout for icmp_seq 14
Request timeout for icmp_seq 15
Request timeout for icmp_seq 16
Request timeout for icmp_seq 17
Request timeout for icmp_seq 18
Request timeout for icmp_seq 19
Request timeout for icmp_seq 20
Request timeout for icmp_seq 21
Request timeout for icmp_seq 22
Request timeout for icmp_seq 23
Request timeout for icmp_seq 24
Request timeout for icmp_seq 25
Request timeout for icmp_seq 26
Request timeout for icmp_seq 27
Request timeout for icmp_seq 28
64 bytes from xx.xx.xx.xx: icmp_seq=29 ttl=59 time=xxxx ms

感想

  • 昔はもっと遅かった気がするけどどうだっけなぁ。
  • grabeni便利。
  • この前やった時は10秒くらいで出来た気がしたんだけど勘違いかなぁ。当時のログが残っていない。

nginxでproxy_hide_header, proxy_set_header, add_headerを書く時にはまりがちな罠

reverse proxyサーバとしてよく使われているnginxですが、意外にハマりやすい罠があったりします。

今回は、proxy_hide_header, proxy_set_header, add_header等で設定内容を複数コンテキストに分割する際にはまりがちな点を紹介します。

概要

  • 上記3つのディレクティブについては、基本的には現在のコンテキストにそのディレクティブがない時に限って上位のコンテキストで設定された値が継承される
    • 他にもaccess_logディレクティブなどでも同様の動きをするようだが、まだどのディレクティブがそのように動くかの一覧は出せていない

どのようなときにハマるのかストーリー

上記説明だと、具体的に何がどうなるのかがわかりづらいため、ストーリー仕立てで説明します。 nginx - app のように、リバースプロキシの後ろにアプリケーションサーバがいるような一般的な形を考えます。

このアプリケーションサーバには、nginxでHTTPヘッダを付与する必要があったとします。今回のストーリでは以下のようにX-HogeとX-Fugaヘッダを付与します。

server {
    listen       80;
    server_name  nginx;

    proxy_set_header X-Hoge hogevalue;
    proxy_set_header X-Fuga fugavalue;
    location / {
        proxy_pass http://app;
    }
}

さて、この状態は特に問題ありません。アプリケーションサーバでX-HogeとX-Fugaの値をロギングするようにした状態でnginxにアクセスを行うと、ログから以下のように正しく値がセットされていることが分かります。

X-Hoge:hogevalue        X-Fuga:fugavalue

ここで、やんごとなき事情によって/test/以下へのアクセスのみ、X-Hoge, X-Fuga以外に、X-Piyoヘッダも付与する必要が出てきたとします。直感的に以下のように設定を変更すれば良さそうな感じがします

server {
    listen       80;
    server_name  nginx;

    proxy_set_header X-Hoge hogevalue;
    proxy_set_header X-Fuga fugavalue;
    location / {
        proxy_pass http://app;
    }
    location /test/ {
        proxy_set_header X-Piyo piyovalue; # これで大丈夫そう!
        proxy_pass http://app;
    }
}

うまく動きそうですね。/test/にきたら、X-HogeもX-FugaもX-Piyoも付与されていることが期待できます。では、/test/以下にアクセスしてみます。

$ curl http://nginx/test/index.html

ログは以下の通り

X-Hoge:-        X-Fuga:-        X-Piyo:piyovalue

…X-HogeとX-Fugaが設定されていませんね…

この場合は、以下のように必要な設定を全部入れてあげる必要があります。

server {
    listen       80;
    server_name  nginx;

    proxy_set_header X-Hoge hogevalue;
    proxy_set_header X-Fuga fugavalue;
    location / {
        proxy_pass http://app;
    }
    location /test/ {
        proxy_set_header X-Hoge hogevalue; # ここにも書く
        proxy_set_header X-Fuga fugavalue; # ここにも書く
        proxy_set_header X-Piyo piyovalue;
        proxy_pass http://app;
    }
}

そうすると当然ながら以下のように必要なヘッダが全て渡っていることが分かります。

X-Hoge:hogevalue        X-Fuga:fugavalue        X-Piyo:piyovalue

同様の動きをする他のヘッダ達

上記ストーリーではproxy_set_headerを例に出しましたが、proxy_hide_header, add_header等他にも同様の動きをするディレクティブが存在します(が、詳しくは追えていない。ドキュメントには一覧はなさそう?)。 以下にも記載しましたが、ディレクティブによっては、そのディレクティブの説明でこのような動きをする旨記載してあるものもあります。

ドキュメント上ではどのように扱われているのか

proxy_set_headerやadd_headerのドキュメントには、以下のような記述がありました。

These directives are inherited from the previous level if and only if there are no proxy_set_header directives defined on the current level.

Module ngx_http_proxy_module

ただ、proxy_hide_headerのドキュメントにはなさそう?

more_set_headersはまた違う動きをする

openrestyのheaders-more-nginx-moduleを使うと、add_headerをもう少し強力にしたようなディレクティブとしてmore_set_headersを使うことができるようになります。 add_headerと同様にクライアントに返すHTTPヘッダを追加することができるディレクティブなのですが、add_headerとの違いは、add_headerは既に存在するHTTPヘッダを上書きすることはできませんが、more_set_headersを使うと上書きすることができます。

ただ、それ以外に、add_headersとは、この記事で書いている挙動をするかしないかという点で、異なる動きをします。

以下で、実際にどのように異なる動きをするのかを解説します。

まず、以下のようにadd_headerとmore_set_headersでそれぞれヘッダを付与していたとします。

server {
    listen       80;
    server_name  nginx;

    add_header X-Add-Header-1 add-header-1;
    add_header X-Add-Header-2 add-header-2;
    more_set_headers 'X-More-Set-Headers-1: more-set-headers-1';
    more_set_headers 'X-More-Set-Headers-2: more-set-headers-2';

    location / {
        proxy_pass http://app;
    }
    location /test/ {
        proxy_pass http://app;
    }
}

curlでリクエストを投げると以下のようにどちらで設定したヘッダも出力されています(なお、curlのオプションの-Iというのはヘッダを出力するオプションです。ただ、-Iを付与するとGETではなくHEADリクエストになってしまうのでそれを阻止するために-XGETオプションも一緒に指定しています。)

$ curl -IXGET http://nginx/test/index.html
HTTP/1.1 200 OK
Server: openresty/1.9.7.4
Date: Sun, 22 May 2016 13:38:14 GMT
Content-Type: text/html
Content-Length: 12
Connection: keep-alive
Last-Modified: Sun, 22 May 2016 12:52:29 GMT
ETag: "5741ab8d-c"
Accept-Ranges: bytes
X-More-Set-Headers-1: more-set-headers-1
X-More-Set-Headers-2: more-set-headers-2
X-Add-Header-1: add-header-1
X-Add-Header-2: add-header-2

ここで、以下のように、/test/以下にアクセスがあった場合にさらにいろいろヘッダを付与するような設定をしたとします。

server {
    listen       80;
    server_name  nginx;

    add_header X-Add-Header-1 add-header-1;
    add_header X-Add-Header-2 add-header-2;
    more_set_headers 'X-More-Set-Headers-1: more-set-headers-1';
    more_set_headers 'X-More-Set-Headers-2: more-set-headers-2';

    location / {
        proxy_pass http://app;
    }
    location /test/ {
        proxy_pass http://app;
        add_header X-Add-Header-3 add-Header-3;
        more_set_headers 'X-More-Set-Headers-3: more-set-headers-3';
    }
}

すると、curlの結果は、以下のように、more_set_headersで付与したHTTPヘッダはそのまま上位のコンテキストで設定されたものに下位のコンテキストで設定した結果を付け足していくような動きになっていますが、add_headerは、上記で説明したproxy_set_headerと同様に、上位のコンテキストで設定したHTTPヘッダは付与されていません。難しいですね。確実に、両方同時に使わない方が良いです。

$ curl -IGET http://nginx/test/index.html
HTTP/1.1 200 OK
Server: openresty/1.9.7.4
Date: Sun, 22 May 2016 13:40:47 GMT
Content-Type: text/html
Content-Length: 12
Connection: keep-alive
Last-Modified: Sun, 22 May 2016 12:52:29 GMT
ETag: "5741ab8d-c"
Accept-Ranges: bytes
X-More-Set-Headers-1: more-set-headers-1
X-More-Set-Headers-2: more-set-headers-2
X-More-Set-Headers-3: more-set-headers-3
X-Add-Header-3: add-Header-3

あるディレクティブがそのように動くかどうかどのように調べればいいか

ドキュメントを読む、実際に試験をしてみる、実装を読むしかなさそうです。 まだ実装を読んでないですがまた読んだらブログにまとめたいです。

あとがき

最近nginxをよく触っているのですが、ここで紹介したproxy_hide_header, proxy_set_header, add_headerの罠に私は既に2回ハマっております。恥ずかしい。また周りでもちょくちょくハマっている姿が見られるため、そのような不幸な人が1人でも減ることを願って、この記事を執筆しました。

よいnginxライフを!

この記事によって今後書きたくなった記事

  • nginxのドキュメントへのコントリビュート
    • hgでコントリビュートするらしいというところまで見て今回は土日が終わってタイムアウト
  • ディレクティブの実装がどのような場合に上記のような動きをするがについてまとめる

技術ブログを月に1回書くことにした

最近技術ブログを書いてないな、書きたいな。しかし実際は書いてないなという思いが強くて、じゃあどうすればいいんだと思いました。

以前はなぜ書いていたのかと考えたら、自分のブログの"DevLOVE Conference 2012 に参加した後、これからの私にとって最も大切なこと"で「ブログを書く(頻度とかを考えてなかった。。最低、月1回くらいでかな)」という宣言をしていたからだ!と思い至り、今回も宣言してみることにしました。

(なお、前回宣言時は2年間毎月ブログを書き続けられました。その時は諸事情により途絶えてしまいましたが、今回はもっと続けたいですね!)