クラウドワークス エンジニアブログ

日本最大級のクラウドソーシング「クラウドワークス」の開発の裏側をお届けするエンジニアブログ

コンテナフレンドリーではなかったRailsアプリケーションをDocker(ECS)に移行するまでの戦い

はじめに

SREチームの @minamijoyo です。 先日 CrowdWorks (crowdworks.jp) の本番環境のRailsアプリケーションを Docker (AWS ECS: Elastic Container Service) に移行しました。

f:id:minamijoyo:20190930160711p:plain

CrowdWorksは2012年にサービスを開始し、2019年10月現在、ユーザ数は300万人、月間で数億円規模のお仕事がやりとりされる、国内最大級のクラウドソーシングプラットフォームにまで成長しました。 サービスの規模拡大に合わせて、ソースコードも数十万行規模に成長し、 決して小さくはない規模のRailsアプリケーションに成長しました。

CrowdWorksの開発環境にDockerが導入されたのはもうかれこれ3年半前の2016年の4月頃、2017年1月頃にはCrowdWorks本体から切り出された一部の機能で本番環境に投入され、 その後新規に追加される周辺サービスや新規事業などでは最初からDockerが利用されるようになりました。 しかしながらCrowdWorksのコア部分であるモノリシックなRailsアプリケーション(通称CrowdWorks本体)は、いわゆる普通のEC2サーバで長らく運用されていました。

開発環境や新規の本番環境にDockerを導入するのはそれほど難しくはありません。 しかし既存の本番環境をDockerに移行するには、現行のシステムの仕様や制約事項など考慮すべきポイントが多く困難が伴います。 それが会社の主力事業であるそれなりに規模と歴史あるWebアプリケーションなら尚更です。 変更に伴うリスクが大きいのはもちろんですし、そもそも歴史あるWebアプリケーションはコンテナフレンドリーではないことが多く、 Dockerに移行するためには単にDockerfileを書けばよいという単純な話ではありません。 アプリケーションのアーキテクチャレベルでの変更が必要です。

これを技術的負債と言ってしまうのは簡単ですが、そもそもDockerの初期リリースは2013年で、イミュータブルなインフラが提唱されるようになってきたのもここ数年の出来事です。 それより以前からあるアプリケーションにコンテナフレンドリーを求めるのはちょっと厳しいかなと思います。

CrowdWorksではこれまでも、本番環境でDockerを利用していましたが、小規模な周辺のサービスで利用してもその恩恵は限定的でした。 というのも、多くの機能は依然としてCrowdWorks本体に実装されており、維持管理上の多くの問題も必然的にCrowdWorks本体に関連するからです。 とはいえDockerに移行するために解決すべき課題は多く、組織的な優先度の高い案件が次から次に降ってきたり、なかなか難しい時代が続きました。

しかし、Dockerというソフトウェアが生き残るかはさておき、 Webアプリケーションをコンテナ化していく時代の流れは巻き戻ることはなさそうですし、 コンテナフレンドリーではないCrowdWorks本体を、歯を食いしばって少しずつコンテナフレンドリーにしていかない限り、我々に幸せが訪れないことは明らかでした。

この記事では、コンテナフレンドリーではなかったCrowdWorks本体のRailsアプリケーションをDocker(ECS)に移行するためにやったことを紹介します。 網羅的なタスクの一覧を挙げるとちょっと書ききれないので、コンテナフレンドリーではない依存からどうやって脱却したのかを中心に、ハマりポイントで学んだ知見を共有します。

目次

コンテナとお友達になるまで

cron依存からの脱却

まず最初に手を付けたのはcronバッチです。 小さなRailsアプリケーションのちょっとしたバッチをcronで書くのは何も問題ありません。むしろスモールスタートとしてはよいでしょう。 しかしながら、ちょっとずつバッチが追加された結果、当時のCrowdWorksには80個ぐらいのバッチが元気にcronで動いており、どのバッチが成功/失敗しているのか管理しづらい状況でした。 またcronの定義の管理には whenever というgemを使って、 Capistrano でデプロイするタイミングでcrontabを生成していました。 これはRailsアプリケーションのリポジトリでジョブスケジュールが管理できるので、便利である反面Docker化するときにちょっと面倒なことになります。 というのもcronは複数プロセスがバックグラウンドで動くアーキテクチャであり、Dockerは1プロセスがフォアグラウンドで動くアーキテクチャなので、crondをDockerコンテナの中ではなく外で動かすことになります。 つまりこれはcrontabの定義がコンテナの外に必要になります。

バッチが管理しづらいという短期的な問題を解決しつつ、その後のDocker化への仕込みとして、まずは、ジョブスケジューラの Rundeck を導入しました。 Rundeckは良くも悪くもWeb画面が付いた高機能なcronみたいなもので、データフローのようなジョブの依存関係のあるバッチを管理するには不向きですが、cronからの移行先としてはよいでしょう。 プラグインを入れることでジョブ失敗時のSlackへの通知などが可能です。具体的には以下のプラグインを使いました。

また、QiitaにTipsをいくつか書いたので、なにかの参考になるかと思い貼っておきます。

Rundeckを導入してよかったことは、ジョブの説明文に障害対応の手順のメモなどが書けたりするので、維持管理のノウハウの共有に大変便利です。 逆に注意すべきポイントとしては、ジョブ実行の度に実行履歴などのメタデータができるので、1分間隔などの短いジョブをスケジューリングすると、実行ログが肥大化して重くなりがちなので最低5分間隔で、それより短いものは常駐プロセスにするなど、バッチ以外の方法で動かした方がよさそうです。 また、開発者が自由にスケジュール登録すると、Rundeckそのもののメンテナンスウィンドウが確保できなくなるので、この時間帯はジョブをスケジューリングしないでねというような社内向けの運用標準のガイドラインを作ったりしました。

Postfix依存からの脱却(メール送信編)

CrowdWorksはシステム上様々なメールを送信しています。 メルマガ配信の仕組みはCrowdWorks本体とは別なのですが、例えば仕事の応募、契約などのイベントのタイミングでユーザにメール送信する機能はCrowdWorks本体にあります。 メール送信もユーザ数が少なければどのようなアーキテクチャでも送信できると思いますが、1日数十万通規模を送信しようとすると多少の工夫が必要です。 CrowdWorksはメール配信の基盤として SendGrid を利用しています。Railsアプリケーションからはアプリケーションサーバのローカルに立てたPostfixでバッファリングしてSendGridに送信していました。 しかしながらPostfixのメールバッファとは要するにアプリケーションの状態なので、デプロイしてコンテナを再起動すると未送信のメールの内容がなくなってしまいます。コンテナ化する場合にPostfixをどうにかする必要がありました。 Postfixだけ別にメールサーバとして切り出すという選択肢もなくはないですが、それなりの流量と可用性が求められるメールサーバはできれば管理したくありません。というわけでPostfixを廃止してSendGridへ直接送信することに決めました。

まずは接続構成を変更する事前準備として 、 Postfixのレイヤで処理していたテスト環境のメール誤爆防止処理 をアプリケーションレイヤに移植しました。これは、テスト環境でうっかり社外にメール送信しないように、@crowdworks.co.jp 以外へのメール送信を制限する仕組みだったのですが、 Postfixを経由せずにメールを送る場合、アプリケーションのレイヤでチェックする必要があります。 これには mail_interceptor というgemを使いました。このgemを使うと指定のドメイン以外へのメール送信の宛先を書き換えることが可能です。 仕組み的にはActionMailerの(厳密にはmail gemの)interceptorという機能で実装されています。

準備ができたので、試しに一部のサーバだけ単純にPostfixを経由せずにSendGridに送信してみたところ、当たり前ですがメール送信のパフォーマンスが下がりました。 処理するプロセス数を増やしたりして、多少パフォーマンスは改善しましたが、しかしながらメール送信の度に都度SMTPコネクションを張る構造自体は変わりません。 またSendGridにSMTP接続/切断を高頻度で行うと不定期に接続エラーを返すことも分かりました。

この時点でSendGridのWebAPIへの移行も検討したのですが、調べたところ WebAPIは文字コードが指定できず 、メール送信の文字コードISO-2022-JPからUTF-8に変更する必要がありました。 現代でUTF-8のメールが読めない人などいるのだろうかという気持ちもありつつ、エンドユーザの環境に依存して影響範囲が読みきれないところもあり、やるならちゃんと調査&検証してやりたいのでWebAPIへの移行は一旦見送りました。

改めてよく考えると、そもそもオンライン処理の中でメール送信を行っている箇所でSendGridと接続エラーになると、これまではPostfixのメールバッファで再送されていたのが、アプリケーションレベルでのエラーになりメールが再送できません。 というわけでとにかくメール送信処理を非同期にしまくる作戦に出ました。非同期処理なら接続エラーでもちょっと待ってリトライが可能だからです。 当時CrowdWorks本体でメール送信をしている箇所は200箇所ぐらいあり、一部の処理は delayed_job を使って非同期に送信されていたのですが、同期的に送信されている箇所を改めて見直してみると、必ずしも同期的に送る必要がないことが分かりました。 というわけで、会員登録/退会/パスワードリセットなど即時にメールを送るべき一部の例外処理を除き、メール送信している箇所を非同期処理に書き換えまくりました。何を変更するにも数の暴力つらいです。

多少のパフォーマンスの劣化は非同期処理にして処理するプロセス数を増やすなどで解決の目処が立ちましたが、問題はそれだけではありませんでした。 エラーを監視していると、いくつかのメールがアドレスフォーマット不正で弾かれることが分かりました。 調査のため送信しようとしている生データを確認すると、メールアドレスのドメイン部分に hoge@example .com のようにドメイン名の一部に半角スペースを含むユーザが複数いて、エラーが発生していました。 しかしながら過去のメール送信のログからは、これらのメールアドレスにはメールは送信できていたようです。

調べたところ、どうやらメールアドレスの宛先のフォーマットには歴史があり、昔は RFC822ドメイン部分に半角スペースが入ってるのも有効なフォーマットだったらしく、その後 RFC2822 で認められなくなっています。以下RFC2822からの引用です。 (※注: 本文中にあるCFWSというのは簡単に言うとホワイトスペースのことです。厳密には3.2.3で定義されています。)

4.4. Obsolete Addressing

   There are three primary differences in addressing.  First, mailbox
   addresses were allowed to have a route portion before the addr-spec
   when enclosed in "<" and ">".  The route is simply a comma-separated
   list of domain names, each preceded by "@", and the list terminated
   by a colon.  Second, CFWS were allowed between the period-separated
   elements of local-part and domain (i.e., dot-atom was not used).  In
   addition, local-part is allowed to contain quoted-string in addition
   to just atom.  Finally, mailbox-list and address-list were allowed to
   have "null" members.  That is, there could be two or more commas in
   such a list with nothing in between them.

A.6.1. Obsolete addressing

  Note in the below example the lack of quotes around Joe Q. Public,
  the route that appears in the address for Mary Smith, the two commas
  that appear in the "To:" field, and the spaces that appear around the
  "." in the jdoe address.

----
From: Joe Q. Public <john.q.public@example.com>
To: Mary Smith <@machine.tld:mary@example.net>, , jdoe@test   . example
Date: Tue, 1 Jul 2003 10:52:37 +0200
Message-ID: <5678.21-Nov-1997@example.com>

Hi everyone.
----

その名残か間に入ってたPostfixが互換性維持のために(?)暗黙にスペースを削除しているようでした。Postfixのドキュメントからはそのような挙動について説明は見つけられなかったのですが、試してみるとそのような動作をしていました。 半角スペースがメールアドレスの一部に入ってしまう理由は、仮説の域を出ませんが、スマホ入力するとそのような傾向があるようです。

本来であれば入力値のバリデーションで弾くべきところですが、残念ながら当時メールアドレスのバリデーション実装のルールが若干緩く、そのようなデータが永続化されてしまっておりました。 対策として、まずはメールアドレスが入力可能な場所の入力値のバリデーションを強化しました。 次に、既に永続化されているデータのうち、フォーマット不正かつメールアドレス確認済みのデータを調査し、データの補正作業を行うことで、正常にメールが送信できるようになりました。

私はただDocker化したかっただけなのに、なんでこんなことをしているんだろうと思いつつ、yak shaving感ハンパない。

ここで残念なお知らせです。

Postfixがやっていたことはメール送信だけではありません。メール受信もありました(。◉ᆺ◉)

Postfix依存からの脱却(メール受信編)

CrowdWorksのシステムから送信されたメールの送信元のメールアドレスは no-reply@crowdworks.jp となっているのですが、 この送信専用のメールアドレスに、うっかり返信してしまう方が一定数いるので、このメールアドレス宛に送信されたメールを受信して、送信専用アドレスである旨をお知らせするべんり機能がありました。 これはPostfixでメールを受信して、Rubyスクリプトでパースし、送信者に自動で返信するような仕組みで動いていました。 また、ドメイン管理者宛のメールを、社内のメーリングリストに自動転送するような設定もPostfixにされていました。 Postfixの依存を捨てるためには、このメール自動返信&転送機能もなんとかする必要がありました。

メール受信はそれほど流量が多くなくクリティカルな使われ方はしていなかったので、やっぱりお手軽にメールサーバ別に立てるかという誘惑が多少ありましたが、 メールサーバはその性質上インターネットanyに公開する必要があり、セキュリティパッチ適用の運用を考えると、やっぱり管理したくありません。 というわけで、 AWS SES (Simple Email Service) + SNS (Simple Notification Service) + Lambdaでサーバレス構成にチャレンジしてみました。

AWS SESはメール送信/受信両方できますが、受信機能のみを利用し、返信や転送などでメール送信が必要な箇所はSendGrid経由に集約しました。 これはメール送信するにはドメイン認証やバウンスの管理などが必要になるからです。

またSESとLambdaを連携させる場合、SESから直接Lambdaをキックするパターンと、SES => SNS => Lambda でSNSを挟むパターンの2種類の選択肢がありますが、若干メリデメが異なります。

SESからLambdaを直接キックする場合は、メール本文は入力イベントに含まれず、メール本文はS3を経由して別途取得する必要があります。 一方、SNSを経由した場合、150KB以上のメールを受信できないという制限がありますが、メール本文が入力イベントに含まれます。

Amazon SES における制限

今回は添付ファイルなど大きなデータを扱わないため、実装が簡単なSNSを経由する構成にしました。

この手のインフラ系のちょっとした機能は個人的にはGoで書くことが多いのですが、この移植作業をやろうとしていた当時、ちょうどAWS Lambdaの公式Rubyサポートが発表され、移植元のオリジナルがRubyだったこともあり、 仕事なので仕方がないなー、あー仕方がない(棒読み)とぶつぶつ言いながら、Ruby on Lambdaで遊びました。 またCrowdWorksではLambdaのデプロイに apex というツールを使っているのですが、 Ruby対応するためのパッチ を書いてコントリビュートしたりもしました。

さいわいRuby対応のパッチはすぐにマージしてもらえたのですが、残念なことにapexはこれを書いてる2019年10月現在、メンテナンス停止状態 になっているので注意が必要です。 メンテナの方がなかなか時間が取れず、マネタイズの目処が立つまで一旦停止するとのことです。はー、OSSは難しいですね。 既存のapexを使っているプロジェクトをすぐに別のツールに移行する必要性はないとは思いますが、新規のプロジェクトでapex採用するのは現時点ではおすすめできません。 apexはAWS Lambdaが正規にGoランタイムをサポートする前から、Goで書いてビルドしたバイナリをNodeランタイムで動かすことにより擬似的にGoをサポートしており個人的に重宝しておりましたが、 その後 Go もAWS Lambdaで正式にサポートされたので、あえてapexを使うメリットは薄れてきているように思います。

実装して実際にメールを受信してみると、自動返信は概ね問題なく動きましたが、転送には若干問題があることがわかりました。 Postfixではメール転送するときにSMTPで閉じていたので、送信元メールアドレスを維持した形でメール転送ができたのですが、 SESを挟んでしまうとそこでSMTPプロトコルとして一旦終端してしまい、後続の処理にはメールデータをただの文字列として渡されるので、Lambdaから別のメールアドレスに転送しようとすると、当然ながら送信元メールアドレスを引き継ぐことはできません。 今回は、ドメイン管理者宛のメールの内容が読めれば実害ないので、転送する際にオリジナルのメールヘッダを本文の先頭に差し込んで送信し直すようにしましたが、一般的なメール転送の用途に使うのはちょっと難しそうな印象です。

また、転送の本文を加工するためにメールの本文をパースする必要があるのですが、 ドメイン管理者宛に届くメールにHTMLパートしかなくて、TEXTパートがあることを前提にしているとパースに失敗することがあったり、 送信元のメーラのプログラムによるのかcharsetの指定がうまくパースできないパターンを見つけたり、 メールの世界はバリエーションがいろいろで、メール難しいです。メール何もわからない。

さらにインターネットanyに晒しているとスパムがHTMLメールや添付ファイルを投げてきて、150KBを超える場合もありました。 今回の用途では、150KBを超えるものは基本的にないので、理論上実害ないはずなのですが、 悩ましいことに、150KBを超えた場合はCloudWatch のメトリクスにSES => SNSのPublishFailureとして記録されるだけで、何が原因だったかを特定する手段がありません。つらい。 仕方がないのでSESの受信ルールで、オリジナルのメールの生データをデバッグ用にS3に保存するようにして、S3のライフサイクルルールで一定期間が過ぎたものは削除するような設定を追加しました。

結局S3にメールデータを保存することになったので、SES => LambdaでSNS挟まない方がよかったのではないかと若干負けた気持ちになりました。 SNS経由で受け取るとイベントにメール本文が入ってるので、Lambdaのテスト書いたりするのが簡単でよかったのですが、 運用を考えるとSES=>S3に保存しつつ、SES=>Lambdaをキックする構成の方が手堅いように思います。

Postfixの依存を捨てるためにだいぶ遠いところまで来てしまった気がしますが、やってみないと分からない学びも多かったです。 学んだことを要約すると、

メールはシンプルではない。 顧客が求めていたのは、AWS SES (Simple Email Service)ではなく、マネージドなElastic Postfixだったようです。

というわけで、AWSさんよろくしお願いします |ω・`)チラッ

Capistrano依存からの脱却

Postfixの依存を捨ててDocker化への目処が現実的になってきたあたりで、デプロイ周りの整備をはじめました。 CrowdWorksのRailsアプリケーションのデプロイは HubotCapistrano を組み合わせて、SlackからChatopsでデプロイできるようになっていました。 CapistranoRailsアプリケーションのデプロイでよく使われる定番ツールですが、基本的に対象サーバにリリース物をpush型で配布するので、これもDockerとの相性がよくありません。 Dockerでデプロイする場合は、リリースするコードを含んだDockerイメージをビルドしてタグを打ち、Dockerのクラスタで必要なイメージをpullして反映する、pull型のデプロイが一般的だからです。 既にCIは CircleCIを利用していたので、Dockerイメージのビルドや、ECSクラスタへの反映の指示などのデプロイパイプラインを、CircleCIのWorkflowとして実装することにしました。

実装する上でいくつか制約条件がありましたが、大雑把に言うと以下2つです。

  • 新環境への移行作業について、一気に切り替えるのはリスクが高いので旧環境と新環境に同じリビジョンをデプロイして並行稼動させたい。
  • デプロイ完了後に、Slack通知、チケットのクローズ、監視系統へのデプロイイベント通知など後続の処理がある。

ということから、Hubotのインターフェースは維持しながら、並行稼動期間中は旧環境と新環境に両方にデプロイできるようにしました。 これをCircleCIで実現するため、デプロイ用のブランチを用意してそのブランチだけで発動するWorkflowを定義し、 Hubotから旧環境へのデプロイ処理に加えて、新環境用へのデプロイをデプロイ用のブランチへのforce pushでCircleCIをキックするような構成にしました。

一点懸念点として、CrowdWorksのCircleCIの設定ファイル .circleci/config.yml は既に1000行近くあり、 ここにさらにデプロイパイプラインのコードを追加するのは、人類の理解を超えそうな懸念がありました。つまり、

高度に発達したCircleCIの設定は魔法と区別がつかない。

なんかよい方法はないものかと、CircleCIの巨大なYAMLを分割してincludeするようなディレクティブが欲しくて、Feature Request出そうかと思ってたのですが、調べていたらローカルorbのFeature Requestが出ていて、 コメントでワークアラウンドとして circleci config pack というYAMLをマージするコマンドが紹介されていました。

CCI-I-704: Local orbs in seperate yml files

これを使うと自動でマージはしてくれないものの、ファイル分割されることにより得られる見通しの良さが得られるので、ファイル分割してコミット前にマージする方法を採用しました。

また、CircleCIからECSへのデプロイにも若干工夫が必要でした。 CrowdWorksでは インフラのコード管理に Terraform を利用しています。 CircleCIからECSへデプロイする場合、TerraformでECSタスク定義をどうやって管理するのかが問題でした。

Dockerの一般的なベストプラクティスとしてDockerイメージをlatestなどのタグでデプロイすると、 docker pullしなおさないと反映されないし、現在デプロイされているソースコードのリビジョンがわからないので、 イメージタグにソースコードのgitのSHA1を埋めるような運用をよくします。

これまでECSで新しいDockerイメージをデプロイする場合にも、DockerのイメージタグにgitのSHA1などを埋めておき、 ECSタスク定義をコピーしてイメージタグを書き換えた新しいリビジョンを作成し、 ECSサービスが新しいタスク定義を使うように更新してデプロイするという方法をよく使っていました。 これらのデプロイフローを自動化するための ecs-deploy というツールもありますし、 CrowdWorksではecs-deployをGoに移植した ecs-goploy というこれまでツールを使っていました。

このようなデプロイ方法をとった場合、ECSタスク定義のリビジョンはアプリケーションコードのデプロイの度に変更になります。 しかしながら、ECSタスク定義をTerraformで管理していると、この変更が差分として検知されてしまうので、 ECSサービスとECSタスク定義の間の関連を Terraform のlifecycleルールで、ignore_changes するような設定をしていました。 ただこの方法だと、Dockerイメージ以外のECSタスク定義内の設定を変更した場合に、反映するのにアプリケーションのデプロイ状態と整合性と取るのが難しいです。

なにか良い方法はないものかとECSエージェントの設定を調べていたら、 ECS_IMAGE_PULL_BEHAVIOR=always という設定項目があり、docker pullが強制できることに気づきました。

Amazon ECS コンテナエージェントの設定

これを使うと、TerraformでECSタスク定義をどうやって管理するのか問題が解決します。

ここでは説明のため、リリース前の現在稼働しているバージョンをrelease-111111 (111111はgitのSHA1) とし、これに production-currentのタグも付いているものとします。

  1. 新しいイメージをリリースする場合、docker buildして release-222222production-latest いうタグを付けてECRにpushします。
  2. リリースする直前に、 現在の production-current タグをロールバック用に production-old タグにコピーしてバックアップします。
  3. リリースするタイミングで、production-latest タグを production-current タグにコピーします。

これで release-222222production-current が付いた状態になるので、 aws ecs update-service --force-new-deployment を使ってDockerイメージをデプロイします。

f:id:minamijoyo:20191001135704p:plain

このデプロイ方法を使うと、ECSタスク定義上のイメージタグは production-current で固定値とできるので、 アプリケーションデプロイのたびに新しいECSタスク定義のリビジョンを作成する必要がありません。 一方で、ECRのリポジトリ上はreleaseのタグも付与されているので、現在の production-current タグが どの release-(gitのSHA1) であるかは特定可能です。

ちなみに、DockerのイメージタグコピーがCircleCI上で別のStepとなるとキャッシュがどうなるのか気になるかもしれませんが、 awsコマンドを使えば、 pullせずにタグだけコピーすることは可能です。

AWS ECR上のDockerイメージをpullせずにDockerタグをコピーする

このデプロイ方法を使用した場合、アプリケーションの変更した場合のデプロイ方法と、その他のECSタスク定義の設定項目を変更した場合のデプロイ方法は、同じ以下の方法で反映可能です。

$ aws ecs update-service \
  --cluster <クラスタ名> \
  --service <サービス名> \
  --task-definition <タスク定義名(familyのみでrevision省略)> \
  --force-new-deployment

AWS CLI Command Reference: ecs update-service

(1) 通常のアプリケーションのデプロイの場合 Dockerイメージを更新した場合、 aws ecs update-service --force-new-deployment を使うことで ECSタスク定義に変更がない場合でもデプロイをトリガできます。 あらかじめECSエージェントの設定で ECS_IMAGE_PULL_BEHAVIOR=always になっているので、 production-current のような動的なタグでもdocker pullしなおしてタスクが再起動されます。 結果として新しいDockerイメージがデプロイされます。

(2) Terraform経由でECSタスク定義を更新した場合 terraform apply した時点でActiveな新しいタスク定義が作成され、古いタスク定義がINACTIVE状態になります。 Terraformの aws_ecs_servicelifecycle ルールの ignore_changes を使い task_definition の変更を無視するように 設定を仕込んでおくと、 terraform apply しただけでは新しいデプロイはトリガされません。 うっかり誰かの別のアプリケーションデプロイと重複デプロイしないように、この方が安全です。

ECSのドキュメントを見るとINACTIVEな状態では一見新しいタスクは起動できないように見えますが、

タスク定義の登録解除

実際には試してみたところ、ECSサービスに関連付いているECSタスク定義がINACTIVEな状態でも以下が可能で、サービス提供には実害ありません。

  • aws ecs update-service --force-new-deployment でのタスク再起動
  • aws ecs update-service --desired-count でタスク数の変更
  • docker kill でコンテナをkillした場合のタスク自動復旧

ECSサービスに設定されているECSタスク定義の revision を更新するには、 --task-definitionfamily のみ指定し、 revision を省略すると最新のActiveなタスク定義が自動で使われます。 つまり terraform apply したのちに aws ecs update-service --task-definition でデプロイをトリガすると、 新しいECSタスク定義でのデプロイが可能です。 --task-definition はタスク定義に変更がない場合に指定しても実害はありません。

上記をまとめると --task-definitionrevision 省略しfamily のみ指定し、さらに --force-new-deployment を指定しておけば、 terraform apply 後のECSタスク定義の反映も、通常のアプリケーションのデプロイ方法と同じになります。

一点懸念点を上げるとすると、 ECS_IMAGE_PULL_BEHAVIOR=always でpullされるDockerイメージのキャシュは ECSクラスタ内のEC2ホストで共有されるので、デプロイ処理中に障害などが発生すると、 どのコンテナにどこまで新しいイメージが反映されたかを厳密にコントロールできなくなるのですが、 最終的にはすべてのコンテナがリリースしたいイメージに収束するはずですし、今のところ、これが問題になったことはありません。 もしリリースしたイメージに問題がある場合には、 バックアップしておいた production-old のタグを production-current にコピーしてデプロイしなおせば旧バージョンに戻すことは簡単です。

Unicorn依存からの脱却

最後の敵はUnicornでした。 Docker化に合わせて、RailsアプリケーションサーバUnicorn から Puma に変更しました。 Unicornはマルチプロセスモデルで、1コンテナの中に複数のプロセスが立ち上がるので、結果として1コンテナの消費メモリが大きくなり、コンテナのスケジューリングがしづらいです。 一方Pumaはマルチスレッドモデルで動くので(厳密にいうとマルチプロセスでも動かせますが)、複数のスレッドを立ち上げれば1プロセスのメモリ空間を共有したまま並列処理ができ、1プロセス/1コンテナとしてデプロイ可能です。 デプロイ可能な粒度が小さい方が、スケールアップ/スケールイン/ローリングアップデートする際に、クラスタの空きリソースを活用してコンテナのスケジューリングをしやすくなります。 ちなみにUnicornのままデプロイ粒度を小さくするために、1プロセス/1コンテナでデプロイすることはできず最低限masterとworkerで2プロセス必要で、masterプロセス分だけオーバーヘッドができてしまうので非効率です。これは小さなアプリケーションでは問題にならないかもしれませんが、CrowdWorks本体の場合、アプリケーションのコードを読み込んでるだけでmasterプロセスに1GBぐらいメモリを持っていかれて無視できません。

というわけで、UnicornをPumaに変更してみたのですが、 既存のUnicornのリクエスタイムアウト(デフォルト60s)に相当する設定値がPumaにはありませんでした。 そんなはずはないだろうと思ってIssueを漁ったのですが、どうやら思想として実装する気はないようです。

puma/puma#1244: What's the recommended way to prevent requests running for longer than we'd like?

そもそもスロークエリをなくすべきだし、もしくは遅い処理に限定してタイムアウトを設定すべきだという意見はもっともなのですが、 なんらかの要因でスロークエリが発生した場合に、データベースに負荷をかけすぎてシステム全体が不安定になるのは避けたいところです。

代替案として、上記のIssueで紹介されていた rack-timeout というRackレイヤでタイムアウトを発生させるgemを導入しました。 rack-timeoutを導入してしばらくすると、稀によくMySQLコネクションの接続エラーが発生するようになりました。 とりあえず被疑部位としてmysql2 のgemのバージョンを上げてみたりしたのですが、ハズレでした。

ローカル環境で擬似的にタイムアウトを短くしたりしてデバッグしたり、関連するrack-timeout/activerecord/mysql2 gemの実装を読んだりして SQL実行中のタイムアウトの挙動を調べました。要約すると以下のとおりです。

  • SQL実行中にタイムアウトが起きると、rack-timeoutによる割り込みが発生し、MySQLのコネクションが切断される。
  • ActiveRecordトランザクションの中で例外が発生すると、ActiveRecordがrescueしてRollbackしようとする。
  • RollbackしようとするがMySQLのコネクションが切断されているので、Rollbackの発行は失敗する。
  • 別途MySQLサーバ側でクライアントの切断を検知してRollbackはされるのでデータの整合性は問題ない。
  • 通常ならRollback完了後にActiveRecordは原因となった元の例外をトランザクションの外側にraiseしなおすが、Rollbackが失敗するので、Rollback失敗エラーがトランザクションの外に投げられる。
  • つまりタイムアウトエラーはトランザクションの外に伝搬できない。
  • DBのコネクションが一度切れてるので、このタイミングで再接続しても、別のDBセッションになるのでRollbackは発行できない。
  • ActiveRecordは1HTTPリクエストごとにDBコネクションを取得し、切断されていれば再接続する機能があるので、1HTTPリクエスト/1トランザクションであれば問題ない。

さらに調査を進めると以下のことが分かりました。

  • ある機能Aを複数件一括で処理する便利な一括A機能がある。
  • 一括A機能はループしながら1件ずつ機能Aを呼び出すが、一部のレコードが失敗した場合でも、全体としては処理を継続する設計になっている。
  • つまりデータベースのトランザクションの単位はAの1件ずつになっている。
  • 処理件数や関連するデータが多いとタイムアウトが発生することがある。
  • しかしながら、一括で処理するために例外を握りつぶして次のループに進もうとすると、この切断されたDBコネクションが再利用されることになる。
  • 結果として本来タイムアウトエラーが発生した場所とは異なる、次のSQL実行時に奇妙なDB接続エラーとして観測される。

本来タイムアウトを設定している目的は、DBに過剰な負荷をかけないためなので、このケースではタイムアウトで処理を止めるべきです。 しかしながら、トランザクションの中でrescueしてもRollback失敗エラーで上書きされて外に伝える手段がないので、 仕方がないので、ループの継続条件のチェックで、DBコネクションが切断されていたら擬似的にトランザクションの外でタイムアウトが発生したことにして回避しました。

ポチポチっと対象を選んで一括で処理したらべんり!

みたいなのよくありますよね。。。気をつけましょう。

理想的には、タイムアウトしないようにパフォーマンス・チューニングするなり、 もしくは「処理を受け付けました」で一回HTTPリクエストを返してしまい、結果を非同期で確認するなりするのがよいとは思いますが、 元々Unicornタイムアウトで死んでたときも後続の処理はできてなかったこともあり、一旦タイムアウトで切るだけに留めておきました。完璧を求めすぎると前に進めませんし。

おわりに

そんなこんなで2019年8月某日、 crowdworks.jp のオンラインのトラフィックを徐々に新環境に切り替え、その後バッチも1ヶ月ほどかけて切り替え、ついに念願のCrowdWorks本体のDocker化が完了しました。

f:id:minamijoyo:20190930160711p:plain

長かったです。

この他にも雑多な問題がいろいろあり、妥協したこともあり、まだまだ書き足りていないことも多々あるのですが、だいぶ長文になってしまったのでこれぐらいにしておきます。

クラウドワークスでは、これまでもDockerを活用していましたが、なかなか主力事業のど真ん中では活用できておらず、他社の事例を聞く度に、なんとなくモヤッとした気持ちがありました。 でもそれももう過去の話です。というわけで、

クラウドワークスでは「コンテナを活用して変更に強く可用性の高いインフラを作っていきたいSRE」を募集しております!!

www.wantedly.com

© 2016 CrowdWorks, Inc., All rights reserved.