AWS Copilotで本番環境をコンテナ化する

14 min read読了の目安(約13200字

Leaner Technologies の黒曜(@kokuyouwind)です。

入社して一月しか経ってないのでまだまだ新米です。

先日、 API サーバ本番環境を AWS Copilot CLIEC2 から ECS に載せ替えたので、移行理由や苦労したポイントなどを紹介します。

本文が長いので 3 行で

AWS Copilot CLI でコンテナ化したら一瞬で ECS 構成を作れてめちゃくちゃ便利でしたが、色々ハマるポイントもありました。

機能上の制約が結構あるため、既存サービスを置き換えるために使うのは厳しいケースが多そうです。

新しいサービスを立ち上げるけど将来を見越してモダンな構成にしたい、という局面で使うのがマッチしそうです。

コンテナ化の背景

今回 ECS 化したサービスは、もともと EC2 上で直接 Rails Server を稼働させていました。[1]

Image from Gyazo

よくある構成ですが、開発が活発になると以下のような問題が出てきました。

  • 各インスタンスに ssh してデプロイ作業やログ調査を行っていたため手間がかかり、オペレーションミスの可能性もある
  • .env をインスタンス間で一致させないとバグに繋がり、変更の履歴も取れていない
  • EC2 インスタンスの脆弱性検査が行えておらず、セキュリティ面の整備が必要

それぞれの問題を個別に対応しても良かったのですが、これを機にモダンなコンテナ構成へ載せ替えてしまうほうが結果的に早く、また将来性もあるだろうと判断しました。

ツールの選択肢

AWS ECS で環境を作るにあたり、いくつかの選択肢がありました。[2]

構築の手間や今後の保守を考えると AWS コンソールから直接作成するのは選択肢から真っ先に外れます。

Terraform は結構好きなツールで以前 ECS 構成に使ったこともあるのですが、 ECS は関連リソースが多く tf ファイルを記述するのがとても大変でした。また CodeDeploy を利用するとデプロイごとにタスク定義が更新されて tfstate とずれてしまうため、運用が非常に難しかったです。

このあたりを考えると AWS Copilot CLI か Docker Compose for Amazon ECS を利用するのが良さそうです。

後者は開発に利用している docker-compose をそのまま使えるメリットがありますが、 CodePipeline などの CD 周りについては特にサポートしてくれません。

今回は CD も含めて見直したかったため、 CodePipeline も簡単に構築できる AWS Copilot CLI を利用することにしました。

作業手順

既存の EC2 環境を ECS に置き換えていくために実施した手順をまとめます。

Dockerfile の本番対応

開発用の Dockerfile は整備されていましたが、そのままだと本番で利用するのにいくつか問題がありました。

  • root ユーザで実行しており、セキュリティ上問題がある
  • 開発時にしか使用しないツールがインストールされている

Dockerfileのベストプラクティス Top 20 などを参考に、 Dockerfile を書き換えてこれらの問題を解消しました。

この際、 docker-compose では root ユーザを使用しないと volume mount したファイルの所有者がおかしくなったため、開発用と本番用で別の stage を使用するようにしました。

Dockerfile の構成は以下のようにしています。

FROM ruby:2.7 as builder
# 共通のビルド処理

FROM builder as development
# 開発時ツールのインストールなど

FROM builder as production
# 実行用ユーザの作成、切替など

docker-compose では、以下のように build.target を指定することで特定ステージを対象にビルドできます。

version: "3.7"

services:
  rails:
    build:
      context: .
      dockerfile: ./docker/rails/Dockerfile
      target: development

Copilot での ECS 環境作成

公式ドキュメント を見ればだいたい書いてありますが、 最初のアプリケーションをデプロイしよう の手順どおりに copilot init してしまうと個別にカスタマイズできず困る局面が多いです。
このため、要素を 1 つずつ順に init していくほうが扱いやすいです。

以下では Application, Environment, Service を個別に init しています。
これらの概念についてはコンセプトに書かれているので読んでおくと理解しやすいでしょう。

# ドメインを指定してアプリケーションを作成
$ copilot app init --domain leaner.jp --name app1
# 対話コンソールで profile などを指定

# production 環境を作成
$ copilot env init --name production
# 対話コンソールでVPCなどを指定

# API サービスを作成
$ copilot svc init --name api
# 対話コンソールでサービスタイプなどを指定

# CodePipeline を作成
$ copilot pipeline init
$ copilot pipeline update

これらのコマンドを順に実行すると、以下のような環境が構成できます。

Image from Gyazo

これだけでも、 ALB で受けたリクエストを ECS Task に流す基本構成が作られるうえ、 CodePipeline による CD も整備された状態になります。便利ですね。

さらに、Load Balanced Web Service の Manifestを更新して設定を変更します。

  • 冗長構成にしたいので count: 2 に変更
  • variables で環境変数を設定し、機密情報は secrets で ParameterStore から受け取るように設定
    • ParameterStore では、タグに copilot-applicationcopilot-environment の設定が必要
  • Firelens サイドカー でログ出力先を S3 に設定

これらの設定により、全体の構成は以下のようになりました。

Image from Gyazo

Copilot の良いところ

典型的な ECS 構成を作るのが一瞬でできる

前述の通り、コマンドラインからサクッと ECS 環境を構築できます。しかも CI も整備された状態。
手作業でタスク定義や IAM Role を作るのは結構な苦行なので、これは衝撃的でした。

設定が manifest.yml に集約されるので見やすい

ALB の healthcheck 周りから環境変数まで、サービスにまつわる設定がすべて manifest.yml に集約されるため、設定の見通しが良くなります。
また GitHub で履歴管理ができるため、設定値を何のために変更したか追跡したり、ロールバックすることも簡単になります。

参考までに、実際のマニフェストファイルは以下のようになっています。
これだけで重要な情報がほとんど分かるので便利ですね。

# Rails APIサーバのECSサービス設定
#  https://aws.github.io/copilot-cli/docs/manifest/lb-web-service/

name: api
type: Load Balanced Web Service

http:
  path: '/'
  healthcheck:
    path: '/api/health'
    healthy_threshold: 3
    unhealthy_threshold: 2
    interval: 30s
    timeout: 10s

image:
  build:
    context: .
    dockerfile: ./docker/rails/Dockerfile
    target: production
  port: 3000

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

environments:
  develop:
    count: 1
    logging:
      image: (image_path)/fluent-bit:develop
      configFilePath: /fluent-bit/etc/fluent-bit_custom.conf
    variables:
      # ...
    secrets:
      # ...

  staging:
    # ...

production:
    # ...

ジョブの実行も簡単

Scheduled Job を設定すれば、任意の周期で好きな処理を実行できます。
EC2 で Whenever gem を使ってバッチ実行していた処理を ECS でどうするかは結構面倒になりやすいのですが、これはお手軽に設定できて便利でした。

デプロイするとAWS Step Functionsから ECS Task を起動する形になるので、手動での実行も簡単です。
場合によっては on.schedule に過去の時刻を設定してスケジュール起動しなくした上で、手動での実行のみ行うジョブを作るのも便利そうです。

copilot svc exec が超便利

Fargate 上で動かすとデバッグや緊急対応でシェルに入れず困ることが多かったのですが、copilot svc execを使うと簡単にコンテナへアタッチできます。[3]
docker exec したいがためだけに Fargate ではなく EC2 で動くタスクを動かすこともあったため、これがあるだけで考えることが大きく減りました。

Tips

DB migration のタイミング

Copilot では CodePipeline の処理内容をあまり細かく設定できないため、どのタイミングで rails db:migrate を実行するか悩ましいです。
今回は Docker Entrypoint で rails db:prepare を実行してからアプリケーションサーバを起動するようにしました。

本番環境では複数のタスクを実行するため、同時にマイグレーションを実行しようとする可能性があります。
しかしながら排他制御がかかっており最初に実行したもの以外は失敗すること、例外でコンテナが落ちても ECS サービスによって次のタスクが実行されることから、大きな問題はないだろうと考えています。

各 svc/job で環境変数定義を揃える

Copilot のサービスやジョブにはそれぞれマニフェストを記述する必要がありますが、同じアプリケーションであれば環境変数を揃えたい場合がほとんどでしょう。
とはいえ複数ファイルかつ環境ごとの設定もあるため、確実に設定を揃えるのは結構大変です。

設定ミスが怖かったため、サービスの環境変数設定を基準に各ジョブのものが一致しているかを RSpec で検査するようにしました。
また「本番環境だけ環境変数を足し忘れた」というケースも怖かったため、各 environments で環境変数キーが一致してるかも検査しています。

# spec/copilot/manifest_spec
describe 'Copilot Manifests' do
  describe 'api manifest' do
    def variable_keys(manifest)
      ((manifest&.dig('variables')&.keys || []) + (manifest&.dig('secrets')&.keys || [])).sort
    end

    let(:manifest) { YAML.load_file(Rails.root.join('copilot/api/manifest.yml')) }
    let(:develop_manifest) { manifest.dig('environments', 'develop') }
    let(:develop_keys) { variable_keys(develop_manifest) }
    let(:staging_manifest) { manifest.dig('environments', 'staging') }
    let(:staging_keys) { variable_keys(staging_manifest) }
    let(:production_manifest) { manifest.dig('environments', 'production') }
    let(:production_keys) { variable_keys(production_manifest) }

    it '同じ環境変数キーが各environmentで定義されている', :aggregate_failures do
      expect(develop_keys).to eq(production_keys)
      expect(staging_keys).to eq(production_keys)
    end
  end

  describe 'manifest間の環境変数' do
    # api serviceを基準に、各jobの定義が一致するかを確認する
    let(:api_manifest) { YAML.load_file(Rails.root.join('copilot/api/manifest.yml')) }

    Dir.glob('copilot/*/manifest.yml', base: Rails.root) do |filepath|
      next if filepath == 'copilot/api/manifest.yml'

      describe filepath do
        let(:job_manifest) { YAML.load_file(Rails.root.join(filepath)) }

        it 'ルートレベルにapi Serviceと同じ環境変数が定義されている' do
          expect(job_manifest.slice('variables', 'secrets')).to eq(api_manifest.slice('variables', 'secrets'))
        end

        %w(develop staging production).each do |environment|
          it "#{environment}にapi Serviceと同じ環境変数が定義されている" do
            expect(job_manifest['environments'][environment].slice('variables', 'secrets')).
              to eq(api_manifest['environments'][environment].slice('variables', 'secrets'))
          end
        end
      end
    end
  end
end

ハマりどころ

Copilot で環境を作る最中にいろいろ罠を踏んだのでまとめておきます。

app init 時に domain を指定しないと https にできない

普通に copilot initcopilot app init してしまうと、 ALB のリスナーが HTTP になってしまいます。
しかもドメイン指定は対話シェルの中で確認してくれないため、 copilot app init --domain leaner.jp のようにコマンドライン引数から指定しないといけません。

これは知らないとまず踏んでしまう上、直すには app から作り直しなので厄介なトラップです。

命名規約以外のドメインが設定できない

上述のとおり copilot app init --domain leaner.jp のようにドメインを指定すると、 svc_name.env_name.app_name.leaner.jp という形式のリスナールールが自動で設定されます。
例えば、 api.develop.app1.leaner.jp のような形式ですね。

これ自体は良いのですが、以下のように気になる点もあります。

  • 本番環境は api.production.app1.leaner.jp ではなく api.app1.leaner.jp にしたいことが多そう
  • 既存環境の置き換えでは、すでにそのドメインが利用されていることがありそう

現時点では別のドメイン名を当てておいて、 DNS やリスナールールを個別に編集するほかないようです。

ただこの問題については Issue が複数上がっており開発も進んでいるようなので、近々解消されそうです。

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

M1 Mac から copilot svc deploy すると詰む

M1 Mac から copilot svc deploy してしまうと、 Docker イメージのアーキテクチャが異なるため ECS が起動せずデプロイに失敗します。
そのうえ一度この状況になると CloudFormation Stack が更新不可能な状態で残ってしまうため、CodePipeline からの更新も一切受け付けなくなります。

これを解決するにはサービスか環境を削除して作り直すか、手作業で該当の CloudFormation Stack を削除しないといけません。
また copilot svc deploy を行わず CodePipeline からのみデプロイさせることで、この問題を回避できます。[4]

この問題も Issue が上がっていますが、こちらはまだバックログに載っていないようなので解決まで時間がかかりそうです。

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

VPC が public subnet と判定される条件が分かりづらい

copilot env init で新規 vpc を作成する代わりに既存の vpc を利用できますが、その vpc には public subnet と private subnet がそれぞれ 2 つ以上必要です。
ここで public subnet と判定される条件が若干わかりづらく、 Internet Gateway をアタッチしているだけでは認識されませんでした。
いろいろ試したところ、「パブリック IPv4 アドレスを自動割り当て」が有効になっていないと public subnet として扱ってもらえないようです。

環境ごとに CodePipeline を分けられない

git-flow や類似のブランチ管理をしている場合、 develop ブランチに紐づく開発環境と release ブランチに紐づくステージ環境・本番環境といった形で、ソースブランチの異なる複数の環境を管理していることが多いでしょう。
残念ながら、 copilot では 1 つのブランチをすべての環境にデプロイするというアーキテクチャになっているため、こうした構成は素直に実現できません。

ワークアラウンドとして、 release ブランチだけ copilot/pipeline.yml を別のファイルにした上で、 .gitattributes を利用してその変更を保持するという方法が利用できました。
Leaner でもこの方法でブランチごとのデプロイ環境を分けています。

https://github.com/aws/copilot-cli/discussions/1925

機能面でもサポートして欲しいところですが、以下の Issue を見る限り特に実証方法の議論などには進んでいないため難しそうです。

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

複数の領域にデータを持つので手作業での復旧が大変

ここから闇の話です。

Copilot ではほとんどの設定値をファイルで管理するのですが、それとは別にメタデータを ParameterStore で管理しています。
例えば /copilot/applications/app1/components/api{"app":"app1","name":"api","type":"Load Balanced Web Service"} のような値が格納されています。

複数のブランチでそれぞれ別のコンポーネントが存在する、などといった状況を避ける目的っぽいですが、これによりコンポーネントの情報が以下の 3 箇所に存在してしまいます。

  1. ParameterStore 上の値
  2. ファイルに記述された値
  3. 実際に稼働している CloudFormation Stack や各リソースの値

Image from Gyazo

このためなんらかの事情でコンポーネントの状態がおかしくなった場合、下手にファイルだけを消したりしてしまうと ParameterStore のデータが残っていて同名の app init が通らなくなったりします。
特に「わからなくなったので全部消してやり直そう」となった場合に引っかかりがちなので、 ParameterStore にメタデータがあるということを覚えておくと命拾いすることがあります。

CFn スタック構成が複雑で手作業での復旧が大変

ParameterStore にメタデータがあるのも罠なんですが、それ以上に CloudFormation Stack とコンポーネントがきれいに対応していないというのが結構な罠になります。

以下は leaner というアプリに production env と api1, api2 の 2 サービスを作り、 CodePipeline も生成した場合の CloudFormation Stack 構成図です。[5]

Image from Gyazo

ぱっと見では leaner-pipeline に CodePipeline 関連のリソース、 leaner-production に環境固有のリソース、 leaner-production-api1,2 にサービスごとのリソースが含まれています。
しかしながら lenaer-infrastructure に含まれている S3 Bucket は CodePipeline の Artifact 用ですし、 ECR Registry はサービス用のものです。

ここがトラップで、 copilot svc init をしたときには ParameterStore へのメタデータ格納とマニフェストファイルの生成に加えて、 leaner-infrastructure に ECR Registry を作成しています。
これによりサービスの Stack を生成するより前の CodeBuild において docker push が行えるのですが、サービスを手作業で復旧する際には ECR Registry の消し忘れや作り忘れに起因するエラーが発生しやすいです。

きちんと copilot svc delete などをしていれば気にしなくてよい部分なのですが、エラー起因で中途半端な状態になったのを解消するときなどはご注意ください。

Copilot の使い所

目次からわかるとおり、まだまだハマりどころや機能制約が多いため、万人におすすめはできなさそうです。
とはいえ ECS 環境を作る上でとても便利なツールなので、上述の機能制約が受け入れらて細かいカスタマイズが不要なのであればおすすめです。

そういう意味では既存環境のリプレイスよりも、新規に立ち上げるサービスのほうが適していそうです。
今回は既存の VPC を使用しましたが、新規 VPC を作ることもできるため、まっさらな AWS アカウントから環境を作るのがとても楽になります。
セキュリティグループなどもベストプラクティスに沿って設定してくれるので、 AWS よくわからんという人こそ使うと幸せになれそうです。

宣伝

Leaner Technologies では AWS Copilot を活用したいエンジニアを募集しています!

https://careers.leaner.co.jp/
脚注
  1. 別のサービスではAWS ElasticBeanstalkをつかっています。今回のものは構築時の速度を優先して外注した結果、 EC2 で直接動かす形になったそうです。 ↩︎

  2. AWS でのコンテナ運用には AWS EKS という選択肢もありますが、規模的に k8s を使うメリットはほぼなかったため AWS ECS にしました。 ↩︎

  3. ecs exec が便利という話なんですが、引数に渡す task-id の指定など面倒な部分を引き受けてくれるので直感的に使いやすいです。 ↩︎

  4. CodePipeline からのデプロイ処理は Cloud Formation Stack の Create or Update となっているため、スタックがなければ作成してくれます。 ↩︎

  5. IAM や Target Group などは省略し、主要リソースのみ示しています。 ↩︎