フレクトのクラウドblog re:newal

http://blog.flect.co.jp/cloud/からさらに引っ越しています

Einstein Discovery で行ったマッチング処理の結果を Einstein Next Best Action のレコメンデーションとして表示する

こんにちは。エンジニアの山下です。

前回の記事 で Einstein Discovery と Einstein Next Best Action の連携方法の一例として、Einstein Discovery の推論結果を元に Einstein Next Best Action のレコメンデーションの出し分けを行う方法を紹介しました。

今回はもう少し進んだ例として、Einstein Discovery で行ったマッチング処理の結果を Einstein Next Best Action のレコメンデーションとして表示する方法について書こうと思います。

やりたいこと

今回は Salesforce の標準オブジェクトの Contact 同士の相性診断を行い、Contact のレコードページでマッチングスコア上位3つまでをレコメンデーションとして表示したいと思います。レコメンデーションの説明文には対応する Contact の名前と ID を表示しておき、レコメンデーションを承諾した場合には Contact ID を元にした何らかの処理を行うということにしておきます。

イメージは以下のような感じです:

コミュニケーション促進用途を想定してそれっぽくしてみました。

あとは背後で動く仕組みをどうするかですが、以下のような構成でいきます。

  • Einstein Discovery でマッチングスコア算出のためのモデルを構築
  • CRM Analytics Data Prep で Contact の全ペアのマッチングスコアを算出
  • マッチングスコアから Einstein Next Best Action のレコメンデーションを作成

最大の難所は Einstein Next Best Action が絡むステップで、ここでは今回のユースケースに合わせて Einstein Next Best Action の制約を回避する作業が必要になります。詳細は後述します。

では上から順にポイントを説明していきます。

Einstein Discovery でモデルを構築する

まずはマッチングスコア算出用のモデルの準備からです。モデルの構築にあたって入力用のデータが必要なため、適当なスクリプトから生成するなどして準備しておきます。

参考までに今回は以下の Python スクリプトでデータを生成しました。

import csv
import random
import uuid

AGE_MIN = 20
AGE_MAX = 60
SCORE_NOISE_MAX = 5.0
SCORE_NOISE_MIN = -5.0
NUM_OF_DATA = 1000

if __name__ == '__main__':
  with open('out.csv', 'w') as f:
    fieldnames = ['id', 'age1', 'age2', 'score']
    writer = csv.DictWriter(f, fieldnames=fieldnames, dialect='unix')
    writer.writeheader()
    for i in range(NUM_OF_DATA):
      id = uuid.uuid4().hex
      age1 = random.randint(AGE_MIN, AGE_MAX)
      age2 = random.randint(AGE_MIN, AGE_MAX)

      # high score if they are around same age
      noise = random.uniform(SCORE_NOISE_MIN, SCORE_NOISE_MAX)
      score = (AGE_MAX - AGE_MIN) - abs(age1 - age2) + noise

      writer.writerow({'id': id, 'age1': age1, 'age2': age2, 'score': score})

Contact のペアのそれぞれの年齢を受け取り、年齢が近いほど高いスコアを出力するという、極めて前時代的なアルゴリズムを使用しています。適当にアレンジして使用してください。

データさえあれば Einstein Discovery のモデル構築は非常に容易で、Trailhead を一読すれば簡単に実施することができます。

注意事項として、モデルをデプロイする際に「モデルを Salesforce オブジェクトに接続する/しない」という選択肢が表示されますが、これは「接続しない」を選択します。「接続する」を選択した場合、単一レコードのフィールドからしかモデルの入力パラメータを設定できないという制約が課されるのですが、今回は複数(2つ)の Contact レコードからモデルに入力を行う必要があるため、この制約を受け入れることはできません。

CRM Analytics でマッチングスコアを算出する

作成した Einstein Discovery のモデルを CRM Analytics の Data Prep Recipe から呼び出してマッチングスコアの算出を行います。Data Prep Recipe についても特に難しい点はなく、こちら の公式ドキュメントを参考にすれば簡単に作成できます。

Recipe の中で実施する内容は、

  1. Contact 同士の Cross Join をとる
  2. 1 で得た結果から同じ Contact 同士の組合せを取り除く
  3. 2 で得た結果に対して Einstein Discovery モデルの推論を適用する
  4. 3 で得た結果を Dataset として保存する

という感じです。

3 で CRM Analytics と Einstein Discovery を連携させることになりますが、これも設定上は特に難しい点はなく、Recipe のパイプライン上に Discovery Predict ブロックを追加して入力パラメータを設定するだけで連携できます。

参考までに筆者が作成した Recipe のパイプラインが以下です:

Predict Score の後に Transform ブロックが1つ入っていますが、このブロックでは API 名の調整をしています。特に Cross Join を行った際に Join 元のカラムの API 名が partner.Id のように . を含む名前に変更されますが、後ほど APEX から Dataset を参照する際に . があると煩雑な対応が必要になるため、ここで修正しています。

Recipe の作成後は実行して Dataset を生成しておきます。

Einstein Next Best Action のレコメンデーションを作成する

いよいよ本丸です。冒頭でも述べましたが、ここが最大の難所です。

何がそれほど難しいのかというと、Einstein Next Best Action に非常に拡張性の乏しい仕様になっている箇所があり、それを回避するのが難しいです。クラウドサービスに携わっている以上、仕様上の制約にはそれなりの頻度で遭遇しますが、Einstein Next Best Action の制約は結構深刻で、ユースケースによっては簡単に詰むくらいのインパクトがあります。

具体的には、今回のユースケースでは以下の2つの仕様上の制約を回避する必要があります。

レコメンデーションの説明文に動的な文章を設定できない

レコメンデーションの説明文に設定できるのは固定の文章のみという制約があります。これは説明文にマッチングスコア上位のお相手の Contact の情報を掲載する際に障害となります。

しかし、レコメンデーション自体を APEX から動的に生成することでこの制約は回避でき、次に紹介する制約に比べれば些事に思える内容です。

レコメンデーション承諾時に起動する Flow から起動元のレコメンデーションの情報を入手する手段がない

何を言っているのかわからないかもしれませんが、私も同じ気持ちです。どうしてこうなった ... 。

残念ながら、レコメンデーションが承諾された際に起動する Flow から、起動元となるレコメンデーションに関する情報を得る手段はありません。では何の情報なら得られるかというと、実は基本的に何の情報も得られません。公式ドキュメントを精査した限りでは、起動されたという事実だけを元に素材ゼロから処理をするというのが Einstein Next Best Action の基本です。

公式ドキュメントに記載のある唯一の例外は isRecommendationAccepted という変数で、この変数にはレコメンデーションが承諾されたか拒否されたかの情報が真偽値で格納されます。しかし、この情報だけで今回のユースケースを実現するのは当然不可能です。

詰んだ ... かと思いましたが、色々調べた結果、以下のスレッドに突破口となる情報が記載されているのを発見しました。

上記のスレッドには Einstein Next Best Action に recordId という隠しパラメータが存在し、それを使えばレコメンデーションが表示されている対象の Contact の ID が得られるという旨が記載されています。なぜ隠した。

残念ながらレコメンデーション自体の情報はやはり得られませんが、これを使って実装を工夫すれば、レコメンデーションに対応する Contact のペアの両方の ID を取得することが可能となります。

具体的には、レコメンデーション承諾時の Flow を3つ定義し、それぞれをマッチングスコア1位のレコメンデーションのための Flow・2位のための Flow というように定めます。これにより、どの Flow が起動したかによってマッチングスコアの順位が特定できます。さらに特定した順位と recordId を組み合わせることで、どの Contact の何位のお相手かが特定できるという寸法です。

図にすると以下のようになります:

これで全ての制約が回避できる見通しがつきました。というわけで、具体的な実装方法に進みます。

レコメンデーション承諾時の Flow を作成する

先述の通り、3つの Flow を作成します。が、その前に recordId とマッチングスコア順位からお相手の Contact ID を取得するための APEX Action を定義しておきます。

APEX から CRM Analytics API を利用するには Wave という名前空間にあるクラス群を利用します。Dataset へのクエリについては こちら の公式ドキュメントにあるサンプルコードを一読するのがオススメです。

global class GetPartnerId {

    private static String DATASET_ID = '(your-dataset-id)';

    // NOTE: 固定値ではなく REST API 経由で適宜 currentVersionId を取得するのが理想だが今回は割愛
    private static String DATASET_VERSION_ID = '(your-dataset-version-id)';

    @InvocableMethod
    public static List<String> get(List<Input> inputs) {
        String id = inputs[0].id;
        Integer targetIndex = inputs[0].targetIndex;

        List<Record> records = retrieveScores(id);
        records.sort(new ScoreComparator());

        Record target = records[targetIndex];
        return new List<String>{target.partnerId};
    }

    private static List<Record> retrieveScores(String id) {
        Wave.ProjectionNode[] projs = new Wave.ProjectionNode[]{
            Wave.QueryBuilder.get('partnerId').alias('partnerId')
        };
        ConnectApi.LiteralJson result = Wave.QueryBuilder.load(DATASET_ID, DATASET_VERSION_ID)
            .filter('Id == "' + id + '"')
            .foreach(projs)
            .execute('q');

        JSONParser parser = JSON.createParser(result.json);
        Response res = (Response) parser.readValueAs(Response.class);

        return res.results.records;
    }

    public class ScoreComparator implements Comparator<Record> {
        public Integer compare(Record r1, Record r2) {
            if (r1.score < r2.score) {
                return -1;
            }
            if (r1.score > r2.score) {
                return 1;
            }
            return 0;
        }
    }

    global class Input {
        @InvocableVariable
        public String id;
        @InvocableVariable
        public Integer targetIndex;
    }

    public class Response {
        public Results results;
    }

    public class Results {
        public List<Record> records;
    }

    public class Record {
        public String partnerId;
    }
}

基本的に Dataset にクエリを投げているだけですね。

あとは上記の APEX Action を呼び出す3つの Flow を定義すれば OK です。それぞれの Flow から APEX Action に渡す targetIndex は、その Flow が担当する順位に応じて変更する必要があるため、ご注意ください。

レコメンデーションを APEX Action 経由で生成する

マッチングスコア上位の Contact の情報を説明文に埋め込みたいので、レコメンデーションは APEX 経由で作成します。具体的には Autolaunched Flow を作成し、そこから APEX Action を呼ぶことでレコメンデーションを作成します。

今回は対象者(レコメンデーション表示先)の Contact ID を Flow から渡し、その Contact のマッチングスコア上位3名分のレコメンデーションを生成するような APEX を実装しています。

global class CreateRecommendation {

    private static String DATASET_ID = '(your-dataset-id)';

    // NOTE: 固定値ではなく REST API 経由で適宜 currentVersionId を取得するのが理想だが今回は割愛
    private static String DATASET_VERSION_ID = '(your-dataset-version-id)';

    @InvocableMethod
    public static List<String> get(List<Id> ids) {
        String id = ids[0];

        List<Record> records = retrieveScores(id);
        if (records.size() < 1) {
            return new List<String>{'No Result'};
        }

        records.sort(new ScoreComparator());

        List<Record> top3 = new List<Record>();
        for (Integer i = 0; i < records.size() && i < 3; i++) {
            top3.add(records[i]);
        }

        List<Recommendation> recommendations = new List<Recommendation>();
        for (Integer i = 0; i < top3.size(); i++) {
            Record record = top3[i];
            Recommendation recommendation = new Recommendation();
            recommendation.Name = 'your_recommendation_' + record.id + '_' + record.partnerId;
            recommendation.Description = 'Talk with ' + record.partnerName + ' (ID: ' + record.partnerId + ')';
            recommendation.ActionReference = 'your_flow_api_name_' + String.valueOf(i);
            recommendation.AcceptanceLabel = 'Go';
            recommendation.RejectionLabel = 'No thanks';
            recommendations.add(recommendation);
        }

        insert recommendations;

        return new List<String>{recommendations.toString()};
    }

    private static List<Record> retrieveScores(String id) {
        Wave.ProjectionNode[] projs = new Wave.ProjectionNode[]{
            Wave.QueryBuilder.get('Id').alias('id'),
            Wave.QueryBuilder.get('partnerId').alias('partnerId'),
            Wave.QueryBuilder.get('partnerName').alias('partnerName')
        };
        ConnectApi.LiteralJson result = Wave.QueryBuilder.load(DATASET_ID, DATASET_VERSION_ID)
            .filter('Id == "' + id + '"')
            .foreach(projs)
            .execute('q');

        JSONParser parser = JSON.createParser(result.json);
        Response res = (Response) parser.readValueAs(Response.class);

        return res.results.records;
    }


    public class ScoreComparator implements Comparator<Record> {
        public Integer compare(Record r1, Record r2) {
            if (r1.score < r2.score) {
                return -1;
            }
            if (r1.score > r2.score) {
                return 1;
            }
            return 0;
        }
    }

    public class Response {
        public Results results;
    }

    public class Results {
        public List<Record> records;
    }

    public class Record {
        public String id;
        public String partnerId;
        public String partnerName;
    }
}

Autolaunched Flow では適当な Contact ID を取得して APEX Action に渡すようにしておきます。今回は Flow のトリガーまでは踏み込まないので、適当に実行してレコメンデーションを生成しておきます。

Recommendation Strategy Flow を作成する

最後の仕上げになりますが、特に難しいポイントはありません。Flow への入力として受け取った recordId を元にレコメンデーションを検索するだけで OK です。

サンプルコードのままであれば your_recommendation_(recordId) を名前に含むレコードを検索すれば、必要なレコメンデーションが一括で取得できます。あとは outputRecommedationsアサインすれば完了です。

Lightning App Builder で Contact のレコードページに Next Best Action コンポーネントを組み込むのをお忘れなく。

まとめ

というわけで、CRM Analytics + Einstein Discovery + Einstein Next Best Action という構成で、マッチングサービス風のレコメンデーション表示機能を実現することができました。

今回のポイントは総じて Einstein Next Best Action 関連で、

  • レコメンデーション承認時の Flow から起動元の情報は得られない
  • Flow で唯一入手可能なのは隠しパラメータの recordId のみ
  • 何かの上位 N 個を表示する機能は創意工夫により実現可能

というところかなと思います。なお、上位 N 個と書きましたが、表示可能なレコメンデーションは4つまでという制限があるため、実際に実現可能なのは上位4つまでの表示に限られます。ご注意ください。

今回は(も)サービスの仕様に振り回されて大変でしたが、うまく仕様上の制約を回避してユースケースの実現に漕ぎ着けたのではないかなと思っています。

実際のレコメンデーション表示画面が気に入っているので、最後にもう一度掲載して終わりにしたいと思います。(ダイマ

以上です。最後までお読みいただき、ありがとうございました。

Einstein Discovery と Einstein Next Best Action を連携する

こんにちは。エンジニアの山下です。

今回は Salesforce の Einstein Discovery と Einstein Next Best Action を連携させ、Lightning Page 上で統計手法ベースのレコメンデーションの出し分けを行う方法について書こうと思います。

Einstein Discovery と Einstein Next Best Action の概要

Einstein Discovery は回帰分析を行うためのモデルをノーコードで構築できるプラットフォームです。モデル構築のための入力データを事前に準備しておく必要はありますが、構築自体は Salesforce 上の簡単な操作のみで実施でき、かつ作成されたモデルの各種メトリクスや推論過程を確認するためのダッシュボード等が完備されています。

画面からの利用方法等の基本的な事柄については以下の Trailhead に十分書かれているため、そちらを参照ください。

trailhead.salesforce.com

もう一方の Einstein Next Best Action ですが、こちらは Flow で定義したロジックに則って Lightning Page 上にレコメンデーションを表示することができるという機能になります。

こちらも基本的な内容は公式ドキュメントの ウォークスルー があれば十分だと思うので、必要であればそちらを参照ください。

統計手法ベースの推論プラットフォームと Flow ベースのレコメンデーション表示機能ということで、この2つのサービスは非常に噛み合いがよいです。これらを組み合わせることで、統計情報を根拠としたレコメンデーション表示機能を実現できるというシナジーを持っています。

連携方法

Einstein Discovery と Einstein Next Best Action の連携方法は2つあり、いずれの方法も Einstein Next Best Action に設定する Flow の Action を利用したものになります。具体的には、

  1. Core Action を利用して連携する
  2. APEX Action を利用して連携する

のいずれかを選択することになります。

2 の方法では APEX コードの管理が必要になるため、特に理由がなければ 1 の方法を採用するのが吉です。1 の方法なら こちら の公式ドキュメントに従って Flow Builder を操作するだけで連携できます。

なのですが、実は筆者が 1 の方法に気づいたのが 2 の方法で実装を終えた後だったという悲しい事情があり、せっかくなので APEX Action を利用した方法も紹介しておきます。

APEX から Einstein Discovery API を利用する

そもそも Einstein Discovery に関連する API 群は Smart Data Discovery というリソースの下で管理されており、APEX でも ConnectApi.SmartDataDiscovery という名前空間にあるクラスを利用することで Einstein Discovery API が利用できるようになっています。このあたりの内容は公式ドキュメントの こちら にサンプルコード付きで書かれています。

今回は推論機能を利用したいので ConnectApi.SmartDataDiscovery.predict() を使います。以下が推論実行のための最小限の APEX コードです。

global class GetPredictionFromEinsteinDiscovery {
  @InvocableMethod
  public static List<Double> get(List<Id> ids) {
    ConnectApi.SmartDataDiscoveryPredictInputRecords predictInput = new ConnectApi.SmartDataDiscoveryPredictInputRecords();
    predictInput.predictionDefinition = '(your-prediction-definition-id)';
    predictInput.records = ids;

    ConnectApi.SmartDataDiscoveryPrediction response = ConnectApi.SmartDataDiscovery.predict(predictInput);
    ConnectApi.SmartDataDiscoveryPredictObject prediction = (ConnectApi.SmartDataDiscoveryPredictObject) response.predictions[0];

    return new List<Double>{
      prediction.prediction.total
    };
  }
}

推論結果は prediction.prediction.total に格納されます。なお、上記のコードでは省略していますが、predictInput.settings に各種設定を施すことができ、推論過程などの情報を追加で得ることができます。

これで推論結果が得られるので、あとは Einstein Next Best Action 側でこの結果を元に条件分岐を行えば OK です。

というわけで、連携できました。簡単でしたね。

そう思っていた時期が私にもありました

そうは問屋が卸しません。難しいのはここからです。

何が難しいかというと、Einstein Discovery API のレスポンスパラメータの意味と仕様を理解するのが難しいのです。これは Core Action でも APEX Action でも同じです。そんなバカなと思うかもしれませんが、実際筆者が最も苦労したポイントがこれでした。

なぜ理解するのが難しいのかというと、ひとえに公式ドキュメントの説明が不十分なためです。レスポンスパラメータの簡単な説明は こちら に一通り書いてあるのですが、あまりにも簡単に書かれており、実際使ってみると戸惑うことが多いです。

どれくらい戸惑うかというと、先ほど示したサンプルコードで推論結果として得ていた total ですが、公式ドキュメントの説明は以下のようになっています。

Final prediction value.

筆者は Einstein Discover で二値分類モデルを作成したのですが、モデル作成時点で画面には以下のような表示があり、二値化の閾値が 0.495 であることがわかります。

そして実際に返ってきた total の値が以下です。

18.518670863527415

二値分類モデルなのに 0 or 1 でないどころか 0-1 の範囲ですらない値が返ってきています。なんですと ... 。

何か見落としがあるのかと思い、公式ドキュメントを見直してみましたが、残念ながら上記以外で total について説明している文言は見当たりませんでした。

Einstein Discovery と Einsten Next Best Action を連携させるためには、この値をベースとした条件分岐のロジックを Flow に実装しなければならないため、この謎を何とか解明しなければなりません。

その他、レスポンスに以下のような変数が存在することもわかりましたが、こちらも公式ドキュメントの説明から内容を理解することはできませんでした。

パラメータ 公式ドキュメントの説明
baseLine double Baseline from where the prediction started. Basically the average if there were no model.
other double Unexplainable portion of the final prediction value.

というわけで、変数名と公式ドキュメントの雰囲気だけの説明を元にレスポンスパラメータを解析する必要性が生じました。つらい。

試行錯誤の結果、幸いにも謎は概ね解けたので、第2第3の被害者がでないことを祈って、その解析結果を以下に書いておきます。

prediction.prediction.total

結論から述べると、total の値は百分率で表現された値でした。つまり total が 18.51 であれば、実際の推論値は 0.1851 という具合です。

どうやってこの結論に辿り着いたかというと、Einstein Discovery には作成したモデルを画面上でお試し実行できる機能があり、こちらを利用している際にわかりました。該当画面のキャプチャ画像が以下です。

画面右に入力パラメータを設定するペインがあり、その入力を与えた場合に出力がどうなるかを中央ペインにあるダッシュボードで確認できるという構成になっています。上記画像の場合、中央ペインの Model Overview に表示されている 0.602 という値が推論結果になります。

この画面を使えば入力と出力の関係が確認できるのですが、この画面で与えた入力と同じ値を API で入力した場合どうなるのかを確認したところ、

60.20929180562907

という total の値が得られたため、この仮説に辿り着きました。その後、入力内容を変えて何度か検証を繰り返し、正しいという確信に至ったという形です。後述する otherbaseLine といったパラメータも同様に全て百分率で表現されているのでご注意ください。何故そんな紛らわしいことを ... 。

ちなみに二値分類モデルの場合でもレスポンスは 0-1 の範囲の推論結果(確率)のみで、二値化の処理は APEX ないし Flow 側に自前で実装する必要があります。ダッシュボード上に表示されている二値化の閾値は、Lightning コンポーネントなどでモデルを利用した場合に適用されるもので、API 経由で推論を利用した場合には適用されないようです。

Einstein Discovery のアルゴリズム(線形回帰モデル)

total の謎は解けました。実のところ、API の利用に必要な知識は total だけで十分で、その他のパラメータは全て解析用のものになります。従って、実益のみが必要な方は以降は読み飛ばしてしまっても問題ありません。

以降で説明する total 以外のパラメータは解析用のため、その意味を理解するには Einstein Discovery で使用されるアルゴリズムについての知識が必要です。なので、まずは Einstein Discovery で使用されているアルゴリズムについて簡単に説明します。

Einstein Discovery では推論に使用するアルゴリズムを選択できるのですが、これらのアルゴリズムは線形回帰モデルをベースとしています。線形回帰モデルでは推論対象が y = ax + b のような式(をもう少し複雑にした式)に従うと仮定して処理を行います。実際には以下のような形の式を仮定しています:

 \displaystyle y = a_0 x_0 + a_1 x_1 + ... + a_n x_n + b

我々が求めたいのは  y の値で、変数  x_n に入力値を当てはめて  y の値を得るのが最終的なゴールになります。この最終的に得たい値  y を目的変数といい、そのために入力する値  x_n を説明変数といいます。 a_n b は事前に収集しておいたデータから導き出した定数で、ひとまとめに回帰係数と呼ばれます。

Einstein Discovery ではこの線形回帰モデルをうまく活かして各説明変数に対応する事象がどのくらい影響力を持つかをダッシュボード上で確認できるようにしています。画面キャプチャを以下に示します。

上の画像の下部に書かれている各事象が説明変数に該当するイメージとなります。

prediction.prediction.baseLine

線形回帰モデルが前提とする式の  b にあたる値が baseLine です。言い換えると式の切片にあたる値です。

baseLine は推論結果に対して無条件に足される値のため、公式ドキュメントの以下の説明に符合しています。

Baseline from where the prediction started. Basically the average if there were no model.

線形回帰モデルと Einstein Discovery のモデルお試し実行画面に表示される以下の Waterfall チャートを合わせて考えると、まず間違いないと思います。チャートからは baseLine に説明変数分の数値(緑のブロック)が加算された結果が最終推論結果になっていることがわかります。

ちなみに baseLine および other の値は APEX で predictInput.settings を設定した場合のみレスポンスに含まれます。デフォルトの設定では null になるので、自身で追試したいという方はご注意ください。

prediction.prediction.other

other の話をするには middleValues の話をしなければなりません。

middleValues は Einstein Discovery API のレスポンスに含まれる比較的理解が容易なパラメータです。公式ドキュメントの説明は以下のようになっています。

List of top predictors that contribute to the prediction.

要するに説明変数の中で最も推論結果への貢献度が高いものを上から順に集めたものです。middleValues の各項には最終推論結果に加算された対象の説明変数の値(先の Waterfall チャートの緑ブロックに該当する値)などが格納されています。なお、middleValues に格納される説明変数の数は predictInput.settings にて指定可能です。

other はこの middleValues に含まれていない説明変数の値の合計値です。つまり貢献度の高い説明変数は詳細に、それ以外はサマリとして別々に取得できるような設計になっているというわけです。

実際に筆者が API で取得した結果を例として挙げると、

// 説明上不要な情報は除いています
middleValues=(
  ConnectApi.SmartDataDiscoveryPredictCondition[
    value=11.385852361102891
  ],
  ConnectApi.SmartDataDiscoveryPredictCondition[
    value=-7.323287318983713
  ],
  ConnectApi.SmartDataDiscoveryPredictCondition[
    value=3.005398168300985
  ]
),
other=0.4877605766188733,
baseLine=10.962947076488378,
total=18.518670863527415

のようになっており、middleValuesotherbaseLine を足し合わせて検証したところ、ちゃんと total に一致するようになっていました。

まとめ

今回の話をまとめると、

  • Einstein Discovery と Einstein Next Best Action は Flow Action で連携できる
  • Einstein Discovery API の推論結果は百分率で返されるので注意する

というところかなと思います。

Einstein Discovery は Salesforce の中では比較的渋いサービスのため、インターネット上にあまり詳細な情報がないという点が今回のネックでした。少なくとも公式ドキュメントにはちゃんとした情報が載っていてほしいという気持ちはあります。

回帰分析自体はそれなりに使いどころがある気がするので、今後、細かい使い勝手が改善することを期待したいですね。第2第3の被害者を出してはいけない。

以上です。最後までお読みいただき、ありがとうございました。

2024/05/16 11:20 頃に記事の内容を一部更新しました。

認証プロバイダ Keycloak の手元テストでコストパフォーマンス最強となった AWS Graviton3

みなさんこんにちは。エンジニアの佐藤です。今回はAWS製プロセッサGraviton3の強さを再確認した、というお話です。

難しい!今時のEC2インスタンス選び

AWS EC2にはさまざまな種類のインスタンスがあります。本稿執筆時点(2024年4月初旬)では、最新世代のCompute Optimizedタイプのインスタンスとして以下の3種類が利用可能です。

種類 プロセッサ 一般利用開始 アーキテクチャ
C7g AWS Graviton3 2022-03 arm64
C7i Intel 4th gen Xeon 2023-09 x86_64
C7a AMD 4th gen EPYC 2023-10 x86_64

AWSは自社開発のGravitonプロセッサの利用を勧めています。他の2つに対して価格が低めに設定されており、電力効率も高いと宣伝されています。Amazonプライムデーなどの自社企画では、これらを活用して優れた電力効率を実現したことが発表されています。一方、Intelは業界一番の老舗であり、一昔前まではクラウド向けプロセッサをほぼ独占供給していました。しかし近年はAMD社製プロセッサの性能向上が著しく、シェアを伸ばしています。

Gravitonプロセッサはarm64と呼ばれる命令セットを採用しているのに対し、Intel/AMDx86_64と呼ばれる命令セットを採用しています。arm64は大雑把にいえばスマートフォンで支配的な命令セットで、これに対しx86_64は伝統的なパソコンで支配的な命令セットです。この2つの命令セットには互換性は無く、ネイティブモジュールはそれぞれ用意する必要があります。とはいえソースコードから再コンパイルすればよく(一部アセンブラで最適化されている部分を除く)、ほとんどのライブラリモジュールは両方の命令セットで提供されています。また、スクリプト(JavascriptPython)やJavaバイトコードは命令セットが異なっても相互運用が可能です。

以上は基本的な話ですが、これに加えて、近年のプロセッサは特定用途向けの命令セットを備えています。代表的なものは以下のような命令セットです。

  • ベクトル演算: 行列計算を高速に行う。
  • 暗号計算: AES, RSA, ECDSAなどの処理を高速に行う。

これらの処理が主要な用途では、適した命令セットを活用することでパフォーマンスアップと電力効率アップが期待できます。ただし新しい命令セットの活用はソフトウェアの対応を要するほか、その他の汎用命令との組み合わせによってはかえって非効率になる場合もあります。

以上のように、今時の最適なEC2インスタンスは、実行するソフトウェアに依存する時代となりました。複雑になったものです。

KeycloakとACCP(Amazon Corretto Crypto Provider)

今回パフォーマンス検証をしたのは、オープンソース認証プロバイダのKeycloakです。シングルサインオン(SSO)を実現するパッケージで、商用版はRedHat Single Sign-Onとして販売されています。

Keycloakは主にJavaで記述されており、arm64とx86_64の両方で実行することができます。

ところで認証プロバイダの主要な処理とは何でしょうか?基本的にはデータベースに各種データを保存するREST APIサーバーですが、パスワード認証やトークンの署名などで暗号計算を頻繁に行います。このため、暗号計算向け命令セットの活用による性能アップが期待できます。AWSAmazon Corretto Crypto Provider(ACCP)を提供しており、Graviton3プロセッサのアピールポイントの一つである暗号計算命令セットが活用できる可能性があります。

パフォーマンス検証を行なったのは、認証プロバイダの最も基本的な機能であるOAuth2 Authorization Code Grantを、2万5千回実行するタスクです。オープンソース認証プロバイダが選択される一つのユースケースとして、ある時刻に極端に大きなトラフィックを受けるオンラインイベントが考えられます。多数のユーザーが一斉にログインするシナリオで、EC2の各種プロセッサにどのような性能差が出るのかを検証しました。

以下のような処理フローとなります。 ※ 赤枠は暗号処理

検証環境

以下が、今回のパフォーマンス検証の環境です。

基本的に、EC2インスタンスにdockerコンテナを展開して作成しました。Keycloakコンテナを十分高負荷にする構成を選び、また、Keycloakは他と同一ラックに収容されないよう、異なるAZに配置しました。

負荷がけではApache Sparkを実行して25,000ユーザーの一斉ログインを仕掛けました。1タスク200ユーザーの粒度で、2 executorで実行。1 executorあたり8スレッドを起こしてそれぞれのユーザーのログイン処理を実行しました。Sparkジョブの開始時刻と終了時刻の差分を所要時間として計測し、計測はKeycloakコンテナ起動直後のコールドスタートと、再実行した場合の合計2回行い、平均を評価しました。

Keycloakには、以下の6種類を用意しました。(価格は本稿執筆時点のもの)

名称 Keycloakコンテナ EC2インスタンス 東京リージョンの価格(米ドル/時間)
base-c7g オフィシャル※1(arm64) c7g.xlarge 0.091
base-c7a オフィシャル(x86_64) c7a.xlarge 0.1292
base-c7i オフィシャル(x86_64) c7i.xlarge 0.11235
accp-c7g カスタム※2(arm64) c7g.xlarge 0.091
accp-c7a カスタム(x86_64) c7a.xlarge 0.1292
accp-c7i カスタム(x86_64) c7i.xlarge 0.11235

※1: quay.io/keycloak/keycloak:23.0.7 ※2: Keycloak 23.0.7、Amazon Corretto 17、Amazon Corretto Crypto Provider(ACCP) v2.3.3を利用したカスタムビルドコンテナ

ベンチマーク中は、KeycloakインスタンスのCPU使用率が80%前後と高率で、負荷がけ環境、nginx, postgresはいずれもCPU使用率は低い状態でした。結果には各CPUの性能差が反映されると期待しました。

結果

細かい数値は割愛します。結果のサマリーは以下の2点です。

  • c7g.xlargeが最も優秀。accp-c7gはbase-c7aと比較して97%の性能を、70%のコストで実現する。1米ドルあたりの性能は36%も高い。
  • Amazon Corretto Crypt Providerの効果は、c7g.xlargeが最も高い。

ログイン速度は、速い順にc7a, c7g, c7iとなりました。また、c7a, c7gではACCPを利用した方が高速になり、c7aで+4%、c7gで+8%の改善となりました。c7aとc7gでは論理コア機能が無効で1物理コア=1論理コアとなっています。これに対してc7iは1物理コア=2論理コアとなっていますので、このような結果になったと考えられます。

速度を価格で除した「1ドルあたりの処理速度」は、高い順にc7g, c7a, c7iとなりました。Graviton3プロセッサの費用効率の高さが際立ちます。

結論として、Keycloakを展開する場合は、c7gインスタンスが最善の選択と言えると思います。ACCPを忘れずに適用しましょう。

振り返って

ここまでお読みいただきありがとうございます。AWSはGravitonプロセッサの費用対効果を盛んに宣伝していますが、それを裏付ける結果となりました。昨今はプロセッサにさまざまな専用命令が搭載されるようになり、それらを活用することで一層のパフォーマンスアップが実現できます。今回のケースで言えば、漫然とx86_64とKeycloakオフィシャルコンテナを使うより、arm64とACCP付きカスタムコンテナにすることで4割近い価格性能比向上を実現できました。最新テクノロジーを常に評価していくことが重要だと思います。

Agents for Amazon Bedrock の手触り

こんにちは。エンジニアの山下です。

昨今の LLM の隆盛は目を見張るものがあり、フレクトでも LLM を使った開発に必要な技術や知識の準備を進めています。その一環として Agents for Amazon Bedrock を使って模擬的に LLM アプリケーションを実装・調整してみたのですが、今回はそこで得た知見について書こうと思います。

本投稿では主に Agents for Amazon Bedrock を使ってみて得た所見について書きますが、前提を共有するため、最初に Agents for Amazon Bedrock の概要とアーキテクチャについても簡単に述べます。既に知っているという方は読み飛ばしていただいて OK です。

Agents for Amazon Bedrock の概要

Amazon Bedrock は AWS が提供する LLM サービスですが、その提供サービスの1つである Agents for Amazon Bedrock は LLM アプリケーションの開発を支援する開発プラットフォームです。

その特徴はとにかく手軽に開始できることで、AWS コンソールでいくつかの設定を行うだけで LLM + RAG のアプリケーションが構築できてしまいます。RAG に必要なデータストアも S3 にファイルを格納しておけば自動で構築してくれる上、構築した LLM + RAG を試用するためのプレイグラウンドやデバッグのためのトレース機能もあり、開発者にとっては割と至れり尽くせりな環境です。

ただし、選択可能な LLM には制限があり、現時点ではテキスト生成に利用可能な LLM は Anthropic Claude シリーズのみで、かつ最新モデルの Claude V3 はサポート外です。利用可能なモデルは以下の3つになります:

  • Anthropic Claude V2
  • Anthropic Claude V2.1
  • Anthropic Claude Instant V1

Claude V2 / V2.1 は高性能推論モデルで、Claude Instant は処理速度に特化した軽量モデルです。なお、Claude V3 は Agents for Amazon Bedrock では利用不可ですが、他の Amazon Bedrock のサービスでは利用可能です。

Embedding モデルは以下の中から選択可能です:

  • Amazon Titan Embeddings G1 - Text v1.2
  • Cohere Embed English v3
  • Cohere Embed Multilingual v3

なお、Agents for Amazon Bedrock は現時点では US East と US West の2つのリージョンでしかサポートされておらず、日本リージョンでは利用できない点にはご注意ください。

Agents for Amazon Bedrock のアーキテクチャ

Agents for Amazon Bedrock で作成された LLM アプリケーションは規定のアーキテクチャに則って実装されます。以下に概要図を示します。

基本的には前処理・後処理にあたる Pre-processsing・Post-processing の間にメインの処理である Orchestration が挟まれている構成になっています。大まかな流れとしては、

  1. Pre-processing で危険な入力のフィルタリングを行う
  2. Orchestration でユーザへの回答の生成に必要な手順の作成と実行を行う
  3. Post-processing で出力データの整形などを行う(任意)

という形になります。

さらに上記 2 の Orchestration では回答に必要なデータを外部から取得することが可能で、これは図中の Invoke Action というブロックで表現されています。データの取得方法は AWS Lambda の実行ないし Knowledge Base (KB) の検索のいずれかが選択できます。なお Knowledge Base とは RAG の参照に使用されるデータストアのことで、冒頭でも少し触れましたが、基本的に S3 から生成してくれます。

なお、図中にあるこれらのブロックは全てプロンプトエンジニアリングの対象となります。Invoke Action を除く4つのブロックは Advanced Prompt と呼ばれる独自のプロンプトを持ち、また Invoke Action では Orchestration が実行要否を判断するための説明文を各アクションに付与する必要があるためです。ちなみに各ブロックで使用される LLM は全て共通(選択した Claude モデル)です。

また、プロンプトエンジニアリング以外にも Knowledge Base に格納するデータのチャンク単位や格納するデータの内容自体(文言や形式など)も LLM に合わせて調整する対象となり得ます。

実際に使ってみて感じたこと

前置きが長くなりましたが、ここからが本題です。Agents for Amazon Bedrock を実際に使ってみて得られた所見について述べたいと思います。内容は以下の通りです:

  • 構築から試用までが非常に容易
  • 各 Knowledge Base が単一の責務しか持たない設計が強制される
  • 日本語の扱いには難がある
  • LLM からの出力内容が比較的安定しない
  • RAG の実行が重い
  • ユーザへの質問を許可するオプションの質が今ひとつ

構築から試用までが非常に容易

冒頭でも述べましたが、とにかくアプリケーションを構築して試用するまでが高速かつ容易に行えます。Agents for Amazon Bedrock の最大の魅力がこれで、開始地点に立つまでに LLM の知識がほぼ不要なため、とにかく試したいという場合には非常に有用です。

なお、構築に必要な時間の大部分は Knowledge Base の生成で、S3 に格納されたファイルを処理して DB を構築するのに少し時間がかかります。参考までに、筆者の場合はフレクトの社員規則が書かれた 50 個程度の PDF を Knowledge Base にしましたが、構築までに数分程度かかりました。

Knowledge Base は一度構築してしまえば差分だけの更新が可能なので、更新に初期構築ほどの時間はかかりません。アーキテクチャの項でも述べましたが、格納するデータ自体も調整の対象となるため、これは結構重要です。ただし、チャンク戦略や Embedding モデルを見直す場合などは例外で、再構築が必要になるので注意が必要です。

解析環境も潤沢で、Pre-processing や Orchestration で何が行われたのかを容易にトレース可能です。また、Advanced Prompt に設定されているデフォルトのプロンプトはかなり精巧に作り込まれており、プロンプトを読むことで学ぶことも多いです。

個人的には Agents for Amazon Bedrock は LLM の学習環境という意味では随一だと思っています。

各 Knowledge Base が単一の責務しか持たない設計が強制される

アーキテクチャの項で説明した通り、Invoke Action が参照するアクションには各々説明文が付与され、その説明文に基づいた実行要否の判断が Orchestration で行われます。Agents for Amazon Bedrock では Knowledge Base の参照を Invoke Action 経由で行うため、当然ながら、Knowledge Base に対しても格納するデータの概要を表す説明文を付与する必要がありますが、これには上限 150 文字という制限があります。これは結構厳しい制約で、以下に述べる通り、Knowledge Base の構築方針を半ば強制してきます。

そもそも Knowledge Base を参照させるには、Orchestration によって「ここを検索すれば必要な情報が得られるぞ」ということを実際に検索を行う前に伝えなければならないというジレンマがあります。これは結構悩ましい問題です。

例えば、A というシステムについての情報を格納した Knowledge Base を作成し、その説明文として「この Knowledge Base には A についての情報が格納されています」という文章を与えたとします。この時、実はこの Knowledge Base が A と連携する B という別のシステムについての情報を格納していたとしても、B についてのみ言及した質問をされた場合、この Knowledge Base は参照されない可能性があります。

この問題にナイーブに対応すると、説明文を修正して「この Knowledge Base には A および B についての情報が格納されています」としたくなりますが、言うまでもなく、これは説明文の文字数を圧迫します。実は A に B 以外の複数の連携先システムが存在した場合、この手法は破綻します。

この問題を根本的に解決するためには、Knowledge Base を複数に分け、各々が単一の責務のみを持つように構成を修正するしかありません。この修正により、個々の Knowledge Base が小さく抑えられ、検索精度や検索性能の向上も見込めます。このような設計を強制するために、説明文はあえて短い文字数に抑えるという設計判断がされたように思えます。

一方で、これを行うにはあらかじめ誰かが Knowledge Base に格納するデータを責務を定めて分類しておくという作業が必要になります。これは Agents for Amazon Bedrock での作業工数を判断する際の1つのポイントになりそうです。

日本語の扱いには難がある

Agents for Amazon Bedrock で使用可能な Claude モデルは、他社の LLM に比べて日本語の扱いがやや苦手な印象があります。特に敬語の扱いが今ひとつで、以下のような問題に悩まされました。

  • 敬語を使用せよというプロンプトを無視してくる
    • 頻繁ではないが一定の頻度で発生する
  • やたらと慇懃である
    • 「〜と存じます」など
    • ごく稀に発生する
  • 無駄な接頭辞を付与してくる
    • 「敬語を使用して申し上げます」など
    • ごく稀に発生する

今回はプロンプトエンジニアリングをベースに改善を試みたのですが、結局最後まで完全に敬語を強制することはできませんでした。

あまりに敬語の強制が難しいので、敬語への変換だけを別処理として切り出して Post-processing で処理するようにもしてみたのですが、逆にやたらと慇懃な敬語に変換される等の問題が増加してしまい、プロンプトエンジニアリングや処理上の工夫だけでは完全な解決は難しいと結論せざるを得ませんでした。

最終手段として Claude にファインチューニングを施すという方法もありますが、これは極めて高価かつ手間のかかる方法なので、敬語の強制程度の話では採りたくない戦略です。それをやるくらいなら他の高性能モデルが使用できるプラットフォームを使うべきだろうという感もあり、今回はファインチューニングまでは立ち入らず、単に一定数の敬語に関する失敗は受け入れるという方針としました。

また、敬語の問題の他、RAG の検索結果に対して日本語特有の曖昧さを残す表現(〜だと思います)を使ってくることもあり、これも UX を微妙に損なうので何とかしたかったのですが、最終的には一定数は諦める方向となりました。日本語は難しいですね ... 。

Claude V3 は V2 よりも日本語の扱いがかなり上達しているように見えるので、そちらが利用可能になれば状況は変わるかもしれません。

LLM からの出力内容が比較的安定しない

Gemini などの他社の LLM と比較すると、Agents for Amazon Bedrock で使用可能な Claude モデルは出力内容がやや不安定な印象があり、モデルの気分次第で回答内容が変わります。これは Claude V2 でも Claude Instant でも同じです。

そもそも Agents for Amazon Bedrock では Temperature の値(出力内容の安定度を制御するために使用される値)が 0 に設定されており、最初から最も安定した回答が得られる設定となっていますが、それでも回答内容にはかなりのばらつきがあります。

一応、プロンプトの品質による問題の可能性もあったため、Anthropic が公開している プロンプトエンジニアリングガイド を精査して修正を行いましたが、そもそもプロンプトの指示自体がしばしば無視されるということもあり、改善されることはありませんでした。厳密な回答が求められる用途では Agents for Amazon Bedrock の採用は(少なくとも現時点で選択可能なモデルのラインナップでは)難しそうです。

また、Pre-processing で行われる危険な入力のフィルタリングにも同様の不安定さがあります。そのため、同じ内容の入力をした場合でも回答が拒否されたりされなかったりします。Pre-processing では入力を以下の A-E の5つのカテゴリのいずれかに分類して判断を下しますが、特に B や C が問題になりやすいです。

  • A: 人を傷つける可能性のある入力である
  • B: 情報窃盗を意図する可能性のある入力である
  • C: 提供されている情報やアクションでは回答不能な入力である
  • D: 提供されている情報やアクションで回答可能な入力である
  • E: LLM からの質問に対する回答を意図した入力である

なお、E については、LLM からユーザへ質問を返すことを許可するオプションがあり、それを有効にした場合にのみ使用されるカテゴリとなります。

Pre-processing の不安定さは、セキュリティや反社会勢力への対応などの微妙な話題に踏み込んだ際に顕著になります。筆者の場合、フレクトの社員規則について回答する AI アシスタントを実装して評価していたのですが、自社社員からの質問という前提を盛り込んでセキュリティの基本方針について訪ねただけでも回答が拒否されるといったことがしばしばありました。意図推論は難しい問題なので、多少揺らぎがあるのは仕方ないと思うのですが、モデル自体の出力が不安定なため調整が困難という点が今ひとつに感じました。

Pre-processing に触れたので、ついでに述べておくと、Pre-processing には入力が拒否された場合に必ず英文で回答するという謎の仕様があり、日本語圏での利用を想定している場合は輪をかけて困ったことになります。具体的には以下のような文言で回答が行われます:

Sorry, I don't have enough information to answer that.

一応、こちらは API レスポンスの中身を精査することで回避可能です。具体的には、API レスポンスの気が遠くなるほど深い以下の場所に Pre-processing の合否判定結果が格納されているので、それを見て自前の出力に差し替えれば OK です。

(response).completion.trace.trace.preProcessingTrace.modelInvocationOutput.parsedResponse.isValid

レスポンスの詳細については AWS の API リファレンス を参照ください。

RAG の実行が重い

Knowledge Base が Invoke Action 経由で実行される関係上、入力に対して RAG が実行される場合とされない場合がありますが、この RAG の実行の有無の差による実行速度比がかなり顕著です。

Claude Instant を使った場合、RAG なしでの実行は長くとも数秒程度なのですが、RAG ありだと最低でも十数秒はかかるようになってきます。Claude V2 に関しては RAG が実行されると回答までに1分以上かかることもあり、boto3 のデフォルトのタイムアウト値を超えて応答待ちが打ち切られたりします。

現時点で Agents for Amazon Bedrock は US リージョンでしか公開されていないため、日本からアメリカにアクセスしているとはいえ、大部分は LLM の推論にかかる時間と考えられるため、速度面にはやや不安を覚えるという印象です。

なお、今回は試していませんが、性能改善のために Provisioned Throughput が利用可能です。Agents for Amazon Bedrock の採用を考える際の考慮事項の1つになりそうです。

ユーザへの質問を許可するオプションの質が今ひとつ

Agents for Amazon Bedrock のオプションの1つに「追加の情報収集のために LLM からユーザへの質問を許可する」というものがありますが、これは無効にしておくのを推奨します。理由は単に生成される質問の質が低いからです。

実際どれくらいの質なのかというと、ユーザが A について教えてくれと質問した場合に、A について教えてくれれば A について回答しますと答えてくるようなレベルです。既に A について知っているなら訊きはしない。

まとめ

今回得た知見をまとめると以下のようになりそうです:

  • 開発環境としての質は高い
  • 厳密な回答を要するユースケースへの利用は難しそう
  • 速度性能に若干の不安がある

全体を通して、開発プラットフォームとしての品質は高いのですが、選択可能な LLM の性能に依存する問題が多いという印象です。LLM が不安定で性能改善が難しいという性質がある以上、現状での案件採用は少し躊躇しますが、Claude V3 が選択可能になれば可能性がぐっと広がりそうという期待感はありそうです。

また、テキスト生成モデル側は色々と問題を抱えている印象ですが、Embedding モデルについては特に大きな問題を感じることはありませんでした。筆者が試した中では Amazon Titan Embeddings G1 - Text が最良だったので、調査中はそれを継続使用していましたが、ドキュメントの検索精度は十分かなと思いました。

RAG に関しては、むしろ格納されているデータを調整する方が多かったです。一般的に PDF は RAG の参照データの形式にはマッチしていないと言われますが、これには筆者も同意です。Knowledge Base 構築時には自動的に PDF からテキストを抽出してデータストアに格納してくれますが、その際に混入する様々なノイズ(無意味な半角スペースやグラフの崩れなど)が LLM の推論精度を落とす要因となるため、結局自分の手でテキストに変換したりしていました。個人的には、この辺りの LLM 全般に関わる知見も得られたことが大きかったです。

以上です。最後までお読みいただき、ありがとうございました。

Passkey のフィッシング耐性

こんにちは。エンジニアの山下です。

パスワードレス技術として Passkey が注目を集めていますが、この Passkey はフィッシング耐性を持つと言われています。今回は Passkey のフィッシング耐性を支える仕組みについて調査した結果を書きたいと思います。

Passkey と FIDO

そもそも Passkey は FIDO Alliance という団体によって定められた概念です。FIDO Alliance の 公式ページ では Passkey を以下のように定義しています:

Any passwordless FIDO credential is a passkey.

FIDO Alliance は FIDO (FIDO2) というパスワードレス認証の技術標準を定めており、この FIDO で使用されるパスワード以外のクレデンシャルを全て Passkey と呼ぶ、と定義は述べています。

実のところ、フィッシング耐性を持つように設計されているのは FIDO であり、単なるクレデンシャルに過ぎない Passkey にはいかなる力もありません。従って、以降は FIDO ではフィッシング耐性をどのように実現しているのかが話の主題となります。

FIDO については 公式ページ にある以下の画像を見るとイメージが掴みやすいと思います:

FIDO では Authenticator と呼ばれる認証デバイス(PC・スマホ・USB ドングルなど)を利用するのですが、上の画像は Authenticator としてスマホを利用した場合のログインの一連の動作を表しています。上記における USER APPROVAL および KEY SELECTED が Authenticator の利用箇所です。ブラウザを使って目的のサイトにアクセスした端末と Authenticator が連動して認証処理を実現している様子が表現されています。

Passkey は WebAuthn とセットで語られることも多いですが、この WebAuthn は FIDO の仕様の一部です。FIDO は全体の挙動を定めるプロトコルの CTAP と、その CTAP においてブラウザ・Authenticator の間の通信に使用するインターフェースとなる JavaScript API を定める WebAuthn で構成されています。

FIDO のフィッシング耐性

FIDO のフィッシング耐性は以下の3つの要素で成り立っているようです:

  • 機密情報のネットワークからの隔離
  • Origin Binding
  • Channel Binding

大まかに述べると、最初の「機密情報のネットワークからの隔離」は機密情報(パスワードや生体情報などのユーザが直接関与するシークレット)を守るためのアーキテクチャの原則で、それ以外は中間者攻撃 *1 に対抗するためのセキュリティ上のテクニックという構成になっています。

以下、それぞれについて順に説明していきます。

機密情報のネットワークからの隔離

古典的な認証システムでは、パスワードなどの機密情報を暗号化されたネットワークチャネルを通じてサーバに送信しますが、FIDO ではそもそも機密情報をネットワーク上に露出させる必要がないような設計になっています。

先述の通り、FIDO では Authenticator と呼ばれる認証用のデバイスを用いて認証を行いますが、機密情報の扱いは Authenticator の中で閉じるようになっています。具体的には以下の流れで認証を行います:

  1. サーバがブラウザにチャレンジ(認証要求)を送信する
  2. ブラウザが Authenticator にチャレンジを送信する
  3. Authenticator がユーザに機密情報の入力を促す
  4. ユーザが Authenticator に機密情報を入力する
  5. Authenticator がブラウザにチャレンジの結果を送信する
  6. ブラウザがサーバにチャレンジの結果を送信する

一方で、詐取対象となり得る以下のものはネットワークに晒されます:

  • Authenticator が生成するクレデンシャル
  • Cookie などに含まれる認証情報(セッションなど)

前者ですが、新規サービスの登録要求の際、Authenticator が認証に成功すると内部でクレデンシャルを生成します。このクレデンシャルは Authenticator がブラウザに送信する応答内容の作成等に使用されます。

しかしながら、このクレデンシャルは公開鍵暗号のキーペアで、その秘密鍵はやはりネットワーク上に露出することはありません。従って、利用している公開鍵暗号アルゴリズムが暗号学的に突破されない限り、安全性は機密情報と同程度になると考えらえます。

後者の認証情報については、残念ながら上記のフローの範疇では一切保護されないため、別途セキュリティ手法を導入するなどして保護する必要があります。後述の Channel Binding はその一例となります。

Origin Binding

Origin Binding とはクレデンシャルとチャレンジ要求元サーバの Origin を紐付けて、ある Origin からの要求で作成されたクレデンシャルは同一の Origin からの要求でなければ受け付けないようにする仕組みのことです。具体的には、ブラウザから Authenticator へリクエストを発行する際に Origin を同封し、Authenticator はその Origin を元に必要なクレデンシャルを判断するという流れになります。

フィッシングの代表的手法に、ユーザを本物そっくりの偽サイトへ誘導してクレデンシャルを詐取する中間者攻撃がありますが、この手の詐取は Origin Binding で防ぐことができます。外観がそっくりでも Origin は異なるため、プロトコルによって機械的に詐取の防止が可能です。

余談ですが、Origin Binding はそれなりに強いネットワーク構成上の制約になるため、緩和のための要望が GitHub で議論されているようです。

FIDO における Origin Binding

FIDO では Origin は WebAuthn の定める RP ID に格納して Authenticator に通知されることになっています。

MDN のサンプルコードがわかりやすいです。create() では publicKeyrp.id として Origin が渡されます:

const publicKey = {
  challenge: new Uint8Array([117, 61, 252, 231, 191, 241, ...]),
  rp: { id: "acme.com", name: "ACME Corporation" },
  user: {
    id: new Uint8Array([79, 252, 83, 72, 214, 7, 89, 26]),
    name: "jamiedoe",
    displayName: "Jamie Doe"
  },
  pubKeyCredParams: [ {type: "public-key", alg: -7} ]
}

navigator.credentials.create({ publicKey })

get() では publicKeyrpId で Origin が渡されます:

const publicKey = {
  challenge: new Uint8Array([139, 66, 181, 87, 7, 203, ...]),
  rpId: "acme.com",
  allowCredentials: [{
    type: "public-key",
    id: new Uint8Array([64, 66, 25, 78, 168, 226, 174, ...])
  }],
  userVerification: "required",
}

navigator.credentials.get({ publicKey })

Origin の改竄可能性

1つ前のセクションを読めばすぐにわかりますが、Origin の指定は JavaScript で行います。これは WebAuthn が指定可能な Origin に幅を持たせているために生じた仕様だと思われます。*2

JavaScript はサーバからダウンロードしたプログラムに過ぎないので、攻撃者が悪意ある JavaScript を仕込んだ場合に Origin の改竄ができないかどうかが気になるところです。

結論から述べると、この rpId を改竄して Authenticator に送信しようとすると、リクエストはエラーとして処理されます。rpId が Origin を正確に表しているかどうかをブラウザが検証しているためです。この振る舞いは WebAuthn の仕様にも明記されています(太字による強調は本記事によるもの):

By default, the RP ID for a WebAuthn operation is set to the caller’s origin's effective domain. This default MAY be overridden by the caller, as long as the caller-specified RP ID value is a registrable domain suffix of or is equal to the caller’s origin's effective domain.

つまり Origin を偽装するには、ブラウザ自体を侵害して悪意あるリクエストを発行可能にするか、ブラウザと Authenticator の間の通信に割って入るかのいずれかが必要になります。どちらも実施するにはそれなりの手間と技術が必要になりそうです。

Channel Binding

Channel Binding とは、認証処理を TLS コネクションに紐づけることで、攻撃者による認証情報のハイジャックを防ぐ仕組みです。

Channel Bindign では、認証で使用する TLS コネクションを一意に特定可能な ID を生成し、その ID が保証する正しい経路でのみ認証情報のやりとりを許可します。具体的には、以下の流れで通信が正しい経路で行われていることを検証します:

  1. 通信の送信側で送信に使用する TLS コネクションの ID を生成する
  2. 通信の送信側から ID と改竄防止のための署名を受信側に送信する
  3. 通信の受信側で受信に使用した TLS コネクションの ID を生成する
  4. 通信の受信側で受信した ID と生成した ID が一致することを検証する

この検証によって中間者攻撃の検出が可能です。中間者攻撃を受けている場合、通信は以下のような構成になります:

Authenticator ⇄ ブラウザ ⇄ 攻撃者 ⇄ サーバ

攻撃者がブラウザとサーバの間に入ったことで、ブラウザ(送信側)とサーバ(受信側)が使用する TLS コネクションの ID が一致しなくなります。

また、認証情報が使用可能な通信経路が限定されるため、攻撃者が Cookie の詐取などで得た認証情報を別経路で使用することができなくなるという利点もあり、セキュリティにおいてはかなり恩恵の大きい機能です。このあたりの具体的な話については FIDO に詳しく述べられているので、興味がある方はそちらを参照していただければと思います。

FIDO における Channel Binding

FIDO では Token Binding を利用して Channel Binding を実現するように仕様が規定されています。Token Binding とは TLS コネクションを一意に特定可能な ID を生成する技術の標準仕様です。ただし、様々なユースケースを考慮して Channel Binding の利用は任意とされています。

Token Binding に関するデータの実態は WebAuthn で CollectedClientData として定義されているオブジェクトの tokenBinding パラメータの中に格納されます。この CollectedClientData はブラウザから取得した情報を元に Authenticator が作成し、それがブラウザ経由でサーバへ渡る形になります。Authenticator が作成するのは、同時に署名を作成してデータの改竄を防ぐためです。

CollectedClientData は clientDataJSON というパラメータとして送信されます。MDN のサンプルコードを見ると、Authenticator からブラウザへの応答の中に clientDataJSON が含まれていることがわかります:

navigator.credentials.create({ publicKey }).then((publicKeyCredential) => {
  const response = publicKeyCredential.response;

  // Access attestationObject ArrayBuffer
  const attestationObj = response.attestationObject;

  // Access client JSON
  const clientJSON = response.clientDataJSON;

  // Return authenticator data ArrayBuffer
  const authenticatorData = response.getAuthenticatorData();

  // Return public key ArrayBuffer
  const pk = response.getPublicKey();

  // Return public key algorithm identifier
  const pkAlgo = response.getPublicKeyAlgorithm();

  // Return permissible transports array
  const transports = response.getTransports();
});

つまり、全体の流れとしては以下になります:

  1. ブラウザが Authenticator に Token Binding の情報を送信する
  2. Authenticator が clientDataJSON とその署名を作成する
  3. Authenticator がブラウザに clientDataJSON とその署名を送信する
  4. ブラウザがサーバに clientDataJSON とその署名を送信する
  5. サーバが独自に Token Binding の情報を作成し clientDataJSON の中に含まれる tokenBinding が正当であるかを検証する

少なくともこのレベルの視点においては、なかなか堅牢そうな造りに見えます。

Token Binding の実際

突然ですが、悲しいお知らせをしなくてはなりません。WikipediaToken Binding に書かれている以下の一文をお読みください。

Only Microsoft Edge has support for token binding.

恐ろしいことにほとんどのブラウザが Token Binding をサポートしていないのです。状況は芳しくなく、ChromiumGroups には Token Binding に関する実装コードを全削除した旨が記録として残されていたりします。

MDN でも tokenBinding パラメータの横に Deprecated を示すゴミ箱アイコンが表示されており、Token Binding は廃れていく方向にあるように見えます。

念のため Google ChromeSafariMicrosoft Edge で Passkey を使用した際に clientDataJSON がそれぞれどうなるのかを試してみました。なお、サーバは Auth0 を使用しています。

結果は以下の通りです:

{
    "type": "webauthn.get",
    "challenge": "WBlOWP_-Mkf6Z-fbLbnK0gUgYRD-lBPltbGTFk_GNlY",
    "origin": "https://test-dyamashita.jp.auth0.com"
    "crossOrigin": false
}
{
    "type": "webauthn.get",
    "challenge": "b0SV24IqAAUOaMXuXPvQegQ_fmgOfTKsl79CCiZvIMM",
    "origin": "https://test-dyamashita.jp.auth0.com"
}
{
    "type": "webauthn.get",
    "challenge": "wCjiCpMCXCa9oQWT1pFoKMXKXyFG5yIAqtvHFGit6DQ",
    "origin": "https://test-dyamashita.jp.auth0.com",
    "crossOrigin": false
}

Token Binding をサポートしているはずの Microsoft Edge ですら tokenBinding パラメータがないという結果になりました。やはり現状では Passkey の Channel Binding は機能していない状態になってしまうようです。

FIDO は ブログ で Channel Binding の利用によって克服される問題について語っていますが、これらについては別途対策を取る必要があるということになりそうです。

まとめ

今回調べてわかった結果をまとめます:

  • 機密情報のネットワークからの隔離
    • 機密情報は Authenticator の中で扱いがとじているので詐取は困難
    • Authenticator が生成するクレデンシャルも公開鍵暗号により詐取は困難
    • ただし認証情報自体の詐取に対する効力はなさそう
  • Origin Binding
    • 外観の類似などの人間の勘違いを基礎にした詐取は防止できる
  • Channel Binding
    • Passkey では機能していない

ちなみに、上記は WebAuthn を使ってフロントエンドを実装する際に特別注意を払わずとも自動的に有効になります。

WebAuthn が絡むもので現在機能しているのは Origin Binding のみですが、Origin Binding に使用するパラメータ rp.id ないし rpId は特に指定せずともデフォルトで現在の Origin が自動で適用されるようになっているためです。MDN に記載があるので、詳しくはそちらを参照ください。

また Channel Binding も Authenticator から受け取った clientDataJSON をそのままサーバに返すのみでよいので、必要な実装を行えば自然に有効になるようになっています。もっとも、先述の通り、現在は Token Binding のサポート状況のため機能していませんが ... 。

FIDO について色々調べている中で Channel Binding について熱く語られている記事を目にしていたため、実際には Channel Binding が機能していない点には非常に驚きました。Origin Binding でも中間者攻撃に対抗できるとはいえ、Channel Binding による恩恵はかなり大きいので、今後のアップデートで代替方法が検討されることに期待したいです。

なお、上記で困難と書いている内容についても、当然ながら、ソーシャルエンジニアリングなどによって突破される可能性はあります。安心し切ってセキュリティホールを見落とさないようにしましょう。(自戒

以上です。最後までお読みいただき、ありがとうございました。

*1:厳密には中間者攻撃以外の攻撃もカバーしています。特に Channel Binding は詐取した認証情報を別のコネクション上で使用することを阻止するため、より広範な攻撃を阻止できます。

*2:どのように幅を持たせているかについては こちら に明記されています。

InfinispanをRedisの代わりに使ってWebサーバーをHA構成する

皆さんこんにちは。エンジニアの佐藤です。今回はメモリキャッシュのお話です。

Redisはポピュラーなメモリストレージではあるが。。

Webアプリケーション開発者の方々なら、Redisを知らない人はいないでしょう。メモリストレージの定番として、筆者が認識し始めたのは10年ぐらい前でした。無料で利用可能で、Dockerコンテナで手軽にデプロイできます。今ではクラウドベンダーはほとんどRedisをSaas提供しており、ローカル開発環境からクラウドの本番環境にそのままま乗せ替えできる点も便利です。

しかし、問題はお値段です。Redisは当然、CPUを占有します。CPUは高価なリソースです。さらに頭が痛いのが、高可用性(HA)構成です。RedisをHA構成しようと思ったら、待機系を用意しなければならず、費用は2倍です。つまり、WebサーバーのHA構成と合わせてCPUを4つ運用する必要があります。昨今は様々な調達方法がありますので実際は単純ではありませんが(本稿執筆中にAWSからElastiCache Serverlessなるアナウンスもありました)、Redisが本質的に高価なストレージであることに変わりはありません。

Infinispanという選択肢が

そんな折、Infinispanなるものの存在を知りました。RedhatJBossの一部としてオープンソースで開発しているもので、商用ライセンスはDataGridとして販売されています。業務要件でこのInfinispanを展開する機会があり、調べて試しているうちに、これはRedisの代わりに使えるんじゃないかと思い始めました。(ただし、仕様は異なります。InfinispanはRedis RESP3エンドポイントもありますが、サポートされるコマンドは限定的です。)

Infinispanの特徴は、HA構成をサポートしていながら、クラスタノード同士は対等な、マルチマスター構成である点です。(もちろん、主系待機系構成のRedisとどちらが良いという問題ではありません。)普段は調達リソースの100%のパフォーマンスを期待し、障害時は半分で我慢する、という構成が取れます。

そこで今回は、このInfinispanをWebサーバーのメモリストレージとしてHA構成する方法をご紹介したいと思います。何かの参考にしていただければ幸いです。

まず最初に、作戦を立てる

Infinispanは高機能なので、様々な構成が可能です(興味のある方は文末の余談「高機能だが知名度が低く、難解なInfinispan」をお読みください。)その分迷いやすく、結局どうすれば良いのかわからなくなりがちです。そこで今回は最初に作戦を立てて、参考知識を収集していきます。

今回立てた作戦は以下のようなものです。

  • 基本的にHA構成のWebサーバーです。
  • Webサーバーは、自身のノード(クラウドインスタンスなどに相当)のInfinispanノードにアクセスします。
  • これが2ノード
  • 2つのノードに配置されたInifispanノードは、ひとつのクラスタとして動作します。片方のInfinispanノードに対する変更は、他方のInfinispanノードにも反映されます。
  • クライアントはロードバランサーを経由してこのWebサーバーを見ており、あたかも1台のサーバーがあるかのように見せられています。

Infinispanはポート11222をREST APIエンドポイントとして使います。このエンドポイントはノード内部専用で、認証は設定しません。ポイントは赤点線の中で、Infinispanノード同士は「ノード間通信」と「障害検知」の2つのコネクションでつながっています。前者はポート7800、後者はポート57800です。(文末の余談「Infinispanのポートはどうやって決まっているのか」参照)

この作戦自体は、特に不自然なものではなく、平易に理解していただけると思います。

この作戦を展開するもっとも簡便な方法は、KubernetesのSidecarの利用です。こうするとノード間通信はKubernetesクラスタの内部ネットワークに隔離され、WebサーバーコンテナとInfinispanノードがひとつのPodに配置されます。今回はKubernetes環境にはCanonical MicroK8sをUbuntu 22.04LTSにインストールして使いました。

Infinispanを設定して、動かしてみる

次に、この作戦に従ってInfinispanを設定します。設定はXMLファイルを記述し、これをKubernetes ConfigMapでInfinispanコンテナに指定します。

まず設定のXMLファイルです。

<infinispan
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="urn:infinispan:config:14.0 https://infinispan.org/schemas/infinispan-config-14.0.xsd
        urn:infinispan:server:14.0 https://infinispan.org/schemas/infinispan-server-14.0.xsd
        urn:org:jgroups http://www.jgroups.org/schema/jgroups-5.2.xsd"
    xmlns="urn:infinispan:config:14.0"
    xmlns:ispn="urn:infinispan:config:14.0"
    xmlns:server="urn:infinispan:server:14.0">
    
    <server xmlns="urn:infinispan:server:14.0">
        <interfaces>
            <interface name="public">
                <inet-address value="${infinispan.bind.address:127.0.0.1}"/>
            </interface>
        </interfaces>

        <socket-bindings default-interface="public" port-offset="${infinispan.socket.binding.port-offset:0}">
            <socket-binding name="default" port="${infinispan.bind.port:11222}"/>
        </socket-bindings>

        <endpoints>
            <endpoint socket-binding="default">
                <rest-connector />
            </endpoint>
        </endpoints>
    </server>

    <cache-container name="default">
        <transport stack="kubernetes"/>
        <distributed-cache name="cache01" owners="2" />
        <counters xmlns="urn:infinispan:config:counters:14.0" num-owners="2" reliability="CONSISTENT">
            <strong-counter name="counter01" initial-value="0" storage="VOLATILE"/>
        </counters>
    </cache-container>
</infinispan>

この内容は、infinispanの公開コンテナの内容から筆者が抜粋して作ったもので、以下のような内容です。

  • server/interface要素以下で、localhostネットワークの利用を設定します。
  • server/socket-binding要素以下で、ポート11222をエンドポイントに定めます。
  • server/endpoint要素以下で、REST APIをサービスプロトコルに定めます。

そしてcache-container要素の中でキャッシュストレージを定義していくわけですが、ここが難解です。

<transport stack="kubernetes"/>

これは、Infinispanノード間通信と障害検知に"kubernetes"という名称で既定で定義されている設定を使うという意味です。その定義はここにありますが、本ブログの範囲を超える内容になりますので、割愛します。

次のdistributed-cache要素は、クラスタ化された(つまり片方のInfinispanノードが障害となっても消えない)キーバリューストアです。本ブログでは、定義のみ紹介します。

次のcounters/strong-counterは、クラスタ化されたカウンターで、本ブログでこの後紹介していくものです。strong-counter(解説ブログはこちら)とは、クラスタ化されていて2ノード以上で維持されているストレージでありながら、その更新をトランザクションによって原子的に実行できるカウンターのことです。このカウンターの利用が、先の作戦図のような冗長構成でできることは、Infinispanクラスタが正常動作していることを確認する最も簡便な手段です。

このXML設定ファイルを利用して、作戦図にあるシステム構成を実現する定義ファイルは以下のようになりました。

apiVersion: v1
kind: ConfigMap
metadata:
  name: ispn-config
data:
  infinispan.conf: |
    (ここにXML設定ファイルを貼り付け)
---
apiVersion: v1
kind: Service
metadata:
  name: test01-service
spec:
  type: NodePort
  selector:
    app: test01
  ports:
  - name: http
    port: 5000
    nodePort: 30001
    targetPort: http  
---
apiVersion: v1
kind: Service
metadata:
  name: infinispan-service
spec:
  ports:
  - name: jgroups
    port: 7800
    targetPort: jgroups
  - name: jgroups-fd
    port: 57800
    targetPort: jrogups-fd
  selector:
    app: test01
  type: ClusterIP
  clusterIP: None
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: test01
  labels:
    app: test01
spec:
  replicas: 2
  selector:
    matchLabels:
      app: test01
  template:
    metadata:
      labels:
        app: test01
    spec:
      containers:
      - name: test01
        image: localhost:32000/test01:20231128_03
        env:
        - name: INFINISPAN_REST_ENDPOINT
          value: http://localhost:11222
        ports:
        - name: http
          containerPort: 5000
      - name: infinispan
        image: infinispan/server:latest
        args: [
          "-c", "/usr-config/infinispan.conf",
          "-Djgroups.dns.query=infinispan-service.default.svc.cluster.local",
        ]
        ports:
        - name: infinispan
          containerPort: 11222
        - name: jgroups
          containerPort: 7800
        - name: jgroups-fd
          containerPort: 57800
        volumeMounts:
        - name: user-config
          mountPath: /usr-config
      volumes:
      - name: user-config
        configMap:
          name: ispn-config
      dnsConfig:
        options:
        - name: ndots
          value: "1"

一番重要なポイントは、Service「infninspan-service」を定めていることです。この定義により、Kubernetesの内部DNS名「infinispan-cluster-service.default.svc.cluster.local」により、Infinispanを含むPodのポート7800と57800の列挙ができるようになります。(KubernetesクラスタのCoreDNSプラグインを有効にしておきましょう。)

Webサーバー(test01コンテナ)はポート5000で着信待機し、localhost:11222で 手元の Infinispanノードと通信します。

InfinispanはConfigMapで作成されたvolumeをマウントし、これをinfinispan/serverコンテナの -c 起動オプションで指定することにより設定します。

Webサーバーの内容は、以下のような、Python Flaskを利用したごく簡単なものです。その機能は、ルートパスでアクセスされると、strong counter counter01を「原子的に参照し、加算する」処理を手元のInfinispanノードに対して行います。どのPodで実行されたかを示すために、Podのアドレスも付記することにします。このWebサーバーのエンドポイントはNodePort 30001で外部に公開します。

from flask import Flask
import requests
import os
import socket

INFINISPAN = os.getenv('INFINISPAN_REST_ENDPOINT', 'http://localhost:11222')

app = Flask(__name__)

@app.route("/")
def index():
    counter = requests.post(f'{INFINISPAN}/rest/v2/counters/counter01?action=increment').text
    addr = socket.gethostbyname(socket.gethostname())
    return f'{addr},{counter}'

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

MicroK8sにインポートして、以下のようにWebサーバーの( HA対応の! )Webサーバーのエンドポイントを連続アクセスしてみましょう。

cmd="curl http://localhost:30001"; for i in $(seq 50); do $cmd; echo ""; sleep 0; done

結果は以下の通りで、ごく短時間で別のPodにアクセスした場合でも、正しくカウントアップされています。期待通りに動作していると考えて良さそうです!

10.1.89.54,162
10.1.89.55,163
10.1.89.55,164
10.1.89.55,165
10.1.89.55,166
10.1.89.54,167
10.1.89.54,168
10.1.89.54,169
10.1.89.55,170
10.1.89.54,171
10.1.89.55,172
10.1.89.54,173
10.1.89.54,174
10.1.89.55,175
10.1.89.54,176
10.1.89.55,177
10.1.89.55,178
10.1.89.54,179
10.1.89.55,180
10.1.89.55,181
10.1.89.54,182
10.1.89.55,183
10.1.89.54,184
10.1.89.55,185
10.1.89.54,186
10.1.89.54,187
10.1.89.55,188
10.1.89.55,189
10.1.89.55,190
10.1.89.54,191
10.1.89.55,192
10.1.89.54,193
10.1.89.55,194
10.1.89.55,195
10.1.89.54,196
10.1.89.55,197
10.1.89.55,198
10.1.89.55,199
10.1.89.54,200
10.1.89.54,201
10.1.89.55,202
10.1.89.54,203
10.1.89.55,204
10.1.89.54,205
10.1.89.55,206
10.1.89.54,207
10.1.89.54,208
10.1.89.54,209
10.1.89.54,210
10.1.89.54,211

今度はPodを落として実験してみましょう。

curl http://localhost:30001/

10.1.89.64,5

Podを確認します。

kubectl get pod -o wide

NAME                     READY   STATUS    RESTARTS   AGE   IP           NODE             NOMINATED NODE   READINESS GATES
test01-f9c589f59-6vkbt   2/2     Running   0          12m   10.1.89.64   ip-172-31-0-83   <none>           <none>
test01-f9c589f59-krh6p   2/2     Running   0          12m   10.1.89.63   ip-172-31-0-83   <none>           <none>

先ほど返信した、IP 10.1.89.64のPodを落とします。

kubectl delete pod test01-f9c589f59-6vkbt

pod "test01-f9c589f59-6vkbt" deleted

再びアクセスすると、カウンタは正しくカウントアップされています!

curl http://localhost:30001/

10.1.89.65,6

振り返って、どうか

ここまでお読みくださりありがとうございました。

うまく当初の目標を達成しました。Webサーバーにメモリキャッシュは追加したいが、HAでRedisを構成するのは予算的に難しそうなケースにうまくフィットするかもしれません。

ただしInfinispanの学習・設定の負担はそれなりにあります。本家マニュアルポータルはこちらで、Dockerイメージの説明はこちらで、本ブログもこれらを参照していますが、不足している情報は周辺調査やソースコードを見て補う必要がありました。

「で、結局ミニマムHA構成はどうすれば良いのか?」と思われた時に、本ブログを参考にしていただければ幸いです。

(余談) Infinispanのポートはどうやって決まっているのか

Infinispanがどうやってポートを決めているのかは、簡単に見つかりません。筆者の邪推ですが、その昔、クラスタ内の一群の機材がファイアウォールを定めず自由に通信していた時代の文化のようにも感じられます。

その定義は、以下の部分に書かれています。

障害検知通信ポートは、ノード間通信ポートからのオフセット(ポート番号の足し算)で定義されているので、既定では7800 + 50000 = 57800になります。

JGroupsのマニュアルには、この障害検知ポートは設定されたポート範囲を定めることもできるとあります。これもまた、通信ポートがファイアウォールで制限される前の時代の文化のように、筆者には感じられます。

(余談) 高機能だが知名度が低く、難解なInfinispan

Infinispanは非常に高機能で、継続開発されており、ドキュメントも詳しく書かれています。今回はWebサーバーに併設する方法を採りましたが、単体のストレージクラスタとして構成することも可能で、遠隔バックアップ構成も可能なようです。

今回はKubernetesの例を紹介しましたが、クラスタノードディスカバリーにはAmazon S3などのオブジェクトストレージやJDBCを経由する方法もあり、AWS ECSではこちらの方法でクラスタを構成できます。

今回紹介していない有用な機能に「Java Transaction APIサポート」があり、XA(2フェーズ)トランザクションにも対応します。これによりメモリストレージとJDBCの更新の両方にまたがるトランザクション処理が構成できます。

年末調整保険料控除XML署名確認の顛末

みなさんこんにちは。エンジニアの佐藤です。今回は年末調整のお話です。

年末調整の時節、会社からは「保険料控除ではXMLを奨励」というアナウンスがありました。「エックスエムエル!」という言葉と共に新鮮な驚きが頭の中に広がりました。ええっ、XMLだって?あいつらまだ生きていたのか!そう、コンピュータ同士の相互通信データフォーマットとして、XMLは時代遅れなのです。現在の主役はとっくにJSON。何を今更XML

しかし、筆者はXMLは嫌いではありませんし、それなりに知識もあります。(昔話に興味のある方は、末尾の「昔話」をご覧ください。) 保険業社が発行するXMLがどんなものか、大きな興味をもってファイルを開いてみました。

保険料控除のXMLはどういう構造になっているのか

ざっくり言うと、以下のような構造になっています。

<文書>
  <保険情報詳細/>
  <署名情報/>
</文書>

その仕様は「源泉徴収票等オンライン化に関する仕様書」として国税庁が配布しています。これ自体は特に難解なわけではなく、保険業社や支払い金額など、年末調整の保険料控除の書類に記入していた項目が書かれているだけです。

難しいのは(そしておもしろいのは)、署名情報の部分です。概要は以下のようになっています。

<dsig:Signature xmlns:dsig="http://www.w3.org/2000/09/xmldsig#" Id="...">
    <dsig:SignedInfo>
        <dsig:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
        <dsig:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
        <dsig:Reference URI="#(保険情報詳細データへのポインタ)">
            <dsig:Transforms>
                <dsig:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
                <dsig:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
            </dsig:Transforms>
            <dsig:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
            <dsig:DigestValue>(保険情報詳細データのダイジェスト)</dsig:DigestValue>
        </dsig:Reference>
    </dsig:SignedInfo>
    <dsig:SignatureValue>(署名データ)</dsig:SignatureValue>
    <dsig:KeyInfo>
        <dsig:X509Data>
            <dsig:X509Certificate>(署名者の証明書)</dsig:X509Certificate>
        </dsig:X509Data>
    </dsig:KeyInfo>
</dsig:Signature>

内容はこうです。まず保険詳細情報の部分がXML文書としてバイナリデータになり、ダイジェストが計算されます。そしてこのダイジェストが署名者の秘密キーで署名され、署名データとなります。この署名データを署名者の証明書に含まれる公開キーで検証することで、証明書の発行者(=署名者)が署名した内容に改ざんがないことが確認できるのです。

証明書はbase64形式ですが、復号すると署名者は「Registrar of Tokyo Legal Affairs Bureau, Japanese Government」となっていました。この署名者として署名できるのは、署名者に対応する秘密キーを手にした者だけに、厳密に制限されます。 つまりこれは、東京法務局が署名した公文書で、法律上の扱いはともかく、その性質は住民票や車検証といった身近な公文書と完全に同じです。(たったこれだけのテキスト断片にそんな価値があるなんて、なんだかワクワクしてきませんか。)

この保険業社発行のデータは、もちろんその有効性を確認してから発行されていると思います。疑うわけではありません。しかし、エンジニアとしてその真正性を確認したいと思った筆者は、署名の検証を手元でやってみようと思い立ちました。

ただ、これが結構、険しい道だったのです。結論はこうです。署名検証は可能で、署名は正しかったです。しかしこれを確認する手順は、ライブラリで一発では決してなかったのです。Java言語とPython言語で、2つの保険料控除XMLの署名を確認しましたが、確認のためにはそれなりの知識とプログラム調整を要しました。 ひょっとしたら同じ問題に遭遇している方がいらっしゃるかもしれないと思い、ここにその顛末を紹介させていただきます。

Java言語の場合

Javaの場合は、JDKXML署名検証のための機能が実装されており、その使い方はOracleの以下のサイトに書かれています。

XML Digital Signature API

ただし、結構な分量があり、その内容もかなり専門的です。幸い以下のGithub公開レポジトリに、この文書で解説されているサンプルコードが取りまとめられており、筆者もこれを動かしてみることから始めました。

https://github.com/jknecht/xml-signature-validation

しかし、、そのまま動かすと以下の例外になってしまいます。

javax.xml.crypto.MarshalException: It is forbidden to use algorithm http://www.w3.org/2000/09/xmldsig#rsa-sha1 when secure validation is enabled

そういえば署名情報を見た時から、悪い予感がしていたのです。SHA1アルゴリズムは、強行突破する計算量が現在のコンピュータの速度と比較して不十分と業界で認識され、非奨励となっていたのです。しかし今回はダイジェストがSHA1で作られているため、このアルゴリズムを動かさざるを得ません。

その手順は巷に幅広く解説されていますが、以下のような手順です。

まず既定のjava.securityファイルを回収します。筆者の場合は以下のディレクトリにありました。

/usr/lib/jvm/java-17-amazon-corretto/conf/security/java.security

次に、この内容を以下のように変更して、SHA1の禁止措置を解除します。

# 以下が禁止措置を解除した元々含まれていた項目
# disallowAlg http://www.w3.org/2000/09/xmldsig#sha1,\
# disallowAlg http://www.w3.org/2000/09/xmldsig#rsa-sha1,\

jdk.xml.dsig.secureValidationPolicy=\
    disallowAlg http://www.w3.org/TR/1999/REC-xslt-19991116,\
    disallowAlg http://www.w3.org/2001/04/xmldsig-more#rsa-md5,\
    disallowAlg http://www.w3.org/2001/04/xmldsig-more#hmac-md5,\
    disallowAlg http://www.w3.org/2001/04/xmldsig-more#md5,\
    disallowAlg http://www.w3.org/2000/09/xmldsig#dsa-sha1,\
    disallowAlg http://www.w3.org/2007/05/xmldsig-more#sha1-rsa-MGF1,\
    disallowAlg http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha1,\
    maxTransforms 5,\
    maxReferences 30,\
    disallowReferenceUriSchemes file http https,\
    minKeySize RSA 1024,\
    minKeySize DSA 1024,\
    minKeySize EC 224,\
    noDuplicateIds,\
    noRetrievalMethodLoops

これをプログラム開始時に以下のコマンドライン引数で指定すれば、SHA1が使えるようになります。

-Djava.security.properties=${workspaceFolder}/java.security

さあこれで動くようになったでしょうか?残念ながらそうではありません。続いて以下の例外となりました。

Caused by: com.sun.org.apache.xml.internal.security.utils.resolver.ResourceResolverException: Cannot resolve element with ID ...

もういきなりわかりません。IDって。。??デバッガでJDKのコードフローを辿っていくと、URIリファレンスの解決に失敗した、と言うふうに読めます。そこで元のXMLを眺めていると、該当しそうな場所が見つかりました。冒頭に紹介した署名情報の以下の部分です。

<dsig:Reference URI="#(保険情報詳細データへのポインタ)">

このURIをIDとして設定しているのは、ルート要素の以下の部分です。

<TEG810 xmlns="http://xml.e-tax.nta.go.jp/XSD/kyotsu" xmlns:gen="http://xml.e-tax.nta.go.jp/XSD/general" xmlns:kyo="http://xml.e-tax.nta.go.jp/XSD/kyotsu" VR="1.0" id="TEG810...">

ははぁ、これは保険情報のXMLノードクエリ失敗だなと思って調べてみると、XML DocumentにはID解決する属性を指定する必要があるという巷情報に行きつきました。

https://stackoverflow.com/questions/3423430/java-xml-dom-how-are-id-attributes-special

Githubソースコードに以下のように追記します。

Document doc = dbf.newDocumentBuilder().parse(
        new FileInputStream(fileName));
Element x = doc.getDocumentElement();

// 追加部分
doc.getDocumentElement().setIdAttributeNS(
    null, 
    "id", 
    true);

すると、、、以下のように表示され、署名検証が成功しました!

Signature passed core validation

試しに保険詳細情報(金額など)や署名の内容を1文字変更して検証してみましたが、正しく検証エラーとなります。A社とB社両方のXMLで署名を確認できました。

Python言語の場合

Pythonの場合は。。と思って巷ライブラリを確認した筆者は、大きなショックを受けました。ほとんど以下の一択なのです。

https://xml-security.github.io/signxml/

しかも、サンプルコードがありません。公式ドキュメントに書かれているサンプルはわずかにこれだけです。

https://xml-security.github.io/signxml/#verifying-saml-assertions

XMLVerifier.verifyの説明を読み込むなどして、以下のコードを書き起こしました。(最終コードは本項目の末尾をご覧ください。)

from lxml import etree
from signxml.verifier import XMLVerifier, SignatureConfiguration
from signxml.algorithms import DigestAlgorithm, SignatureMethod

xmlData = None

with open("a.xml", "rb") as fh:
    xmlData = fh.read()
    x = etree.XML(xmlData, None)
    el = x.find(".//ns:X509Certificate", namespaces={"ns": "http://www.w3.org/2000/09/xmldsig#"})
    cert = el.text

verifier = XMLVerifier()
assertion_data = verifier.verify(
    xmlData, 
    x509_cert=cert
).signed_xml
s = etree.tostring(assertion_data)
print(s)

証明書データをあえて事前に回収しているのは、verifyメソッドの説明に以下のように書かれているからです。

If left set to None, requires that the signature supply a valid X.509 certificate chain that validates against the known certificate authorities.

東京法務局が今回の証明書に対応するルート証明書を発行しているのか、本稿執筆時点では確認できませんでしたので、システムCAにそれを収録する代わりにCAチェックを回避する方法を選びました。

実行すると、Javaの時にも遭遇したあのエラーです。

signxml.exceptions.InvalidInput: Signature method RSA_SHA1 forbidden by configuration

以下のようにして有効化します。

verifier.verify(
    xmlData, 
    x509_cert=cert,
    expect_config=SignatureConfiguration(
        signature_methods=frozenset({
            SignatureMethod.RSA_SHA1
        }),
        digest_algorithms=frozenset({
            DigestAlgorithm.SHA1
        })
    )
)

ここまで修正して、B社のXMLは検証成功しました!例によって内容を変更して確認しましたが、正しく検証できているようです。

しかし、、A社のXMLでは以下の例外になっていまいます。

signxml.exceptions.InvalidSignature: Signature verification failed: bad signature

こんなことって。。 (あるんですよね、こういうことが。これが言わば、ソフト開発技術者業務の難所だと筆者は思います。特に今回のように暗号アルゴリズムを使う場合は、数多ある設定が完全に想定通りでないと、このような冷たいエラーから抜け出すことができないのです。)

しかし、B社のXMLでは署名検証成功しているので、この差分を詰めていけば、絶対に正解に辿り着くはずです。まずXMLの中身の確認から入りました。署名情報部分に以下のような違いがありました。

A社(失敗)

<Signature xmlns="http://www.w3.org/2000/09/xmldsig#" ...>

B社(成功)

<dsig:Signature xmlns:dsig="http://www.w3.org/2000/09/xmldsig#" ...>

XMLに詳しい方なら分かると思いますが、この2つのXMLは、この部分だけを見れば、同値です。要素の名前が違うじゃないかと思われるかもしれませんが、B社XMLの「dsig」の部分は名前空間のプレフィクスであり、本当の名前空間識別子は「xmlns:dsig」属性で指定されている「http://www.w3.org/2000/09/xmldsig#」の方なのです。

念のためA社XML名前空間をB社形式に合わせてみましたが、エラーは解消しませんでした。

頭を総動員して考えられる可能性を探っていきます。

そもそもXMLの署名検証とは、以下のような過程で行われるものです。(他の方式もありますが、今回の場合です。また、今回はA社XMLとB社XMLで方式は共通です。)

  1. SignedInfoの内容 から作った署名情報を、証明書の公開キーで復号したSignatureValueの署名データで検証する。
  2. Reference ID指定された要素からSignature要素を除いた部分の内容の ダイジェストを計算する。
  3. ダイジェストをDigestValueと比較する。

SignedInfoの中にはダイジェストが含まれていますので、こうすることでXML全体の内容保証ができるのです。問題は、SignatureInfoや、Reference ID指定された要素はXMLテキストであり、先ほど紹介したように、その内容に表現の多様性があることです。一方でデジタル署名は内容が完全に一致するバイナリデータに対する署名ですので、XMLテキストはそのままでは署名データとしては使えないのです。そこでcanonicalizeというプロセスが登場します。CanonicalizationMethod要素を見ると、その手順の識別子が定義されています。

しかし、canonicalize手順の識別子はA社とB社で全く同じです。何が問題なのでしょうか。

次に筆者が思いついたのは、署名検証データとして持ち込まれるSignatureInfoの 完全な文字列情報の 確認です。これはPythonデバッグ情報を出力させることで行えます。

出力した文字情報は、以下のようになっていました。(括弧内は固有情報なのでマスクしています。)

<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#" xmlns:gen="http://xml.e-tax.nta.go.jp/XSD/general" xmlns:kyo="http://xml.e-tax.nta.go.jp/XSD/kyotsu"><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></CanonicalizationMethod><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"></SignatureMethod><Reference URI="#(ID情報)"><Transforms xmlns=""><Transform xmlns="" Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"></Transform><Transform xmlns="" Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></Transform></Transforms><DigestMethod xmlns="" Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"></DigestMethod><DigestValue xmlns="">(保険情報詳細データのダイジェスト)</DigestValue></Reference></SignedInfo>

A社のXMLJavaでの署名情報検証には成功しています。その時のSignedInfo文字列はどうなっていたのでしょうか。

その出力手順は複雑で、まず以下のようなlogging.propertiesファイルを用意します。

handlers= java.util.logging.ConsoleHandler
java.util.logging.ConsoleHandler.level = FINER
java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter
org.jcp.xml.dsig.internal.level = FINER
com.sun.org.apache.xml.internal.security.level = FINER

次にこれをjava起動パラメターで指定します。

-Djava.util.logging.config.file=${workspaceFolder}/logging.properties

出力されたのは、以下のような文字列でした。

<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#" xmlns:gen="http://xml.e-tax.nta.go.jp/XSD/general" xmlns:kyo="http://xml.e-tax.nta.go.jp/XSD/kyotsu"><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></CanonicalizationMethod><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"></SignatureMethod><Reference URI="#(ID情報)"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"></Transform><Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></Transform></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"></DigestMethod><DigestValue>(保険情報詳細データのダイジェスト)</DigestValue></Reference></SignedInfo>

Pythonの方にだけ、「xmlns=""」という、空のXML名前空間指定が付いています。これは、ライブラリの処理としては誤りで、バグと考えても良いかもしれません。

幸い、この空のXML名前空間指定を文字列編集で除去するオプションがライブラリにありました。これはどこにも解説されていない内容で、筆者がライブラリのソースコードを読んで掘り出したものです。

verifier = XMLVerifier()
verifier.excise_empty_xmlns_declarations = True

最終的なPythonプログラムは以下のようになりました。

from lxml import etree
from base64 import b64decode
from signxml.verifier import XMLVerifier, SignatureConfiguration
from signxml.algorithms import DigestAlgorithm, SignatureMethod
import logging

logging.basicConfig(encoding='utf-8', level=logging.DEBUG)

xmlData = None

with open("a.xml", "rb") as fh:
    xmlData = fh.read()
    x = etree.XML(xmlData, None)
    el = x.find(".//ns:X509Certificate", namespaces={"ns": "http://www.w3.org/2000/09/xmldsig#"})
    cert = el.text

verifier = XMLVerifier()
verifier.excise_empty_xmlns_declarations = True

assertion_data = verifier.verify(
    xmlData, 
    x509_cert=cert,
    expect_config=SignatureConfiguration(
        signature_methods=frozenset({
            SignatureMethod.RSA_SHA1
        }),
        digest_algorithms=frozenset({
            DigestAlgorithm.SHA1
        })
    )
).signed_xml
s = etree.tostring(assertion_data)
print(s)

このプログラムで検証すると、A社のXMLも検証OKとなり、内容改ざんの検出も期待通りでした!

振り返りと、昔話と、雑談

様々な問題がありましたが、なんとか目的を達成しました。最後までお読みいただきありがとうございます。

いろいろ調べながら感じたのは、XML署名が業界的にマイナー技術であり、とにかく文献が少なくて古いことです。時代の流れは着実にJSON/JWTであり、XMLは旧技術なのです。しかしXMLで決まっていることは仕方がないし、一度決まった業務仕様は長い間生きながらえます。知識の力で何とかつなぐのがエンジニアの仕事です。

今から20年ほど前、XMLが大きな注目を集めたことがありました。Microsoft.NET Framework 1.0を発表し、その目玉機能としてアピールしたからです。商売の話はさておき、筆者も、あのとき、紙書類の階層構造と、バイナリメッセージフォーマットの中間にある、コンピュータでクエリできる構造化された共通テキストフォーマットであるXMLに大きな可能性を感じていました。

そんなXMLはどうして主流になれなかったのか?筆者の知る限り、現在XMLが使われているのは、Mavenなどの設定ファイルと、SOAPプロトコルと(Salesforce SOAP APIは現役です!)、SAML査証だけです。XSLなどのスキーマ定義やXSLTなどのHTMLへのデータ変換に至っては、ほとんど見かけなくなりました。その理由はおそらく、仕様を膨らませ過ぎたことにあるのではないかと筆者は思っています。そのため難解になり敷居が上がり、開発工数と戦うエンジニアの方々からは遠ざかってしまったのです。XMLが果たすはずだった役割は、今ではほぼJSONが代わりに果たしているように筆者には思えます。

このような経緯はあれど、ようやく当時の技術が実用化されて手元にあるのだと思うと、年末調整保険料控除XMLシステム開発に携わったエンジニアの方に感謝したい気持ちになりました。