スパイスな人生

素敵な人生をおくるためのスパイスを届けていきたい、そんな想いで仕事をするspice lifeメンバーブログ

RailsアプリをECSで運用するまでにやったこと、これからしていくこと

構築したインフラの簡易的な図

おはようございます。一番よく使うemojiは 👀 (:eyes:) のうなすけです。

さて弊社では、最近社内Railsアプリをひとつ構築しました。それをECSで運用することにしたので、そこに至るまでの経緯、つまづき、これからの課題などなどを記事にしていこうと思います。上の図は現時点での簡単なAWS上での構成図です。

以下、見出しは時系列順でやったことを記録していきます。

社内Railsアプリ、一体どんなもの?

ここで新規に構築することになった社内Railsアプリですが、特に凝ったことはしていない単純なRailsアプリです。初めからECSで運用することにしていたので、開発環境も全てDockerで構築しています。Railsのバージョンは5.1.0、Docker imageのFROMにはruby:2.4.1-silmを採用しています。

Docker imageのtagについて

development環境のdocker imageですが、手元でbuild/pushさせるのは属人化しやすく、また継続的に実行したいので、CircleCIでbuildしてECRにpushすることにしました。そのときのimageのtagですが、push毎にgit commit hashをtagとして付与し、master branchでのbuildではそれに追加でlatestを付けることにしました。

これにより、「とりあえずlatestなら動く」という状況にしています。

共通部分を切り出し、production用のDockerfileをつくる

さて稼動させようかという状況になりましたが、この時点ではdevelopmentのDockerfileしかありませんでした。そこで、DRYになるように共通部分(OSのパッケージインストールなど)を切り出してbase imageとし、developmentとproductionはそれらをFROMにするDokerfileに分割することにしました。

app
config
db
docker/
  └ app-base/
      └ Dockerfile
  └ app-development/
      └ Dockerfile
  └ app-production/
      └ Dockerfile

ディレクトリ構造はこのような感じです。base imageはcontainer/baseにmergeされたときにbuild/pushを行なうようにしました。

assets配信どうする問題

production運用といえば、assetsの配信をどうするか、ということも当然考慮する必要があります。Dockerで運用をするとなれば、imageの少サイズ化も見込めるので、AssetSync/asset_sync を採用する場合が多いかと思います。

ですが僕達は、amakanの構成に倣って、assetsをcontainer内に含めることにしました。その思想については、id:r7kamura 氏の以下の記事に詳しく記載されているので、ここで詳細に述べることはしません。

r7kamura.hatenablog.com

リバースプロキシどうする問題

Railsアプリに限らず、Webアプリを運用していくとなるとアプリの前段に行ないたい処理は出てくるものです。主な処理としては以下が挙げられます。

  • スロークライアント対策
  • SSL/TLS の終端処理
  • 静的ファイルの配信

スロークライアント対策、SSL/TLSの終端処理についてはALBを利用することで達成されます。また、静的ファイルの配信については先述のamakanと同様の構成(Railsによる配信 + CDN)にすることで達成されます。一度リバースプロキシは不要になるかと思われましたが、その他細かい要件として以下が挙げられました。

  • HTTP → HTTPSへのリダイレクト
  • Basic認証
  • Cache-Control Headerの書き替え
  • assets配信用ドメインでの/assets/*以外のアクセスに対して400系のレスポンスを返す

これらをRack middlewareで行うことも検討したのですが、アプリ層で行うべきではないと判断し、前段にNginxを設置することにしました。これが後に問題を引き起こすのですが……

app
config
db
docker/
  └ app-base/
      └ Dockerfile
  └ app-development/
      └ Dockerfile
  └ app-nginx/
      ├ Dockerfile
      └ nginx.conf
  └ app-production/
      └ Dockerfile

このNginxもDocker containerとして動作させ、その設定はアプリのリポジトリに含めることにしました。container/nginxにmergeするとbuild/pushが行なわれます。

Deployどうする問題

さあ、productionのimageもできましたし、Nginxの設定も終わりました。となると残すはDeployです。

この時点で、社内にはもうECS上で動くアプリがありましたが、それはNode.jsで動作するアプリであり、Deployもaws-sdk-jsを使用したJavaScriptによるスクリプトを実行するというもので、今回のRailsアプリでは同様の仕組みを利用することができません。

それに、今後TMIXやSTEERSをECSで運用することを視野に入れると、汎用的に使える新しい仕組みを構築したほうがよさそうです。

ECS Deploy tool選定

僕達がECS deploy toolに求める要件は、主に以下の4つでした。

  • Service / Task Definition を一元管理する
    • awslogs の設定等をインフラチームで管理したい
  • Task Placement Constraints は Task Definition で管理する
    • Task Placement Constraintsの変更によるServiceの作り直しを避けたい
  • Service の Task Definition や Task Definition の Image はコードで管理せず、外部パラメータを用いて更新できるようにする
  • 環境を問わずデプロイできるようにする
    • 例えば gem にしてしまうと Ruby 以外のアプリケーションのデプロイでも Ruby 環境を用意する必要がある

そして、ECSのdeploy toolというと、これらが挙げられるでしょう。

これらを触ってみて、自分達の要件に合うかどうか調査して……自前で実装することに決めました。

esaのコメント

それが、こちらになります。

github.com

このgemを使い、あるひとつのリポジトリで全てのアプリのTask DefinitionとServiceの定義を含むDocker imageを作成し、Deployはdocker execすることによって実現させる、という風にしました。

まだまだ扱いはWIPなgemですが、そろそろversion 1.0をリリースしたい気持ちはあります。

db:migrateができない問題

database schemaを変更した際には、deploy前にdb:migrateを実行してやらなければなりません。

しかし、ECSのRunTask経由で実行すると、なぜかbundle exec rails db:migrateは実行できず(SIGTERMで死ぬ)、試行錯誤の結果/bin/sh -c bundle exec rails db:migrateだと実行できることがわかりました。

これについてAWSのサポートに問い合わせたところ、同Taskで動作しているNginx contaierがすぐに落ち(何もさせないようtrueを実行するようにしていた)、Task Definitionにessential: trueを指定しているためにそれに引き摺られてRailsのcontainerも落ちる、という動作になっていることが判明しました。

これについては、RunTask用にNginxを含まないTask Definitionを用意し、それを使用することで回避しました。Nginxの存在によって構成が複雑になってしまっているため、本来アプリ層でやるべできはないことをアプリ層でやることとのトレードオフですが、NginxをやめてRack middlewareを使用することでTask Definitionの二重管理をやめることを検討しています。

ログが見たいけどCloudWatch Logsは面倒

Railsのログは、STDOUTに吐いて、それをawslogs log driverでCloudWatch Logsに保管しています。これでは手元でgrepawsを使ったログの解析ができません。

この問題は、現在実行中のコンテナのログを取得する次のようなscriptを作成することで解決しました。(一部省略しています)

#!/usr/bin/env ruby

require 'aws-sdk'

ecs    = Aws::ECS::Client.new
cwlogs = Aws::CloudWatchLogs::Client.new

service    = ecs.describe_services(cluster: CLUSTER, services: [SERVICE]).services.first
deployment = service.deployments.find { |d| d.status == 'PRIMARY' }
task_arns  = ecs.list_tasks(cluster: cluster, started_by: deployment.id).task_arns

events = task_arns.flat_map do |arn|
  cwlogs.get_log_events(
    log_group_name: LOG_GROUP_NAME,
    log_stream_name: "#{LOG_STREAM_PREFIX}/#{CONTAINER}/#{arn.sub(/.+\//, '')}"
  ).events
end

events.sort_by(&:timestamp).each do |event|
  puts event.message
end

現状では時間帯の指定等ができず大量のログが流れてきてしまうため、引き続き改善を行っています。

Docker imageの共通化をやめる

Dockerでは、言語環境やOSのパッケージなどの共通部分は、別Dockerfileに切り出してFROMで参照することによってfull build時間の短縮やイメージサイズの削減を図るのは常識であり、僕達もこのアプリのDockerfileはbase、development、productionの3つに分割していました。

具体的にbaseに含めているのは、以下に例として示す通り、developmentでもproductionでも共通のgemのインストールまでです。

FROM ruby:2.4.1-slim

RUN apt update && \
    apt install --assume-yes \
      make \
      curl \
      略

COPY package.json /app
COPY yarn.lock /app
RUN yarn install

COPY Gemfile Gemfile.lock /app
RUN bundle install --without development test

しかし、このような構成にしてしまった結果、gemを追加するのに一度container/base branchへmergeしてからでないとdevelopmentのコンテナでgemを使えなかったり、developmentにのみ含めたいOSパッケージの存在があったりして、少しやりすぎた共通化なのではないか、という認識がチーム内で発生しました。

なので、base imageは廃止し、productionでもdevelopmentでもFROMはruby公式のDocker imageを指定しています。これによる弊害は、今のところチーム内では認識できていません。

Docker imageのbuildが遅い

今までのDocker imageのbuild/pushは全てCircleCI 2.0にて行っていましたが、Docker layer cacheが効いたり効かなかったりして、buildに長時間かかることがしょっちゅうでした。そこで、layer cacheが有効になるように、CI serviceの乗り替えを検討しました。

Dockerを使用できるCI Serviceとして以下を検討しましたが、それぞれ記載する理由により採用を見送りました。

次にオンプレミス環境で動作するCI Serviceを検討しました。それらに代表的なものとしてJenkinsとDrone.ioが挙げられるでしょう。

どちらも試用して検討し、上記理由からDrone.ioを採用しました。

Drone.ioはserverとagentに役割が分かれていて、agentをスケールさせることでbuild時間の短縮が目論めます。もちろん、Drone.ioもECR上に構築しました。

Drone.ioのserverとagent間の通信が切れる

drone-agentのみをスケールさせるために、drone-serverとdrone-agentは別Task Definition、別Serviceにしました。しかし、drone-agentからdrone-serverへのWebSockert通信がALBのIdle timeoutで強制的に切断されてしまい、この問題を解決することがどうしてもできませんでした。

なので、drone-serverとdrone-agent間の通信は、ALB越しではなく、インスタンスに割り当てられたPrivate IPによって行なうことにしました。弊害として、Dynamic Port Mappingを用いたdrone-serverのスケールアウトはできなくなりましたが、drone-serverをスケールアウトさせることに大した意味は無さそうなので、この構成で運用していこうと思います。

現時点のまとめ

  • Rails 5.1 の新規社内アプリをECR上で運用している
  • assetsはcontainerの中に含め、CDNから配信している
  • NginxをRailsの前段に配置しているが、Rack middlewareに置き替えることを検討している
  • developmentとproductionの共通部分をまとめたbase imageを採用していたが、廃止した
  • CI ServiceをCircleCIからオンプレミスのDrone.ioに移行した

Docker化って、難しいですね。ログの解析やdb:migrateなど、今までの運用と大きく変更しなければならない部分だったり、思わぬ落し穴があったりして、インフラ構築の最中はずっとああでもない、こうでもない、どのような方法がいいか、もっといいやり方があるはずだ、と議論を重ねていました。とても面白かったです。

さて、僕のインフラチームとしての仕事はここまでで一旦終わり、今月からTMIXチームに復帰することになりました。今回の記事は、今までのインフラ部での仕事を振り返るために書いたというのが正直なところです。

TMIXでやっていくぞ!!!!!