CDN(CloudFront)でカナリアリリースを実現する

CDN(CloudFront)でもカナリアリリースができます。

# カナリアリリース(canary release)とは

新旧2つのバージョンを同時に稼働し徐々に新バージョンへリクエストを渡すことで、新バージョンに問題が無いことを確認しつつリリースする手法です。

Blue-Greenデプロイがひとつ前のバージョンをホットスタンバイとすることで即座にロールバックできるようにするのに対して、
カナリアリリースは新バージョンのリリースを徐々に行うことで、負荷などの問題が無いことを確認しつつ移行することを目的としています。

「継続的デリバリー」では以下の利点をあげています

* ロールバックの容易さ
* A/Bテストが可能であること
* キャパシティ要件の確認が可能であること

また、英語ですが[MartinFowloer.com](https://martinfowler.com/bliki/CanaryRelease.html)にも説明が有ります。

ただし、これらの要素はフィーチャートグルを使用しても実現でき、そちらの方が容易であるため、通常はフィーチャートグルを利用した方が簡単です。
しかし、フィーチャートグルでは対応できない課題があったため、カナリアリリースを実施しました。

# CDNでカナリアリリース

直面した課題はtypescriptへの移行です。導入の最初は変更範囲を少なくして、安全のためにカナリアリリースを行おうと考えました。
しかし、ここで通常のWebサービスには無い問題が発生しました。
それは、「対象ファイルのパスがユーザーのwebサイト上に埋め込まれている」ということです。しかもCDN(CloudFront)から配信されているため、サーバー側でリクエスト内容を把握することもできません。
そのため、本来はリクエスト情報から新旧の出し分けを行えばよいはずが、リクエスト情報を書きかえることも見ることもができない状況に有りました。
これを解決したのが、[Lambda@Edge](https://aws.amazon.com/jp/lambda/edge/)です。

# Lambda@Edgeを使う

Lambda@Edgeはaws lambdaのインターフェースを通してCloudFrontのエッジ上でパスやオリジンを変更することができる仕組みです。
公式ではA/Bテストをサンプルに用意していますが、同じ要領でカナリアリリースを行いました。

手順は

1. 自ipからのアクセスの場合にのみ、新バージョンを配信する
2. 自サイトからのアクセスの場合にも新バージョンを配信する
3. ランダムに全員に配信する
4. 全員に新バージョンを配信する

という4ステップです。
各ステップで問題がないことを確認しつつステップを移行しました。

通常、カナリアリリースに時間をかけ過ぎると別のリリースに支障が出てしまいますが、
今回は新言語への切り替えを小規模にスタートしたおかげで、別リリースの影響を新旧言語同時に反映することができ問題にはなりませんでした。

以下、コード ([serverless](https://serverless.com/)使用)です。
本来のコードから大きく変更しているためコピペでは動きませんが、やりたいことは伝わると思います。

## 自ipからのアクセスの場合にのみ、新バージョンを配信する

リクエストを受けた時に自IPの場合のみ新ファイルへ飛ばします(この場合はパスにより新旧を分岐)

```ts
export const selectByIp = (event, context, callback) => {
  const request = event.Records[0].cf.request;
  try {
    if (!isTargetUri(request)) {
      callback(null, request);
      return;
    }

    if (comesFromMyIp(request)) {
      request.uri = '/new_version';
    }

    callback(null, request);
  } catch (err) {
    console.error(`Error: "${err}"`);
    callback(null, request);
  }
};

function isTargetUri(request: AwsEdgeRequest) {
    return request.uri === '/old_version';
}

function comesFromMyIp(request: AwsEdgeRequest) {
    // 自ip
    return request.clientIp === '111.111.111.111';
}
```

## 自サイトからのアクセスの場合にも新バージョンを配信する
Refererを基にパスを変更することで、変更の影響を自サイトのみに絞ります

```ts
export const selectByReferer = (event, context, callback) => {
  const request = event.Records[0].cf.request;
  try {
    if (!isTargetUri(request)) {
      callback(null, request);
      return;
    }

    if (comesFromMyIp(request) || comesFromTargetDomain(request)) {
      request.uri = '/new_version';
    }

    callback(null, request);
  } catch (err) {
    console.error(`Error: "${err}"`);
    callback(null, request);
  }
};

function comesFromTargetDomain(request: AwsEdgeRequest): boolean {
    const headers = request.headers;
    if (!headers) return false;
    if (!headers.referer) return false;
    if (!headers.referer[0]) return false;
    if (!headers.referer[0].value) return false;

    const url = URL.parse(headers.referer[0].value);
    return url.hostname === 'www.mysite.com');
}
```

## ランダムに全員に配信する

ランダム要素を追加し、全員に配信するようにします。

```ts
export const selectByRefererAndRandom = (event, context, callback) => {
  const request = event.Records[0].cf.request;
  try {
    if (!(isTargetUri(request))) {
      callback(null, request);
      return;
    }

    if (comesFromMyIp(request) || comesFromTargetDomain(request)) {
      request.uri = '/new_version';
    }

    if (Math.random() < 0.5) {
      request.uri = '/new_version';
    }

    callback(null, request);
  } catch (err) {
    console.error(`Error: "${err}"`);
    callback(null, request);
  }
};
```

## 全員に新バージョンを配信する
旧バージョン用のパスにリクエストが来た場合にも新バージョンを返すようにして、
頃合いを見てLambda@Edgeを外します。

これで、カナリアリリースの終了です。

## 自動化する

今回は試験的な導入であったため、自動化はしませんでしたが、
aws-sdkを通してCloudFrontに使用するLambda@Edge用の関数を変更できるので、同時リリースさえ対処すれば簡単です。

# 面倒な部分

ただ、自動化するにあたり問題になりそうな箇所が有りました。

## 環境変数が使えない

全ての処理をまとめて、設定を基にステップを変更できると楽で良いのですが、Lambda@Edgeでは環境変数を使用できません。
そのため、1~4の場合分けを行うために関数を分けて登録し、実行時に順次関数を切り替えるという作業が必要でした。
しかも、関数の切り替えには10分程度かかるようです。

## リリースが短期間で行われた場合に新旧が入り交じる
関数の切り替えに10分程度かかるせいで、エラー確認などの作業を除いても１回のデプロイに1時間かかります。そのため、次のデプロイが開始される可能性が十分あります。
対策として、各ステップで配信するファイルを全て別のものとして、各デプロイを別ステップで動作するように保証することを考えました。
これを行えば、複数のリリースを同時に進行し、各リリース内容の確認を行えるのですが、配信しているファイルの見通しが悪くなるのでもう少し良い方法が無いかなと考えています。
このあたりはgoogleなどがどのように対処しているか気になるところです

## ロールバック

Invalidationを待つか、関数の切り替えを待つ必要が有ります。ただ、Invalidationがとても速くなっているのであまり影響を心配する必要は無いと考えています。