Hello AWS (part 3:サービスの分離)
動機
Part 1およびPart 2で扱ったアプリは、Nginxのコンテナで静的HTMLファイルを返すだけの構成だった。
これはHello world
としては良いものの、実用的なアプリケーションの殆どは複数のサービスを組み合わせて(例えばリバースプロキシ、アプリケーション、データベースなど)構築される。
一つのコンテナで全てのサービスを運用することは可能だが、分離して運用する(=各々の粒度を小さく保つ)ことで、
- それぞれのサービスを独立にスケール・デプロイ・監視できる
- 障害の影響範囲を限定できる
といった恩恵を受けられる。
目的
複数のサービスからなるシステムを構築する。
具体的にはNginx
とPHP
を組み合わせ、複数のタスクの間をプライベートなネットワークで繋ぐような構成とする。
Nginx
でALBからのリクエストを受け、PHP
がNginx
から転送されたリクエストを処理するような形とする。
免責
内容の正確性に注意を払ってはいますが、不正確な理解による不正確な記述があり得ます。
定期的に見直し改善していく予定ですが、その点注意して読んで頂ければ幸いです。
構成
以下のような構成図となる。基本的には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 name
:my-namespace.local
-
Service discovery name
:app
とした。
なお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 balancing
とService 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 server
やRoute 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
と呼ばれるAccept
やContent-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)
- Type:
- ALBからのリクエストを受け取る
-
outbound
- PHPにリクエストを送る
- Type:
Custom TCP
- Protocol:
TCP
- Port range:
9000
- Destination:
ecs-sg-app
(PHPサービスのSG)
- Type:
- ECRからDockerイメージをpullする
- Type:
All trafic
- Protocol:
All
- Port range:
All
- Destination:
0.0.0.0/0
- Type:
- PHPにリクエストを送る
PHPサービスのSG ecs-sg-app
は
-
inbound
- Nginxサービスからのリクエストを受け取る
- Type:
Custom TCP
- Protocol:
TCP
- Port range:
9000
- Source:
ecs-sg-web
(NginxサービスのSG)
- Type:
- Nginxサービスからのリクエストを受け取る
-
outbound
- ECRからDockerイメージをpullする
- Type:
All trafic
- Protocol:
All
- Port range:
All
- Destination:
0.0.0.0/0
- Type:
- ECRからDockerイメージをpullする
などと設定する(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