Serverless Framework(CloudFormation)でAPI GatewayのAPIキーを差し替えるためにデプロイが2回必要だった話

# 概要

「Serverless Framework(以下Serverless)で構築したシステム」のAPI GatewayのAPIキーをローテーションするというミッションがありましたが、デプロイを二段階で行う必要があったため備忘として経緯と解決方法をまとめます。

# 経緯

## 構成

![old-api_key.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8002/cfbd1344-3409-02a9-91a9-41bc8649cec8.png)

図のとおりAPIキーの値はAPI Gatewayによる自動生成機能を使わず、SSMパラメータストアに保管している文字列を取得して設定するようにしています。

## やろうとしたこと

今回はいくつかの事情があり、一時的に新旧のAPIキーが並存する期間を設けることにしました。

- 新しいAPIキーは古いAPIキーとは異なる名前で作成する
- ローテーション対象の古いAPIキーはすぐに削除せず残しておく
- 一定期間経過した後古いAPIキーを削除する

古いAPIキーを残しつつ新しいAPIキーを作成するため `serverless.yml` は以下のように追加/変更すればよいだろうと考えました。

```diff
  apiGateway:
    apiKeys:
      - name: "old-api_key"
-       value: ${ssm(${self:custom.regions.main}, raw):/${self:service}/${sls:stage}/APIGW_API_KEY}
+       value: ${ssm(${self:custom.regions.main}, raw):/${self:service}/${sls:stage}/APIGW_API_KEY_OLD}
+     - name: "new-api_key"
+       value: ${ssm(${self:custom.regions.main}, raw):/${self:service}/${sls:stage}/APIGW_API_KEY}
```

既存の `old-api_key` の下に新しいAPIキーの行を追加しています。
Serverlessの変数を並べているためvalueの値が長いですがSSMパラメータの名前を指定しているだけです。

上の要件に書いたようにローテーションとはいっても古いAPIキーは残すため、キー文字列のソースとなるSSMパラメータ `APIGW_API_KEY` の扱いを考えないといけません。
このSSMパラメータ名は最新のAPIキー用に使いたいので古いAPIキーには新たに `APIGW_API_KEY_OLD` という名前のSSMパラメータを用意し、そこに古いキーを保存することにしました。

| APIキー名 | 【変更前】キー値取得用SSMパラメータ | 【変更後】キー値取得用SSMパラメータ |
| --- | --- | --- |
| new-api_key | - | APIGW_API_KEY |
| old-api_key | APIGW_API_KEY | APIGW_API_KEY_OLD |

Serverless(CloudFormation)から見ると `old-api_key` のキー文字列の参照先が変更されるという形になります。

## 実際の挙動

### 1度目の実行 → 失敗

さっそく開発環境で `sls deploy` してみたところ、エラーとなり失敗しました。
Serverlessの裏で実際のデプロイ処理を実行しているCloudFormationのイベント履歴を確認します。
上のほうが新しいメッセージです。

```plain
[論理ID]             [ステータス]             [状況の理由]
ApiGatewayApiKey1    UPDATE_FAILED         Resource handler returned message: "ApiKey with name old-api_key already exists" (RequestToken: xxxx, HandlerErrorCode: AlreadyExists)
----
ApiGatewayApiKey1    UPDATE_IN_PROGRESS    Requested update requires the creation of a new physical resource; hence creating one.
```

"UPDATE_FAILED" とあり古いAPIキーの更新で失敗しているようですが、「更新」にもかかわらず「既に同名のAPIキーが存在している」のが原因とされています。
しかしその下の一つ前のメッセージを見ると「要求された更新には新しい物理リソースの作成が必要」と書かれています。
つまりこのAPIキーの「更新」は実際は新規作成扱いとなるようで、上のエラーメッセージに繋がるということが理解できました（ステータスが "UPDATE_FAILED" なのはやや誤解を招きそうですが。。）
既存のAPIキーの値は更新できず、また同名のAPIキーを新規作成し上書きするような形もとれないことがわかりました。

### 古いキーを手動で削除して再実行 → 失敗

同名のキーで上書き保存できないのであれば、先に古いAPIキーを削除しておかないといけません。
そこで `old-api_key` をAWSマネジメントコンソールから手動で削除し再度デプロイしてみたところ、今度はServerless上で "Invalid API Key identifier specified" というエラーになり失敗しました。
正確にはわかりませんでしたがエラー文の意味から「(手動でAPIキーを削除したため)CloudFormationスタックで指定されているIDに対応するキーが存在していない」ことが原因ではないかと思われます。

## 成功した方法

となるとAPIキーのキー参照先の変更をServerless(CloudFormation)で行う場合、以下のようにデプロイは2回必要であることになります。

1度目のデプロイ … 古いAPIキーの削除
2度目のデプロイ … 古いAPIキーの復活と新しいAPIキーの作成

まずは古いキーを削除するため `serverless.yml` のAPIキー作成部分をコメントアウトします。

```yaml
  apiGateway:
#   apiKeys:
#     - name: "old-api_key"
#       value: ${ssm(${self:custom.regions.main}, raw):/${self:service}/${sls:stage}/APIGW_API_KEY_OLD}
#     - name: "new-api_key"
#       value: ${ssm(${self:custom.regions.main}, raw):/${self:service}/${sls:stage}/APIGW_API_KEY}
```

この状態でデプロイすると古いAPIキーが削除されました。
次にコメントを外して元の状態に戻してからデプロイすると無事新旧両方のAPIキーが作成されました。

```yaml
  apiGateway:
    apiKeys:
      - name: "old-api_key"
        value: ${ssm(${self:custom.regions.main}, raw):/${self:service}/${sls:stage}/APIGW_API_KEY_OLD}
      - name: "new-api_key"
        value: ${ssm(${self:custom.regions.main}, raw):/${self:service}/${sls:stage}/APIGW_API_KEY}
```

## その他

問題点としてgit-flowのような仕組みでシステムのリリースを行なっている場合、この方法ではコード変更のないまま2度のデプロイをするため同一のコミットに対し2回タグ付けを行わないといけなくなります。
もっと良い方法が見つかったらまた記事にしたいと思います。