こんにちは。プラットフォーム開発部のthorです。
プラットフォーム開発部では現在、翻訳の基盤システムのリアーキテクチャをしています。このシステムは元々オンプレミス環境向けにScala言語で開発された製品でした。それをみらい翻訳がAI翻訳のSaaSサービスを開始するために、迅速に市場に投入するために最小限のモダナイズのみを実施してSaaSに移行したシステムです。(このような最小の変更でSaaSに移行する方法は『マルチテナントSaaSアーキテクチャの構築』の13.1でも推奨されています) この移行方法の場合、最小限の変更を実施した後に必要なモダナイズをしないと、市場投入後のサービスの成長の障害となります。この翻訳基盤システムの場合、一つのテナントだけが専有するオンプレミスのシステムではなく複数のテナントが利用するSaaSサービスを運用するには機能を拡張しにくかったり、SaaS移行後に弊社の標準として採用したプログラミング言語やフレームワークに合わなくなりしました。そのため、機能拡張の工数が大きくなったり、セキュリティ関連を含めて担当できる開発者が少なかったりする課題が出てきました。この課題を解決するため、基盤翻訳システムのリアーキテクチャを実施する事になりました。
今回のリアーキテクチャでの主な変更点は以下の通りです。
- プログラミング言語: Scala言語、Java言語、およびPython言語とプログラミング言語が混在しているものを、Java言語に統一する。
- コンピューティングサービス: AWS EC2、ECS、およびLambdaと各種サービスを利用していたものを、AWS EKS上で統一して運用する。
- フレームワーク: AkkaとSpring Bootを併用していたものを、Spring Bootに統一する。
- API仕様: 同期処理的なHTTPのAPIを、非同期処理を基本としたリソース指向のREST APIに変更する。
このような大規模なリアーキテクチャには、ストラングラーフィグパターンを利用して段階的に機能を新しいシステムに移行する事が有効です。そして移行を安全に行うためにフィーチャーフラグを利用する予定です。
本記事では、ストラングラーフィグパターンにフィーチャーフラグを利用して、段階的かつ安全に移行する方法をコードを交えながら解説します。
ストラングラーフィグパターンによる移行
ストラングラーフィグパターンとは
ストラングラーフィグパターンは、古いシステム(レガシーシステム)を新しいシステム(モダナイズされたシステム)に段階的に置き換えていくためのソフトウェア開発手法です。
この名前は、熱帯雨林に生息する「絞め殺しのイチジク(Strangler Fig)」という植物に由来しています。このイチジクは、既存の木に絡みつきながら成長し、最終的には元の木を覆い尽くし、枯らしてその場所を置き換えるという特徴があります。この植物の成長プロセスが、レガシーシステムを少しずつ新しいシステムに置き換えていく様子に似ているため、この名前が付けられました。
リアーキテクチャでの移行の概略
現在、翻訳の基盤システムは以下のような構成になっています。
このシステムは、クライアントからのHTTPリクエストを受け付けるファサードを介して、テキスト翻訳システム、ファイル翻訳システム、および用語集(お客様の会社の製品名や役職のような固有の対訳集)管理システムにリクエストを転送します。
移行対象のシステムがHTTPインタフェースを持っている場合に、ストラングラーフィグパターンを利用するときはHTTPリバースプロキシを利用できます。(この方法は『モノリスからマイクロサービスへ』の3.3で解説されています。) 以下の図のようにHTTPリバースプロキシをクライアントと翻訳システムの間に挿入して、新しいシステムで機能が構築できた分のリクエストだけを新しいシステムに転送する事ができます。またこの場合はレガシーシステムのファサードのホスト名をHTTPリバースプロキシのホスト名になるようにDNSの設定を変えるだけでクライアントの変更をせずに移行を開始できます。
上記の図では、HTTPリバースプロキシがクライアントからのリクエストを受け取り、レガシー翻訳システムとモダン翻訳システムに振り分けています。また上図の段階では、モダン翻訳システムは非同期のテキストとファイル翻訳の機能を統合した翻訳システムと用語集管理システムを持つ予定ですが、テキスト翻訳のみが完成しているとします。この場合、用語集を利用したテキスト翻訳リクエスト、ファイル翻訳リクエストおよび用語集管理リクエストはレガシー翻訳システムに転送し、用語集を利用しないテキスト翻訳リクエストはモダン翻訳システムに転送すれば、クライアントに影響なく一部の機能を移行できている状態になります。
モダン翻訳システムが完全に機能するようになった時点で、HTTPリバースプロキシの設定を変更してレガシー翻訳システムへのリクエストをすべてモダン翻訳システムに転送します。これでレガシーシステムを撤去する事ができます。またHTTPリバースプロキシは古いAPIを使い続けるクライアントに対するラッパーとして機能します。
最終的にクライアントがモダン翻訳システムの新しいAPIを利用するようになったら、HTTPリバースプロキシは撤去できます。
一般的には、HTTPリバースプロキシはNginxやHAProxyなどを利用して実装できます。ですが、今回の翻訳のAPIにおいて用語集を使用するかどうかといった情報は、HTTPリクエストのbodyにあるためNginxではLuaによって書かれたコード等で振り分けを実装する必要が出てきます。ですが、今回は馴染みがあり弊社で標準的に利用しているAWS EKS上で稼働するSpring Bootを利用して、HTTPリバースプロキシを実装します。AI翻訳のAPIはディープラーニングを用いたシステムで秒単位のレスポンスタイムを持つ仕様であるため、ミリ秒単位のレスポンス速度の違いはあまり問題にならないと言うのも方式選択の理由にあります。
HTTPリバースプロキシの実装
モダン翻訳システムの実装前段階でのプロキシの実装
移行最初期のモダン翻訳システムが全く実装されていない状態では、HTTPリバースプロキシはレガシー翻訳システムにすべてのリクエストを転送するだけの機能を持つようにします。
ここでレガシー翻訳システムのAPIをご紹介しますと以下のような構成になっています。(詳細は弊社のみらい翻訳 APIサービスのページから仕様へリンクを辿ってご覧ください。)
POST /mt/v2/translate
- テキスト翻訳を行うAPIPOST /mt/v1.0/file/upload
- ファイル翻訳の原文ファイルをアップロードするAPI- その他、ファイル翻訳の進捗確認や訳文ファイルの取得、用語集の管理を行うAPI
HTTPリバースプロキシの実装は、前述の通りSpring Bootを利用して行います。以下は、テキスト翻訳APIのリクエストをレガシー翻訳システムに転送するためのコードの概略です。(実際はもっと複雑ですが説明のため簡略化しています。)
Controllerクラス
@RestController @RequestMapping("/mt/v2") class TranslateController { private final LegacyMtService legacyMtService; // 中略 @PostMapping("translate") public Mono<ResponseEntity<TranslationResponse>> translate( @RequestParam("langFrom") String langFrom, @RequestParam("langTo") String langTo, @RequestParam("profile") String profile, @RequestParam("subscription-key") String subscriptionKey, @RequestBody TranslationRequest request ) { return legacyMtService.forwardTranslationRequest(langFrom, langTo, profile, subscriptionKey, request) .map(ResponseEntity::ok) .doOnSuccess(response -> logger.debug("Translation request processed successfully")) .doOnError(error -> logger.error("Error processing translation request", error)); } }
レガシー翻訳システムへ転送するServiceクラス
@Service public class LegacyMtService { private final WebClient webClient; private final String legacyMtUrl; // 中略 public Mono<TranslationResponse> forwardTranslationRequest( String langFrom, String langTo, String profile, String subscriptionKey, TranslationRequest request) { return webClient.post() .uri(uriBuilder -> uriBuilder .path("/mt/v2/translate") .queryParam("langFrom", langFrom) .queryParam("langTo", langTo) .queryParam("profile", profile) .queryParam("subscription-key", subscriptionKey) .build()) .body(Mono.just(request), TranslationRequest.class) .retrieve() .bodyToMono(TranslationResponse.class) .doOnSuccess(response -> logger.debug("Received translation response from legacy MT service")) .doOnError(error -> logger.error("Error forwarding translation request to legacy MT service", error)); } }
リクエストのbodyクラス
public class TranslationRequest { private List<String> sources; private List<String> udIds; public List<String> getSources() { return sources; } public void setSources(List<String> sources) { this.sources = sources; } public List<String> getUdIds() { return udIds; } public void setUdIds(List<String> udIds) { this.udIds = udIds; } }
用語集を利用しないテキスト翻訳機能がモダン翻訳システムで実装された段階
モダン翻訳システムで用語集を利用しないテキスト翻訳機能が実装された段階では、HTTPリバースプロキシはモダン翻訳システムで処理できる翻訳リクエストをモダン翻訳システムに転送するように変更します。以下のコードのように、TranslationRequest
のudIds
がnullまたは空である場合はモダン翻訳システムに転送し、そうでない場合はレガシー翻訳システムに転送するように変更します。
@RestController @RequestMapping("/mt/v2") class TranslateController { private final LegacyMtService legacyMtService; private final ModernMtService modernMtService; // 中略 @PostMapping("translate") public Mono<ResponseEntity<TranslationResponse>> translate( @RequestParam("langFrom") String langFrom, @RequestParam("langTo") String langTo, @RequestParam("profile") String profile, @RequestParam("subscription-key") String subscriptionKey, @RequestBody TranslationRequest request ) { if (request.getUdIds() == null || request.getUdIds().isEmpty()) { // 用語集を利用しないテキスト翻訳リクエストはモダン翻訳システムに転送 return modernMtService.forwardTranslationRequest(langFrom, langTo, profile, subscriptionKey, request) .map(ResponseEntity::ok) .doOnSuccess(response -> logger.debug("Modern translation request processed successfully")) .doOnError(error -> logger.error("Error processing modern translation request", error)); } else { // 用語集を利用したテキスト翻訳リクエストはレガシー翻訳システムに転送 return legacyMtService.forwardTranslationRequest(langFrom, langTo, profile, subscriptionKey, request) .map(ResponseEntity::ok) .doOnSuccess(response -> logger.debug("Legacy translation request processed successfully")) .doOnError(error -> logger.error("Error processing legacy translation request", error)); } } }
前述のようにモダン翻訳システムは非同期ベースのAPIに変更したため、ModernMtService
クラスは以下のシーケンス図のように非同期のAPIを同期処理に変換してクライアントには同期的なレスポンスを返すように実装します。
こうやってモダン翻訳システムに機能が実装される段階ごとにHTTPリバースプロキシの実装を変更していくことで、段階的にレガシー翻訳システムからモダン翻訳システムへと移行していくことができます。そして最終的には、HTTPリバースプロキシはモダン翻訳システムにすべてのリクエストを転送するようになり、レガシー翻訳システムは完全に置き換えられます。
フィーチャーフラグを利用した移行
HTTPリバースプロキシを利用した段階的な移行は、ストラングラーフィグパターンの基本的なアプローチの一つです。ここでさらにフィーチャーフラグを利用することで、より柔軟かつ安全な移行が可能になります。AI翻訳サービスの翻訳システムのようなサービスの根幹となるシステムでは、フィーチャーフラグを利用して安全に新しいシステムに移行する事は特に重要です。
フィーチャーフラグの利用方法
フィーチャーフラグを以下のように利用することで、柔軟かつ安全に新しいシステムに移行できます。(こちらで解説されている4種類のうち3種類を利用しています)
- デプロイとリリースの分離(Releaseトグル): このフラグがOffの時は、レガシー翻訳システムにリクエストを転送し、Onの時はモダン翻訳システムにリクエストを転送します。これにより、デプロイのタイミングとリリースのタイミングを分離できます。
- 緊急時のロールバック(Opsトグル): Releaseトグルと同様にOnの時は、モダン翻訳システムにリクエストを転送し、Offの時はレガシー翻訳システムにリクエストを転送します。これにより、モダン翻訳システムに問題が発生した場合でも、迅速にレガシー翻訳システムに戻せます。
- カナリアリリース(Experimentトグル): これはフラグというよりも値のリストというもので、該当するユーザのAPIキーの場合は、モダン翻訳システムにリクエストを転送し、その他はレガシー翻訳システムにリクエストを転送します。社内のユーザのAPIキーを登録しておくことで、社内のユーザにのみ新しいシステムを試してもらえます。これにより、問題が発生した場合でも、影響を受けるユーザを限定できます。
- 段階的なロールアウト(Experimentトグル): これもフラグではなく数値で、0から100の値を持ちます。この値が50の場合は、50%の確率でモダン翻訳システムにリクエストを転送し、残りの50%はレガシー翻訳システムにリクエストを転送します。これにより、段階的に新しいシステムへの移行を行うえます。
AWS AppConfigを使ったフィーチャーフラグの実装例
フィーチャーフラグの利用方針について解説できましたので、ここからはAWS AppConfigを利用してデプロイとリリースの分離(Releaseトグル)を実装する方法を解説します。
AWS AppConfig利用時のアーキテクチャ
HTTPプロキシをEKS上のコンテナで実行する場合、以下のようにサイドカーコンテナとしてAWS AppConfigのエージェントを実行することで、フィーチャーフラグの取得を簡単に行うことができます。
AWS AppConfigの設定
AWS AppConfigを利用するためには、以下の手順で設定を行います。
- アプリケーションの作成: AWS AppConfigで新しいアプリケーションを作成します。以下の画面で「アプリケーションを作成する」ボタンをクリックします。
- アプリケーションの詳細の入力: アプリケーションの名前と説明を入力します。名前に「machine-translation」と入力します。(ここには、マイクロサービスや機能モジュールの名前を設定するのが良いでしょう)
- 機能フラグの作成: アプリケーションの作成後、機能フラグを作成します。以下の画面の「設定プロファイルと機能フラグ」タブの中のリストにおいて「機能フラグ」を選択し、「設定を作成」ボタンをクリックします。
- 設定タイプの選択: 設定オプションとして「機能フラグ」を選択します。「設定プロファイル名」には「strangler」と入力し、「次へ」ボタンをクリックします。(ここには、マイクロサービスの中での大きな利用目的等を設定するのが良いでしょう)
- 機能フラグの定義: 機能フラグの定義を入力します。「フラグ名」、「フラグキー」に「release_modern_text_translation」を入力します。「バリアント」は「基本フラグ」を選択し、「これは短期フラグです」にチェックを入れ、「非推奨日」に移行完了予定日を入力します。「フラグの値」は「オフ」にして、「次へ」ボタンをクリックします。(ここでやっと実際のフラグを設定できます。短期フラグになるかどうかは、こちらの「Long-lived toggles vs transient toggles」を参考にすると良いでしょう。)
- 確認して保存: 入力内容を確認し、「保存してデプロイを続ける」ボタンをクリックします。
- デプロイの開始: デプロイの詳細を入力します。「環境を作成」ボタンをクリックし、デプロイ先の環境を作成します。
- デプロイ先の環境の作成: 環境の名前を「dev」と入力し、「環境を作成」ボタンをクリックします。
- デプロイの環境と戦略の選択: デプロイの環境として「dev」を選択し、デプロイ戦略として「AppConfig.AllAtOnce(Quick)」を選択します。「デプロイを開始」ボタンをクリックします。(今回の例では開発環境向けとしてdevを設定しています。ステージング環境や本番環境向けには別の環境を作成します。)
- デプロイの詳細: 以下のようにデプロイのステータスが表示されます。
以上の手順で、AWS AppConfigを利用してフィーチャーフラグを設定することができました。
AppConfigエージェントのためのサービスアカウントの作成
次にEKS上のコンテナがAppConfigのサービスにアクセスできるようにする方法を説明します。まず、AppConfigエージェントがAppConfigのサービスにアクセスするためのIAMポリシーを作成します。最初に以下のappconfig-agent-policy.json
ポリシーファイルを作成します。
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "appconfig:StartConfigurationSession", "appconfig:GetLatestConfiguration" ], "Resource": "*" } ] }
aws iam create-policy \ --policy-name AppConfigAgentPolicyEKS \ --policy-document file://appconfig-agent-policy.json
更に、このポリシーをアタッチしたKubernetesのサービスアカウントを作成します。以下のコマンドでサービスアカウントを作成します。
eksctl create iamserviceaccount \ --name appconfig-agent-sa \ --namespace <プロキシが属するnamespace> \ --cluster <クラスタ名> \ --attach-policy-arn arn:aws:iam::<AWSアカウントID>:policy/AppConfigAgentPolicyEKS \ --override-existing-serviceaccounts \ --approve
これで、このサービスアカウントを使用するコンテナはAppConfigにアクセスできるようになります。
HTTPプロキシのPod定義にAppConfigエージェントを追加
HTTPプロキシのPod定義に、AppConfigエージェントをサイドカーコンテナとして追加します。以下のように、strangler-proxy.yaml
のEKSリソースファイルにappconfig-agent
コンテナを追加します。
apiVersion: apps/v1 kind: Deployment metadata: name: strangler-proxy labels: role: strangler-proxy spec: replicas: 1 selector: matchLabels: app: strangler-proxy template: metadata: labels: app: strangler-proxy role: strangler-proxy spec: serviceAccountName: appconfig-agent-sa # eksctlで作成したServiceAccountを指定 containers: - name: strangler-proxy image: <AWSアカウントID>.dkr.ecr.<AWSリージョン>.amazonaws.com/strangler-fig/strangler-proxy:latest # ECRにプッシュしたHTTPプロキシのイメージ tty: true imagePullPolicy: Always ports: - name: strangler-proxy containerPort: 8080 protocol: TCP - name: aws-appconfig-agent # AppConfig Agentのコンテナ image: public.ecr.aws/aws-appconfig/aws-appconfig-agent:latest # AppConfig Agentの公式イメージ ports: - containerPort: 2772 env: - name: APPCFG_APPLICATION value: machie-translation - name: APPCFG_ENVIRONMENT value: dev - name: APPCFG_CONFIGURATION value: strangler - name: SERVICE_REGION value: us-west-2 # AWSのリージョンを指定 restartPolicy: Always --- apiVersion: v1 kind: Service metadata: name: strangler-proxy spec: type: LoadBalancer ports: - name: "http-port" protocol: "TCP" port: 8080 targetPort: 8080 nodePort: 30082 selector: app: strangler-proxy
HTTPプロキシの実装にAppConfigエージェントからフィーチャーフラグを取得するコードを追加
HTTPプロキシの実装に、AppConfigエージェントからフィーチャーフラグを取得するコードを追加します。以下のように、Serviceクラスを作成し、AppConfigエージェントからフィーチャーフラグを取得します。
@Service public class AppConfigAgentService { private final String applicationName; private final String environmentName; private final String profileName; private final WebClient webClient; // 機能フラグのキャッシュ private volatile FeatureFlagConfig currentFeatureFlags = new FeatureFlagConfig(); // 中略 public AppConfigAgentService(AppConfigAgentConfig appConfigAgentConfig, WebClient.Builder webClientBuilder) { this.applicationName = appConfigAgentConfig.getApplicationName(); this.environmentName = appConfigAgentConfig.getEnvironmentName(); this.profileName = appConfigAgentConfig.getProfileName(); this.webClient = webClientBuilder.baseUrl(appConfigAgentConfig.getAppConfigAgentUrl()).build(); } @PostConstruct public void init() { // アプリケーション起動時に一度設定を取得 fetchConfiguration(); } @Scheduled(fixedDelayString = "${appconfig.pollingIntervalMs:300000}") // デフォルト5分 (300000 ms) public void fetchConfiguration() { String path = String.format("/applications/%s/environments/%s/configurations/%s", applicationName, environmentName, profileName); webClient.get() .uri(path) .retrieve() .bodyToMono(FeatureFlagConfig.class) .subscribeOn(Schedulers.boundedElastic()) // @Scheduledの実行スレッドをブロックしないように別スレッドで実行 .subscribe( config -> { // 成功時の処理 currentFeatureFlags = config; log.info("Successfully fetched AppConfig configuration: {}", currentFeatureFlags); }, error -> { // エラー発生時の処理 log.error("Error fetching AppConfig configuration: {}", error.getMessage(), error); } ); } public FeatureFlagConfig getFeatureFlags() { return currentFeatureFlags; } }
Configurationクラスは以下のように定義します。
@Configuration public class AppConfigAgentConfig { private final String appConfigAgentUrl; private final String applicationName; private final String environmentName; private final String profileName; public AppConfigAgentConfig(@Value("${appconfig.agent.url}") String appConfigAgentUrl, @Value("${appconfig.agent.application}") String applicationName, @Value("${appconfig.agent.environment}") String environmentName, @Value("${appconfig.agent.profile}") String profileName) { this.appConfigAgentUrl = appConfigAgentUrl; this.applicationName = applicationName; this.environmentName = environmentName; this.profileName = profileName; } public String getAppConfigAgentUrl() { return appConfigAgentUrl; } public String getApplicationName() { return applicationName; } public String getEnvironmentName() { return environmentName; } public String getProfileName() { return profileName; } }
Controllerクラスでは、AppConfigAgentService
を利用してフィーチャーフラグを取得し、リクエストの処理を行います。
@RestController @RequestMapping("/mt/v2") class TranslateController { private final LegacyMtService legacyMtService; private final ModernMtService modernMtService; private final AppConfigAgentService appConfigAgentService; // 中略 @PostMapping("translate") public Mono<ResponseEntity<TranslationResponse>> translate( @RequestParam("langFrom") String langFrom, @RequestParam("langTo") String langTo, @RequestParam("profile") String profile, @RequestParam("subscription-key") String subscriptionKey, @RequestBody TranslationRequest request ) { FeatureFlagConfig flags = appConfigAgentService.getFeatureFlags(); if (flags.isReleaseModernTextTranslation().isEnabled() && (request.getUdIds() == null || request.getUdIds().isEmpty())) { // リソースフラグがOnの場合で用語集を利用しないテキスト翻訳リクエストはモダン翻訳システムに転送 return modernMtService.forwardTranslationRequest(langFrom, langTo, profile, subscriptionKey, request) .map(ResponseEntity::ok) .doOnSuccess(response -> logger.debug("Modern translation request processed successfully")) .doOnError(error -> logger.error("Error processing modern translation request", error)); } else { return legacyMtService.forwardTranslationRequest(langFrom, langTo, profile, subscriptionKey, request) .map(ResponseEntity::ok) .doOnSuccess(response -> logger.debug("Translation request processed successfully")) .doOnError(error -> logger.error("Error processing translation request", error)); } } }
HTTPリバースプロキシのデプロイ
HTTPリバースプロキシをEKSにデプロイします。以下のコマンドで、先ほど作成したstrangler-proxy.yaml
ファイルを適用します。
kubectl apply -f strangler-proxy.yaml
これで、HTTPリバースプロキシがEKS上にデプロイされ、AWS AppConfigを利用してフィーチャーフラグを取得できるようになります。
ReleaseトグルをOnにしてモダン翻訳システムを利用する
テキスト翻訳が実装されたモダン翻訳システムがデプロイされたら、AWS AppConfigのコンソールから、先ほど作成した「strangler」設定プロファイルの「release_modern_text_translation」フラグをOnにします。これにより、HTTPリバースプロキシはモダン翻訳システムにリクエストを転送するようになります。
- AWS AppConfigのコンソールで設定プロファイルを開く: AWS Management ConsoleからAppConfigで「machine-translation」アプリケーションを選択し、「strangler」設定プロファイルを選択し、「機能フラグ」で「release_modern_text_translation」を選択し、「編集」ボタンをクリックします。
- 機能フラグの更新: 「フラグの値」を「オン」に変更し、「Update feature flag」ボタンをクリックします。
- バージョンの保存: 「Save version」ボタンをクリックして、変更を保存します。
- デプロイの開始: 「デプロイを開始」ボタンをクリックして、変更をデプロイします。
- 更新のデプロイの開始: 「デプロイ戦略」で「AppConfig.AllAtOnce(Quick)」を選択し、「デプロイを開始」ボタンをクリックして、更新をデプロイします。
これで、HTTPリバースプロキシはモダン翻訳システムにリクエストを転送するようになります。これにより、用語集を利用しないテキスト翻訳リクエストはモダン翻訳システムで処理されるようになります。
まとめ
本記事では、ストラングラーフィグパターンを利用してレガシー翻訳システムからモダン翻訳システムへの移行を段階的に行う方法を解説しました。また、AWS AppConfigを利用してフィーチャーフラグを実装し、柔軟かつ安全な移行を実現する方法も紹介しました。