開発環境のAmazon Auroraクラスターを利用時間外に自動停止してコスト削減する

f:id:liquid-tech:20211029131215p:plain

はじめに

こんにちは!Liquidインフラチームの野々山です。

この投稿では、AWS EventBridgeとAWS Systems Managerを用いて、Amazon Auroraクラスターの起動・停止を自動制御する方法を紹介します。

サンプルのterraformコードも掲載していますので、もし機会がありましたら試してみてください!


背景

Liquid eKYCでは、主にAWSを用いてインフラ構築を行っています。

サービス提供開始から2年ほど経過し、サービスの利用者数の増加や機能追加とともに、AWSの利用料も増加の一途をたどっています。そのためインフラチームでは、安定したインフラ基盤を提供できるリソースを保ちつつ、様々なコスト削減に取り組んでいます。

コスト削減に対するアプローチはいくつかありますが、今回紹介するAuroraクラスターの起動・停止制御は「時間単位の従量制費用を削減する」類の一部になります。


方針

  • AWS Systems ManagerでAWSが提供しているドキュメント AWS-StartStopAuroraCluster を利用してAuroraクラスタを制御します。
  • AWS EventBridgeで、平日おおよそエンジニアが開発環境を利用開始・終了する時刻にルールが実行されるよう設定し、クラスターを起動・停止します。
    • Liquidでは個々人のワークスタイルに合わせるために、朝晩少し長めに開発環境を稼働させています。組織の方針次第ですが、働きづらくなるような行き過ぎたコスト削減は極力行わないよう気をつけています。
  • 上記利用を目的としたIAMロール・ポリシーを設定します。
  • terraformで構成管理します。

設定方法

前提として

  • Auroraクラスター cluster01 が存在する
  • terraformはバージョン1.0.1、AWS providerはバージョン3.57.0を使用する
  • 東京リージョン(ap-northeast-1)を使用する

ものとします。

1. IAMロール及びポリシーの設定

# IAM role
resource "aws_iam_role" "aurora_startstop" {
  name               = "aurora-startstop-role"
  path               = "/"
  assume_role_policy = data.aws_iam_policy_document.assume_aurora_startstop_role.json
}

data "aws_iam_policy_document" "assume_aurora_startstop_role" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = [
        "events.amazonaws.com",
        "rds.amazonaws.com",
        "ssm.amazonaws.com"
      ]
    }
  }
}

# IAM policy
resource "aws_iam_policy" "aurora_startstop" {
  name        = "aurora-startstop-policy"
  path        = "/"
  description = "autora startstop policy"
  policy      = data.aws_iam_policy_document.aurora_startstop.json
}

data "aws_iam_policy_document" "aurora_startstop" {
  statement {
    effect = "Allow"

    actions = [
      "rds:DescribeDBClusters",
      "rds:StartDBCluster",
      "rds:StopDBCluster",
    ]

    resources = ["*"]
  }

  statement {
    effect = "Allow"

    actions = [
      "ssm:*",
    ]

    resources = ["*"]
  }
}

# IAM role policy attachment
resource "aws_iam_role_policy_attachment" "aurora_startstop" {
  role = aws_iam_role.aurora_startstop.name
  policy_arn = aws_iam_policy.aurora_startstop.arn
}
  • サンプルとして用意をしていますので、適宜リソースの制限を加えてください。

2. EventBridge Ruleの設定

# 平日の8時00分にクラスターを起動する
resource "aws_cloudwatch_event_rule" "start_aurora_cluster01" {
  name        = "start-aurora-cluster01"
  description = "Starts an Amazon Aurora DB cluster: cluster01"
  schedule_expression = "cron(00 23 ? * SUN-THU *)"
  is_enabled = true
}

# 平日の22時00分にクラスターを停止する
resource "aws_cloudwatch_event_rule" "stop_aurora_cluster01" {
  name        = "stop-aurora-cluster01"
  description = "Stops an Amazon Aurora DB cluster: cluster01"
  schedule_expression = "cron(0 13 ? * MON-FRI *)"
  is_enabled = true
}
  • schedule_expressionUTCで以下の記法で設定します:
  • is_enabled で 有効/無効の切り替えを行えるので、クラスターを常時起動したままにしたい場合は false を指定してapplyすれば、すぐに自動起動・停止を無効化できます。
  • (EventBridgeの前身はCloudWatch Eventsのため、terraformのリソース名には「cloudwatch」がそのまま残っています。)

3. EventBridge Targetの設定

resource "aws_cloudwatch_event_target" "start_aurora_cluster01" {
  arn       = "arn:aws:ssm:ap-northeast-1::automation-definition/AWS-StartStopAuroraCluster:$DEFAULT"
  rule      = aws_cloudwatch_event_rule.start_aurora_cluster01.name
  role_arn  = aws_iam_role.aurora_startstop.arn
  input = <<INPUT
{
    "Action": [
        "Start"
    ],
    "ClusterName": [
        "cluster01"
    ]
}
INPUT
}

resource "aws_cloudwatch_event_target" "stop_aurora_cluster01" {
  arn       = "arn:aws:ssm:ap-northeast-1::automation-definition/AWS-StartStopAuroraCluster:$DEFAULT"
  rule      = aws_cloudwatch_event_rule.stop_aurora_cluster01.name
  role_arn  = aws_iam_role.aurora_startstop.arn
  input = <<INPUT
{
    "Action": [
        "Stop"
    ],
    "ClusterName": [
        "cluster01"
    ]
}
INPUT
}
  • input 内の
    • ActionStart or Stop のいずれかを設定します
    • ClusterName :起動・停止をしたいクラスター名を設定します

4. 変更の反映

上記リソースをterraform applyします。


動作確認

上記をapplyすると、EventBridgeルール<https://ap-northeast-1.console.aws.amazon.com/events/home?region=ap-northeast-1#/rules>に以下が追加されます:

f:id:liquid-tech:20211029105130p:plain

ルールの編集画面にアクセスすると、ローカルタイムゾーンで直近10回イベントがいつトリガーされるか確認できます:

f:id:liquid-tech:20211029105234p:plain

Systems Managerのオートメーションの実行<https://ap-northeast-1.console.aws.amazon.com/systems-manager/automation/executions?region=ap-northeast-1>ページにアクセスすると、直近実行されたAuroraクラスターの起動・停止に成功したか確認することができます。

f:id:liquid-tech:20211029105247p:plain


参考

本対応の際、以下のサイトを参考にさせていただきました:

また、下記ページ内に制約事項として記載のある条件に該当するクラスター(Aurora グローバルデータベースの一部であるクラスターやAurora マルチマスタークラスターなど)に対しては、本投稿で記載した方法では設定できない可能性があります。条件に該当しないか事前に確認することをおすすめします。


おわりに

本記事で紹介した方法で、開発環境でのAurora利用料を大きく削減することができました。 インフラコストの削減対応は地道なものも多いですが

  • エンジニアとして、ビジネス上の収益改善を考えダイレクトにコミットできる
  • インフラリソースの持つキャパシティを正しく把握し、アーキテクチャへの理解を深められる

ような価値のある機会と個人的にとらえているので、他業務とうまく折り合いをつけながら継続して取り組みたいと思います!

オンラインボドゲシステム

おはこんにちは、Liquid R&Dチームの布目です。

Liquid R&Dチームでは、eKYCの顔撮影時の偽造検知の実験や開発を行っています。今回は偽造検知などは置いておいて、オンラインでボードゲームをやるためのシステムを作成した話をしたいと思います。

今回作成したオンラインボドゲシステムはこんな感じで動いていました。(生活感がひどい写真だ・・・

リポジトリはこちらになります。

GitHub - yujidn/discord_bodoge_bot: discord botを介してボドゲをする仕組みを作る

f:id:liquid-tech:20210930144437p:plain

動機

システム作成の動機は、ずばりボードゲームをやることになります。

かつては2,3週に1回くらいの頻度でボドゲ会をオフィスでやっていました。が、コロナ禍になり、出社はなくなり、集会は自粛するようになった結果、ボドゲ会自体が消えてなくなりそうになっているのが現状です。

オンラインでやれるボードゲームもあります。例えばボードゲームアリーナやカタンユニバースなどです。

上記のオンラインボドゲでは解決できない問題が1つあります。そう、積みボドゲの消化です。未開封ボドゲはオンラインではプレイできないのです。

f:id:liquid-tech:20210930144458p:plain

要件定義

趣味的に作るものなのでそこまでガッチリしたものは必要ではないですが、必要な機能、不要な機能を考えていきます。

  • 必要要件
    • ダイスロール
    • プレイヤー登録
    • ゲーム全体を俯瞰する機能
    • 各プレイヤーに手札を渡す機能
    • 各プレイヤーのとのやり取り機能
      • 人狼系ゲームのカミングアウト
      • 何らかの行動をする、しない、何点分行う
  • 不必要要件
    • すべてのボードゲームに対応しようとする心
    • ゲームごとの得点計算、HP管理

大雑把に下図のようなものを想定しました。

f:id:liquid-tech:20210930144508p:plain

通話しながら行うことを考えていたので、鯖の部分はDiscord Botにします。 俯瞰視点のスマホカメラはDiscordの通話でカメラ映像を配信すれば良さそうなので、そのほかの機能をDiscord Botに詰め込んでいくことにしました。

開発

Discord botのコマンド周り

DiscordのAPIについてはラッパーを使用しました。 ( が、最近開発終了したようですね・・・。(2021年9月(どうしよう・・・

GitHub - Rapptz/discord.py: An API wrapper for Discord written in Python.

これによりコマンドの実装や各プレイヤーとのやり取りはサクッと実装できました。 コマンドの実行例は下図のようになります。プレイヤーを登録して、そのプレイヤーに対してDM(ダイレクトメッセージ)で手札を配るようにしています。

f:id:liquid-tech:20210930144529p:plain

ダイスロールはよくある感じにしつつ、ゲームによっては出目の頻度が必要なものもあるので、ヒストグラムも返しています。

f:id:liquid-tech:20210930144539p:plain

スマホカメラ画像周り

androidにしろiosにしろネイティブアプリを作るのはめんどくさいなーっと思っていました。 そういえば弊社のeKYCはブラウザで顔撮影などができるなーっということを思い出し、ブラウザ経由でカメラ画像をDiscord Botに送るようにしました。

カメラ周りは [HTML5] カメラをJSで操作し写真を撮影する を参考に作成しています。 カメラ制御から各プレイヤーへの配信は下図のような流れになりました。

f:id:liquid-tech:20210930144601p:plain

実際に撮影された画像と各プレイヤーに配信される画像はこんな感じです。(保存していた画像がテスト時の画像で別のゲームのものになっています。ごめんなさい。。

裏面からの撮影画像 f:id:liquid-tech:20210930144612p:plain

それを各プレイヤー用に分割したもの f:id:liquid-tech:20210930144629p:plain

実際にbotから送られてきたDM f:id:liquid-tech:20210930144922p:plain

テーブル作成

ホームセンターで分厚いアクリル板と塩ビパイプを買ってきて日曜大工しました(多分合計1500円ほど)。剛性がまっっっったく足りません。補強したいですね。

f:id:liquid-tech:20210930144716p:plain

作成した感想

作成途中から気づいていながら実際にプレイした感想は、

  • 手札の多いゲーム、手札移動の多いゲームには不向き
  • 盤面の大きなゲームは厳しい
  • 実際にプレイするとGM(というか私)がめちゃくちゃ大変
  • カメラを天井に向けると天井ライトの逆光で何も見えなくなる

などです。

これまでの人生でhtmlとjavascriptに触れる機会があまりにも無かったこともあり、スマホブラウザ経由でカメラ画像を取得するところを作成するのにだいぶ手間取りました。 骨子となる部分は大体1日で作成できて、そのほかの細々とした部分の実装込みで5人日?くらいで作成できたと思います。

改善点はたくさんあるのですが、GMが物理的に大変なところが多いのでお蔵入り状態で放置しています。。。数こなせば慣れるかもしれない。。。

終わりに

今回はeKYCも偽造検知も何も関係のないオンラインボドゲシステムの話を書かせていただきました。 社内ではボードゲーム以外にも、電源ゲームやアウトドア、筋トレの話をしているslackチャンネルなどもあり、活発なようなあまり活発じゃないような感じです。

コロナ感染者数が減少してこれば、感染症対策をした上でオフィスでのボドゲ会も開催できる機運になってきています。 採用強化中ということもありボドゲ会から来ていただいてもいい気がします(ほんとか?。ぜひぜひどうぞー。

採用情報 - 株式会社Liquid(リキッド)

Liquidの社内勉強会devconfの様子を紹介します

CTOの大岩です。

Liquidでは毎月、社内勉強会であるdevconfを開催しています。開催の様子を、いくつかの過去の発表について軽く触れながら紹介していきます。

devconf の運営について

当初はオフィスの会議室で毎週していたのですが、去年からコロナの影響により基本リモート開催となりました。

  • 月1回 1時間-1時間半
  • 各チームから1人の発表を基本とする
  • 参加自由。立ち見も歓迎!内職上等! ٩(๑´3`๑)۶

のルールで運営しており、実態としては、

  • 3-4名程度が発表
  • 一人あたり15-30分程度
  • 業務が忙しければ気軽に発表をスキップ
  • 時間が来て発表できなかった人は、次回に発表

となっています。

f:id:ooiwa:20210721145840p:plain
開催までのslackのやりとり

過去の発表を紹介

題材はなんでもありで、

  • 仕事で得た知見の紹介
  • 調べてみた、作ってみた
  • 技術書の書評
  • ガジェットやゲームや本の紹介

などが今まで発表されてきました。今回はその一部を紹介していきます。

Goを採用して半年の振り返り

VPoEでeKYC開発責任者の清水の記事です。 eKYCの開発ではGoを採用しています。まず2ヶ月間のプロトタイプ開発による検証を経てから、正式にGoを採用しました。devconfでは、選定理由から、開発時に出てきた課題、解決に向けた取り組みを話しました。

f:id:ooiwa:20210721151105p:plain

開発合宿で作ったものの紹介

Liquidの属するグループELEMENTSFANTRYチームの開発リーダーの渡邉の発表です。この発表の前に、開発合宿にてNFCを用いたデモを作っており、その発表をしました。

f:id:ooiwa:20210721152927p:plain
NFCを読み込むセルフレジのデモを発表してくれました!

令和時代の人体錬成

RDチームの布目が、顔の3次元復元についての検証を発表しました。このテックブログにも記事として載っています。

tech.liquid.bio

おわりに

他にも、WWDCレポや、コーディングテストでの問題設定について、Ergodox EZの紹介、Oura Ringを使ってみた、eKYCの無停止リリースなどの発表が今までありました。

2019年2月に初回開催してから続けており、途中でマンネリ化などの意見が出たこともありました。ですが頻度やルールを適宜振り返り、緩く続けられています。Liquidではエンジニアはリモートワークが多く、チーム外の人と話す場は多くありません。そんな中、専門外の話も聞け、メンバーの意外な一面を知れたりと、devconfが交流の場を一部担っています。やってよかった、これからも続けたいと思います。

そんなLiquidでは、絶賛採用強化中です。興味を持ってくれた人は、ぜひ話を聞きに来てください!

liquidinc.asia

karateを使ったAPIテストの自動化

こんにちは。QAチームのhanです。

LiquidのQAチームでは、リリースをする度に実施する回帰テストを、正確且つ迅速な結果を得るために、テスト自動化を推進しております。
今回はkarateというテストフレームワークを使ったAPIテストの一例を紹介させて頂きます。

karateとは?

  • Gherkinの文法を自然言語に近い形で記述できるテストフレームワーク
  • 学習コストが低い、プログラミング経験がない人も使える(と思っています!)
  • テスト記述方法が直観的で、結果も見やすい(と思っています!)

詳細はこちらで確認できます。 github.com

サンプルコードを実行して、どんなものなのか確認して見ましょう

1.Quickstartを参考にプロジェクト作成します。

f:id:liquid-tech:20210705225727p:plain

2.プロジェクトが作成されたので、中を確認して見るとexamplesがあったのでとりあえず実行して見ます。

f:id:liquid-tech:20210705230103p:plain f:id:liquid-tech:20210705231008p:plain f:id:liquid-tech:20210705230856p:plain

3. ビルドが成功すると、このようにhtml形式でファイル出力されるのでブラウザで確認できます。

f:id:liquid-tech:20210705231330p:plain f:id:liquid-tech:20210705231604p:plain

サンプルコードは実行できたので、テストを書いて見ましょう!

例えば、以下のような仕様と接続情報があるとします。

チケット購入システムにてチケットを購入した人の申請IDを渡すとチケット番号とチケットの有効期限が取得できるAPI

接続URL:https://hogehoge.com/ticket_results/:applicant_id
method:get
Content-Type:application/json
XXX-Key:'xxxxxx'
レスポンス形式:json
テストデータと期待値
シナリオ名           申請IDの状態                 申請ID http status エラーコード 期待値
Scenario_1 存在する
申請ID
abc12345 200 - {"ticket_number":"451104905710","expire_date":"20210731"}
Scenario_2 存在しない
申請ID
def67890 409 CE00004 -
テストの記述方法
Feature: 
テストの概要、詳細などを記述

Background: 
共通的に利用するグローバル変数などを定義しておくと、 各シナリオの前に実行される
複数記述可能

Scenario: シナリオの概要
Given   前提条件を記述
And     複数の条件を連続して記述する際に使う(直前のステップと同じ意味を持つ)
When    REST-APIのmethod(postやgetなど)を記述
Then    期待値を記述
* match response レスポンスのチェック
期待値通りになるようにテストを書いて見る
Feature: sample

Background:
* def URL = 'https://hogehoge.com'
* def requst_headers = {Content-Type: 'application/json', XXX-Key: 'xxxxxx'}

Scenario: 存在する申請ID
Given url URL
And path '/ticket_results/abc12345'
And headers requst_headers
When method get
Then status 200
* match response == {"ticket_number":"451104905710","expire_date":"20210731"}

Scenario: 存在しない申請ID
Given url URL
And path '/ticket_results/def67890'
And headers requst_headers
When method get
Then status 409
* match response contains {"error_code": "CE00004"}
あえて期待値を書き換えてテストを失敗させて見る
  • 存在する申請IDでは、ticket_number の 451104905710351104905710
  • 存在しない申請IDでは、error_code のCE00004CE00005
Feature: sample2

Background:
* def URL = 'https://hogehoge.com'
* def requst_headers = {Content-Type: 'application/json', XXX-Key: 'xxxxxx'}

Scenario: 存在する申請ID
Given url URL
And path '/ticket_results/abc12345'
And headers requst_headers
When method get
Then status 200
* match response == {"ticket_number":"351104905710","expire_date":"20210731"}

Scenario: 存在しない申請ID
Given url URL
And path '/ticket_results/def67890'
And headers requst_headers
When method get
Then status 409
* match response contains {"error_code": "CE00005"}
実行結果
  • テストの成功有無が色分けされています。 f:id:liquid-tech:20210707225210p:plain

  • 失敗しているテストは該当場所をクリックすると、なぜ失敗したか詳細がわかるようになっています。 f:id:liquid-tech:20210707225158p:plain

QAチームでは実際どうしているのか!?

  • Jenkinsを使って定期的に実行しています。
  • 個別のJOBをPipelineに順次実行するようにしています。
  • 失敗しているテストがあったらいち早く気づけるため、Slackに通知されるように仕込んでいます。

f:id:liquid-tech:20210705235142p:plain f:id:liquid-tech:20210707223258p:plain

おわりに

  • 一緒にテストを書きたい人
  • 一緒にキャンプ行きたい人

を探しています。(自称キャンプ部長です。)

お話しましょう、ぜひ気軽にこちらからご連絡ください! www.wantedly.com

Goにジェネリクスが入るので、調べてみた。

f:id:nasjp:20210707165118j:plain

はじめまして!

Liquidのバックエンドエンジニアの谷口と申します。(画像左)

GoでLiquid eKYCや新規プロダクトの開発を行っています。

ジェネリクスproposalがacceptされたので、調べてみました。

今回解説するproposalの範囲

このproposalではtype listという概念を使って、ジェネリクスを実現しています。

さらに、これを改良する新しいproposalも出されています。(https://github.com/golang/go/issues/45346)

type listを導入するのではなく、type setという新しい概念の導入によって、ジェネリクスを実現しようというものです。

これらの2つのproposalを理解するには下記の記事が非常に参考になります。

Go の "Type Sets" proposal を読む

type listではなく、type setが導入されると仮定して、どのようにeKYCに活用できるか考えてみます。

Go のジェネリクス

Goにジェネリクスが導入されると、下記のようなコードを記述できるようになります。

これはどちらのproposalでも変わりません。

type A[T any] []T
func B[T any](p T) { fmt.Println(p) }
type Constraint interface{ Do() }

func C[T Constraint](p T) { p.Do() }

anyは新しく導入される予約語です。どんな型でも受け付けます。

上記のTには型制約がついているので、これを実装した型以外を渡すとコンパイルエラーになります。

型制約はinterfaceでなければなりません。

それぞれ下記のように使用します。

var m A[int] = []int{1, 2, 3}
B[int](1)
type cImplemented int

func (c cImplemented) Do() { fmt.Println(c) }

C[cImplemented](cImplemented(2))
// C(cImplemented(2)) 型推論可能なので左のように記述できる

go2go Playground

型制約とtype list

従来のinterfaceしか型制約として使用できないと困るケースがあります。

+<といった演算ができません。

func D[T Comparable](s T) {
  result := s[0] > s[1] // これができない
  fmt.Prinltn(result)
}

そのため、accept済みのproposalではtype listという概念を導入して、これを解決しています。

type Comparable interface {
    type int
}

これでintunderlying typeに持つ型を使用できるようになります。

The Go Playground

underlying typeのついては下記の資料が参考になります。

入門Go言語仕様 Underlying Type / Go Language Underlying Type

簡単に説明すると、下記のような型および、intintunderlying typeです。

type MyInt int // これ
type MyMyInt MyInt // これも
type MyMyMyInt MyMyInt // これも

下記のように複数記述することで、いずれかの型をunderlying typeに持つ型を使用できます。

type Comparable interface {
    type int, int8, int16, int32, int64
}

type listではなくtype set

新しいproposalでは、type listは無くなり、type setという概念を導入しています。

下記のように記述します。

type Comparable interface {
    int
}

int型のみしか使用できなくなります。

underlying typeは使用できません。

type MyInt int // これは使えない

underlying typeを使用するには下記のように記述します。

type Comparable interface {
    ~int
}

type MyInt int // 使えるようになる

複数の型を許容したい場合は下記のように記述します。

type Comparable interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64
}

使用例

エンティティを表現した構造体のsliceからIDを全件取得したい

エンティティを表現するときには構造体を使用すると思います。

このような構造体のsliceからIDを全件取得するメソッドをそれぞれのsliceにすべて記述するのは結構面倒です。

type A struct {
    ID     int
    FieldA string
    FieldB string
    FieldC string
}

type As []*A

func (as As) IDs() []int {
    ids := make([]int, 0, len(as))
    for _, a := range as {
        ids = append(ids, a.ID)
    }
    return ids
}

type B struct {
    ID     int
    FieldD string
}

type Bs []*B

func (bs Bs) IDs() []int {
    ids := make([]int, 0, len(bs))
    for _, a := range bs {
        ids = append(ids, a.ID)
    }
    return ids
}

func main() {
    fmt.Println(As{{ID: 1}, {ID: 2}, {ID: 3}}.IDs())
    fmt.Println(Bs{{ID: 1}, {ID: 2}, {ID: 3}}.IDs())
}

The Go Playground

これはジェネリクスを使用して下記のように修正できるでしょう。

type Entity interface {
    PrimaryKey() int
}

func PluckID[T Entity](es []T) []int {
    ids := make([]int, 0, len(es))
    for _, e := range es {
        ids = append(ids, e.PrimaryKey())
    }
    return ids
}

type A struct {
    ID     int
    FieldA string
    FieldB string
    FieldC string
}

func (a *A) PrimaryKey() int { return a.ID }

type B struct {
    ID     int
    FieldD string
}

func (b *B) PrimaryKey() int { return b.ID }

func main() {
    fmt.Println(PluckID([]*A{{ID: 1}, {ID: 2}, {ID: 3}}))
    fmt.Println(PluckID([]*B{{ID: 1}, {ID: 2}, {ID: 3}}))
}

The go2go Playground

同じようなメソッドを書く必要がなくなるのでバグが減りそうです。

type setが下記のような制約を許すようになれば、PrimaryKeyメソッドすら実装不要になるでしょう。

type Fooer interface {
    ~struct { Foo int; Bar string; ... }
}

type MyFoo struct {
    Foo int
    Bar string
    Baz float64
}

しかし、これを質問している方がすでにおり、回答を見る限り、今回のproposalには含まれなさそうです。

https://github.com/golang/go/issues/45346#issuecomment-812670939

I'm curious whether it would be possible to allow approximations of structs to match not only the underlying type of a particular struct, but also to allow matches for structs that have at least the exact fields that are listed as the approximate struct element?

https://github.com/golang/go/issues/45346#issuecomment-812661004

Thanks, I think this is an idea for later, likely with a different syntax. I don't think we want to overload the ~ syntax just for struct types.

ポインタに変換するだけのメソッド

構造体をjsonエンコードする際、特定のフィールドに対してnullを許容したいときポインタを使用することがあると思います。

type Response struct {
    A *int    `json:"a"`
    B *string `json:"b"`
    C *bool   `json:"c"`
}

func main() {
    a := 1
    b := true
    res := &Response{
        A: &a,
        B: nil,
        C: &b,
    }

    buf := bytes.NewBuffer(nil)

    json.NewEncoder(buf).Encode(res)

    fmt.Println(buf.String())
}

The Go Playground

a = 1のように一時変数に代入するのを避けるには

それぞれの型に対してメソッドを定義する必要があります。

func uintPtr(uint v) *uint {return &v }
func intPtr(int v) *int {return &v }
func strPtr(string v) *string {return &v }

ジェネリクスを使用することで、これらのメソッドを一つにまとめられます。

func Ptr[T any](v T) *T {
    return &v
}

type Response struct {
    A *int    `json:"a"`
    B *string `json:"b"`
    C *bool   `json:"c"`
}

func main() {
    res := &Response{
        A: Ptr(1),
        B: nil,
        C: Ptr(true),
    }

    buf := bytes.NewBuffer(nil)

    json.NewEncoder(buf).Encode(res)

    fmt.Println(buf.String())
}

The go2go Playground

感想

コピペで作られがちなコードをまとめたり、interface{}で書いている処理を型安全に書けるようになるので良いと感じました。

複数型に対応した処理を自動生成するケースでも、かなりの量をジェネリクスにリライトできると思います。

リリースが楽しみですね。

以上です。

3DDFA形式3DMMを用いた感情(表情)移植

はじめに

 Liquid R&Dチームの小倉です。今回は3DDFAを用いて顔画像からその感情の表出であるところの表情を抽出し、それを他人に移植することで間接的ですが感情の移植を試みたいと思います。

f:id:liquid-tech:20210705152441p:plain
感情移植イメージ

手順

 以下の流れで感情(表情)を移植します

  1. 抽出元、移植先から顔3次元情報を抽出、それから顔2次元情報を復元
  2. 抽出元の顔3次元情報の中の表情成分を移植先の顔3次元情報の表情成分に移植
  3. 表情成分が移植された顔3次元情報から顔2次元情報を復元
  4. 移植先の元の顔2次元情報と表情成分移植後に復元された顔2次元情報を使用して移植先の顔画像を変換

顔3次元情報(3DMM)

 今回顔画像からの表情の抽出は、まず顔検出をFaceBoxesで行い、3DDFA_V2 (3DDFAの後継)で検出された顔領域から顔3次元情報を抽出し、その顔3次元情報の中の表情成分を切り取る事で行います。この顔3次元情報は界隈で3DMM(3D Morphable Model)と呼ばれており、以下3DMMと呼んでいきます。

3DDFA形式の3DMM

 顔の3次元情報の一般的な呼称として3DMMという言葉が最初 に出てきたのは20年ほど前ですが、それから各自で様々な形式 が開発、提案されてきました。

 今回使用する3DDFA形式の3DMMは62次元のベクトルで、内訳は以下の様になっています

  • アフィン変換成分(12次元)
  • 形状成分 (40次元)
  • 表情成分 (10次元)

 アフィン変換成分はカメラ座標から顔の中心への変換行列(3x3の回転成分+3x1の移動成分)となっており、形状成分と表情成分はそれぞれ A 3D Face Model for Pose and Illumination Invariant Face RecognitionFaceWarehouse: a 3D Facial Expression Database for Visual Computingを元にした特徴量をPCAで次元削減したものになっています。

f:id:liquid-tech:20210705155519p:plain
3DDFA の元論文での3DMMの紹介

3DMMを見てみる

 実際に顔画像を入力してみて出力された3DMMを見てみます。左から入力画像、アフィン変換成分(p vec)、形状成分(shp_vec)、表情成分 (exp_vec)となっています。一番右の表情成分に注目すると、感情が無い顔ではあまり値が入っておらず、感情が有る顔ではそれなりに値が入っているのがわかります。

 今回はこの表情成分のみを抽出元から移植先にコピーすることで感情の移植を図ります

f:id:liquid-tech:20210705155748p:plain
感情が有る顔の3DMM

f:id:liquid-tech:20210705155733p:plain
感情が無い顔の3DMM

(アフィン変換成分に関しては移動成分を省略しているため、回転成分の9次元のみを表示しています)

顔2次元情報(ランドマーク)

 顔の2次元情報も様々な形式がありますが、今回は現状最も一般的に使われている68点の形式のものを用います。目、鼻、口、輪郭の各点が画像中の2次元座標で表現されています。

f:id:liquid-tech:20210705155856p:plain
顔の各パーツと68点の対応

3DMM(3次元)から68点ランドマーク(2次元)への変換

 3DMMからこの2次元情報(以下ランドマークと呼びます)を計算する際は、生出力である3DMMの形状成分(alpha_shp)、表情成分(alpha_exp), アフィン変換成分(p, offset )から顔を3D再構成した後に該当のランドマークを画像座標に射影して計算されます。また、この際には3DDFA V2がモデル学習時の正規化で使用された平均、偏差などを使用する必要があります。

 vertex = p @ (u_base + w_shp_base @ alpha_shp + w_exp_base @ alpha_exp).reshape(3, -1, order='F') + offset

ランドマークを見てみる

 先程抽出した3DMMをランドマークに変換すると以下の様になります

入力顔画像 ランドマーク ランドマーク重畳後
f:id:liquid-tech:20210705160617p:plain f:id:liquid-tech:20210705160627p:plain f:id:liquid-tech:20210705160641p:plain
f:id:liquid-tech:20210705160705p:plain f:id:liquid-tech:20210705160716p:plain f:id:liquid-tech:20210705160729p:plain

画像変換

 今回はMoving Least Squaresという手法で画像を変換します。詳細は元論文をご参照下さい。実装はこちらをお借りします。

f:id:liquid-tech:20210705160918p:plain
歪められた微笑

感情の移植

 それでは感情を移植してみます。

まず移植先としては、先程から感情が無い顔の例で登場していた弊社CTO大岩をそのまま用います。

f:id:liquid-tech:20210705160952p:plain
感情の無いCTO大岩

そして抽出元としては、感情がわかりやすくてインターネットで探しやすい、顔画像認識界隈でおなじみトランプ元大統領を用います。

f:id:liquid-tech:20210705161024p:plain
感情むき出しのトランプ元大統領

それでは、喜怒哀の感情がむき出しのトランプ元大統領の感情を、感情の無いCTO大岩の顔に移植していきます(楽は喜とかぶっている気がするので今回は省略します)

抽出元

f:id:liquid-tech:20210705161118p:plain
喜んでいるトランプ元大統領

上機嫌です

移植

f:id:liquid-tech:20210705161401p:plain

喜びがちょっと漏れ出ています

抽出元

f:id:liquid-tech:20210705161440p:plain
怒っているトランプ元大統領

トランプ元大統領といえばこの顔

移植

f:id:liquid-tech:20210705161540p:plain

何か言いたげです

抽出元

f:id:liquid-tech:20210705161615p:plain
哀しんでいるトランプ元大統領

珍しく落ち込んでいます

移植

f:id:liquid-tech:20210705161704p:plain

どこか物憂げです

感情の増幅

 さて感情の移植はしてみましたが、移植前の顔にあまりに感情が無いせいか移植後の顔でもまだ感情の物足りなさが否定できません。そこで、感情の無い顔の表情成分がほぼ0だった事から表情成分を増幅して移植してみるとどうなるかを見てみます。使用する画像は移植の時と同じです。

f:id:liquid-tech:20210705161941p:plain

だいぶ口角が上がっています

f:id:liquid-tech:20210705162026p:plain

怒りが抑えきれずに吠えています

f:id:liquid-tech:20210705162109p:plain

今にも泣き出しそうです

おわりに

 今回は3DDFAを用いた感情の移植と増幅を行ってみました。抽出元、移植先共に画像1枚で画像変換時の補完もしていないので、移植後の画像は正直かなり不自然な感じになっていますが、とりあえず3DDFA形式の3DMMの表情成分が果たしている役割の一端は確認できたかと思います。

 Liquid R&Dチームでは、例えば今回使用したFaceBoxes、3DDFAといったOSSの性能検証もこの様に楽しく行っています。そして、感情の無い顔の持ち主として登場したCTO大岩ですが、本当はこの様に顔画像を乱用しても笑顔で許してくれる感情溢れる良い人です。

長めの補足

 ちなみに顔の表情を抽出して他の顔に移植することはみんな興味があるようで結構昔からやられていて、そもそも3DDFA形式3DMMの表情成分のベースとなっているFaceWarehouse: a 3D Facial Expression Database for Visual Computingでも他人間、本人間での表情の移植がやられています。

f:id:liquid-tech:20210705162205p:plain
FaceWarehouse での他人間感情移植

 またGANが登場してからというもの、学習データセットと移植先画像によっては人間の目では生成されたものかどうかの判断がつかないほどに自然な移植が行われるようになっています

f:id:liquid-tech:20210705162254p:plain
StarGANでの各種GANによる感情移植(生成)

さらに学習済みモデル・コードの公開、計算資源、クラウド環境利用の一般化が進み、インターネットさえ繋がれば誰でも簡単に移植が出来るようになっています。以下はsonyさんが提供しているニューラルネットワークライブラリnnablaにて実装された、First Order Motion Model元ソース)のサンプルコードのGoogleColab上のノートブックにて、トランプ元大統領の映像とCTO大岩の画像を入力して実行した結果です。

f:id:liquid-tech:20210705162542p:plain
歯が合成されて口が自然に開いているように見えます

f:id:liquid-tech:20210705162704p:plain
ただ目の感じなど微妙に変わったようにも見えます

 このように、現在はもはやどの画像がオリジナルでどの画像が生成されたものかが人間の目では判断がつかないという時代になってきています。Liquidではこの現状を受け止めながら、真贋判定技術の開発に取り組んでいます。

令和時代の人体錬成

おはこんにちは、Liquid R&Dチームの布目です。

Liquid R&Dチームで現在重点的に取り組んでいることに、eKYCでの顔の真贋判定があります。この記事では真贋判定について簡単に解説します。また真贋判定をより頑強にするための、顔3次元復元+3Dプリンタで人体錬成する取り組みについてまとめました。

真贋判定とは

真贋判定は、本物と偽物を判定するといった意味合いです。顔の真贋判定では、撮影された顔が本物か偽物かを判定することになります。顔の偽物は大雑把に3種類に分けることができます。

  • Photo Attack
    • 偽装したい人の顔写真を紙に印刷して容貌撮影
  • Display Attack
    • 偽装したい人の画像/動画をスマホやディスプレイに表示して容貌撮影
  • Mask Attack
    • 偽装したい人の顔のお面を使って容貌撮影

eKYCでは、例えば不正な手段で入手した他人の顔画像を印刷して、顔撮影を突破しようとすることが想定されます。もし突破されてしまうと、赤の他人で本人確認ができてしまい、銀行口座の開設などができるようになってしまいます。撮影された顔が本物であるか偽物であるか見極めるのはとても大切です。しかしながら、本物は本物と、偽物は偽物と判定する手法はまだまだ発展途上であり多くの研究が行われています。(arxivなどでface anti spoofなどをキーワードに検索するとたくさん論文を読むことができます。)

真贋判定の課題点の一つにデータ集めがあります。上であげたPhoto AttackとDisplay Attackは多大な工数が必要ですが、比較的容易にデータ収集できます。しかしMask Attackはそもそも誰かのお面を作る必要があります。今回は3Dプリンタでお面を作ることを目標に、機材、3次元復元手法について調査しました。最終的には以下のようなサイクルを想定しています!

f:id:liquid-tech:20210705123042p:plain

顔3次元復元について

3つの方向性があります。

  • RGBカメラで複数の角度から撮った数枚の画像から復元
  • RGB-Dカメラで複数の角度から撮った数枚の画像から復元
  • 3Dスキャナで顔形状の復元

RGBカメラとRGB-Dカメラを使ったものを試しました。

RGBカメラ

RGBカメラはスマートフォンデジタルカメラなどに搭載されてるカメラです。いわゆる一般的なカメラですね。

MetaShapeとFaceBuilderというソフトを試しました。

f:id:liquid-tech:20210702123144p:plain f:id:liquid-tech:20210702123154p:plain

MetaShapeはちょっとギザギザした感じ、FaceBuilderはなんとなくスムージングされたモデルを出力しました。

RGB-Dカメラ

RGB-Dカメラは、RGBカメラ同様の画像に追加で深さ(depth)情報を取得できるカメラです。MicrosoftのKincet, Kincet AzureやIntelのReal SenseシリーズなどがRGB-Dカメラにあたります。(AppleのFaceIDでも使われているようです。)

まずはReal Sense d415 + RecFusionというソフトを試しました。

f:id:liquid-tech:20210702123214p:plain f:id:liquid-tech:20210702123240p:plain

ちょっと荒さを感じる結果になりました。

ちょうどこれらを調べている時期にRGB-Dカメラを使ったフォトリアルな3次元復元手法に関する論文が出たのでそちらも試しました。(https://arxiv.org/pdf/2010.05562.pdf) こちらはAzureKinectを使用しています。

f:id:liquid-tech:20210702123300p:plain

なかなかリアルなモデルが出力されて良さそう!でも目がない!となりました。

おわりに

RGB,RGB-Dカメラでの3次元復元を試し、なかなか良いモデル作成が行えることを確認しました。どちらの場合でも言えることですが、高性能なカメラ、正確なカメラキャリブレーションがないとリアルなモデルの作成は難しいです。またリアルなモデルが真贋判定で有利になるのかはまた別に議論が必要な点です。実はちょっとカクカクしているなどの低品質なマスクの方が真贋判定を欺ける可能性も十分に考えられるので、今後も実験を試していきたいところになります。

今回の人体錬成で持って行かれるものはお金と時間だけです!腕と脚は持ってかれないのでとても良心的です!みなさんも気軽に人体錬成しましょう!

Goでスタックトレースを上書きせずにエラーをラップする方法

こんにちは。eKYC開発チームの藤本です。

eKYCのサーバーサイドではpkg/errorsパッケージを使用してGoのスタックトレースを記録しています。スタックトレースは標準のerrorsパッケージではサポートされていませんが、エラー発生時のスタックトレースがわかるとエラーの解決が楽になるので、是非記録しておきたい情報です。

pkg/errorsパッケージを使用してスタックトレースを記録するには、エラーを初期化するときにerrors.New, errors.Errorf関数を使用するか、既存のエラーに対してerrors.WithStack関数を使用します。こうすると、作成されたエラーerrに対してerr.StackTraceメソッドを呼び出すことによりスタックトレースを取得することができ、err.Formatメソッドの出力にもスタックトレースが含まれるようになります。

こうして作成したエラーをラップするとどういう挙動になるでしょうか?

errors.Wrap関数を使用した場合

pkg/errorsには、errors.Wrapというその名の通りにエラーをラップしてくれる関数があるので、これを使用して作成したエラーがどういう挙動をするのかを実際にコードを書いて確認してみます。

コード

package main

import (
    errs "errors"
    "fmt"
    "github.com/pkg/errors"
)

func main() {
    err := errors.New("error")

    we := errors.Wrap(err, "wrap")

    fmt.Println("### Error() ###")
    fmt.Printf("%v\n\n", we.Error())
 
    fmt.Println("### StackTrace() ###")
    if e, ok := we.(interface{ StackTrace() errors.StackTrace }); ok {
        fmt.Printf("%+v\n\n", e.StackTrace())
    }

    fmt.Println("### Formatter ###")
    if e, ok := we.(fmt.Formatter); ok {
        fmt.Printf("%+v\n\n", e)
    }

    fmt.Println("### Unwrap() ###")
    fmt.Printf("%v\n", errs.Is(we, err))
}

出力

### Error() ###
wrap: error

### StackTrace() ###

main.main
    /tmp/sandbox309561315/prog.go:12
runtime.main
    /usr/local/go-faketime/src/runtime/proc.go:225
runtime.goexit
    /usr/local/go-faketime/src/runtime/asm_amd64.s:1371

### Formatter ###
error
main.main
    /tmp/sandbox309561315/prog.go:10
runtime.main
    /usr/local/go-faketime/src/runtime/proc.go:225
runtime.goexit
    /usr/local/go-faketime/src/runtime/asm_amd64.s:1371
wrap
main.main
    /tmp/sandbox309561315/prog.go:12
runtime.main
    /usr/local/go-faketime/src/runtime/proc.go:225
runtime.goexit
    /usr/local/go-faketime/src/runtime/asm_amd64.s:1371

### Unwrap() ###
true

Go Playground

StackTraceメソッドの出力は、errros.Wrap関数を呼び出した時点(12行目)のスタックトレースになってしまい、元々のエラーの発生箇所(10行目)がわからなくなってしまいます。Formatメソッドの出力には、ラップした時点のスタックトレースと元々のスタックトレースが両方出力されるようになります。
標準パッケージのIs関数で元々のエラーとの一致判定を行うことで、作成したエラーがUnwrapメソッドをサポートしていてラップされた状態になっていることも確認しています。(Is関数の仕様)

エラーの原因を特定するためには、元々のエラーの発生箇所を知りたいので、それがわからなくなってしまったり、余分な情報がついて読みづらくなるのはできることなら避けたいところです。

errors.WithMessage関数を使用した場合

命名からは一見わかりませんが、errors.WithMessage関数もエラーをラップしてくれるので、これを使用して作成したエラーの挙動も同様のコードを書いて確認してみます。

コード

package main

import (
    errs "errors"
    "fmt"
    "github.com/pkg/errors"
)

func main() {
    err := errors.New("error")

    me := errors.WithMessage(err, "message")

    fmt.Println("### Error() ###")
    fmt.Printf("%v\n\n", me.Error()) 

    fmt.Println("### StackTrace() ###")
    if e, ok := me.(interface{ StackTrace() errors.StackTrace }); ok {
        fmt.Printf("%+v\n\n", e.StackTrace())
    }

    fmt.Println("### Formatter ###")
    if e, ok := me.(fmt.Formatter); ok {
        fmt.Printf("%+v\n\n", e)
    }

    fmt.Println("### Unwrap() ###")
    fmt.Printf("%v\n", errs.Is(me, err))
}

出力

### Error() ###
message: error

### StackTrace() ###
### Formatter ###
error
main.main
    /tmp/sandbox076104167/prog.go:10
runtime.main
    /usr/local/go-faketime/src/runtime/proc.go:225
runtime.goexit
    /usr/local/go-faketime/src/runtime/asm_amd64.s:1371
message

### Unwrap() ###
true

Go Playground

こちらの出力では、スタックトレースをが上書きされずにラップできていることがわかります。ただし、StackTraceメソッドは使えないため、Formatメソッドの出力結果でスタックトレースを確認する必要があります。

まとめ

エラーをerrors.Wrap関数とerrors.WithMessage関数でラップした場合の挙動を比較してみました。StackTraceメソッドが使えないというデメリットはありますが、errors.WithMessage関数を使うとスタックトレースを上書きせずに簡単にエラーをラップできるので是非使用してみてください。

Liquid が目指すものと技術組織の取り組み

こんにちは、Liquid の CTO の大岩 ( @ooiwa ) です。

Liquid という名前を聞いたことがあるでしょうか? ご存知な方は、生体認証、eKYCという言葉を思い浮かべるかもしれません。僕たちは、LIQUID eKYC というB2B2Cのサービスを作っています。一言でいうと、「従来は窓口に行く必要があった本人確認を、スマートフォンでセルフィー撮影と身分証撮影で済むようにした」サービスです。このブログ開設の日からちょうど2年前にあたる2019年7月1日にリリースされ、金融口座の開設から、携帯電話の契約、C2Cサービスでの本人確認など、幅広く使われています。

それを運営するLiquidは、「認証を空気化し、滑らかな世界をつくる」ことを目指しています。LIQUID eKYC の実現により、このビジョンへ少し近づくことができました。窓口や郵送の必要がなくなり、新しくサービスを利用するまでの時間を短縮できました。

目指すべき世界は更に滑らかなものを想像します。例えば、サービスを利用するまでの時間を短縮できたものの、より待ち時間を少なくできないか。口座作成時のみならずログイン時や端末を変えたときなども、パスワードなしにスムーズに利用ができないか。

f:id:ooiwa:20210630151435p:plain
公式HPでは、我々が抱く未来のイメージを紹介しています。

そこに近づくために、Liquidのエンジニアたち、リサーチャーたちは何に取り組んでいるのか? 第1回テックブログでは、その課題と取り組みの一部を紹介していきます。

LIQUID eKYC を末永く続けるための改善

今日で2周年を迎えたLIQUID eKYCですが、

  • セキュリティルームの構築を含む高いセキュリティ
  • いち早いスマートフォンWEBでの対応
  • リリースから今までの離脱率の改善

他にも多くのことを達成できており、ここまで至れたチームを誇りに思います。

一方で、機能追加と安定性を重視して進んできた結果、運用負荷への対応が後回しになってしまったり、当初の想定を超えた大幅な仕様変更に苦労するところも出てきています。コードベースやパフォーマンスの改善、チームとして取り組めるようなレベルアップと、一歩ずつ先に進んでいくことに取り組んでいます。

そして、このサービスは、金融を含めた様々な業界を支える認証のインフラとして、至極当たり前ですが今後長く存在し続けます。技術の発展に伴い、より楽に使えて、より高セキュリティに成長をし続ける予定です。また僕たちは、eKYCをビジョンの実現のための起点と考えています。本人確認された情報をもとに、様々なサービスが作られていくことを目指しています。データの持ち方のありようや、セキュリティのレベルを離散的に上げられる技術も検討していきます。

f:id:ooiwa:20210630151650p:plain
Miro でのデザイン協議

f:id:ooiwa:20210630151716p:plain
ある日のeKYCバックエンドおしゃべりのメモ

不正検知技術の向上

鍵となる技術の1つが、不正検知技術です。

今のeKYCでは、セルフィーを撮影する際に顔を動かしてもらったり、免許証の斜め面をとってもらったりと、少しユーザーに負担をお願いしています。また、オペレーターの管理画面での確認の時間もかかります。大多数のユーザーには関係ないものの、ごく一部の不正をチェックするためにこのプロセスが存在しています。逆に、不正を検知する技術が上がれば、それぞれの負担を軽減し、今までコストが高くてできなかった用途でも楽に認証ができる可能性があります。

Liquidでは、顔認証、画像処理による偽造顔検知(※1)、端末の使いまわし検知(※2)のあわせ技でこの問題に取り組んでいます。データのあり方の検討から、スマートフォンアプリやWEBでのUIの検討と作成、データの作成、モデルの検証、実データでの評価とループを回しています。

※1 特許出願済 2020-173696 ※2 特許出願済 2020-177094​

f:id:ooiwa:20210630151948p:plain
左から本物(Bonafide)の大岩、ディスプレイ上の大岩、お面の大岩、face swap された大岩

eKYC の次の認証を空気化する新規事業

そして、eKYCで登録された情報をどう活用し、さらなる価値を作り出していくかが、新規事業のチームの課題となります。

eKYCで顔撮影をして口座登録をしたら、ログインや送金時の認証も顔でできないでしょうか。パスワードを忘れても、面倒なやり取りで2,3日待つことなく、安全に即座に復帰ができないでしょうか。登録後のアカウントの引き渡しを防ぐすべはないでしょうか。

  • 法律や、世界の標準化の動きの把握
  • 顔認証や不正検知の技術の精度や振る舞いの見極め
  • 高いセキュリティの担保とユーザーの同意
  • ユーザーや事業者のニーズの開拓

と多くのことを考慮し、さらにこれを高速で検証しなければならず、もう一つスタートアップを作るようなものになっています。

特にここは圧倒的に人が足りないのですが、今中心メンバーとなってくれる方を絶賛募集しています。

おわりに

今回は課題と取り組みの一部を紹介しました。次回からは、様々な情報発信をして、どうこのビジョンに向かっているのか、またどんな人達がいるのかを知ってもらえればと思います。

こんな記事を書く予定です。

  • LIQUID eKYC を支える技術の紹介
  • メンバーが日々学び試している技術の紹介
  • 社内の議論のメタファーとしてよく出る本やアニメの紹介

そんなLiquidでは、一緒に技術のチャレンジをしてくれる仲間を探しています。

  • 目指すべき世界にワクワクする人
  • 技術の取り組みに興味がある人
  • (これからのブログ記事で)中の人に興味を持った人

お話しましょう、ぜひ気軽にこちらからご連絡ください!

liquidinc.asia