蟻地獄

Twitterに書ききれない長めの文とか書くよ

エウルアとアンバーにChatGPTの仕組みを説明してもらった

全国5億人のエウアン推しのみなさんこんにちは!最近、ChatGPTに百合SSを書かせるのが私のエコーチェンバーの中で流行っていますよね。そこで、GPT-4を使ってエウルアとアンバーにChatGPTの仕組みを説明してもらいました。

やり方

まず、以下のようなプロンプトで各キャラの特徴をChatGPTに教えます。

以下は原神のキャラクター「エウルア」のプロフィールとセリフ例です。
この内容を基に、エウルアが言いそうなセリフを10個挙げてみて。
セリフ例の口調を真似ることを意識して。

# プロフィール
(ここにゲーム中のプロフィールを貼る)

# セリフ例
(ここにゲーム中のセリフを貼る)

そして、二人の関係性(筆者の私見をやや含む)を教えた後、以下のプロンプトで出力を開始します。

ChatGPTの仕組みについて、エウルアとアンバーの会話劇の形式でなるべく詳しく説明してみて。
二人の関係性がよく表れているやりとりを必ず含めて。

出力結果

いかがでしょうか。やや違和感はあるものの、けっこうエウアンしてるのではないでしょうか。 今回は二人の関係性の説明を200文字程度で入力しましたが、もっと詳しく入力すればより洗練されたエウアンができるのではないかと思います。

現場からは以上です。

MagicOnionをUnity 2019.4 + IL2CPP環境で使う

MagicOnionをUnity 2019.4 + IL2CPP環境でビルドしたらエラーが出てハマったのでメモ。

環境

  • Unity 2019.4.26f1
  • MagicOnion 4.3.1
  • gRPC 2.42.0 (daily builds)

問題

Scripting BackendをIL2CPPにしてビルドするとこのエラーが出る。 github.com

解決法

Assets\Plugins\Grpc.Core\runtimes\grpc_csharp_ext_dummy_stubs.c の末尾に下記のコードを追加するとビルドが通る。

void dlopen() {
  fprintf(stderr, "Should never reach here");
  abort();
}
void dlerror() {
  fprintf(stderr, "Should never reach here");
  abort();
}
void dlsym() {
  fprintf(stderr, "Should never reach here");
  abort();
}

上記のIssueのコメントによるとこの方法だと解決しなかったっぽいが、私の環境だとこれだけで大丈夫だった。

ちなみにアプリケーション終了時にフリーズする場合はIStreamingHub.DisposeAsync()を呼んでいないのが原因なので、MonoBehaviour.OnApplicationQuit()とかで呼ぶようにする。

async void OnApplicationQuit()
{
    await this.streamingClient.DisposeAsync();
}

参考情報

github.com

AWS SDKをUnityで使う方法(2020年度版)

AWS SDK Unity」でググると、AWS Mobile SDK for Unityの公式ドキュメントがトップに表示されます。 どこの馬の骨かもわからない個人ブログなんか参考にしなくても公式ドキュメント見ればええやんと思うかもしれませんが、残念ながら公式ドキュメントの内容は古いです。

公式ドキュメントからリンクが貼ってある http://sdk-for-net.amazonwebservices.com/latest/aws-sdk-unity.zip のunitypackageは、 まず同梱のサンプルシーンが動かず、エラーメッセージをググりながらなんとか動かせたとしてもAndroidでエラーが出たりします。

どうしてこうなった

AWS SDK for Unityは元々AWS SDK for .NETをフォークして作成されたもので、リポジトリも分かれていました。 しかし、サポートされる機能が増えるにつれ両方のリポジトリをメンテするのが困難になったため、Unity版は.NET版のリポジトリに吸収され、.NET版の一部として提供されるようになりました。 しかし、この時点ではコードを完全に共通化できたわけではなく、プラットフォーム毎にコードが分かれている部分があり、インタフェースも微妙に違っていました。

その状況を解消したのが.NET Standard 2.0です。 .NET Standard向けのビルドではUnityでも共通のコードが使えるようになり、今までUnityでは一部のサービスのSDKしか提供されていなかったのが全てのサービスをサポートするようになりました。

というわけでUnity向けのAWS SDKは、

  • Unity Custom版
  • .NET Standard版

の2つのバージョンがあり、インタフェースも対応サービスも異なります。 そして公式ドキュメントで説明されている導入方法は、Unity Custom版のものです。 Unity Custom版は既にサポートが終了しており、.NET Standard版への移行が推奨されています。じゃあドキュメント直せよ。

.NET Standard版の導入方法

公式ブログに書かれています。

  1. http://sdk-for-net.amazonwebservices.com/latest/v3/aws-sdk-netstandard2.0.zip からdllをダウンロードしてAssetフォルダにコピー
  2. IL2CPPを使う場合はlink.xmlを書く

コードに関してはUnity独自のおまじないとかは不要になったので、.NET版のドキュメントを参考に書けば大丈夫です。 UnityInitializer.AttachToGameObject(this.gameObject); とかも不要です。

注意点

ただし、こちらの記事で書かれているように、 .NET Standard版はコードを共通化した代償として、Unity Custom版でしか提供されていなかった一部機能も削除されてしまいました。これらの機能を使いたい場合は古いコードを参考に自前で実装する必要があります。

例えば、CognitoはUnity Custom版ではPlayerPrefsをキャッシュとして使用しており、認証されていないユーザーのIDはアプリケーションを再起動しても保持されます。 しかし、.NET Standard版ではPlayerPrefsを使ったコードを入れるわけにはいかないので、デフォルトでは認証されていないユーザーのIDはアプリケーションの起動毎に変わってしまいます。 この動作を変更するには、こちらのコードのコメントに書いてあるように、CognitoAWSCredentialsをオーバーライドしてキャッシュの動作を実装する必要があります。

まとめ

ドキュメントはちゃんと更新しよう。

ソーシャルVR「Hubs Cloud」を立ててみた + 日本語化

2020/5/30追記

維持費が高いのでOffline Modeにしました。Aurora Serverless、400PV/月くらいでも$50くらい掛かるよ!


出来上がったものがこちらです。

f:id:mitomemel:20200530093252p:plain f:id:mitomemel:20200530094253p:plain

Hubs Cloudとは、MozillaのソーシャルVRMozilla Hubs」を自前のAWSアカウントで立てることのできるCloudFormationテンプレートです。あまり詳しくない人向けに説明すると、マストドンVRだと思っとけばだいたいあってます。

Mozilla Hubsはオープンソース化されており、がんばれば自前で1からビルドしてデプロイすることも可能です。しかし、現代のWebサービスのデプロイはなかなか複雑で、Apacheの公開ディレクトリにファイルをコピーすれば終わりというわけにはいきません。Hubs Cloudは、GUIをぽちぽちするだけで冗長化とオートスケーリング対応済みのそのまま本番投入可能なサーバを立ち上げることができます。いい時代になったものですね。

Hubs Cloudをわざわざ立てなくても、本家のMozilla Hubsでも自分のプライベートルームを作って自前アバターやシーンをインポートすることは可能です。ではなぜ人はHubs Cloudを立てるのかというと、自前で立てることでAdmin画面に入ることができるようになります。Admin画面では以下のような項目の変更が可能です。

  • デフォルトで選べるアバター・シーンのラインナップ (Mozilla Hubsのデフォルトアバターはちょっとバタ臭いですよね。アバターだけに)
  • Title・Description・ロゴなどのブランディング
  • トップページのDocs・Communityなどのボタンのリンク先、表示非表示
  • Adminユーザー以外がルームを作れないようにするなどの権限設定
  • UIの配色

逆に言えば、上記のような項目を変えたいわけではなく単に自分のプライベートルームを作りたいだけであれば本家のMozilla Hubsで十分です。Hubs Cloudは最小構成でも$40/月$100/月くらい掛かります。マストドンを立ててみたものの結局Pawooしかアクセスしてないそこのあなた、本当にHubs Cloudを立てる必要があるかはよく考えましょう。私はよく考えずに立てました。勢いって大事だよね。

立て方

こちらのページに従って作業するだけです。レシピをアレンジしないことが重要です。初心者はまずレシピ通りに作ってみましょう。

特に「2. Register a new domain name on Route 53」を下手にアレンジして失敗している人が多いようです。ドメインの構成についてはこちらのページに詳しく書いてありますが、どのパターンにしても、新規ドメインを2個買う必要があります。はい、2個です。「うそやん、うまいことやれば1個でできるやろ?」とか「既に持ってるドメインサブドメインで運用したろ」と思うことでしょう。そう思っていた時期が私にもありました。「Route53で管理されている」「未使用のドメイン」「2個」必要です。ここはあきらめてさっさとドメインをポチりましょう。

日本語化について

私が立てたインスタンスは日本語化されていますが、これはどうやっているのかというと、クライアントのリポジトリをフォークしてコードをいじりました。こちらのページに書いてある通り、既にデプロイフローが用意されておりクライアントを簡単に差し替えることができます。

言語ファイルはhttps://github.com/mozilla/hubs/blob/master/src/assets/translations.data.jsonにあります。現状だとこの"en":{}の中身を直接書き換えてしまうのが日本語化の一番簡単な方法です。"ja":{}というキーを追加してもうまく日本語化されません。というのも、一部言語を"en"決め打ちで書かれているコードがあるからです。決め打ちで書かれている部分を修正するプルリクを投げようかとも思いましたが、現在こちらのIssueで議論されているように、多言語対応についてはライブラリをFluentに変更してPontoonを使ったワークフローでやっていくようです。なので、現在のコードベースのまま多言語対応しても無駄になる可能性が高そうです。

まとめ

早く本家が多言語対応しますように。 Fluentに詳しい人、OSSに貢献できるチャンスですよ!!!

CloudSearchのdomainを複数環境で共有したい

この記事は VALU Advent Calendar 2019 20日目のエントリです。

株式会社VALUのバックエンドエンジニアのMito Memelです。前回のエントリで、渾身のギャグ「これでキューが急に詰まっても安心ですね!」が誰にも拾ってもらえなくて悲しいです。

VALUのサーバインフラはAWSですが、AWS全文検索システムを構築する場合、Elasticsearch ServiceとCloudSearchどちらを使えば良いか迷いますよね。Elasticsearchにしか無い機能を使いたい場合は選択の余地はありませんが、そうでない場合、CloudSearchのオートスケーリングは魅力的です。Elasticsearch Serviceは自動フェイルオーバーはしてくれるものの、インスタンスタイプ・ノード数の計画は自分で立てる必要があります。何も考えずにdomain(CloudSearchクラスタの設定単位)を1個作るだけで良いCloudSearchはとてもお手軽です。

しかし、CloudSearchにも欠点があります。スケールアウトは得意なんですが、性能がそれほど必要では無い場合、スケールダウンしたくても最小構成で1domainにつき$60/月程度(東京リージョンの場合)掛かります。1domainだけならまだ良いんですが、例えば開発環境が複数あったりすると環境ごとにdomainを作らなければならず、リソースは有り余っているのに料金がかさむという状況になりがちです。その点Elasticsearchだと1domain内でもindexで分けることができるので、複数環境でdomainを共有するのも簡単です。残念ながらCloudSearchにはElasticsearchで言うindexのようなものは無いのですが、どうにかして1domainを複数環境で共有できるようにしたいものです。そこで、実際に試してみました。

fieldでがんばる

まず最初に思い付くのは、index みたいな名前のfieldを追加して、検索時に毎回そのfieldを条件に含めるというやり方です。この方法でもできなくは無いのですが、いくつか欠点があります。

idフィールドの被り

CloudSearchにはMySQLで言うauto_incrementのような自動採番は無く、documentのアップロード時にユニークなidを明示的に指定する必要があります。このidは例えばユーザー検索ならRDB側で振っているユーザーIDを使うなどすれば良いのですが、1domainを複数環境で共有する場合、idが被ってしまう可能性があります。なので、idのprefixに環境名を付けるなどの工夫が必要となります。

field名のルールが増える

index みたいな名前のフィールドを追加する」と先ほど書きましたが、documentの検索対象fieldでうっかり同じ名前を使ってしまうみたいな事故が考えられます。なので環境識別のための名前空間として使用するfieldはsuffixに _ を付けて index_ みたいな名前にし、検索対象fieldではそのsuffixを避ける、みたいなルールが必要になります。これでも良いんですが、なんかこう、もうちょっとスマートにやりたいですよね。

idを構造化する

idフィールドの被りを避けるためにidにprefixを付けるなどの工夫が必要なのであれば、いっそのことidフィールドのprefix検索だけで名前空間を分けられないか?と考えました。

idフィールドに使える記号は​ _ - = # ; : / ? @ です。これを使って、例えば {環境名}/{データ種別}/{連番} のような階層構造のidを付ければ、idの被りを防いだ上で、余分なfieldを追加しなくてもidのprefix検索で環境を特定することが可能です。実際のコードは以下のようになります。

documentのアップロード

public function uploadDocuments(CloudSearchDomainClient $client, string $index, string $type, array $documents)
{
    // idを置換
    $formatted_documents = array_map(function($document) use ($index, $type){
        $document['id'] = "{$index}/{$type}/{$document['id']}";
        return $document;
    }, $documents);

    $client->uploadDocuments([
        'contentType' => 'application/json',
        'documents' => json_encode($formatted_documents),
    ]);
}

documentの検索

public function search(CloudSearchDomainClient $client, string $index, string $type, string $query)
{        
    // クエリにidのprefix条件を追加
    $formatted_query = "$query _id:{$index}/{$type}/*";

    $response = $client->search([
        'query' => $formatted_query,
        'queryParser' => 'lucene',
    ]);

    // id部分のみを返す
    return array_map(function($id){
        return explode('/', $id)[2];
    }, array_column(array_get($response, 'hits.hit', []), 'id'));
}

まとめ

idのprefix検索を利用することで、CloudSearchのdomainを複数環境で共有することができました。これで開発環境では料金を抑えつつ、本番環境ではCloudSearchのオートスケールのメリットを享受することができます。

LaravelのキューワーカーをCloudWatchで監視する

この記事は VALU Advent Calendar 2019 5日目のエントリです。

株式会社VALUのバックエンドエンジニアのMito Memelです。FGOの星4配布はぐっちゃん先輩にしました。

皆さん、Laravelのキューワーカーは監視していますか?公式ドキュメントに書いてあるようにSupervisorに登録しただけで監視した気にはなっていないですよね?

Supervisorはプロセスの死活を見ているだけで、実際にキューが消化されているかは見てくれません。キューが詰まってしまってもなかなか気づきにくいのがキューワーカーの怖いところですよね。タイムアウトを設定していていも、そもそもワーカー本体が何らかの原因で固まってしまうこともあり得ます(というか最近本番環境で発生しました)。そこで、実際にキューが消化されているかをCloudWatchで監視してみます。

キューが消化されているかを確認するには、実際にジョブを投げて処理されるかを見るのが確実です。つまり外形監視ですね。というわけで、まずはCloudWatchに値を投げつけるだけのジョブを作成します。

class Heartbeat implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    private $created_at;

    public function __construct()
    {
        $this->created_at = now();
    }

    public function handle()
    {
        $client = App::make('aws')->createClient('cloudwatch');
        $client->putMetricData([
            'Namespace' => config('app.name') . '/' . config('app.env'),
            'MetricData' => [
                [
                    'MetricName' => 'QueueWorkerLatency',
                    'Value' => now()->diffInSeconds($this->created_at),
                    'Unit' => 'Seconds',
                ]
            ],
        ]);
    }
}

__construct()handle()の時間差を測ることで、「ジョブをdispatch()してから実際に処理されるまでの時間」が測れるわけですね。

そしてこのジョブをdispatch()するだけのコマンドを作成してタスクスケジュールに登録します。

class DispatchHeartbeat extends Command
{
    protected $signature = 'heartbeat:dispatch';

    protected $description = 'Heartbeatジョブをキューに積みます';

    public function handle()
    {
        Heartbeat::dispatch();
    }
}

これでCloudWatchでジョブの処理遅延時間をメトリクスとして取得することができるようになりました! f:id:mitomemel:20191204145642p:plain

あとはCloudWatchのアラーム設定でデータの欠落or処理遅延時間の増加を検知してアラートメールを送るなり煮るなり焼くなりしましょう。 これでキューが急に詰まっても安心ですね!

Monappy事件はどうすれば防げたのか

この記事は VALU Advent Calendar 2018 25日目のエントリです。

株式会社VALUのバックエンドエンジニアのMito Memelです。始皇帝が宇宙開発したりガンジーが核撃ってくるシヴィライゼーションっていうゲームが好きです。

今年9月にMonacoinウォレットサービス「Monappy」でホットウォレットに保管されていた全てのMonacoinが不正に出金されるという事件がありましたが、詳細な原因が早期に公開されたことが話題になりました。

medium.com

本エントリでは上記の記事を元に、どうすればMonacoinの多重送金を防げたのかを考えます。

上記の記事では、多重送金の原因が以下のように説明されています。

MonappyからMonacoinを送信する際は、monacoindという別のサーバ上のアプリケーションと通信する仕様になっています。この通信がタイムアウト等で失敗した場合はサーバダウンなどで送金に失敗しているとみなして、取引をロールバックする仕様となっていました。また、ギフトコードの処理に関してはデータベースのトランザクション機能等を利用しており、本来同じギフトコードを二重に使用することはできないようになっていました。しかし、高負荷状態でギフトコードを連続して使用しようとした場合、通信を受け取ったmonacoindの応答に時間がかかり、サイト側ではタイムアウトとなってロールバックされたあとにmonacoind側では送金が行われていました。この結果、一つのギフトコードから複数回送金されたものと考えられます。

中央集権型ウォレットサービスにおけるコインの出金処理とは、単純化すると以下の処理を行うことです。

  • ブロックチェーン上で指定されたアドレスにコインを送金する
  • サービス側のDB(通常はリレーショナルデータベース)上のユーザーの残高を減らす(Monappyの場合はギフトコードを使用済みにする)

ではまずダメな例を考えてみましょう。上記の処理を単純にコード化すると以下のようになります。

function use_giftcode($code, $address)
{
    try{
        DB::statement("START TRANSACTION");
        $giftcode = DB::select("SELECT * FROM giftcodes WHERE code=? AND status='unused' FOR UPDATE", [$code]);
        if(!$giftcode){
            throw Exception("ギフトコードが既に使用されています");
        }
        $tx = send_monacoin($address, $giftcode->mona_amount); // ブロックチェーン上で送金
        DB::update("UPDATE giftcodes SET status='used', tx_id=? WHERE code=?", [$tx->id, $code]);
        DB::statement("COMMIT");
    }catch(Exception $e){
        DB::statement("ROLLBACK");
        throw $e;
    }
}

※実際にはブロックチェーン上での承認数の確認なども必要になりますが、それはまた別レイヤーの話なので省略します。

上記のコードの場合、send_monacoin()より下のDB更新で失敗すると、ブロックチェーン上での送金は成功しているのにギフトコードが使用済みになりません。攻撃者がDB負荷を高めるなどして意図的にDB書き込みの失敗率を上げられるのであれば、ホットウォレットが枯渇するまで出金し放題です。

これを防ぐためには、どの時点で失敗したとしても、

という状態にする必要があります。つまり、ブロックチェーンRDBをまたいだ分散トランザクションが必要になるわけです。はい、胃が痛くなってきましたね。

では次に、上記を考慮したコードにしてみましょう。下記がおそらくMonacoinの多重送金が発生した際のコードに近いと思われます。

function use_giftcode($code, $address)
{
    $giftcode = DB::select("SELECT * FROM giftcodes WHERE code=?", [$code]);
    // ローカルでMonacoinのトランザクションデータを作成
    $tx = build_monacoin_transaction($address, $giftcode->mona_amount);

    try{
        DB::statement("START TRANSACTION");
        $giftcode = DB::select("SELECT * FROM giftcodes WHERE code=? AND status='unused' FOR UPDATE", [$code]);
        if(!$giftcode){
            throw Exception("ギフトコードが既に使用されています");
        }
        DB::update("UPDATE giftcodes SET status='sending', tx_id=? WHERE code=?", [$tx->id, $code]);
        DB::statement("COMMIT");
    }catch(Exception $e){
        DB::statement("ROLLBACK");
        throw $e;
    }

    try{
        // トランザクションデータをMonacoinネットワークにブロードキャスト
        broadcast_monacoin_transaction($tx);
    }catch(Exception $e){
        // 送金が失敗したのでギフトコードを未使用状態にロールバック
        DB::update("UPDATE giftcodes SET status='unused', tx_id=NULL WHERE code=?", [$code]);
        throw $e;
    }

    DB::update("UPDATE giftcodes SET status='used' WHERE code=?", [$code]);
}

まず、ローカルでMonacoinの送金トランザクションを作成し、トランザクションIDをギフトコードのDBレコードに紐付けます。次にMonacoinの送金をブロードキャストし、成功すればギフトコードを使用済みに更新、失敗すれば未使用状態にロールバックします。こうすることで、どの時点で失敗してもMonacoinの送金が成功したかどうかをトランザクションIDで追跡可能になるので、statusが sending のギフトコードを定期監視するバッチがあれば自動リカバリ可能です。

しかし、上記のコードにも問題があります。それは、broadcast_monacoin_transaction()が失敗した際に、エラー種別に関係なく必ずギフトコードを未使用状態にしていることです。例えば、monacoindがブロードキャストに時間が掛かりタイムアウトした場合、broadcast_monacoin_transaction()は失敗扱いになりますが、ブロードキャスト自体は成功している場合があり得ます。その場合、送金が成功したのにギフトコードが未使用状態になります。攻撃者がこの状態を意図的に発生させることが出来れば、ホットウォレットが枯渇するまで出金し放題です。これを防ぐためにはブロードキャストの部分を下記のように修正する必要があります。

    try{
        // トランザクションデータをMonacoinネットワークにブロードキャスト
        broadcast_monacoin_transaction($tx);
    }catch(BeforeBroadcastException $e /*ブロードキャスト前に失敗したことが確実な場合*/){
        DB::update("UPDATE giftcodes SET status='unused', tx_id=NULL WHERE code=?", [$code]);
        throw $e;
    }

エラーの種別を分けて、Monacoinネットワークにブロードキャストする前に失敗したことが確実な場合のみ未使用状態へのロールバックを行います。ブロードキャストが成功したか失敗したか分からない場合はギフトコードを sending 状態のまま保留しておき、後で定期監視バッチにより成否を判定します。

まとめ

以上がMonappyの公開情報から推測した多重送金の仕組みと対策案です。「ブロックチェーンは多重送金が仕組み上不可能で信頼性が高い」というのは取引がブロックチェーン上だけで完結する場合の話です。現実世界でサービスを提供するには多くの場合外部リソースとの連携が不可欠で、上記のような分散トランザクションが必要になります。それを解決するのがスマートコントラクトということなのでしょうが、それはそれでスケーラビリティなど問題が山積みです。

最後に

弊社では一緒に来年のAdvent Calendarを書いてくれるバックエンドエンジニアやその他だいたい全部の職種を募集しています。電話会社を最近退職した分散トランザクションが得意な方などはぜひご応募下さい。

graspy.jp