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.

AI開発時に、あるClassのデータを集中的に追加する時のTipsと注意点

こんにちは!JX通信社シニアMLエンジニアのファンヨンテです。私は普段から、顧客にとって価値の高い(言い変えれば、顧客を幸せにできる)AIを開発することをモットーに仕事に励んでます。そのためには、AI開発の速度を高め、様々なAIやその見せ方を顧客に試していただくことで、顧客の幸福度を上げる方向性を見出していく必要があります。

効率性を高め、AIの開発速度を上げるために、JX通信社では様々な工夫を行っており、これまでもブログで紹介してきました。

tech.jxpress.net

tech.jxpress.net

tech.jxpress.net

今回はその取り組みの中でも、あるClassのデータを集中的に追加する時の背景やTipsと注意点についてまとめます

目次

背景

分類をおこなうAIの開発において、とあるClassだけ学習データの数が少なかったり、新たに分類しなければならないClassが追加されることはよくあります。このような場合の対策として、「とあるClassの学習用データだけを追加で作成し、AIの精度を上げたい」と考えることがあると思います。

例として、スイーツに関するアンケートの回答文を、

  • Class 1 : 『甘さに関する言及をしている』
  • Class 2 : 『しょっぱさに関する言及をしている』
  • Class 3 : 『味に関する言及なし』

の3つのClassに分類するAIがありました。ビジネス的な要望で、新たに

  • Class 4 : 『辛さに言及している』

のClassを追加する事になりました (図1)。しかし、これまで辛さに言及しているテキストにラベルを付けてなかったので、辛さのデータが少ないです。そこで、新たに辛さに言及しているテキストを収集し、Class 4の学習用データに追加していく状況を考えます(図1)。

図1 背景の例。テキストから『甘さに関する言及をしている』、『しょっぱさに関する言及をしている』、『味に関する言及なし』のテキスト分類していたが、『辛さに関する言及をしている』が追加されたり、データが少ない場合がある。

データ作成の手順

簡単なフィルター

アンケートデータの中から、『辛い』という意図が含まれるテキストを取得するために

(からい OR 辛い OR からっ) が含まれる

という条件でフィルターして集めるとします。しかし、このような条件で集めたデータには

ここ最近、本当に辛いから運動して気分転換しよ

のように「味」としての意味ではなく、「つらさ」を意図したものが含まれてしまう可能性があります。これらは本来 味の言及なしのラベルに該当し、辛さに言及している 学習データとして追加するにはふさわしくありません。

このようにキーワードマッチング程度のフィルターでは誤ったラベルが混入する可能性が高いため、そのまま学習データに加えるのはリスクがあります。 *1

Classにふさわしいデータを判別するシステムを作成する

簡単なフィルタリングだけでは学習データに適さず、その中からClassにふさわしい学習データ( 味としての辛い のデータ)を選び出す必要があります (図2)。その際、人間の力に頼らざるを得ないですが、自動翻訳機AIを利用することで自動化出来る部分もあります。(本ブログの最後に補足として後述しています。)

図2 キーワードマッチング程度の軽いフィルタだけでは、学習データとしてふさわしくないノイズが多く紛れている。したがって、その後、人 or システム (翻訳機の利用、学習データ作成に特化したAI等)により、対象Classにふさわしいデータを選別する必要がある。

対象Classにふさわしいデータも、ふさわしくないデータも、共に大事

Classにふさわしいデータの選別が終了後、本来の目的通り 味としての辛い と言及しているデータだけを対象Classに追加し、対象Classにふさわしくないデータは廃棄して学習に進んでしまいがちです。しかし、この手法をとってしまうと、AIは 味としての辛いと言及していないデータも 味としての辛い言及したと間違った予想をしてしまいます(図3)。

図3 対象Classにふさわしいデータだけを追加してしまうと偽陽性の予想が増えてしまう。

この問題はAIに対象Classに入るべきデータだけを教えて、入るべきではないデータを教えなかったことが原因です。したがって、この問題を解決するためには、対象にふさわしいデータだけではなく、ふさわしくないと選別されたデータの両方を学習データに加える必要があります (図4)。

図4 データを加えるときの注意点。『辛い』を追加する場合、『味としての辛い』と言及しているものを対象Classに追加し、『つらいの意味で使われている辛い』は不適切であるため廃棄したいと考えがちです。しかし、そのように排除し学習してしまうと、『つらいの意味で使われている辛い』は不適切とAIが理解できないため、つらいの意味で使われていても『辛い(からい)』のClassに予想されてしまいます。したがって、『つらいの意味の辛い』等を他のClassに学習データとして含ませることで、それらが『辛さ(からさ)に関する言及をしている』Classからそれらが排除されることを教えて上げる必要があります。

そのためには、対象Classにふさわしくないデータ全てに他のラベルを付与し、学習データに追加する必要があります。 しかし、このラベル付けの作業には多くの時間的、金銭的コストが掛かかるため、できれば避けたいです。そこで本ブログでは自動的にラベル付けをする方法を最後に紹介します。

対象Classにふさわしくないデータに再度ラベルを付ける

対象Classにふさわしくないデータ全てに、手動でラベルを付与することは非常に労力がかかります。そこで、対象Classを除いたデータで学習させたAI (図5 上)を用いて、対象Classに属さなかったデータを予想させ、それをラベルにすることで、大量のデータであってもラベルをつけることが出来ます (図5 下)。*2

図5 対象Class以外のClassで学習させたAIを用いて、仮をラベルを付ける方法

このように、対象Classにふさわしいデータだけではなく、ふさわしくないデータの両方をうまく利用することでAIの精度向上を達成することができます

まとめ

AIをビジネス運用していると、とあるClassの分類精度が他のClassより大事な状況や、環境の変化でAIに求められる性能が変化することは多々あります。その要求にAIがうまく対応するために、とあるClassだけ特別にデータを作成したいと思うことはAIエンジニアにとって比較的あるあるなのではないでしょうか?

この時、対象ClassにふさわしいデータだけをそのClass に追加する方法を思いつきがちですが、この方法では精度が上昇しないどころか、下がる場合もあります。実際、筆者も色んな人の協力や、システムを利用して作成したデータを追加したのに、AIの精度が上昇しなくて苦労した経験があります。その大きな原因は、対象Classにふさわしくないとされたデータを破棄したことにありました。したがって、AIの精度向上のためには、本ブログで紹介したように、対象Classにふさわしくないが対象クラスに近いデータを他のClassに追加して、そのクラスに入るべきではないデータも同時に教える必要があります。

また、筆者がこのブログでもう一つ伝えたかったこととして、アノテーションを手動ですべて行うことは無理があるため、なるべく簡素化するための方法を考える必要があるということです。

本ブログでは、筆者が行った工夫の例として、

を紹介しました。その他にも、MLM (Masked Langage Model)を用いた同音異義語の分類等、様々な工夫も考えられます。

読者の皆さまが同様な状況になった時、この情報が役立つことを願っています。また、本ブログで紹介したもの以外の原因や工夫も考えられますので、なにか思いつくことがあれば私のtwitterにお知らせください。

※ このドキュメントは文書分類の話ですが、画像分類を含めたその他の種類についての同様に適応できる考えです。

補足

本ブログの補足として、データのラベル付けをある程度自動化するためのテクニックを紹介します。

補足① : 翻訳機を利用して、同音異義語を区別する

機械翻訳では文脈まで考慮されるので、日本語の同音異義語も翻訳すると、他の単語に変換されることを利用出来ることもあります。

例として、味として適切なデータである

このスパイス辛いからホント好き

を英訳すると (DeepLを使用)

I really like this spice because it's hot.

のように辛い(からい)は hotspicy に変換されます

一方、味として適切でない

ここ最近、本当に辛いから運動して気分転換しよ

を英訳すると (DeepLを使用)

It's been really hard here lately, so I need to get some exercise and change things up.

となり、 辛い(つらい)は hard に変換されます

このように、機械翻訳をし、日本語の同音異義語を別の単語にすることで意味によるフィルタリングができることがあります。

一方で、この手法では一定確率でノイズ(ラベルの誤り)が発生してしまうリスクもあります。ここで発生するノイズの割合が低ければ、AIを用いたデータのクレンジングの利用も筋が良いと思います。

補足② : 学習データを作成するためだけのAIを構築する

人や翻訳機で全てのデータをアノテーションするのは、時間やお金のコストがかかってしまいますが、ある程度のデータだけは人力で溜めて、 味としての辛いデータ or not を診断するだけのAIを新たに開発することも考えられます。 このAIが一定水準以上の精度を得られることが担保できれば、学習データのアノテーションを半自動 & 高速に作成することも出来ます。

※注意 : ここで、ふさわしいデータ or not を診断できるAIを作成できるなら、これをサービスで利用すればいいのでは?と思うかもですが、このAIは簡単なフィルタリングを通した後のデータに対しての分類AIなので、汎化性能は少なくフリーテキストのデータの推論には向いていません。

*1:色々とフィルター条件を調整することで適切なフィルタを作成可能かもしれませんが、ルールでの対策はいたちごっこになりがちです。そして、その条件を見つけるには多くの時間と労力がかかります。というより、そもそもそのフィルタが完成するのなら、それをサービスで利用すればよく、AIを構築する必要はありません

*2:ここでのラベルはAIがつけた仮のラベルであるため学習は完全な教師あり学習ではなく半教師あり学習になります。

AWS Lambdaの構成管理のためにterraformを導入してみた

FASTALERT開発チームバックエンドエンジニアの鈴木(泰)です。

本記事は、AWS Lambdaの構成管理のためにTerraformを導入してみたというお話です。

TL;DR

  • FASTALERTチームの開発文化とTerraformの導入に至った背景
  • AWS Lambda関数をTerraformでどう管理しているか

もくじ

本対応の背景

具体的なAWS Lambda x Terraformの話に入る前に、FASTALERTチーム、workerレポジトリ、今回Terraformの導入に至った動機について、少しだけお話しさせてください。

FASTALERTとチーム文化

FASTALERTは、事件・事故・災害などのリスク情報をAIが自動収集し、収集された情報をお客様にとって使いやすい形で提供するBtoB向けのSaaSです。まだまだ成長過程のサービスであるため、顧客からのフィードバックを含め、寄せられる要望へ迅速に対応しなければなりません。

上記のような事情から、FASTALERTのバックグラウンド処理の内容は規模が小さく多岐に渡り(広く浅い)、「開発速度」が重要となってきます。

様々なバックグラウンド処理

  • データやシステムの監視とメトリクス算出、データベース中の防災情報の管理、防災情報へのメタ情報、社内システム連携
  • トリガーも様々。イベント駆動、Web API、運用者が不定期的に手動で実行する、等。

開発速度を重要視するチームの慣習

  • ほぼ全てのコンポネントがCD化されている、本番稼働するシステムの全ソースコードはGitで管理されている、Gitのタグを付与するだけのお手軽リリース、IaCの導入、スクラム開発

workerレポジトリ

FASTALERTチームにはworkerという名前のレポジトリがあります。 このレポジトリは、様々なバックグラウンド処理を小さい工数で提供し、かつ、ソースコード管理システムでソースコードを管理し、CI/CDを完備することを目的とし、Lambda製の様々なバックグラウンド処理を一元的に管理しており、Lambda関数を簡単に追加できるようにしています。具体的には(1)ソースコードを書く(2)デプロイ用のコードを追加(3)Gitのタグを付与する。これだけで新しいLambda関数をリリース可能です。本記事執筆時においては約50個のLambda関数が管理されており、ソースコードの追加・更新が頻繁に行なわれています。

apexのメンテナンス停止

workerレポジトリでは、Lambda関数の構成管理ツールとしてこれまではapexを利用していました。apexが2019年にメンテナンスを停止しました。今回のTerraform導入は、このapexのEOLを受けてのことです。

Lambdaの構成管理ツールとしてTerraformを採用した理由

Terraform以外の構成管理ツールの候補としてAWS SAMがありました。Terraformを採用することとなった決め手となったのは、以下の点です。

  • AWS上の実際のリソース設定とTerraformのstateを柔軟に解消できる。
  • FASTALERT開発チームのメンバーはTerraformを使い慣れている。

AWS上の実際のリソース設定とTerraformのstateを柔軟に解消できる

過去の経験上、SAMはリソース定義(yamlファイル)と実際のインフラ設定の間に生じた差分の解決がとても面倒臭い、という印象があります(昔、SAMを利用していたとき、差分が生じて、CloudFormationが動かなくなり、CloudFormationに紐づくリソースを丸ごと削除して新しく作り直す、というような危険な運用をしたことが苦い経験として記憶に焼き付いていたりします)。

IaCを進める過程において、構成管理用のソースコードと実際のインフラ設定の間に差分が生まれるということはよくあります。FASTALERTチームのように開発速度が求められる現場においては、そのような差分が発生する可能性はとても高いです。

Terraformは、CloudFormationを使用しておらず、かつ、実際のインフラ設定との差分を解消するためのimportコマンドがあるため、差分の発生に対して柔軟に対処することができます。

FASTALERT開発チームのメンバーはTerraformを使い慣れている

Terraformを採用したもう1つの理由は、FASTALERT開発チームメンバーがTerraformに慣れているという点です。最近FASTALERTチームでは、構成管理をTerraform化することを推奨しており、Terraformを使えるメンバーが増えつつあります。

AWS Lambdaの構成管理のためにTerraformを導入(詳細)

それでは、我々がどのようにしてTerraform化を実現したかについてお伝えいたします。

本記事で紹介している詳細実装は、Python3.8、Terraform1.1.7で動作することを前提としています。

コードサンプルはこちらにあります。

ディレクトリ構造

workerレポジトリは以下のようなディレクトリ構造です。

functions/
terraform/
build-pkg.sh
Pipfile
Pipfile.lock

Pipfile, Pipfile.lockは依存ライブラリを管理します。

本記事の要となる、functions/, terraform/ディレクトリについての詳細です。build-pkg.shはLambda用のzipを作るためのスクリプトです(後述)。

# functionsディレクトリ配下にはLambda関数のエントリーポイント群があります。
functions/
functions/lambda_process_checker.py
functions/lambda_export_api_log.py
...以下省略...

# 複数のLambda関数から共通して使われるソースコードは
# functions/worker_lib/ ディレクトリ配下にあります
functions/worker_lib/
functions/worker_lib/common.py
...以下省略...

# Terraformディレクトリ配下にはTerraformのコードがあります。
terraform/

## 共通するリソース(IAMとか)はmain.tfに書いています。
terraform/main.tf

## Lambda関数のリソース定義は
## 可読性を向上させるために
## 基本的にはLambda関数1つにつき、1つのファイルです。
## ファイル名は /functions ディレクトリ配下のpythonのソースコードと同じ。
terraform/lambda_process_checker.tf
terraform/lambda_export_api_log.tf
...以下省略...

ファイルの中身

functions/process_checker/main.pyの中には、Lambdaのエントリーポイントがあります。

# coding: utf-8
def handler(event, context):
    print('start process_checker')
    ...以下省略...

terraform/main.tfの中には、共通するリソース定義があります。

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}

provider "aws" {
  region = "ap-northeast-1"
}

resource "aws_iam_role" "worker-role" {
  name        = "worker-role"
  description = "Do not edit manually! This is auto generated by Terraform. Allows Lambda Function to call AWS services on your behalf."
  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      ...以下省略...

terraform/lambda_process_checker.tfの中には、Lambda関数リソースの定義があります。dists.zipは、build-pkg.sh(後述)が生成するLambda関数のパッケージです。

resource "aws_lambda_function" "aws_lambda_process_checker" {
  function_name    = "aws_lambda_process_checker"
  filename         = "dists.zip"
  source_code_hash = filebase64sha256("dists.zip")
  runtime          = "python3.8"
  role             = aws_iam_role.worker-role.arn
  handler          = "lambda_process_checker.handler"
}

Lambdaのデプロイ方法

  1. Lambda関数のパッケージを作る。
  2. terraform applyコマンドを実行する。

Lambda関数のパッケージを作る

Lambda関数用のzipファイルを作ります。全てのLambda関数のエントリーポイント、その実行に必要となる依存ライブラリを全て1つのzipファイルの中に格納します。参考 .zip ファイルアーカイブで Python Lambda 関数をデプロイする

workerレポジトリ上のbuild-pkg.shがzipファイルを作ります。build-pkg.shが正常終了するとdists.zipというファイルが生成されます。

#!/bin/bash

set -e

WORK_DIR="/tmp/lambda-pkg-$(date +%s)"
DIST_DIR="${WORK_DIR}/dists"
OUT_DIR=$(pwd)/terraform

mkdir $WORK_DIR
mkdir $DIST_DIR

pipenv lock -r > requirements.txt
pip install -r requirements.txt -t ${DIST_DIR}/
cp -r ./functions/* ${DIST_DIR}/

cd ${DIST_DIR}/ && zip -q -r dists.zip * && mv dists.zip ${OUT_DIR}/

注意点

この実装方法ですと、Lambda関数の数が増えていくのに伴い、dists.zipファイルのサイズが大きくなることが懸念されます。dists.zipファイルのサイズが大きくなることを回避するための実装方針として、サイズが大きくなりがちな変数の定義(長い文字列や配列、連想配列の定数値)はDynamoDBやS3等の永続化層へ配置するようにしています。Pythonのソースコードが増えるだけであれば、ファイルのサイズが大きくなることは回避できます。(それでも、Pythonの依存パッケージのサイズが大きくなってしまう懸念点は依然として残されています。。。もしそうなってしまった場合、AWS Lambda Layers等の利用を検討すべきかもしれません。)

terraform applyコマンドを実行する

dists.zipが生成された後、以下のコマンドを実行し、Lambda関数をデプロイします。

cd terraform && terraform init && terraform apply

以上です。

新しいLambda関数を作成する場合、Pythonのソースコード、Terraformの定義ファイルを追加するだけです。とてもお手軽に作れます。例えば、new_func1という新しいLambdaを追加する場合、以下の2つを追加するだけです。逆に既存のLambdaを削除する場合、2つのファイルを削除するだけです。

  • functions/new_func1/main.py
  • terraform/lambda_new_func1.tf

コードサンプルはこちらにあります。

所感

AWS Lambdaの構成管理ツールとしてTerraformを導入してから3ヶ月ほど経っています。本対応を終えて、振り返って思うことをお話しします。

本記事で紹介した実装方法のメリットは、シンプルな構成であることかなと思います。ソースコードとインフラのリソース定義が1つのレポジトリで完結し、全体の見通しが良いです。Lambdaの追加や削除がとても簡単にできます。

実務的に困るようなデメリットは今のところは顕在化していません。しかしながら本記事で紹介した、規模の小さなプログラムを一元管理するようなタイプのレポジトリ(上記のworkerレポジトリ)の注意点として以下のことがあげられます。本来マイクロサービスとして独立して作るべき処理を、処理の追加とリリースが簡単という理由だけで、追加してはならないということです。このタイプのレポジトリに追加して良い処理は、緊急性が必要とされ、かつ、マイクロサービスとして切り出すことの費用対効果が得られないもの(規模の小さな処理、マイクロサービスとして切り出すべきかどうか、その境界が曖昧な処理)、に限定すべきです。

あとがき

本記事では書いていないこと

  • 実際には、zipファイルの作成、terraform applyコマンドの実行は、CD環境上で自動化されています。
  • Pythonソースコードのユニットテストの実行は、CI環境上で自動化されています。
  • 我々の本番環境では、workerレポジトリ上で管理されているLambda関数は約50個あるため、functions/ディレクトリ配下には50個のLambdaのエントリーポイント、terraform/ディレクトリ配下には50個のLambda関数リソース定義があります。
  • 実際には、Terraformのリソース定義ファイルはもう少し作り込んであります(Lambdaのトリガーリソースの定義、モジュール機能、-var-fileオプション、stateファイル管理周り)。今回の記事では省略しました。
  • 50個全てのLambdaを、無停止でapexからTerraformへ切り替える作業が本当に大変なところであり、話したいところでもあるのですが(汗)、記事がとても長くなってしまうので省略しました。