GKE上で本番稼働中のサービスを別Google Cloudプロジェクトへダウンタイム無しで移行する

今回はGKEクラスタで運用中のサービスを別のGoogle Cloudプロジェクトに移す方法を紹介します。
当初簡単に考えていたのですが、思いがけず罠があったので紹介します。
# はじめに
## 要件
現在GKE上で稼働しているサービスを別のGoogle Cloudプロジェクにダウンタイム無しで移行したい。

## 構成
サービスは[Google Kubernetes Engine（GKE）](https://cloud.google.com/kubernetes-engine)の[Pod](https://kubernetes.io/ja/docs/concepts/workloads/pods/)上で稼働しており、APIのアクセスポイントが[Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/)経由で外部に公開されています。
DNSはGoogle Clloudの[Cloud DNS](https://cloud.google.com/dns)で管理、TLS/SSL証明書は[Google マネージド SSL 証明書](https://cloud.google.com/load-balancing/docs/ssl-certificates/google-managed-certs)を使用しています。
この状態でサービスを別のGoogle Cloudプロジェクトへ移行することとなりました。

# 移行前の準備
## 移行前の計画
移行前に考えていたのは次の流れでした。
移行用の新しいドメインを作る。
移行先のGoogle CloudプロジェクトのGKEクラスタに既存と同じ構成を用意する。
どちらのPodでもリクエストを受け付けられる状態にして、リクエストもとのドメインを移行先のドメインに切りかえる。
これでシンプルに移行できる予定でした。

## 計画の頓挫
しかし、詳細を検討していくと問題があることに気づきました。

### サービスがステートフルだった
移行予定のサービスではユーザーからのリクエストによって内部状態が変更されるステートフルなサービスでした。
移行期間中に新旧クラスタへのアクセスが混在したときにも、状態を一貫して保持する必要があります。

### 一部リクエストでドメインを切り替えられない
運用上の問題で一部サービスを呼び出している部分についてドメインを切り替えられないことが判明しました。
つまり旧ドメインへのアクセスが残り続けます。

## 新しい計画
上記の問題に対応すべく新しい計画を考えました。

### ステートフルなサービスへの対応
複数のクラスタ間で内部状態を同期出来るように内部状態を提供する新たなAPIを追加しました。
内部状態に依存した処理を実行した時に以前の状態が存在しない場合は他方のクラスタにリクエストして内部状態を取得するようにしました。
これにより、新旧クラスタへのリクエストが混在しても内部状態が一貫するようになりました。
このAPIは外部に提供したくないため[Cloud Armor](https://cloud.google.com/security/products/armor)を使用してアクセス元を制限しています。

### リクエスト元のドメインを切り替えられない問題の対応
旧ドメインへのアクセスについて、新ドメインへリダイレクトする方法と、旧ドメインのDNSを書き換えて新クラスタへアクセスさせる方法を検討しました。
前者であればリダイレクトするだけなので対応は簡単ですが、リダイレクトする仕組みが残り続けると余計な管理コストが発生します。また少なからずレスポンスタイムも悪化することになります。
一方でDNSの書き換えは移行作業自体が大変でも、作業完了後はより理想的な状況となります。
そこで後者、DNSの書き換えを採用します。

## 実行中のサービスでDNSを切り替えるときの問題
### DNSの切り替えだけでは不十分
DNSの切り替えはCloud DNSのAレコードを新クラスタに切り替えるだけではありません。
新クラスタに旧ドメインのTLS/SSL証明書をもたせる必要があります。
旧ドメインのTLS/SSL証明書がないままDNSを切り替えるとブラウザからなりすましと判断され通信が行えません。

TLS/SSL証明書はGoogle マネージド SSL 証明書を使うことで更新作業などを心配することなく管理することができます。
terraformで旧ドメインのGoogle マネージド SSL 証明書の発行依頼をかけるには次のように行います。

```terraform
resource "google_compute_managed_ssl_certificate" "legacy-domain-cert" {
  name     = "legacy-domain-cert"
  provider = google-beta
  managed{
    domains = ["old.example.com"]
  }
```

### 新Google Cloudプロジェクトで証明書が発行されない
このリクエストはいつまでたっても`Progressing`のままとなり、TLS/SSL証明書の作成は行われません。
新プロジェクトが旧ドメインの持ち主であることを証明できていないためです
この証明なくTLS/SSL証明書が発行されると誰でも好き勝手に好きなドメインを処理できるので証明書の意味がなくなってしまいます。

### 稼働中のサービスでGoogle マネージド SSL 証明書を発行するとダウンタイムが発生する
新Google CloudプロジェクトでGoogle マネージド SSL 証明書を発行してもらうにはDNSの向き先を新Goolge CloudプロジェクトにあるIngressなどのロードバランサーに向ける必要があります。

しかし、この状態でDNSを切り替えると旧ドメインへのアクセスが新クラスタに向いてしまいます。
新クラスタにはまだ旧ドメインのTLS/SSL証明書がないためブラウザはなりすましと判断しSSLエラーを出してしまうでしょう。

つまり、DNSを切り替えるには事前に旧ドメインのTLS/SSL証明書を新クラスタに発行してもらう必要があるが、旧ドメインのTLS/SSL証明書を新クラスタに発行してもらうには事前にDNSを切り替える必要がある。というデッドロック状態になってしまいました。

サービス停止が許されるのであれば、DNSを切り替えてGoogle マネージド SSL 証明書の発行依頼をかけるという選択肢もありえます。
この場合DNSを切り替えてからGoogle マネージド SSL 証明書が発行されIngressに設定するまでの間サービスを受け付けられなくなります。
さらなる注意点としてこのときメンテナンス画面などを表示することもできず該当ドメインへのアクセスはすべてSSLエラーになります。

## Google マネージド SSL 証明書はエクスポートできない
そこであらかじめ旧GKEクラスタで使用しているTLS/SSL証明書を新Google Cloudプロジェクトに登録しようとしました。
しかし、Google マネージド SSL 証明書にはエクスポートする機能がなく旧クラスタのTLS/SSL証明書を取得できませんでした。
ダウンタイムは許容したくなかったため、別の方法を検討します。

# Let's encryptを使った移行
## 別の証明書サービスを使う
Google マネージド SSL 証明書はエクスポートができませんが、GKEはGoogle マネージド SSL 証明書以外の独自に用意した証明書を使用可能です。
そこでエクスポート可能な証明書を用意して移行することにしました。
今回は無料で使える[Let's encrypt](https://letsencrypt.org/)を使用します。

Let's encryptもドメインの所有確認を行う必要があります。
ドメインの確認は[DNS-01](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge)を採用しました。
この方法もDNSを使用するため、TLS/SSL証明書は旧ドメインのDNSが向いている旧クラスタで発行します。
証明書の発行にはKubernets上でLet's encryptの証明書発行を自動で行える [cert-manager](https://cert-manager.io/)を使用しました。
cert-managerの使用方法はesakaさんの[GKEのIngressでLet's Encryptの証明書を利用できるようにする](https://zenn.dev/esaka/articles/59a3142a60dbe7)という記事がとても参考になりました。

## 証明書の移行
cert-managerで取得した証明書は`certificate.yml`の`secretName`に記載した名前で`secret`に格納されるため、`kubectl`コマンドで取り出します。
crt部分に証明書が格納され、key部分に秘密鍵がbase64でエンコードされて格納されているので、それぞれbase64にデコードして取得します。
```bash
kubectl get secret <SECRET_NAME> -n cert-manager -o jsonpath='{.data.tls\.crt}' | base64 --decode > cert.pem 
kubectl get secret <SECRET_NAME> -n cert-manager -o jsonpath='{.data.tls\.key}' | base64 --decode > cert.key
```

新Google Cloudプロジェクトの[Certificate Manager](https://console.cloud.google.com/security/ccm/list/lbCertificates) で[証明書を追加](`https://console.cloud.google.com/security/ccm/certificates/add?`)を選び、セルフマネージド証明書を作成する を選んで先ほど取得した証明書をアップロードします。

新クラスタのIngressで `ingress.kubernetes.io/ssl-cert:` に移行用のTLS/SSL証明書を *追加* します。
このとき現在使用しているTLS/SSL証明書を上書きしないように注意してください。現在使用しているTLS/SSL証明書は新ドメイン向けの証明書のため、これを削除すると新ドメインへのアクセスがSSLエラーを起こすようになってしまいます。

カンマ区切りで複数の証明書を指定します。
```yaml
ingress.kubernetes.io/ssl-cert: new-domain-cert,migrate-cert
```

これで旧ドメインへのアクセスを新クラスタで受け付けられるようになります。
## DNSの移行
DNSの向き先を新クラスタのIngressに変更します。

```terraform
resource "google_dns_record_set" "api-record" {
中略
  type         = "A"
  rrdatas      = ["<新クラスタIngressのIPアドレス>"]
}
```

DNSが切り替わると次第に旧ドメインへのリクエストが新クラスタへ流れてくるようになります。
DNSはキャッシュによりアドレスが浸透するまで時間がかかるため、旧クラスタへのアクセスが完全になくなるまでしばらく待ちます。
余裕があれば72時間以上待つのが望ましいです。

## Google管理証明書の作成
Let's Encryptは証明書の有効期間が短く短期間での更新が必要です。
今回はcert-managerを使いましたが、cert-managerを実行しているのは旧クラスタのため次回の更新は失敗します。
そこで、証明書をGoogle管理証明書に切り替えます。
Google管理証明書を使用することで証明書の更新などをマネージドに任せることが可能です。
すでにDNSの向き先が新クラスタに変わっているので、新Google CloudプロジェクトでGoogle管理証明書を発行することができます。
```terraform
resource "google_compute_managed_ssl_certificate" "legacy-domain-cert" {
  name     = "legacy-domain-cert"
  provider = google-beta
  managed{
    domains = ["old.example.com"]
  }
```

[Certificate Manager](https://console.cloud.google.com/security/ccm/list/lbCertificates)を開き、証明書が `FAILED_NOT_VISIBLE` となっているのを確認します。
これは、まだ新しいGoogle管理証明書をIngressに登録していないためです。
移行用の証明書を登録したときと同じように、新しく作成した旧ドメイン用のGoogle管理証明書をingressに追記します。
```yaml
ingress.kubernetes.io/ssl-cert: new-domain-cert,migrate-cert,legacy-domain-cert
```
このときも移行用の証明書を登録したときと同じように、新ドメインや移行用の証明書を削除しないように注意してください。

Certificate Managerに戻り証明書が有効になるのを待ちます。

## 移行用証明書の削除
Google管理証明書が有効になったら、IngressとCertificate Managerから移行用の証明書を削除します。
```yaml
ingress.kubernetes.io/ssl-cert: new-domain-cert,legacy-domain-cert
```
急ぐ必要はないですが、有効期限が切れる前に削除する必要があります。
certbotを使用して移行用の証明書を失効させます。
```bash
certbot revoke --cert-path cert.pem
```

## 旧クラスタのお掃除
これで移行作業は完了しました。
旧クラスタを削除することができます。
移行用に作成した状態を同期するためのAPIやCloud Armorの設定も削除しておきます。

# おわりに
今回Google Cloudプロジェクトを移行するという比較的シンプルな要件に対して思ったより罠が待ち構えている感じでした。
参考にさせていただいた方やご助力いただいた方に感謝したいと思います。
Google Cloudプロジェクトをダウンタイム無しで移動させたいと思ったときは思い出していただけると嬉しいです。