ちょっと話題の記事

[AWS] 可用性の高い堅牢なデプロイプロセスについて考える

2017.07.08

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。こむろ@今は東京です。

アプリケーション開発において、実行環境へのデプロイプロセスはとても重要です。AWSでもCodeDeployやElasticbeanstalk、OpsWorksと言った各種デプロイプロセスをサポートするサービスがあります。しかし、どのデプロイプロセスが今自分たちのフェーズでのスタイルと一番相性が良いのでしょうか?最終的に全て結果は同じです。アプリケーションが環境へデプロイされ、アプリケーションが正常に実行、そしてサービスが正常に動作することを目指しています。このデプロイプロセスは新たにインスタンスを立ち上げる際にも関わりがあります。そのため、スケールアウトすることを前提に作られているシステムはデプロイプロセスとは切っても切り離せない関係です。今回は運用面から見たデプロイプロセスの再考と改善を考えます。

はじめに

自分が担当しているプロジェクトでは開発タイミングや担当者の関係から様々なデプロイプロセスが混在しています。本来統一されるべきなのですが(複数のデプロイプロセスが混在すると、オペレーションミスが発生する可能性が高いですし)、このカオスな状況を逆手にとってそれぞれのデプロイプロセスの利点・弱点を自分なりにまとめました。そして最終的に最も可用性の高い、つまり障害が発生しにくいデプロイプロセスを自分なりに検討してみました。全てのケースに適用できる万能なものではないですが、AWSを利用してアプリケーション開発・運用する際に、どのようなデプロイプロセスを選択し、どのように運用していくかの参考になれば幸いです。

本プロジェクトが実際に運用しているデプロイプロセス

自分の担当しているプロジェクトのデプロイプロセスは以下の3種類です。当然それぞれオペレーションも違いますし、動きも全く違います。ツールも当然異なります。

  1. ベースAMI+Ansible+CodeDeployを使ってミドルウェア、アプリケーションを管理
  2. ベースAMI+Ansible+PackerによるGolden AMI方式によるミドルウェア、アプリケーションを管理
  3. ElasticBeanstalkを利用してインフラからミドルウェア、アプリケーションまでを管理

用語について

本エントリーで指し示す用語について簡単に定義しておきます。

  • デプロイプロセス: アプリケーションを実行環境へ適用するためのツールやサービスを利用した手順を指します。
  • ミドルウェア: JDKやRuby on Railsなどアプリケーションを利用するために必要なフレームワークやログ転送サービスのエージェント及び、それらに依存するライブラリを指します。
  • カスタムベースAMI: ベースとなるAmazonLinux AMIにユーザーが何らかの機能追加やアプリケーションを事前にインストール、環境変数の定義などを行ったAMIを指します。

それぞれ細かく見ていきます。

CodeDeploy

SpringBoot+Java8とRuby on Railsで作られた各アプリケーションはCodeDeployを中心にデプロイプロセスを構成しています。以下のような構成です。

CodeDeploy

このプロセスでは以下の手順で実行されます。

  1. ASGによってベースAMIを元にインスタンスが起動されます
  2. Userdataで各種環境変数が設定されます
  3. リポジトリからPlaybookを取得します
  4. Ansibleのインストールが実施されます
  5. インストール完了後、Ansible-Playbookが実行され、ミドルウェアのインストールが実行されます
  6. CodeDeployによってアプリケーションが配置されます
  7. 全て完了したらCodeDeployのステータスが正常で記録され、デプロイ完了となります。

AWSのドキュメントにも色々特徴が記載されていますが、自分が実感できているCodeDeployの利点は以下です。

  1. アプリケーションのみを更新する際にはインスタンスの入れ替えが発生しない(オプションによってインプレースメントを指定)
  2. ローリングアップデートが可能
  3. アプリケーション不具合の場合のロールバック
  4. 1つのサービスで完結する
  5. アプリケーションのRevision管理が可能

基本的にはCodeDeployはアプリケーションのみ管理しています。Revisionで管理しているため、いつアップロードされていつデプロイされたかをマネジメントコンソールやAPIから確認することができます。EBなどのようにインフラストラクチャの管理はしていません。またEBのように特にプラットフォームなども限定されません。

場合によってはインスタンスの入れ替えが発生しますが、基本的にアプリケーションの入れ替えであればインスタンスの入れ替えは発生しません。そのため、デプロイ時にローカル内のログをどうするかなどを考える必要は特にありません。当然スケールアウト・インイベントによるインスタンスの入れ替えは発生するため、全く考慮しなくてよいわけではありません。あくまでアプリケーションのデプロイ時に気にしなくて良いだけです。

Golden AMI方式

Golden AMIは頻出のパターンかと思います。素のAMIから立ち上げたEC2に対して、ミドルウェアのインストールからアプリケーションのインストールまでを行う場合、とても時間がかかる場合があります。その場合、最悪ELBからよろしくないインスタンスと判定され、Terminateされてしまう可能性があります。それらを避けるため、事前にミドルウェアやアプリケーション等必要なものを全てインストール済みのAMIを作成しておきます。

担当プロジェクトでは、GoldenAMI方式を利用してPlay for Scalaのアプリケーションのデプロイプロセスを構成しています。AMI作成時にはPackerを利用し、Rakeタスクとして定義しています。AMI作成時にはこのコマンドを利用することでデプロイ用のAMIを作成します。

GoldenAMI

実際に環境に展開する場合は、CloudFormationを利用し、こちらもRakeタスクとしてラップして定義しています。実行の手順は以下のとおりです。

  1. デプロイAMIの作成元となるEC2をベースAMIから起動します
  2. Ansible等を実行し、ミドルウェアのセットアップが実行されます
  3. アプリケーションコードをgithubのリポジトリから取得し、展開します
  4. 全て完了したら起動しているインスタンスから新たにAMIを作成します
  5. AMIの登録が完了したら、立ち上げたEC2をTerminateします(ここで一旦途切れる)
  6. CloudFormationを実行し、先程登録したAMIを指定した起動設定を作成します
  7. ASGの起動設定を前段で作成した起動設定に切り替えます(切り替え前の起動設定は削除)
  8. 新しい設定のASGによって新しいインスタンスが起動します
  9. 旧インスタンスがELB配下からデタッチされます
  10. 新しいインスタンスがELB配下にアタッチされます
  11. 全部入れ替わったらデプロイ完了です

これらをRakeタスク+Packerを利用して実行しています。Golden AMI方式の利点は以下です。

  1. ローリングアップデートが可能
  2. アプリケーションインスタンスの起動速度を早められる
  3. デプロイに失敗した場合、CloudFormationによって以前の環境へ全て巻き戻される(環境ごとRollback)
  4. 必要なものは全てAMIの中にBundle済みであるため、正常に起動するスナップショットとしての役割を果たす
  5. インスタンスの入れ替えが発生するため、メモリリーク等が発生している場合状況がリセットされる

デプロイの手順は二段階です。まずはデプロイAMIを作成します。続く手順でデプロイAMIをCloudFormationを利用して、インスタンスを起動し既存のインスタンスとの入れ替えを実行します。

CodeDeploy方式とは異なり、複数のツールやサービスを利用します。さらに環境への展開時には、CloudFormationでのインスタンス入れ替えが必ず発生します。これが良いことか悪いことかはまた別の話ですが、Terminate時にインスタンス内のローカルで管理していたログを全て確実に転送済みにしておく必要があるなど、ある程度の注意が必要です。 *1

ElasticBeanstalk

SpringBoot+Java8+DockerをEBで管理しています。そのためアプリケーションだけでなく、プラットフォーム/インフラストラクチャの管理もEBが行っています。 *2

EB

デプロイ手順(インフラへの設定変更がないものとする)は以下です。

  1. インスタンスが一時的にELB配下からデタッチされます
  2. アプリケーションが入れ替えられます
  3. 再度ELB配下へアタッチされます
  4. EBへのステータス転送が開始されます

EBでの利点は以下になります。

  1. ローリングアップデートが可能
  2. 必要なインフラストラクチャの設定が一元管理(これが良いことも悪いこともありますが)
  3. ミドルウェアが自動的に構成される(これは一長一短)
  4. 1つのサービスでほぼ全てが完結する

こちらも場合によってはインスタンスの入れ替えが発生する可能性があります。しかし、通常のアプリケーションの入れ替えであればインスタンスの入れ替えは特に発生しません。

利点は大体同じ

利点はそれぞれあまり変わりはありません。大きく異なるのはインスタンスの入れ替えがあることくらいでしょうか。AMIでの管理、S3でのアプリケーションRevision管理等の違いはありますが、いずれも任意のバージョンまで遡ってロールバックさせることは可能です。どれも正しく見えますし、どれでも目的は達成できそうな気がします(EBはプラットフォーム制限等はありますが)

さて、この構成で問題が発生する可能性はどこでしょうか?具体的な例から上記デプロイプロセスの改善点を探していきます。

ミドルウェアのセットアップでの障害

インスタンス立ち上げ時には、それぞれAnsibleを実行したり、謎の黒魔術(??)を使ったりしてミドルウェアのセットアップを実行していることは確認しました。ミドルウェアについてはその多くが、外部のリポジトリなどから取得してインストールする場合が多いかと思います。 yum install であったり wget してから手動でインストールするなど、Ansibleの場合は特にそのあたりは柔軟に指定ができます。

さて、以前も怒りのあまり記事にしたこともありますが、ミドルウェアの取得元でエラーが発生した場合はどうなるでしょうか。

CodeDeploy方式の場合

CodeDeployは通常のアプリケーションの更新では、インスタンスの新たな立ち上げはないので問題はありません。 *3しかしASGのスケールアウト、オートヒーリングなどの動作によって新たにインスタンスが起動される際に初めて発覚します。

CodeDeploy_NG

しかも悪いことに、ASGを構成している場合は、新しいインスタンスの起動に失敗し続けるため、

インスタンスの起動 => ミドルウェアのインストールに失敗 => インスタンスのTerminate => ASGの設定により別のインスタンスが起動

CodeDeploy_infinitloop

となり延々と無限ループが繰り返され、とてつもない金額が課金されます。新たにインスタンスを立ち上げる際に失敗する要因があると上記のような現象を意図せず引き起こす可能性があります。

codedeploy-failed

Golden AMI方式の場合

Golden AMI方式の場合は、アプリケーションのみの更新でもインスタンスの入れ替えが発生します。しかしAMIを作成した時に、すでにミドルウェアもアプリケーションも丸っとインストール済みです。全て梱包されているイメージからインスタンスを起動するため、新たな起動に外部要因による障害は影響がありません。正常に動作する環境をスナップショットで取っているようなものですから。このデプロイプロセスで外部要因による不具合が発覚するのは、AMIを作成する時です。

そのため、実際にミドルウェアのインストール不具合は現在アプリケーションが動作している環境には一切影響を及ぼしません。これはサービス可用性の観点から見ると非常に大事な点です。事前に作成したAMIが外部環境で起こりうる障害をアプリケーション環境に影響を及ぼすことのないように最大の防波堤になっています。

GoldenAMI_NG

EBの場合

EBの場合はミドルウェアのインストールに関しては、私達がほとんど関与出来る箇所がありません。プラットフォームを選択すると、適切なミドルウェアを自動的にインストールしてくれるためほとんどカスタムする幅がないというのが現状です。ただ、プラットフォームに新しいバージョンが出た場合など古いバージョンを使い続けようとすると、依存ライブラリなどでエラーが発生したりするようなので、プラットフォームは極力最新版を適用しておく、というのが無難なようです。

EB_NG

[余談] EBのエラー詳細を知る

色々と裏側で自動的にインストールされるEBでは、何かしらエラーが発生した場合に起動したインスタンスの中にsshで入り、以下を確認することが多いです。ひとまず何らかのエラーが出た場合は、 /var/log/eb-activity.log を中心に確認します。

例えばこのようなエラーログが見ることができます。

caused by: ++ /opt/elasticbeanstalk/bin/get-config container -k tarball_url
  + EB_TARBALL_URL=https://s3-ap-northeast-1.amazonaws.com/elasticbeanstalk-env-resources-ap-northeast-1/stalks/eb_ruby_passenger_4.0.1.13.67/lib/tarballs
  ++ /opt/elasticbeanstalk/bin/get-config container -k script_dir
  + EB_SCRIPT_DIR=/opt/elasticbeanstalk/support/scripts
  ++ /opt/elasticbeanstalk/bin/get-config container -k ruby_version
  + EB_RUBY_VERSION=2.2.4
  + is_baked ruby_packages
  + [[ -f /etc/elasticbeanstalk/baking_manifest/ruby_packages ]]
  + false
  + yum install -y pcre-devel gcc-c++ make libcurl-devel libxml2-devel libxslt-devel sqlite-devel mysql mysql-devel postgresql postgresql-devel patch
  Loaded plugins: priorities, update-motd, upgrade-helper
  Package 1:make-3.82-21.10.amzn1.x86_64 already installed and latest version
  Package patch-2.7.1-8.9.amzn1.x86_64 already installed and latest version
  Resolving Dependencies
:
:
  --> Finished Dependency Resolution
  Error: Package: glibc-2.17-106.167.amzn1.i686 (amzn-main)
             Requires: glibc-common = 2.17-106.167.amzn1
             Installed: glibc-common-2.17-157.169.amzn1.x86_64 (@amzn-main/latest)
                 glibc-common = 2.17-157.169.amzn1
             Available: glibc-common-2.17-106.167.amzn1.x86_64 (amzn-main)
                 glibc-common = 2.17-106.167.amzn1
  Error: Package: 1:openssl-devel-1.0.1k-14.91.amzn1.x86_64 (amzn-updates)
             Requires: openssl(x86-64) = 1:1.0.1k-14.91.amzn1
             Installed: 1:openssl-1.0.1k-15.99.amzn1.x86_64 (@amzn-main/latest)
                 openssl(x86-64) = 1:1.0.1k-15.99.amzn1
             Available: 1:openssl-1.0.1k-14.90.amzn1.x86_64 (amzn-main)
                 openssl(x86-64) = 1:1.0.1k-14.90.amzn1
             Available: 1:openssl-1.0.1k-14.91.amzn1.x86_64 (amzn-updates)
                 openssl(x86-64) = 1:1.0.1k-14.91.amzn1
   You could try using --skip-broken to work around the problem
   You could try running: rpm -Va --nofiles --nodigest (Executor::NonZeroExitStatus

ここから原因を特定し修正をしていくのですが、ほとんど 自動で 実行されてしまうため原因によっては全く手出しができないことも多々あります。

他にも以下のようなエラーも確認されました。こちらも自動でスクリプトが実行されてしまうため、こちらが介入する余地はないようです。

  • AMI ID = aws-elasticbeanstalk-amzn-2016.03.3.x86_64-ruby-hvm-201608220934 (ami-1ea9667f)
  • EBプラットフォーム設定 = 64bit Amazon Linux 2016.03 v2.1.0 running Ruby 2.2 (Passenger Standalone)
Initialization failed at 2017-06-14T12:49:27Z with exit status 1 and error: Hook /opt/elasticbeanstalk/hooks/preinit/22_gems.sh failed.

++ /opt/elasticbeanstalk/bin/get-config container -k script_dir
+ EB_SCRIPT_DIR=/opt/elasticbeanstalk/support/scripts
++ /opt/elasticbeanstalk/bin/get-config container -k gem_dir
+ EB_GEM_DIR=/opt/elasticbeanstalk/support/gems/passenger
+ . /opt/elasticbeanstalk/support/scripts/use-app-ruby.sh
++ . /usr/local/share/chruby/chruby.sh
+++ CHRUBY_VERSION=0.3.9
+++ RUBIES=()
+++ for dir in '"$PREFIX/opt/rubies"' '"$HOME/.rubies"'
+++ [[ -d /opt/rubies ]]
++++ ls -A /opt/rubies
+++ [[ -n ruby-1.9.3-p551
ruby-2.0.0-p648
ruby-2.1.9
ruby-2.2.5
ruby-2.3.1
ruby-current ]]
+++ RUBIES+=("$dir"/*)
+++ for dir in '"$PREFIX/opt/rubies"' '"$HOME/.rubies"'
+++ [[ -d /.rubies ]]
+++ unset dir
+++ cat /etc/elasticbeanstalk/.ruby_version
++ chruby 2.2.4
++ case "$1" in
++ local dir match
++ for dir in '"${RUBIES[@]}"'
++ dir=/opt/rubies/ruby-1.9.3-p551
++ case "${dir##*/}" in
++ for dir in '"${RUBIES[@]}"'
++ dir=/opt/rubies/ruby-2.0.0-p648
++ case "${dir##*/}" in
++ for dir in '"${RUBIES[@]}"'
++ dir=/opt/rubies/ruby-2.1.9
++ case "${dir##*/}" in
++ for dir in '"${RUBIES[@]}"'
++ dir=/opt/rubies/ruby-2.2.5
++ case "${dir##*/}" in
++ for dir in '"${RUBIES[@]}"'
++ dir=/opt/rubies/ruby-2.3.1
++ case "${dir##*/}" in
++ for dir in '"${RUBIES[@]}"'
++ dir=/opt/rubies/ruby-current
++ case "${dir##*/}" in
++ [[ -z '' ]]
++ echo 'chruby: unknown Ruby: 2.2.4'
chruby: unknown Ruby: 2.2.4
++ return 1.

chruby でRubyバージョンを変更しようとしてるけど存在しないRubyバージョンへ変更しようとしてるためエラーです。これも結局手動では直せず、プラットフォームバージョンを最新に更新した結果、解決しましたが、運用するにおいてなかなか厳しいものがあります。

結局どのデプロイプロセスが良いか

運用という側面から考えると、Golden AMI方式がとても安全なので僕は推します。デプロイプロセスとしてはアプリケーションの更新に対してインスタンスの入れ替えが発生するため、非常に重厚なプロセスになり、手軽さとは無縁ですが、確実に立ち上がるAMIが確保されているというのは、とても大きなメリットかと思います。

ただ、この方式も万能ではありません。アプリケーションの不具合が発覚した場合は直ちにロールバックを行うべきですが、こちらはCodeDeployなどとは異なり、1つ前のバージョンのAMIを指定した上でCloudFormationを再度実行し、インスタンスを総入れ替えする必要があります。そのため、復旧までの時間は長くかかってしまうのが難点です。

CodeDeploy方式の改善

とは言え、CodeDeploy方式でも改善を行えば十分に障害の影響を最小化した上で運用を行えるかと思います。

先程までのCodeDeployのデプロイプロセスでは、「ベースAMIからAnsibleを実行しミドルウェアをインストールする」までがプロセスの一環としてインスタンス起動時に実行されていました。これをカスタムベースAMIにしてミドルウェアをセットアップ済みにしておけばどうでしょうか。以下のようなイメージです。

CodeDeploy_kaizen

こうしておくことで、正常にミドルウェアがインストールされている状態を元にアプリケーションコードのみを入れ替える形になるため、先ほどのミドルウェアのインストール失敗による無限ループなどの危険性が排除されます。Golden AMI方式に比べ、デプロイプロセスとして非常に軽量になり素早いデプロイが可能になります。

懸念点

これでも幾つかの懸念点は残ります。カスタムベースAMIの変更が必要になった場合の修正です。

こちらの場合はカスタムベースAMIとして不変なAMIを作成します。そのため、カスタムベースAMI作成時点でのミドルウェアの最新バージョンのスナップショットとなるため、時間が経つにつれ脆弱性などが発見される可能性があります。その場合は、ミドルウェアの更新のために再度カスタムベースAMIを作り直すことになります。作り直したカスタムベースAMIを元にインスタンスを起動するよう修正する必要があるなど、若干手間が発生するのは致し方ないかもしれません。

まとめ

幸か不幸か担当者が異なるプロジェクトを全て引き継いだため、多種多様なデプロイプロセス *4を一気に体感できました。そのため、それぞれのデプロイプロセスの利点・弱点や改善すべき箇所、障害などを知ることが出来ました。どのような観点でデプロイプロセスを構成するのかは非常に大事かと思います。

運用という観点から見るとGolden AMIによるデプロイプロセスが、障害が起こりうる外的要因から稼働しているシステムへの影響を最小限にするために有効です。

開発時には軽量なプロセスで素早い開発、運用時には外部環境の障害から切り離し稼働しているサービスの環境を守る、と言うようにフェーズによってデプロイプロセスには向き不向きがあります。最強のデプロイプロセスが世界に唯一つあるのではありません。状況にあった改善を施していくために見直すべきなのは、アプリケーションコードやログだけでなく、デプロイプロセスも例外ではないのではないでしょうか。

参照

脚注

  1. 消えて良いログなんて滅多にないのでデプロイプロセスの方式に問わず注意が必要ですが。
  2. ただし、EBの構成管理の設定はCloudFormationで管理していますが
  3. ターゲットグループでの指定がインプレイスメントになっていることが前提
  4. どうしてこうなった