おはようございます。一番よく使う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 氏の以下の記事に詳しく記載されているので、ここで詳細に述べることはしません。
リバースプロキシどうする問題
Railsアプリに限らず、Webアプリを運用していくとなるとアプリの前段に行ないたい処理は出てくるものです。主な処理としては以下が挙げられます。
スロークライアント対策、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 はコードで管理せず、外部パラメータを用いて更新できるようにする
- 環境を問わずデプロイできるようにする
そして、ECSのdeploy toolというと、これらが挙げられるでしょう。
- eagletmt/hako: Deploy Docker container
- silinternational/ecs-deploy: Simple shell script for initiating blue-green deployments on Amazon EC2 Container Service (ECS)
- aws/amazon-ecs-cli
これらを触ってみて、自分達の要件に合うかどうか調査して……自前で実装することに決めました。
それが、こちらになります。
この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に保管しています。これでは手元でgrepやawsを使ったログの解析ができません。
この問題は、現在実行中のコンテナのログを取得する次のような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として以下を検討しましたが、それぞれ記載する理由により採用を見送りました。
- Travis CI ( https://travis-ci.com/ )
- レイヤーキャッシュが使えない
- Codeship ( https://codeship.com/ )
- レイヤーキャッシュが使え実行自体も高速だが、各ステップの開始にひどいときは数分かかってしまう
- Codefresh ( https://codefresh.io/ )
- GitHub Integrationに未対応
次にオンプレミス環境で動作するCI Serviceを検討しました。それらに代表的なものとしてJenkinsとDrone.ioが挙げられるでしょう。
- Jenkins ( https://jenkins.io/ )
- Drone.io ( https://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でやっていくぞ!!!!!