AWS ECS Fargateにシンプルなアプリをデプロイする

## 概要

シンプルなGo言語アプリケーションを、AWSのECS Fargateにデプロイする手順を紹介します。Fargateって最近聞くけど試したこと無い、これから試してみたい、という人向けの内容です。
また、Goでなくともにもコンテナ化できれば、同じ手順で構築できると思います。

※この記事は、2020/9月時点のコンソールを元に書かれています。

## 環境

 - Go言語 1.14.3
 - Mac OS X 10.15.4
 - AWS CLIは設定済み

## ECS Fargateとは

まずECS（Elastic Container Service）は、AWSが提供するコンテナオーケストレーションのサービスです。ECSはAWSの他サービスとも連携しやすく、インストールや管理等が不要である点から、既にAWSを活用しているチームやスモールスタートしたいチームに向いていると言えます。コンテナオーケストレーションはKubernetes（k8s）が有名ですが、k8sの学習コストや定期的なバージョンアップ運用などが気になる場合には、ECSも選択肢になるでしょう。

ECSではコンテナが動く環境（サーバ）も当然必要となりますが、以下2種類の環境を取ることができます。

|環境|特徴|
|---|---|
|__ECS on EC2__|・自前のEC2インスタンスを立ててECSを稼働させる方式。 <br> ・EC2の細かなパラメータ設定等ができる。  <br> ・EC2のパッチ当てやスケーリング等は利用者側で行う必要がある。|
|__ECS on Fargate__|・実行環境の管理をAWS側で行う方式。 <br> ・EC2のパッチ当てやスケーリング等がAWSによって行われるマネージドなサービス。 <br> ・EC2の細かなパラメータ設定等はできない。|


上記の通り、FargateではEC2を立てる必要が無くサーバインフラの管理やOS設計などが不要になるため、運用負荷が非常に減ります。今回は、そのFargateを使ってアプリをデプロイします。


## 0. 構築するアプリ
アプリのイメージ図は以下の通りです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/451175/d2e99519-d870-52a8-4bf9-5912c71afb93.png)
試しにローカルでclient-service, api-serviceのそれぞれを実行すると、以下のようなレスポンスが得られます。

```zsh:ローカルでのリクエスト
# api-serviceへのリクエスト
$ curl http://localhost:8080/messages
{"message":"hello"}
# client-serviceへのリクエスト
$ curl http://localhost:8090/messages
{"text":"hello world!"}
```

### ディレクトリ構成

```zsh
├── api-service
│   ├── docker
│   │   └── Dockerfile
│   ├── exe
│   │   └── api-service
│   └── src
│       └── api-service.go
└── client-service
    ├── docker
    │   └── Dockerfile
    ├── exe
    │   └── client-service
    └── src
        └── client-service.go
```

### ソース

```go:api-service/src/api-service.go
package main

import (
	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()
	r.GET("/messages", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "hello",
		})
	})
	r.Run(":8080")
}
```

```go:client-service/src/client-service.go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"

	"github.com/gin-gonic/gin"
)
// 環境変数からAPIサービスのエンドポイント取得
var (
	ApiServiceURL  = os.Getenv("API_SERVICE_URL")
	ApiServicePort = os.Getenv("API_SERVICE_PORT")
	ApiResource    = os.Getenv("API_RESOURCE")
)

type Messages struct {
	Message string `json:"message"`
}

type ResponseBody struct {
	Text string `json:"text"`
}

func WrapperFunc(fn func(c *gin.Context)) gin.HandlerFunc {
	return func(c *gin.Context) {
		fn(c)
	}
}
// APIサービスにリクエストしてレスポンス作成
func CreateResponse(c *gin.Context) {
	url := ApiServiceURL + ":" + ApiServicePort + "/" + ApiResource
	resp, _ := http.Get(url)
	defer resp.Body.Close()

	var message Messages
	err := json.NewDecoder(resp.Body).Decode(&message)
	if err != nil {
		fmt.Println(err)
	}

	returnValue := message.Message + " world!"
	response := ResponseBody{
		Text: returnValue,
	}
	c.JSON(200, response)
}

func main() {
	r := gin.Default()
	r.GET("/messages", WrapperFunc(CreateResponse))
	r.Run(":8090")
}
```

api-serviceは、ginでAPIを実行しているだけです。
client-serviceは、ginでAPIを実行＋api-serviceへのリクエストという2つの処理を行っています。


```dockerfile:api-service/docker/Dockerfile
# STEP 1 
FROM golang:alpine as builder
RUN apk update && apk add --no-cache ca-certificates && update-ca-certificates
WORKDIR $GOPATH/bin/
COPY ./exe/api-service ./api-service

# STEP 2 
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /go/bin/api-service /go/bin/api-service

ENTRYPOINT ["/go/bin/api-service"]
```

```dockerfile:client-service/docker/Dockerfile
# STEP 1 
FROM golang:alpine as builder
RUN apk update && apk add --no-cache ca-certificates && update-ca-certificates
WORKDIR $GOPATH/bin/
COPY ./exe/client-service ./client-service

# STEP 2 build a small image
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /go/bin/client-service /go/bin/client-service

ENTRYPOINT ["/go/bin/client-service"]
```

Dockerfileはscratchイメージをマルチステージビルドで作成しています。ただ、今回はSSL通信はしないため通常のイメージでも問題ありません。なお、Goのbuild時には`GOOS=linux`を指定することを忘れないよう注意しましょう。

```zsh:Goのbuild例
$ GOOS=linux go build client-service.go
```

先述の通り、同様のコンテナイメージを作れれば他の言語でも問題ありません。
このアプリをECS Fargate上で動かしていきます。

## 1. ECRにpush
ECSでコンテナを利用するため、AWSのコンテナレジストリサービスであるECRにコンテナイメージをpushします。
まずはECRのコンソールからリポジトリを作成します。任意の名前なので、ここでは`sample-mesh/api-service`という名前です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/451175/16c8ed69-05a0-2a54-a88b-ef5a8e6c7beb.png)
作成されたリポジトリを開き、右上を押してプッシュコマンドを表示します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/451175/0800e584-a585-7f1e-955a-297dd2ca270e.png)
以下のようなコマンド例が表示されます。意味は、コンソールに表示されています。

```zsh:pushコマンド例
$ aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin <account-id>.dkr.ecr.ap-northeast-1.amazonaws.com
# docker build -t sample-mesh/api-service .
# 今回のフォルダ構成だとDockerfileのパスが異なり上記コマンドはエラーになるため、パスを指定して実行します。
$ docker build -t sample-mesh/api-service -f ./docker/Dockerfile .
# -----------------
$ docker tag sample-mesh/api-service:latest <account-id>.dkr.ecr.ap-northeast-1.amazonaws.com/sample-mesh/api-service:latest
$ docker push <account-id>.dkr.ecr.ap-northeast-1.amazonaws.com/sample-mesh/api-service:latest
```

もし、複数AWSアカウント等でAWSプロファイルを複数設定している場合、`aws ecr get-login-password~`でエラーが出る場合がある場合があります。その場合、使用するプロファイルを指定することで解消できるので、以下のコマンドを実行します。

```zsh:
$ export AWS_DEFAULT_PROFILE="プロファイル名"
$ export AWS_PROFILE="プロファイル名"
```

pushが上手くいくとECRのコンソールからもpushされたイメージが確認できます。これをECSで利用する全てのイメージ（今回で言えば、api-serviceとclient-serviceの2つ）について行います。

## 2. ECSの設定

次にECSの設定に入ります。クラスター、タスク定義、サービスの順に構築していきます。

### 1. クラスターの作成
クラスターはECSが管理対象とする論理的なグループです。コンソールから「クラスターの作成」を選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/451175/2cffe089-22b5-e311-3681-df6c542adf1b.png)

続いて、「ネットワーキングのみ」を選択して次のステップへ進みます。これがFargateを利用する上で必要になります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/451175/100b1582-c780-5808-0ed8-2db7d360066a.png)
クラスター名を任意で指定します。タグはお好みでかまいません。
また、クラスター用にVPCを作成する場合には、VPCの作成にチェックを入れましょう。（今回は既にあるVPCを利用）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/451175/afc250fc-35bf-f8e1-6180-7fabb5b9ba9b.png)

「作成」を押すとクラスターが作成されます。この時点では、論理的なグループを作成しただけです。

### 2. タスク定義の作成

タスク定義では、実際に動作するコンテナのイメージや割り当てるIAMロール、環境変数などを定義します。同じくコンソールから「新しいタスク定義」を押します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/451175/7c9e2109-a733-e22e-17a3-33fb0861623f.png)

Fargateを選択した後、タスク定義の入力画面に移ります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/451175/affdf96b-a28a-e805-33b1-78ccee62487c.png)
画面から入力しても良いのですが、結構手間になりますし入力ミスも気になります。ECSではJSONによる設定の登録が可能なので、今回はJSONで登録します。画面半ばくらいにある「JSONによる設定」を選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/451175/87a32a28-af01-748c-6823-fb4e9b70605b.png)
実際に登録するJSONの内容は以下の通りです。account-idの部分は各自のアカウントIDに置き換える必要があります。

```json:ECSタスク定義（api-service）
{
    "family": "api-apl",
    "taskRoleArn": "arn:aws:iam::<account-id>:role/ecsTaskExecutionRole",
    "executionRoleArn": "arn:aws:iam::<account-id>:role/ecsTaskExecutionRole",
    "networkMode": "awsvpc",
    "containerDefinitions": [
        {
            "name": "api-endpoint",
            "image": "<account-id>.dkr.ecr.ap-northeast-1.amazonaws.com/sample-mesh/api-service:latest",
            "portMappings": [
                {
                    "containerPort": 8080,
                    "protocol": "tcp"
                }
            ],
            "essential": true,
            "environment": [
            ],
            "ulimits": [
                {
                    "name": "msgqueue",
                    "softLimit": 0,
                    "hardLimit": 0
                }
            ],
            "logConfiguration": {
                "logDriver": "awslogs",
                "options": {
                    "awslogs-create-group": true,
                    "awslogs-group": "/ecs/sample-mesh",
                    "awslogs-region": "ap-northeast-1",
                    "awslogs-stream-prefix": "api-service"
                }
            }
        }
    ],
    "requiresCompatibilities": [
        "FARGATE"
    ],
    "memory": "512",
    "cpu": "256",
    "tags": [
        {
            "key": "Name",
            "value": "api-service"
        }
    ]
}
```

```json:ECSタスク定義（api-service）
{
    "family": "client-apl",
    "taskRoleArn": "arn:aws:iam::<account-id>:role/ecsTaskExecutionRole",
    "executionRoleArn": "arn:aws:iam::<account-id>:role/ecsTaskExecutionRole",
    "networkMode": "awsvpc",
    "containerDefinitions": [
        {
            "name": "client-endpoint",
            "image": "<account-id>.dkr.ecr.ap-northeast-1.amazonaws.com/sample-mesh/client-service:latest",
            "portMappings": [
                {
                    "containerPort": 8090,
                    "protocol": "tcp"
                }
            ],
            "essential": true,
            "environment": [
                { "name": "API_SERVICE_URL", "value": "http://api-service.sample-mesh.local" },
                { "name": "API_SERVICE_PORT", "value": "8080" },
                { "name": "API_RESOURCE", "value": "messages" }
            ],
            "ulimits": [
                {
                    "name": "msgqueue",
                    "softLimit": 0,
                    "hardLimit": 0
                }
            ],
            "logConfiguration": {
                "logDriver": "awslogs",
                "options": {
                    "awslogs-create-group": true,
                    "awslogs-group": "/ecs/sample-mesh",
                    "awslogs-region": "ap-northeast-1",
                    "awslogs-stream-prefix": "client-service"
                }
            }
        }
    ],
    "requiresCompatibilities": [
        "FARGATE"
    ],
    "memory": "512",
    "cpu": "256",
    "tags": [
        {
            "key": "Name",
            "value": "client-service"
        }
    ]
}
```

詳細は、AWS公式の[タスク定義パラメータ](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task_definition_parameters.html)に一覧がありますが、主要なパラメータを紹介していきます。

|パラメータ|コンソールでの名前|概要|
|---|---|---|
|taskRoleArn|タスクロール|タスクが処理を行う際に利用するIAMロール。例えば、DynamoDBにアクセスする処理がある場合には、DynamoDBにアクセス可能なIAMポリシーの適用が必要 <br> 今回は特にAWSサービスを利用しないため権限が無くても問題無い。選択できるecsTaskExecutionRoleを利用|
|executionRoleArn|タスクの実行IAMロール|ECS自体が利用するIAMロール。ECRからのイメージのpullやCloudWatchへのログ書き込みを行う。 <br> 今回はデフォルトで用意されたecsTaskExecutionRoleを利用|
|containerDefinitions|コンテナの定義|コンテナに対するパラメータ群|
|image|イメージ|コンテナレジストリのURIを指定。今回は、ECRにpushしたイメージのこと。|
|portMappings|ポートマッピング|アプリで利用するポート番号。今回は8080と8090。 <br> __このポート番号はアプリで利用する番号と後続で設定するセキュリティグループと合わせる必要がある。__|
|environment|環境変数|コンテナに渡す環境変数。今回の場合、client-serviceに渡す環境変数を設定 <br> APIサービスのURLには、Cloud Mapで作成される名前検出名を設定（後述）|
|logConfiguration|ログ設定|logDriverにawslogsを指定することで、コンテナ内の標準出力がCloudWatch Logsに出力される。またawslogs-create-groupをtrueにすることで、ロググループが以下の名前で自動作成される。 <br> ・ロググループ： \$awslogs-group <br> ・ログストリーム： \$awslogs-stream-prefix/\$name/ID | 

これを貼り付けて保存するとコンソール上にも反映されます。反映された後「作成」を押すとタスク定義が作成されます。
この時点で、タスク定義が作成されますが、まだコンテナは実行されません。

### 3. サービスの作成

作成したタスク定義を使って、クラスター内でコンテナを実行させます。これがサービスとなります。
タスク定義から、アクション>サービスの作成と進みます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/451175/888ad087-1fd7-da45-d43e-73bfca14fb2d.png)
サービスの設定では、起動タイプにFARGATE, クラスターにはさきほど作成したものを選択、サービス名は任意、タスクの数はひとまず1を入力します。画面下部にあるデプロイメントタイプは「ローリングアップデート」で問題ありません。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/451175/a586377c-d9de-38f4-3ba2-4b0814816279.png)
次のステップに進みます。ネットワーク構成からVPCとサブネットを選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/451175/8f072abf-e015-f225-3c6f-7d4c70d56558.png)
また、セキュリティグループは「編集」から利用するポートを許可します。今回の場合、8080, 8090を利用しているのでそれぞれ許可します。__ECS（というかAWS全体的に）で上手く接続できない場合、ここの設定が誤っているパターンやIAMロールの設定ミスが非常に多いです__

[APIサービス側のセキュリティグループ設定例]
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/451175/54e02027-446c-0a24-9517-18140e1f1c64.png)


その後、__api-serviceの設定時のみ__サービスの検出でチェックを入れて有効化します。ここで、名前空間名に`sample-mesh.local`、サービス検出名に`api-service`を設定します。
これにより、AWSの別サービスであるCloud Mapが設定され、__別のサービスからapi-serviceにapi-service.sample-mesh.localという名前でアクセスすることが可能になります。__さきほど、タスク定義でclient-serviceの環境変数に設定した値はこれのことです。
client-serviceは名前解決不要のため、サービスの検出にチェックを入れる必要はありません。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/451175/c6b1113a-a408-78ce-faad-71b57952ac91.png)
検出名は、サービスの作成後に以下の通りコンソールからも確認できます。先にapi-serviceから作成すると良いと思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/451175/ac5233c7-fdd3-09e2-edc1-2f9a4535da48.png)

その他の設定はデフォルトで次のステップへ進んでいき、最後に「サービスの作成」を押すとサービスが作成されます。正常に作成が終わると、以下の通り2つのサービスが作成され「実行中のタスク」が1になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/451175/0cf2d3ce-5496-7bfe-fa7b-8010ec82cee6.png)

クラスター>タスクタブ>詳細のネットワーク欄に、割り当てられたパブリックIPアドレスが表示されます。（タスクごとにIPアドレスが割り当てられます。）試しにリクエストするとレスポンスが返ってきます。
※デプロイ先がパブリックサブネットである場合です。


```zsh:apiサービスへのリクエスト
$ curl http://XXX.XXX.XXX.XXX:8080/messages
{"message":"hello"}
```

```zsh:clientサービスへのリクエスト
$ curl http://XXX.XXX.XXX.XXX:8090/messages
{"text":"hello world!"}
```

以上で、Fargateで起動するアプリケーションのデプロイと疎通が終わりました。

## 3. 片付け

放置しておくと時間で課金されるため、作成したものを削除していきます。
※今回の構成はセキュリティ的にも緩めなので、その点からもすぐに削除することをおすすめします。

### クラスターの削除
ECSのコンソールから作成したクラスターを選び、画面右上にある「クラスターの削除」を押して「delete me」を入力します。たまに、削除に失敗した旨のメッセージが出ることがありますが、その場合でもサービスはちゃんと削除されてたりします。失敗したら、もう一度「クラスターの削除」を押せばクラスターは削除されます。
※それでも失敗する場合は、サービスから削除していきましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/451175/59e78d4f-230e-cc64-4d49-4c5d06e61c27.png)

### Cloud Mapの削除
api-serviceの名前検出で利用したCloud Mapも削除しておく必要があります。「AWS Cloud Map」のコンソールに移動すると、以下の通りさきほど作成した名前空間が作成されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/451175/25d17c2f-020e-4c1d-a1b9-eb0f10f88732.png)
`sample-mesh.local`を選択して進むと`api-service`がサービスの一覧にあるので削除します。サービスを削除すると名前空間の削除が可能になるため、名前空間の画面に戻って`sample-mesh.local`を削除します。

### ECRの削除
ECRのコンソールから作成したリポジトリを開き削除します。対象は、api-serviceとclient-serviceの2つです。

余談ですが、ECRはコンテナのイメージサイズが大きいほど課金額も上がります。今回、GoのScratchイメージで構築していますが、この場合コンテナサイズが10数MBとかになります。そのため、課金額も非常に安くなります。
こういった理由から、Go以外の言語も含めてコンテナのサイズを小さくすることが重要と言えます。

## 4. 実践的な構成に向けて

今回、できる限りシンプルな構成で作成しましたが、本番適用を考えると以下のような構成も最低限必要になります。

 - プライベートサブネットでの構築
    - 今回両方のサービスをパブリックサブネットで構築していますが、インターネットと通信しないようなサービスはセキュリティ的にプライベートサブネットに配置されるべきです。少なくともapi-serviceはプライベートサブネットに置く必要があります。
    - この場合、セキュリティグループで、api-serviceに対するアクセス元をclient-serviceのVPCに限定する、等の設定も必要です。
    
 - ELBでのロードバランシング/HTTPS化
    - タスク数1で作成しましたが、実際は複数のタスクによる処理分散が行われることが多いです。そのため、ELBを設定して負荷分散を行ったりDNSでアクセスするほうが望ましいです。
    - また、インターネットと繋ぐ場合、本来セキュリティ的にHTTPS化は必須ですので、ELBとACMなどを統合してSSL通信できるように設定します。

## 終わりに
思いのほか長くなってしまいましたが、ECS Fargateの構築手順をまとめてみました。ECSは最近新しい機能や改善が次々と行われるサービスのため、非常に面白いなと個人的には感じています。今後のアップデート等にも期待しましょう。

もし手順ミスなどありましたら、ご指摘いただけるとありがたいです！