承前
会社で需要予測AIサービスをやっています。そこで
- アプリケーション:FastAPI
- Webサーバ:Nignx
- データストア:DynamoDB
で需要予測 API をホストしています。
この API は Docker Compose の AWS 拡張を使っていました。このやりかたはとても便利で、 docker-compose.yml に AWS 用の設定を書いておくと、 docker compose up コマンドで AWS 側に ECS 環境を構築してくれるんです。
ですが、なんと
Docker Compose’s integration for ECS and ACI is retiring in November 2023.
https://docs.docker.com/cloud/ecs-integration/
このインテグレーションは削除されてしまうんですよね(だいぶ前から分かっていたんですが)
ここで CLI をつかって再び手動でわけのわからんことをしてコンテナをデプロイする世界に戻るのは困ります。なので、なんとか簡単にデプロイ出来る方法を探したところ、いまは Amazon Copilot CLI というのを使うのがトレンドらしいと言うことで使ってみることにしました(この名称で検索しようとすると Github Copilot と混在するので極めてノイズが大きいのが悲しいところ)
実は、Copilot は周辺のいろんなものを自動的に生成してくれるのがいいところのひとつ(クセが強いところでもある)なので、 すでにリソースが作られている今回のような移行案件はかえって面倒 ですが、上述の理由でやらざるをえないのでやってみたいとおもいます。
インストール
Windows の WSL2 で作業をします。公式のインストール方法 をみてコマンドを打つだけです。対象は Linux(64bit)でいいでしょう。
$ curl -Lo copilot https://github.com/aws/copilot-cli/releases/latest/download/copilot-linux && chmod +x copilot && sudo mv copilot /usr/local/bin/copilot && copilot --help
でインストールされました。インストールされたバージョンは
$ copilot -v
copilot version: v1.32.0
でした。
AWSアカウントの考え方
始める前に……アカウントの考え方をこれまでと考えを変えないとダメそうなのでまとめ。
(リソースとか適当な図です)
これまでは開発環境でいろいろ試行錯誤してできた構成と同じ構成を本番環境にそのまま構築するイメージだったので、時期が来たら設定値を変えながら本番環境も作るみたいなことも多かったです。
ところが、Copilotはアカウント横断のリソースが作られるため、本番環境・開発環境とわけていても両者を横断するための情報を入れる場所を作る必要があります。本番環境にデプロイする前から本番環境にはリソース管理の仕組みをおいておく必要があります。で、開発環境にデプロイできる状態を作った時点でほぼ本番環境も準備が出来ているようなものですね。
この管理情報を置く場所は別に開発環境でも良いんですけど(なんなら本番でも開発でも無いところでも良い)うちのばあいドメインの管理(Route53)が本番環境側であることが多くて楽そうなので、結局最初から本番環境におくことになりそうです。
Application の作成
ではやっていきます。まずはApplicationを作ります。
現在フォルダ構成なんですが
/
├ app/
│ ├ Dockerfile
│ └ ...
├ nginx/
│ ├ Dockerfile
│ └ ...
├ .env
├ docker-compose-dev.yml
├ docker-compose-local.yml
└ docker-compose-proc.yml
となっています。app 以下にアプリがあります。このアプリは Submodule になっており、自分が Nginx などと協調しながら動作したりデプロイされることをアプリ側は知らないですむような作りになっています。
いまは docker-compose-dev.yml に AWS の開発環境へのデプロイの情報が入っています。x-aws-cloudformation とかのフォールドですね。最終的にこの docker-compose-dev.yml と docker-compose-prod.yml は不要になる はずです(ローカルでの docker-compose によるテストはできたほういいのでlocal は残します)。
Application はこの階層レベルの概念のはずなのでここで copilot app init します。
$ copilot app init hawk-api --domain 'ai-hawk.com'
✘ get application hawk-api: get application hawk-api: UnrecognizedClientException: The security token included in the request is invalid.
status code: 400, request id: 129d491c-8c3c-470a-abae-b11326c255ad
いきなり怒られました。どうやら AWS Profile の default で作りに行こうとしてエラーになったようです。いくつかアカウントを併用して使っていることもあって、ミスを防ぐために default はダミーを入れているんですよね。対話的にどのプロファイルを使うか聞いてくれるモノだと思ってましたが、管理用の情報を入れるアカウントはデフォルトが採用されてしまうってことでしょう。
試しに --profile オプションが効くかと思って試しましたがダメだったので、素直に環境変数をいじります。
$ export AWS_PROFILE=zenk_prod
$ export AWS_REGION=ap-northeast-1
次は成功しました。なんだかうにゃうにゃ作っていましたが、しばらくして終わりました。
$ copilot app init hawk-api --domain 'ai-hawk.com'
(省略)
[create complete] [34.1s]
- Creating the infrastructure for stack hawk-api-infrastructure-roles [create complete] [99.1s]
- A StackSet admin role assumed by CloudFormation to manage regional stacks [create complete] [16.6s]
- Add NS records to delegate responsibility to the hawk-api.ai-hawk.com subdomain [create complete] [34.1s]
- A hosted zone for hawk-api.ai-hawk.com [create complete] [56.1s]
- A DNS delegation role to allow accounts: 071921569924 to manage your domain [create complete] [22.1s]
- An IAM role assumed by the admin role to create ECR repositories, KMS keys, and S3 buckets [create complete] [20.2s]
✔ The directory copilot will hold service manifests for application hawk-api.
Recommended follow-up action:
- Run `copilot init` to add a new service or job to your application.
よく分かりません。長すぎて前半が見切れてました。まあエラーなかったので出来たってことかな。
ローカルには /complot/.workspace
というファイルが出来ています。中身はこれだけ
application: hawk-api
ここでアプリケーション名を規定しているようです。
クラウド側をみてみます。
CloudFormation に
- hawk-api-infrastructure-roles という名前の Stack
- hawk-api-infrastructure という名前の StackSet
などが作成されていました。
Parameter Store に /copilot/applications/hawk-api
というパラメータが出来てました。中身は
{"name":"hawk-api","account":"*********","domain":"ai-hawk.com","domainHostedZoneID":"Z*********","version":"1.0"}
です。Parameter Store にいろんな設定値を保存していく作りになっているようですね。
Route 53 に hawk-api.ai-hawk.com のホストゾーンが出来ており、NSレコードとSOAレコードが出来ていました。
$copilot app ls
hawk-api
Environment を作成
次は Environment を作成します。別のアカウント zenk_dev を用意しました。
最初に
Default environment configuration?
と聞かれるので
1.Yes, use default.
と答えておきます。VPCなど既存のものを使いたいときは他の選択肢があるようです。
$ copilot env init --app hawk-api --name dev --profile zenk_dev
Default environment configuration? Yes, use default.
✔ Wrote the manifest for environment dev at copilot/environments/dev/manifest.yml
✔ Shared DNS permissions for this application to account 690144984034.
- Update regional resources with stack set "hawk-api-infrastructure" [succeeded] [0.0s]
- Update regional resources with stack set "hawk-api-infrastructure" [succeeded] [153.1s]
- Update resources in region "ap-northeast-1" [create complete] [153.3s]
- KMS key to encrypt pipeline artifacts between stages [create complete] [120.7s]
- S3 Bucket to store local artifacts [create in progress] [1.7s]
✔ Proposing infrastructure changes for the hawk-api-dev environment.
- Creating the infrastructure for the hawk-api-dev environment. [create complete] [51.0s]
- An IAM Role for AWS CloudFormation to manage resources [create complete] [21.9s]
- An IAM Role to describe resources in your environment [create complete] [21.5s]
✔ Provisioned bootstrap resources for environment dev in region ap-northeast-1 under application hawk-api.
Recommended follow-up actions:
- Update your manifest copilot/environments/dev/manifest.yml to change the defaults.
- Run `copilot env deploy --name dev` to deploy your environment.
作成後はデプロイが必要とのことなので、デプロイしてみます(まだアプリケーションの指定も無いのに何をデプロイするのかよく分からないですが……)
$ copilot env deploy --name dev
エラーになりました。
The maximum number of internet gateways has been reached
これは、VPCの作成上限にひっかかってしまっている……ってことなんでしょうか。この辺りを参考に、VPCのクオータ上限の引き上げリクエストをしてみることにしました。なんだか遠回りが多いですね。
作成できました。
- Creating the infrastructure for the hawk-api-dev environment. [update complete] [221.5s]
- An ECS cluster to group your services [create complete] [3.6s]
- An IAM role to manage certificates and Route53 hosted zones [create complete] [17.8s]
- Delegate DNS for environment subdomain [create complete] [76.0s]
- A Route 53 Hosted Zone for the environment's subdomain [create complete] [45.5s]
- A security group to allow your containers to talk to each other [create complete] [6.0s]
- Request and validate an ACM certificate for your domain [create complete] [83.8s]
-
- An Internet Gateway to connect to the public internet [create complete] [19.4s]
- A resource policy to allow AWS services to create log streams for your workloads. [create complete] [0.0s]
- Private subnet 1 for resources with no internet access [create complete] [4.1s]
- Private subnet 2 for resources with no internet access [create complete] [4.1s]
- A custom route table that directs network traffic for the public subnets [create complete] [10.4s]
- Public subnet 1 for resources that can access the internet [create complete] [4.1s]
- Public subnet 2 for resources that can access the internet [create complete] [4.1s]
- A private DNS namespace for discovering services within the environment [create complete] [47.0s]
- A Virtual Private Cloud to control networking of your AWS resources [create complete] [11.8s]
また見切れてしまいましたが、まあ大丈夫なのでしょう。
Route53に
- dev.hawk-api.ai-hawk.com のホストゾーン
ができていました。
CloudFormation に
- hawk-api-dev というスタック
がありました。中を見ると VPC や Subnet やらが出来ています。
ローカルに copilot/environments/env/manifest.yml
というファイルが置かれています。中身はこんな感じです。
# The manifest for the "dev" environment.
# Read the full specification for the "Environment" type at:
# https://aws.github.io/copilot-cli/docs/manifest/environment/
# Your environment name will be used in naming your resources like VPC, cluster, etc.
name: dev
type: Environment
# Import your own VPC and subnets or configure how they should be created.
# network:
# vpc:
# id:
# Configure the load balancers in your environment, once created.
# http:
# public:
# private:
# Configure observability for your environment resources.
observability:
container_insights: false
ほとんどコメントアウトされているのでテンプレートって感じですね。
管理アカウントのパラメータストアには
/copilot/applications/hawk-api/environments/dev
というパラメータが出来ており、中身は
{"app":"hawk-api","name":"dev","region":"ap-northeast-1","accountID":"********","registryURL":"","executionRoleARN":"arn:aws:iam::********":role/hawk-api-dev-CFNExecutionRole","managerRoleARN":"arn:aws:iam::********":role/hawk-api-dev-EnvManagerRole"}
こんなかんじでした。
Service を作成
続いてサービスを作成します。
$ copilot svc init --app hawk-api --name api --dockerfile ./app/Dockerfile
エラーになりました。
Note: It's best to run this command in the root of your workspace.
Service type: Load Balanced Web Service
parse EXPOSE: no EXPOSE statements in Dockerfile ./app/Dockerfile
Port: 80
Docker Compose's integration for ECS and ACI will be retired in November 2023. Learn more: https://docs.docker.com/go/compose-ecs-eol/
Command "info" not available in current context (hoge_prod), you can use the "default" context to run this command
check if docker engine is running: get docker info: exit status 1
おっと、Docker Compose's integration でつくったコンテキスト(hoge_prod)が有効のままだったようです。コンテキストをデフォルトにします。というかつまりこれって ローカル側で Docker が動いてないとダメ ってことなのか……。
$ docker context use default
そしてもういちどやりなおしてみます。
$ copilot svc init --app hawk-api --name api --dockerfile ./app/Dockerfile
聞かれます。
Which service type best represents your service's architecture? [Use arrows to move, type to filter, ? for more help]
Request-Driven Web Service (App Runner)
> Load Balanced Web Service (Public. ALB by default. Internet to ECS on Fargate)
Backend Service (Private. ALB optional. ECS on Fargate)
Worker Service (Events to SQS to ECS on Fargate)
Static Site (Internet to CDN to S3 bucket)
Load Balanced Web Service を選択します。
成功したみたいです。
ローカルに copilot/api/manifest.yml
というファイルが出来ました。ここで、このファイルにSidecarの設定をします。Sidecar パターンはメインのアプリケーションと連動して起動するコンテナがあるときのやり方のようで、Nginx のような Web サーバは Sidecar に設定するのがよいみたいですね。
さらにこのアプリはヘルスチェック用のエンドポイントがあるので、ヘルスチェックを /healthcheck とするようにマニフェストを書き換えました(じつはこの段階では間違っていたのであとで再掲します)
Storage の作成
今回このアプリケーションではちょうど DynamoDB を使っていたので以下のように作成します。
ApiJobs というテーブルを、パーティションキー jobId のみ(ソートキーなし)で作成します。
$ copilot storage init --storage-type DynamoDB --name ApiJobs --workload api --partition-key jobId:S --no-sort
Lifecycle: Yes, the storage should be created and deleted at the same time as api
✔ Wrote CloudFormation template at copilot/api/addons/ApiJobs.yml
Recommended follow-up actions:
- Update api's code to leverage the injected environment variable API_JOBS_NAME.
For example, in JavaScript you can write:
```
const storageName = process.env.API_JOBS_NAME
```
- Run `copilot deploy --name api` to deploy your storage resources.
ApiHawkPredictions というテーブルをパーティションキーとソートキーつきで作成します。
$ copilot storage init --storage-type DynamoDB --name ApiHawkPredictions --workload api --partition-key clientId:S --sort-key predId:S
Lifecycle: Yes, the storage should be created and deleted at the same time as api
Additional sort keys? No
✔ Wrote CloudFormation template at copilot/api/addons/ApiHawkPredictions.yml
Recommended follow-up actions:
- Update api's code to leverage the injected environment variable API_HAWK_PREDICTIONS_NAME.
For example, in JavaScript you can write:
```
const storageName = process.env.API_HAWK_PREDICTIONS_NAME
```
- Run `copilot deploy --name api` to deploy your storage resources.
ファイルが二つ作成されたみたいです。
- copilot/api/addons/ApiJobs.yml
- copilot/api/addons/ApiHawkPredictions.yml
デフォルトは BillingMode: PAY_PER_REQUEST (従量課金)で作成されるみたいですね。キャパシティ有りの設定も出来るようですが、今回は触りません。
さて、ここで作ったテーブルですが、こういう名前になるようです。
${App}-${Env}-${Name}-ApiJobs
${App}-${Env}-${Name}-ApiHawkPredictions
これは環境変数でアプリに渡さないといけないやつですね。マニフェストを書き換えて環境変数を追加しないと……ようかとおもったんですが、なんとここでつくったストレージの名前などは自動的に環境変数に吐き出されるようなんです。この記事の最後を見ると、Outputsのフィールドに書かれているものがそのようです。
というわけで、さっきの copilot/api/addons/ApiHawkPredictions.yml のところをみます
Outputs:
ApiHawkPredictionsName:
Description: "The name of this DynamoDB."
Value: !Ref ApiHawkPredictions
という行があります。これをもとに ApiHawkPredictionsName をスネークケースにして大文字化したAPI_HAWK_PREDICTIONS_NAMEという名前の環境変数を勝手に作ってくれるようです。ただこれだと既存の環境変数と命名的に混乱しそうなので、名前を変更しました。
Outputs:
DynamoPredictTableName:
Description: "The name of this DynamoDB."
Value: !Ref ApiHawkPredictions
これで、DYNAMO_PREDICT_TABLE_NAME という変数にテーブル名が入るはず。同じことを copilot/api/addons/ApiJobs.yml にもしておきます。
デプロイ
そしていよいよデプロイ
copilot deploy --name api
……なんですが、ちょっと別の理由で docker イメージのビルドに失敗してまして(前にビルドしてからしばらく時間が経っていたのでライブラリの依存関係の問題が発生していた)、実際にデプロイできるようになるまで時間がかかりました。あたりまえですが、ローカルでビルドできることを確かめてからデプロイした方が時間のロスが少ないですね。
さっそくビルドしてみましたが、うまくいきませんでした。
- Creating the infrastructure for stack hawk-api-dev-api [create in progress] [1750.7s]
- Service discovery for your services to communicate within the VPC [create complete] [0.0s]
- Update your environment's shared resources [update complete] [182.2s]
- A security group for your load balancer allowing HTTP traffic [create complete] [3.6s]
- A security group for your load balancer allowing HTTPS traffic [create complete] [0.0s]
- An Application Load Balancer to distribute public traffic to your services [create complete] [155.4s]
- A load balancer listener to route HTTPS traffic [create complete] [3.0s]
- A load balancer listener to route HTTP traffic [create complete] [0.0s]
- An IAM role to update your environment stack [create complete] [15.1s]
- An IAM Role for the Fargate agent to make AWS API calls on your behalf [create complete] [16.8s]
- An HTTP listener rule for path `/` that redirects HTTP to HTTPS [create complete] [1.9s]
- A custom resource assigning priority for HTTP listener rules [create complete] [0.0s]
- An HTTPS listener rule for path `/` that forwards HTTPS traffic to your tasks [create complete] [1.7s]
- A custom resource assigning priority for HTTPS listener rules [create complete] [3.4s]
- The default alias record for the application load balancer [create complete] [33.8s]
- A CloudWatch log group to hold your service logs [create complete] [0.0s]
- An IAM Role to describe load balancer rules for assigning a priority [create complete] [15.1s]
- An ECS service to run and maintain your tasks in the environment cluster [delete complete] [52.6s]
Resource handler returned message: "Error occurred during operation 'E
CS Deployment Circuit Breaker was triggered'." (RequestToken: ed93fa61
-7caf-3ac9-953e-96ba9dfc3795, HandlerErrorCode: GeneralServiceExceptio
n)
Deployments
Revision Rollout Desired Running Failed Pending
PRIMARY 1 [failed] 1 0 10 1
Latest 2 stopped tasks
TaskId CurrentStatus DesiredStatus
3f7aed44 STOPPED STOPPED
f979887b STOPPED STOPPED
✘ Latest 2 tasks stopped reason
- [3f7aed44,f979887b]: Essential container in task exited
Troubleshoot task stopped reason
1. You can run `copilot svc logs --previous` to see the logs of the last stopped task.
2. You can visit this article: https://repost.aws/knowledge-center/ecs-task-stopped.
✘ Latest failure event
- (service hawk-api-dev-api-Service-PVr41NH4rAm5) (deployment ecs-svc/20
80008317557257062) deployment failed: tasks failed to start.
- A target group to connect the load balancer to your service on port 443 [delete complete] [1.6s]
- An ECS task definition to group your containers and run them on ECS [delete complete] [0.0s]
- An IAM role to control permissions for the containers in your tasks [delete complete] [14.3s]
✘ deploy service api to environment dev: deploy service: stack hawk-api-dev-api did not complete successfully and exited with status ROLLBACK_COMPLETE
エラーが表示されてから実際に終わるのにめちゃくちゃ時間がかかった のは何かつくったさまざまなリソースを全てロールバックしているからでしょうか。なるべくローカルで動くのを確かめてからの方が良さそうです。
なお、ぜんぶ消えてしまうと AWS のコンソールのタスクのところからログが見えなくなる(タスクが消えちゃうから)のですが、ロールバック中ならまだタスクがあるのでログのタブから見られます(もっとも CloudWatch にデプロイ中のログは残っているようなのでタスクが消えても見られる)
今回のエラーは Nginx の設定の問題でした。
nginx: [emerg] host not found in upstream "api-hawk-v1" in /etc/nginx/nginx.conf:42
nignx の設定でもともと docker-compose の設定で作っていたapi-hawk-v1というネットワーク名を使ってリバースプロキシを組んでいたところが問題になっていました。これは API の別バージョンを別コンテナとして同時にデプロイできるように意味があってやってたんですが、ここはいったん localhost に変更しておきます。
それから、
image:
build: app/Dockerfile
port: 5000
ここのポートが間違っていました。ここは Unicorn が起動するポートで、Docker Compose でいうとexpose という設定ですね。ここを 80 にして、サイドカーを HTTPS にしなきゃと 443 に設定していたのが間違いだったようです(前者を 5000 に、Sidecar を 80 に設定するのが正解)
これが正解です。
# The manifest for the "api" service.
# Read the full specification for the "Load Balanced Web Service" type at:
# https://aws.github.io/copilot-cli/docs/manifest/lb-web-service/
# Your service name will be used in naming your resources like log groups, ECS services, etc.
name: api
type: Load Balanced Web Service
# Distribute traffic to your service.
http:
# Requests to this path will be forwarded to your service.
# To match all requests you can use the "/" path.
path: '/'
# You can specify a custom health check path. The default is "/".
healthcheck: '/healthcheck/'
targetContainer: nginx
# Configuration for your containers and service.
image:
# Docker build arguments. For additional overrides: https://aws.github.io/copilot-cli/docs/manifest/lb-web-service/#image-build
build: app/Dockerfile
# Port exposed through your container to route traffic to it.
port: 5000
cpu: 256 # Number of CPU units for the task.
memory: 512 # Amount of memory in MiB used by the task.
count: 1 # Number of tasks that should be running in your service.
exec: true # Enable running commands in your container.
network:
connect: true # Enable Service Connect for intra-environment traffic between services.
# storage:
# readonly_fs: true # Limit to read-only access to mounted root filesystems.
# Optional fields for more advanced use-cases.
#
#variables: # Pass environment variables as key value pairs.
# LOG_LEVEL: info
#secrets: # Pass secrets from AWS Systems Manager (SSM) Parameter Store.
# GITHUB_TOKEN: GITHUB_TOKEN # The key is the name of the environment variable, the value is the name of the SSM parameter.
# You can override any of the values defined above by environment.
#environments:
# test:
# count: 2 # Number of tasks to run for the "test" environment.
# deployment: # The deployment strategy for the "test" environment.
# rolling: 'recreate' # Stops existing tasks before new ones are started for faster deployments.
sidecars:
nginx:
port: 80
image:
build: nginx/Dockerfile
variables:
NGINX_PORT: 80
あと普通にアプリのライブラリの古さなどで何回もこけましたが、ついに動くようになりました。
最終的には
- hawk-api-dev-Cluster-<ランダム数字>というクラスター
- クラスターの中に hawk-api-dev-api-Service<ランダム数字> というサービス
- クラスターの中に hawk-api-dev-api というタスク定義
- nginx と api というコンテナ
- hawk-a-Publi-<ランダム数字> というALB
- dev.hawk-api.ai-hawk.com への証明書
などなどが出来ておりました。
最終的な構成図
これ以外にも作られているので網羅的ではありませんが、こんな感じになります。
めちゃくちゃややこしい!!! たくさんリソースを作ってくれるのは良いんですが、こりゃ手軽にやるってかんじじゃないですね。とはいえ、もともとの docker-compose を使ったやり方も、あらかじめ AWS がわにたくさんリソースを作ってそれを記載することで、アプリケーションの入れ替えは簡単になるというものだったので、リソース自体を Copilot に管理させることが出来るぶん、理解できてさえいればこちらのほうが便利かもしれないです。
気付いたことなど
HTTPS アクセスについて
マニフェストですが、HTTPSでアクセスされたいからといってサイドカーの設定を
nginx:
port: 443
image:
build: nginx/Dockerfile
variables:
NGINX_PORT: 443
のように443にしなくてもよいです。以下のような 80 設定で問題なく HTTPS で公開されます(HTTP でアクセスしても HTTPS にリダイレクトされる)
nginx:
port: 80
image:
build: nginx/Dockerfile
variables:
NGINX_PORT: 80
443の設定だと内部 Ngnix -> アプリ(今回はUnicornが起動してる)の間もSSL通信になるみたいなことだとおもうのですが上手く行きませんでした。Ngnix 側も設定が必要なのかも。ここはあまり重要性を感じないため追うことはしませんでした。
ドメインについて
dev.hawk-api.ai-hawk.com という証明書が開発環境側に勝手に出来てしまいましたが ai-hawk.com はワイルドカード証明書なのでなんとかした方が良いのかな? という気はしてます。
バージョン毎にコンテナを分けてリバースプロキシで割り振るやり方を考える
もともとはAPIバージョン毎にアプリケーションサーバのコンテナを分けてまして、 Ngnix の設定でサブディレクトリ /v1/ ならバージョン1をホストしているコンテナに振る、みたいなことをしていましたが、今回の構成でそれをやるのは難しいだろうか……? Ngnix も複数立てる構成なら簡単なんですが……
CIをくっつける
pipeline を簡単に設定出来るらしいので、次はそれをやってみます。