catalinaの備忘録

ソフトウェアやハードウェアの備忘録。後で逆引きできるように。

MTGのカード情報の傾向分析を行う(準備編)

GWが終わって、しばらく祝日なしの日が続きます。 私は毎年この時期を「6月砂漠」と呼ぶことにしています。かたりぃなです。

今回はMTGのカードのテキストデータを分析してみようと思います。 画像処理ばかりやってると数式相手になることが多いので、時には気分転換も必要です。

目的とか

MTGのカードは時代によって流行り廃れがあります。 たとえば、去年はすごく流行っててカードショップでも高額で取引されていたカードが、今は全然見かけないとか。

これを分析することができれば、何かデータが見えてこないかなというのが今回の目的です。

テキストマイニングは初めてなので、できるだけ手軽に既存のツールやライブラリを組み合わせて実現していこうと思います。

まず今回は環境を作り上げて軽く動かすところまでやってみます。

環境

いつものです

  • ホストOS : Windows10 Pro
  • 仮想化 : Docker desktop for windows
  • ゲストOS : Linux
  • 分析基盤 : Elasticsearch
  • GUIフロントエンド : Kibana

コンテナ一式を立てる

Elasticsearchを使うためのコンテナ一式を立てます。

今回のElasticsearchの用途は次のとおりです

  • 他ホストからRESTでデータを投げ込んでもらう
  • 日本語解析にはkuromojiを使う
  • Kibanaで確認する

早速コンテナを立てるところからやってみます。

docker-compose

docker-compose.ymlはこうなりました。

version: "3"
services:
  elasticsearch:
    build: 
      context: .
      dockerfile: ./kuromoji.dockerfile
    image: elastic
    ports: 
      - "9200:9200"
      - "9300:9300"
    environment:
      - discovery.type=single-node
    networks: 
      - elastic-stack

  kibana:
    image: docker.elastic.co/kibana/kibana:6.5.4
    ports:
      - "5601:5601"
    networks: 
      - elastic-stack

networks:
  elastic-stack:

kuromoji.dockerfile

elasticsearchの日本語解析プラグインとしてkuromojiを使いたいので、それを組み込むためのdockerfileを書きます。

上記docker-compose.ymlが参照するkuromoji.dockerfileはこうなりました。

インストールするだけでした。

FROM docker.elastic.co/elasticsearch/elasticsearch:6.5.4

# 公式マニュアルにあるkuromojiをインストールする。
# https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html
RUN elasticsearch-plugin install analysis-kuromoji

これでelasticsearchの用意ができました。

起動確認

docker-compose up -dして起動確認します。うまくいかないときはdocker psで生存確認したり、docker-compose logsでログを見たりします。

うまく起動したら、kuromojiが有効になっているか確認します。

curl -X GET 'http://localhost:9200/_nodes/plugins?pretty' 

このあたりの基本的な操作はこちらのサイトを参考にさせていただきました。 http://pppurple.hatenablog.com/entry/2017/05/28/141143

Elasticsearchに投入するデータを用意する(MTGのカードデータ)

以下の手順でデータを注入します。

  1. MTGのカードデータをMTG公式のRESTから取得し、ファイルに保存
  2. 保存したファイルを整形して、ElasticsearchのRESTに渡す

データのダウンロードとElasticSearchへの注入を分けた理由は、MTG-APIが外部サーバだからというのと、個人的に空いた時間にちまちまとコードを書きたかったからです。

ネットが繋がらない状態でもローカルにファイルを持っていれば作業できるので。

MTG公式からのカードデータ取得と保存

python3でいきます。こうなりました。 以前DeepLearningのときに試したコードの引用ですね。

# -*- coding: utf-8 -*-

from mtgsdk import Card
from mtgsdk import Set
from mtgsdk import Type
from mtgsdk import Supertype
from mtgsdk import Subtype
from mtgsdk import Changelog

import json
import argparse
import pickle
import urllib
import os
import codecs

def print_allsets():
    print("================ cardset list ================")
    allsets = Set.all()
    for cardset in allsets:
        print('{}  ({})'.format(cardset.code, cardset.name) )
    print("================ cardset list ================")

def main():
    parser = argparse.ArgumentParser(description='mtg image download')
    parser.add_argument('--setcode',
        action='store', type=str, nargs=None, const=None, 
        default=None, choices=None,
        help='download card images from set code')
    args = parser.parse_args()    

    if args.setcode == None:
        print_allsets()
        parser.print_help()
        exit()

    cardlist = Card.where(set=args.setcode).where(language='japanese').all()
    workdir = os.path.join( '.', 'card-texts', args.setcode)

    print("{} card num = {}".format(args.setcode, len(cardlist) ) )
    print("image download to {}".format(workdir) )

    if not os.path.exists(workdir):
        os.makedirs(workdir)

    for index, card in enumerate(cardlist):
        # 有用な情報は多々とれそうだが、一旦日本語の部分だけ抽出してみる
        for foreign_names in card.foreign_names:
            if foreign_names['language'] == 'Japanese':
                # ファイル名にはユニークなイテレーションの数値を使う
                out_file_name = '%s/%d%s' % (workdir, index, '.txt')
                print("{} ( {} )".format(out_file_name, card.name) )
                out_file = codecs.open(out_file_name, "w", 'utf-8')
                out_file.writelines( json.dumps(foreign_names, ensure_ascii=False) )
                out_file.close()

    print("download complete")

if __name__ == '__main__':
    main()

使い方はこんな感じです

python download_cardtext.py --setcode=DOM

こうするとカレントディレクトリに./card-texts/DOMディレクトリが作られ、そこにドミナリアのカード情報一覧が保存されます。

たとえば./card-texts/DOM/0.txtはこんな内容になります。json形式ですね。

{"name": "アカデミーのドレイク", "text": "キッカー{4}(あなたはこの呪文を唱えるに際し、追加で{4}を支払ってもよい。)\n飛行\nアカデミーのドレイクがキッカーされていたなら、これは+1/+1カウンターが2個置かれた状態で戦場に出る。", "flavor": null, "imageUrl": "http://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=444273&type=card", "language": "Japanese", "multiverseid": 444273}

elasticsearchにデータを入れていく

ElasticSearchの前準備として次の2つを行います。

  • kuromoji日本語解析器を設定します
  • MTGのカードを解析するためのマッピング設定を行います。

簡単に説明します、前者の日本語解析器はいわゆる形態素解析してくれるモジュールです。

形態素解析 is 何?

私はその分野の専門家ではないので、今回の用途だけに絞って簡単に説明します。

興味ない人は読み飛ばしてください。

上記jsonのままでは、たとえばtextフィールドの長い文字列からの検索になってしまい、単語単位での分析がうまくできません。

おおざっぱに解釈すると、単語単位に分割する方法の一つが形態素解析らしいです。

そんな面倒な処理必要なの?って思ったので軽く調べたところ、

  • 英語やドイツ語などであれば、空白で区切れば単語単位になる
  • 日本語では空白で区切るという概念はない

あたりが要因のようです。

「日本語は助詞で区切ればいいのでは?」と思いますが、境界判別という問題があって、助詞がどこかわからない文字列だと解釈に困ります。

そのあたりを頑張ってくれるモジュールということなので、便利に使わせてもらいましょう。

設定はこうなりました。これをmapping.jsonとして保存しておいて

{
  "settings": {
    "index": {
      "analysis": {
        "tokenizer": {
          "kuromoji_user_dict": {
            "type": "kuromoji_tokenizer",
            "mode": "extended"
          }
        },
        "analyzer" : {
          "my_analyzer" : {
            "type" : "custom",
            "tokenizer" : "kuromoji_tokenizer"
          }
        }
      }
    }
  },
  "mappings": {
    "cards" : {
      "properties": {
        "name": {
          "type": "text",
          "fielddata": true,
          "analyzer": "kuromoji",
          "fields": {
              "keyword": {
                "type": "keyword",
                "ignore_above": 256
              }
          }
        },
        "text": {
          "type": "text",
          "fielddata": true,
          "analyzer": "kuromoji",
          "fields": {
              "keyword": {
                "type": "keyword",
                "ignore_above": 256
              }
          }
        },
        "flavor": {
          "type": "text",
          "fielddata": true,
          "analyzer": "kuromoji",
          "fields": {
              "keyword": {
                "type": "keyword",
                "ignore_above": 256
              }
          }
        }
      }
    }
  }
}

こうやってelastic-searchに投げます。

curl -X PUT 'localhost:9200/mtg?&pretty=true' -d @mapping.json -H 'Content-Type: application/json'

うまくいけばこんなjsonレスポンスが返ってきます。

{
  "acknowledged" : true,
  "shards_acknowledged" : true,
  "index" : "mtg"
}

下準備ができたのでデータ注入します。

Elasticsearchに入力するデータは少々加工する必要があるので、スクリプト書きました。

スクリプトの概要はこんな感じです。

  • ローカル環境に保存してある特定のカードセットすべてのファイルを読み込んで、結合
  • ElasticSearchのために各カード情報にヘッダ(のようなもの)を付与
  • 動作確認用にall_<カードセット名>.txtとして保存
  • elasticSearchに入れる

思ったより簡単にできたのでブログに直接乗せてしまいます。

# -*- coding: utf-8 -*-

import json
import argparse
import urllib
import os
import codecs

from mtgsdk import Set

def print_allsets():
    print("================ cardset list ================")
    allsets = Set.all()
    for cardset in allsets:
        print('{}  ({})'.format(cardset.code, cardset.name) )
    print("================ cardset list ================")

def listup_carddata(workdir):
    for filename in os.listdir(workdir):
        fullpath = os.path.join(workdir, filename)
        if os.path.isfile(fullpath):
            yield fullpath

# 1件のエントリをelasticsearchに転送できるよう加工する
def deform_carddata(data):
    return '{ "index": {"_index":"mtg", "_type":"cards" } }\n' + data + '\n'


def collect_data(workdir):
    allcard = ''
    for filepath in listup_carddata(workdir):
        f = codecs.open(filepath, 'r', 'utf-8')
        data = f.read()
        allcard += deform_carddata(data)
    return allcard

def register_es(elasticsearch_host, indexname, json_data):
    headers = {'Content-Type': 'application/json',}
    api = '/_bulk/?pretty'
    url = urllib.parse.urljoin(elasticsearch_host, indexname) + api
    print("request url = {}".format(url) )
    req = urllib.request.Request(url=url, data=json_data.encode(), headers=headers )
    result = urllib.request.urlopen(req)
    print(result)

def main():
    parser = argparse.ArgumentParser(description='mtg image download')
    parser.add_argument('--setcode',
        action='store', type=str, nargs=None, const=None, 
        default=None, choices=None,
        help='download card images from set code')
    args = parser.parse_args()

    if args.setcode == None:
        print_allsets()
        parser.print_help()
        exit()

    workdir = os.path.join( '.', './card-texts', args.setcode)
    if not os.path.exists(workdir):
        exit()

    # データを集めて
    allcard = collect_data(workdir)
    allcard += '\n\n'  # esとしては末尾に改行が必要なので

    # データ形式があってるか目視確認用のファイルを保存
    f = codecs.open('all_'+args.setcode+'.txt', 'w', 'utf-8')
    f.write(allcard)
    f.close()

    # ElasticSearchに入れる
    elasticsearch_host = 'http://localhost:9200'
    es_index = 'mtg'
    register_es(elasticsearch_host, es_index, allcard)


if __name__ == '__main__':
    main()

動かします。

python register_cardlist.py --setcode=DOM
request url = http://localhost:9200/mtg/_bulk/?pretty
<http.client.HTTPResponse object at 0x000001FCA430A048>

エラーチェックがすっぽ抜けてますね。。。 HTTPResponseオブジェクトが返ってきてとりあえずいけました。

API叩いていても寂しいので、テキストマイニングっぽくワードクラウドを作ってみます。

これでkibanaから日本語で検索したり色々できるようになります。

ElasticSearchでワードクラウドを作る用意

kibanaで操作していきます。

インデックスパターンを作る

  1. kibanaの画面左のManagementを選択
  2. ElasticSearch の Index Pattern を選択
  3. インデックス名に"MTG"を入力
  4. あとは適当にOKおしていく

こんだけです。

このときの注意点で、フィールド名一覧の管理画面でtext,name,flavorの各フィールドの「Aggregatable」が有効になっていることを確認しておきます。

詳しいことはわからないのですが、どうやらRAM上に載せておいて色々な処理に使える(たとえば今回のワードクラウド)ようです。

データが入っているか確認する

  1. kibanaの画面左のDiscoverを選択
  2. 目視確認

それっぽいデータが入っていればOKです。

ワードクラウドを作る用意

ElasticSearchではタグクラウドというらしいです。

タグクラウドを作るための基本設定をしていきます。

  1. kibanaの画面左のVisualizeを選択
  2. "+ Create a visualization"を選択
  3. ビジュアライズ手法の最下部にある'Tag Cloud'を選択
  4. From a New Search, Select IndexのNameから'MTG'を選択(これはkibanaで最初に作ったインデックスの名前です)

これでタグクラウドの設定画面まできました。

タグクラウドを出してみる

タグクラウドの設定画面で色々設定してみます。

テキストマイニングは初めてなので、まずは雰囲気を掴んで面白いと思うところが大事だと思います。

設定値はこんな感じにしました。

  • Buckets
    • Aggregation = Terms
    • Field = text
    • Order By = metric:count
    • order = desenc
    • size = 40

こんな感じのできました。 f:id:Catalina1344:20190528133913p:plain

なかなか楽しいですね。

ぱっと見たところ、MTG固有の用語がうまく分析できてなさそうです。

  • プレインズウォーカー = (プレイン / ズウォーカー)
  • トークン = (トー / クン)
  • トレイリア(地名) = (トレイ / リア)

ひとまずテキストマイニングの土台ができたと思うので一旦良しとします。

感想と今後の展望

テキストの分析がうまくいっていないのは形態素解析の都合で、未知の単語をうまく解釈できてないことによるものです。

今回の用途(MTG専用の分析をやりたい)であれば、kuromojiの辞書に追加してあげるだけでよさそうです。

また単語の出現傾向を分析するのも色々楽しそうです。

今回のタグクラウドをいじっていると歴史的アーティファクトがセットで現れることが多いです。

ゲームが分かる人からみれば「ドミナリアでテストしたから当然でしょ」って内容ではありますが、こういう「当たり前だね」っていうのをデータで示しておくことは分析では重要なことだと思います。

それでは今回はこれくらいで。

WinRTを試してみる

桜の花も散っていき、春から初夏への移り変わりを感じます。かたりぃなです。

今回は新年度ということで、新しいことを試してみようと思います。C++/WinRTです。

C++/winRTを使う理由

Hololens用のUnityのネイティブプラグインを書こうと色々調べていたら、C++/CX終わるよみたいなアナウンスに気づきました。

https://docs.microsoft.com/ja-jp/cpp/cppcx/visual-c-language-reference-c-cx?view=vs-2019

要約すると、「C++17とWinRTを使うと、幸せになれるよ」ってことですね。

というわけで、今後のことも考えてwinrtを試してみます。ダメだったら戻ればいいかと思って試したところ、かなりイケてると思います。

なのでWinRT/C++でいきます。

WinRTとは

winRTはc++17の機能を使うことでC++/CXよりエレガントに記述できます。 実際に書いてみるとわかるのですが、C++使いなら直感的に記述できる感じでした。

プラットフォームの観点からは

  • UWPアプリを書ける
  • ネイティブアプリも書ける(従来のデスクトップアプリ)
  • Unityプラグインとしてもいける(はず?)

コードを書く観点から

  • 非同期操作が扱いやすくなっている(concurrencyTaskなんて無かった)
  • プロパティへのアクセスが直感的
  • イベントハンドラ登録が直感的に書ける
  • COMへの参照はwinrtの中で良しなにやってくれる

とかでしょうか。 特に非同期操作がラクになったのが大きくて、意味不明なテンプレートのエラーを見る回数が減ったと思います。

今回はまだ非同期操作まわりはよくわかってないので、同期的な処理で完結しています。

実際にwinrtでコード書いてみる

最終目標はunity ネイティブプラグインです。

なので、visual studio プロジェクトとしてはDLLになります。

また、DLLのテスト用にコンソールアプリケーションを作成します。

UWPアプリケーションからDLLを呼び出すテストはまた今度。。。(Hololens実機でのネイティブプラグイン相当)

環境

いつものです。

環境設定

Visual Studio 2017に色々いれたのですが、試行錯誤していたせいで失念してしまいました。

VisualStudioでオンラインのWinRTテンプレートをインストールか、VisualStudio のインストーラからコンポーネントを指定したかもしれません。

設定が終わると新規作成プロジェクトからC++/WinRTが選択できるようになります。

プロジェクト構成

VisualStudioのプロジェクトとしては2つ作ります。 1つはc++/winRTを使用したDLLそのものので、もう一つはテスト用プロジェクトです。

ソリューションにこの2つのプロジェクトを配置することにします。

このDLLは最終的にUnity側で利用するわけですが、テストのたびにUnityとVSを行ったり来たりしてのデバッグは大変なので、テストプロジェクトで普通のDLLとして扱えることを確認しておきます。

WinRTのDLLプロジェクトを作成する

WinRTのDLLプロジェクトのテンプレートが手元にないので、手作業で作ります。

具体的には次の手順です。

  1. 新規プロジェクト作成から、VisualC++ -> Windowsデスクトップ -> DLL を選択
  2. プロジェクトの設定値を変更する

これでWinRTでDLLが作成できるようになりました。

WinRTを利用するためのプロジェクト設定の手順はこちらのサイトを参考にさせていただきました。 https://blog.okazuki.jp/entry/2018/10/16/144556

実際のDLLのコードは後で示します。

テストプロジェクトを作成する

DLLをテストするプロジェクトとしてコンソールアプリケーションを作成します。 テストプロジェクトは、上記DLLプロジェクトと同じソリューションに含まれるようにしておきます。

具体的には ソリューションエクスプローラのroot要素(ソリューション)を右クリックして、

追加->新しいプロジェクト->コンソールアプリケーション

です。

テストプロジェクトは同じくコンソールアプリケーションを使います。

2つのプロジェクト間の依存関係の設定

テストプロジェクト側をスタートアッププロジェクトに設定し、プロジェクトの依存関係からDLLが先にビルドされるよう設定しておきます。

ついでに関数の呼び出し規約をstdcallに設定します。

ソリューションエクスプローラのプロジェクトを右クリックして「プロパティ」「C++タブ」「詳細設定」「呼び出し規約」のところを「stdcall/Gz」に設定します。

あとはビルドターゲットを

  • Releaseビルド
  • 32bit,64biをUnityのバージョンと合わせる

とします。

テストプログラムから呼び出す

DLLを呼び出すにはWindowsAPIでゴリゴリ(LoadLibrary, GetProcAddr)とかやってもいいのですが、手抜きでいきます。

DLLプロジェクトがDLLとインポートライブラリを生成してくれているので、インポートライブラリ利用します。

プロジェクト設定画面から、 インポートライブラリはリンカの設定で、DLL本体はデバッグ時のパスの設定で解決します。

リンク設定とデバッグ環境のパス設定はこんな感じです。

リンク設定

リンカ-> 入力-> 追加の依存ファイルに $(SolutionDir)x64\release\consoleDll.lib;%(AdditionalDependencies) を設定します。 ここでconsoleDllというのはDLL側プロジェクトの名前です。

デバッグ環境のパス設定

デバッグ-> 環境 の項目にPATH=$(SolutionDir)x64\release\を設定します。

ここまででDLLの生成とそれを読み込むテストプログラムの設定ができました。

設定が正しいかどうかDLLとアプリケーションをビルドして確認しておきます。パス指定を間違えたりしているとリンク時やデバッグ時にエラーが出るので状況確認してつぶしていきましょう。

実際にコードを書いてみる(DLL側)

単純なDLLではデスクトップアプリを作る場合と何も変わらないので、UWPで利用できるAPIを呼び出してみることにします。

昔の記事でC++/CXでやってたMediaCaptureを使ってみます。

#include <thread>

#pragma comment(lib, "windowsapp")

#include <winrt/Windows.UI.Notifications.h>
#include <winrt/Windows.Data.Xml.Dom.h>

using namespace winrt;
using namespace Windows::Foundation;
using namespace Windows::UI::Notifications;

#include "winrt/Windows.Foundation.h"
#include "winrt/Windows.Web.Syndication.h"
using namespace winrt;
using namespace Windows::Foundation;
using namespace Windows::Web::Syndication;

#include <winrt/Windows.Media.h>
#include <winrt/Windows.Media.Capture.h>
#include <winrt/Windows.Media.Capture.Frames.h>

using namespace Windows::Media::Capture;
using namespace Windows::Media::Capture::Frames;

// 新しいメディアフレームが到着した際にMediaCaptureから呼び出されるコールバックイベントハンドラ
void OnFrameArrived(
    MediaFrameReader sender,
    Windows::Media::Capture::Frames::MediaFrameArrivedEventArgs args)
{
    OutputDebugString(L"OnFrameArrived called \n");
    auto frame = sender.TryAcquireLatestFrame();
}


// MediaCaptureのための状態をまとめて管理する
struct MediacaptureWrapper {
    MediacaptureWrapper(MediaCapture cap, MediaFrameReader rd) :
        mc(cap),
        reader(rd) {};

    MediaCapture        mc;
    MediaFrameReader    reader;
};

// todo : できることならDLL利用側で管理してもらう
std::shared_ptr<MediacaptureWrapper>  g_capture;


extern "C" {
    // unityプラグインのP/Invoke呼び出し規約はstdcall
    __declspec(dllexport) int __stdcall mediatest(int a, int b) {
#if 1
        // todo : これはUnityエディタのときは呼び出してはいけない
        //        実行時環境を参照する方法を調べて、自動的に呼び出しを制御する方法を考える。
        winrt::init_apartment();

        // media capture の関数を一戸だけ呼び出すテスト
        // これ自体は問題なく成功するので、UnityからWinRTのAPIを呼びだすこと自体は問題ない
        // Win32アプリケーションでテストコードをかいて実験する必要あり
        auto groups = MediaFrameSourceGroup::FindAllAsync().get();

        MediaFrameSourceGroup selectedGroup = nullptr;
        MediaFrameSourceInfo selectedSourceInfo = nullptr;

        // 列挙したカメラの最初に見つけたものを使用する。
        // Hololens RS-4で確認したところ、NV12カラーフォーマットで列挙された
        for (MediaFrameSourceGroup sourceGroup : groups) {
            for (MediaFrameSourceInfo sourceInfo : sourceGroup.SourceInfos() ) {
                if (sourceInfo.SourceKind() == MediaFrameSourceKind::Color) {
                    selectedSourceInfo = sourceInfo;
                    break;
                }
            }

            if (selectedSourceInfo != nullptr) {
                selectedGroup = sourceGroup;
                break;
            }
        }

        // 利用可能なカメラが見つからない場合
        if (selectedGroup == nullptr || selectedSourceInfo == nullptr) {
            OutputDebugString(L"selected source group not found\n");
            // todo
        }

        // たぶんプロパティと同様の扱いでいいんだろうけど、どうなの?
        auto settings = MediaCaptureInitializationSettings();
        settings.MemoryPreference(MediaCaptureMemoryPreference::Cpu); // Need SoftwareBitmaps for FaceAnalysis
        settings.StreamingCaptureMode(StreamingCaptureMode::Video);   // Only need to stream video
        settings.SourceGroup(selectedGroup);

        // よくわかってないところ。
        // winrtのオブジェクトに対してnew演算子が使えなくて、こういう方法でインスタンス化できるっぽい?
        // ただ、MSDNにあるサンプルではインターフェースをもつクラスインスタンスを作る例なので、MediaCaptureではこれが正解かどうかは謎い
        auto mcaf = winrt::get_activation_factory<MediaCapture>();
        auto mc =  mcaf.ActivateInstance<MediaCapture>();

        // 使用するカメラのパラメータが確定したので、初期化を行う
        // 1, メディアキャプチャの初期化
        // 2, 読みだしストリームの作成
        // 3, キャプチャの開始
        mc.InitializeAsync(settings).get();
        auto mapview = mc.FrameSources();
        auto selectedSource = mapview.Lookup(selectedSourceInfo.Id() );

        auto media_reader = mc.CreateFrameReaderAsync(selectedSource).get();
        auto status = media_reader.StartAsync().get();

        // https://docs.microsoft.com/ja-jp/windows/uwp/cpp-and-winrt-apis/handle-events
        // デリゲートを使ってイベントを処理する方法
        media_reader.FrameArrived({&OnFrameArrived});

        g_capture = std::make_shared<MediacaptureWrapper>(mc, media_reader);

        if (status == MediaFrameReaderStartStatus::Success) {
            // mediacapture, mediaframereaderのペアができた
            // 選択されているカメラはselectedSourceである
            OutputDebugString(L"media capture start success\n");
        }
        else {
            // メディアキャプチャの読み出しが開始できない
            OutputDebugString(L"media capture start fail \n");
        }
        std::this_thread::sleep_for(std::chrono::seconds(5) );
#endif
        // 単純なDLLのテスト用として、加算した結果を返すだけ。
        return a + b;
    }
}

次にテストプログラム側です。

ライブラリのリンクはできているので、単純に呼び出すだけです。

int main()
{
    std::cout << "Hello World!\n"; 

    // dll のテスト
    mediatest(1,2);

    std::cout << "finish.\n";
}

コードの概要(全体像)

DLLはmediatestという関数をエクスポートし、テストプログラム側はこれを利用します。 引数、戻り値にint型をとっていますが、DLLが呼べるかなどの基本的なテスト用に残しています。

このコードは実行すると5秒間カメラから映像フレームを取得し続けます。

映像フレームは保存せずに捨てるだけなので害はありません。

コードの詳細(DLL側)

DLL側ではwinRTを使った実装をしています。慣れない部分は多々ありますが、理解した範囲でメモしておきます。

C++マングリング

ネームマングリングについて雑に説明します。

C++には関数のオーバーライドとか、デフォルト引数みたいな機能がたくさんあります。 これはC言語には存在していないものです。

関数を拡張したような機能を実現するためにC++コンパイラはネームマングリングといって、内部的に異なる関数名を生成します。

(リンクエラーで関数が見つからないときに記号や数字が付いた関数名が表示されると思いますが、アレがコンパイラ内部で扱われている関数名相当です。)

これがネームマングリングと呼ばれるもので、これだとDLLとして呼び出すときに非常に不便です。(マングル規則を知らないと呼び出せない)

そこで、「ネームマングリングしないで。Cの関数として扱って」というのがextern "C"です。

WinRTライブラリの指定

#pragma comment(lib, "windowsapp") は、ライブラリのリンクの指定で、ここではwindowsappのライブラリ一式をリンクする指定です。

includeとかusing

使う機能をincludeしてusingで以降の名前空間の指定を省略するという使い方です。

ここで注意点で、たとえばinclude <winrt/Windows.Media.Capture.Frames.h>を消すと、コンパイルは通るがリンクエラーになるという事態が起きます。

(これは公式マニュアルのFAQにもあるので読んでみるのが良いでしょう)

なぜコンパイルエラーではなくリンクエラーかというと、おそらくC++の「テンプレートの明示的インスタンス化」の影響だと考えています。(ヘッダオンリーライブラリにありがち)

たとえば#include <winrt/Windows.Media.Capture.h>をすると、MediaCaptureの入出力でFramesクラスを使うので「Framesクラスがあるよ」宣言が必要がありますが、ここでは実態は定義されません。で、テンプレートのインスタンスはFrames.h側にあるので、リンク時に問題になるわけですね。

順番ごちゃごちゃして汚いですが、こんな感じになりました。

#include <winrt/Windows.UI.Notifications.h>
#include <winrt/Windows.Data.Xml.Dom.h>

using namespace winrt;
using namespace Windows::Foundation;
using namespace Windows::UI::Notifications;

#include "winrt/Windows.Foundation.h"
#include "winrt/Windows.Web.Syndication.h"
using namespace winrt;
using namespace Windows::Foundation;
using namespace Windows::Web::Syndication;

#include <winrt/Windows.Media.h>
#include <winrt/Windows.Media.Capture.h>
#include <winrt/Windows.Media.Capture.Frames.h>

using namespace Windows::Media::Capture;
using namespace Windows::Media::Capture::Frames;

エクスポート関数の指定と呼び出し規約の指定

 __declspec(dllexport) int __stdcall mediatest(int a, int b) {

DLLが外部に公開する関数だということを明示します。今回はテスト用に1個の関数だけexportするので、defファイル書くまでもないなと思い、こうやってます。

__stdcallはプロジェクト設定でやってもいいですし、こうやって各関数個別に指定してもいいです。

COMの初期化

winrt::init_apartment()

winrtの内部実装はCOMらしいので、その初期化をします。 こいつも注意で、今回のテストプログラムから使うときは必要ですが、Unityから使う場合は消しておく必要があります。(Unityの中でCOMの初期化やってる?) まあ、DirectXもCOMですし、何かやっててもおかしくはないでしょう。

非同期操作(async)

非同期操作関連はコルーチンを使えばきれいに書けるみたいですが、コルーチンはまだよくわからないので同期的に処理することにしました。

たとえば auto groups = MediaFrameSourceGroup::FindAllAsync().get()では、FindAllAsync非同期操作の完了まで待機して結果を受け取ります。 もちろんFindAllAsync()を一旦変数で受けてからgetするというのもできます。

コレクションの処理

IVectorViewなどのWindows固有のコレクションも、範囲forで片づけます。

for (MediaFrameSourceGroup sourceGroup : groups)とかですね。

今回は勉強なので、各要素の型を明示しましたが、autoで受けても大丈夫でした。

プロパティ

C#のプロパティのように、クラスのフィールドにアクセスするには

  • 代入時は関数呼び出しの構文で
  • 取り出し時は変数 = プロパティ

と覚えておけばだいたい事足りました。

たとえばメディアキャプチャの設定をするのはこんな感じですね。

 auto settings = MediaCaptureInitializationSettings();
    settings.MemoryPreference(MediaCaptureMemoryPreference::Cpu);
    settings.StreamingCaptureMode(StreamingCaptureMode::Video);
    settings.SourceGroup(selectedGroup);

インスタンス

ここはまだ理解しきれていないので自信ないところです。

C++/CXのときはref new ClassName()とかしてた部分になります。

一旦activation_factoryテンプレートクラスを、インスタンス化したいクラス名のテンプレートとして構築し、そのactivationfactoryのActivateInstanceメソッドを呼ぶことでインスタンス化できるようです。

 auto mcaf = winrt::get_activation_factory<MediaCapture>();
    auto mc =  mcaf.ActivateInstance<MediaCapture>();

イベントハンドラ

イベントハンドラはコルーチン(?)に対するデリゲートでいけました。

media_reader.FrameArrived({&OnFrameArrived});

すごくシンプルですね。c++/WinRT素晴らしいですね。

一番楽になったのはコールバック関数をコルーチン(?)として与えることができるので、すごく楽になったと感じます。

ちなみにC++/CXではstd::bindにプレースホルダー指定しつつ、テンプレートのインスタンス化みたいな難読なコードでした。

できた

ここまでで、テストプログラムを実行するとコールバック関数が呼び出されることが確認できます。

DLL側にブレークポイントを打ってデバッグすることも可能です。

軽くUnityエディタで動かしてみたところ、問題なく動いたので、これを利用してネイティブプラグインの開発ができそうです。

感想と今後の展望

ここまでで、UWPのMediaCaptureを利用するDLLができました。

本当はUnityエディタから利用する部分まで書こうかと思っていたのですが、記事が長くなりそうなので今回はこれくらいにしておきます。

次回はUnityのネイティブプラグインのファイル配置、開発環境、デバッグ方法などを書いてみようと考えています。

少しずつですがUnityプラグイン作って色々やっていきたいと思います。

それでは今回はこれくらいで。

OpenCV4.0をソースコードからインストールする

想像以上に手間取りました。かたりぃなです。

OpenCVのバージョンだけなら大した問題ではなかったのですが、バージョンアップに追従してC++も新しいものにしていこうという動きがあるみたいです。 (実際OpenCV3の世代でCインターフェースはレガシーなものとして切り捨てられましたし。)

さらに4.0になってからはOpenCV自体をビルドするCMakeも要求バージョンが上がっていて、これもビルドしなおす必要があります。

CMakeもビルド自体は簡単なのですが、CMakeをビルドするためには新しいlibcが必要になっています。

新しいlibcが必要ということはgcc自体のアップデートが必要になるということです。

私がよく使うCentOSgccがかなり古いバージョンしか入っていないので、gccからビルドしなおす必要があるということです。

ついにこの時が来てしまったかといった感じですね。。。

というわけで、次の順にやっていきます。

  1. gcc8.1のビルドとインストール
  2. CMake3.13のビルドとインストール
  3. OpenCV4.0のビルドとインストール

なんか大変なことになってしまいましたが、gcc8までいくとC++20も多少は使えるようになるので、未来志向で前向きにいきたいと思います。

環境はいつものです

  • Windows10Pro
  • Docker for windows
  • centos7ベース

躓いたポイント

cmake/opencvとも、いつものようにビルドすればいいのですが一点だけ注意。

cmakeが参照するcurlhttps対応してない問題というのがあるらしいです。

https://stackoverflow.com/questions/51573524/cmake-is-unable-to-download-by-protocol-https-while-own-cmake-option-procedure

要約すると、opencvのビルド時に追加で必要なファイルをダウンロードしてきたりしているところで面倒な問題があるようです。

追加ライブラリののダウンロードにcurlが使われるのですが、cmakeはシステム組み込みのcurlではなくcmake自信が持っているcurlを使うようになっています。(デフォルトの動作)

これだけなら大したことないのですが、cmakeが持っているcurlと不随するライブラリが古いらしく、httpsサイトからのダウンロードに失敗します。(SSLの一部をサポートしていないため)

これがopencvのbuild時に発覚するので、ビルド時間の長さも相まって手戻りが半端なかったです。

環境構築のためのdockerfileはベタ書きしたので、他の環境でやるときも手順書の代わりになるかと思うので、次回以降はビルドやり直しの手間は減るかなと思います。

docker-file

まずは答えから。

インストールするものをまとめてコンテナ化するdockerfileになります。

ひたすらコマンドを実行するだけのものです。

RUN yum groupinstall -y 'Development tools'
RUN yum install -y git wget cmake
RUN yum install -y sudo

# cmakeのためにlibcとgccを入れる(C++最新を使いたい)
RUN wget http://ftp.tsukuba.wide.ad.jp/software/gcc/releases/gcc-8.1.0/gcc-8.1.0.tar.gz
RUN tar zxvf gcc-8.1.0.tar.gz
RUN cd gcc-8.1.0; ./contrib/download_prerequisites; mkdir build
RUN cd gcc-8.1.0; ./configure --enable-languages=c,c++ --prefix=/usr/local --disable-bootstrap --disable-multilib; make;
RUN cd gcc-8.1.0; make install
RUN cp gcc-8.1.0/x86_64-pc-linux-gnu/libstdc++-v3/src/.libs/libstdc++.so.6.0.25 /usr/lib64/
# libcのバックアップとインストール
RUN mv /usr/lib64/libstdc++.so.6 /usr/lib64/libstdc++.so.6.backup
RUN ln -s /usr/lib64/libstdc++.so.6.0.25 /usr/lib64/libstdc++.so.6
RUN rm gcc-8.1.0.tar.gz

# opencv4のためにcmake 3.5以上が欲しい
RUN yum install -y git wget cmake
RUN yum install -y sudo curl-devel zlib-devel
RUN wget https://gitlab.kitware.com/cmake/cmake/-/archive/v3.13.4/cmake-v3.13.4.tar.gz
RUN tar zxvf cmake-v3.13.4.tar.gz
RUN cd cmake-v3.13.4; ./bootstrap --system-curl && make && make install
RUN mv /bin/cmake /bin/cmake.backup
RUN ln -s /usr/local/bin/cmake /bin/cmake
RUN rm cmake-v3.13.4.tar.gz

# c++ opencv
RUN git clone https://github.com/opencv/opencv.git /root/opencv; cd /root/opencv/; git checkout -b 4.0.0 4.0.0 ;
# contrib入れると大きすぎてdockerのデフォルト容量をオーバーして失敗することがあるので、一旦除外
#RUN git clone https://github.com/opencv/opencv_contrib.git /root/opencv_contrib; cd /root/opencv_contrib; git checkout -b 4.0.0 4.0.0 ;
#RUN mkdir /root/opencv/build; cd /root/opencv/build; cmake -D OPENCV_EXTRA_MODULES_PATH=/root/opencv_contrib/modules ..
RUN mkdir /root/opencv/build; cd /root/opencv/build; cmake ..
RUN cd /root/opencv/build; make; make install;

このdockerfileの注意点として、OpenCVのcontribはつけてません。

contribはやたら大きいので、手元の環境ではdocker desktop for windowsのコンテナサイズを超えてしまいました。 なので、一旦contribなしでいきました。

各パッケージのinstall後でファイルを削除しているのも同様の理由です。

動作確認

適当にコード書きます。

OpenCVのビルド情報を表示するだけのものです。

#include <iostream>

#include <opencv2/opencv.hpp>

int main(int argc, char*argv[])
{
    auto buildinfo = cv::getBuildInformation();
    std::cout << buildinfo;
    return 0;
}

ビルドします。ここでも注意点が1つ。

OpenCV4.0ではpkg-configのサポートを終わらせるかどうか議論しているようです。

(自分でビルドとインストールするならパスは分かっているので気にしなくていいのですが、cmake使わない人=パッケージマネージャな人?だとどうするんだとか)

https://github.com/opencv/opencv/issues/13154

どうやらcmakeのオプションにpkgconfig使うように指定すれば、pcファイル吐いてくれるので、それを使えばよさそうです。

今回は自分でビルドしてインストールパスもわかっているので、直接指定します。たとえばこんな感じで。

g++ test.cpp -I/usr/local/include/opencv4/ -L/usr/local/lib64/ -lopencv_core

動かしてみる

ここでもpkg-configがsoを探してくれないので、ちょっとばかり対応が必要です。

出来上がった実行ファイルをそのまま実行すると、soが見つからないっていわれます。

[root@ce33ad34d4ba src]# ./a.out
error while loading shared libraries: libopencv_core.so.4.0: cannot open shared object file: No such file or directory

新しいライブラリ入れるとよくある現象ですね。soを参照できているか見てみます。

[root@ce33ad34d4ba src]# ldd a.out
        linux-vdso.so.1 =>  (0x00007fffd5d80000)
        libopencv_core.so.4.0 => not found
        libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007f51622b6000)
        libm.so.6 => /lib64/libm.so.6 (0x00007f5161fb4000)
        libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007f5161d9e000)
        libc.so.6 => /lib64/libc.so.6 (0x00007f51619d1000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f5162639000)
[root@ce33ad34d4ba src]# ./a.out

確かにopencvがnot found.ですね。ここでもパスは分かっているので、環境変数を指定して実行します。

LD_LIBRARY_PATH環境変数opencv*.soの置かれているパスを入れて実行。

[root@ce33ad34d4ba src]# LD_LIBRARY_PATH=/usr/local/lib64/ ./a.out

General configuration for OpenCV 4.0.0 =====================================
  Version control:               4.0.0

(略)

やったね。

トラブルシューティング

libcのバージョンが古いときは、共有ライブラリの中を確認する必要があります。

strings /usr/lib64/libstdc++.so.6 | grep GLIBCXX

これで該当するバージョンが入っているかどうか調べることができます。

gccとlibcのバージョンアップではこちらのサイトを参考にさせていただきました。

https://www.saintsouth.net/blog/update-libstdcpp-on-centos6/

感想と今後の展望

サーバ側のOpenCVは最新にすることができました。これでOpenCVのONNIXが利用できるようになります。

ONNNIXはDeepLearningの各種フレームワーク間でデータを受け渡せるようにするフォーマットで、今後はサーバ側はC++/OpenCVだけでいけるかもしれません。

とはいえサーバ側をすべてC++で書くのは骨が折れるので、PythonバインディングOpenCV叩いたほうが早い気がしなくもないです。

あとはC++が最新版になったので、いろいろと使いたい機能が使えるようになりました。

C++も20になって関数型言語っぽい香りが本格的になってきたので、時間をとっていろいろ試したいと思います。

それでは今回はこれくらいで。

websocketとhttpの共存Webアプリを作る(nginx + uwsgi + flask)

そろそろWebSocketやってみようと思いました。かたりぃなです。

まずWebSocketを概念レベルで整理してから、技術的な実装をやっていきます。サンプルコードコピペしたい人は後半まで飛んでください。(未来の自分へのメッセージ)

WebSocketの概要

WebSocketはウェブアプリケーションでの双方向通信を実現するためのプロトコルです。

と一言で表現しても抽象的すぎます。このままでは何が美味しいのかわからないので幾つかの方向から分析してみます。

HTTPとの比較

HTTPは基本的にRequest-Responseという1組のメッセージのやり取りで構成されるシンプルなものです。

クライアントからのRequestがシーケンスの起点となっているため、クライアント→サーバのメッセージは任意のタイミングで実行できますが、サーバ→クライアントのメッセージは色々と難しいという問題があります。

クライアントがポーリングするとか、ロングポーリングという解決策もありますが、HTTPのヘッダを毎回送信するのとか、ロングポーリングの時間の問題とかいろいろあったりします。

んで、この問題を解決する方法の1つがWebSocketです。

WebSocket使わずにTCPレベルでやりとりすればいいのでは?

WebSocket以外の解決策を考えた時、実装レイヤを下げてトランスポート層(TCP,UDP)でやるのもテかと思います。

しかしそうしてしまうと面倒な問題が増えてしまいます。すぐ思いつくのは疎通できるかどうかの問題です。

疎通できるかどうかで一番大きなものはルータ越えの問題(NATトラバーサル)とかでしょうか。 昔のPCでやるMMORPGなんかで見かけた「ポートxxを通過できるようルータやファイアウォールを設定してください」とかいうやつですね。

最近だとルータのUPnPを叩いてポートマッピングを自動でやったりとかが多いようですが、Webアプリで双方向通信をするためにそういうのを使うのは大げさすぎます。

なので、そんな大げさなことはせず、せっかくWeb標準としての通信規格として存在しているWebSocketを利用するのが現時点では最良と考えます。

実際のプロトコル

誤解を恐れずにいうなら「HTTP上に構成されるソケット通信プロトコル」といったところでしょうか。

実際のシーケンスとしては

  1. クライアントはHTTPリクエストに「WebSocket使いたいよ!」というUpgrade要求を含めて、サーバへ要求
  2. サーバはアップグレード要求に対する応答を返す
  3. WebSocket通信開始
  4. クライアント/サーバとも任意のトリガでソケットをread/writeすればいい

これでNATトラバーサルの話はこの時点で既に半分はクリアしていることになります。 HTTP上に構成されるプロトコルなのでWebページを見る場合と何ら変わりません。

少々気を付けるポイントとしてKeepAlive的なところで、WebSocket接続を維持するために一定時間ごとにメッセージを送受信する必要があります。

ただし、いくつかのWebSocket対応ライブラリでは自動で面倒見てくれるようになっています。

今回利用するuwsgiも自動でやりますよと明記されています。

WebSocketを試してみる

実験環境はいつものです。

システム構成

nginxをリバースプロキシとして、HTTPページとWebSocketページに振り分けます。

HTTPとWebSocketのどちらもWebアプリケーションのフレームワークとしてflaskを使用しますが、実際にはルーティング程度しかやっていません。

flaskをむき出しで運用するのはよくないので、アプリケーションサーバとしてuwsgiを使用することにします。

uwsgiとアプリケーション間のプロトコルは色々選択できるようになっています。今回はHTTPでやっちゃいます。

nginxの設定

ホスト名を見て振り分けたいところですが、ホストOSのhosts設定とか面倒なのでポート番号で振り分けることにしました。

HTTPの場合は何もせずHTTPサーバへ転送します。

user  nginx;
worker_processes  1;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;


events {
    worker_connections  1024;
}


http {
    include /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                  '$status $body_bytes_sent "$http_referer" '
                  '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;

    upstream uwsgi {
        server uwsgi:3031;
    }

    # FlaskによるHTTPサーバ
    server {
        listen 8080;
        charset utf-8;

        location / {
            include uwsgi_params;
            uwsgi_pass uwsgi;
        }
    }

    upstream websocket {
        server uwsgi-ws:3032;
    }

    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

    # WebSocket
    server {
        listen 8081;
        charset utf-8;

        location / {
            proxy_pass http://websocket;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
        }
    }
}

一個だけポイントがあって、WebSocketホスト側の転送設定でproxy_set_headerしています。

これはnginxをWebSocketのリバースプロキシとして使うとき、upgradeメッセージを適切にバックエンドに渡してあげる必要があるためです。

詳細はnginxのマニュアルを参照。

https://www.nginx.com/blog/websocket-nginx/

UWSGI

uwsgiはアプリケーションサーバです。cgi関連の実装にWSGIというのがありますが、それとは別物です。

初めてuwsgiの名前を知った時、u=μ(マイクロ)だと思って「軽量なwsgiプロトコルかな?」とか思ってしまいましたが、そうではなかったようです。

今回のuwsgiの役割はpythonアプリケーションのホスティングです。

今までflaskむき出しでテストしていましたが、これからはflaskむき出しではなく、サービス化も目指してこいつを使っていこうと思います。(実際、flaskのビルトインサーバは実運用では非推奨なので。)

uwsgi環境を用意するdockerfileはこうなりました。(後述の参考URLの内容ほぼそのままです)

# ベースイメージ
FROM python:3.6

RUN mkdir /var/www
# workdirの指定
WORKDIR /var/www

# 依存Pythonライブラリ一覧コピー
COPY requirements.txt ./

# 依存Pythonライブラリインストール
RUN pip install --no-cache-dir -r requirements.txt

# ログ出力用ディレクトリ
RUN mkdir /var/log/uwsgi/

CMD ["uwsgi","--ini","/var/www/uwsgi.ini"]

依存ライブラリを指定するrequirements.txtには

Flask
uwsgi

を書いておけばOKです。

UWSGIを構成する

今回は1つのuwsgiで1つのアプリケーションをホスティングします。実質、2つのuwsgiを立てます(http用,websoket用)。

1つのuwsgiで複数のWebアプリを管理する方法がまだわからない&dockerコンテナなら1コンテナ1役割のほうがいいだろうという考えです。

コンテナならこういうのを軽く実現できるから良いですね。

コンテナ起動時にuwsgi.iniを与えて、それに従って動作します。

たとえばwebsocket側のuwsgiの設定はこうなりました

[uwsgi]
wsgi-file = /var/www/src/uwsgi_test.py
callable = app
master = true
processes = 1
http=0.0.0.0:3032
vacuum = true
die-on-term = true
py-autoreload = 1
http-websockets = true
log-5xx = true
async = 100
ugreen = true

HTTP側はこんな感じです。

[uwsgi]
wsgi-file = /var/www/src/run.py
callable = app
master = true
processes = 1
http=0.0.0.0:3031
chmod-socket = 666
vacuum = true
die-on-term = true
py-autoreload = 1

注意点として、このdockerfileでインストールされるuwsgiはasyncとupgreenパラメータの設定が必要でした。 バージョンごとにasync対応かどうかみたいなのがあるみたいですね。

flask(HTTP)

単純な仕組みなのでコードだけ記載して説明は省略します。 hello worldを返すだけのアプリケーションです。

from flask import Flask
app = Flask(__name__)

@app.route("/")
def hello():
    return "Hello World!"

flask(WebSocket)

こちらも単純な仕組みなのでコードだけ記載して説明は省略します。 これで一旦動くとこまで来ましたが、なんか違う気がしていてちょっと自信ないです。

import uwsgi
import time
import logging
from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello():
    app.logger.setLevel(logging.INFO)
    app.logger.info('flask document root accessed')
    uwsgi.websocket_handshake()
    while True:
        msg = uwsgi.websocket_recv()
        app.logger.info('uwsgi.websocket_recv result = {}'.format(msg) )
        uwsgi.websocket_send(msg)
        app.logger.info('uwsgi.websocket_send finish.')

if __name__ == '__main__':
    app.run(debug=True)

docker-compose

ここまでに作成した3つのコンテナを立てたり落としたりするdocker-composeです。

今回は実験用なので、nginx, uwsgi(websocket), uwsgi(http)の3つのホストすべてを同じネットワークに接続しています。

各サービスでnetworksを記述することで、そこに接続されたホストと通信できるようなネットワーク構成ができます。

version: "2"
services:

  # HTTPのWebアプリケーション
  uwsgi:
    build: ./app
    volumes:
      - ./app:/var/www/
    networks:
      - python-website
    ports:
      - "3031:3031"
    environment:
      TZ: "Asia/Tokyo"

  # WebSocketのWebアプリケーション
  uwsgi-ws:
    build: ./appws
    volumes:
      - ./appws:/var/www/
    networks:
      - python-website
    ports:
      - "3032:3032"
    environment:
      TZ: "Asia/Tokyo"

  # WebFront
  nginx:
    build: ./nginx
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
    networks:
      - python-website
    ports:
      - "8080:8080"
      - "8081:8081"
    environment:
      TZ: "Asia/Tokyo"

networks:
  python-website:

client

WebSocketのサーバを叩くのはjsからやることにしました。ほとんど書いたことありませんが。

本当は使い慣れた言語(C++とか)から叩きたかったのですが、仕様とか調べるのが面倒だったので。

jsのコードはこちらのページを参考にさせていただきました。

https://qiita.com/hiroykam/items/c3e3d20c223b01d9f0e8

<!DOCTYPE HTML>
<html>
  <head>
    <script type="text/javascript">
      function WebSocketTest()
      {
        if ("WebSocket" in window) {
          alert("WebSocket is supported by your Browser!");
          var ws = new WebSocket("ws://localhost:8081/");
          ws.onopen = function() {
            ws.send("Hello from client");
            alert("Message is sent...");
          };
          ws.onmessage = function (evt) {
            var received_msg = evt.data;
            alert("Message is received...");
            alert(received_msg);
          };
          ws.onclose = function() {
            alert("Connection is closed...");
          };
        } else {
          alert("WebSocket NOT supported by your Browser!");
        }
      }
    </script>
  </head>
  <body>
    <input type="button" onClick="WebSocketTest();" value="WebSocket Test">
  </body>
</html>

動作確認

上記htmlを開いてボタンを押せば順にアラートが表示されます。

サーバ側のログを見たい場合はdocker-compose logsとかdocker log コンテナ名で確認できます。

websocket側のログにwebsocketで接続受けたとかメッセージ受けたとか残っています。

感想と今後の展望

ひとまずWebSocketの最低限の基盤が整いました。

今後試していきたい内容は

  • 具体的なデータ送受信(たとえばバイナリデータや、圧縮された映像、音声データのようなフレーム単位での時系列データなど)
  • ポートでHTTP/WebSocket分岐ではなく、flaskのルーティングで分岐したい
  • もう一歩進んでWebRTC

などでしょうか。

特にARでの利用を考えたときに物体検出などは時系列データである動画のほうが有利です。(空間方向だけでなく時間方向も推定する)

ただし、動画にしてしまうとエンコード時に多少なりとも情報が落ちる&処理の負荷があがるので、どうするのが最適かとか考えていきたいですね。

(よくある動画フォーマットならデコードせずに周波数空間で物体検出できるのでは?とか思っていますが、コード書くの面倒すぎるのでたぶんやらないと思います。)

サーバースペックとの兼ね合いもあると思うので、

それでは今回はこれくらいで。

DockerでJenkinsとテスト用のコンテナを動作させる

DockerでCI/CD環境を構築してみます。かたりぃなです。

どうやってやるか?

今回はCI実行環境やアーキテクチャについて考えます。 まずCIといえばJenkinsやTravisとかいろいろありますが、次の機能さえ備えていれば良いと思います。

  • 繰り返しテストを再現可能な状態で実行できること(CI)
  • 任意のトリガでデプロイできる(CD)

というわけで、使い慣れているJenkinsでいきます。

Jenkins環境はプラグインとか入れ始めると汚れてくるので、立て直ししやすいようDockerコンテナとして実行します。

JenkinsコンテナからホストOSに接続して、ホスト上からテストコンテナを立ち上げてテスト実行するということをやってみます。

これができると何が良いのか?

主に次の点でメリットがあると考えます。

  • テスト環境を常にクリーンな状態に保てる
    • jenkins, テスト対象コンテナともに
  • ローカルマシンでCIできる
  • CDもできるはず
  • コンテナのオーケストレーションのテストもできる

環境

環境ですが、Hyper-Vの有無で少々環境が異なります。 Windows10側は少々手こずっていて納得いっていない状態ですが、一旦良しとします。

Windows10 + Hyper-V

この環境ではdocker-desktopを稼働させます。 https://www.docker.com/products/docker-desktop ホストOSはHypser-V上のMobyLinuxとなるのですが、こいつへの接続がややこしいので、WindowsでOpenSSHを動かしてここからMobyLinuxのdockerを操作することにします。

いつの頃からかWindows10でもssh-serverが使えるようになっているので、設定からインストールして、設定しておきます。 Windows10のSSHのユーザーとパスワードはWindowsログイン時のものそのままです。

Windows7 + VirtualBox

こちらはいつもの環境です。 https://docs.docker.com/toolbox/toolbox_install_windows/ コンテナからホストへはNATのIPで接続できるので簡単でした。

詳細

ここからはdocker-toolboxでの説明です。

jenkinsに入って他の仮想マシンを立てるところまでやってみます。

Jenkinsコンテナを立てる

ssh接続のパス入力を自動でやりたいので、追加でパッケージを入れておきます。 docker-toolboxのdocker-engine側にはdocker-composeが入っていないので、いつでも追加できるようにここで保持させることにしました。

dockerfileはこうなりました。

FROM jenkinsci/jenkins:2.11

# install via apt
USER root

# install jenkins plugins
COPY plugins.txt /usr/share/jenkins/plugins.txt
RUN /usr/local/bin/plugins.sh /usr/share/jenkins/plugins.txt

# install docker-compose
RUN curl -L https://github.com/docker/compose/releases/download/1.24.0-rc1/docker-compose-Linux-x86_64 > /usr/local/bin/docker-compose
RUN chmod +x /usr/local/bin/docker-compose

# install ssh tools
RUN apt-get update
RUN apt-get -y install sshpass


# drop back to the regular jenkins user - good practice
USER jenkins

このdocker-fileを使うdocker-composeはこうなりました。

version: "3"
services:
jenkins-CI:
    build: .
    image: jenkins
    ports: 
    - "12380:8080"
    - "12322:22"
    volumes:
    - "./jenkins_home:/var/jenkins_home"
    - "./test_container:/var/test_container"

立ち上げます

docker-compose up -d

これでhttp://localhost:12380に接続すればjenkinsの初期パスワード入力画面が表示されます。 接続できない場合はHyper-Vのネットワーク設定、VirtualBoxならNAT設定あたりを見直します。

ちなみに、この設定だとdockerコンテナを再起動かけるとジョブが動かなくなってしまいます。 理由はvolumesでしているのはホスト側のパス(Windowsファイルシステム)なので、パーミッションがおかしくなるためです。 対策は後述。(volumeを使う)

docker-containerへのログイン

まずdockerのお約束的にjenkinsコンテナに入ります。 初期パスワードがjenkinsのホームディレクトリ以下に置かれているのでそれを見るために必要です。

docker exec -it コンテナ名 /bin/bash

cat /var/jenkins_home/secrets/initialAdminPassword これがjenkinsの初期パスワードです。

jenkinsコンテナから、テスト用のコンテナを立ち上げる準備

jenkinsでジョブ作れるところまできたので、 jenkinsからDockerコンテナを立ち上げる設定をします。 とはいえJenkins自体がコンテナで動作しているので、そのままではコンテナ in コンテナになってしまいます。

ここではJenkinsコンテナが動作している仮想マシン(docker-engine)に接続して、docker-engine側からコンテナを立ち上げてもらうことにします。

docker-engineに接続する

dockerが動作しているVM(デフォルト設定だとdefaultというマシン名)に接続するため、engine側のIPを調べます。

docker-containerからホスト(docker-engine)のIPを調べるのは、NATの対向IPなのでこうすればよいです。

ip route | awk 'NR==1 {print $3}'

つまりdocker-engine(ホスト)にSSHするには

ssh docker@上記で調べたIP

となります。

sshのパスワードはtcuserです。

sshのパスワード自動入力とフィンガープリントチェックの(Y/N)なしも含めるとSSHコマンドはこうなります。

machine_ip=`ip route | awk 'NR==1 {print $3}'`
sshpass -p tcuser ssh -oStrictHostKeyChecking=no docker@${machine_ip}

あとはdocker-composeをすればよいです。 他のコンテナを起動するスクリプトを書くとこうなります。

このスクリプトの前提条件は2つです

  • docker-engineにdoker-composeがインストールしてあって、パスも通っている
  • /home/docker/test_containerにdocker-compose.ymlが置かれている
#!/bin/bash

# docker-engineへの接続情報
machine_ip=`ip route | awk 'NR==1 {print $3}'`
sshuser="docker"
sshpass="tcuser"

# ホスト上でのテストスクリプト置き場
compose_path="/home/docker/test_container"

function docker_compose(){
    sshpass -p ${sshpass}\
        ssh -oStrictHostKeyChecking=no ${sshuser}@${machine_ip}\
        "cd ${compose_path}; docker-compose ${1}"
}

case "$1" in
  start)
        docker_compose "up -d"
        echo "Start"
        ;;
  stop)
        docker_compose "down"
        echo "Stop"
        ;;
  *)
        echo $"Usage: {start|stop}"
esac

docker-engineにdocker-composeをインストールする

docker-engineとは、ざっくりいうとVirtualboxから見えるマシンに相当します。 このマシンにはdocker-composeが入っていないのでインストールする必要があります。

上記jenkinsのdockerfileで持ってくるようにしたので、jenkins起動時にscpで転送してもよさそうです。

念のためインストール手順をメモします。

ここを確認する

https://github.com/docker/compose/releases

ダウンロードURLをクリップボードにコピーしておきます。

docker-engine上でrootに昇格

ダウンロードしたファイルは/usr/local/binに実行ファイルを配置したいのでrootに昇格させておきます。

sudo -i

rootになった状態で次のコマンドを打ちます。

$ curl -L https://github.com/docker/compose/releases/download/1.24.0-rc1/docker-compose-Linux-x86_64 > /usr/local/bin/docker-compose
$ chmod +x /usr/local/bin/docker-compose

これでDocker用の仮想マシン上でdocker-composeコマンドが使えるようになりました。

動作確認

$ docker-compose

使い方のメッセージが出てきたらOKです。

あとは

  1. jenkinsからdocker-engineにssh
  2. doker-engine上でdocker-composeしてコンテナを起動する
  3. 必要なテストをする
  4. docker-compose downして環境をきれいにする

というジョブを書くことでいつでもきれいな環境でテストが実行できます。

2回目以降Jenkinsが立ち上がらない対策

最初に書いたJenkinsのdocker-compose.ymlでは二回目以降docker-compose up -dするとジョブが流せなくなります。 これはWindows側にJenkinsの各種ファイルを保存するので、パーミッションがおかしくなるためです。 具体的にはジョブの実行結果が書けないとか、ジョブ設定ファイルが読めないとか。

対策として、dockerにはデータを保存するボリュームという機能があるので、これを使うことになります。

データボリュームコンテナというのもあったらしいですが、現在はオススメじゃないらしいので今回はボリュームを使います。

データボリュームを使うdocker-compose.yml

これだけです。

version: "3"
services:
  jenkins-CI:
    build: .
    image: jenkins
    ports: 
      - "12380:8080"
      - "12322:22"
    volumes:
      - jenkins_data:/var/jenkins_home
      - "./test_container:/var/test_container"

# 上記コンテナで使用するデータボリュームを指定
volumes:
  jenkins_data:

ポイントはservicesと同じトップレベルにあるvolumesで、docker-compose up -dすると自動的に作成されます。 名前を付けてボリュームが作られることから、名前付きボリュームというとのこと。

コンテナのvolumes指定ではこの名前を指定してマウントします。

ボリュームってどこにあるの?

上記設定でボリュームにデータが保存されます。

テストのログなんかはとっておきたいので、念のためたどこに置かれているか確認する必要があります。

ボリュームの場所は次のように確認します。

$ docker volume ls
local               jenkins-ci_jenkins_data
$ docker volume inspect jenkins-ci_jenkins_data
[
    {
        "CreatedAt": "2019-02-05T23:58:11Z",
        "Driver": "local",
        "Labels": {
            "com.docker.compose.project": "jenkins-ci",
            "com.docker.compose.version": "1.24.0-rc1",
            "com.docker.compose.volume": "jenkins_data"
        },
        "Mountpoint": "/mnt/sda1/var/lib/docker/volumes/jenkins-ci_jenkins_data/_data",
        "Name": "jenkins-ci_jenkins_data",
        "Options": {},
        "Scope": "local"
    }
]

どうやら /mnt/sda1/var/lib/docker/volumes/jenkins-ci_jenkins_data/_data にあるらしいですね。

rootじゃないと読み書きできないので、sudo -iしてから確認してみましょう。

前述のパスワードファイルとかよさそうです。

ls /mnt/sda1/var/lib/docker/volumes/jenkins-ci_jenkins_data/_data/
config.xml                           jobs                                 secrets
copy_reference_file.log              logs                                 updates
hudson.model.UpdateCenter.xml        nodeMonitors.xml                     userContent
hudson.plugins.git.GitTool.xml       nodes                                users
identity.key.enc                     plugins                              war
init.groovy.d                        secret.key                           workflow-libs
jenkins.install.UpgradeWizard.state  secret.key.not-so-secret

jenkinsのファイルが一通り入ってそうですね。

これでパスワードファイルも読めます。

cat /mnt/sda1/var/lib/docker/volumes/jenkins-ci_jenkins_data/_data/secrets/initialAdminPassword

最後に、あらかじめ以下ボリュームを作成しておくこともできるようです。$ docker volume create --name jenkins_data

感想と今後の展望

CI/CD環境が整ってきました。 これでサーバーサイドは好きなようにテスト流せますし、複数台の場合のオーケストレーションテストも実施できそうです。

dockerでElasticsearch+logstash+kibanaを立ち上げる

新年一発目のエントリは少々新しいことに挑戦しようということにしました。かたりぃなです。

今回はElasticsearchとC#でのログ分析基盤の作成をやってみます。

まだ実践的なことは何もできていない状態ですが、できたことを少しずつブログに残していこうと思います。

(完成してからとかやっていると、昨年末みたいに更新頻度が下がってしまいますので。)

今回利用するパッケージや技術要素はこんな感じです。

  • Elasticsearch
  • kibana
  • logstash
  • docker
  • .Net core(C#)

なぜログ分析が重要か

ログというのは、何が起きたかを分析するのに重要です。 特に問題が起きた場合にはログから分析する場合がほとんどで、これは分野を問わず同じだと思っています。(WebならApacheログ、組み込みならカーネルログとか)

ログ分析で得た結果は、エンジニア・経営どちらにも波及していくものだと考えています。

具体的には

  • サービスの構成や仕組みを変える必要がある
  • サービス運用方針を変える

などです。

エンジニア視点と経営視点でそれぞれ何をしたいかを明確にします。

エンジニア視点

不具合調査などで必要です。

私が考えているカードゲームARのサービス構成では、複数のAPIとクライアントアプリが存在してるので、バグなどが見つかった場合それぞれのログを順に見ていく必要があります。 これが結構手間です。たとえば、、、

  • Hololensのログを抽出して、APIを叩いた順序を確認
  • 叩かれたサービス(物体検出 or カテゴリ分類)とパラメタを特定
  • 各サービスのログを収集
  • ログを結合して、全体のシーケンスを見直す

と、いろいろと面倒です。

一か所で集中的にログを解析できればもっと効率上がると思いますし、サービス開始した後でもこういったことは必要になってきますので、今のうちに基盤を整えたいといったところです。

経営視点

個人開発サービスではリスクをできるだけ避けたいです。

リスクを避けるためにはスモールスタートが基本だと思っていて、ユーザーが増えてきたらインスタンスを増やすなどの対策をしていけばよいと考えます。 この「ユーザーが増えてきたら」というのはログ分析などからも得られるので、ログはどんどん蓄積していきたいところです。

なぜElasticSearch?(しかもC#)

ログ収集と分析に使うOSSは個人的には何でもよくて、AWSGCPなどのクラウドサービスでも提供されているログ監視サービスでもいいんじゃないかなと思っています。

Elasticsearchでなければならないということは全くありません。本業でも応用できる可能性があるためですが、これも確定ではないので本当に好みの問題かと思います。

クライアントソフトの実装言語としてC#を選んだ理由も特にありません。しいて言うならHololens+Unityでも使うから、少しずつ慣れたいなといったところです。

Elasticsearchを立ててみる(dockerコマンドで直接)

早速立ててみます。 Elasticsearchはdockerイメージが公開されているので、チュートリアルどおりにやればOKです。

https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html#docker-prod-cluster-composefile

手元の環境は次のとおりです

ElasticSearchをDockerで動かして、Windows上の.NetCoreからこのDockerを叩いていきます。

さっそくDockerコンテナを動かしてみましょう。

docker pull docker.elastic.co/elasticsearch/elasticsearch:6.5.4

docker run -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:6.5.4
# ログがいっぱい流れる
# OpenJDKがAVX要求してるけど、マシンがAVX対応してないからオプション指定しろとか
# OpenJDKのオプションにdeprectedなのが指定されてるからうんたら
# あとは色々な情報

これでElasticSearchは立ち上がったみたいなので、まずはステートを問い合わせてみます。 WSLのbashからcurlでいきます。

curl http://127.0.0.1:9200/_cat/health
1472225929 15:38:49 docker-cluster green 2 2 4 2 0 0 0 0 - 100.0%

チュートリアルのとおりですね。ひとまず立ち上がったようです。 次のステップとしてdocker-composeで起動できるようにしたいので、コンテナは一旦落とします。

Elasticsearchを立てる(docker-compose)

公式のインストールガイドにも書かれていますが、そのままやればOKです。 ただし公式のガイドでは2つのコンテナでクラスタを組むようになっている点に注意です。

ここでは入門として、一台のdockerで最低限の設定だけで立てることにします。つまり、先のコマンドをdocker-compose化するだけです。

version: "3"
services:
  elastic-search:
    image: docker.elastic.co/elasticsearch/elasticsearch:6.5.4
    ports: 
      - "9200:9200"
      - "9300:9300"
    environment:
      - discovery.type=single-node
    volumes:
      - "<windows側の適当なディレクトリ>:/usr/share/elasticsearch/data"

できました。 このファイルをdocker-compose.ymlとして保存し、

docker-compose up -d

で起動します。

動作確認コマンドは先のものと同じCurlからのチェックで、注意点は2点です。

  1. デタッチモードなのでElasticSearchの起動時ログが出てきません。 ログなしだと起動完了したか見えなくて困りますが、elasticsearchが起動するまで数秒かかるので、起動完了を待ちます。 先のコマンドをしばらく叩いていればそのうち応答返ってくるようになります。
  2. Elasticsearchは複数インスタンスクラスタ組むのが基本らしく、ステートがyellowと返ってくることがあります。 勉強段階であれば単一インスタンスのほうが色々とやりやすいので、今回はstate=yellowでも良しとします。

ログ収集用のクライアントを作成するための準備

ログ収集といえばfluentdですが、入れるの面倒なので後回しです。 一旦SCPでAWS上からコピーしてくることにします。 .NetCoreの勉強もしたいので、C#でいきます。

プロジェクトを作る

.NetCoreのプロジェクトを作って、必要なパッケージを入れます。

dotnet new console
dotnet add package SSH.NET --version 2016.1.0 
dotnet add package Elasticsearch.Net --version 6.4.1 
dotnet add package NEST --version 6.4.1

プロジェクトディレクトリをVSCodeで開くとデバッガもインストールされます(選択肢でYesを選択すればよい) デバッガは本家VisualStudioと同じ感覚で操作できます。

scpでログ収集してくる

AWSに接続してログを集めてくることにします。 SSH.NETでだいたい面倒見てくれるので、外からパラメータ与えてAPIを順に叩いていくだけです。

  • SCPで接続するホスト、ポート、ユーザー、秘密鍵を指定して設定を行う
  • SSHClientに設定を渡して接続する
  • SSHClientのDownloadでファイルをダウンロード -> ストリームになる
  • ストリームからローカルファイルに保存する

といった流れです。

using System;
using System.IO;
using System.Linq;
using Renci.SshNet;
using Elasticsearch;
using Nest;

  // memo : ここらでHostAddressをとかを適当に設定する

  static void Main(string[] args)
  {
    // SSH接続のための設定
    var ConnNfo = new ConnectionInfo(HostAddress, HostPort, SshUser,
        new AuthenticationMethod[]{
            new PrivateKeyAuthenticationMethod(SshUser,new PrivateKeyFile[]{
                new PrivateKeyFile(SshPrivateKeyFileName, SshPassword)
            }),
        }
    );

    // SCPでログ収集
    using (var client = new ScpClient(ConnNfo)){
        client.RemotePathTransformation = RemotePathTransformation.ShellQuote;
        client.Connect();
        try
        {
            if (!client.IsConnected)
            {
                Console.WriteLine("[NG] SSH Connection failed!!");
            }
            using (var ms = new MemoryStream())
            {
                client.Download("ログファイル名", ms);
                using (FileStream file = new FileStream("testfile", FileMode.Create, FileAccess.Write)) {
                    ms.WriteTo(file);
                }
            }
        }
        finally
        {
        }
    }
  }

コードを書いたらF5でデバッグして、問題なければ次のコマンドで実行できます。

dotnet run

C#からElasticSearchを叩く

dotnet用のパッケージが入っているので、それを使って叩くことができました。

気を付けるポイントとして、ElasticSearchのクライアントは単一ホストで構築されたクラスタであってもコレクションを返してくる(要素数=1)ため、Linqで片づけます。

Linq初めてなのでよくわかりませんが、このあたりをよしなにしてくれるもののようです。

コード

using System;
using System.IO;
using System.Linq;
using Renci.SshNet;
using Elasticsearch;
using Nest;

  static void Main(string[] args)
  {
    //Elasticsearchの状態確認
    var elastic_settings = new ConnectionSettings(new Uri("http://127.0.0.1:9200"));
    var esclient = new ElasticClient(elastic_settings);
    var health = esclient.CatHealth().Records.SingleOrDefault();
    Console.WriteLine("status : " + health.Status);
  }

これで "status : green"と返ってくればOKです。C#からElasticsearchを叩く用意が整いました。

ログを注入する用意

Elasticsearch単体では大した事はできません。ただのRDBだけでは面白くもなんともないのと同じです。

ログの集計等を試したいところですが、そのためにはログが必要ですので注入していきましょう。

どうやってログをElasticsearchに入れていくのか

方法は色々あるみたいで、軽く調べた感じ次の方法がありました

  • ElasticsearchのAPI
  • fluentd
  • logstash

まあfluentdもlogstashもAPI叩いているのだろうとは思うので、使うとしたらこのどちらかかなと思います。

今回はlogstashを使ってみます。

logstashはElasticsearch開発元のElastic社が作ってるので、公式リファレンスが充実していて、Elasticsearchと組み合わせたときに問題が起きにくいと考えられます。

たとえばElastic公式ではこういうdockerサンプルを出してたりするので、何か困ったらこいつを動かせば済むと思います。

https://github.com/elastic/logstash-docker

logstashのインストールと設定

docker-compose.ymlにlogstashも追加するとこんな感じのファイルになりました。

version: "3"
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:6.5.4
    ports: 
      - "9200:9200"
      - "9300:9300"
    environment:
      - discovery.type=single-node
    volumes:
      - "./data:/usr/share/elasticsearch/data"
    networks: 
      - elastic-stack

  logstach:
    image: docker.elastic.co/logstash/logstash:6.5.4
    volumes:
      - "./pipeline/:/usr/share/logstash/pipeline/"
    networks:
      - elastic-stack

networks:
  elastic-stack:

logstashそのままでは意味がないので、一定周期でログを送り続けるパイプライン設定ファイルを食べさせます。

設定ファイルはこれをコピーしてきました。

https://github.com/elastic/logstash-docker/blob/master/examples/logstash.conf

このファイルをdocker-compose.ymlディレクトリをカレントとして、./pipeline/logstash.confに配置します。

logstash側が見てくれる設定ファイルの置き場所は

https://www.elastic.co/guide/en/logstash/current/dir-layout.html

とのことなので、/usr/share/logstash/pipeline/logstash.confに先ほど作った./pipeline/logstash.confが来るようにマウントします。

最後にdocker-compose up -dすればOKです。

logstashの動作確認のためにkibanaをインストール

logstashが5秒周期でelasticsearchにログを送っていると思うので動作確認をしたいです。

先の手順であったサンプルコード見たときに、docker-composeに書いてあるのを見つけたので試してみた次第です。

API叩こうにもまだそこまで理解が進んでいないので、GUIから簡単に操作できるkibanaを使ってみます。 kibanaもelastic社がつくってるので、たぶん動くでしょう。

https://github.com/elastic/logstash-docker/blob/master/templates/docker-compose.yml.j2

pythonのjinjaテンプレート展開でごにょごにょしてますが、必要な設定だけ持ってくればOKでした。

というわけでdocker-compose.ymlはこうなりました。

version: "3"
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:6.5.4
    ports: 
      - "9200:9200"
      - "9300:9300"
    environment:
      - discovery.type=single-node
    volumes:
      - "./data:/usr/share/elasticsearch/data"
    networks: 
      - elastic-stack

  kibana:
    image: docker.elastic.co/kibana/kibana:6.5.4
    ports:
      - "5601:5601"
    networks: 
      - elastic-stack

  logstach:
    image: docker.elastic.co/logstash/logstash:6.5.4
    volumes:
      - "./pipeline/:/usr/share/logstash/pipeline/"
    networks:
      - elastic-stack

networks:
  elastic-stack:

これでdocker-compose upしたDockerホストの5601にブラウザでアクセスすれば、kibanaの画面が開きます。

ログを確認

GUI操作はSS撮るの面倒なので文字でメモだけ残します。

最低限の設定

managementからkibanaのindex Patternsを設定します。 これはログを成形して内部的に管理しやすい形にするものです。

今回の動作確認をするだけなら"*"で全部見れるようになるので、これで十分です。

ログを見る

discoverから先ほど設定したフィルタを選択します("*")。これで送られてきたログをすべてみることができます。

https://github.com/elastic/logstash-docker/blob/master/examples/logstash.conf

のinputにある文字列がintervalの周期で送られてきていればOKです。

まとめ

今回はサービスを運用していくためのログ分析基盤であるElasticSearchを試してみました。 まだデータは入れていませんが、今後どんどんログ入れていこうと思います。

ただし、無秩序にログを入れると分析の時に泣きを見るので検討してからですね。

dockerなので環境は作って壊してを簡単にできる利点を生かして、どんどん試していきたいと思います。

それでは今回はこれくらいで。

2018年振り返り

年の瀬も迫ってきたので、今年一年を振り返ってみようと思います。かたりぃなです。

よかったこと

ちょっと残念

  • DeepLearning

  • Unity

  • C++オンリーでHololens開発の限界

よかったこと詳細

インフラまわり色々できた

なんやかんやで、今年はインフラをいじってることが多かったと思います。 インフラの具体的な技術要素としては

こんなところでしょうか

2018年に身に着けた技術で特に大きかったのはAWSとansibleですね。

Hololensなどのアプリを作る場合のアーキテクチャ設計を考えたとき、クラウドとの連携は必要だと考えています。 これはHololensに限らずモバイルアプリ全般に言えることです。

そのための土台作りができたということで、かなり大きな前進です。

これらをうまく活用して開発を進めていきたいですね。

開発環境のコンテナ化

ローカル開発環境はdockerを本格的に使うようになりました。

くーべねてぃす?とやらはまだ試していませんが、dockerfile, docker-composeあたりを駆使して色々な環境を試しています。

軽く試したコンテナとしては

などです。

dockerでやってみて個人的にいいなと思ったのが次のものです

  • 環境の固定, 実験の再現のしやすさ
  • マイクロサービス

前者はこのブログでも時々書いてますが、docker-compose up すれば状況を再現できるので、すごく楽です。

後者のマイクロサービスはコンテナ化と合わせて考慮していきたい事項です。

マイクロサービスを目指すモチベーションは、これまでの開発で個人的に悲しい案件が多々あって、それを回避する手段として使えそうだなというところです。

これまでの開発で個人的に経験した悲しい事案として

  • chainerとflaskを組み合わせたい
    • flask最新版では動かない
  • rubyのバージョン上げた
    • 同居してるfluentdが死んだ
  • PHPのバージョンを上げた

こんなのがありあす。

そもそも上記悲しい事案は複数サービスが1つの環境に同居しているから起きるのであって、分離してしまえば何も問題はないはずです。

分離の方法としては言語ごとにあるenvで回避できそうな気もします(pythonだとpyenvとか)が、言語ごと/モジュールごとにそれをやっていくのはどうなの?的な思いがあります。

各言語のプロフェッショナルならenv使って上手に回避できるのかもしれませんが、私の母国語はC,C++ということもあってライブラリの管理が少々面倒というところもあって、個人的にはマイクロサービス化とコンテナ化推しです。

そのうちAWSのほうもdockerで動かしてみたいですね。ECS(elastic-container-service)というサービスらしいです。

通信プロトコル

クラウドとHololensの連携との話をさらに掘り下げると、具体的にWebフロントどうするかとか、プロトコルどうするとかあります。 webサーバはnginxで色々できるようになってきたので、これでいけそうと思ってます。apacheでもいいですけど。

通信プロトコルの現状の実装はHTTPSのRESTですが、セッション張り直しのコストとか考えるとWebSocketかなと思ってます。

というわけで、カードゲームのARのための最低限の土台が整ったと思います。 性能面とか考慮しつつ、今後どうするか色々試していきたいですね。

WebとHololensの連携

まだ完成していないので記事にしていないのですが、HololensとWebで少しずつ連携できるようになってきました。

通信部分はただのRESTではありますが、重要なのはWebリクエストを出した時点でのHololensの姿勢を使うというところです。

Webと連携すると多少なりとも遅延は発生するので、そのあたりをどうやって解決していくかという実験をするための土台が整ったといえます。

実際に動かした感じでは秒単位の遅延が起きることが分かっています。 これは通信レイテンシだけでなく物体検出・カテゴリ分類などの、処理が重いことも影響しています。

リアルタイムのARアプリでWebと連携する場合、いかにユーザーにストレスや違和感を与えずに演出を組み込んでいくかなど、そういった部分を今後色々と試していきたいですね。

開発環境について

少々蛇足になりますが、貴重な時間を有効活用するために、通勤時間や昼休みにSurfaceBook2で作業しています。

初期投資に多額の資金を投入するリスクを負うのは怖かったので、RAMは8GBのモデルです。

動作は快適なのですが、8Gだと少々つらくて、それぞれの開発環境を個別に動かすことはできますが、同時実行ができないです。

こういう状況なので、Webとの連携が進み始めた段階でAWSも使い始めることができたのは幸運だったなぁと思う次第です。

技術の融合

個人的には私の好きな分野で技術を活用できた一年だったと考えています。

技術の融合の話に入る前に、まず「技術」もしくは「テクノロジー」について私の思想について少し書きます。

私は「テクノロジーはエンドユーザーに価値を提供してこそ意味があるもの」だと思っています。

エンドユーザーに提供する価値というと、具体的には「製品」や「体験」といったものです。 店で売っているような商品や、同人作品などは製品そのものですし、アプリで遊んでもらう(=楽しむ)なんてのは体験ですね。

今年は「技術を活用する、価値を提供する」領域を広げるために「コスプレでのAR利用」を試し始めました。

「コスプレでのAR利用」は、イベント会場で興味を持ってくれた人もいたみたいで、今後も表現方法の1つとして色々試したいと思います。

コスプレでAR, 3Dプリンタの利用

ます「コスプレ」は仮想世界(ゲーム, アニメなど)にあるものを現実世界に再現して、キャラになりきる行為です。

言うなれば「ごっこあそび」ですね。子供の頃やっていたであろう「ドラゴンボールごっこ」とかの進化形です。

こういった分野で個人的にやりたいこと(製品そのもの+アプリ)の実現に一歩近づいた年だと思います。

具体的には「ARとコスプレの融合ができた」というのが大きいと思います。イベント会場ではほとんど使えませんでしたが。(猫耳が引っかかってHololens装備できなかった。。。)

技術要素はこんな感じでしょうか

仮想世界にあるものを現実世界で再現する(たとえば3Dプリンタ)という行為は、ARとは異なる技術で現実世界を楽しくしてくれます。 というわけで、コスプレとARは相性が良いだろうと考えています。

特に3Dプリンタは一昔前は「家も建てることができる夢の装置」みたいにもてはやされましたが、プリント時間やコスト・耐久性などを考えると、そんなことは全然ありません。家庭用なら尚のことです。

3Dプリンタだけでは大して面白いものは作れませんが、ARデバイスと組み合わせることでとても楽しいものができるということは見えてきました。

特に私の場合はギミック(光る、動く、変形、合体)とかが大好きですから、そういうものの再現にも使えそうです。

他の技術でもいいのでは?

VRでいいのでは?と思うこともありますが、個人的には実物のほうが好きです。

たとえば、コスプレの武器って実際に持ってみると「すごいもの持ってる」感があって、VRのコントローラとは全然違った感覚です。

(VR自体は家電量販店でやってるデモしか体験したことないので、私の中での誤解があるかもしれませんが。)

現行のVRでは再現できないけれども、コスプレ用の武器で再現できる(優れている)点について考えてみました。

具体的には次の点がコスプレの武器が優れていると思っています。

  • 持ち手部分の触った感じ
  • 振ると重さを感じる

前者は武器製作者のセンスにも依存しますが、金属的な冷たさとか、革のようなじっとりした感じは、実物ならではです。

後者の「重さ」というのは、ただの重量だけに留まらず、実世界での動きに関係してきます。というのも、コスプレ用の武器というのは装飾がついていたりして、重心が不安定です。

  • 杖の先端のほうが重い
  • 斧は刃がついてる側のほうが重い

とかですね。

こういうものを振ったり構えたりすると遠心力でさらに不安定になるため、独特の「持ってる感じ」を受けます。これは現状のVRコントローラでは再現できてなくて、少々物足りない感じになります。

もちろん人によって受け止め方は違っていて、

  • そういうのは要らない派(たとえばゲームコントローラの振動もいらない派)
  • VRでもそういうのを再現できればよい派(研究をしている方々)
  • もっと実物の再現度上げたい派(ARすら不要派)

みたいに色々な考え方がありそうです。

私は実物+AR派でいこうと思っていますし、各個人が目的にあったテクノロジーを使えば幸せになれそうだなと思います。

色々と堅苦しいことも書きましたが、自分が楽しめることが一番大事だったりします。 継続できるのは楽しいからこそです。

年齢を重ねても夢は忘れないようにしたいですね。

ちょっと残念だったこと

やってみたけど上手くいかなかったり、やる予定だったけど実現できなかったことを書いてみます。

DeepLearning

まだ記事にしていませんが、chainerを使ってVGG16からのMTGのカード分類のファインチューニングはじめました。 試しに自宅のゲーミングPCで一週間くらいかけて40Epoc回したところ、50~60%の正答率でした。 学習・教師データともに少ないので過学習してる気はします。

Unity

2018年内にネイティブプラグイン試しに作るくらいやりたかったのですが、手付かずでした。。。

C++の限界

これも記事にしてない案件ですね。 Unityでやればすぐ終わることがC++だとめっちゃ手間かかるので、あきらめました。

これはC++がダメというわけではなくて、プログラムコードだけでは3D空間を把握できないという事実です。

文字列だけでのプログラミングの限界なのかなぁと。

具体的には3D空間での衝突判定とかで、bulletとeigenを使おうとビルドして組み込んではみたものの、うまく動いてくれなくてデバッグに手間かかりすぎです。

ベクトルの値なんかをグラフィカルに表示出来ればいのですが、結局ノートに鉛筆で書いたりとかしてるうちに「Unityでいいじゃない」って結論に辿り着きました。

C++のみの実装自体は断念しましたが、線形代数を復習する良い機会だったと思います。

やったこと自体は来年の目標であるネイティブプラグイン開発にも引き継げると思います。

2019年やりたいこと

来年の目標というか、やってみたい願望を書いてみます。

こういうところは現実主義なので、目標の段階で「無理やん?」みたいなのは自然に除外しているみたいです。

目標を達成していくことで次の2つが実現できると考えています。

  • カードゲームのAR化
  • コスプレでのAR活用

Unityネイティブプラグイン (継続)

とりあえず趣味レベルとはいえ2年近くC++/CXとUnityを触ってみてわかったことは、どちらも一長一短ですね。 Unityだとエンジンがほぼ全部面倒見てくれて、C++だと細かいところまで手が届く。

なので、これらの美味しいところ取りをしたいと思っています。

どういった場合にどちらが優れているかという点は見えてきたので、Unityをフロントとして、C++プラグインとして使っていく方向で2019年は動いてみたいと思います。

納得できなければまたC++からDirectX叩けばいいですし。

DeepLearning (継続)

ご無沙汰してる間にDeepLearning界隈も進んだようです。 カードゲームのAR化での画像分類やカード検出で使おうと試しているものをさらに進めてみたいと思います。

やりたいこととしては2つあって、1つ目は学習用のデータ収集です。 まずはアプリ側のプロトタイプ実装を終わらせて、それを使って学習用データを収集するところからですね。 現状のWebAPIから拾ってきたカード画像だけでは限界があります。

2つ目はOpenCVのONNIXローダを試したいですね。

ONNIXというのはDeepLearningのデータ交換用フォーマットです。、乱暴に表現するなら「Web技術でいうJSON/XMLのDeepLearning版」といった感じです。

何ができるかというと、Chainerで学習させたネットワークをONNIXで出力し、そのONNIXをOpenCV(C++)で読み込めるようになります。

これはどういうことかというと、

  • 学習はクラウドの強いマシンでChainerを使う
  • 識別はモバイル端末でOpenCVのネイティブ実装を使う

といったことができそうです。

Hololensだと、識別フェーズの処理ですら厳しいかもしれませんが、試す価値はあると思っています。

次期Hololensに搭載されるという噂のAIチップでONIIXを使えるAPIを公開してくれることを祈りましょう。(そういう用途ではなさそうですし、公開されないでしょうけれど)

電子工作魔法陣 (新規)

ずっとご無沙汰してた電子工作を復活させたいと思います。

catalina1344.hatenablog.jp

今更感ありますが、現状のAR/VRには致命的な問題があると思っていて、それを解決するのが目的です。

AR/VR最大の課題は「デバイスを持っていない人は楽しくない」というところです。

どちらも外野から見ると「何やってんだ?」状態ですし、「装着者が見ている映像はコレです」と見せても、面白さは伝わりにくいです。

Hololensでは映像体験を共有する仕組みがありますが「デバイスorそれに準ずるものを持っていること」という前提条件ありきです。

私としてはコスプレとかのサブカルをテクノロジーで盛り上げたいと思っているので、外野のギャラリー(=デバイスを持っていない人)にも「面白い」「楽しい」と感じてもらいたいです。

通りがかりに「ちょっと写真一枚撮らせてほしい」という時に、「Hololensでぜひ」というやり取りはムリがあります。

なので、「ARデバイスがなくても楽しい」「傍から見てても楽しい」という物理魔法陣をまた頑張ってみようかなと思った次第です。

魔法(物理)ですね。

ちなみに「デバイスを持っていない人が楽しくない」のであれば「全員がデバイスを持っている環境を作ればいい」という発想で進めているのがHadoとか、VRエンタテインメントなんかの施設だと考えます。

個人活動ではハコモノ作れるわけもなく、そもそも貸し出し用デバイスを購入する資金もありませんので、別のアプローチでいくのが正解だと思っています。

まとめ

今年も色々と試行錯誤して勉強になりました。

自分で手を動かして失敗しないと覚えないタイプなので、順調に進められた一年だったかなと思います。

このブログも更新が少ないのに読者になってくださる方もいてありがたい限りです。

スターとかブクマとかもらえたときにはめっちゃやる気UPしてたりします。

ARそのものをお仕事にできれば楽しそうだとは思っています。

それでは今回はこれくらいで。

皆様よいお年を。