ダウンタイムを抑えてAWSからGoogle Cloudにデータベースを移行したはなし

こんにちは。kimihiro_nです。

今回はプロダクトで使用しているデータベース(MySQL 互換)を AWS から Google Cloud に引っ越ししたときのはなしを紹介します。

AWSから Google Cloud へ

AWS では MySQL 5.7 互換の Aurora グローバルデータベースを利用していました。

グローバルデータベースを使っているのは、大規模災害時におけるリージョンレベルでの障害に備えるためのもので、万一リージョンレベルの障害が発生してもサービス継続できるような体制を作っていました。

今回ある事情から Google Cloud の CloudSQL へのお引っ越しを行い、同じようにホットスタンバイでのマルチリージョン構成を構築することになりました。

なぜ AWS から Google Cloud に

恐らく一番気になるのがこの理由の部分かもしれませんが、大人の事情ということで詳細は伏せさせてください。 大きな理由としてはコストなのですが、Google Cloud に移行した方が絶対的に安くなるというわけではないので、いろいろ総合的な判断をした結果ということになります。 データ基盤が BigQuery 上で構築されていて連携がしやすいみたいな分かりやすいメリットもあります。

ちなみに Aurora は2017年くらいから利用していますが、ここ数年データベース由来での障害が発生していないので非常に優秀なデータベースサービスだと思います。

AWS から Google Cloud に移行する上での調査

どちらも MySQL 互換とはいえ Aurora と Cloud SQL では仕組みに違いがあります。

違いについてはこちらの Google Cloud 公式のドキュメントに既にまとまっていたので、こちらをよく確認し問題がないかを検証しました。

ここでの一番の懸念点はパフォーマンスでした。Aurora は独自にチューニングされて高いスループットが出るようになっているため、移行した途端データベースが悲鳴をあげてしまうことが考えられました。とはいえパフォーマンスのうたい文句などから推測で判断することも難しいです。

なので実際 Cloud SQL に建てたデータベースを用意してLocustを用いた負荷試験を実施しました。シナリオ自体は以前にDBの負荷をみるために作ったものがあったので、今回はそれを流用し Aurora に繋いだときと Cloud SQL に繋いだときとでレスポンスタイムの比較を行いました。結構重めのクエリも入っていたのですが Aurora のときと大して遜色なく捌く事ができて一安心でした。

プライマリ↔レプリカ間の同期ラグについても検証してみましたがこちらも許容範囲内でした。

どうやって移行したか

移行可能ということが確かめられたので実際の移行手順について検討をしていきました。

移行する上での前提

  • 少ないダウンタイムでの移行

災害・事件・事故といったリスク情報を速く伝えるサービスなので、ユーザーが利用できなくなってしまう時間は極力減らしたいです。とはいえ完全に無停止でやろうとすると移行の工数と難易度が跳ね上がってしまうため、メンテナンスの事前アナウンスをしつつ短いダウンタイムで移行できるような方針を検討したいです。

  • 常時情報の流入がある

ダウンタイムに関連する話ですが、データベースには毎秒かなりの数の書き込み操作が行われています。そのため新旧データベースを動かしながら無停止で乗り換えるみたいな事が難しく、データの不整合を避ける意味でも短時間の書き込みの停止は許容するようにしました。ニュースなどの取り込みはDBに書き込む前にキューを活用していたので、切り替え中の時間帯のニュースも取りこぼすことなく取り込みが可能です。

  • プライマリ レプリカ構成(マスタースレーブ構成)

データベースは読み書きが可能なプライマリと読み取り専用のレプリカの複数台構成になっていて、サービスの主機能はほぼ読み取り専用のデータベースから取得しています。そのため、プライマリDBへの接続を一時停止してもエラーで何もできないといった状況は回避できるようになっていました。

  • DBのサイズ

データベースのサイズは数百GBほどあります。数TBとかそういった規模ではないのは幸いですが、それでもサッとコピーして終わる規模でもありません。

  • AWS VPC

Aurora のデータベースは VPC 内に配置してあり、データベースを利用するアプリケーション(ECS、 Lambdaなど)もVPC内に配置して接続出来るようにしています。今回アプリケーションの Google Cloud への移行は行わず、データベースの移行だけを行うことを目的としています。そのため Cloud SQL と AWS の VPC をどう接続するかも考える必要がありました。

移行方針

上記のような前提から、このような方針ですすめることにしました

  • Cloud SQL へレプリカを構築し、アプリケーションの読み込み系統をまず移行する
  • 書き込み系統の移行は事前に顧客周知を行い、メンテナンス時間を設けて移行する
  • AWS と Google Cloud は VPN を構築し相互に通信できるようにする

データベースの読み込み系統(レプリカへのアクセス)と書き込み系統(プライマリへのアクセス)の2段階にわけて移行する方針としました。

先にレプリケーションを利用して Cloud SQL の方にデータベースを用意しておき、Readerへアクセスしていたアプリケーションをすべて移行してしまいます。このときはレプリカからレプリカへの切り替えなので無停止で実行が可能です。

次にアプリケーションの書き込みも Cloud SQL へ向けるよう切り替えを行います。このときは不整合を防止するためサービスの書き込みを一時停止してアプリケーションの切り替え作業を行います。メンテナンス中も読み込み系統は生きているためサービスの閲覧などはそのまま利用が可能です。

この移行作業を行うにあたり、Database Migration Service(DMS) というサービスを活用することにしました。

Google Cloud の機能として用意されているマイグレーション用の仕組みで、既存の DB から簡単にレプリカを生成することができます。動きとしては手動で MySQL のレプリカを作成するのを自動化したものに近くて、「Cloud SQLの立ち上げ」「mysql dumpの取り込み」「dump以降のリアルタイムな差分取り込み」をやってくれます。今回は MySQL 互換同士でしたが、異なるデータベース種別間でも移行が可能になっているみたいです。

また、データベースのネットワークに関しては VPN を構築することにしました。 移行に際し VPC 内からデータベースアクセスを行う構成を崩したくなかったため、AWS と Google Cloud 間に VPN を構築して相互にやりとりできるようにしました。冗長な構成にもみえますが DB 移行後にアプリケーションも Google Cloud へ移行していくことを想定したときに、同じネットワークとして扱えるメリットが大きかったのでこのようにしています。

移行

VPN 構築

まず始めに Google Cloud ↔ AWS間のVPN構築するところから実施しました。

こちらの記事を参考に Terraform で VPN の HA 構成を取っています。

VPN 以外にもサブネットやルーティングの設定が必要で手こずりましたがなんとか相互で通信する事ができるようになりました。

DBのエンドポイントにドメインを割り当て

今回アプリケーションのデータベース切り替え作業にあたり、DNSを用いて切り替えが簡単にできるようセットアップを行いました。

プライマリ用とレプリカ用のホストにそれぞれ固有のドメインを割り当て、ドメインの向き先を変えることでアプリケーションの接続先も追従するようにしました。

こうすることで ECS、Lambda にあるアプリケーションを再デプロイしたりせずに切り替えができます。

また Cloud SQLでは Aurora のような接続エンドポイントが提供されず、インスタンスの IP を指定して接続する形なのでドメインを割り当てておくことでラウンドロビンをしたりもできるようになります。

Route 53 を使って読み込み系統用、書き込み系統用のドメインを作成し、CNAME で Aurora のエンドポイントを指定しました。

DNS で切り替える際の注意としては、アプリケーション側でデータベース接続を維持していると、DNS の向き先が切り替わった事を知らないまま古い DB に接続し続けてしまうことがあるので、一定時間で再接続するよう設定しておく必要があります。

Database Migration Service起動

DMS を利用して Google Cloud に レプリカ を立ち上げます。VPN 構築済みなので、あとは DB の接続情報を入れるくらいで自動でレプリカインスタンスを作ってくれます。

RDS からの移行の場合、mysql dump を開始するときに一時的にプライマリの書き込みを停止する必要があり、このとき作業込みで5分くらいの書き込みダウンタイムが発生します。

それ以外はほぼ全自動で、ダンプの取り込みが完了すれば読み込み専用データベースとして利用できるようになります。取り込みの速度はだいたい100GB/時間ほどでした。

読み込み系統切替え

Cloud SQL にレプリカができたので、読み込み系統をまず移行します。Route 53のドメインを切り替えるだけなのですが一つ大きな問題があります。

DMS では MySQL の接続ユーザーは同期されないため、そのまま接続しようとしても認証に失敗してしまいます。

Database Migration Service 実行中はデータベースも読み取り専用になっていて困っていたのですが、Cloud SQL のコンソールから接続ユーザーが作れる事が分かったので、そちらを利用して既存の接続情報を移植しました。なおコンソールから作成したユーザーは root 権限がついてしまうのであとで権限を修正するなどの作業が必要です。

書き込み系統切替え

DB 移行の大詰めである書き込み系統の移行です。DMS の管理画面で「プロモート」を実行するとソースのデータベース(ここではAWS)から切り離され、独立したプライマリの DB として利用できるようになります。このとき5分ほどのダウンタイムが発生します。

無事プライマリが起動したことを確認したらアプリケーションを Google Cloud 側に切り替えて移行が完了です。

レプリカ作成

最後に Google Cloud 側にレプリカを構築して AWS のときと同じような構成に戻します。読み込み系統切替えの時点で Google Cloud 側にもレプリカを用意しておきたかったのですが DMS の管理下にあるときはレプリカの作成などができないため、移行が完了してからレプリカを構築しています。

読み込みがヘビーな場合は一時的に強めのインスタンスにして移行する必要があるかも知れません。

また、図には記載していないですがリージョンレベルの障害に備えるため別リージョンに置いたレプリカも別途用意しています。Google Cloud の場合 VPC をリージョンをまたいで構築できるのでとても簡単でした。

おわりに

DMSを活用することで DB 移行の手間を大幅に減らすことができたように思います。気がかりだったサービスのダウンタイムも、ダンプ取得時の5分と書き込み系統移行時の5分くらいと短時間で完了することができました。

移行時の大きなシステムトラブルもなく一安心です。

データベースをそっくり移行する機会はなかなかないと思いますが参考になったら幸いです。

チーム規模が変動するチームでスクラムマスターとしてやってきたこと

お久しぶりです。シニアエンジニアの @sakebook です。今回私がスクラムマスター(以下SM)として所属するチームで、SMとして、およびチームで取り組んできたことなどを共有します。

背景

他社事例なども見聞きするなかで、今まで何をやってきたのかを共有することはそれなりに誰かの役に立つことがあると思ったのでこの度まとめました。

前提

この手の話を見聞きする上で前提が異なっていると解釈しにくいので、私たちのチームについて説明します。

  • チームには業務委託や副業の方、つまり週5稼動ではない人たちもいました。
  • 最大でエンジニアは10人強いました。出入りは数ヶ月単位で発生していて、最小は5人です。
  • PBI管理にはJIRAとNotionを併用しています。
  • 1スプリントは2週間です。
  • チームはフルリモートです。

リファインメントの整理

スプリント計画で時間を押してしまうことが多く、リファインメントが慢性的に足りていないようでした。

リファインメントは30分x4で週に2時間の枠だったのですが、30分x8の4時間の枠にしました。

スクラムガイドでは作業時間の10%以下にすることが多いとあったので、その最大まで枠を確保しました。

初めはチームからも「多いのでは?」という声もあったのですが、やってみると「今までむしろリファインメントが足りていなかった」と感じる声も上がって、今までよりスプリントゴールへの自信度があがりました。毎回実施するのはなく、不要な時はスキップしたり柔軟に対応しています。

詳細見積もりの分離

リファインメントで、詳細が曖昧な部分、または実現方法が複数あって決めきれない場合などに話しきれないことが度々発生していました。これはストーリーポイントを見積もるタイミングがリファインメントしかないとチームで受け止めていて、なるべく正確に見積もろうという意識から発生しているようでした。

そこで、詳細見積もりの時間を枠として設けました。目的は見積もったPBIおよび見積もるPBIのストーリーポイントの精度を上げることです。こちらもリファインメントと同じだけ確保していますが、リファインメントとの違いとして次の特徴があります。

  • POは参加しなくても良い
  • チームも必要な人のみで話す
  • チームが不要と思ったら予定を削除できる

詳細見積もりで調整ができる前提で、リファインメントでは詳細が曖昧な部分があったとしても、それによってストーリーポイントに変動が起きそうかどうかに着目してもらい以前に比べて気軽に見積もりができるようになりました。

また、結果的にスプリント計画でやっていた内容も分離でき、スプリント計画ではプランニングに集中できるようになりました。

今のチームではストーリーポイントの数字にはフィボナッチ数を採用しています。

ワーキングアグリーメントとストーリーポイントの基準の定期的な見直し

前提にある通り、チームの変動が度々ありました。チームへの受け入れドキュメントの一つとしてワーキングアグリーメント(以下WA)を活用しました。ストーリーポイントも、チームが異なれば基準が変わるので、どんなPBIだとストーリーポイントがどの程度になるのかを参照できるようにドキュメント化しました。それぞれクォーターごとに見直しイベントを設定し、更新しています。

別途スプリントレトロスペクティブでWAに採用したいことがあったり、見直したいという声があればクォーターを待たずに更新しています。

スプリントゴールに目的を添える

POから、スプリントゴールは目指してもらってるがその背景にある目的意識が浸透しているか不安という声がありました。

何のためにそのスプリントゴールを目指しているのかがぶれていると、チームで判断が必要になった時に自分たちで判断できなかったり、間違った優先度で進めてしまうことが起きてしまいます。

日々何のために開発しているのかを意識してもらうために、少し冗長にはなるのですがスプリントゴールに目的も含めるようにしました。このスプリントゴールを毎日リマインドし、デイリースクラムでも確認することで同じ目的を目指している意識を高めました。

この形式にするタイミングで、スプリントバックログにあるものを全部達成することが良いという考えから、スプリントゴールを達成するというタスクベースから目的ベースへと移行できました。

なので今のチームでは全AC(スプリントバックログの全消化)にはこだわっていません。

予期せぬ差し込み依頼などがあった時にも目的に沿って、後にすべきか先に対応しないといけないのかチームで判断できるようになってきています。

デイリースクラムの自分事化

デイリースクラムを朝会として毎日行っています。

SMのためにチームが日々の進捗確認をする場になっていました。

SMがいなくてもチームがやっていけるようにすることが必要なのと、目的がチーム内でスプリントゴール達成への障害に気づくことなので、朝会で確認するフォーマットを規定し、事前に自分たちで朝会前にフォーマットを埋めてもらうようにしました。

それまではSMがチームの話をメモしていくスタイルで話す粒度も人それぞれだった部分があったのですが、ブレが少なくなりより目的に対して機能する朝会になりました。

終わりに

多様なチームがあるのでそのまま適応できることもあれば適応できないこともあると思います。どういう課題があって取り組むと改善できるのかを見極めて小さく試していけると良いと思います。

もっと詳細を知りたいとかあれば話せる範囲で話せるので、Twitterでもコメントでも書いてもらえると私も嬉しいです。

APIをダウンタイムなしでAWSからGCPに引越しました

こんにちは。JX通信社 サーバーサイドエンジニアの内山です。

私が所属するNewsDigestチームでは先日、モバイルアプリ用APIをAWSからGCPにお引越しする、というプロジェクトを行いました。
会社の方針としてGCP利用を推進していることや、GCP特有のマネージドサービスの活用を視野に入れ、移行を決めました。

今回のAPI移行では、大きく以下2パートの作業がありました。

  1. APIのホスティングサービスをGCP上のものにするためのCI/CD設定, 定義作成, アプリケーション修正などの作業
  2. APIドメインのルーティング先をAWS -> GCPに切り替えるインフラ作業

本記事では、後者のルーティングに関して取り上げていきます。

作業概要・前提

今回は以下の制約を設けての移行としました。

  • APIのドメインを変更しない
  • ダウンタイムなしで行う

アプリ側には手を入れず、サーバーサイドチームで日中に完結させられるように・・と言う願いを込めた作戦です。

今回のメインテーマは、以下の点をケアすることです。

  1. 移行後の環境で最初からSSL証明書が有効な状態にする必要がある
  2. 何か問題が起こった場合に速やかに切り戻せる必要がある
  3. 移行を段階的に行なえると良い

これらについて、それぞれどのように対応したかを解説していきます。

有効なSSL証明書を事前に用意するには

選定理由などは省略しますが、今回のインフラ環境はCloud Load Balancing(以降CLB)を最前段に置いてバックエンドサービスにルーティングするという構成となっております。

CLBでは、HTTPSプロトコルのフロントエンドサービス定義を作成する際に、GCP環境に用意しておいた証明書を紐づけることで設定を行います。

なお、Cloud Load Balancingに紐付けられるSSL証明書は以下の二種類があります。

有効な証明書がない状態でリクエストを受けるとSSLエラーが発生してしまうので、GoogleマネージドSSL証明書の有効化のために稼働中のAPIのリクエストを向けるとなると、一時的なダウンタイムを許容するかメンテナンス中に行う必要があります。

一方、セルフマネージドSSL証明書は向き先変更前に有効な証明書を得ることができますが、証明書更新の手間が永続的に発生することとなります。

両者のいいところどりをしたい・・ダウンタイム許容したくないし運用もしたくない・・ということで、今回は以下の作戦を取ることにしました。

  1. セルフマネージドSSL証明書を紐付けた状態で向き先変更を行う
  2. その裏でGoogleマネージドSSL証明書を紐付けて有効化を行う
  3. GoogleマネージドSSL証明書が有効になったらセルフマネージド証明書をCLBから取り除く

結論、この手順で問題なく作業が行えました。GCPコンソールを利用する場合はほとんどぽちぽちで済むのですが、セルフマネージドSSL証明書の発行は手作業なため、再度行う際の備忘を兼ねて以下に手順を記載します。

セルフマネージドSSL証明書の発行・検証作業

セルフマネージドSSL証明書に関してはさまざまな発行・有効化の手法がありますが、今回はLet’s Encrypt証明書のTXT検証手順を採用しました。また、Let’s Encrypt証明書の発行手段として、今回はCertbotを利用することとしました。

詳細は省き、作成時の作業手順を紹介します。ドメイン名やメールアドレスは適宜書き換えて参考にしてください。

1: 以下コマンドで指定ドメインのTXTレコードに設定する文字列を取得

sudo certbot certonly --manual --domain api.example.com --email hoge@example.com --agree-tos --manual-public-ip-logging-ok --preferred-challenges dns

2: 該当ドメインのTXTレコードを作成

3: しばし待ち、手順1の画面でEnterを入力して検証実施

以上の手順で得られたfullchain.pemとprivkey.pemを、GCPのセルフマネージド証明書の要素としてコンソールなりAPIなりからアップロードし、しばし待てば準備完了です。

トラフィック移行前の確認

証明書が有効かを確認するためには最終的にはAレコードを変更することになりますが、ローカルで /etc/hosts を編集してドメインとCLBのIPを紐付ければローカル環境からは事前にHTTPSアクセスが有効か検証できます。

トラフィックを流す前にセルフマネージド証明書が有効化され浸透していることを確認することをおすすめします。

安全なトラフィック移行作業をどう行うか

さて、SSL証明書を事前に用意できたため、あとはドメインへのトラフィックをCloud Load Balancingに流してあげればOKです。
・・が、前述したようにドメインをそのままにすることと、段階的な移行や速やかな切り戻しを実現するため、このステップで必要となった工夫について記載していきます。

まずは、説明のため移行前のドメイン周りの状況を図示しておきます。

移行前は、Route53でホストしているドメインのCNAMEをCloudFrontに向け、そこからオリジンへ流すような構成を取っていました。

APIのドメインを変更しないことにしたため、上図で言うところのapi.example.comの向き先がGCP世界に向いている状態がゴールと言えます。

今回GCP上にデプロイしたAPIはCloud Load Balancingを最前段に置いています。CLBはAWSのALBやCloudFrontとは違ってドメインが振られず、ロードバランサーにIPを紐付けることで疎通させる仕組みとなっています。
そのため、CLBに紐付けたIPに向けたAレコードを作成することでドメインへのトラフィックをCLBに流すことになります。

ですが、利用中のドメインにはCNAMEがセットされているため追加でAレコードを設置できません。(ダウンタイムが許容できるなら、既存のCNAMEを外してバツっと差し替えても良いですが・・)

ではどうするのかというと、新ドメインでAレコードを作成して、そのドメインへのルーティングとして既存ドメインにCNAMEを作成します。

この構成を取ると、CNAMEでの加重ルーティングを用いることが出来るため、どのレコードに何%のトラフィックを流すか指定できます。

つまり、初期は10%程度GCPに流しておき、挙動に問題があればGCP側の加重を0にして戻す or 問題なければGCP側の加重を100にして全て流す、といったコントロールが可能です。

これで、課題としていた段階的な移行と速やかな切り戻しが実現できます。

まとめ

以上2パートの作業を組み合わせ、ダウンタイムなしで段階的にAPIのAWS->GCP切り替えを行うことができました。

実際の作業では、移行後のコストが嵩みすぎて一度AWSに戻したり、Cloud Load Balancingの後段のサービスを別のものに切り替えるイベントが発生したりと、何度か加重ルーティングに助けられたシーンがありました。

ワンチャンスで祈りながら移行・・ではなく、様子を見ながらゆっくり行える作戦で作業できたことが、精神的に本当に良かったですし運用工数も小さく絞れて大満足でした。

NewsDigestの根幹APIに関してはこれで移行完了となりましたが、JX通信社全体で見るとまだまだインフラでもアプリケーションでも大幅なリファクタリング・リアーキテクチャが望ましい箇所が残っています。

制約は諸々ありますが、その中で理想を追求し、プロダクトを自分の手で育てていきたい!といった志を持った仲間を募集していますので、ぜひご連絡ください!

Hydraで書かれたコードをVertex AI Pipelineで動作できるようにした

背景

こんにちは!JX通信社シニアMLエンジニアのファンヨンテです。 JX通信社では 属人化しがちなR&Dをチーム開発するため社内共通のテンプレートコードを用いて機械学習が行われています。テンプレートコードにはハイパーパラメータ管理のパッケージとして、Hydraを用いています。

しかし、HydraとVertex AI (GCPのマネージドMLサービス)の相性が悪い部分があり、工夫なしではエラーになることがあります。 前回のブログでは、Hydraで書かれたコードでVertex AIのハイパーパラメータ調整を行うための工夫とサンプルコードを紹介しました。

tech.jxpress.net github.com

本ブログでは、ML Pipelineの簡単な紹介の後に、Hydraで書かれたコードをVertex AI Pipelineで動作できるようにするための方法を記載しています。またサンプルコードも公開しているので、一人でも多くの人の参考になれば幸いです。

github.com

ML Pipelineについて

ML Pipelineとはなにか?

機械学習のトレーニングは、データの前処理、学習、評価等様々なプロセスによって構成されています。 これらのプロセスを、一つのマシーンまたはコンテナで行う学習システムはMonolith システムと一般的に言われます (図1 (a))。機械学習を初めて体験したときには、多くの方が Monolith なシステムで試したのではないかと思います。

一方、機械学習系のシステムの本番運用まで考慮したとき、

  • データとモデルの再現性の担保
    • 例 : それぞれのプロセスにランダム性が含まれると、すべてのプロセスを一貫して行うMonolith システムでは、結果が変化した要因がつかみにくい
  • プロセスごとに求められるマシーンスペックが異なる
    • 例 : ハイメモリが必要なプロセスもあれば、メモリではなくGPUが必要なプロセスもある
  • プロセスが独立しているので、使い回しが容易

等の理由から、個々のプロセス (コンポーネントと一般的に呼ばれる)を独立させて処理を行う、Pipelineシステムによる学習が推奨されています (図1 (b))

図1 学習システム。 (a)Monolith システムでは、前処理や学習、評価等のプロセスをすべて、同じマシーンまたはコンテナで行う。(b) Pipelineシステムでは、各プロセスを分け独立したリソースで実行する。各プロセスは一般的にコンポーネントと呼ばれている。Pipeline システムでは、コンポーネント間のデータは外部のStorageやDBを経由して行われる。

ML Pipelineについてのより詳しい情報は what-a-machine-learning-pipeline-is-and-why-its-importantFull Stack Deep Learningを御覧ください。 また、Googleのブログ (Rules of Machine Learning:Best Practices for ML Engineering)では、MLの学習はPipelineの利用が前提に書かれています。

Vertex AI pipelineとは?

学習の Pipeline をコンポーネントに分け、それぞれを異なるマシーンで実行するようなシステムをゼロから構築することは、非常に複雑で困難であることが想像できると思います。 一方、Vertex AI Pipelineを利用することで、図2に示すような GCP の他のサービスと連携しながら、ML Pipelineを容易に構築することができます。

図2 Vertex AI Pipelineのシステム例。 Docker ImageはArtifact RegistryやContainer Registryで管理し、各コンポーネントはGCE等のリソースを用いて処理する。学習データやAIモデル等はGoogle Cloud Storageに保存ができる。Vertex AI Pipelineを用いることで、これらのGCPサービスと連携しながらML Pipeline構築を容易に行うことができる。

Vertex AI Pipeline のその他メリットや、より詳細な部分については、以下のような素晴らしいブログやsample codeが公開されているので、是非ご覧になってください。特に著者がML Pipelineに入門する際、杉山様のブログを理解し、サンプルコードを手元で動かすことは、たいへん大きな成長につながったので、ぜひ皆様も一度サンプルコードを手元で動かしてみてください!Vertex AIを用いることで、ML Pipelineの構築を楽に行えることが体験できると思います。

HydraとVertex AI Pipeline

Hydraはハイパーパラメーター管理のライブラリとして、非常に素晴らしく、こののように様々な学習用のコードが取り組まれています。 一方、Hydraで記載されたコンテナを、Vertex AI Pipelineのコンポーネントとして利用しようとした場合、問題が発生します。

問題点

Vertex AIでは、各コンポーネントにわたす引数を、yamlファイルのargsで定義します。 この際、Vertex AIの公式の書き方では

    command: [python3, main.py]
    args: [
      --project, {inputValue: project},
    ]

のように記述する必要があります。 このように argsを記述した場合、コンテナには以下のような argparse形式のコマンドが渡されます。

python3 main.py --project <value of project>

一方、Hydraを用いたコンテナには

python3 main.py project=<value of project>

の形式でコマンドを引き渡す必要があり、工夫なしで実行するとエラーになります。

解決法

yamlファイルの書き方を

    command: [python3, main.py]
    args: [
      'project={{$.inputs.parameters["project"]}}',
    ]

に変更する必要があります (図3)。

図3 YAMLファイルのコマンドを修正する理由と方法の概要図. Hydraで書かれたで公式のコーディングスタイルを使用するとエラーが発生します。エラーを回避するためには、コードを書き換える必要があります。

Vertex AIの一般的に利用される引数と、それに対応する変換方法は表1に掲載しています。


表1 : Vertex AIで推奨されている引数の渡し方をHydra用変換する対応表

公式の書き方 Hydra形式に変換する方法
--input-val, {inputValue: Input_name} input-val={{$.inputs.parameters['Input_name']}}
--input-path, {inputPath: Input_path_name} input-path={{$.inputs.artifacts['Input_path_name'].path}}
--output-path, {outputPath: Output_path_name} output-path={{$.inputs.artifacts['Output_path_name'].path}}

実際に動かしてみた

シンプルな例として、MNIST分類のAI PIpelineの構築を紹介します。sample codeはこちらで公開しております。READMEにコードをベースとした具体的な動作方法を記載したので、ぜひ皆様体験してみてください。

Pipelineは、

  • data prepare : MNISTのデータをダウンロードする

  • train : 学習を行う

の2つのコンポーネントから構成されます。 どちらのコンポーネントもHydraで書かれています。

図4 本ブログで作成するPipeline

各コンポーネントのコンテナイメージを作成

data prepare

data prepareのコンポーネントはこちらで記載されています。このコンポーネントも管理を容易になるようHydraで記載しています。 具体的には、必要な関数を functions フォルダに書き込みます。その後、config.yamlで処理したい関数とその引数を記述することで、処理する関数をパラメーターとして決定することができます。

train

学習コンポーネントは前回ご紹介したHydraとPyTorch Lightningを用いたコードを用いて行いました。

各コンポーネントの設定を行う

コンポーネントの設定はconfig フォルダで行われています。configの書き方は公式のドキュメント、または、Kubeflowのサイト参照ください。

ここの注意点として、argsは公式の書き方をしてしまうと、エラーになるので、表1のように書き直しが必要です。

各コンポーネントの接続

定義したコンポーネントの接続はpipeline.pyで定義され、コンパイルが行われます。 コンパイルで作成されたjsonをVertex AI Pipelineに提出すると、Pipelineが実行されます。

まとめ

今回はHydraで書かれたコードをVertex AI Pipelineで動作できるようにするときの問題点と工夫について記載させていただきました。 このブログが皆様にとって参考になれば幸いです。

Sentry で Go 製アプリケーションのエラーを楽に管理する

*1

こんにちは、サーバーサイドエンジニアの @kimihiro_n です。 今回はSentryというエラー集約管理システムをGo言語で扱う場合の知見を共有したいと思います。

Sentry とは

Sentryはエラーの集約管理を行うためのシステムで、作成したアプリケーション内で発生したエラーを一括で収集して見やすく管理することができます。

sentry.io

類似のエラーをグルーピングして発生頻度を確認したり、エラーの発生状況をSlackのようなチャットツールに通知してくれたりします。 予期しないエラーが発生したとき、生のログを見なくてもSlackやWebのUIで確認出来るのはとても便利です。 バックエンドからフロントエンドまで幅広い言語に対応しているためシステムのエラーを一括で集約が可能です。

JX通信社ではエラー管理ツールとしてSentryを広く利用しており100を超えるシステムが登録されています。 Sentryはセルフホスト版のSentryも存在していて、当初はサーバーを構築して利用していたのですが運用の煩雑さからマネージドなサービス版を利用するようになりました。

sentry-goを使ってみる

SentryのGo言語向けSDKの sentry-go、触ったことない方もいると思いますので、まずは使い方を軽く紹介します。

github.com

package main

import (
    "log"
    "time"

    "github.com/getsentry/sentry-go"
)

func main() {
  // SDK初期化
  // Options については後述
    err := sentry.Init(sentry.ClientOptions{})
    if err != nil {
        log.Fatalf("fail to init sentry: %s", err)
    }
  defer func() {
    // panic の場合も Sentry に通知する場合は Recover() を呼ぶ
    sentry.Recover()
    // サーバーへは非同期でバッファしつつ送信するため、未送信のものを忘れずに送る(引数はタイムアウト時間)
      sentry.Flush(2 * time.Second)
  }()

  // なにかのプログラム
  err = hoge.Exec()
  if err != nil {
     // err を sentry へ送信する
     sentry.CaptureException(err)
  }
}

このような形でSDKをセットアップすると、エラーやPanicが発生した場合にSentryへとデータが送られます。

Recover() は内部でGo言語の recover() を呼んでpanicから脱しSentryへの送信を行います。 なのでゴルーチンとかで処理が分岐している場合はゴルーチン側でも sentry.Recover() を呼ばないと捕捉できないケースがあります。

APIサーバーの場合はSentry側でWebFrameworkのインテグレーションを用意してくれています。 ユーザーのリクエスト情報(パスやメソッド、リモートアドレスなど)をエラーと合わせて記録してくれるので原因の調査がしやすくなるので利用可能であれば使ってみましょう。

おすすめのClientOptions

Sentryの初期化時に渡せるClientOptionsについて参考程度におすすめの設定を紹介します。 詳細については公式ドキュメントを参照してください。

  • DSN:
    • Sentry のDSNを入れるオプションです。
    • プロジェクトごとに専用のDSNが発行されるので、これをソースコードにセットすることで正しくセットアップができます。
    • が、ソースコードから設定しなくても SENTRY_DSN という環境変数をSDKが参照してくれるのでセットせず利用することが多いです。
    • DSNが未設定の場合でもエラーにはなりません。
      • 未設定時の場合、sentry.CaptureException()などを呼んでも何も起こりません。
      • Debug を有効にするとEventを破棄したログが出ます。
    • ローカルの開発環境ではSentryを使わず開発し、検証用のサーバーにあげるときにSentryのログを有効にする使い方が簡単にできます。
  • Environment:
    • 環境を入れるオプションです。
    • staging production などを入れておくと本番で起きたエラーなのかがすぐ分かります。
    • この Env を元に通知する Slack のチャンネルを変えるといった使い方も可能です。
  • AttachStacktrace:
    • CaptureMessageを呼んだ時にスタックトレースを付与するかのオプションです
    • ソースコードの位置がわかるので基本付けておいて損はないかと。
  • IgnoreErrors:
    • 文字列がマッチするエラーを無視することが出来ます
    • Sentry はサーバーにエラーを飛ばした数で料金プランが変化するので、対処する必要は特にないけれど大量に出てしまうエラーなどはここでセットしておくと安心です。

他にもパフォーマンス計測用のオプションなどがありますが今回は割愛します。

Goでの困りごと

エラーの収集は上記で出来るのですが、Go言語の場合すこし困ったことが起こります。

Go 1.13からエラーの Wrap 機能が提供されるようになり、捕捉したエラーをfmt.Errorfでラップして上位に返すパターンをよく書くようになりました。

...
err := json.Unmarshal([]byte(`{"wrong json"}`), &result)
if err != nil {
    return fmt.Errorf("fail to parse result: %w", err)
} 
...

ラップをすることでエラーの抽象度があがり、呼び出す側が意図を把握しやすくなります。

ところがこうしてラップされたエラーをSentryに渡すと

このような形で処理されてしまいます。 実際に発生しているエラーは json の Unmarshal のエラーのはずですが、画面の方では *fmt.wrapError のエラーとして扱われ、ソースの情報もエラー発生箇所ではなく sentry.CaptureException(err) を呼び出した箇所になってしまっています。

エラーの原因を調査をするとき、CaptureExceptionを呼び出した箇所というのはあまり重要ではなく、実際にWrap前のエラーが発生した箇所や、fmt.Errorfでラップを行った箇所を知らせてくれるほうが有用です。

Python版のSentryの場合、SDKをセットアップすればよしなにスタックトレースを出してくれたのですが、Goの場合、言語の標準エラーにスタックトレースの機能が存在しないため、少し不便な出力になります。

またエラーが「*fmt.WrapError」として扱われてしまうと、fmt.Errorf でラップしたエラーがすべて同列のエラーとして集約されてしまう問題もあります。Sentryの場合、エラーのグループごとに通知を設定することが多いので、異なるエラーが同一にまとめられてしまうと重要なエラーを見逃してしまうリスクがあります。

このあたりもPython版だとSDK入れるだけだったのですが静的言語なのでいろいろ制約がありそうです。

  • エラーが発生した箇所のソースコードがSentryで見られる
  • fmt.Errorf でラップした異なるエラーは異なるエラーとして扱われる の2つが実現できればSentryでの取り扱いがよくなりそうです。

Go でも Sentryを見やすくする

スタックトレースについては pkg/errors など外部エラーパッケージを利用すると、Sentryがそこからスタックトレースを取り出して表示してくれることが分かりました。

  • pkg/errors
  • xerrors
  • go-errors/errors
  • pingcap/errors

ドキュメントとして明文化されていないものの、現在この4つのパッケージに対応してそうです。

https://github.com/getsentry/sentry-go/blob/master/stacktrace.go#L75

pkg/errors は有名なエラーパッケージでしたが、現在レポジトリがArchiveされてしまっているため新規では利用しづらいです。

xerrors はGo公式がメンテナンスしているエラーパッケージです。 Wrap などの機能が Go 1.13 で本体に取り込まれたものの、スタックトレースの機能については取り込まれなかったためパッケージが残っています。スタックトレースのみを表示する用途であればこのパッケージで十分そうです。

xerrors でスタックトレース

...
err := json.Unmarshal([]byte(`{"wrong json"}`), &result)
if err != nil {
    return xerrors.Errorf("fail to parse result: %w", err)
} 
...

fmt.Errorf の部分を xerrors.Errorf に変えてみました。

今度は sentryでエラーを投げるところだけではなくてちゃんとエラーが発生したところをトレースできてますね。

じゃあソース内の fmt.Errorf を一括で変換してしまえばいいかというとそうでもなくて、今度は過剰にスタックトレースが付与されてしまいます。

xerror でラップしたものを更に xerror でラップすると、それぞれに対して別々のスタックトレースが付与されてしまい、Sentry ではそれらを全部列挙しようとします。本当に見たい部分以外のスタックトレースが入ってしまうので調査のための情報が逆にノイズになってしまいます。

なので、以下の方針でラップすると無駄のないエラーハンドリングが出来るかと思います。

  • Goのパッケージやライブラリが吐くエラーは xerror.Errorf でラップする
  • 自分で書いた関数のエラーをラップするときは fmt.Errorf を使う

xerror のスタックトレース付きエラーを fmt.Errorf でラップしてもちゃんとスタックトレースを展開してくれ、fmt.Errorf した部分もトレースが残っていくので上記の方針で進めると無駄なくエラーにスタックトレースを付与できると思います。

wrap したものを適切にグルーピングする

fmt.Errorf でラップしたものがSentry上でまとめられてしまう問題についても見ていきます。

https://github.com/getsentry/sentry/issues/17837

GitHubのIssueにヒントがないかみてみたのですが明確な解決法が見当たらず、SDKでタイトルを上書きするのがいいのではないかというコメントで終わっています。

GoのSentrySDKにはBeforeSend というフックが用意されており、こちらを利用することでタイトルの書き換えが可能になるみたいです。

  • グループ名(上段太字): *xerrors.wrapError
  • サマリ(下段): fail to parse result: invalid character '}' after object key

となっているところを

  • グループ名: fail to parse result
  • サマリ: invalid character '}' after object key

のように書き換えてあげれば、最後にWrapしたメッセージがグループ名として利用できそうです。

BeforeSend のシグネチャは

BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event

のようになっており、渡されたeventを書き換えてreturnしてあげれば上書きが可能です。

Sentryの captureException を利用した場合、Sentry側でエラーのグルーピングのキーになるのは event.Exception 配列の最後の Type が利用されるみたいたいです。 なので wrapError の場合だけ上書きしてみることにします。

err := sentry.Init(sentry.ClientOptions{
        Debug:            true,
        AttachStacktrace: true,
        // BeforeSend のフックで Event を書き換え
        BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
            for i, _ := range event.Exception {
                exception := &event.Exception[i]
        // fmt.wrapError, xerrors.wrapError 以外は何もしない
                if !strings.Contains(exception.Type, "wrapError") {
                    continue
                }
        // 最初の : で分割(正しく Wrapされていないものは無視)
                sp := strings.SplitN(exception.Value, ":", 2)
                if len(sp) != 2 {
                    continue
                }
        // : の前を Typeに、 : より後ろを Value に
                eexception.Type, exception.Value = sp[0], sp[1]
            }
            return event
        },
    })

先ほどのエラーを再度送信してみると、

無事エラーを書き換えることが出来ました。

今回のケースでは一律で一番外側の Wrap を剥がしていますが、処理を変えればより柔軟にカスタマイズできそうです。

まとめ

  • Sentry は便利
  • エラーを文字列でラップして使うときは以下のルールにするとスタックトレースが扱いやすい
    • Goのパッケージやライブラリが吐くエラーは xerror.Errorf でラップする
    • 自分で書いた関数のエラーをラップするときは fmt.Errorf を使う
  • BeforeSend のフックでイベントを加工することで有意なエラーグルーピングを実現できる

参考

*1:The Gopher character is based on the Go mascot designed by Renee French. The design is licensed under the Creative Commons 3.0 Attributions license.