UPSIDER Tech Blog

1年動き続けるワークフローを壊さずにデプロイする ― Temporal Worker Versioning 導入記

はじめに

こんにちは。請求基盤チームでバックエンドエンジニアをしている西脇です。

私たちは請求・決済まわりのバックエンドで Temporal を使い、長期間にわたって動き続けるワークフローを運用しています。請求のように「翌月の支払い期日まで待つ」といった性質があるため、1 つのワークフローが数か月から年単位で生き続けることも珍しくありません。

この記事では、そうした長寿命のワークフローを止めたり壊したりせずにコードを更新するために導入した、Temporal の Worker Versioning と、その運用を支える Temporal Worker Controller について、つまずいた点も含めて紹介します。

長期ワークフローでは「普通のデプロイ」が通用しない

Temporal はワークフローの実行履歴(イベント履歴)を保存しておき、ワーカーが再起動したときにその履歴を最初から再生(リプレイ)して状態を復元します。これが「途中で落ちても続きから流れる」durable execution の仕組みです。

ところがこの仕組みには制約があります。リプレイ時に、保存済みの履歴とコードが生成する処理の順序が食い違うと 非決定性エラー(Non-Determinism Error) になり、そのワークフローが止まってしまうのです。

ステートレスなマイクロサービスなら、新しいイメージをローリングアップデートで入れ替えれば済みます。しかし長寿命ワークフローの場合、「すでに走っているワークフロー」が古いコードの前提で履歴を持っているため、ワークフローのコードを少し変えるだけで、稼働中のものが次のリプレイで壊れる可能性があります。

実際に私たちも、ワークフローに排他制御を 1 行足しただけで非決定性エラーを踏んだことがありました。危ないのは主に、リプレイ時に生成される処理の順序(Temporal が記録する Command の列)が変わる変更です。アクティビティの中身を変えるだけならリプレイの対象にはなりませんが、ワークフローの処理の流れを変える更新は、versioning の仕組みがない限り稼働中のものを壊しうると気づきました。

Temporal Worker Versioning とは

この問題に対する Temporal の答えが Worker Versioning です。考え方はシンプルで、ワーカーに「バージョン」を持たせ、新旧のバージョンを同時に動かすというものです。

ワークフローごとに、次の 2 つの振る舞いから選びます。

  • Pinned: ワークフローは「起動したときのバージョン」で最後まで実行される。途中でコードが新しくなっても、そのワークフローは古いバージョンのまま完走する
  • AutoUpgrade: ワークフローは、ロールアウトで指定された新しいバージョン(Current / Ramping)へ自動的に移動する。ただし履歴の互換性を保つための patching が必要になる

Pinned では、新しいコードをデプロイしても走っている途中のワークフローは元のバージョンで完走し、新しく始まるものだけが新バージョンを使います。そして古いバージョンは「もう誰も使っていない」状態になってから片付けます。複数バージョンを同時に走らせるこのパターンは、色がたくさんある blue/green ということで rainbow deployment とも呼ばれます。

私たちが選んだ方針

私たちはまず、すべてのワークフローを Pinned で始めました。起動したバージョンで完走させれば、履歴の互換性を気にせず、patching の複雑さも避けられるためです。

ただし Pinned は万能ではありません。紐づくワークフローが終わるまで古いバージョンのワーカーを残し続ける必要があり、その運用コストとのトレードオフになります。ワークフローが「バージョンを残しておきたい期間」より長く動く場合は、AutoUpgrade(+ patching)や、Continue-as-New の境界で新バージョンへ乗り換える方法も選択肢になります。私たちはまず Pinned で始め、必要に応じて AutoUpgrade や Continue-as-New 境界での切り替えを検討する段取りにしました。

加えて、運用方針として次を決めました。

  • ワーカー単位でデフォルトの振る舞いを設定せず、ワークフローの種類ごとに明示的に注釈を付ける。こうすると「付け忘れ」が起動時に検出できる
  • すでに走っているワークフローを新バージョンへ動かしたいときは、コードの分岐ではなくコマンド(CLI のオプション更新)でまとめて移す。ただし移動先のコードが既存の履歴を再生できることが前提で、互換性がなければ patching が必要になる
  • 本番では versioning に必要な設定が欠けていたら起動時に失敗させ、意図せず無印(unversioned)で動くのを防ぐ

段階的に入れる(expand-and-contract)

versioning は一気に切り替えると危険です。古いバージョンに紐づいたワークフローが長く残るため、互換性を保ちながら少しずつ移行する expand-and-contract で進めました。

最初の数ステップは「versioning を ON にしない準備」に費やしています。

  • スケジュール起動の分離: versioning が有効だと複数バージョンのワーカーが並走する。ワーカー起動時にスケジュール定義を書き換えていると競合するため、その処理を起動経路から切り離した
  • 環境変数の互換化: 後で導入するコントローラーが注入する環境変数を、先回りで受け取れるようにした
  • SDK 配線: 全ワーカーに versioning 対応のコードを入れつつ、環境変数が未設定なら versioning 機能は完全に休眠するようにした

ここでの「休眠する」は Temporal の一般的な性質ではなく、versioning を有効化しない設定(無印)へフォールバックさせる私たちの配線です。設定を入れるまでは従来どおり動くため、これが安全な中間状態の肝になりました。私たちはこの挙動を SDK のソースまで読んで確かめ、さらにステージング環境で「無印のまま正常にポーリングしている」ことを実機で確認してから次へ進みました。

rainbow を自動化する ― Temporal Worker Controller

ここで壁になったのが、rainbow をどう運用するかです。「古いバージョンのワーカーを、紐づくワークフローが終わるまで生かしておく」という運用は、1 つの Deployment を更新していく従来のやり方だと手動で回し続けるのが難しいと感じました。

そこで採用したのが、Temporal 公式の Kubernetes operator である Temporal Worker Controller です。新しいイメージを渡すと、バージョンごとの Deployment を立て、新しく始まるワークフローを(設定に応じて段階的に、あるいは一括で)新バージョンへ流し、古いバージョンは紐づくワークフローが片付くまで生かし、不要になったら自動で削除してくれます。

共有クラスタに operator を入れる判断にあたっては、事前に次のような点を一次情報で確認しました。

  • 他サービスへの影響: この operator の admission webhook は自分の専用リソースだけを対象にしており、他チームのリソースには干渉しない
  • 権限: operator が持つ権限は、すでに運用している他の operator と同等の範囲に収まっている
  • コスト: Temporal Cloud の料金はワーカー数やバージョン数では決まらない(Actions と Storage が中心)ため、versioning を入れても Temporal 側の料金は基本的に変わらない。増えるのは主に、複数バージョンが並走する間の追加 Pod 分のクラスタ費用

operator は共有基盤に乗るため、プラットフォームを担うチームと PR 上で方針をすり合わせながら進めました。アプリ側とプラットフォーム側で役割を分担し、レビューを通じて細部を詰めていく形が、この手の横断的な変更ではうまく回ると感じています。

つまずいた点・学び

最後に、進めるなかで効いた学びをいくつか挙げます。

  • 「無害そう」をソースで確かめる: 「設定が無いときに本当に何もしないのか」を、公式ドキュメントの記述だけでなく SDK の実装まで読んで確証を取った。安全策の前提は、思い込みではなく根拠で固めると安心して進められる
  • 数字と主張は裏取りする: operator のリソース使用量のような数字は、サイドカーまで含めて実際の定義から計算し直した。「思い込みの修正」がいくつも見つかり、共有する情報の精度を担保できた
  • rainbow には運用設計が要る: バージョンは増え続けるため、古いものを畳む運用(明示的な移送や自動の掃除)をあらかじめ決めておく必要がある
  • GitOps と operator の同居は要検証: 既存の継続的デリバリの仕組みと operator 製のリソースが衝突しないかは、本番投入の前に小さく試して確かめたい

versioning の本格運用はこれからで、まずはステージングで 1 つのワーカーを対象に小さく検証し、問題がなければ全ワーカー・本番へ広げていく予定です。長寿命ワークフローを抱えるチームの参考になればうれしいです。

法人カード基盤の外部提供の挑戦 技術ブログ バトンリレー

各エンジニア組織が法人カード基盤の外部提供の挑戦の中で得た知見を順次公開していきます!技術、開発設計や思想、Tipsなど、幅広くお伝えします。

▼公開予定表 (各記事公開後、リンク集になります)

タイトル チーム 執筆者
UPSIDERのカード基盤を「外部提供できる形」にするまで——マイクロサービス化とマルチブランドアーキテクチャ 法人カード基盤の外部提供 全体構想 Mitsui
Spanner Change Streams を自前で読む ― 決済データを欠損なく取り込む カード管理基盤チーム(Solaris) Ohsako
1年動き続けるワークフローを壊さずにデプロイする ― Temporal Worker Versioning 導入記 請求基盤チーム(Hermes) nishiwaki
GCP Error Reportingで「最低限の異常検知」を低コストに実現する 法人審査基盤チーム(Hestia) Mitomi
複数の銀行データソースを1つに束ねる ― マルチブランド法人カードを支える銀行連携基盤 銀行連携基盤 terry
仮説駆動で取り組む負荷試験計画 processor Kinsho
AIが実装しレビューする時代に、プロダクトエンジニアは何をするのか お客様向け管理画面チーム(Iris) Akari
法人カード基盤の外部提供にも耐えうる不正利用対策基盤の構築 Anti-fraudチーム Ryutaro
法人カード基盤の外部提供という挑戦 決済基盤を「他社にまるごと使ってもらう」QAの話 QAチーム Naoki

なぜUPSIDERが法人カード基盤の外部提供に取り組むのか、その背景を綴っている記事も合わせてぜひご覧ください。

note.com

We Are Hiring

herp.careers

herp.careers

UPSIDER Engineering Deckはこちら📣

speakerdeck.com