Amazon Web Services ブログ

AWS Copilot によるコンテナアプリケーションの自動デプロイ

本投稿は Nathan Peck による記事を翻訳したものです

アプリケーションをアイデアから人々に触ってもらえる実装に落とし込むのは複数のステップを含むプロセスです。設計が固まりコードが書かれると、どうやってそのアプリケーションをデプロイし、ユーザーのもとに届けるかというのが次のチャレンジとなります。その実現方法の1つが Docker コンテナを利用することであり、AWS Copilot のようなコンテナを実行するためのインフラストラクチャを自動的に構築してくれるようなツールです。もしあなたがまだ AWS Copilot のことをよく知らない場合は、以前のブログ記事「AWS Copilot のご紹介」をお読みいただくとその全体概要を掴んでいただけるかもしれません。

Copilot を利用すると copilot svc deploy のような CLI コマンドを実行することでアプリケーションのビルドやデプロイを実行することができますが、例えば複数の開発者が複数のサービスからなる規模のアプリケーションについて長期的な視点で考えた時には理想的な使い方とは言えません。この記事では、Copilot の基礎的な使い方に基づいてアプリケーションリリースの自動化を進める方法を紹介していきます。そこで、まずはコード変更をリポジトリにプッシュする度にコンテナアプリケーションをビルド、プッシュ、デプロイするという基本的なリリースパイプラインを動かすところから始めます。その後、複数のステージやテストを備え、プロダクションへのリリース前にそのアプリケーションが動作することを確認できるようなベストプラクティスをパイプラインを実装していきます。『本番環境で発見されたバグを修正し、それをユーザーに向けてリリースする』という現実世界のシナリオのウォークスルーが本記事のフィナーレです。

本日のアプリケーション

あなたは “String Services” というオンライン文字列操作 API 界のトッププロバイダになることを狙っているスタートアップで働いているとしましょう。ある日、あなたの会社は “reverse.” と呼ばれる文字列操作のサービスを提供していくことを決めます。このサービスはどんな文字列もそのインプットとして受け入れ、逆向きの(reversed)文字列をその結果として返します。あなたの仕事はこの新しいサービスをデプロイし、この API を熱望するお客さまに届けることです。まずは Node.js で書かれたコードを見ていきましょう。

var getRawBody = require("raw-body");
var http = require("http");

var server = http.createServer(function (req, res) {
  getRawBody(req)
    .then(function (buf) {
      res.statusCode = 200;
      res.end(buf.toString().split("").reverse().join(""));
    })
    .catch(function (err) {
      res.statusCode = 500;
      res.end(err.message);
    })
});

server.listen(3000);

また、アプリケーションコードに加えてマルチステージが定義されたシンプルな Dockerfile があります。これはアプリケーションの依存物をインストールし、アプリケーション用のミニマルな Docker イメージを作るために利用されます:

FROM node:14 AS build
WORKDIR /srv
RUN npm install raw-body

FROM node:14-slim
WORKDIR /srv
COPY --from=build /srv .
ADD . .
EXPOSE 3000
CMD ["node", "index.js"]

これらの2つのファイルがあれば、Copilot で文字列反転サービスのデプロイに必要なものは全て揃っています。「AWS Copilot のご紹介」で触れたように、copilot init コマンドを使うことで、アプリケーションの検出とその自動的なデプロイを可能にするウィザードを実行できます。コマンドの実行が終わると、Copilot はアプリケーションにアクセスするための URL をコンソールの出力として表示します。

今回、String Services は https://string.services というドメインを持っており、それを使ってアプリケーションをホストしたいとしましょう。次のようなコマンドでカスタムドメインを利用しつつ、2つの環境に文字列反転サービスをデプロイすることができます:

copilot app init --domain string.services 
copilot env init --name test
copilot env init --name prod
copilot svc init --name reverse
copilot svc deploy --name reverse --env test
copilot svc deploy --name reverse --env prod

これにより2つの環境に独立した文字列反転サービスがデプロイされ、サービスの URL (次のコマンドの例では prod 環境)に対して文字列を送ることで反転された結果を得られるようになります:

$ curl -d "Hello" https://reverse.prod.std.string.services
olleH

アプリケーションのリリースを自動化する

Copilot を使って CLI からアプリケーションをデプロイすることはそんなに難しくはありませんが、String Services がさまざまなオンライン文字列処理サービス群を大規模に標準ライブラリのように作っていることを考えると、彼らは将来的に100を超えるサービスを持ち得ますし、その多くは定期的なアップデートが必要となるでしょう。このような状況に対しては、いくつかのパスが選択肢として考えられます:

  • 中央集権的なデプロイ – 全てのサービスのデプロイを特定の人物もしくは少数の人々が Copilot コマンドを実行することで個別に実施する。開発者をデプロイから分離してしまうだけでなく、サービスのデプロイ行為を実施する人々が深刻なボトルネックとなりえます。
  • 非中央集権的なデプロイ – 全ての開発者に Copilot の使い方を教え、サービスのデプロイや更新について彼ら自身に責任を持たせます。これはうまくいくかもしれませんが、開発者が個別にコードを変更して個別にデプロイを実施することで、ときにはそのサービスを動かないものにしてしまう可能性も懸念されます!
  • ガードレイル付きの自動化 – 両者にとって最良の選択肢: チーム内の全ての開発者がデプロイを実施できるようにしつつ、すべてのデプロイが中央集権的に用意された自動化されたパイプラインを通るようにします。これにより、そのサービスが顧客に届く前に正しく動くことを担保しやすくなります。

この3番目、両者にとって最良のアプローチによって、開発者は必要があれば Copilot を使い、そうでなくとも単にコードレポジトリに git push することで、自動化されたプロセスによってコードをデプロイできるようになります。

Git push によって自動的にビルドし、アプリケーションをデプロイする自動化されたパイプラインは、いくつかのコマンドを利用することで作成できます:

copilot pipeline init
git push
copilot pipeline update

パイプラインが作成されると、copilot pipeline status コマンドでパイプラインの状態を確認できるようになります。

このパイプラインは AWS CodePipeline のマネジメントコンソールでも確認できます。ここまでで、すでに基本的な自動化ができあがりました。開発者はコードを変更した上で git commitgit push を行うと、テスト環境や本番環境にアプリケーションをデプロイすることができます。また、開発者のマシンには Copilot そのものがインストールされている必要もなければ、その使い方を知る必要もありません。Git の使い方さえ知っていれば、コードの変更を自動的にデプロイできるわけです。

テストを追加する

開発者の加えた変更によってサービスが壊れていないことを確認できるよう、いくつかのテストを追加していくことが次のステップです。一口にテストと言っても様々なものがありますが、重要な不具合を見つけるためのもっとも効果的なテストの1つが結合テストでしょう。結合テストのゴールは現実のユーザーが行うようにサービスを利用し、サービスから返される結果が正しいものであることを確認することです。本記事の題材となっているサービスは、サービスのエンドポイントに対して文字列を送信することで利用され、サービスは文字列を構成する文字の順序を反転させた文字列を返します。ここでは、文字列をサービスに送信し、その戻り値の文字列が期待したものかどうかを確認できそうです。

文字列反転サービスの開発者はサービスに対して実行できる以下のような結合テストを用意していました:

var superagent = require('superagent');
var expect = require('expect');

const url = process.env.APP_URL;

if (!url) {
  throw new Error('Test process requires that env variable `APP_URL` is set');
}

test('should be able to reverse a simple string', async () => {
  const res = await superagent.post(url).send('Hello');
  expect(res.text).toEqual('olleH');
});

Copilot は、このテストスクリプトをパイプラインに簡単に追加する方法を提供しています。pipeline.yml を見ると、デプロイメントのステージとテストコマンドを追加するための記述を持つリストが確認できます。つまり、ここでやるべきことは、テストの依存物をインストールし、テストコードが必要とするテスト環境のエンドポイント URL を環境変数として渡して結合テストを呼び出すだけです:

# The deployment section defines the order the pipeline will deploy
# to your environments.
stages:
    - # The name of the environment to deploy to.
      name: test
      # Optional: use test commands to validate this stage of your build.
      test_commands:
        - npm install --prefix test
        - APP_URL=https://reverse.test.std.string.services npm test --prefix test
    - # The name of the environment to deploy to.
      name: prod

テストに関するスクリプトをパイプラインのファイルに加えたら、いくつかのコマンドを実行してパイプラインがテストを実行するように更新します:

git push
copilot pipeline update

これで、いつ開発者がコードの変更をプッシュしても最初にテスト環境にデプロイされ、結合テストがテスト環境に対して実行されます。そして、テストが通った場合にのみ、その変更が実際にお客様が利用している本番環境にデプロイされます。今回のパイプラインへの変更で、パイプラインのステータス表示が以下のような新しいステップを含むようになります::

この例は、テストコマンドが成功してデプロイがプロダクションでも実施され、お客様に対してアプリケーションの更新がリリースされたことが分かります。

バグの修正をリリースする

お客様に向けてサービスがリリースされ、数週間は全てが順調でした。ここではじめてのバグ報告がお客様から飛び込みます: “文字列の反転を試してみたんだけど謎のクエスチョンマークが返ってくるんです!”。幸いなことにこのお客様は再現手順をバグ報告に含めてくれていました:

$ curl -d "Hello ?" https://reverse.prod.std.string.services 
�� olleH

調査に多少の時間がかかりましたが、答えが明確になってきました。文字列を反転させる処理は単にその文字列をバイト単位で分割し、それらを逆向きに結合するように実装されていたのです。この実装はそれぞれの文字が1バイトで表現される基本的な ASCII 文字列には有効ですが、多くのモダンなシステムはユニコードを扱います。ユニコードは UTF-8 のようなエンコーディング手法を使って文字を表現できるキャラクタセットです。これによって例えば連続した2つもしくは4つのシーケンシャルなバイト群によって絵文字のようなものを表現できるようになります。文字列反転アプリケーションがこのようなバイトシーケンスを反転させようとすると、個々のバイト単位でそれらが反転されてしまうため、解釈不能となった結果がクエスチョンマークとしてレンダラによって表示されてしまったわけです。

サービスのバグ修正をリリースするときです。信頼性と安定性を継続して保ちながらサービスを成長させていく最良の方法の1つは、お客さまが遭遇したどんなバグも、2箇所で発生したバグとして取り扱うことです。1つめはもちろんアプリケーションコード。そしてもう1つバグがあったのは、テストそのものです。もしテストが適切な範囲をカバーしていれば、リリースされる前に今回のバグは発見されていたでしょう。

まず最初に今回のバグが直ったことを検証できる新しいテストを追加しましょう。このテストは現時点では必ず失敗しますので、パイプラインが本番環境にデプロイすることをブロックしてくれます:

test('should be able to reverse a string containing UTF-8 characters', async () => {
  const res = await superagent.post(url).send('Hello ?');
  expect(res.text).toEqual('? olleH');
});

git push の後にパイプラインが実行されますが、リリースプロセスはテストの失敗によってアプリケーションが本番環境に届く前に止まるはずです。AWS Codepipeline のマネジメントコンソールを見てみると、以下のような情報を確認できます。

TestCommands ステージの “Details” をクリックすると、先ほど追加したテストによってこの失敗が起きていることが明確になるはずです:

これはいいことですね!テストがちゃんと問題を検出したことを意味しています。この問題を修正したあとは、このバグが再び本番環境にリリースされてしまうことはないはず、と自信を持てますね。

これでアプリケーション側の問題を直すのが簡単になります。ここではユニコードを適切に分割してくれるオープンソースパッケージの runes を使うことにしましょう。このパッケージをアプリケーションに追加して利用すれば、表示されている文字列に沿った適切なバイト境界で文字列を分割することができるはずです。

var getRawBody = require("raw-body");
var runes = require('runes');
var http = require("http");

var server = http.createServer(function (req, res) {
  getRawBody(req)
    .then(function (buf) {
      res.statusCode = 200;
      let stringRunes = runes(buf.toString());
      res.end(stringRunes.reverse().join(""));
    })
    .catch(function (err) {
      res.statusCode = 500;
      res.end(err.message);
    })
});

server.listen(3000);

process.once('SIGTERM', function () {
  server.close();
});

git commitgit push を再び済ませたら、この変更がパイプラインに進みます。今回はテストが成功し、変更は本番環境にロールアウトされるはずです。

お客様が送ってくれたバグ報告に含まれていた再現手順を本番環境に対して実行し、文字列が意図した通りに反転された形で返ってくることを確認しましょう:

$ curl -d "Hello ?" https://reverse.prod.std.string.services
? olleH

まとめ

この記事では、Copilot を使ってアプリケーションのデプロイを自動化していくステップに触れてきました:

  • まず最初に基本的なデプロイパイプラインを作成
  • 本番環境へのデプロイ前にテスト環境に対して結合テストを実行するようにし、パイプラインを改善
  • 新しいテストケースを追加してアプリケーションのバグを修正し、その修正をデプロイ

この一連のストーリーが、皆様がコンテナのデプロイ自動化に取り組む際にお役に立てると光栄です!この記事で用いたサンプルアプリケーションとパイプラインのコードは、すべて GitHub で確認することができます。このリポジトリでは、みなさまもプルリクエストを開いて変更をリクエストすることが可能です。このリポジトリは実際に Copilot のパイプラインとつながっているため、もし変更が実際にマージされたら、その変更は本記事で触れてきたものと同じパイプラインを通ってサンプルのサービスである https://reverse.prod.std.string.services にデプロイされます。

翻訳: トリ (原文はこちら)