🐥

Hello AWS (part 3:サービスの分離)

に公開

動機

Part 1およびPart 2で扱ったアプリは、Nginxのコンテナで静的HTMLファイルを返すだけの構成だった。
これはHello worldとしては良いものの、実用的なアプリケーションの殆どは複数のサービスを組み合わせて(例えばリバースプロキシ、アプリケーション、データベースなど)構築される。

一つのコンテナで全てのサービスを運用することは可能だが、分離して運用する(=各々の粒度を小さく保つ)ことで、

  • それぞれのサービスを独立にスケール・デプロイ・監視できる
  • 障害の影響範囲を限定できる

といった恩恵を受けられる。

目的

複数のサービスからなるシステムを構築する。
具体的にはNginxPHPを組み合わせ、複数のタスクの間をプライベートなネットワークで繋ぐような構成とする。
NginxでALBからのリクエストを受け、PHPNginxから転送されたリクエストを処理するような形とする。

免責

内容の正確性に注意を払ってはいますが、不正確な理解による不正確な記述があり得ます。
定期的に見直し改善していく予定ですが、その点注意して読んで頂ければ幸いです。

構成

以下のような構成図となる。基本的にはPart 2にPHPのFargateタスクが加わるだけである。
赤矢印がユーザによるリクエストの流れ、青矢印がそれ以外(AWSサービスからの通信など)を表す(青はレスポンスの流れではない)。

Part 2で使用したものに追加で以下のサービスを新しく使用する。

その他Security group、VPCも多少設定を見直す。

NginxとPHPのイメージ

Nginxがリクエストを受理し、それをPHPに転送するような構成にするため、Dockerイメージを二つ用意し、ECRに登録する。

PHP

ただのエコーサーバを作成する。

FROM php:fpm

COPY index.php /var/www/html/index.php

CMD ["php-fpm", "-F"]
<?php

echo "Hello AWS";

Part 1で扱ったような手順でイメージをビルドし、ECRにpushする。名前はmy-project/appなどとする。

Nginx

DockerfileはPart 1、2と同じものを使用する:

FROM nginx:stable

COPY nginx.conf /etc/nginx/nginx.conf

CMD ["nginx", "-g", "daemon off;"]

NginxがPHPに処理を転送するには、PHPコンテナのDNS名を知っておく必要がある。
これを実現するnginx.confについて以下で考える。

nginx.confが完成した後にこちらもECRにpushする。名前はmy-project/webなどとする。

AWS Cloud Map

先述の通り今回は複数のサービスを立ち上げ、それらを連携する。
それぞれのサービス(今回ならNginxとPHP)は別々のサービスで管理され、別々のタスクとして起動されるため、通信の際には相手を発見・特定する必要がある。

Part 2で述べたように、ECSにおいて個々のタスクは起動するたびに異なるプライベートIPアドレスを取得するため、通信先を固定のIPアドレスで決め打ちすることはできない。
これを解決するのがCloud Mapによる内部DNSの仕組みであり、コンテナにプライベートなDNS(名前)を付与することで、別のコンテナからその名前を使用しての疎通が可能となる。

実装の上ではECSのサービスを定義する際、Service discoveryのセクションで名前空間を新規に作成できる。
今回はNginxコンテナがPHPコンテナにFastCGIプロトコルでリクエストを転送するので、PHPコンテナ(app)側に名前を設定する必要がある。具体的には

  • Namespace namemy-namespace.local
  • Service discovery nameapp

とした。
なおCloud Mapのコンソールページで名前空間を生成することもできるが、上のようにECSのサービスを定義する際に設定するのが簡便だと思う。

これを踏まえたNginxの設定(nginx.conf)は以下のようになる:

error_log /dev/stdout info;

events {
    worker_connections 1024;
}

http {
    access_log /dev/stdout;
    # Cloud Mapの解決を一定の周期で行う
    resolver 169.254.169.253 valid=10s;
    server {
        listen 80;
        server_name _;
        # /api以下へのリクエストをPHPタスクへ転送する設定
        location /api {
            # Preflight Request(CORS)に対応する
            if ($request_method = OPTIONS) {
                add_header Access-Control-Allow-Origin *;
                add_header Access-Control-Allow-Headers "Content-Type, X-Api-Key";
                add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS";
                add_header Access-Control-Max-Age 3600;
                return 204;
            }
            # ヘッダを確認し、API Keyが要求と異なる場合はForbiddenを返す
            if ($http_x_api_key != "my-secret") {
                return 403 'Invalid secret';
            }
            # ここでCloud Mapで設定した名前空間を参照する
            # 一旦変数に格納しているのがミソで、resolverによる解決を行わせている
            # ポート番号9000はPHP-FPM(FastCGI)でデフォルトで使用されるポート
            set $url app.my-namespace.local;
            fastcgi_pass $url:9000;
            include fastcgi_params;
            fastcgi_param SCRIPT_FILENAME /var/www/html/index.php;
        }
        # ALBのヘルスチェックを受けるルート
        location /health {
            return 200 'OK';
        }
        # 他のリクエストは全て拒否する
        location / {
            deny all;
        }
    }
}

Load balancingと名前空間はともにサービスを定義するタイミングで指定する(Create service内でそれぞれLoad balancingService discoveryの項目がある)が、NginxとPHPで異なった設定が必要である。以下簡単にまとめる。

  • PHPサービスはALBからの通信は受け付ける必要がないのでload balancingは設定しないが、Nginxから発見できるように名前空間の設定が必要である。
  • NginxサービスはALBから転送を受け付けるためload balancingの登録が必要だが、他のサービスから見える必要はないので名前空間の設定は不要である。

nginx.confには他にもいくつか変更を加えているので以下で詳述する。

resolverについて

NginxとPHPのサービスは独立しているので、それぞれを個別にデプロイし直すことが可能である。
これは分離して運用することの大きなメリットの一つだが、以下のような問題が生じる。

上述の通りNginxからPHPへの参照は、Cloud Mapによって生成した名前(app.my-namespace.local)を用いてPHPタスクを参照しているが、裏ではDNSの仕組みによりこの名前はIPアドレスに変換されている。
この「名前空間を参照してプライベートIPアドレスに解決するプロセス」はデフォルトだとNginxタスクの起動時にしか行われない(すなわちNginxは起動時に得られた名前解決の結果をキャッシュする)。
これではPHPサービスを再デプロイなどしてIPアドレスが変化した場合、間違ったIPアドレスを参照し続けることになってしまう。

これを防ぐための設定がresolverを用いて行われており、Amazon DNS serverRoute 53 Resolverと呼ばれるAWSのサーバ(169.254.169.253)を定期的(上の例では10sec毎)に参照することで、正しい名前空間とIPアドレスの組み合わせを得られるようにしている。

fastcgi_passの設定において固定文字列を与えずに変数を使用しているのがポイントで、resolverによる解決を行わせている。
より詳しく言えば(下記日本語参考記事でproxy_passに対しても扱われているように)

Parameter value can contain variables.
In this case, if an address is specified as a domain name, the name is searched among the described server groups, and, if not found, is determined using a resolver.

(適当意訳:fastcgi_passの値には変数が使える。IPアドレスでなくドメイン名が与えられる場合、まずserver groupsを探索し、見つからない場合はresolverを用いてドメイン名からIPアドレスへの解決が行われる。)

に相当する。今回はserver groupsを定義していないので、結局最後のresolverの使用に至る。

参考資料:

単純な認証処理の導入

現状だとURLは全世界からアクセス可能になっている。
大した処理は行っていないが、たとえサンドボックス環境であっても、プライベートサブネット内のPHPサーバが外部から無制限にアクセス可能な状態は避けるべきである。
ただしっかりとした認証処理の導入はそれなりにハードルが高いので、今回はNginxで許可されない通信を遮断してしまうことで対応する。

$http_x_api_keyを参照している条件分岐がそれで、HTTPヘッダの中にX-Api-Key: my-secretが見つからない場合、PHPコンテナへの転送を行わずにNginxからForbiddenを返している。

なおこれはサンドボックス用の言わば仮の処置であり、当然プロダクトで使用すべきではない。

CORSへの対応

上述の「単純な認証処理」でも使用したように、ヘッダ部分にメタ情報などを含めてHTTPリクエストを送信する、という状況はままあるが、ブラウザ経由でこうしたリクエストを送信する場合は注意が必要となる(curlなどを使用する場合、この問題は生じない)。
というのはヘッダにSimple Headerと呼ばれるAcceptContent-Typeなどの一般的なもの以外を含めてブラウザから(異なるオリジンの)サーバにリクエストを送信する場合、セキュリティ上の懸念からSimple Requestとは見做されなくなり、Preflight RequestがメインのGETやPOSTの前にサーバに送信される。
サーバがこれに対して適切なレスポンスを返さない場合、リクエストは失敗してしまう。
これはCross-Origin Resource Sharing(CORS)と呼ばれるメカニズムによるものである(参考)。

具体的にはブラウザはPreflight Requestに対してサーバが以下のヘッダを設定したレスポンスを返却することを求めている:

  • Access-Control-Allow-Origin:リクエスト元のホワイトリスト
  • Access-Control-Allow-Headers:含まれることが許される追加のHTTPリクエストヘッダ
  • Access-Control-Allow-Methods:許可されるメソッド

上述の設定によってこれらを適切に設定し、ブラウザからサーバへのリクエストが通るようにしている。

Security Group(SG)の設定

Part 2ではECS内で唯一のSGを使用していたが、今回は

  • NginxサービスはALBからのリクエストを受け取り、PHPサービスにリクエストを送る
  • PHPサービスはNginxサービスからのリクエストを受け取る

とそれぞれ違った需要を持つため、SGも分割する必要がある。
なお両方ともにタスク生成(コンテナ起動)のタイミングでDockerイメージを(NAT経由で)インターネットから取得する必要がある、ということにも注意が必要である。

具体的にはNginxサービスのSG ecs-sg-web

  • inbound
    • ALBからのリクエストを受け取る
      • Type: HTTP
      • Protocol: TCP
      • Port range: 80
      • Source: alb-sg(ALBのSG)
  • outbound
    • PHPにリクエストを送る
      • Type: Custom TCP
      • Protocol: TCP
      • Port range: 9000
      • Destination: ecs-sg-app(PHPサービスのSG)
    • ECRからDockerイメージをpullする
      • Type: All trafic
      • Protocol: All
      • Port range: All
      • Destination: 0.0.0.0/0

PHPサービスのSG ecs-sg-app

  • inbound
    • Nginxサービスからのリクエストを受け取る
      • Type: Custom TCP
      • Protocol: TCP
      • Port range: 9000
      • Source: ecs-sg-web(NginxサービスのSG)
  • outbound
    • ECRからDockerイメージをpullする
      • Type: All trafic
      • Protocol: All
      • Port range: All
      • Destination: 0.0.0.0/0

などと設定する(ToDo:outboundルールは甘すぎるのでもう少しなんとかする)。

VPCの設定

この仕組みを利用するためにはVPCの設定でEnable DNS hostnamesがオンになっている必要がある(デフォルトではオフだった)。
設定横のinformationには

The DNS hostnames attribute determines whether instances launched in the VPC receive public DNS hostnames that correspond to their public IP addresses.

とあるが、ここでのpublic DNS hostnamesは「インターネットから参照できるDNS名を自動付与するか」という意味ではなく、内部的な名前解決についてだと思われる。

もう一つ似た設定のEnable DNS resolutionも同様にオンである必要があるが、こちらはデフォルトでオンになっていた。

確認

色々なパターンが考えられる。

例えば以下はヘッダにAPI Keyを含まないため、403が期待される:

curl https://hello-aws.my-project.jp/api

次のリクエストはヘッダはあるが値が間違っているので、同様に403が返ってくる:

curl -H 'X-Api-Key: invalid-secret' https://hello-aws.my-project.jp/api

次のリクエストではヘッダに正しい値が設定されているので、リクエストはPHPに送られ、レスポンス(ステータスコード200とテキスト"Hello AWS")が返る:

curl -H 'X-Api-Key: my-secret' https://hello-aws.my-project.jp/api

/api以外のエンドポイントはdeny allのルールからAPIキーにかかわらず403が返る:

curl -H 'X-Api-Key: my-secret' https://hello-aws.my-project.jp

ただし/healthは例外で、ALBのヘルスチェックを受け付けて200を返すようにしている:

curl https://hello-aws.my-project.jp/health

Discussion