🐥

Hello AWS (part 1:最小構成のプロジェクト)

に公開

動機

  • AWS(というかインフラ)にほぼ始めて触るので、右も左もわかっていない。
  • とりあえず何かしらのサービスをAWSでホストして、インターネット経由でアクセスできる状態にしたい。

目的

  • AWS上にサーバ(とりあえずNginx一台)を構築し、インターネット経由(とりあえずHTTP)でコンテンツ(とりあえず静的HTMLファイル一枚)にアクセスできるようにすること。
  • 使用するAWSのサービスと周辺について浅く理解すること。

免責

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

構成

初めに簡単な構成図を載せ、以下構築手順とそれぞれの要素について詳述する。
赤矢印がリクエストの流れ、青矢印がそれ以外(AWSサービスからの通信など)を表す(青はレスポンスの流れではない)。

(なお構成図はDiagramsを利用して描画した。)

AWSのリソースのうち以下のものを使用する。

ざっくり言えば

  1. NginxのDockerイメージをECRに登録
  2. VPCを作成(主にネットワーク周りを設定)
  3. ECSでデプロイ(Fargateタスクから登録したDockerイメージを読み込んでインスタンスを生成)
  4. ローカルからアクセス

という手順を踏む。

色々なリソースを扱うので、Tag(key:valueのペア)をつけて管理することにする。
例えばProject:<プロジェクト名>CreatedBy:<名前>などとタグ付けしておくと、複数のプロジェクトを同一のアカウントで扱う場合や複数人でアカウントを共有する場合などに便利である。
なお同一のkeyを持つリソースはAWS Resource Groupsで検索が可能で、利用額を確認したい時や一括して削除したい時などに有用である。

Amazon ECR

Dockerイメージを保管するのに用いられるサービス。
ECSからFargateタスクのインスタンスを生成する(後述)際に参照される。

a) リポジトリの作成

Private Registryの中にRepository(Dockerイメージを登録する対象)を作成する。
基本的には設定はそのままで問題ない。

Repositoryを生成して該当のページにアクセスすると、右上にView push commandsのボタンが出てくるので、それに従いながら以下の手順を踏む。

b) イメージ作成

ここではHTMLファイルを一つ含んだNginxの適当なイメージをローカルで作成する。
なおただHTMLを返すだけなので、本来ならNginxサーバを立てる必要すらないが、非常に簡単な例ということで。

まずはローカルで以下の3ファイルを作成する。

.
├── Dockerfile
├── index.html
└── nginx.conf

Nginxの設定(nginx.conf)はルートへのアクセス(http://xxx.xxx.xxx.xxx/もしくはhttp://xxx.xxx.xxx.xxx/index.html)のみを考えて、こんな感じにする:

error_log /dev/stdout info;

events {
    worker_connections 1024;
}

http {
    access_log /dev/stdout;
    server {
        listen 80;
        server_name _;
        location / {
            root /usr/share/nginx/html;
            index index.html;
        }
    }
}

HTMLファイル(index.html)は適当に返却したいものを定義する(以下は画面中央に赤文字でHello AWSと出す例):

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Hello AWS</title>
  </head>
  <body style="margin: 0; padding: 0; width: 100dvw; height: 100dvh; display: flex; flex-direction: column; justify-content: center;">
    <div style="color: #F00; width: 100%; text-align: center; font-size: 15dvh;">
      Hello AWS
    </div>
  </body>
</html>

Dockerfileは以下のような感じ。
nginx.confindex.htmlをローカルからコピーして、コンテナ内部でよしなに配置する。
index.htmlのコンテナ内での場所は、nginx.confで設定した内容と一致する必要がある。

FROM nginx:stable

COPY nginx.conf /etc/nginx/nginx.conf
COPY index.html /usr/share/nginx/html/index.html

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

c) AWSにCLIからログインする

ECRへのDockerイメージの登録はコンソール経由ではなくCLIを使用するのがスタンダードなので、CLIで認証を行う必要がある。

何はともあれまずはAWS CLIをインストールする
なお以下の内容はCLI v2を前提としている(CLI v1だとコマンドが若干異なるらしい)。

認証についてであるが、

  • ドキュメントが認証に詳しくない人間にはとっつきにくい
  • やり方が複数ある
  • AWSアカウントの設定によって取れる方法が異なる

といったことから、万人向けの方法はなさそうである。
特に私を含めた初学者にとっては、この認証設定が一番つまずきやすいポイントだと思われる。

結果だけ言えば環境変数:

  • AWS_PROFILE

もしくは

  • AWS_ACCESS_KEY_ID
  • AWS_SECRET_ACCESS_KEY
  • AWS_SESSION_TOKEN

が取得・設定できれば(後述)、

aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin ${account-id-12-digits-number}.dkr.ecr.ap-northeast-1.amazonaws.com

を実行することでDockerコマンドを用いてAWS ECRと疎通できるようになる。
以下に細かい注意点を挙げる。

  • 変数${account-id-12-digits-number}は12桁の数字(Account IDから数字だけ抜き出したもの)で、ECRリポジトリのView push commands内に記載がある。またマネジメントコンソールのアカウントIDからも確認できる。
  • コマンド中のユーザネームは自分のものではなくAWSでよい(ECRに対してIAMロールでアクセスしており、ユーザという概念を用いないため)。

d) イメージのbuild

通常通り

docker build -t foo:bar .

とする。
なお環境によってはプラットフォームを指定する必要がある(後述:Task definition)。

e) Tagの設定

Buildした後でtagを設定する。

docker tag foo:bar ${account-id-12-digits-number}.dkr.ecr.ap-northeast-1.amazonaws.com/${aws_ecr_repository_tag}

変数${aws_ecr_repository_tag}ECRのrepository詳細のView push commandsを参照する。

f) イメージのECRへの登録

ログインさえ完了していれば単純に

docker push ${account-id-12-digits-number}.dkr.ecr.ap-northeast-1.amazonaws.com/${aws_ecr_repository_tag}

でよい。これもView push commandsに書いてある。

Amazon VPC

コンテナとインターネット間の疎通を制御する目的でVPCを使用する。

a) VPCの作成

VPCを構築することで、独立したアドレス空間を持つネットワークが利用できる。

基本的にはデフォルトの設定から変更していないが、IPv4 CIDRのみ10.0.0.0/16とした。
これは65536通りのIPアドレスを持つアドレス空間を定義したことになる(詳細は後述する)。
この後subnetなども作成するが、Resources to createVPC onlyで作成した。

作成するとResource mapVPCSubnetRoute tableNetwork connectionの繋がりが確認できるので、以下これをヒントに作業する。

b) Subnetの作成

サブネットはVPCの中でさらに粒度の細かな独立したネットワークのこと。

こちらもデフォルトでおおよそOKだが、IPv4 subnet CIDR blockのみ10.0.1.0/24とした。
これは256通りのIPアドレスを持つアドレス空間をこのサブネットに対して割り当てたことになるが、サブネットのCIDRはVPCに全て含まれる必要がある(詳細は後述する)。

c) Route table(RTB)の作成

ルートテーブルとは受け取ったパケットをどこに渡すべきかを定めるリソース(一般的なネットワークで言うところのルータの役割)。
各サブネットにはルートテーブルを関連付けて使う。

ルートテーブルに含まれる一つ一つの設定をルートと呼ぶ。
各ルートはDestinationTargetを取り、「パケットがDestinationを目指す場合に向かわせるTarget」を定める。
文だとわかりにくいので、以下で出てくるケースで具体的に考える。

  • Destination: 10.0.0.0/16(VPCのCIDR)とTarget: localの組み合わせ
    VPC内のどこかを目指すパケット(例えば同一VPC内のNginxサーバからPHPサーバへの通信)はそのVPC内(local)で直接やりとりする、ということで、つまりVPC内部での通信を司るルート。
    なおこれはVPC作成時にデフォルトで作成されるmain route tableにあらかじめ定義されている。
  • Destination: 0.0.0.0/0Target: igw-xxxの組み合わせ
    インターネットを目指すパケット(例えばNginxサーバが返した、ユーザからのHTTPリクエストに対するレスポンス)はigw-xxxに向けられる、ということ。
    インターネットゲートウェイ(IGW)については後述。
    なおこちらはデフォルトでは存在せず、自分で作成・定義したものである。
  • なお10.0.0.0/160.0.0.0/0に含まれている(後述のCIDR参照)が、AWSはlongest prefix matchを採用しているので、前者に後者のルールは適用されず、前者はきちんとVPC内のルートとして扱われる。

上述の通りデフォルトでVPCに紐づいたメインのルートテーブルは存在するが、サブネットに陽に紐づけたものを別で用意した方が見通しが立ちやすいので、今回は新規で作成する。
AWSのドキュメントによれば、作成されたルートテーブルはサブネットに紐づくのでsubnet route tableであり、これは同時にIGWにも紐づいているのでgateway route tableにも分類される。

作成自体は非常に簡単で、VPCを選択するだけで完了する。

d) Internet gateway(IGW)の作成

インターネットゲートウェイはVPCに付随して、内(VPC内)と外(インターネット)のやり取りを担うリソース。
今回であればパブリックIPアドレスを持つFragateタスクとインターネットの間を取り持つ。

なおあるサブネットのルートテーブルがIGWに紐付けられている時、そのアブネットをパブリックサブネットと呼ぶ(逆にIGWを含まない場合プライベートサブネットと呼ぶ)。
プライベートサブネット(IGWへのルートを持たない)内のインスタンスは、例えパブリックIDアドレスを保持していたとしても(例えばFargateのタスク)、インターネットとの通信はできない。

作成は名前を付けたのちにVPCとattachするだけでよい。

e) Security group(SG)の作成

SGはどんな通信を受け付け、どんな通信が外に出ていくことを許すか、を決定する仕組み(要はfire wall)。
これはVPCの直接的な要素ではないが、ネットワーク周りの話としてここで設定する。
Inbound rulesOutbound rulesを適切に設定する必要がある。

今回の場合、Inboundhttpリクエストを受け付ける必要があり、Outboundhttpsアクセスを許可する必要がある(内部的にDocker imageをpullしてくる際にhttpsプロトコルを使用しているためらしい)。
簡単のため今回はOutboundは全部許可としている(外に向かう通信なのでInboudに比べて影響は小さい)が、本来ならサーバが必要とする通信のみを許可するような最小構成に設定するのが望ましい。

なおSGは後述のECSでサービスを設定する際に指定するので、VPC内の他のリソースと紐づけるようなことはない。

f) 接続

完了したらそれぞれをよしなに接続する。

  • ルートテーブル(メインではなく作成した方)をIGWと紐づける必要がある。
  • うまくいけばVPCコンソールで確認できるResource mapでネットワークがpublicになっているはず。

Amazon ECS

ECRに登録したDockerイメージをもとにサービスを運用するプラットフォームとして使用する。

a) Clusterの作成

サーバレス環境としてAWS Fargateをインフラに用いる。

b) Task definitionの作成

作成したDockerイメージのプラットフォームと合致するOperating system/Architectureを選択する。今回の私の場合であればApple silicon(ARM64)で(をターゲットとして)ビルドしたので、Linux/Arm64を選択する。
CPUとMemoryは最小で構わない。
Essential containerにはECRにpushしたDocker imageのリンクを指定する(ECRのイメージの詳細からコピペできる)。

c) Deploy

作成したclusterでCreate serviceをする。

  • 作成したtask definitionを指定する。
  • Networkingでは作成したVPC、Subnet、Security groupを指定する。
  • Security groupは自動でデフォルトが選択されるが、作成したものを利用するように変更する。

確認

クラスタ上に生成されたタスクから公開IPアドレスがわかるのでアクセスする:

curl http://xxx.xxx.xxx.xxx

うまくいっていれば初めに定義したHTMLファイルの内容が返ってくる。

なおこの取得したIPアドレスは一時的に確保されているもので、AWSによって変更されうる。
固定する(正確には固定されたように扱う)方法はpart 2で扱う。

リソースの削除

無駄な課金を避けるため(使用しなければ置いておいても課金されないものもあるが)、タグを頼りに作成したリソースを削除しておく。

サービスの削除

サービスを削除する前にDesired tasksを0に変更する必要がある。サービスの設定から変更できる。

VPCの削除

VPC周りのリソースは依存関係があるので順番に削除する必要がある。

Task definitionの削除

Task definitionの削除はインタラクティブにはできない。
放置しておいても害はないと思うが、必要な場合はCLIを用いて行う(よって事前に認証処理を行っておく必要がある)。
まずは一覧を取得する:

# "Active"なtask definitionsを取得する
aws ecs list-task-definitions --status=ACTIVE --no-cli-pager

# "Inactive"なtask definitionsを取得する
aws ecs list-task-definitions --status=INACTIVE --no-cli-pager

Activeなものを削除するには、まずは登録解除(deregister)が必要:

aws ecs deregister-task-definition --no-cli-pager --task-definition ${task_definition}

Inactiveなものはそのまま削除可能である:

aws ecs delete-task-definitions --no-cli-pager --task-definition ${task_definition}

Deregisterとdeleteで単複形が異なるのに注意。

なお削除してもすぐには反映されず、ステータスがDELETE_IN_PROGRESSに変更される。これの一覧も確認可能:

aws ecs list-task-definitions --status=DELETE_IN_PROGRESS --no-cli-pager

参考:Announcing Amazon ECS Task Definition Deletion

補足(真偽怪しい)

以下サービスの構築とは直接関係ないが、関連する内容をまとめる。
(本文の内容に輪を掛けて曖昧な理解なのでファクトチェック必須です。)

リクエストが処理される流れ

今回の構成の下でユーザが送信したHTTPリクエストがどのように処理されるのかをまとめる。

a) クライアントがリクエストを送信する

クライアントがブラウザやcurlなどを用いてhttp://xxx.xxx.xxx.xxxにリクエストを送信する。
今回はIPアドレスを直接指定しているので、DNS解決(ドメイン名をIPアドレスに変換する作業のこと)は必要なく、DNSサーバとのやり取りは行われない。
指定の通り(アプリケーション層の)プロトコルはhttpを使用する。
またポート番号の指定がないので、デフォルトのTCPポート(80番)に向けてパケットが送信される。

b) IGWがパケットをタスクにルーティングする

送信されたパケットは、インターネット上を経由してIPアドレスが示すサーバに到達する。
IPアドレス(パブリックIPアドレス)がFargateタスクの設定ページから確認できることからわかる通り、このIPアドレスはECSのFargateタスクに関連付けられている。つまりIPアドレスはElastic Network Interface(ENI)に割り当てられていて、IGWに紐づいたものではない。
ただし結果的にはそのENIが属するVPCのIGWを経由するような構造になっている。

IGWはインバウンドアクセスの送信先であるパブリックIPアドレス(xxx.xxx.xxx.xxx)を、VPC(パブリックサブネット)内のプライベートIPアドレス(例えば10.0.yyy.yyy)に変換(NAT)し、該当するENI(タスク)に転送する役割を担っている(出典)。

c) Security group(SG)の確認を行う

パケットがENIに到達すると、まずAWSはそのENIに関連付けられたSGのインバウンドルールを確認する。SGは仮想ファイアウォールとして機能し、許可された通信のみを通過させる。

今回は以下のようなルールを定義している:

  • プロトコル:TCP
  • ポート範囲:80HTTP
  • ソース:0.0.0.0/0(全てのIPアドレスからのアクセスを許可)

この通信はこのルールにマッチするため、HTTPリクエストは許可され、パケットはFargateタスク上のNginxに転送される。

SGはステートフルであり、一度インバウンド通信が許可されれば、その通信に対するレスポンス(逆向きのパケット)は自動的に許可される。

d) Nginxサーバがリクエストを処理、クライアントに返却される

SGによって許可されたHTTPリクエストは、Fargateタスク内のNginxサーバに届き、設定ファイルに従って/usr/share/nginx/html/index.htmlがHTTPレスポンスとして生成される。

このレスポンスは、TCP接続が既に確立されているため、リクエストが辿ってきたルートを逆向きに通ってクライアントに返却される。具体的には:

  1. Nginx
  2. タスクのENI
  3. サブネット
  4. IGW
  5. インターネット
  6. クライアント

の順。

最終的に、クライアントのブラウザやcurlなどのツールに対して、NginxからのHTMLコンテンツが返却され、完結となる。

ネットワークについて

VPCを構築することで、同一のVPCに所属するサービスの内部では他のVPCとは独立したアドレス空間を持つprivate networkが利用できる。
具体的には例えば10.0.0.0から10.255.255.255の範囲のIPアドレスが自由に使用可能(参考:Private network - Wikipedia)。

今回はVPCを作成し、IPv4 CIDR10.0.0.0/16に設定した。
なおCIDRClass-less Inter Domain Routingの略で、「サイダー」と読む。
今回の設定は10.0.0.0の左側x = 16ビットが一つのネットワークを表現するのに使用され(ネットワーク部)、残りの32-x = 16ビットがホストの識別に用いられる(ホスト部)ことを表している。
すなわち2^16 = 65536個の異なるホストが同一のネットワーク空間で識別できる(10.0.0.0から10.0.255.255まで)ような設定、ということ(正確には内部的に使用しているものがあって2つくらい減るらしい)。

次にサブネットをIPv4 CIDR = 10.0.1.0/24の設定で作成したが、これは字の如くVPCの中でさらに粒度の細かな独立したネットワークのこと。

サブネットはVPCの中に存在するので、当然サブネットで使用するIPアドレスは全てVPCのものに含まれている必要がある。
今回の場合、サブネットは(左側24ビットが固定されているので)10.0.1.0から10.0.1.255の256通りを占有する設定になっているが、確かにVPCの使用可能範囲に収まっている。

一つのVPC内部に複数のサブネットを保持し、それぞれ別々に設定することで、例えばsubnet Aはpublic(インターネット接続が可能)、subnet Bはprivate(インターネットに接続していない)のような使い方ができる(part 3以降で扱う)。

通信について

TCPプロトコルにおける通信は、より詳細には以下のステップを踏む。

  1. TCPに定められる通り、3-way handshakeにより接続が確立される。今回でいえばクライアントとサーバがSYNSYN-ACKACKの3つのパケットの送受信によってお互いに接続確認を行う。細かい話をすればAWSのSecurity Groupによるインバウンド通信の検査は、最初のSYNパケットの受信時点で行われる(らしい)。
  2. 接続が確立されたらクライアントからサーバにメインのHTTPのGETリクエストが行われる。
  3. レスポンスの受信に使用するため、クライアント側ではephemeral portと呼ばれる一時的なポートを自動的に確保し、これを送信元ポートとして通信を行う。

AWS CDKを用いた自動構築

今回の設定を含めてAWSのサービスはスクリプトによって構築を自動化できる。
これについてはpart 4で議論する。

Discussion