AWS CopilotでWorker Serviceを試す

## はじめに

AWS ECSを使ったアプリケーションを簡単にデプロイできるツールであるAWS Copilot CLIでSQSを使ってみます。  
Copilotにはインターネットからアクセスされるウェブアプリケーションをデプロイできる Load Balanced Web Service や定期的に起動するバッジである Scheduled Job と並んで Worker Service というメッセージを受信して処理するタイプのサービスがあります。が、この Worker Service で具体的にどのようなアプリケーションがデプロイされるのかいま一つイメージがつかなかったため、実際にWorker Serviceをデプロイして、非同期サービス間通信を体験してみました。  

### 参考

公式ドキュメント

https://aws.github.io/copilot-cli/ja/docs/concepts/services/#worker-service

https://aws.github.io/copilot-cli/ja/docs/developing/publish-subscribe/

## 前提

```bash
$ copilot version
version: v1.14.0, built for linux
```

CopilotにおけるApplication、Serviceとはなにか、などCopilotに関する基礎知識に関する説明は省略します。また、Application、Environmentは作成済みとしてそこへServiceをデプロイする部分の手順だけ記載します。  

## 目標

次の図のような一連の流れを実現させようと思います。（ALBなどを省略した簡略図です）

![worker_service](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273742/e5c5eef5-6fec-5ab4-3a5d-b972d3a66d68.png)

Load Balanced Web Service をECSにデプロイし、これをメッセージを送信するパブリッシャーとします。  
具体的には、今回の試行ではAPIをデプロイし、インターネットからPOSTアクセスがあればそのアクセスに含まれるデータをSNSに送信します。  

さらにもう一つ、 Worker Service をECSにデプロイし、メッセージを処理するサブスクライバーとします。  
このサービスがSQSからメッセージを取得し、処理します。

Copilotのドキュメントによると Worker Service は[pub/sub アーキテクチャ](https://aws.amazon.com/jp/pub-sub-messaging/)を実装するものらしいです。  
私はpub/sub アーキテクチャについての知見が無いため、これについて解説することはできないのですが、ともかくまずは最小の構成で試してみます。

## パブリッシャーをデプロイ

まず、SNS経由でSQSにメッセージを送信する側であるパブリッシャーをデプロイします。  
Load Balanced Web Service で試してみましたが、パブリッシャー側は Scheduled Job などどのタイプのサービスでも良さそうです。  
今回デプロイしてみたアプリケーションはGitLabに置いてあります。[send-message-api](https://gitlab.com/my-study-etc-group/send-message-api)で確認できます。  
Spring Boot + JavaによるWebアプリケーションになっており、POSTリクエストがあるとそのBodyをSNSへ送信します。Controllerで全ての処理を実施している簡易的な実装です。

### Service作成

```bash
copilot svc init -a demo-app -n send-message-api -t "Load Balanced Web Service"
```

`demo-app`というApplicationは事前にできているものとします。`send-message-api`というServiceを`Load Balanced Web Service`タイプでinitします。  

次にcopilotがプロジェクト内に作成したmanifestに次のような`publish`セクションを追加します。  

```yaml
publish:
  topics:
    - name: order-events
```

参考のためにmanifest全体を折りたたみ中に記載しておきます。  

<details><summary>manifest.yml</summary><div>

```yaml
name: send-message-api
type: Load Balanced Web Service

http:
  path: 'send-message-api'
  healthcheck: '/send-message-api/actuator/health'

image:
  build: Dockerfile
  port: 8080

cpu: 256
memory: 512
count: 1
exec: true

publish:
  topics:
    - name: sample-sns
```

</div></details>

### デプロイ

```bash
copilot svc deploy -a demo-app -e environment -n send-message-api
```

でデプロイします。`develop`Environmentは作成済みとします。
`demo-app`Applicationの`develop`Environmentに`send-message-api`というServiceがデプロイされます。

`publish`セクションの内容に沿って、SNSが作成されます。  
自分が試したときには`demo-app-develop-send-message-api-sample-sns`という名前のSNSになりました。  
SNSの名称は  
`${Application}-${Environment}-${Service}-${manifestファイル中のpublish.topics.name}`  
のように命名され、一意になるということのようです。  
なお、実際はSNSのARNは環境変数経由で取得できるため、SNSが具体的にどのような名前で作られたかは基本的に意識する必要はありません。

#### 環境変数に格納されるSNSのARN

作成されたSNSのARNは環境変数としてコンテナに設定されます。  
具体的には今回の動作確認では`COPILOT_SNS_TOPIC_ARNS`という環境変数に`{"sample-sns":"arn:aws:sns:ap-northeast-1:xxxxxxxx:demo-app-develop-send-message-api-sample-sns"}`のようなJSON文字列が設定されました。  
単純にARNが設定されるのではなく、JSON文字列であることに注意が必要です。パースしてARNを取り出さなければなりません。

## サブスクライバーをデプロイ

次にSQSからメッセージを取り出して処理するサブスクライバーをデプロイします。  
今回デプロイしてみたアプリケーションはパブリッシャー同様GitLabに置いてあります。[receive-message-batch](https://gitlab.com/my-study-etc-group/receive-message-batch)で確認できます。  
ほぼメインメソッドしかない超簡易的な実装です。

### Service作成

```bash
copilot svc init -a demo-app -n receive-message-batch -t "Worker Service"
```

`receive-message-batch`というServiceを`Worker Service`タイプでinitします。  

次にcopilotがプロジェクト内に作成したmanifestの`subscribe`セクションを次のように記載します。

```yaml
subscribe:
  topics:
    - name: sample-sns
      service: send-message-api
```

[Worker Serviceのmanifestについてのドキュメント](https://aws.github.io/copilot-cli/ja/docs/manifest/worker-service/)

上記のような書き方をすることで`send-message-api`Serviceの`sample-sns`トピックをサブスクライブします。つまり、先程デプロイしたServiceが送信するメッセージを受け取れるようになります。  

参考のためにmanifest全体を折りたたみ中に記載しておきます。  

<details><summary>manifest.yml</summary><div>

```yaml
name: receive-message-batch
type: Worker Service

image:
  build: Dockerfile

cpu: 256
memory: 512
count: 1
exec: true

subscribe:
  topics:
    - name: sample-sns
      service: send-message-api

command: java -jar receive-message-batch-all.jar
```

</div></details>

### デプロイ

```bash
copilot svc deploy -a demo-app -e environment -n receive-message-batch
```

でデプロイします。`demo-app`Applicationの`develop`Environmentに`receive-message-batch`というServiceがデプロイされます。

`demo-app-develop-receive-message-batch-EventsQueue-yyyyyy`のような名称のSQSが追加されます。さらに、SNSのへのサブスクリプション、必要なポリシーなども自動で作成されます。  

SNSと似たように、SQSの名称は  
`${Application}-${Environment}-${Service}-EventsQueue-${ランダム文字列}`  
のように命名され、一意になるということのようです。  
パブリッシャーにおけるSNS同様にSQSのURIは環境変数経由で取得できるため、具体的にどのような名前のSQSが作成されたかなどは基本的に意識する必要はありません。

#### 環境変数に格納されるSNSのARN

作成されたSQSのARNは環境変数としてコンテナに設定されます。  
具体的には今回の動作確認では`COPILOT_QUEUE_URI`という環境変数に`https://sqs.ap-northeast-1.amazonaws.com/xxxxxx/demo-app-develop-receive-message-batch-EventsQueue-yyyyyy`のような文字列が設定されました。今回はJSONではありません。  

#### Worker Serviceの挙動

勝手な思い込みでしかないのですが、当初私は Worker Service はSQSにメッセージが送信されるたびに起動されてメッセージを処理し、それ以外のタイミングでは起動しないものだと思っていました。  
例えばlambdaだとそのような起動の仕方が可能だったはずです。  
しかし、実際の Worker Service はそのようなものではなく、基本的にコンテナは常駐し、SQSをポーリングしメッセージがあるか確認する処理などは自前で実装する必要があります。  
どのような実装が良いのかはよくわかりませんでしたが、一旦while文で無限ループさせ、SQSをポーリングするようにしました。（[この部分です](https://gitlab.com/my-study-etc-group/receive-message-batch/-/blob/master/src/main/java/com/example/receivemessagebatch/ReceiveMessageBatch.java#L29)）

## 動作確認

ここまでの手順でパブリッシャーとサブスクライバーがデプロイされ、SNS・SQSを介して連携するようになりました。  

今回私はサンプルとして[send-message-api](https://gitlab.com/my-study-etc-group/send-message-api)（パブリッシャー）、[receive-message-batch](https://gitlab.com/my-study-etc-group/receive-message-batch)（サブスクライバー）をデプロイしています。

今回の場合、次のように動作確認しました。

#### パブリッシャーへPOSTリクエスト

パブリッシャーは Load Balanced Web Service としてSNSへメッセージを送信するWebアプリケーションをデプロイしているので、

```bash
curl -X POST -H "Content-Type: application/json" -d '{"message":"java"}'  http://${デプロイしたLoad Balanced Web Serviceに紐づくALBのDNS名}/send-message-api/send
```

のようにPOSTリクエストします。


#### SNSからSQSへとメッセージの伝達

先述のPOSTリクエストでパブリッシャーはリクエストボディ取得してSNSへ送信します。（[この部分です](https://gitlab.com/my-study-etc-group/send-message-api/-/blob/master/src/main/java/com/example/sendmessageapi/SendMessageController.java#L35)）  
Worker Serviceデプロイ時に作成されたSQSはSNSをサブスクリプションしているので、SQSへメッセージが渡ります。

Worker ServiceはSQSをポーリングしているため、メッセージがSQSに入ると、それを取得し、処理します。今回の[receive-message-batch](https://gitlab.com/my-study-etc-group/receive-message-batch)は処理といってもログ出力（[この部分です](https://gitlab.com/my-study-etc-group/receive-message-batch/-/blob/master/src/main/java/com/example/receivemessagebatch/ReceiveMessageBatch.java#L38)）するだけですが、メッセージがあると、そのメッセージをログ出力します。

これで2つのサービスがSNS・SQSを介して非同期的にコミュニケーションできていることが確認できました。

## その他追加で試したこと

### キューの細かい設定

Worker Service で最小の設定として

```yaml
subscribe:
  topics:
    - name: sample-sns
      service: send-message-api
```

のような記載をmanifestに書きました。この場合SQSはデフォルト設定で作成されます。  
しかし、[Worker Serviceに関するドキュメント](https://aws.github.io/copilot-cli/ja/docs/manifest/worker-service/)にはキューのカスタマイズ設定も紹介されています。  
目一杯設定すると次のようになります。

```yaml
subscribe:
  topics:
    - name: sample-sns
      service: send-message-api
  queue:
    # 可視性タイムアウト
    timeout: 45s
    # メッセージ保持期間
    retention: 71h
    # 配信遅延
    delay: 30s
    dead_letter:
      # デッドレターキュー 最大受信数
      tries: 5
```

コメントに書いている項目を設定できるようです。  
「可視性タイムアウト」とはなにか、などは前述のCopilotのドキュメントおよびSQSのドキュメント（[キューパラメータの設定(コンソール)](https://docs.aws.amazon.com/ja_jp/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-configure-queue-parameters.html)など）が参考になります。

### サブスクライバーが複数いる場合

1つのパブリッシャーに対して複数のサブスクライバーがいるパターンも試しました。  
別の Worker Service を`copilot svc init -t "Worker Service"`で作成し、manifestに全く同じように、

```yaml
subscribe:
  topics:
    - name: sample-sns
      service: send-message-api
```

と記載してデプロイするだけです。  
すると、このサービス用にもう一つSQSが作成され、SNSへのサブスクリプションが設定されます。  
パブリッシャーがSNSへメッセージを送信すると両方のSQSにメッセージが送られ、ほぼ同時に2つの Worker Service が同じメッセージを受信して処理を実施します。  

### FIFOキューについて

現在SQSのFIFOキューには対応していなさそうです。  
しかし、下記のIssueを見ると近いうちに対応されるのかもしれません。

https://github.com/aws/copilot-cli/issues/2800