こんにちは。弁護士ドットコム クラウドサイン事業本部で SRE をしています、大内と申します。 クラウドサイン事業本部の SRE ではサービスの可用性、信頼性の向上や開発の高速化、省力化を目指した開発を日々行っています。
クラウドサインは 2024 年 10 月で 10 年目のサービスとなりました。 裏ではさまざまなアプリケーション(定期実行バッチ、常駐バッチ、内部 API サーバーなど)が稼働し、相互に連携してサービスを提供しているのですが、中には非常に古くから稼働しているものも存在します。 今回お話する nginx もその 1 つです。
クラウドサインの裏で稼働するアプリケーションのほとんどはコンテナで動作しています。 基盤としては AWS Fargate を使用しています。 昔は EC2 で稼働していたさまざまなアプリケーションも、サービス提供しながら裏でひっそりと Fargate に移行していきました。 nginx は、そんな Fargate 移行対象の中の最後のアプリケーションでした。
今回は、そんな EC2 上で稼働していた nginx をゼロダウンタイムで Fargate に移行したお話をご紹介します。
背景
EC2 から Fargate に移行したかった理由は大きく 2 つありました。
- EC2 の保守に工数がかかる
- nginx の設定管理に Ansible を使っていて、これの実行が SRE でないとできない
EC2 を使う場合の悩みごとは、パッチ適用です。 nginx しか動かす必要がなくとも、EC2 上にはその他にもさまざまなプロセスが稼働しています。 セキュリティパッチなどを適用する際は nginx 以外のプロセスにも影響が出ていないか確認しなければなりません。
また nginx の設定管理をしている Ansible を SRE しか実行できないのも課題でした。 SRE にしかできない作業をなるべく減らし、エンジニアなら誰でも設定変更できる環境にしたかったのです。
これらの問題を解決するために、以下 2 つの対応をすることにしました。
- nginx を EC2 から Fargate に移行する
- CI から nginx をデプロイできるようにする
移行前と移行後
まず、移行前のインフラ構成はおおむね以下のような構成となっていました。 VPC 内に ALB と EC2 が存在しており、一般ユーザーは ALB 経由で EC2 にアクセスします。 そして、EC2 の保守作業のため、SRE のみ Ansible を使って EC2 のプロビジョニングができるようになっていました。
上記インフラは、移行により以下のような構成となりました。 EC2 は Fargate に変わり、SRE によるプロビジョニングは GitLab CI + CodePipeline + CodeBuild による自動デプロイに変わりました。 GitLab CI からリリース対象のソースコードを S3 にアップロードし、CodePipeline がそれをトリガーにデプロイを開始します。 デプロイ操作はエンジニアと SRE のどちらも可能になっています。 便宜上、下図の「エンジニア」は「SRE 以外のエンジニア」として記載しています。
移行手法
前述の Fargate への移行には、Amazon Route 53 の加重ルーティングを使用しました。
加重ルーティングは、1 つのドメインに対して複数のリソースを登録し、それぞれのリソースにルーティングするトラフィック量を指定できる機能です1。 いわゆる DNS ラウンドロビンをより便利にした機能です。
移行では、加重ルーティングを使って nginx とその前段にいる ALB を丸ごと切り替えるようにしました。 Fargate 版 nginx の手前にも ALB を配置し、ALB のドメインを重量調整により少しずつ切り替えていきました。
大まかな移行の流れは、以下のとおりです。
- 旧 nginx 用の ALB(以降、旧 ALB)の Route 53 レコードの TTL を小さい値に変更する
- 旧 ALB のレコードのルーティングポリシーを「シンプル」から「加重」に変更する
- このとき、重量を 100 にする
- 新 nginx 用の ALB(以降、新 ALB)ドメインを Route 53 にルーティングポリシー「加重」で登録する
- このとき、重量を 0 にする
- 新 ALB のレコードの重量を 1 〜 200 に徐々に増やしつつ経過観察する
- 旧 ALB のレコードの重量を 0 に変更して、数週間ほど経過観察する
- 旧 ALB のレコードを削除する
- 旧 nginx のリソースを削除する
- 新 ALB のレコードのルーティングポリシーを「シンプル」に変更する
- 新 ALB の Route 53 レコードの TTL を元の値に戻す
これを図で表現すると次のようになります。
以降はこれらの詳細を説明します。
TTL を小さい値にする
DNS レコードの TTL は、レコードのドメインに対応する IP アドレスの問い合わせ結果をキャッシュしてよい時間(秒)を表します。
DNS レコードの値を変更したり、複数値登録する際は、TTL を短い値にするのが一般的です。
TTL が大きい場合、キャッシュされた問い合わせ結果は長い間保持されます。 つまり「レコードの値を変更したのに、古いキャッシュの値が使用され続けていつまでも新しい値が使用されない」といった事象が起こります。
今回の件でも、ALB を切り替えるのに DNS レコードを使用するため、TTL を小さい値に設定しました。
ルーティングポリシーを「加重」にする
Route 53 レコードのルーティングポリシーを加重に変更することで、加重ルーティングが利用できます。
加重ルーティングを使う場合、同一ドメインで複数レコード登録して使うことになりますが、レコードが 1 つだけでも問題ありません。 トラフィック量を指定するためのパラメータ「重量」には、1 以上の値を設定すれば良いのですが、今回はトラフィック量を細かく調整したかったため 100 にしました。
重量によるトラフィック量の計算式は、AWS 公式で公開されています[^1]。 その計算式は「特定のレコードの重量 ÷ すべてのレコードの重量の合計」です。
例えば「重量が 1 のレコード old」と「重量が 2 のレコード new」が存在する場合を考えます。
レコード old のトラフィック量は 1 / (1 + 2)
となるため、3 分の 1 です。
逆にレコード new のトラフィック量は 2 / (1 + 2)
となるため、3 分の 2 です。
このため、少しずつトラフィック量を増やしたい場合は、片方の重量を大きい値にします。
今回のケースでは、旧レコードの重量を 100 にし、新レコードの重量を 1 から始めたため、101 分の 1 のトラフィック量から始まります。 新レコードの重量が 100 になると、200 分の 100、つまり 2 分の 1 となるので、新旧レコードのトラフィック量は均一になります。
このように徐々に新 ALB へのトラフィックを増やしていき、最終的にはすべてのトラフィックを新 ALB に切り替えます。
もし切り替え後に何か問題が見つかった場合は Route 53 レコードの重量を変更して、旧レコードを 100、新レコードを 0 にすればロールバックは完了です。
なお実際の移行作業では、監視系から以下を確認しながら作業しました。
- 監視系のアラート有無
- ALB のレスポンスコード、レイテンシ
- nginx のログ、ステータス、レイテンシ
- nginx の後続アプリケーションにアクセスが到達しているか
- 移行前の nginx と移行後の nginx のトラフィック量の変化
デプロイ環境の整備
EC2 から Fargate に切り替えた後のデプロイでは GitLab CI + CodePipeline + CodeBuild を使うようにしました。 GitLab CI では簡単なテストを実行し、テストがパスした場合のみ CD (CodePipeline) を実行するようにしています。
CI ではそこまで複雑なテストはしていません。以下の 2 つをテストしています。
- nginx の Docker イメージのビルドが通ること
- nginx の設定ファイル検証(
nginx -t
)が通ること
これで設定不備は最低限検出できるようになりました。 今までは Ansible を使う際に SRE が都度手動でチェックしていたのですが、それらを CI に寄せられました。
テンプレートエンジンを gomplate に変更
Ansible を使わなくするため、テンプレートエンジンをどうするかが問題となりました。
Ansible では、テンプレート機能を使うことで設定ファイルの内容を環境ごとに切り替えることが可能です。 Ansible が使用しているテンプレートエンジンは Jinja2 です2。
Jinja2 の機能により、例えば環境ごとに値を変更したい場合は以下のように記述できます。
numprocs={{ numprocs }}
また条件分岐によりコード自体の切り替えも可能です。
{% if enable_flag %} command=application --flag1 --flag2 --flag3 {% else %} command=application {% endif %}
Jinja2 は Ansible 専用の機能ではなく、独立したテンプレートエンジンであるため、Jinja2 だけインストールして使用できます。 そのため、以下の 2 つの選択肢がありました。
- Jinja2 を使って今の設定ファイルを可能なかぎりそのまま引き継ぐ
- 別のテンプレートエンジンに切り替えて、設定ファイルも修正する
私は 2 を選択し、テンプレートエンジンを gomplate 3 に変更しました。 gomplate の採用理由は、以下の 3 つです。
- gomplate は単一の実行コマンドのため、簡単にインストールして使用できる
- テンプレート構文は Go 言語標準の HTML テンプレートとほぼ同じため、クラウドサインのエンジニアに馴染み深く扱いやすい
- 開発が活発に行われている
前述のとおり、エンジニアも設定変更しやすくしていきたかったため、クラウドサインのエンジニアが馴染み深い構文でテンプレートを記述できる gomplate は最適でした。 構文も細かい部分で Jinja2 と異なりますが、そこまでコードの修正も難しくありませんでした。
例えば、以下のように、環境変数を埋め込むことも、環境変数で条件分岐も可能です。
numprocs={{ .Env.NUMPROCS }} {{ if eq .Env.ENABLE_FLAG "true" -}} command=application --flag1 --flag2 --flag3 {{- else -}} command=application {{- end }}
このテンプレートに対して gomplate を実行すると、次のようになります。 環境変数の埋め込みも、条件分岐も処理されていることが分かります。
$ export NUMPROCS=2 $ export ENABLE_FLAG=true $ gomplate -f sample.ini.tmpl numprocs=2 command=application --flag1 --flag2 --flag3
gomplate で環境変数にアクセスするときの注意点
range
内で .Env
の環境変数にアクセスしようとするとエラーになります。
以下のような range
を使ったテンプレートに対して gomplate を実行してみます。これは成功します。
{{- range $i, $v := coll.Slice "hello" "world" -}} i: {{ $i }}, v: {{ $v }} {{ end -}}
⟩ gomplate -f sample.ini.tmpl i: 0, v: hello i: 1, v: world
この range
内で、環境変数にアクセスしようとすると、エラーになります。環境変数へのアクセスを追加します。
{{- range $i, $v := coll.Slice "hello" "world" -}}
i: {{ $i }}, v: {{ $v }}
+app: {{ .Env.APP }}
{{ end -}}
$ export APP=sample $ gomplate -f sample.ini.tmpl i: 0, v: hello app: 11:58:42 ERR err="renderTemplate: failed to render template sample.ini.tmpl: template: sample.ini.tmpl:3:12: executing \"sample.ini.tmpl\" at <.Env.APP>: can't evaluate field Env in type interface {}"
これは、Go のテンプレートエンジンの仕様として、{{ . }}
でループ変数などにアクセスできる構文と衝突しているからと思われます。
以下のように {{ . }}
を呼び出してみると、$v
と同じ値が埋め込まれます。
{{- range $i, $v := coll.Slice "hello" "world" -}} i: {{ $i }}, v: {{ $v }} app: {{ . }} {{ end -}}
$ gomplate -f sample.ini.tmpl i: 0, v: hello app: hello i: 1, v: world app: world
そのため range
内で .Env.APP
にアクセスしようとすると、
前述の例では文字列型に対して Env フィールドにアクセスしようとするためエラーになります。
これを回避するには、Env を別の変数に移してアクセスすることで回避できます。
以下のように $env
変数などを定義して、$env
経由で環境変数にアクセスできます。
{{- $env := .Env -}} {{- range $i, $v := coll.Slice "hello" "world" -}} i: {{ $i }}, v: {{ $v }} app: {{ $env.APP }} {{ end -}}
$ gomplate -f sample.ini.tmpl i: 0, v: hello app: sample i: 1, v: world app: sample
移行結果
移行は無事成功しました。 事前に検証環境でリハーサルをして問題なく切り替えられることは確認済みでしたが、それでもやはり本番環境の切り替えはドキドキする作業です。
日中帯での切り替え作業でしたが、特に可用性、スループット、レイテンシのいずれも大きな変化はなく、ゼロダウンタイムで移行作業を終えることができました。
経過観察の期間の後、旧 nginx, EC2, ALB など諸々のリソースを削除しました。 nginx の設定はすべて Docker で管理するようになり、CI/CD を整備したことで SRE 以外も設定を修正できるようになりました。 当初課題に感じていたすべては解消できました。
まとめ
内容をまとめると、以下のとおりです。
- nginx を EC2 から Fargate に移行する背景を説明した
- EC2 の保守に工数がかかる
- nginx の設定変更が SRE しかできない
- 移行前後のインフラ構成について説明した
- 移行手法について説明した
- Route 53 の加重ルーティングを使って、徐々に重量を調整して切り替えた
- nginx の CI/CD を整備した
- GitLab CI + CodePipeline + CodeBuild で自動デプロイするようにした
- 設定ファイルのテンプレートエンジンを Jinja2 から gomplate に変更した
- 移行結果について説明した
- 可用性、スループット、レイテンシに影響を与えること無く、ゼロダウンタイムで切り替えを完了した
以上です。
- Amazon Route 53 - 加重ルーティング, 2024-09-04↩
- Templating (Jinja2) - Ansible Community Documentation, 2024-09-09↩
- hairyhenderson/gomplate - GitHub, 2024-09-09↩