catalinaの備忘録

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

父の日ですね

たまには文章書かないと日本語忘れてしまいそうです。職場でも人と話することもないので。かたりぃなです。

 

ブログで技術以外のことを書くのは初めてかもしれません。どちらかというと政治&過去語りな内容。

興味のない人はそっと閉じてくださいね。

 

まず、私の父はもういません十数年前に他界しました。

そんな父は私がプログラマになるための最後の助けをしてくれました。

 

上京・進学のとき

時代は20世紀末期。就職氷河期が始まっているので大学進学したところで就職先があるかどうかもわからないご時世。

国公立ならあるいは?という選択肢もありましたが、浪人などしようものなら阪神淡路大震災の影響で悪化していた家庭環境。居場所がないのは自明でした。実家は四国なので、震災の直接的な影響はなかったのですが、親戚関連とかでいろいろあったらしいです。

 

公務員という選択肢も考えましたが、当時は就職氷河期の折。公務員の競争率は跳ね上がっていましたし、田舎なので縁故採用らしき枠がほとんどを持って行ってしまうんですね。悲しい現実です。

 

そんなこんなで、私がお金を使ってしまうと弟や妹が進学できなくなってしまう。国公立に賭けるみたいなギャンブル打つくらいなら専門学校で。と進路を決めました。

国公立に余裕で行ける学力がなかったのは自分の勉強不足だったんだろうなと思います。

専門学校となると今まで視野に入れてなかったので色々調べる必要がありました。

私がいた地方の専門学校は一族経営だったり、人脈だけで成り立ってたりと、そういう所だったので、こうするほかなかったという状況だったので場所は東京となりました。

母は大反対でしたが、父は快諾とまでは言わないまでも「やれるだけやってみ。無理なら帰ってこい」と言ってくれました。

 

上京の日は父がついてきていました。寮母さんに挨拶してから、西友で生活に必要なものを買ってくれたのを記憶しています。「バイトで頑張って貯めたみたいだけど、それは生活のためじゃなくて自分のやりたいことをやるために使いなさい。」そんなことを言ってた気がします。

 

当時の私の地方のバイト代は時給600が平均値でしたから、ほとんど溜まっていない状況でした。あの生活基盤を作ってくれたお金がなかったら、あっというまに困窮していたと思います。父に感謝です。

 

パソコン

進学と同時にパソコンを買いたかったのですが、そんなお金はどこにもありませんでした。進学後はなんとかしようとバイトしつつも試行錯誤の毎日でした。

通学時間中に本を読んで、紙にコードを書いて、学校のPCで打ち込むという生活を半年近く続けました。バイトも掛け持ちしていたので当時の睡眠時間は3時間前後だったと思います。今にしてみればよく生きてたなと。

専門学校の一年目が終わるころにはC++, DirectXくらいはいじれるようになっていましたが、やっぱりパソコンなしでは限界がありました。

ダメもとで父に電話したところ「ボーナスが少し入った。少しだけなら」と。

当時としてはなかなかのスペックの中古PCとモニタ(Gateway製, Pentium100MHz, RAM=96M)みたいなマシンでした。

おかげで制作発表会では実写を取り込んだ2D格闘ゲームを展示できて反響もありました。

反響があったというのが嬉しくて、寝る間も惜しんで色々プログラムを作ってた気がします。

父のおかげです。当時の私はまだ19歳でした。

 

成人式

もともと出るつもりはなかったのですが、アルバイト先の仲間から出たほうがいいといわれ、休みももらえたので出ることにしました。

成人式前日に実家に帰ってみると、母が封筒をくれました「待ってたよ。父さんから預かったお金、スーツ代。リクルートスーツじゃせっかくの成人式が台無しだからね」と。

成人式に多少なりともまともなスーツで参加できたのも父のおかげです。

その後も何度か実家には帰ったりはしました。

父から聞いた最後の言葉は「気をつけてな。悪いやつに騙されるなよ。お前は人が良すぎる」でした。

 

父の他界

世間がサブプライム問題とか賑わっていた時期でした。

当時の会社の同僚の結婚披露宴の最中に電話が鳴りました。父が倒れたと。

すぐ羽田から四国へ飛び、病院に着いてみると父は手術中でした。

待っている間に状況を聞いてみると、あまりよくないようでした。

一度は医大病院に運ばれたが、医者が居ないからと県立病院へ移送、一刻も早く手術をしないといけない状況なのに、ここにも医者が居ないから手術まで数時間待ち。そんな状況でした。

幸いにも手術は成功しました。

 

私は安心して東京へ戻り、次の日には再び職場で仕事をしていました。「元気になったら好物でも買って行ってやろう。一緒に酒飲んだこともなかったな」なんて呑気に考えてました。

3日後、再び電話がなりました。合併症とのこと。もうダメだと。

私は喪服を用意して再び四国へ飛びました。「すぐ息を引き取るわけではないけれども、回復の見込みは極めて低い。」そんなお医者さんからのお話でした。

私はせめてその時くらい一緒にいてやろうと一週間有給を入れました。

 

当時の私の職場は下請け企業なのでリーマンショックの余波を受けての政治争い(受注争い)が大変ですが、そんなことよりも大切なことがあると思っています。

 

親のことが好きとか嫌いとか、自分の立場がとか、会社の業績がとか。

そんなものどうでもよかった。ただ今まで助けてくれた父の最期を看取ることくらいしたかったのです。

 

有給の期間が過ぎ、母が告げました「あんた、リーマンショックの影響で大変なんだから帰りな。あとはなんとかする。」と。

強情な母なのでそれを受けて、次の日に東京に戻ることで話がついた夜。病院から電話でした。

「血圧が低下しています。ご家族や親類の方を。」

没年50歳後半でした。

 

職場に電話して忌引き休暇の連絡、葬儀屋の手配、病院の請求書の処理。

葬儀は私が喪主を務め無事に終えることができました。

火葬のボタンを押すとき「今までありがとうございました」と。

 

後悔

せめて一緒に酒くらい呑んでやればよかった。嫁に合わせてやりたかった。

そんな思いで四十九日を終え、やっと本格的に職場復帰したころでした。

やはり長期間職場を不在にすると政治的に不利になりますね。

現場としてはそんな負荷を押し付けられたと思うでしょうし、総務や人事は知ったこっちゃないという状況です。

心理的にも耐えられないと思い、退職を申し出ました。しばらく田舎で父の残してくれたものの処理をしようと考えていました。

 

ここで父の最期の言葉を思い出しておけばよかったと悔やまれます。

離職票の退職日や保険の加入などを偽装され、金額的にも精神的にもかなりの損失を被りました。

ストレスからの暴飲暴食からの胃潰瘍で出血多量となって死にかけましたが、今も私はなんとか生きてます。

 

もう10年以上前のことです。

 

個人的に思うこと

私は親があまり好きではありませんでした。。

ただ、今にして思えば、生活が苦しい中で、父は本当に必要なものを選んで私に与えてくれていたんだと思います。

父が他界したとき、葬儀屋さんからいただいた言葉に救われた気がします。

「皆さん、親にお礼してないと悔やまれます。ただね、子供が親にするお礼というのはですね、10歳まで一緒に生きるというのが最初にして最高のお礼なんですよ。10歳過ぎたら、感謝しつつ自分の道を進んでいく。それで充分なんですよ」と。

確かにそれくらいまでの父の姿が一番記憶に残っている気がします。それを過ぎると反抗期も始まりますし。

 

政治に対して思うこと

法の下の平等というのは何なのだろう?と父の他界を機に本気で考え続けています。

 

ただ、一つだけ不平等だと言えるのは父の他界です。

小泉政権のときに「地方切り捨てだ!」などと叫ばれていましたが、私は他人事のように思っていました。

地方切り捨てが行われたらどうなるのかも考えず、自分の地方がその切り捨てられる側になるなんて考えず。

そして「医者がいないから手術ができなかった」という言葉をまさか父が受けることなるなんて考えてもいませんでした。

 

父はこのことを教えてくれたのかもしれません。

 

総括

日本の政治によって私の父は命を奪われた。私は今でもそう思っています。

ただ、憎しみは何も生まない。だから私は悲しむ人が少しでも減るような社会になってほしい。そう願います。

今の日本社会を見ていると、皆が自分のことで精一杯で余裕がないなと感じています。

 

だとしたら、それは今の政治であったり、社会、働き方、会社、色々なしくみが限界を迎えているのではないか?と。

 

少し暗い話になりましたが、できることなら、全世界から苦しみが無くなって幸せになれる世界が訪れるといいなと思っています。

これを仏教の言葉で「大欲得清浄」というらしいです。意味は大きな欲は清浄を生む。

自分だけ良ければそれでよいというのではなく、もっと大きな目で物事を見てみると何か大切なものが見えてくるかもしれません。

 

私にそれを教えてくれたのは父の命でした。

 

南無大師遍照金剛

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なので環境は作って壊してを簡単にできる利点を生かして、どんどん試していきたいと思います。

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