特定のブランチをもとに本番同様の動作確認ができる「レビュー環境」の話

こんにちは。tkykです。

みなさん、コードレビューしていますか?今日はMisocaのレビュープロセスで用いられている、とっても便利な「レビュー環境」について紹介します。

Misocaのレビュー体制とその課題

MisocaではPull Request(以下、PR)ベースの開発体制をとっており、必ず他のエンジニアによるPRのレビューを経てから、masterへマージすることになっています。

レビュー時に動作確認をするには、エンジニア各自がローカル環境にブランチをチェックアウトして行うのですが、時にはそれだけでは不都合なケースもあります。

  • 非エンジニアにも動作確認をしてほしい
  • 動作確認をするための条件を整えたい
    • 最終的にはマージされないコードを一時的に追加したい
    • 依存するライブラリのバージョンを変更したい
    • RAILS_ENV=production でビルド・実行したい
    • などなど

このようなケースに対応するために、「レビュー環境」という仕組みを作りました*1

レビュー環境とは

レビュー環境とは、特定のブランチに基づいて構築された、独立したアプリケーションの実行環境です。専用のURLが割り当てられ、本番環境と同様に動作しますが、サーバリソースは独自に確保されており、自由な操作によるテストが可能です。

レビュー環境の使い方はとても簡単です。例えば今、 awesome-feature ブランチで新機能の開発を行っているとしましょう。このブランチを元にレビュー環境を構築するには、 review/[任意の名前] という名前のブランチをリポジトリにpushします。

git push origin awesome-feature:review/awesome

構築は自動で行われ、完了するとslackにそのレビュー環境専用に割り当てられたURLが通知されます。このURLにはGoogle認証によるアクセス制限がかかっていますが、Misocaの開発メンバーなら誰もが自由にアクセスすることができます。

(当初はPRが作られるたびに自動で環境を構築する方式も検討しましたが、すべてのPRが専用の環境を必要とするわけではなく、リソースの無駄が大きくなるので、ブランチ名で明示する方式になりました)。

仕組み

レビュー環境の正体はDockerコンテナです。コンテナの管理はAWS ECS(Elastic Container Service)で行っています。

全体の構造は次の通りです。GitHub Webhookを起点にAWS CodeBuildでDockerイメージの構築を行い、AWS Lambdaでコンテナの起動やslackへの通知を行います。

(prprについては過去の記事をご覧ください)

f:id:tkykmw:20171107151244p:plain

今回はCodeBuildの設定と、ECSを管理するLambdaの処理内容について、少し詳しく説明します。

CodeBuildによるDockerイメージの構築

CodeBuildではDockerイメージの構築と、ECR(Amazon EC2 Container Registry)への登録を行います。一連の処理の流れは、公式ドキュメントのサンプルとほぼ同じです。

# appspec.yml

phases:
  pre_build:
    commands:
      - echo Logging in to Amazon ECR...
      - $(aws ecr get-login --region $AWS_DEFAULT_REGION)
  build:
    commands:
      - echo Building the Docker image...
      - docker build -t $IMAGE_REPO_NAME:$IMAGE_TAG -f config/docker/Dockerfile.review .
      - docker tag $IMAGE_REPO_NAME:$IMAGE_TAG $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG
  post_build:
    commands:
      - echo Pushing the Docker image...
      - docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG
      - curl -X POST -d "{\"repo_name\":\"$IMAGE_REPO_NAME\",\"image_tag\":\"$IMAGE_TAG\"}" https://xxxxxxxx.execute-api.$AWS_DEFAULT_REGION.amazonaws.com/build-review-container

イメージのタグとして、レビュー環境の名前(ブランチ名の review/[この部分])を割り当てます。ファイル中では $IMAGE_TAG という変数の部分です。

ECRへのイメージ登録が完了したら、後続のLambdaを実行するために、API Gatewayに対するHTTP POSTリクエストを発行します。このとき、レビュー環境の名前をパラメータとして渡します。

LambdaによるECS管理

ECSでは、コンテナの定義情報(タスク定義)と実行情報(サービス)を分けて管理します。実際に起動されるコンテナは「タスク」として管理されます。

レビュー環境を構築するためのLambdaの処理は、次の通りです。

  1. タスク定義を作成・更新する
  2. サービスを作成・更新する
  3. (必要な場合)既存のタスクを終了する

順にコードを見ていきます。Lambdaのランタイムはnode.js 6.10を使用し、エラー処理は省いています。

タスク定義

タスク定義は単純なJavaScriptオブジェクトとして作成し、registerTaskDefinition で新規作成または更新します。

/*
 * タスク定義には、
 * - Dockerイメージのパス
 * - ポートマッピングの設定
 * - 環境変数の設定
 * - ログ出力の設定
 * - etc.
 * が含まれる
 */
const taskDefinition = (repository, tag, hostname, port) => {
  return {
    containerDefinitions: [ {
      family: taskName(tag),
      image: `000000000.dkr.ecr.ap-northeast-1.amazonaws.com/${repository}:${tag}`,
      portMappings: [ { hostPort: port, containerPort: 3000, protocol: 'tcp' } ],
      entryPoint: [ 'bin/launch' ],
      name: tag,
      environment: [
        { 'name': 'RAILS_ENV', 'value': 'production' },
        { 'name': 'APPLICATION_HOST_WITH_PORT', 'value': hostname },
        { 'name': 'MYSQL_DATABASE_PRODUCTION', 'value': `misoca_review_${tag}` }
      ],
      logConfiguration: {
        logDriver: 'awslogs',
        options: { 'awslogs-group': 'ECS', 'awslogs-region': 'ap-northeast-1', 'awslogs-stream-prefix': 'misoca-review' }
      },
      memoryReservation: 256
    } ],
    networkMode: 'bridge',
    placementConstraints: [],
    volumes: [],
    essential: true,
    volumesFrom: []
  };
};

/*
 * タスク定義の更新 or 新規作成
 * `event` はCodeBuildからLambdaに渡されたイベントオブジェクト
 * ホスト名とポート番号はタグ名をもとに生成する前提
 */
ecs.registerTaskDefinition(
  taskDefinition(
    event.repo_name,
    event.image_tag,
    hostname(event.image_tag),
    port(event.image_tag)
  ), (err, data) => {
  updateOrCreateService(event.image_tag);
});

タスク定義の内容に変更がない場合、毎回更新する必要はないのですが、処理を単純化するために常に registerTaskDefinition を実行するようにしました。

サービス

タスク定義の作成(更新)に成功したら、その定義を参照するサービスを作成(更新)します。レビュー環境一つにつき、一つのタスク(=コンテナ)を起動したいので、desiredCountは1に設定します。

const updateOrCreateService = (tag) => {
  ecs.describeServices({ services: [serviceName(tag)], cluster: CLUSTER }, (err, data) => {
    // 削除されたサービスは 'INACTIVE' ステータスとして見える
    if(data.services.length > 0 && data.services[0].status !== 'INACTIVE') {
      ecs.updateService({
        cluster: CLUSTER,
        service: serviceName(tag),
        taskDefinition: taskName(tag),
        desiredCount: 1
      }, (err, data) => {
        stopRunningTask(tag);
      });
    } else {
      ecs.createService({
        cluster: CLUSTER,
        serviceName: serviceName(tag),
        taskDefinition: taskName(tag),
        desiredCount: 1
      });
    }
  });
};

サービスが作成されると、タスクの起動はECSが自動で行ってくれます。しかしすでにタスクが起動していた場合、そのままでは新たなイメージがロードされないので、stopTask で起動済みタスクを終了します。するとECSが新たなイメージとタスク定義に基づいて、タスクを再起動してくれます。

/*
 * 常に1件のみ、実行中であることを想定する
 */
const stopRunningTask = (tag) => {
  ecs.listTasks({
    maxResults: 1,
    cluster: CLUSTER,
    desiredStatus: 'RUNNING',
    serviceName: serviceName(tag)
  }, (err, data) => {
    ecs.stopTask({
      task: data.taskArns[0],
      cluster: CLUSTER,
      reason: 'Review deploy'
    });
  });
};

まとめ

いかがでしたでしょうか。

Dockerコンテナによる軽量な実装と、AWS APIを活用した構築の自動化によって、細かな目的ごとに、本番同様に動く環境を作れるようになりました。今ではレビュー目的に限らず、エンジニア各自が自由な実験を行うためにも用いられ、開発生産性の向上に寄与しています。

採用

Misocaでは日々の開発生産性の向上に取り組みたいエンジニアも募集中です!

*1:この名前と機能は、Heroku Review Appsにインスパイアされたものです