Quantcast
Channel: mattintosh note
Viewing all 891 articles
Browse latest View live

Elasticsearch と Vue.js で電子書籍ランキングを作ってみた

$
0
0

Raspberry Piで作った Elasticsearch サーバにデータをポイポイと突っ込むこと数日。ある程度データも集まり、ストア間の項目も整理できてきたのでサムネイル一覧的なものが欲しいなぁと思ったので http://ebook.stellarcat.net/index.htmlで作ってみた。(お名前.comの契約が2月までなのでこれはそのうち消えます)

f:id:mattintosh4:20190119225515p:plain

会社の人から「Vue.js 使ってみて」と言われたので Vue.js を使うことに。Vue.js でサーバ要らずなので EC2 は使わずに S3 と CloudFront だけでやることにした。

  • Elasticsearch サーバはオフィスにあるので公開はしない。
  • クローリングは一定間隔で行っており、リアルタイム性は無いので JSONは S3 に置いてしまおう。(APIGatewayや Lambda の使用も考えたがランキングを表示する度に Elasticsearch に毎回アクセスする必要は無いのでやめた)
  • Vue.js って何?

f:id:mattintosh4:20190120001042p:plain
AWS

データソースは当初 AmazonApp StoreGoogle Playにしていたが、他の電子書籍サービスを見ているとランキングが単巻ではなくシリーズになっているのだと知った。で、そっちの方に専念してしまって Elasticsearch の設計なんかも変わってしまい、Amazonなどは作り直しが追いついていない。

とりあえず以下のサービスに絞った。

下調べとしてサービスごとのランキングの変動タイミングの調査。eBookJapan と BookLive! は1日1回の更新らしい。(可視化には Kibana を使っていたが Grafana が ARM でも動くようだったので Grafana に変更した)

f:id:mattintosh4:20190119230130p:plain
Elasticsearch × Grafana

Elasticsearch のクエリは filtershouldを使っていて色々面倒だったが、query_stringの使い方がわかったのでかなり楽になった。

Before

{"query": {"bool": {"filter": [{"term": {"store_id": "XXXXXX"
          }},
        {"term": {"category_id": "XXXXXX"
          }},
        {"range": {"@timestamp": {"gte": "now-1h"
            }}}]}},
  "sort": {"rank": {"order": "asc"
    }},
  "from": 0,
  "size": 100}

After

{"query": {"query_string": {"query": "store_id:XXXXX AND category_id: XXXXX AND @timestamp:[now-1h TO now]"
    }},
  "sort": {"rank": {"order": "asc"
    }},
  "from": 0,
  "size": 100}

Vue.js は「基礎から学ぶ Vue.js」さんのところを見ながら学習。

cr-vue.mio3io.com

とりあえず Webpack を使うほどでもなかったので HTML と JavaScriptをカキカキ…。最近は table も cssで簡単にピボット出来たり便利だなぁ。

で、S3 に放り投げてドメインの設定して http://ebook.stellarcat.net/index.htmlで公開。

いまのところ Elasticsearch からの JSONをそのまま使っているのでこれも整形したいところ。著者名とかフィールドが配列になっているところの処理も考えなきゃいけない。Amazonとかのクローラーも作り直さなきゃ。

このランキングは継続して公開する予定はいまのところ無いです。一通り終わる頃にはドメインの契約が切れてる頃だと思うのでそこで終了する予定です。

そういえば本当はランキングじゃなくて無料配信中の書籍を集めるものを作りたかったんだけどそれはどこ行ったのか…。


Vue.js で2つの配列からデータを取得する

$
0
0

Vue.js の勉強してるけどなんかなぁ…って感じ。どうも文法というか書式というかに馴染めない感じ。自分の頭がオブジェクト指向じゃないからなんだろうけど。MVVM の解説読んでみたけど初歩的なことやってないから View とか Model とかよくわからない。

さて、Elasticsearch に貯めたデータをほぼそのまま Vue.js に持ってきてるんだけども、設計のミスもあったせいでちょっと困った。

大抵の書籍の場合、一つの書籍に対して一人の著者が結び付く 1:1なんだけど、書籍やストアによっては一つの書籍に対して複数の著者が結びつく 1:nになっていることがある。

xpathで取り出しているせいもあって、初期段階では全ての項目が配列になっているところを、配列の長さが 1のものは文字列に変換する処理をかけていた。

1:1 のデータ

{"hits": {"_source": {"book_name": "五等分の花嫁",
      "author_name": "春場ねぎ",
      "author_url": "https://booklive.jp/focus/author/a_id/123817"
    }}}

1:n のデータ

{"hits": {"_source": {"book_name": "転生したらスライムだった件",
      "author_name": ["川上泰樹",
        "伏瀬",
        "みっつばー"
      ],
      "author_url": ["https://booklive.jp/focus/author/a_id/121592",
        "https://booklive.jp/focus/author/a_id/110906",
        "https://booklive.jp/focus/author/a_id/110907"
      ]}}}

Pythonであれば zip(author_name, author_url)で2つの配列を紐付けることができるんだけど Vue.js というか JavaScriptでその方法がわからない。

じゃあインデックスは一致してるんだから片方のインデックス使って参照すればいいんじゃないの、と思ったけど、1:1の方は文字列で入っているので v-forに入れてしまうと1文字ずつに分割されてしまう…Oh。

というわけで v-if="Array.isArray()"で条件分岐すればいいんじゃないかと思ったけどネット上にうまいことやってるお手本が無かった。個人的にはこういう情報がネットにあると嬉しいんだけどなぁと思う。言語によって説明が上手い人もいたりいなかったり。言語の好みはこういうところから分かれていくのだろうか。

v-forはインデックスも取れるようなので author_nameのインデックスを index変数に入れて、そのインデックスを使って author_urlの値を拾ってくる。

<!-- author_name が配列の場合 --><ul v-if="Array.isArray(hits._source.author_name)"><li v-for="author_name, index in hits._source.author_name"><a v-bind:href="hits._source.author_url[index]">{{ author_name }}</a></li></ul><!-- author_name が文字列の場合 --><ul v-else><li><a v-bind:href="hits._source.author_url">{{ author_name }}</a></li></ul>

Vue.js の公式サイトも翻訳されててまぁいいんだけどどうも説明の仕方が微妙。MDN はわかりやすいんだけどなぁ…。最近は文章読んでも理解できないし、いまから JavaScript思い出すのもしんどい…(;´Д`)

久しぶりに Web サイト作ってみたものの、内容が至極個人的なものなので見た目とか自分がわかればどうでもいいじゃんよって思うようになりこれ以上モチベーションが上がらない。

ebook.stellarcat.net

対象ストアも増えてきちゃったし。ていうかお前 Amazonしか使ってないじゃん?しかもランキングなら Kibana とか Grafana で推移見ながらの方が楽しいじゃない?とかなんとか。

昨日、ニュースに出ていた「アル」というサービスはとても良いものだと思う。自分にはこういうアイデア思いついたりとか実際に作ろうという行動力が無いのですごいなと思う。

alu.jp

www.itmedia.co.jp

さぁこの下がり続けるモチベーションをどうしようか…。

Elasticsearch で nested したフィールドの検索

$
0
0

電子書籍ランキングで収集しているデータの構造をちょっと変更。

ebook.stellarcat.net

いままで複数の著者がいる場合、名前(author_name)と ID(author_id)と URL(author_url)を別々に格納していたんだけど「1著者で1つのオブジェクトにした方が都合がいいのでは?」と思ったので変えてみた。

Before

{"author_id": ["1",
    "2",
    "3"
  ],
  "author_name": ["一郎",
    "二郎",
    "三郎"
  ],
  "author_url": ["https://example.com/1",
    "https://example.com/2",
    "https://example.com/3"
  ]}

After

{"authors": [{"author_id": "1",
      "author_name": "一郎",
      "author_url": "https://example.com/1"
    },
    {"author_id": "2",
      "author_name": "二郎",
      "author_url": "https://example.com/2"
    },
    {"author_id": "3",
      "author_name": "三郎",
      "author_url": "https://example.com/3"
    }]}

Elasticsearch で配列の中にオブジェクトを入れたことはなかったのでどうなるんだろうなと思ってやってみたらそのまま入った。

{"_shards": {"failed": 0,
    "skipped": 0,
    "successful": 2,
    "total": 2},
  "hits": {"hits": [{"_id": "YwMcpGgBCkW6nWKC_myU",
        "_index": "bookwalker-2019.01.31",
        "_score": 1.6931472,
        "_source": {"@timestamp": "2019-01-31T22:33:00+09:00",
          "authors": [{"author_id": "22719",
              "author_name": "小宮利公",
              "author_url": "https://bookwalker.jp/author/22719/"
            },
            {"author_id": "19960",
              "author_name": "笑うヤカン",
              "author_url": "https://bookwalker.jp/author/19960/"
            }],
          "book_id": "de95d1f812-2aff-416a-aceb-e21e1178e5ef",
          "book_name": "魔王の始め方 THE COMIC5",
          "book_url": "https://bookwalker.jp/de95d1f812-2aff-416a-aceb-e21e1178e5ef/",
          "categories": [{"category_id": "2",
              "category_name": "マンガ",
              "category_url": "https://bookwalker.jp/category/2/"
            }],
          "genres": [{"genre_id": "2",
              "genre_name": "男性向け",
              "genre_url": "https://bookwalker.jp/tag/2/"
            },
            {"genre_id": "1491",
              "genre_name": "青年マンガ",
              "genre_url": "https://bookwalker.jp/tag/1491/"
            },
            {"genre_id": "556",
              "genre_name": "限定特典",
              "genre_url": "https://bookwalker.jp/tag/556/"
            }],
          "price_amount": 680,
          "price_raw": "680円",
          "rank": 1,
          "ranking_category_id": null,
          "ranking_category_name": null,
          "ranking_genre_id": "tcl22",
          "ranking_genre_name": "青年マンガ",
          "store_id": "bookwalker.jp",
          "store_name": "BOOK☆WALKER"
        },
        "_type": "_doc"
      }],
    "max_score": 1.6931472,
    "total": 20},
  "timed_out": false,
  "took": 15}

一応マッピングでは事前に下記のように設定しているが普通にデータ投げれば多分勝手にやってると思う(多分)。

{"mappings": {"_doc": {"properties": {"authors":    {"type": "nested" },
        "categories": {"type": "nested" },
        "genres":     {"type": "nested" }}}}}

実際に投入したデータを Kibana の Discover で見てみると Objects in arrays are not well supported.となっており「配列の中のオブジェクトはうまくサポートしてないよ」ということらしい。

f:id:mattintosh4:20190201181058p:plain
Kibana 6.5.2

次に、検索が効くのかどうか試すのにとりあえず Lucenegenres.genre_id:2なんてやってみるものの結果は返ってこない。これがサポートしてないよってことなんだろう。

f:id:mattintosh4:20190201181759p:plain
Elasticsearch 6.5.2 / Kibana 6.5.2

Nested Query なんてものがあるからできそうな気がするんだけど今度は Dev Tools から JSONを投げてみる。

www.elastic.co

Dev Tools の補間で出てくる nested のテンプレートは下記のようになっている。nested には pathが必須だそうだ。

{"query": {"nested": {"path": "path_to_nested_doc",
      "query": {}}}}

boolだと面倒なので)query_stringを使う場合はこんな感じの書式かな。

{"query": {"nested": {"path": "<ネストされているフィールドの名前>",
      "query": {"query_string": {"query": "<クエリ>"
        }}}}}

<親>.<子>(ここでは genres.genre_id)に対して検索をかけたい場合はフルパス(完全修飾)で記述する。

{"query": {"nested": {"path": "genres",
      "query": {"query_string": {"query": "genres.genre_id:2"
        }}}}}

nested じゃないフィールドと組み合わせたい場合(例えば @timestamp)、nested ではないフィールドの条件は nestedの外に書かなければいけないっぽい。nestedするとその下の階層が対象になるということなのかな。nested した先で sort するならソート条件も nested の中、ということになりそう。

インデックス
|
+-- @timestamp ❶
|
+-- genres
    |
    +-- genre_id ❷
    |
    +-- genre_name
    |
    +-- genre_url

非 nested、nested、sort を組み合わせるとこんな感じか。

{"query": {"bool": {"must": [{"query_string": {"query": "@timestamp:[now-1h TOnow]" ❶
          }},
        {"nested": {"path": "genres",
            "query": {"query_string": {"query": "genres.genre_id:2" ❷
              }}}}]}},
  "sort": {"rank": {"order": "asc"
    }}}

うーん、これを Kibana で出来ないのかなぁと Kibana のドキュメント(Nested Objects | Kibana User Guide [6.6] | Elastic)を見てみたらこう書いてあった。

Kibana cannot perform aggregations across fields that contain nested objects. It also cannot search on nested objects when Lucene Query Syntax is used in the query bar. Kibanaは入れ子になったオブジェクトを含むフィールドをまたがって集約を実行することはできません。 クエリバーでLucene Query Syntaxが使用されている場合も、入れ子になったオブジェクトを検索できません。 Using include_in_parent or copy_to as a workaround is not supported and may stop functioning in future releases. 回避策としてinclude_in_parentまたはcopy_toを使用することはサポートされておらず、将来のリリースで機能を停止する可能性があります。

Oh...

まぁ Elasticsearch 自体にクエリは投げられるので問題無いとして、最大の問題は Grafana である。Grafana の Query エリアで genres.genre_id:2みたいに書いても返ってこない。クエリの結果を見てみると nested せずに query_stringしているので Kibana とやっていることが同じだからか。

f:id:mattintosh4:20190201201550p:plain
Grafana

{"xhrStatus": "complete",
  "request": {"method": "POST",
    "url": "api/datasources/proxy/6/_msearch",
    "data": "{\"search_type\":\"query_then_fetch\",\"ignore_unavailable\":true,\"index\":\"bookwalker-*\",\"max_concurrent_shard_requests\":256}\n{\"size\":0,\"query\":{\"bool\":{\"filter\":[{\"range\":{\"@timestamp\":{\"gte\":\"1548846787202\",\"lte\":\"1549019587202\",\"format\":\"epoch_millis\"}}},{\"query_string\":{\"analyze_wildcard\":true,\"query\":\"genres.genre_id:2\"}}]}},\"aggs\":{\"3\":{\"terms\":{\"field\":\"book_name.keyword\",\"size\":50,\"order\":{\"1\":\"asc\"},\"min_doc_count\":1},\"aggs\":{\"1\":{\"min\":{\"field\":\"rank\"}},\"2\":{\"date_histogram\":{\"interval\":\"1h\",\"field\":\"@timestamp\",\"min_doc_count\":0,\"extended_bounds\":{\"min\":\"1548846787202\",\"max\":\"1549019587202\"},\"format\":\"epoch_millis\"},\"aggs\":{\"1\":{\"min\":{\"field\":\"rank\",\"script\":{\"inline\":\"_value * -1\"}}}}}}}}}\n"
  },
  "response": {"responses": [{"took": 28,
        "timed_out": false,
        "_shards": {"total": 2,
          "successful": 2,
          "skipped": 0,
          "failed": 0},
        "hits": {"total": 0,
          "max_score": 0,
          "hits": []},
        "aggregations": {"3": {"doc_count_error_upper_bound": 0,
            "sum_other_doc_count": 0,
            "buckets": []}},
        "status": 200}]}}

うーん、困ったなぁと思ったらちょうど https://github.com/grafana/grafana/pull/7863でやりとりしていて近々マージされそう?な感じ。いまのところ nested のフィールドを使わなくてもいいようにしているので大丈夫なんだけど将来的には対応して欲しいなと思う。

ただ、Kibana もサポートしていないところを見るとそもそも自分のデータの作り方に問題があるようにも思える。こういう場合どういう風に構成するのがいいだろうなぁ。闇雲に フィールド名_{n}みたいにフィールド増やしても意味は無いし。やはり配列のインデックスで紐付けるのが正しいんだろうか?

まだまだ Elasticsearch についてわからないことが多い。

Elasticsearch で kuromoji を使って Kibana でタグクラウドを作る

$
0
0

Slack のメッセージを解析するために Elasticsearch に Kuromoji を入れて Kibana でタグクラウドでも作ろうとしたんだけど、まぁいろんなサイト見てもわかりづらいので自分なりにまとめる。

Elasticsearch のバージョンは下記の通り。

Version: 6.5.2, Build: default/tar/9434bed/2018-11-29T23:58:20.891072Z, JVM: 1.8.0_181

Kuromoji のインストール

elasticsearch-pluginコマンドを使って analysis-kuromojiをインストールする。elasticsearch-pluginコマンドは elasticsearchコマンドと同じディレクトリに入っている。

$ ./elasticsearch-plugin install analysis-kuromoji

新しいプラグインを読み込むため Elasticsearch を再起動する。Elasticsearch が起動したらプラグインリストを表示して analysis-kuromojiが有効になっているか確認する。(起動ログにも出力されるのでそちらで確認することもできる)

$ curl localhost:9200/_nodes/plugins?pretty
      :
      "plugins" : [{"name" : "analysis-kuromoji",
          "version" : "6.5.2",
          "elasticsearch_version" : "6.5.2",
          "java_version" : "1.8",
          "description" : "The Japanese (kuromoji) Analysis plugin integrates Lucene kuromoji analysis module into elasticsearch.",
          "classname" : "org.elasticsearch.plugin.analysis.kuromoji.AnalysisKuromojiPlugin",
          "extended_plugins" : [],
          "has_native_controller" : false}],
      :

マッピングの設定

スキーマレスでデータを投入しても Kibana では項目として使うことが出来ないので事前にマッピングの設定をしておく必要がある。特に Kuromoji のカスタマイズなどをしない場合の書式は下記のようになる。

{"mappings": {"<ドキュメントタイプ>": {❶
    "properties": {"<親フィールド名>": {❷
        "type": "text",
        "analyzer": "kuromoji", ❸
        "fielddata": true, ❹
        "fields": {"<子フィールド名>": {❺
            "type": "keyword",
            "ignore_above": 256}}}}}}
  • ❶ … タイプの指定。最近では _doc一択。
  • ❷ … トップレベルのフィールド名。
  • ❸ … アナライザの指定。ここで kuromojiを指定する。
  • ❹ … ここが trueになっていないと Kibana の Aggregation が有効にならず、Terms で表示されない。
  • ❺ … 元のテキストをそのまま保存しておくためのフィールド。使わなければ作らなくてもいい。

インデックス名を my_index、ドキュメントタイプを _doc、トップレベルフィールド名を messageとした場合の JSONは下記のようになる。

{"mappings": {"_doc": {"properties": {"message": {"type": "text",
          "analyzer": "kuromoji",
          "fielddata": true,
          "fields": {"keyword": {"type": "keyword",
              "ignore_above": 256}}}}}}}

データ投入とタグクラウドの作成

せっかくはてなブログを使っているのではてなさんの代表メッセージをお借りして、先程作成したインデックスにデータを投入する。

{"message": """はてなは「知る」「つながる」「表現する」というミッションを掲げ、Webサービスを提供している会社です。

はてなを代表するサービスである「はてなブログ」や「はてなブックマーク」のように、インターネットサービスを通じ、色々な知識を得て、様々な人とコミュニケーションをとり、自分の考えや体験を表現する。そんなサービスをこれからも作っていきます。

自分の持っている技術にこだわりを持ち、研鑽を怠らない人。失敗を恐れずチャレンジ精神をもって新しいことに取り組める人。素敵な表情で働ける人。はてなのサービスやカルチャーに興味を持ち、もっと良くしていける人。インターネットが好きな人。そんな人をはてなは求めています。

世の中をもっと便利におもしろく。一緒に世界を変えていきませんか?
"""
}

Kibana で my_indexなインデックスパターンを作成する。messagemessage.keywordではない)の Aggregatableが有効になっていることを確認する。

f:id:mattintosh4:20190205212911p:plain
Kibana - Management

Discover で投入されたデータを確認してみる。「はてな」で文字列検索が有効になっていることも確認。

f:id:mattintosh4:20190205212457p:plain
Kibana - Discover

Visualize で Tag Cloud を選択して Buckets を messageに指定して実行するとタグクラウドが形成される。

f:id:mattintosh4:20190205213324p:plain
Kibana - Visualize (Tag Cloud)

整った文章の場合はあまり変な区切られ方はしないが、Slack のチャットのようにメチャクチャなワードが含まれていると変に分割されることがある。その場合、Buckets の Advanced オプションで Exclude の欄に .(任意の1文字)や ..(任意の2文字)を指定して除外して上げればよい。条件は |で区切ることで複数指定することが出来、ある程度の正規表現を使うことができるようだ。

条件書式
1文字だけ.
1文字または2文字.|..
httpまたは httpshttps?
英数字1文字〜4文字[a-zA-Z0-9]{1,4}

とりあえずうちの Slack では「半角英数字1文字」、「http または https」、「www」、「com」などを [a-zA-Z0-9]|https?|www|comという条件で除外している。

アナライザの設定で事前にフィルタを設定しておけば Slack 内部で変換されたメンション部分や URL を形態素解析の対象から外すこともできるがその話はまた今度。

サンプルスクリプト

今回のテストで使用した下記の JSONを Kibana の Dev Tools に貼り付けていただくとご自分の環境で同じ検証が出来ます。各コマンドは Dev Tools に表示される「▶」ボタンか、実行したい行をクリックして Ctrl + Enter で個別に実行出来るので順番に進めていくと Kuromoji 導入の勉強になるかもしれません。

※インデックス名はご自分の環境に合わせて変更してください。そのまま実行すると DELETE my_indexmy_indexが削除されますのでご注意ください。

※データを投入していまうとマッピングの変更ができなくなりますのでインデックスの作成からやり直す必要があります。

f:id:mattintosh4:20190205215317p:plain
Kibana - Dev Tools

DELETE my_index

PUT my_index
{
  "mappings": {
    "_doc": {
      "properties": {
        "message": {
          "type": "text",
          "analyzer": "kuromoji",
          "fielddata": true,
          "fields": {
            "keyword": {
              "type": "keyword",
              "ignore_above": 256
            }
          }
        }
      }
    }
  }
}

PUT my_index/_doc/1
{
  "message": """
はてなは「知る」「つながる」「表現する」というミッションを掲げ、Webサービスを提供している会社です。

はてなを代表するサービスである「はてなブログ」や「はてなブックマーク」のように、インターネットサービスを通じ、色々な知識を得て、様々な人とコミュニケーションをとり、自分の考えや体験を表現する。そんなサービスをこれからも作っていきます。

自分の持っている技術にこだわりを持ち、研鑽を怠らない人。失敗を恐れずチャレンジ精神をもって新しいことに取り組める人。素敵な表情で働ける人。はてなのサービスやカルチャーに興味を持ち、もっと良くしていける人。インターネットが好きな人。そんな人をはてなは求めています。

世の中をもっと便利におもしろく。一緒に世界を変えていきませんか?
"""
}

GET my_index/_doc/1

アズレン一覧めーかーの続き

$
0
0

昨日に引き続きアズールレーンネタで昨日作ったやつをちょっと改良。

mattintosh.hatenablog.com

図鑑のスクショを全部並べるとデカイのでちょっと小さくしようかなと。元の画像が大きいもんね。

f:id:mattintosh4:20171022225849p:plain

元の状態から必要な部分だけ切り出して GIMPでレイアウト調整してみる。「コメント」とかのボタンに被るのは仕方ない。

f:id:mattintosh4:20171022225824p:plain

ひとまず各パーツごとに切り出して、あとから各パーツを -compose Over -compositeで合成するようにした。

#!/bin/bash

convert -alpha on 001.png -crop 657x592+0+47+repage x001.png
convert -alpha on \'(' 001.png -crop 342x208+707+137+repage')'\'('-size 342x208 xc:none -draw'roundrectangle 0,0 341,207 5,5'')'\-compose DstIn -composite x002.png
convert -alpha on \'(' 001.png -crop 88x88+725+421+repage')'\'('-size 88x88 xc:none -draw'roundrectangle 0,0 87,87 8,8'')'\-compose DstIn -composite x003.png
convert -alpha on \'(' 001.png -crop 88x88+833+421+repage')'\'('-size 88x88 xc:none -draw'roundrectangle 0,0 87,87 8,8'')'\-compose DstIn -composite x004.png
convert -alpha on \'(' 001.png -crop 88x88+942+421+repage')'\'('-size 88x88 xc:none -draw'roundrectangle 0,0 87,87 8,8'')'\-compose DstIn -composite x005.png

convert x001.png \-gravity NorthEast \-compose over \
                x002.png -geometry+8+350-composite\
                x003.png -geometry+8+65-composite\
                x004.png -geometry+8+157-composite\
                x005.png -geometry+8+249-composite\
        x006.png

レイアウトが概ね決まったら今度は1コマンド化。よく考えたら土台になる部分(上で言うと x001.png)は切り出す必要がなく、チャートやアイコンを重ねて最後にまとめてクロップしてしまえばよいことに気づいた。

-clone 0でベースの画像を複製して重ねて複製して重ねて…の繰り返し。+clone+swapとか -swap {index},{index}とか使ってやる気だったけど -clone {index}があるんだった…。マジで ImageMagick忘れる…。

#!/bin/bash

convert "${1}" \
  '(' -clone 0 -alpha on -crop 342x208+707+137 '(' -size 342x208 xc:none -draw 'roundrectangle 0,0 341,207 5,5' ')' -compose DstIn -composite -geometry +307+397 ')' -compose Over -composite \
  '(' -clone 0 -alpha on -crop   88x88+725+421 '(' -size 88x88   xc:none -draw 'roundrectangle 0,0 [f:id:mattintosh4:20171022231147j:plain][f:id:mattintosh4:20171022231215j:plain]  87,87 8,8' ')' -compose DstIn -composite -geometry +561+112 ')' -compose Over -composite \
  '(' -clone 0 -alpha on -crop   88x88+833+421 '(' -size 88x88   xc:none -draw 'roundrectangle 0,0   87,87 8,8' ')' -compose DstIn -composite -geometry +561+204 ')' -compose Over -composite \
  '(' -clone 0 -alpha on -crop   88x88+942+421 '(' -size 88x88   xc:none -draw 'roundrectangle 0,0   87,87 8,8' ')' -compose DstIn -composite -geometry +561+296 ')' -compose Over -composite \
  -crop 657x592+0+47 +repage "${1%.*}"_.png

最後の結合もお好みで分割できるようにしたのでだいぶコンパクトになった。

https://cdn-ak2.f.st-hatena.com/images/fotolife/m/mattintosh4/20171022/20171022231601_original.jpg

https://cdn-ak2.f.st-hatena.com/images/fotolife/m/mattintosh4/20171022/20171022231547_original.jpg

https://cdn-ak2.f.st-hatena.com/images/fotolife/m/mattintosh4/20171022/20171022231540_original.jpg

あとは加賀とエンタープライズが来てくれれば…(´;ω;`)

Elasticsearch で電子書籍ランキングを作ってみた Vol.2

$
0
0

前回の続き。

なんだか時間が経つうちにどんどん収集対象が増えてしまった。

ebookman.ga

新しく Table や Chart.js でグラフを追加してみたが、こういったものをボタンポチポチで簡単に出力できる Kibana や Grafana は本当に便利なのだなと感じた。

f:id:mattintosh4:20190225180358p:plain
Amazon

今日は Elasticsearch のデータを JavaScriptでグラフにしたときのことをメモっておく。

Kibana でグラフを作ってみる

まずは Kibana で作りたいグラフのイメージを固める。

グラフを作成するときに X-Axis(Date Histogram)Split Seriesのどちらを前にするかで結果が変わってくる。

Split Series を優先した場合

最初にアイテムを分割し、それらを時系列で並べる。最初に抽出されたアイテムがその時間に通った順位になるので空きが出来ることがあるが、アイテムごとの最初から最後まで状態を追うことが出来る。アイテム数は「分割数」になる。

f:id:mattintosh4:20190225184408p:plain
Kibana - Split Series を優先した場合

X-Axis を優先した場合

ある時間帯のなかで分割をするので順位に空きが出来ることは無いが、分割の範囲外に出てしまったものは線が切れてしまうことがある。時間帯ごとに分割をするのでアイテム数は「分割数 + α」になることがある。

f:id:mattintosh4:20190225184312p:plain
Elasticsearch / Kibana

Elasticsearch のクエリを書いてみる

Split Series を優先した場合のクエリを書いてみるが、慣れないうちは少し大変かもしれない。

例えばこんな感じでデータが入っているとする。

{"@timestamp": "2019-02-25T00:00:00+09:00",
  "book": {"name": "五等分の花嫁",
    "rank": 1}}{"@timestamp": "2019-02-25T01:00:00+09:00",
  "book": {"name": "五等分の花嫁",
    "rank": 2}}

今回は書籍名 book.nameを基点に順位の推移を出していく。

まず、termsで書籍名 book.nameを抽出して、それを date_histogram@timestampごとに分割する。更にそこから minbook.rankの値を取得する。取得したいフィールドを同じレベルで拾うのか、ネストした aggs以下で拾うのかによって意味が変わるのだが最初のうちはなかなか慣れない。

statsというのは数値型のフィールドに対して使えるもので、minavgmaxsumcountを同時に取得することができる。これを上位の termsから参照させることでソートキーとして使えるようになる。

Terms … ❶
|
+-- Stats … ❷
|
+-- Date Histogram … ❸
    |
    +-- Min … ❹

データの取得と整形は Pythonでやっているので Python用のコードをそのまま載せておくが、JSONもだいたい同じ。ポイントとしては termsなどの集計の種類と更にネストする場合の aggsは同じレベルであること。今回は book.nameを基点にしているが、基点は複数指定することもできるので1回のクエリで複数の異なる集計結果を得ることも出来るはず。

query = {
    'from': 0,
    'size': 0,
    'query': {
        'query_string': {
            'query': '抽出条件',
        }
    },
    'aggs': {
        '名前❶': {
            'terms': {
                'field': 'book.name.keyword',
                'order': [
                    { '名前❷.min': 'asc' },
                    { '名前❷.avg': 'asc' },
                    { '名前❷.max': 'asc' },
                ],
                'size': 25,
            },
            'aggs': {
                '名前❷': {
                    'stats': {
                        'field': 'book.rank',
                    }
                },
                '名前❸': {
                    'date_histogram': {
                        'field': '@timestamp',
                        'interval': 'hour',
                    },
                    'aggs': {
                        '名前❹': {
                            'min': {
                                'field': 'book.rank',
                            }
                        }
                    }
                }
            }
        }
    }
}

抽出結果

{"took" : 82,
  "timed_out" : false,
  "_shards" : {"total" : 6,
    "successful" : 6,
    "skipped" : 0,
    "failed" : 0},
  "hits" : {"total" : 200,
    "max_score" : 0.0,
    "hits" : []},
  "aggregations" : {"data" : {"doc_count_error_upper_bound" : -1,
      "sum_other_doc_count" : 196,
      "buckets" : [{"key" : "薬屋のひとりごと 4巻 (デジタル版ビッグガンガンコミックス)",
          "doc_count" : 2,
          "histogram" : {"buckets" : [{"key_as_string" : "2019-02-25T09:00:00.000Z",
                "key" : 1551085200000,
                "doc_count" : 1,
                "rank" : {"value" : 1.0}},
              {"key_as_string" : "2019-02-25T10:00:00.000Z",
                "key" : 1551088800000,
                "doc_count" : 1,
                "rank" : {"value" : 1.0}}]},
          "rank" : {"count" : 2,
            "min" : 1.0,
            "max" : 1.0,
            "avg" : 1.0,
            "sum" : 2.0}},
        {"key" : "たとえばラストダンジョン前の村の少年が序盤の街で暮らすような物語 3巻 (デジタル版ガンガンコミックスONLINE)",
          "doc_count" : 2,
          "histogram" : {"buckets" : [{"key_as_string" : "2019-02-25T09:00:00.000Z",
                "key" : 1551085200000,
                "doc_count" : 1,
                "rank" : {"value" : 2.0}},
              {"key_as_string" : "2019-02-25T10:00:00.000Z",
                "key" : 1551088800000,
                "doc_count" : 1,
                "rank" : {"value" : 2.0}}]},
          "rank" : {"count" : 2,
            "min" : 2.0,
            "max" : 2.0,
            "avg" : 2.0,
            "sum" : 4.0}}]}}}

Chart.js 用に整形する

抽出したデータを今度は Chart.js 用に整形していく。Chart.js の書式は公式のサンプルでは下記のようになっている。

Chart.js Example

var ctx = document.getElementById('myChart').getContext('2d');
var chart = new Chart(ctx, {// The type of chart we want to create
    type: 'line',

    // The data for our dataset
    data: {
        labels: ["January", "February", "March", "April", "May", "June", "July"],
        datasets: [{
            label: "My First dataset",
            backgroundColor: 'rgb(255, 99, 132)',
            borderColor: 'rgb(255, 99, 132)',
            data: [0, 10, 5, 2, 20, 30, 45],
        }]},

    // Configuration options go here
    options: {}});

datasetsの指定方法は値を配列で与える方法と xy で指定する方法があるが、ポイントが決まっているので xy の方を採用する。

Chart.js Example

https://www.chartjs.org/docs/latest/charts/line.html#point

data: [{
        x: 10,
        y: 20
    }, {
        x: 15,
        y: 10
    }]

Elasticsearch で取り出したデータをそのまま Pythonで整形する。Date Histogram にはタイムスタンプが格納された keyタイムゾーン情報付きの key_as_stringがあり、タイムスタンプはミリ秒まで入っているので 1000 で割る。クエリの段階で date_histogram.formatkey_as_stringの書式を指定しておくのもいいかもしれない。詳しくは https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-datehistogram-aggregation.htmlを参照。線色は特に思いつかなかったのでランダムにする。

response = es.search(index=index, body=query)
labels   = []
datasets = []
for top_bucket in response['aggregations']['data']['buckets']:
    d = {
        'label': top_bucket['key'],
        'data': [
            {
                'x': datetime.fromtimestamp(x['key']/1000).strftime('%Y-%m-%d %H:%M'),
                'y': x['rank']['value'],
            } for x in top_bucket['histogram']['buckets']
        ],
        'borderColor': 'rgba({}, {}, {}, 0.8)'.format(*[int(uniform(128, 255)) for i inrange(3)]),
        'borderWidth': 2,
        'fill': False,
    }
    labels += [x['x'] for x in d['data']]
    datasets.append(d)
print(json.dumps({
    'labels': sorted(list(set(labels))),
    'datasets': datasets,
}, ensure_ascii=False)

整形後はこんな感じになる。labelsとか labelとか、慣れないうちは困惑するが、labelsは横軸の名称で datasetsの中の labelは凡例。

{"datasets": [{"borderColor": "rgba(241, 233, 170, 0.8)",
      "borderWidth": 2,
      "data": [{"x": "2019-02-25 21:00",
          "y": 2},
        {"x": "2019-02-25 22:00",
          "y": 2}],
      "fill": false,
      "label": "たとえばラストダンジョン前の村の少年が序盤の街で暮らすような物語 3巻 (デジタル版ガンガンコミックスONLINE)"
    }],
  "labels": ["2019-02-25 21:00",
    "2019-02-25 22:00"
  ]}

Bucket Aggregation 難しい…🤔

www.elastic.co

Elasticsearch とオブジェクト指向。Object datatype と Nested datatype の違い

$
0
0

Elasticsearch のデータタイプには Object datatype と Nested datatype というものがある。

Object datatype | Elasticsearch Reference [6.6] | Elastic

Nested datatype | Elasticsearch Reference [6.6] | Elastic

これを説明する前にオブジェクト指向な考え方について知っておかなければいけないが、これを書いている人間はつい最近まで「オブジェクト指向とはなんぞや?」という人間だったため詳しい人からすると誤った考え方をしているかもしれないのでその辺をご容赦いただいた上で読んでもらいたい。

オブジェクト指向的なデータの作り方

オブジェクト指向的な考え方の場合、データを下記のような構造で作成すると思う。こういうデータ構造が出来ないわけではないし間違いでもない。

{"first_name": "John",
  "last_name": "Smith"
}

ではオブジェクト指向的な考えでデータを入れるならどういう構造になるだろうか?first_namelast_nameも名前を構成する部品なので「名前」にぶら下げるのがいいのだろう。

{"name": {"first": "John",
    "last": "Smith"
  }}

「え、なんか面倒臭くないですか!?」なんて思ったりするかもしれない。この JSONを見るとネストされて若干複雑になっていたりキーも増えているし、デメリットしか無さそうに思える。(そう思っていた時期が私にはありました)

しかし、フィールドへのアクセスはフラットな場合とほとんど変わらない。

name.first
name.last

データの見方を変えてみる

JSONのままではわかりづらいのでそれぞれを表にしてみよう。

最初に書いた非オブジェクト指向的な考え方の場合、フィールドはフラットな関係なのでこんな感じになる。よくある表だ。

オブジェクト指向的な考え方
userfirst_name (string)last_name (string)
John SmithJohnSmith
Alice WhiteAliceWhite

ではオブジェクト指向的な考えで作成した JSONを表にしてみよう。

オブジェクト指向的な考え方①
username (object)
first (string)last (string)
John SmithJohnSmith
Alice WhiteAliceWhite

あるいはこういうイメージの仕方かもしれない。

オブジェクト指向的な考え方②
username (object)
John Smithfirst (string)last (string)
JohnSmith
Alice Whitefirst (string)last (string)
AliceWhite

Excelとかを使う人であればセルの結合はよく使っていると思うのですぐイメージ出来ると思う。非オブジェクト指向的な考え方であってもデータベースでよく見る構造だし間違っちゃいない。でも、オブジェクト指向的な考え方をすることによってそれぞれのフィールドに関連性をもたせることが出来るようになる。

さらに「オブジェクト」というものを意識した場合はこんな感じになるだろうか。「name」フィールドには直接「first」や「last」のデータが入っているわけではなく、「name Object」というものが入っていて、その中に「first」と「last」というフィールドが用意されているのだ。

オブジェクト指向的な構造
username (object)
John Smith
"name" Object
first (string)last (string)
JohnSmith
Alice White
"name" Object
first (string)last (string)
AliceWhite

オブジェクト指向的な構造の場合、

「John Smith さんの『姓』のデータと『名』のデータをちょうだい」

と、2つのお願いをしなくてはいけない。また、我々人間には『姓』と『名』から『名前』という関連性をイメージできるが、機械からすれば『姓』と『名』の関連性はわからないだろう。

オブジェクト指向的な構造であれば

「John Smith さんの『名前』のデータちょうだい」

と、お願いすればあとは自分で好きに出来るし、機械からしても『姓』と『名』がどういうものかはわからないが『名前』というものに紐付いた何かなんだろう、くらいには感じるんじゃなかろうか。

Elasticsearch にネストしたデータを入れてみる

Elasticsearch にネストされたデータを入れた場合、特にマッピングの設定をしていなくても Dynamic templates がよしなにやってくれる。

f:id:mattintosh4:20190226162254p:plain
Kibana の Dev Tools でサンプルデータを投入してみる

PUT my_index/_doc/1
{"name": {"first": "John",
    "last": "Smith"
  }}PUT my_index/_doc/2
{"name": {"first": "Alice",
    "last": "White"
  }}GET my_index/_search

マッピングはこんな感じになる。通常、フィールド名のすぐ下には typeの指定が来るが、ネストしている場合は ❶ の部分に propertiesが来る。これが Elasticsearch で言うところの Object datatypeである。型指定は下層の値を格納する ❷ の部分で設定する。

GET my_index/_mapping
{"my_index" : {"mappings" : {"_doc" : {"properties" : {"name" : {"properties" : {❶
              "first" : {❷
                "type" : "text",
                "fields" : {"keyword" : {"type" : "keyword",
                    "ignore_above" : 256}}},
              "last" : {❷
                "type" : "text",
                "fields" : {"keyword" : {"type" : "keyword",
                    "ignore_above" : 256}}}}}}}}}}

Luceneで検索する場合は親と子のフィールドを .で連結する。

name.first:John AND name.last:Smith

Object datatype と Nested datatype について知る

特にマッピングをせずにデータを投入した場合、ネストされたデータは Object datatypeになる。検索も普通に効くし、特に問題が無いように思われるが、実は投入した _sourceの構造と Elasticsearch の内部での構造が異なってしまうことがある。

例えば、以下のように studentsフィールドを配列にして複数のデータを投入したとする。

PUT my_index/_doc/1
{"students": [{"first": "John",
      "last": "Smith"
    },
    {"first": "Alice",
      "last": "White"
    }]}

これ、入れたとおりの構造になっているかと思いきや、Elasticsearch の内部ではこういう風に解釈されているのである。

{"students": {"first": ["John", "Alice"],
    "last": ["Smith", "White"]}}

students.first:Johnstudents.last:Smithで検索すればちゃんとマッチするし、特に問題ないのでは?と思うが、誤った結果を招くこともある。

例えば、本来は存在しない「John White」という人を検索してみるとする。

"John White"を検索するクエリ

GET my_index/_search
{"query": {"query_string": {"query": "students.first:John AND students.last:White"
    }}}

「John White」という人はいないので結果は0件であることが期待されるが、Elasticsearch からすれば、students.first:Johntrueを返すし、students.last:Whitetrueを返すので先程投入したデータが返ってくる。

{"took" : 17,
  "timed_out" : false,
  "_shards" : {"total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0},
  "hits" : {"total" : 1,
    "max_score" : 0.5753642,
    "hits" : [{"_index" : "my_index",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 0.5753642,
        "_source" : {"students" : [{"first" : "John",
              "last" : "Smith"
            },
            {"first" : "Alice",
              "last" : "White"
            }]}}]}}

このような結果にならないように、データ構造を維持しておけるデータタイプが Nested datatypeである。ネストされたオブジェクトを Nested datatype として扱いたい場合はマッピングnestedを指定する。

PUT my_index/
{"mappings": {"_doc": {"properties": {"students": {"type": "nested"
        }}}}}

「データ構造を維持したまま保存できる Nested datatype の方が Object datatype よりもよいのではないか?」と思うが、Nested datatype では Luceneなどからネストされたフィールドに対して検索が出来ないという制限があるため、student.first:Johnといった感じの気軽な検索が出来なくなる。

Nested datatype のフィールドの検索には Nested Query という方法が用意されていて、使い方としてはネストの親を pathで指定し、そこを基点に検索するという感じ。

では、nested されたフィールドに検索をかけて誤った検索結果が返ってこないか試してみよう。

"Nested Query"のサンプル

# JohnSmithの検索
GET my_index/_search
{"query": {"nested": {"path": "students",
      "query": {"query_string": {"query": "students.first:John AND students.last:Smith"
        }}}}}

# JohnWhiteの検索(存在しないので何も出ない)
GET my_index/_search
{"query": {"nested": {"path": "students",
      "query": {"query_string": {"query": "students.first:John AND students.last:White"
        }}}}}

きっと期待通りの結果が得られると思う。

Kibana での見え方

Kibana では「配列内のオブジェクトはうまくサポートしない」と表示されるが、この表示は Object datatype も Nested datatype も同じ。

f:id:mattintosh4:20190226205542p:plain
Kibana - 配列内のオブジェクトの扱い

Index Patterns を見ても下層のフィールドはきちんと登録されるので nestedになっているかどうかはマッピング情報を見ないとわからない。

f:id:mattintosh4:20190226233544p:plain
Kibana - Index Patterns

Nested datatype のメリットを活かしつつ Luceneも使いたい

Nested datatype で Luceneが使えないということは Kibana の検索バーなどからも検索が出来なくなってしまうということであり、これは結構痛い。(これは Grafana も同様だったが、こちらは issue に要望が上がっていたので近々サポートされるかもしれない)

対応策として、copy_toでトップレベルの任意のフィールドに値をコピーしておくという方法がある。

下記は students.firstfirst_namesに、students.lastlast_namesにコピーするマッピングの例。

PUT my_index/
{"mappings": {"_doc": {"properties": {"students": {"type": "nested",
          "properties": {"first": {"type": "text",
              "copy_to": "first_names", ❶
              "fields": {"keyword": {"type": "keyword",
                  "ignore_above": 256}}},
            "last": {"type": "text",
              "copy_to": "last_names", ❷
              "fields": {"keyword": {"type": "keyword",
                  "ignore_above": 256}}}}}}}}}

ツリーで書くとこんな感じだろうか。

_doc
|
+-- students
|   |
|   +-- [0]
|   |   |
|   |   +-- first -> copy_to: first_names ❶
|   |   |
|   |   +-- last  -> copy_to: last_names ❷
|   |
|   +-- [1]
|       |
|       +-- first -> copy_to: first_names ❶
|       |
|       +-- last  -> copy_to: last_names ❷
|
+-- first_names ❶
|
+-- last_names ❷

名前のデータを入れてからマッピングを見てみると _sourceには存在しない first_nameslast_namesのフィールドが増えているのがわかる。

{"my_index" : {"mappings" : {"_doc" : {"properties" : {"first_names" : {"type" : "text",
            "fields" : {"keyword" : {"type" : "keyword",
                "ignore_above" : 256}}},
          "last_names" : {"type" : "text",
            "fields" : {"keyword" : {"type" : "keyword",
                "ignore_above" : 256}}},
          "students" : {"type" : "nested",
            "properties" : {"first" : {"type" : "text",
                "fields" : {"keyword" : {"type" : "keyword",
                    "ignore_above" : 256}},
                "copy_to" : ["first_names"
                ]},
              "last" : {"type" : "text",
                "fields" : {"keyword" : {"type" : "keyword",
                    "ignore_above" : 256}},
                "copy_to" : ["last_names"
                ]}}}}}}}}

表にするとこんな感じだろうか。

my_index/_doc
students (object array)first_names (array)last_names (array)
Object[0]
first (string)last (string)
JohnSmith
Object[1]
first (string)last (string)
AliceWhite
  • John
    -> /students[0]/first
  • Alice
    -> /students[1]/first
  • Smith
    -> /students[0]/last
  • White
    -> /students[1]/last

Kibana で検索する場合は copy_toで作成したフィールドに対して検索をかければよい。

first_names:John

余談だが、copy_toはコピー先を複数指定することができるので、例えば姓と名の両方を集約したフィールドを作成することもできる。下記のように「❶ 名前検索」「❷ 名字検索」、用のフィールドに加えて「❸ 氏名検索」用のフィールドへコピーしてあげればよい。

        :
        "students": {"type": "nested",
          "properties": {"first": {"type": "text",
              "copy_to": ["first_names", ❶
                "full_names" ❸
              ],
              "fields": {"keyword": {"type": "keyword",
                  "ignore_above": 256}}},
            "last": {"type": "text",
              "copy_to": ["last_names", ❷
                "full_names" ❸
              ],
              "fields": {"keyword": {"type": "keyword",
                  "ignore_above": 256}}}}}
        :

メモ:copy_to のデータの取り出し方

copy_toで作成したフィールドは _sourceに含まれないので script_fieldsまたは docvalue_fieldsで取り出す必要がある。

この2つはデフォルトの _sourceの返し方が異なるので必要に応じて設定しておく。また、取り出したフィールドは _source内の配列順とは限らないので注意。

リクエスト方法_source
scripted_fieldsfalse
docvalue_fieldstrue

scripted_field を使って copy_to のコピー先のフィールドを出力するクエリ

GET my_index/_search
{"_source": true,
  "script_fields": {"任意のフィールド名": {"script": {"source": "doc['first_names.keyword'].values"
      }}}}
{"took" : 38,
  "timed_out" : false,
  "_shards" : {"total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0},
  "hits" : {"total" : 1,
    "max_score" : 1.0,
    "hits" : [{"_index" : "my_index",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 1.0,
        "_source" : {"students" : [{"first" : "John",
              "last" : "Smith"
            },
            {"first" : "Alice",
              "last" : "White"
            }]},
        "fields" : {"任意のフィールド名" : ["Alice",
            "John"
          ]}}]}}

docvalue_fieldsでは fieldをそのまま指定すればいい。formatには use_field_mappingを指定しておけばマッピングを元に決めてくれる。

docvalue_fields を使って copy_to のコピー先のフィールドを出力するクエリ

GET my_index/_search
{"_source": false, 
  "docvalue_fields": [{"field": "first_names.keyword",
      "format": "use_field_mapping"
    },
    {"field": "last_names.keyword",
      "format": "use_field_mapping"
    }]}
{"took" : 13,
  "timed_out" : false,
  "_shards" : {"total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0},
  "hits" : {"total" : 1,
    "max_score" : 1.0,
    "hits" : [{"_index" : "my_index",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 1.0,
        "fields" : {"first_names.keyword" : ["Alice",
            "John"
          ],
          "last_names.keyword" : ["Smith",
            "White"
          ]}}]}}

それでは良い Elasticsearch ライフを😊

DNS サーバのログを Elasticsearch と Kibana で可視化する

$
0
0

自宅で DNSサーバに Unbound を使っているのだけど立ててるだけで特に監視していないので何か遊んでみようと考えた。

久しぶりに Fluentd を使おうと思ったらバージョンが変わっていて conf の書式にハマった。Elasticsearch は Raspberry Piで動かしているが Unbound のログの量が多いので調整はまだ続きそう…(´・ω・`)

  • Unbound 1.9.0
  • fluentd 1.4.0
  • Python 3.5.3
  • Elasticsearch 6.5.2
  • Kibana 6.5.2

Fluentd tail Input Plugin で Unbound のログを解析する

まずは Unbound のログの解析。ログファイルから tail プラグインを使って読み込む。

https://docs.fluentd.org/v1.0/articles/in_tail

Unbound のログは log-queriesを有効にしておいて infoqueryreplyの区別が付くようにしておく。

/etc/unbound/unboud.conf

log-queries: yes

Unbound のログは以下のような書式になる。ここからタイムスタンプ、クライアント、ホストを取り出していく。

/var/log/unbound/unbound.log

[1551614551] unbound[24609:0] query: 192.168.1.10 ssl.gstatic.com. A IN

fluent.conf

Unbound のログをすべて解析するわけではないので、パターンに一致しない行が pattern not match でダラダラと出てくるので @log_level errorで出力を抑制しておく。

<source>
  @type       tail
  @log_level  error
  tag         unbound.log
  path        /var/log/unbound/unbound.log
  pos_file    /tmp/fluentd_unbount.log.pos
  <parse>
    @type       regexp
    expression  ^\[(?<time>[0-9]+)\] unbound\[.+\] query: (?<client>[^ ]+) (?<host>[^ ]+)\. A IN$
    time_key    time
    time_type   unixtime
  </parse>
</source>

# 出力確認用
<filter unbound.log>
  @type stdout
</filter>

🤔 @timestamp フィールドを作るか作らないか

Elasticsearch プラグインlogstash_formatを有効にする場合、プラグイン側で自動的に @timestampフィールドを作成してくれるのでここで用意しておく必要はない。外部フィルターで時間に対して何かしらの処理をしたい場合や logstash_formatを使わない場合はここで @timestampフィールドを作成しておくといいかもしれない。

🤔 時間フィールドが消えてしまう問題

time_keyで時間として指定したフィールドはデフォルトだと消えてしまうので残しておきたい場合は keep_time_keyを有効にする。

🤔 数値が数値型ではなく文字列型になってしまう問題

キーの型を指定したい場合は typesキー名:型と指定する必要があるようだ。ここでは数値型にしたいので @timestamp:integerとする。

tail Input Plugin の出力結果

2019-03-03 21:02:31.000000000 +0900 unbound.log: {"client":"192.168.1.10","host":"ssl.gstatic.com"}

exec_filter でホスト名から国や緯度経度情報を取得するフィルターを作る

以前は GeoIPモジュールを使っていたけど今回は GeoLite2-City.mmdb を使いたかったので maxminddbモジュールを使うことにした。GeoIPだと record_by_name()でホスト名から情報を拾えるんだけど maxminddbget()で IP アドレスを渡すくらいしかできないので socket.gethostbyname()を通して IP アドレスを渡している。

#!/usr/bin/env python3import sys
import json
import socket
import maxminddb
reader = maxminddb.open_database('GeoLite2-City.mmdb')
for line in sys.stdin:
    d = json.loads(line)
    g = reader.get(socket.gethostbyname(d['host']))
    d.update({
        'country': {
            'iso_code': g['country']['iso_code'],
        },
        'location': {
            'lat': g['location']['latitude'],
            'lon': g['location']['longitude'],
        },
    })
    sys.stdout.write(json.dumps(d))

fluent.conf

GeoIP フィルターとやりとりする部分を書いていく。バッファはメモリに配置。<format></format><parse></parse>が以前のバージョンになかったので困った。

Elasticsearch プラグインlogstash_formatを使う場合、外部フィルターから返ってきた JSONから再度時間を抽出する必要がある。

<match unbound.log>
  @type   exec_filter
  tag     exec.unbound
  command /usr/bin/python3 geoip.py
  <format>
    @type json
  </format>
  <parse>
    @type json
  </parse>
  <inject>
    time_key  @timestamp
    time_type unixtime
  </inject>
  <buffer>
    @type memory
  </buffer>
</match>

Elasticsearch のマッピングをする

Fluentd から Logstash 形式でデータを投入する場合、日時でインデックスが作成されるが、マッピングの設定ができない。今回は緯度経度情報を扱うため、geo_pointの指定が必須になる。そこで、テンプレートを用意して自動的に適用されるようにしておく。データ量が多いので refresh_intervalをデフォルトの 1sから 30sに変更。string 型のデータは解析する必要もないので keywordで入るようにしておく。

@timestampepoch_secondを使う場合はここで設定しておけばよい。

PUT _template/unbound{"index_patterns": "unbound-*",
  "settings": {"number_of_shards": 1,
    "number_of_replicas": 0,
    "refresh_interval": "30s"
  },
  "mappings": {"_doc": {"_all": {"enabled": false},
      "dynamic_templates": [{"strings": {"match_mapping_type": "string",
            "mapping": {"type": "keyword"
            }}},
        {"geo_point": {"match": "location",
            "mapping": {"type": "geo_point"
            }}}]}}}

Fluentd から Elasticsearch にデータを投入する部分を書く

データ量が多いせいか1時間ほどで Elasticsearch にデータが入らなくなってしまうので request_timeoutをデフォルトの 5sから 30sに増やしてある。(他は調整中)

今回は logstash_formatを有効にするのでレコードの @timestampフィールドは自動的に作成される。

<match exec.unbound>
  @type           elasticsearch
  hosts           localhost:9200
  type_name       _doc
  logstash_format true
  logstash_prefix unbound
  request_timeout 30s
  <buffer>
    flush_thread_count  4
    chunk_limit_records 200
  </buffer>
</match>

Elasticsearch に投入されたデータ

{"_index": "unbound-2019.03.03",
  "_type": "_doc",
  "_id": "bbb0Q2kBVp0AiN9Y-bd0",
  "_score": 1,
  "_source": {"client": "127.0.0.1",
    "location": {"lon": -97.822,
      "lat": 37.751},
    "host": "www.elastic.co",
    "country": {"iso_code": "US"
    },
    "@timestamp": "2019-03-03T23:28:41.234223023+09:00"
  },
  "fields": {"@timestamp": ["2019-03-03T14:28:41.234Z"
    ]}}

Kibana でダッシュボードを作る

Fluentd から送られてきたデータを Coordinate Map、Heat Map、Line で可視化する。

こうして見てみるとただブラウザで調べごとをしていたりするだけでも案外いろんな国にまで行っているのだなぁと感じる。

f:id:mattintosh4:20190304000151p:plain
Kibana - Dashboard

しばらく監視してみておかしなサイトに繋ぎに行ってないかとか発見出来れば面白いかな。

プロキシサーバのログも解析したいけど今日はもう疲れたのでまた今度にする…( ˘ω˘)スヤァ


Python GeoIP 系のメモ

$
0
0

最近 Pythonで GeoIP を使うことが多いけど、なんか色々種類があってわからなくなってきたのでちょっとまとめておく。

  • GeoIP2
  • maxminddb
  • GeoIP

データベースファイル(GeoLite2-City.mmdb)は下記からダウンロードできる。

GeoIP2

GeoLite2-City.mmdb が使えるモジュール。インストールは aptまたは pipから。

Console

sudo apt install python3-geoip2
pip3 install geoip2

Readerクラスのインスタンスを作って city()で IP アドレスを渡せばよい。Readerクラスは __init__(self, fileish, locales=None, mode=0)となっているので modeに以下のいずれかを渡すことでモードの指定が可能。

MODE_MMAP_EXT - use the C extension with memory map.
MODE_MMAP     - read from memory map. Pure Python.
MODE_FILE     - read database as standard file. Pure Python.
MODE_MEMORY   - load database into memory. Pure Python.
MODE_FD       - the param passed via fileish is a file descriptor, not a path. This mode implies MODE_MEMORY. Pure Python.
MODE_AUTO     - try MODE_MMAP_EXT, MODE_MMAP, MODE_FILE in that order. Default.

書き方は2種類かな?

Python3

import geoip2.database
reader = geoip2.database.Reader('GeoLite2-City.mmdb')
response = reader.city('54.70.157.111')
reader.close()

Python3

import geoip2.database
with geoip2.database.Reader('GeoLite2-City.mmdb') as reader:
    response = reader.city('54.70.157.111')

戻り値は geoip2.models.Cityオブジェクト。

Response

<class'geoip2.models.City'>
geoip2.models.City({'country': {'names': {'ru': 'США', 'es': 'Estados Unidos', 'fr': 'États-Unis', 'zh-CN': '美国', 'en': 'United States', 'pt-BR': 'Estados Unidos', 'de': 'USA', 'ja': 'アメリカ合衆国'}, 'geoname_id': 6252001, 'iso_code': 'US'}, 'location': {'accuracy_radius': 1000, 'time_zone': 'America/Los_Angeles', 'metro_code': 810, 'longitude': -119.7143, 'latitude': 45.8491}, 'continent': {'names': {'ru': 'Северная Америка', 'es': 'Norteamérica', 'fr': 'Amérique du Nord', 'zh-CN': '北美洲', 'en': 'North America', 'pt-BR': 'América do Norte', 'de': 'Nordamerika', 'ja': '北アメリカ'}, 'code': 'NA', 'geoname_id': 6255149}, 'traits': {'ip_address': '54.70.157.111'}, 'registered_country': {'names': {'ru': 'США', 'es': 'Estados Unidos', 'fr': 'États-Unis', 'zh-CN': '美国', 'en': 'United States', 'pt-BR': 'Estados Unidos', 'de': 'USA', 'ja': 'アメリカ合衆国'}, 'geoname_id': 6252001, 'iso_code': 'US'}, 'subdivisions': [{'names': {'ru': 'Орегон', 'es': 'Oregón', 'fr': 'Oregon', 'zh-CN': '俄勒冈州', 'en': 'Oregon', 'pt-BR': 'Oregão', 'de': 'Oregon', 'ja': 'オレ ゴン州'}, 'geoname_id': 5744337, 'iso_code': 'OR'}], 'postal': {'code': '97818'}, 'city': {'names': {'ru': 'Бордман', 'en': 'Boardman'}, 'geoname_id': 5714964}}, ['en'])

各キーに入っている値を見て見ると geoip2.recordsというオブジェクトで入っている。(_localesを除いて)rawは辞書で入っているので全体が欲しければこれを取り出すのが簡単。

Python Console

>>> pprint({k: type(v) for k, v in response.__dict__.items()})
{'_locales': <class'list'>,
 'city': <class'geoip2.records.City'>,
 'continent': <class'geoip2.records.Continent'>,
 'country': <class'geoip2.records.Country'>,
 'location': <class'geoip2.records.Location'>,
 'maxmind': <class'geoip2.records.MaxMind'>,
 'postal': <class'geoip2.records.Postal'>,
 'raw': <class'dict'>,
 'registered_country': <class'geoip2.records.Country'>,
 'represented_country': <class'geoip2.records.RepresentedCountry'>,
 'subdivisions': <class'geoip2.records.Subdivisions'>,
 'traits': <class'geoip2.records.Traits'>}

locationには latitudelongitude以外にもデータが入っているので Kibana で geo_pointとして扱う場合は編集が必要。また、Region Map を使って都道府県別にマッピングしたい場合は {国コード}-{区域コード}を組み合わせるので {country.iso_code}-{subdivisions.iso_code}(例: JP-01)みたいになるのだけど、subdivisionsのキーが存在しないことがあるので確認した方がいいかも。

response.raw

{'city': {'geoname_id': 5714964, 'names': {'en': 'Boardman', 'ru': 'Бордман'}},
 'continent': {'code': 'NA',
               'geoname_id': 6255149,
               'names': {'de': 'Nordamerika',
                         'en': 'North America',
                         'es': 'Norteamérica',
                         'fr': 'Amérique du Nord',
                         'ja': '北アメリカ',
                         'pt-BR': 'América do Norte',
                         'ru': 'Северная Америка',
                         'zh-CN': '北美洲'}},
 'country': {'geoname_id': 6252001,
             'iso_code': 'US',
             'names': {'de': 'USA',
                       'en': 'United States',
                       'es': 'Estados Unidos',
                       'fr': 'États-Unis',
                       'ja': 'アメリカ合衆国',
                       'pt-BR': 'Estados Unidos',
                       'ru': 'США',
                       'zh-CN': '美国'}},
 'location': {'accuracy_radius': 1000,
              'latitude': 45.8491,
              'longitude': -119.7143,
              'metro_code': 810,
              'time_zone': 'America/Los_Angeles'},
 'postal': {'code': '97818'},
 'registered_country': {'geoname_id': 6252001,
                        'iso_code': 'US',
                        'names': {'de': 'USA',
                                  'en': 'United States',
                                  'es': 'Estados Unidos',
                                  'fr': 'États-Unis',
                                  'ja': 'アメリカ合衆国',
                                  'pt-BR': 'Estados Unidos',
                                  'ru': 'США',
                                  'zh-CN': '美国'}},
 'subdivisions': [{'geoname_id': 5744337,
                   'iso_code': 'OR',
                   'names': {'de': 'Oregon',
                             'en': 'Oregon',
                             'es': 'Oregón',
                             'fr': 'Oregon',
                             'ja': 'オレゴン州',
                             'pt-BR': 'Oregão',
                             'ru': 'Орегон',
                             'zh-CN': '俄勒冈州'}}],
 'traits': {'ip_address': '54.70.157.111'}}

ついでに geoip2.models.Cityオブジェクトの属性も見てみる。

Python Console

>>> print(*dir(response), sep='\n')
__class__
__delattr__
__dict__
__dir__
__doc__
__eq__
__format__
__ge__
__getattribute__
__gt__
__hash__
__init__
__le__
__lt__
__metaclass__
__module__
__ne__
__new__
__reduce__
__reduce_ex__
__repr__
__setattr__
__sizeof__
__str__
__subclasshook__
__weakref__
_locales
city
continent
country
location
maxmind
postal
raw
registered_country
represented_country
subdivisions
traits

🤔 ホスト名はわかるけど IP アドレスがわからない

socket.gethostbyname()で変換するとか。

Python3

import socket
ip = socket.gethostbyname('elastic.co')

Response

'54.70.157.111'

dnspythonを使うとか。こっちは nameserversで問い合わせ先の変更ができる。戻り値は配列。

Python3

import dns.resolver
resolver = dns.resolver.Resolver()
resolver.nameservers = ['1.1.1.1']
response = resolver.query('elastic.co')
ips = [x.address for x in response]

Response

['52.11.225.213', '54.70.157.111']

maxminddb

GeoIP2 よりもシンプル。

Console

sudo apt install python3-maxminddb
pip3 install maxminddb

Reader()または open_database()でデータベースファイルのパスを与えてインスタンスを作成する。open_database()ではモードの指定が出来る(モードについては help(maxminddb)参照)。

Python3

from pprint import pprint
import maxminddb
reader = maxminddb.Reader('GeoLite2-City.mmdb')
# reader = maxminddb.open_database('GeoLite2-City.mmdb', maxminddb.MODE_MMAP
response = reader.get('54.70.157.111')
print(type(response))
pprint(response)

戻り値は辞書型。

Response

<class'dict'>

出力は GeoIP2 の rawとほぼ一緒。ただし、問い合わせた IP アドレスは含まれない。

Response

{'city': {'geoname_id': 5714964, 'names': {'en': 'Boardman', 'ru': 'Бордман'}},
 'continent': {'code': 'NA',
               'geoname_id': 6255149,
               'names': {'de': 'Nordamerika',
                         'en': 'North America',
                         'es': 'Norteamérica',
                         'fr': 'Amérique du Nord',
                         'ja': '北アメリカ',
                         'pt-BR': 'América do Norte',
                         'ru': 'Северная Америка',
                         'zh-CN': '北美洲'}},
 'country': {'geoname_id': 6252001,
             'iso_code': 'US',
             'names': {'de': 'USA',
                       'en': 'United States',
                       'es': 'Estados Unidos',
                       'fr': 'États-Unis',
                       'ja': 'アメリカ合衆国',
                       'pt-BR': 'Estados Unidos',
                       'ru': 'США',
                       'zh-CN': '美国'}},
 'location': {'accuracy_radius': 1000,
              'latitude': 45.8491,
              'longitude': -119.7143,
              'metro_code': 810,
              'time_zone': 'America/Los_Angeles'},
 'postal': {'code': '97818'},
 'registered_country': {'geoname_id': 6252001,
                        'iso_code': 'US',
                        'names': {'de': 'USA',
                                  'en': 'United States',
                                  'es': 'Estados Unidos',
                                  'fr': 'États-Unis',
                                  'ja': 'アメリカ合衆国',
                                  'pt-BR': 'Estados Unidos',
                                  'ru': 'США',
                                  'zh-CN': '美国'}},
 'subdivisions': [{'geoname_id': 5744337,
                   'iso_code': 'OR',
                   'names': {'de': 'Oregon',
                             'en': 'Oregon',
                             'es': 'Oregón',
                             'fr': 'Oregon',
                             'ja': 'オレゴン州',
                             'pt-BR': 'Oregão',
                             'ru': 'Орегон',
                             'zh-CN': '俄勒冈州'}}]}

GeoIP

(古い?)dat 形式のデータベースを使う方。new()または open()インスタンスを作成する。 GeoIP.open()の場合はデータベースファイルとモードを指定する。GeoIP2 と異なり、IP アドレスだけでなく record_by_name()でホスト名を与えることが出来る。

Python3

from pprint import pprint
import GeoIP
gi = GeoIP.open('/usr/share/GeoIP/GeoIPCity.dat', GeoIP.GEOIP_MEMORY_CACHE)
response = gi.record_by_name('elastic.co')
# response = gi.record_by_addr('54.70.157.111')print(type(response))
pprint(response)

戻り値は辞書型。

Response

<class'dict'>

GeoIP2 に比べると情報が少ないが、country_code3を持っていたり、緯度経度情報が細かったりする。(ただしこれは日本の場合は都庁や皇居などであることが多く、ピンポイントで建物を示しているわけではない)

Response

{'area_code': 541,
 'city': 'Boardman',
 'country_code': 'US',
 'country_code3': 'USA',
 'country_name': 'United States',
 'dma_code': 810,
 'latitude': 45.869598388671875,
 'longitude': -119.68800354003906,
 'metro_code': 810,
 'postal_code': '97818',
 'region': 'OR',
 'region_name': 'Oregon',
 'time_zone': 'America/Los_Angeles'}

AWS Elasticache で作成した Redis に外部から接続したい

$
0
0

Heroku Redis を使っている方から「Redis だけ AWSを利用出来ないか?」というご相談をいただいたので検証してみた。

本記事は接続検証を目的としたものです。本記事に起因して発生したいかなるトラブルや損害等について当方は一切責任を負いません。

事前調査

  • Elasticache のエンドポイントには EIP を付与することはできない。
  • Elasticache で作成したエンドポイントにはパブリック IP が付与できないので EC2 インスタンスのように外から簡単にアクセス出来るようにはなっていない。
  • Network Load Balancer を使えば TCPのロードバランシングも出来るが、ターゲットには IP アドレスか EC2 インスタンスしか選択できないので Elasticache のエンドポイントが指定できない。(プライマリのプライベート IP アドレス決め打ちなら出来るかもしれないが問題が起きた時に変更するの面倒)

というわけでリバースプロキシを立てることにして、今回は HAProxyメインで、NGINX でも検証してみる。透過プロキシは設定が面倒なので普通のリバースプロキシで。

AWSの構成

VPC

パブリックサブネット1つとプライベートサブネットを1つ作成する。パブリックサブネットに EC2 インスタンスを配置、Elasticache のサブネットグループでプライベートサブネットを指定する。

EIP

EC2 インスタンス用に1つ作成しておく。(グローバル IP が変わってもいいなら不要)

セキュリティグループ

EC2 インスタンス用と Redis 用の2つを用意する。HAProxy で 6379 番ポートを待ち受けるのは危険なので Redis のデフォルトから変更してソースもマイ IP で絞り込んでおく。

EC2 インスタンス

タイププロトコルポート範囲ソース
SSHTCP22マイ IP
カスタム TCPルールTCP16379マイ IP

Redis 用

タイププロトコルポート範囲ソース
カスタム TCPルールTCP6379↑で作った EC2 インスタンス用のセキュリティグループ

EC2 インスタンス

普通の EC2 インスタンスを作成する。AmazonLinux系はバージョンがかなり古いので Ubuntuが良いかもね。SSL対応は HAProxy 1.5 からなので AmazonLinuxも一応対応してる。

amzn2-ami-hvm-2.0.20190228-x86_64-gp2 (ami-097473abce069b8e9)

HA-Proxy version 1.5.18 2016/05/10
Copyright 2000-2016 Willy Tarreau <willy@haproxy.org>

Build options :
  TARGET  = linux2628
  CPU     = generic
  CC      = gcc
  CFLAGS  = -O2 -g -fno-strict-aliasing -DTCP_USER_TIMEOUT=18
  OPTIONS = USE_LINUX_TPROXY=1 USE_ZLIB=1 USE_REGPARM=1 USE_OPENSSL=1 USE_PCRE=1

Default settings :
  maxconn = 2000, bufsize = 16384, maxrewrite = 8192, maxpollevents = 200

Encrypted password support via crypt(3): yes
Built with zlib version : 1.2.7
Compression algorithms supported : identity, deflate, gzip
Built with OpenSSL version : OpenSSL 1.0.2k-fips  26 Jan 2017
Running on OpenSSL version : OpenSSL 1.0.2k-fips  26 Jan 2017
OpenSSL library supports TLS extensions : yes
OpenSSL library supports SNI : yes
OpenSSL library supports prefer-server-ciphers : yes
Built with PCRE version : 8.32 2012-11-30
PCRE library supports JIT : no (USE_PCRE_JIT not set)
Built with transparent proxy support using: IP_TRANSPARENT IPV6_TRANSPARENT IP_FREEBIND

Available polling systems :
      epoll : pref=300,  test result OK
       poll : pref=200,  test result OK
     select : pref=150,  test result OK
Total: 3 (3 usable), will use epoll.

ubuntu/images/hvm-ssd/ubuntu-bionic-18.04-amd64-server-20190212.1 (ami-0eb48a19a8d81e20b)

HA-Proxy version 1.8.8-1ubuntu0.4 2019/01/24
Copyright 2000-2018 Willy Tarreau <willy@haproxy.org>

Build options :
  TARGET  = linux2628
  CPU     = generic
  CC      = gcc
  CFLAGS  = -g -O2 -fdebug-prefix-map=/build/haproxy-Mxbbv4/haproxy-1.8.8=. -fstack-protector-strong -Wformat -Werror=format-security -Wdate-time -D_FORTIFY_SOURCE=2
  OPTIONS = USE_GETADDRINFO=1 USE_ZLIB=1 USE_REGPARM=1 USE_OPENSSL=1 USE_LUA=1 USE_SYSTEMD=1 USE_PCRE=1 USE_PCRE_JIT=1 USE_NS=1

Default settings :
  maxconn = 2000, bufsize = 16384, maxrewrite = 1024, maxpollevents = 200

Built with OpenSSL version : OpenSSL 1.1.0g  2 Nov 2017
Running on OpenSSL version : OpenSSL 1.1.0g  2 Nov 2017
OpenSSL library supports TLS extensions : yes
OpenSSL library supports SNI : yes
OpenSSL library supports : TLSv1.0 TLSv1.1 TLSv1.2
Built with Lua version : Lua 5.3.3
Built with transparent proxy support using: IP_TRANSPARENT IPV6_TRANSPARENT IP_FREEBIND
Encrypted password support via crypt(3): yes
Built with multi-threading support.
Built with PCRE version : 8.39 2016-06-14
Running on PCRE version : 8.39 2016-06-14
PCRE library supports JIT : yes
Built with zlib version : 1.2.11
Running on zlib version : 1.2.11
Compression algorithms supported : identity("identity"), deflate("deflate"), raw-deflate("deflate"), gzip("gzip")
Built with network namespace support.

Available polling systems :
      epoll : pref=300,  test result OK
       poll : pref=200,  test result OK
     select : pref=150,  test result OK
Total: 3 (3 usable), will use epoll.

Available filters :
        [SPOE] spoe
        [COMP] compression
        [TRACE] trace

Elasticache Redis

クラスタ構成無しで作成。HAProxy が SSLオフローダーになるので SSL対応不要な気がするけど実験なので「送信中の暗号化」を有効にしておく。他は以下のように設定。

  • 疎通確認なので「Redis AUTH」は使わない。
  • サブネットグループにはプライベートサブネットを選択。
  • EC2 に付与してあるセキュリティグループからのみ 6379 番ポートのインバウンド接続を受け付けるセキュリティグループを用意する。

f:id:mattintosh4:20190313191141p:plain
AWS Elasticache

HAProxy の設定

まずはインストールから。

AmazonLinux

yum install haproxy
service haproxy start
chkconfig haproxy on

Ubuntu

apt install haproxy

設定はどちらも /etc/haproxy/haproxy.cfgくらいしか使わない。

自己署名証明書を作成する

HAProxy を SSL対応させるために証明書を作成するんだけど実験なので自己署名で作成する。ドメイン持ってるなら Let's Encrypt で作成した privkey.pemfullchain.pemを結合したものを用意すれば良い。

ファイルの配置は以下のようにするけど人それぞれ。

/etc/haproxy/
|
+-- haproxy.cfg
|
+-- ssl/
    |
    +-- haproxy.pem

自己署名なので openssl reqコマンドで作成するけどいつも忘れるのでここらでちゃんと覚えておく。

openssl req -help

Usage: req [options]
Valid options are:
 -help               Display this summary
 -inform PEM|DER     Input format - DER or PEM
 -outform PEM|DER    Output format - DER or PEM
 -in infile          Input file
 -out outfile        Output file
 -key val            Private key to use
 -keyform format     Key file format
 -pubkey             Output public key
 -new                New request
 -config infile      Request template file
 -keyout outfile     File to send the key to
 -passin val         Private key password source
 -passout val        Output file pass phrase source
 -rand val           Load the file(s) into the random number generator
 -newkey val         Specify as type:bits
 -pkeyopt val        Public key options as opt:value
 -sigopt val         Signature parameter in n:v form
 -batch              Do not ask anything during request generation
 -newhdr             Output "NEW" in the header lines
 -modulus            RSA modulus
 -verify             Verify signature on REQ
 -nodes              Don't encrypt the output key
 -noout              Do not output REQ
 -verbose            Verbose output
 -utf8               Input characters are UTF8 (default ASCII)
 -nameopt val        Various certificate name options
 -reqopt val         Various request text options
 -text               Text form of request
 -x509               Output a x509 structure instead of a cert request
                     (Required by some CA's)
 -subj val           Set or modify request subject
 -subject            Output the request's subject
 -multivalue-rdn     Enable support for multivalued RDNs
 -days +int          Number of days cert is valid for
 -set_serial val     Serial number to use
 -extensions val     Cert extension section (override value in config file)
 -reqexts val        Request extension section (override value in config file)
 -*                  Any supported digest
 -engine val         Use engine, possibly a hardware device
 -keygen_engine val  Specify engine to be used for key generation operations

使うオプションは以下の通り。対話形式で作成したいなら -subjは省略。

オプション動作
-new新規リクエス
-x509証明書要求の代わりに X509 構造体を出力する
-nodes鍵を暗号化しない(パスワード不要で作成する)
-newkey type:bits鍵のタイプを指定する(例:rsa:2048
-keyout /path/to/file鍵の出力先
-out /path/to/file出力先(-x509と併用しているので証明書の出力先)
-subj val対話形式での回答を省略
-days val証明書の有効期間を指定

Common Name を EIP の IP アドレスにしておく。秘密鍵と証明書を分けて作成してあとから結合してもいいけど、実験なので最初からまとめてしまう。

install -d-m0700 /etc/haproxy/ssl
openssl req -new-x509-nodes\-newkey rsa:2048\-keyout /etc/haproxy/ssl/haproxy.pem \-out /etc/haproxy/ssl/haproxy.pem \-subj"/C=JP/CN=192.168.1.254"\-days365chmod0600 /etc/haproxy/ssl/haproxy.pem

HAProxy の SSL設定

AmazonLinuxUbuntuでバージョンが異なるため初期設定も異なる。以下には必要なところだけ記載しているので詳しくは各バージョンごとの公式ドキュメントを参照。frontendブロックで入力を作って backendで出力を作ってやればよい。

/etc/haproxy/haproxy.cfg

global
  ssl-default-bind-ciphers ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!MD5:!DSS
# ssl-default-bind-options no-sslv3# tune.ssl.default-dh-param 2048

defaults
  log    global
  mode   tcp
  option tcplog
  option dontlognull

## 非 SSL 用設定例
frontend nossl
  bind *:16379
  default_backend redis-nossl

backend redis-nossl
  server redis-1 名前.apne1.cache.amazonaws.com:6379 check

## SSL 用設定例(フロントエンド、バックエンドともに SSL 対応)
frontend ssl
  bind *:16380 ssl crt /etc/haproxy/ssl/haproxy.pem
  default_backend redis-ssl

backend redis-ssl
  server redis-1 master.名前.apne1.cache.amazonaws.com:6379 check ssl verify none

AmazonLinuxの場合、ssl-default-bind-options no-sslv3だとオプションが存在しないためエラーになる。また、tune.ssl.default-dh-param 2048を書いておかないと起動時に警告がでる。

AmazonLinuxUbuntuの haproxy.cfg のソース

インスタンス作って確認するの面倒なので残しておく。

AmazonLinux

#---------------------------------------------------------------------# Example configuration for a possible web application.  See the# full configuration options online.##   http://haproxy.1wt.eu/download/1.4/doc/configuration.txt##---------------------------------------------------------------------#---------------------------------------------------------------------# Global settings#---------------------------------------------------------------------
global
    # to have these messages end up in /var/log/haproxy.log you will# need to:## 1) configure syslog to accept network log events.  This is done#    by adding the '-r' option to the SYSLOGD_OPTIONS in#    /etc/sysconfig/syslog## 2) configure local2 events to go to the /var/log/haproxy.log#   file. A line like the following can be added to#   /etc/sysconfig/syslog##    local2.*                       /var/log/haproxy.log#
    log         127.0.0.1 local2

    chroot      /var/lib/haproxy
    pidfile     /var/run/haproxy.pid
    maxconn     4000
    user        haproxy
    group       haproxy
    daemon

    # turn on stats unix socket
    stats socket /var/lib/haproxy/stats

#---------------------------------------------------------------------# common defaults that all the 'listen' and 'backend' sections will# use if not designated in their block#---------------------------------------------------------------------
defaults
    mode                    http
    log                     global
    option                  httplog
    option                  dontlognull
    option http-server-close
    option forwardfor       except 127.0.0.0/8
    option                  redispatch
    retries                 3
    timeout http-request    10s
    timeout queue           1m
    timeout connect         10s
    timeout client          1m
    timeout server          1m
    timeout http-keep-alive 10s
    timeout check           10s
    maxconn                 3000

#---------------------------------------------------------------------# main frontend which proxys to the backends#---------------------------------------------------------------------
frontend  main *:5000
    acl url_static       path_beg       -i /static /images /javascript /stylesheets
    acl url_static       path_end       -i .jpg .gif .png .css .js

    use_backend static          if url_static
    default_backend             app

#---------------------------------------------------------------------# static backend for serving up images, stylesheets and such#---------------------------------------------------------------------
backend static
    balance     roundrobin
    server      static 127.0.0.1:4331 check

#---------------------------------------------------------------------# round robin balancing between the various backends#---------------------------------------------------------------------
backend app
    balance     roundrobin
    server  app1 127.0.0.1:5001 check
    server  app2 127.0.0.1:5002 check
    server  app3 127.0.0.1:5003 check
    server  app4 127.0.0.1:5004 check

Ubuntu

global
    log /dev/log    local0
    log /dev/log    local1 notice
    chroot /var/lib/haproxy
    stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
    stats timeout 30s
    user haproxy
    group haproxy
    daemon

    # Default SSL material locations
    ca-base /etc/ssl/certs
    crt-base /etc/ssl/private

    # Default ciphers to use on SSL-enabled listening sockets.# For more information, see ciphers(1SSL). This list is from:#  https://hynek.me/articles/hardening-your-web-servers-ssl-ciphers/# An alternative list with additional directives can be obtained from#  https://mozilla.github.io/server-side-tls/ssl-config-generator/?server=haproxy
    ssl-default-bind-ciphers ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!MD5:!DSS
    ssl-default-bind-options no-sslv3

defaults
    log global
    mode    http
    option  httplog
    option  dontlognull
        timeout connect 5000
        timeout client  50000
        timeout server  50000
    errorfile 400 /etc/haproxy/errors/400.http
    errorfile 403 /etc/haproxy/errors/403.http
    errorfile 408 /etc/haproxy/errors/408.http
    errorfile 500 /etc/haproxy/errors/500.http
    errorfile 502 /etc/haproxy/errors/502.http
    errorfile 503 /etc/haproxy/errors/503.http
    errorfile 504 /etc/haproxy/errors/504.http

AmazonLinuxでのログファイルの出力設定

rsyslog の設定

AmazonLinuxではログが rsyslog に飛ぶようになっているので UDPを有効にして local2.*/var/log/haproxy.logにでも出力されるようにしておけば良い。

/etc/rsyslog.conf

--- /dev/fd/63  2019-03-13 11:21:15.779833034 +0000+++ /etc/rsyslog.conf   2019-03-11 04:56:02.570224255 +0000@@ -10,8 +10,8 @@
 #$ModLoad immark  # provides --MARK-- message capability
 
 # Provides UDP syslog reception
-#$ModLoad imudp-#$UDPServerRun 514+$ModLoad imudp+$UDPServerRun 514
 
 # Provides TCP syslog reception
 #$ModLoad imtcp
@@ -55,6 +55,7 @@
 
 # Save boot messages also to boot.log
 local7.*                                                /var/log/boot.log
+local2.*                                                /var/log/haproxy.log
 
 
 # ### begin forwarding rule ###

AWS Logs の設定

CloudWatch Logs にログを飛ばす場合は以下のように書いておく。インスタンスにロールを付与しておくのを忘れずに。

/etc/awslogs/awslogs.conf

[/var/log/haproxy.log]
datetime_format = %b %d %H:%M:%S
file = /var/log/haproxy.log
log_stream_name = /var/log/haproxy.log
initial_position = start_of_file
log_group_name = foo

サービスの再起動

AmazonLinux

sudo service haproxy restart

Ubuntu

sudo systemctl restart haproxy

HAProxy Statistics Report を使う場合

ロードバランシングの状態が確認できる。haproxy.cfgのどこかに listenディレクティブを作って軽く定義するだけで使える。

f:id:mattintosh4:20190313201702p:plain
HAProxy Statistics Report

HAProxy の状態 haproxy.cfglistenというディレクティブで設定を書いていく。書き方は色々あるけど Datadog さんのサンプルがわかりやすいかな。

listen stats # Define a listen section called "stats"
  bind :9000 # Listen on localhost:9000
  mode http
  stats enable  # Enable stats page
  stats hide-version  # Hide HAProxy version
  stats realm Haproxy\ Statistics  # Title text for popup window
  stats uri /haproxy_stats  # Stats URI
  stats auth Username:Password  # Authentication credentials

NGINX で同じことやってみる

NGINX にもリバースプロキシの機能があるので試してみた。ポイントは秘密鍵と証明書が別ってところと、stream内では access_logが使えないので error_log使うくらいかな。

/etc/nginx/nginx.conf

stream {
        server {
                listen 16379 ssl;
                ssl_protocols TLSv1 TLSv1.1 TLSv1.2;  
                ssl_prefer_server_cipherson;
                proxy_pass redis-ssl;
                proxy_ssl on;
                ssl_certificate     /etc/nginx/ssl/cert.pem;
                ssl_certificate_key /etc/nginx/ssl/privkey.pem;
                error_log /var/log/nginx/proxy.log info;
        }
        upstream redis-ssl {
                server master.名前.apne1.cache.amazonaws.com:6379;
        }
}

接続テスト

192.168.1.254は EIP のアドレスに置き換えてください。

openssl コマンド

openssl s_client -connect 192.168.1.254:16379
ping
set foo bar
get foo

Python

import redis
r = redis.Redis(host='192.168.1.254', port=16379, ssl=True)
r.ping()
r.set('foo', 'bar')
r.get('foo')

Ruby

require"redis"
r = Redis.new(url: "rediss://192.168.1.254:16379")
r.ping()
r.set("foo", "bar")
r.get("foo")

jq コマンドで2つのファイルから配列を結合する

$
0
0

YouTube Data APIを使っていると maxResult=50が限界なのでそれ以上になるとどうしても JSONが分かれてしまう。Pythonとかなら JSONをオブジェクトに変換してしまえばいいのだけど、忘れるので jqコマンドで実行する方法をメモっておく。

1.json

{"items": [{"id": "UCD8HOxPs4Xvsm8H0ZxXGiBw",
      "snippet": {"title": "Mel Channel 夜空メルチャンネル",
        "publishedAt": "2018-04-25T02:07:54.000Z",
        "thumbnails": {"default": {"url": "https://yt3.ggpht.com/a-/AAuE7mC5-XNF0MJc4spIJdwfxY1sFIyjWB8qAHd9_A=s88-mo-c-c0xffffffff-rj-k-no",
            "width": 88,
            "height": 88},
          "medium": {"url": "https://yt3.ggpht.com/a-/AAuE7mC5-XNF0MJc4spIJdwfxY1sFIyjWB8qAHd9_A=s240-mo-c-c0xffffffff-rj-k-no",
            "width": 240,
            "height": 240},
          "high": {"url": "https://yt3.ggpht.com/a-/AAuE7mC5-XNF0MJc4spIJdwfxY1sFIyjWB8qAHd9_A=s800-mo-c-c0xffffffff-rj-k-no",
            "width": 800,
            "height": 800}}},
      "contentDetails": {"relatedPlaylists": {"uploads": "UUD8HOxPs4Xvsm8H0ZxXGiBw"
        }},
      "statistics": {"viewCount": "1217525",
        "subscriberCount": "32661"
      }}]}

2.json

{"items": [{"id": "UCsg-YqdqQ-KFF0LNk23BY4A",
      "snippet": {"title": "樋口楓【にじさんじ所属】",
        "publishedAt": "2018-01-31T10:47:47.000Z",
        "thumbnails": {"default": {"url": "https://yt3.ggpht.com/a-/AAuE7mCLW7sO_ERKAC41K1XInz_nT0r8Q7JHgGnV0w=s88-mo-c-c0xffffffff-rj-k-no",
            "width": 88,
            "height": 88},
          "medium": {"url": "https://yt3.ggpht.com/a-/AAuE7mCLW7sO_ERKAC41K1XInz_nT0r8Q7JHgGnV0w=s240-mo-c-c0xffffffff-rj-k-no",
            "width": 240,
            "height": 240},
          "high": {"url": "https://yt3.ggpht.com/a-/AAuE7mCLW7sO_ERKAC41K1XInz_nT0r8Q7JHgGnV0w=s800-mo-c-c0xffffffff-rj-k-no",
            "width": 800,
            "height": 800}}},
      "contentDetails": {"relatedPlaylists": {"uploads": "UUsg-YqdqQ-KFF0LNk23BY4A"
        }},
      "statistics": {"viewCount": "13124038",
        "subscriberCount": "172943"
      }}]}

トップレベルのオブジェクトが2つある場合はそれぞれ .[0].[1]のようなインデックスに入っているらしい。そこから配列部分を取り出して +で連結してあげる。あとは並び替えの条件とかを追加して最後にまた {"items": .}に入れてあげる。

メモ: 配列からピンポイントで .[0]のように取ろうとすると要素が1つの場合は配列にしてくれないようなので .[0:1]のようにスライスで取得する。

cat 1.json 2.json | jq -s'.[0].items + .[1].items | sort_by(.statistics.viewCount | tonumber) | reverse | {"items": .}'

concatenated.json

{"items": [{"id": "UCsg-YqdqQ-KFF0LNk23BY4A",
      "snippet": {"title": "樋口楓【にじさんじ所属】",
        "publishedAt": "2018-01-31T10:47:47.000Z",
        "thumbnails": {"default": {"url": "https://yt3.ggpht.com/a-/AAuE7mCLW7sO_ERKAC41K1XInz_nT0r8Q7JHgGnV0w=s88-mo-c-c0xffffffff-rj-k-no",
            "width": 88,
            "height": 88},
          "medium": {"url": "https://yt3.ggpht.com/a-/AAuE7mCLW7sO_ERKAC41K1XInz_nT0r8Q7JHgGnV0w=s240-mo-c-c0xffffffff-rj-k-no",
            "width": 240,
            "height": 240},
          "high": {"url": "https://yt3.ggpht.com/a-/AAuE7mCLW7sO_ERKAC41K1XInz_nT0r8Q7JHgGnV0w=s800-mo-c-c0xffffffff-rj-k-no",
            "width": 800,
            "height": 800}}},
      "contentDetails": {"relatedPlaylists": {"uploads": "UUsg-YqdqQ-KFF0LNk23BY4A"
        }},
      "statistics": {"viewCount": "13124038",
        "subscriberCount": "172943"
      }},
    {"id": "UCD8HOxPs4Xvsm8H0ZxXGiBw",
      "snippet": {"title": "Mel Channel 夜空メルチャンネル",
        "publishedAt": "2018-04-25T02:07:54.000Z",
        "thumbnails": {"default": {"url": "https://yt3.ggpht.com/a-/AAuE7mC5-XNF0MJc4spIJdwfxY1sFIyjWB8qAHd9_A=s88-mo-c-c0xffffffff-rj-k-no",
            "width": 88,
            "height": 88},
          "medium": {"url": "https://yt3.ggpht.com/a-/AAuE7mC5-XNF0MJc4spIJdwfxY1sFIyjWB8qAHd9_A=s240-mo-c-c0xffffffff-rj-k-no",
            "width": 240,
            "height": 240},
          "high": {"url": "https://yt3.ggpht.com/a-/AAuE7mC5-XNF0MJc4spIJdwfxY1sFIyjWB8qAHd9_A=s800-mo-c-c0xffffffff-rj-k-no",
            "width": 800,
            "height": 800}}},
      "contentDetails": {"relatedPlaylists": {"uploads": "UUD8HOxPs4Xvsm8H0ZxXGiBw"
        }},
      "statistics": {"viewCount": "1217525",
        "subscriberCount": "32661"
      }}]}

Elasticsearch を使って VTuber のデイリーレポートを作ったのでちょっとまとめておく

$
0
0

最近 VTuberにハマりつつある筆者です。

会社とかでたまに VTuberの話が出ることがあるんですが、だいたい「VTuberってどれくらいいるの?」みたいに聞かれるので「これ見ればいいよ」的なものがあったらなぁと思って気がついたらウェブサイト作ってました。最初は単に YouTube Data APIを試したくて Elasticsearch にデータを入れてただけなのにどうしてこうなった…。

vtubers.ga

f:id:mattintosh4:20190317214912p:plain
vtubers.ga

構成は前回の電子書籍ランキングと同じ AWS CloudFront + S3 + Route53 です。Elasticsearch は Raspberry Piで動かしているため非力で耐えられないので予めクエリの結果を JSONファイルでエクスポートしておいて JavaScriptで整形しています。前回と違うのは SEO無視で Vue.js にお任せしてるところです。フロントエンドはなかなか慣れないなぁ、という感じです。

YouTubeのデータは YouTube Data APIから取得できます。無料でも使えるので Elasticsearch 用のサンプルデータを取得するのにもいいのではないでしょうか。(一日のリクエスト回数には上限があります) 使い方は公式のドキュメントを見ればだいだいわかると思います。データを取得するだけなので使うメソッドは listです。チャンネルはいいんですが、動画になるとAPIの制限がなかなか厳しいですね。

Channels: list  |  YouTube Data API (v3)  |  Google Developers

YouTube Data APIからデータを取得する

データの取り方は色々あると思うのですが、自分の場合は1回あたりの最大50件ずつで取得するようにしています。

取得する partは下記の3つです。

  • snippet
  • contentDetails
  • statistics

さらに上記から fieldsを使って必要な項目だけに絞ってます。この辺の項目絞りもクォータ量に影響するんだった気がしますがいまは覚えてません。

fields=items(id,snippet(title,publishedAt,thumbnails),contentDetails(relatedPlaylists/uploads),statistics(viewCount,subscriberCount))

idはカンマ区切りで複数指定が出来るので50件まとめてしまいます。現時点で119チャンネルが取得対象なんですが、これなら3回のリクエストで終わります。

https://www.googleapis.com/youtube/v3/channels?maxResults=50&id=UCWMwHoGz5QhhRDc3K8SQ6cw,UC6UwdMiDJfyjEipxJ66ceUg,UCZ1WJDkMNiZ_QwHnNrVf7Pw,UCCebk1_w5oiMUTRxdNJq0sA,UC4YaOt1yT-ZeyB0OmxHgolA,UC53UDnhAAYwvNO7j_2Ju1cQ,UCIdEIHpS0TdkqRkHL5OkLtA,UCCVwhI5trmaSxfcze_Ovzfw,UCB1s_IdO-r0nUkY2mXeti-A,UCfiy-dr0s1O6LJRV6KHomLw,UC1suqwovbL1kzsoaZgFZLKg,UCfM_A7lE6LkGrzx6_mOtI4g,UCyof-1Ko_jy2sOtivyTpc4Q,UCQ0UDLQCjY0rmuxCDE38FGg,UCpPuEfqwYbpn7e2jWdQeWew,UCT1AQFit-Eaj_YQMsfV0RhQ,UCPvGypSgfDkVe7JG2KygK7A,UCQlLqVz0RFOkFpjrJv-k-Zg,UCD-miitqNY3nyukJ4Fnf4_A,UCmUjjW5zF1MMOhYUwwwQv9Q,UCARI2g7r-PHaxrIcAYsMfmA,UCBe_jjkUHhVNAj46bukAbJA,UC2ZVDmnoZAOdLt7kI7Uaqog,UCM6ZAX8qPfCzEkKcGOFWPMw,UCbFwe3COkDrbNsbMyGNCsDg,UCAr7rLi_Wn09G-XfTA07d4g,UCmTcayoDVo7HXAAV_mquHEg,UCbxANlIBzexmsg7-eucWNoA,UCsg-YqdqQ-KFF0LNk23BY4A,UCtpB6Bvhs1Um93ziEDACQ8g,UCCvInijwD6Qg9xwdtYJcYtQ,UC_GCs6GARLxEHxy1w40d6VQ,UC1zFJrfEKvCixhsjNSb1toQ,UC7fk0CB07ly8oSl0aqKkqFg,UCfiK42sBHraMBK6eNWtsy7A,UCXTpFs_3PqI41qX2d9tL2Rw,UCD8HOxPs4Xvsm8H0ZxXGiBw,UCpnvhOIJ6BN-vPkYU9ls-Eg,UCJQMHCFjVZOVRYafR6gY04Q,UCKYPwPHjmgLWrJwkcLhGvNg,UCHTnX0CSX_KObo5I9WuZ64g,UCmgWMQkenFc72QnYkdxdoKA,UCLhUvJ_wO9hOvv_yYENu4fQ,UCYKP16oMX9KKPbrNgo_Kgag,UCp-5t9SrOQwXMU7iIjQfARg,UC_4tXjqecqox5Uc05ncxpxg,UCwRKt_raV3N5KZgxcFyC1vw,UCkPIfBOLoO0hVPG-tI2YeGg,UC48jH1ul-6HOrcSSfoR02fQ,UC8NZiqKx6fsDT3AVcMiVFyA&part=snippet,contentDetails,statistics&fields=items(id,snippet(title,publishedAt,thumbnails),contentDetails(relatedPlaylists%2Fuploads),statistics(viewCount,subscriberCount))&key=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

50件ずつの区切り方はこんな感じですね。チャンネル数が 50で割り切れなかったら Noneで埋めておきます。その後、 zip()で 50 件ごとのリストに分け、最後に Noneを削っています。最初は None埋めをピッタリやろうかなと思ったんですが、zip()の時点で勝手に削られるのであまり拘らないことにしました。

MAX_LENGTH = 50# TSV ファイルからチャンネル ID を読み込むwithopen('vtuber.tsv', 'r') as f:
    channelIds = list(set([row[0] for row in csv.reader(f, delimiter='\t')]))

# リストが MAX_LENGTH で割り切れるかチェックiflen(channelIds) % MAX_LENGTH > 0:
    # 割り切れなかったら None で埋める
    channelIds += [Nonefor i inrange(MAX_LENGTH)]

# 50 件ごとのリストに分割しつつ None を除去
channelIdsSets = [[y for y in x if y isnotNone] for x inlist(zip(*[iter(channelIds)] * MAX_LENGTH))]

あとはこのリストをまとめて urllib.parse.urlencode()とかで変換するんですが、ポイントとしては fields(),safeキーワードで指定しておかなきゃいけないところですかね。

    url = urllib.parse.urlunsplit([
        'https',
        'www.googleapis.com',
        '/youtube/v3/channels',
        urllib.parse.urlencode({
            'key'       : 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
            'id'        : ','.join(s),
            'part'      : ','.join(['snippet', 'contentDetails', 'statistics']),
            'fields'    : 'items(id,snippet(title,publishedAt,thumbnails),contentDetails(relatedPlaylists/uploads),statistics(viewCount,subscriberCount))',
            'maxResults': MAX_LENGTH,
        }, safe=',()'),
        None
    ])

Pythonのコードの方はスクラッチで書いたものなのでいまのところあんまり凝って書いてはいません。

では取れたデータを見てみます。実際には {"items": []}という配列の中に1チャンネルごとに入っています。statistics.subscriberCountstatistics.viewCountが string になってるのがちょっと注意ですかね。

{"id": "UCMxKcUjeTEcgHmC9Zzn3R4w",
  "snippet": {"thumbnails": {"medium": {"url": "https://yt3.ggpht.com/a-/AAuE7mDsZK0Vy1Ih2fGAU8nBLBBM2Y3cmupPAoBZ9w=s240-mo-c-c0xffffffff-rj-k-no",
        "height": 240,
        "width": 240},
      "high": {"url": "https://yt3.ggpht.com/a-/AAuE7mDsZK0Vy1Ih2fGAU8nBLBBM2Y3cmupPAoBZ9w=s800-mo-c-c0xffffffff-rj-k-no",
        "height": 800,
        "width": 800},
      "default": {"url": "https://yt3.ggpht.com/a-/AAuE7mDsZK0Vy1Ih2fGAU8nBLBBM2Y3cmupPAoBZ9w=s88-mo-c-c0xffffffff-rj-k-no",
        "height": 88,
        "width": 88}},
    "publishedAt": "2018-08-08T05:20:43.000Z",
    "title": "由宇霧ちゃんねる"
  },
  "@timestamp": "2019-03-17T09:00:00+09:00",
  "statistics": {"subscriberCount": "26961",
    "viewCount": "940158"
  },
  "contentDetails": {"relatedPlaylists": {"uploads": "UUMxKcUjeTEcgHmC9Zzn3R4w"
    }}}

string になっている数値は Pythonではキャストせずに Elasticsearch のマッピングで対応しています。

{"mappings": {"_doc": {"dynamic_templates": [{"count": {"match_mapping_type": "string",
            "match": "*Count",
            "mapping": {"type": "long"
            }}}]}}}

デイリーの差分を Aggregations で抽出する

Elasticsearch の良いところは内部で色々な計算が出来るところですね。YouTubeのデイリーデータの活用方法をあまり見い出せていませんが、とりあえず必要になるのは以下の2つでしょうか。

  • 再生回数の前日との差
  • チャンネル登録者数の前日との差

これは Aggregation Query で前日と当日の maxを取り、それらの Bucketを Pipeline Aggregations の derivativeで計算する感じですね。詳しくは https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-derivative-aggregation.htmlを見ていただくとわかるかと。

{"size": 0,
  "query": {"query_string": {"default_field": "@timestamp",
      "query": "[now-1d/dTOnow/d]" // 対象データを前日と当日に限定}},
  "aggs": {"channel": {"terms": {"field": "id",
        "size": 500 // 取得対象のチャンネル数に応じて調整},
      "aggs": {"daily": {"date_histogram": {"field": "@timestamp",
            "interval": "day"
          },
          "aggs": {// チャンネル登録者数用"subscriberCount": {"max": {"field": "statistics.subscriberCount",
                "missing": 0}},
            "derivativeSubscriberCount": {"derivative": {"buckets_path": "subscriberCount" // 上で計算した Bucket を指定する}},
            // 再生回数用"viewCount": {"max": {"field": "statistics.viewCount",
                "missing": 0}},
            "derivativeViewCount": {"derivative": {"buckets_path": "viewCount" // 上で計算した Bucket を指定する}}}}}}}}

Aggregations の結果を絞り込む

グラフ用のデータを取り出す必要があったんですが、チャンネルの件数が多いので Aggregations の結果から絞り込みをしようと思いました。条件指定なので filterを色々と調べていたんですが、bucket_sortでした(filterは別の使い方でした)。やや複雑ですが、順番と書き方のコツさえ掴めばそんなに難しくはないはず。

  1. ❶: 日付ごとの値を取り出す
  2. ❷: ❶の結果から差分を計算する
  3. ❸: ❷の結果の累積和を計算する
  4. ❹: ❸の結果の最大値を計算する
  5. ❺: ❹の結果を降順でソートする
  6. ❻: ❺の結果を上位 n 件で絞り込む

date_histogramintervalごとに分割された結果では親はどの値を元にソートすればいいかわからないため、分割のひとつ上の階層(date_histogramと同じ階層)から分割された bucketの中を除き、max等で値を取り出して単体の要素にします。で、あとは buckets_sortfromsizeが指定できるので絞り込む感じ。

{"size": 0,
  "query": {"query_string": {"default_field": "@timestamp",
      "query": "[now-7d/dTOnow/d]" // 対象データを前日と当日に限定}},
  "aggs": {"channel": {"terms": {"field": "id",
        "size": 500 // 取得対象のチャンネル数に応じて調整},
      "aggs": {"daily": {"date_histogram": {"field": "@timestamp",
            "interval": "day"
          },
          "aggs": {// ❶ 計算の元になる値を取り出す"subscriberCount": {"max": {"field": "statistics.subscriberCount",
                "missing": 0}},
            // ❷ 上の差分を計算する"derivativeSubscriberCount": {"derivative": {"buckets_path": "subscriberCount" // 上で計算した Bucket を指定する}},
            // ❸ 上の差分からさらに累積和を計算する"cumulativesumDerivativeSubscriberCount": {"cumulative_sum": {"buckets_path": "derivativeSubscriberCount" // 上の derivative で計算した bucket を指定する}}}},
      // 上の derivative で計算した結果の最大値を取得する(この項目は今回の条件としては使っていません)"maxDerivativeSubscribersCount": {"max_bucket": {"buckets_path": "daily.derivativeSubscriberCount" // 階層が違うので fqdn で指定する}},
      // ❹ 上の cumulative_sum で計算した結果の最大値を取得する"maxCumulativesumSubscriberCount": {"max_bucket": {"buckets_path": "daily.cumulativesumSubscriberCount" // 階層が違うので fqdn で指定する}},
      // ❺ ソートとサイズの条件"sortCondition": {"bucket_sort": {"sort": [{"maxCumulativesumSubscriberCount": {"order": "desc" // ❻}}],
          "from": 0, // ❻"size": 10 // ❻}}}}}

AWSと Vue.js については次の記事で書く予定。

Bash alias の「A trailing space in value causes(末尾に空白があると)」って何?

$
0
0

久しぶりに Bashのマニュアルを読む機会があって aliasについて読んでたら謎いことが書いてあった。

alias コマンドを引き数を付けずに (あるいは -p オプションを付けて) 実行すると、エイリアスのリストが 「alias name=value」の形で標準出力に出力されます。引き数を与えた場合には、valueを与えられた name それぞれに対するエイリアスが定義されます。valueの末尾に空白があると、エイリアスが展開されたときに、空白の次の単語についてエイリアス置換があるかどうか調べられます。引き数リスト中に valueが与えられていない name があった場合は、それぞれに対して名前とエイリアスの値が出力されます。エイリアスが定義されていない name が指定された場合以外は、alias は真を返します。

何言ってんだこいつ?と思ったけど Wikipedia見たらわかった。

https://en.wikipedia.org/wiki/Alias_(command)#Chaining

Bashのバージョンは下記の通り。

GNU bash, version 4.4.12(1)-release (x86_64-pc-linux-gnu)

xtrace を有効にしておくとわかりやすいので set -xする。

Bash

linus@debian:~$ set +x

まず普通に空白を入れずに aliasを定義する。そして定義したエイリアスエイリアスの引数として与える。

Bash

linus@debian:~$ alias list='ls'
+ alias list=ls
linus@debian:~$ list list
+ ls list
ls: 'list'にアクセスできません: そのようなファイルやディレクトリはありません

結果は最初のエイリアスだけ展開されて ls listになり「listなんてファイルは無いよ」となる。

続いて、値の末尾にスペースを入れて定義する。

Bash

linus@debian:~$ alias list='ls '
+ alias 'list=ls 'linus@debian:~$ list list
+ ls ls
ls: 'ls'にアクセスできません: そのようなファイルやディレクトリはありません

今度は list listls lsに展開された。これが 空白の次の単語についてエイリアス置換があるかどうか調べられますということなんだろう。

コマンドの先頭のエイリアスを他のコマンドに変えても同じように末尾にスペースがあればその次がエイリアスだった場合は展開される。

linus@debian:~$ rm list
+ rm list
rm: 'list'を削除できません: そのようなファイルやディレクトリはありません
linus@debian:~$ alias rm='rm '
+ alias 'rm=rm 'linus@debian:~$ rm list
+ rm ls
rm: 'ls'を削除できません: そのようなファイルやディレクトリはありません

空白の次の単語についてエイリアス置換があるかどうか調べられますというか「空白の次の単語についても展開されます」って言う方が妥当なのでは?と思う。(調べるというかそのときにはもうコマンドに引数として渡してしまってるわけで…)

Wikipediaのサンプルにはオプションもエイリアスとして使うような例が載ってるけど、オプション覚えたくないマンがやりそうなだけで使いやすいとは思わないな…。

CentOS 7 で xinetd を使った Telnet サーバの構築

$
0
0

LPIC/LinuC の試験範囲に inetd/xinetd が入っているが、CentOS 7 では既に telnetサーバは systemd に移行していて手作業じゃないと xinetd での検証が出来ないので方法を書いておくよ。(この辺の古い話題はいつまで試験に出るんですかね)

Bash

[root@localhost ~]# yum install xinetd telnet-server

各パッケージ含まれているファイル郡。

Bash

[root@localhost ~]# rpm -ql xinetd
/etc/sysconfig/xinetd
/etc/xinetd.conf
/etc/xinetd.d/chargen-dgram
/etc/xinetd.d/chargen-stream
/etc/xinetd.d/daytime-dgram
/etc/xinetd.d/daytime-stream
/etc/xinetd.d/discard-dgram
/etc/xinetd.d/discard-stream
/etc/xinetd.d/echo-dgram
/etc/xinetd.d/echo-stream
/etc/xinetd.d/tcpmux-server
/etc/xinetd.d/time-dgram
/etc/xinetd.d/time-stream
/usr/lib/systemd/system/xinetd.service
/usr/sbin/xinetd
/usr/share/doc/xinetd-2.3.15
/usr/share/doc/xinetd-2.3.15/CHANGELOG
/usr/share/doc/xinetd-2.3.15/COPYRIGHT
/usr/share/doc/xinetd-2.3.15/README
/usr/share/doc/xinetd-2.3.15/empty.conf
/usr/share/doc/xinetd-2.3.15/sample.conf
/usr/share/man/man5/xinetd.conf.5.gz
/usr/share/man/man5/xinetd.log.5.gz
/usr/share/man/man8/xinetd.8.gz

Bash

[root@localhost ~]# rpm -ql telnet-server
/usr/lib/systemd/system/telnet.socket
/usr/lib/systemd/system/telnet@.service
/usr/sbin/in.telnetd
/usr/share/man/man5/issue.net.5.gz
/usr/share/man/man8/in.telnetd.8.gz
/usr/share/man/man8/telnetd.8.gz

Telnet用のファイルも無いので /etc/xinetd.d/telnetを作成する。属性の詳細は man 5 xinetd.confを参照。xinetd では TCPWrapper を使っていないでアクセス制御は only_fromno_access属性で行う。

/etc/xinetd.d/telnet

service telnet
{
    disable         = no
    flags           = REUSE
    socket_type     = stream
    wait            = no
    user            = root
    server          = /usr/sbin/in.telnetd
    log_on_failure  += USERID
}

Bash

[root@localhost ~]# systemctl reload xinetd

クライアントから接続出来れば完了。

Bash

[root@localhost ~]# telnet localhost
Trying ::1...
Connected to localhost.
Escape character is '^]'.

Kernel 3.10.0-957.el7.x86_64 on an x86_64
localhost login:

外部からの接続を許可する場合は firewall-cmdtelnetサービスを追加しておく必要がある。実際には接続元などを制限するべきだが、レベル1の試験範囲外(のはず)なのでここでは割愛する。CentOSVirtualBoxで実行している場合、NAT であればホストの適当なポートをゲストの 23 番に転送するか、ブリッジであれば IP でそのまま接続出来るはず。

Bash

[root@localhost ~]# firewall-cmd --add-service=telnet

systemd では

telnet-serverパッケージをインストールして telnet.socketを開始すればよいだけ。

Bash

[root@localhost ~]# yum install telnet-server
[root@localhost ~]# systemctl start telnet.socket

Debian 9 では

telnetdパッケージをインストールすれば openbsd-inetdがインストールされ、/etc/inetd.confが設定される。

/etc/inetd.conf

telnet          stream  tcp     nowait  telnetd /usr/sbin/tcpd  /usr/sbin/in.telnetd

仮想マシンの Debian でエントロピーが溜まらなくて GPG の鍵が作れないとき

$
0
0

GPG では gpg --gen-keyなどで秘密鍵を作成する際にエントロピー(マウスとかキーボードとかを操作することによって溜まる不規則な情報)が必要になるが、仮想マシンの場合はエントロピーが溜まらないのでいつまで経っても鍵が生成出来ないので havegedrng-toolsといったパッケージをインストールしてエントロピーを溜める。

環境は以下の通り。

lsb_release

Distributor ID: Debian
Description:    Debian GNU/Linux 9.8 (stretch)
Release:        9.8
Codename:       stretch

haveged の場合

havegedパッケージをインストールする。

Console (Debian 9)

root@debian:~# apt install havegend -y

サービスが稼働しているか確認しておく。

Console (Debian 9)

root@debian:~# systemctl status haveged
● haveged.service - Entropy daemon using the HAVEGE algorithm
   Loaded: loaded (/lib/systemd/system/haveged.service; enabled; vendor preset: enabled)
   Active: active (running) since Wed 2019-04-03 17:21:13 JST; 5s ago
     Docs: man:haveged(8)
           http://www.issihosts.com/haveged/
 Main PID: 7460 (haveged)
   CGroup: /system.slice/haveged.service
           └─7460 /usr/sbin/haveged --Foreground --verbose=1 -w 1024

 4月 03 17:21:13 debian systemd[1]: Started Entropy daemon using the HAVEGE algorithm.
 4月 03 17:21:14 debian haveged[7460]: haveged: ver: 1.9.1; arch: x86; vend: GenuineIntel; build: (gcc 6.3.0 ITV); collect: 128K
 4月 03 17:21:14 debian haveged[7460]: haveged: cpu: (L4 VC); data: 32K (L4 V); inst: 32K (L4 V); idx: 22/40; sz: 31886/59215
 4月 03 17:21:14 debian haveged[7460]: haveged: tot tests(BA8): A:1/1 B:1/1 continuous tests(B):  last entropy estimate 7.99355
 4月 03 17:21:14 debian haveged[7460]: haveged: fills: 0, generated: 0

rng-tools/rng-tools5 の場合

rng-toolsまたは rng-tools5パッケージをインストールする(ここでは rng-toolsとしておく)。恐らくサービス開始のトリガーは失敗する。

Console (Debian 9)

root@debian:~# apt install rng-tools

rng-toolsrng-tools5はどちらもデフォルトのデバイス/dev/hwrngになっており、これが存在しないのでシンボリックリンクを貼る。

Console (Debian 9)

root@debian:~# ln -s /dev/urandom /dev/hwrng

サービスを起動する。

Console (Debian 9)

root@debian:~# systemctl start rng-tools

もしくは rngdを手動で実行する。

Console (Debian 9)

root@debian:~# rngd -r /dev/urandom

恒久的に使用するのであれば systemd のユニットを修正するか、手間をかけずに使える havegedパッケージの方がいいかもしれない。

GPG 鍵を生成する

Debian 9 と CentOS 7 ではバージョンが異なるからかどうか知らないが動作が違う。

Console (Debian 9)

linus@debian:~$ gpg --help
gpg (GnuPG) 2.1.18
libgcrypt 1.7.6-beta
Copyright (C) 2017 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Home: /home/linus/.gnupg
Supported algorithms:
Pubkey: RSA, ELG, DSA, ECDH, ECDSA, EDDSA
Cipher: IDEA, 3DES, CAST5, BLOWFISH, AES, AES192, AES256, TWOFISH,
        CAMELLIA128, CAMELLIA192, CAMELLIA256
Hash: SHA1, RIPEMD160, SHA256, SHA384, SHA512, SHA224
Compression: Uncompressed, ZIP, ZLIB, BZIP2

Syntax: gpg [options] [files]
Sign, check, encrypt or decrypt
Default operation depends on the input data

Commands:
 
 -s, --sign                  make a signature
     --clear-sign            make a clear text signature
 -b, --detach-sign           make a detached signature
 -e, --encrypt               encrypt data
 -c, --symmetric             encryption only with symmetric cipher
 -d, --decrypt               decrypt data (default)
     --verify                verify a signature
 -k, --list-keys             list keys
     --list-signatures       list keys and signatures
     --check-signatures      list and check key signatures
     --fingerprint           list keys and fingerprints
 -K, --list-secret-keys      list secret keys
     --generate-key          generate a new key pair
     --quick-generate-key    quickly generate a new key pair
     --quick-add-uid         quickly add a new user-id
     --quick-revoke-uid      quickly revoke a user-id
     --quick-set-expire      quickly set a new expiration date
     --full-generate-key     full featured key pair generation
     --generate-revocation   generate a revocation certificate
     --delete-keys           remove keys from the public keyring
     --delete-secret-keys    remove keys from the secret keyring
     --quick-sign-key        quickly sign a key
     --quick-lsign-key       quickly sign a key locally
     --sign-key              sign a key
     --lsign-key             sign a key locally
     --edit-key              sign or edit a key
     --change-passphrase     change a passphrase
     --export                export keys
     --send-keys             export keys to a keyserver
     --receive-keys          import keys from a keyserver
     --search-keys           search for keys on a keyserver
     --refresh-keys          update all keys from a keyserver
     --import                import/merge keys
     --card-status           print the card status
     --edit-card             change data on a card
     --change-pin            change a card's PIN
     --update-trustdb        update the trust database
     --print-md              print message digests
     --server                run in server mode
     --tofu-policy VALUE     set the TOFU policy for a key

Options:
 
 -a, --armor                 create ascii armored output
 -r, --recipient USER-ID     encrypt for USER-ID
 -u, --local-user USER-ID    use USER-ID to sign or decrypt
 -z N                        set compress level to N (0 disables)
     --textmode              use canonical text mode
 -o, --output FILE           write output to FILE
 -v, --verbose               verbose
 -n, --dry-run               do not make any changes
 -i, --interactive           prompt before overwriting
     --openpgp               use strict OpenPGP behavior

(See the man page for a complete listing of all commands and options)

Examples:

 -se -r Bob [file]          sign and encrypt for user Bob
 --clear-sign [file]        make a clear text signature
 --detach-sign [file]       make a detached signature
 --list-keys [names]        show keys
 --fingerprint [names]      show fingerprints

Please report bugs to <https://bugs.gnupg.org>.

Console (CentOS 7)

[linus@localhost ~]$ gpg --help
gpg (GnuPG) 2.0.22
libgcrypt 1.5.3
Copyright (C) 2013 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Home: ~/.gnupg
Supported algorithms:
Pubkey: RSA, ?, ?, ELG, DSA
Cipher: IDEA, 3DES, CAST5, BLOWFISH, AES, AES192, AES256, TWOFISH,
        CAMELLIA128, CAMELLIA192, CAMELLIA256
Hash: MD5, SHA1, RIPEMD160, SHA256, SHA384, SHA512, SHA224
Compression: Uncompressed, ZIP, ZLIB, BZIP2

Syntax: gpg [options] [files]
Sign, check, encrypt or decrypt
Default operation depends on the input data

Commands:
 
 -s, --sign                 make a signature
     --clearsign            make a clear text signature
 -b, --detach-sign          make a detached signature
 -e, --encrypt              encrypt data
 -c, --symmetric            encryption only with symmetric cipher
 -d, --decrypt              decrypt data (default)
     --verify               verify a signature
 -k, --list-keys            list keys
     --list-sigs            list keys and signatures
     --check-sigs           list and check key signatures
     --fingerprint          list keys and fingerprints
 -K, --list-secret-keys     list secret keys
     --gen-key              generate a new key pair
     --gen-revoke           generate a revocation certificate
     --delete-keys          remove keys from the public keyring
     --delete-secret-keys   remove keys from the secret keyring
     --sign-key             sign a key
     --lsign-key            sign a key locally
     --edit-key             sign or edit a key
     --passwd               change a passphrase
     --export               export keys
     --send-keys            export keys to a key server
     --recv-keys            import keys from a key server
     --search-keys          search for keys on a key server
     --refresh-keys         update all keys from a keyserver
     --import               import/merge keys
     --card-status          print the card status
     --card-edit            change data on a card
     --change-pin           change a card's PIN
     --update-trustdb       update the trust database
     --print-md             print message digests
     --server               run in server mode

Options:
 
 -a, --armor                create ascii armored output
 -r, --recipient USER-ID    encrypt for USER-ID
 -u, --local-user USER-ID   use USER-ID to sign or decrypt
 -z N                       set compress level to N (0 disables)
     --textmode             use canonical text mode
 -o, --output FILE          write output to FILE
 -v, --verbose              verbose
 -n, --dry-run              do not make any changes
 -i, --interactive          prompt before overwriting
     --openpgp              use strict OpenPGP behavior

(See the man page for a complete listing of all commands and options)

Examples:

 -se -r Bob [file]          sign and encrypt for user Bob
 --clearsign [file]         make a clear text signature
 --detach-sign [file]       make a detached signature
 --list-keys [names]        show keys
 --fingerprint [names]      show fingerprints

Please report bugs to <http://bugs.gnupg.org>.

CentOS 7 で gpg --gen-keyを実行した場合、鍵種と鍵長、有効期限が聞かれるが、Debian 9 ではこれらの項目は聞かれず、名前とメールアドレス、パスワードの応答のみになっている。コマンドを叩いた時にメッセージが出るが、鍵種等も指定したいのであれば --full-generate-keyを使えとのこと。

Console (Debian 9)

linus@debian:~$ gpg --full-generate-key

試験では確か --gen-keyを覚えておけばよかったような気がするけど、同じコマンドなのにディストリで動作が違うものについて LPI や LPI-Japan はどうしていくのかね。

(個人的には gzip -kRHEL系に無いのが辛いがこれは試験に出ない)


EFI システムで USB メモリから CentOS 7 が起動できない場合

$
0
0

VirtualBoxで作成した仮想マシンの仮想ディスクイメージを USB メモリに書き出しても CentOS 7 はスプラッシュ・スクリーン(?)の部分で止まる。Escを押すと Dracut の部分でファイルシステムがマウント出来ずに止まっていることがわかる。

f:id:mattintosh4:20190419000524p:plain
CentOS 7

調べてみると Dracut に USB ドライバが含まれていないために起きる問題らしい。出来るだけ未設定の状態でのディスクイメージを作成したいので VirtualBoxCentOS 7 を起動したらログインせずにホストの右 Ctrl + F2等でコンソールに切り替えて作業する。

f:id:mattintosh4:20190419000613p:plain
CentOS 7

f:id:mattintosh4:20190419000658p:plain
CentOS 7

/etc/dracut.confを直接編集する方法もあるが、/etc/dracut.conf.d内のファイルを読み込むようになっているので USB ドライバ用のファイルを配置して dracutで新しい initramfs を作成する。

echo'add_drivers+="usb_storage"'>/etc/dracut.conf.d/usb_storage.conf
dracut -vf

f:id:mattintosh4:20190419000818p:plain
CentOS 7

f:id:mattintosh4:20190419000844p:plain
CentOS 7

あとは仮想マシンの電源を落として USB メモリに書き出す。

USB メモリで起動しているのもあるけど Ubuntuと比べると全体的にもっさりした動き。

Linux USB 量産用のイメージを作成する

$
0
0

新入社員入社シーズン。企業によっては技術研修等を行っている時期でしょうか。私はつい先日まで出張講師で LPIC/LinuC を教えていたのですが、Linuxはやはり実機で覚えるのが一番だと思います。しかし、会社の資産である Windows PC で Windowsを削除して Linuxをインストールするというのはなかなか受け入れてもらえません。

そのため、USB メモリから Linuxを起動する方法を用いるのですが、実機を使って USB メモリひとつひとつに Linuxをインストールするのはとても大変です。そこで、パワーのあるマシンで仮想マシンを作成してマスターイメージを作成し、Linuxがインストールされた USB メモリを量産する方法を使います。

用意するもの

Windowsマシンでも作成は可能ですが、vboxmanageコマンドへのパスが面倒なことと、ddコマンドが必要になるためここでは紹介しません。

ここで紹介する方法は最悪の場合、データの破損を招きますので作業は自己責任で行ってください。

ベースイメージを作成する

truncateコマンドでイメージファイルを作成します。USB メモリの表記容量は 10 の累乗になっているのでサイズは Gではなく GBで指定します。

linus@localhost:~$ truncate -s 8GB CentOS7.img

単位指定の方法の違いによるイメージファイルのサイズは下記のようになります。8G16Gで指定すると USB メモリに収まらないことがあるので注意です。

linus@localhost:~$ truncate -s 8GB 8GB.img
linus@localhost:~$ truncate -s 8G 8G.img
linus@localhost:~$ ls -l 8GB.img 8G.img
-rw-rw-r-- 1 linus linus 8000000000  4月 30 15:47 8GB.img
-rw-rw-r-- 1 linus linus 8589934592  4月 30 15:47 8G.img

続いて作成したイメージファイルを VirtualBoxで使えるように VMDK イメージを作成します。macOSの場合は /Applications/VirtualBox.app/Contents/MacOSの中に vboxmanageコマンドがあると思います。問題がなければ created successfullyのメッセージが表示されます。

linus@localhost:~$ vboxmanage internalcommands creatrawvmdk -rawdisk CentOS7.img -filename CentOS7.vmdk
RAW host disk access VMDK file CentOS7.vmdk created successfully.

あとは通常通り仮想マシンを起動してインストールを進めていきます。

VirtualBox仮想マシンを作成し、ディスクの選択で「既存のディスクを選択」して、上記で作成した VMDK ファイルを指定します。使用する PC が BIOSではなく UEFIの場合は仮想マシンの設定で EFIを有効にしておく必要があります。

f:id:mattintosh4:20190430200724p:plain
VirtualBox

インストールだけではなく、初期設定もしておきたい場合はここで済ませておきましょう。

イメージのバックアップ

インストールが完了したらイメージファイルをバックアップを作成しておくといいでしょう。圧縮方法には GZIP、BZIP2、XZ などがありますが、圧縮効率を優先するのであれば XZ が良いでしょう。

Debian系の場合

Debian系の場合は -k|--keepオプションが使えますので簡単に圧縮元のファイルを残しておくことができます。

linus@localhost:~$ xz -k CentOS7.img

RHEL系の場合

RHEL系は -k|--keepが使えないのでリダイレクトで作成します。

linus@localhost:~$ xz <CentOS7.img >CentOS7.img.xz

USB メモリへの書き込み

ddコマンドやディスクイメージライターで USB メモリにイメージを書き込みます。ここでは USB メモリが /dev/sdbに接続されているものとします。ブロックサイズ等はご自由に指定してください。macOSの場合は diskutils listコマンドで USB メモリのデバイスパスを確認してください。

書き込み先の指定を誤るとデータを破壊する可能性がありますので十分に注意してください。

linus@localhost:~$ sudo dd if=CentOS7.img of=/dev/sdb bs=16M conv=noerror,sync status=progress

圧縮された状態から書き込む場合はコマンドの標準出力から ddの標準入力に渡します。

linus@localhost:~$ xz <CentOS7.img.xz | sudo dd of=/dev/sdb

USB メモリを取り外します。

linus@localhost:~$ sync
linus@localhost:~$ sudo eject /dev/sdb

あとは同じ方法で USB メモリに書き込めば Linuxがインストールされた USB メモリを量産することが出来ます。(パフォーマンス次第では複数同時に作成することも出来ます)

領域の拡張

今回の方法では USB メモリよりイメージサイズの方が小さくなるため USB メモリには未使用の領域が出来るはずです。ここからの手順で USB メモリの全領域を使えるようにしていきます。手順としては下記のようになります。

  1. パーティションテーブルを編集してパーティションを拡張する
  2. ファイルシステムを拡張する

LVM を使用している場合は少し手順が増えます。

  1. パーティションテーブルを編集してパーティションを拡張する
  2. LVM 物理ボリュームを拡張する
  3. LVM 論理ボリュームを拡張する
  4. ファイルシステムを拡張する

この作業は USB メモリにイメージを書き込んだあとにそのマシンで行ってもいいのですが、私の場合はパーティションテーブルについて理解してもらうため、受講者に各自のマシンで行ってもらっています。(USB メモリから起動すると /dev/sdaWindowsの入ったディスクになるため十分に注意してもらう必要はあります)

USB メモリにインストールした OS で LVM を使用していない場合

MBRの場合は fdiskコマンド、GPT の場合は gdiskコマンドを使ってパーティションテーブルを操作します。ここでは USB メモリのデバイスパスが /dev/sdbだとします。ルートファイルシステムが含まれたパーティションを削除して新しいパーティションを作ります。

ubuntu@localhost:~$ sudo gdisk /dev/sdb

ディスクに変更を書き込んだら新しいパーティションテーブルを認識させるためシステムを再起動します。

次に、ファイルシステムを拡張しますが EXT 系と XFS ではファイルシステムの拡張に使用するコマンドが異なります。

EXT2/EXT3/EXT4の場合は resize2fsで拡張します。ここではルートファイルシステムが含まれるパーティション/dev/sdb3とします。

ubuntu@localhost:~$ sudo resize2fs /dev/sdb3

XFS の場合は xfs_grawfsで拡張します。ここではルートファイルシステムが含まれるパーティション/dev/sdb3とします。

centos@localhost:~$ sudo xfs_grawfs /dev/sdb3

USB メモリにインストールした OS で LVM を使用している場合

CentOS7 の場合はデフォルトで LVM を使うようになっているかと思います。先に説明した通り、LVM を使用している場合はパーティションテーブルの編集に加え、物理ボリュームと論理ボリュームの拡張が必要になります。

まず、編集前の状態を確認します。ディスクは 16 GB として認識されていますが、パーティションファイルシステムは 8 GB のときのままになっています。

[root@localhost ~]# lsblk
NAME            MAJ:MIN RM  SIZE RO TYPE MOUNTPOINT
sda               8:0    0 14.9G  0 disk 
├─sda1            8:1    0  200M  0 part /boot/efi
├─sda2            8:2    0    1G  0 part /boot
└─sda3            8:3    0  6.3G  0 part 
  ├─centos-root 253:0    0  5.5G  0 lvm  /
  └─centos-swap 253:1    0  764M  0 lvm  [SWAP]
sr0              11:0    1 1024M  0 rom  
[root@localhost ~]# df -h
Filesystem               Size  Used Avail Use% Mounted on
/dev/mapper/centos-root  5.5G  3.7G  1.9G  67% /
devtmpfs                 479M     0  479M   0% /dev
tmpfs                    496M     0  496M   0% /dev/shm
tmpfs                    496M  7.1M  489M   2% /run
tmpfs                    496M     0  496M   0% /sys/fs/cgroup
/dev/sda2               1014M  150M  865M  15% /boot
/dev/sda1                200M   12M  189M   6% /boot/efi
tmpfs                    100M     0  100M   0% /run/user/0

MBRであれば fdisk、GPT であれば gdiskを使ってルートパーティションを拡張します。

[root@localhost ~]# gdisk /dev/sda
GPT fdisk (gdisk) version 0.8.10

Partition table scan:
  MBR: protective
  BSD: not present
  APM: not present
  GPT: present

Found valid GPT with protective MBR; using GPT.

Command (? for help): p ❶
Disk /dev/sda: 31250000 sectors, 14.9 GiB
Logical sector size: 512 bytes
Disk identifier (GUID): D3D40B34-6318-4815-9C14-7E5605FD5815
Partition table holds up to 128 entries
First usable sector is 34, last usable sector is 31249966
Partitions will be aligned on 2048-sector boundaries
Total free space is 15627014 sectors (7.5 GiB)

Number  Start (sector)    End (sector)  Size       Code  Name
   1            2048          411647   200.0 MiB   EF00  EFI System Partition
   2          411648         2508799   1024.0 MiB  0700  
   3         2508800        15624966   6.3 GiB     8E00  Linux LVM

Command (? for help): d ❷
Partition number (1-3): 3 ❸

Command (? for help): p ❹
Disk /dev/sda: 31250000 sectors, 14.9 GiB
Logical sector size: 512 bytes
Disk identifier (GUID): D3D40B34-6318-4815-9C14-7E5605FD5815
Partition table holds up to 128 entries
First usable sector is 34, last usable sector is 31249966
Partitions will be aligned on 2048-sector boundaries
Total free space is 28743181 sectors (13.7 GiB)

Number  Start (sector)    End (sector)  Size       Code  Name
   1            2048          411647   200.0 MiB   EF00  EFI System Partition
   2          411648         2508799   1024.0 MiB  0700  

次に、ルートファイルシステムが含まれるパーティションを再作成します。

Command (? for help): n ❶
Partition number (3-128, default 3): ❷
First sector (34-31249966, default = 2508800) or {+-}size{KMGTP}: ❸
Last sector (2508800-31249966, default = 31249966) or {+-}size{KMGTP}: ❹
Current type is 'Linux filesystem'
Hex code or GUID (L to show codes, Enter = 8300): 8e00 ❺
Changed type of partition to 'Linux LVM'

Command (? for help): p ❻
Disk /dev/sda: 31250000 sectors, 14.9 GiB
Logical sector size: 512 bytes
Disk identifier (GUID): D3D40B34-6318-4815-9C14-7E5605FD5815
Partition table holds up to 128 entries
First usable sector is 34, last usable sector is 31249966
Partitions will be aligned on 2048-sector boundaries
Total free space is 2014 sectors (1007.0 KiB)

Number  Start (sector)    End (sector)  Size       Code  Name
   1            2048          411647   200.0 MiB   EF00  EFI System Partition
   2          411648         2508799   1024.0 MiB  0700  
   3         2508800        31249966   13.7 GiB    8E00  Linux LVM

Command (? for help): w ❼

Final checks complete. About to write GPT data. THIS WILL OVERWRITE EXISTING
PARTITIONS!!

Do you want to proceed? (Y/N): y ❽
OK; writing new GUID partition table (GPT) to /dev/sda.
Warning: The kernel is still using the old partition table.
The new table will be used at the next reboot.
The operation has completed successfully.
  • ❶ … 新しいパーティションを作成します。
  • ❷ … パーティション番号を指定します。空白のままでかまいません。
  • ❸ … 開始セクターを指定します。空白のままでかまいません。
  • ❹ … 終了セクターを指定します。空白のままでかまいません。
  • ❺ … パーティションタイプを指定します。Linux LVM は 8e00です。
  • ❻ … パーティションテーブルを確認します。新しく作成されたパーティションが拡張されていることを確認します。
  • ❼ … 変更をディスクに書き込みます。
  • ❽ … yを指定して書き込みを実行します。

新しいパーティションテーブルをシステムに反映させるためにシステムを再起動します。

[root@localhost ~]# reboot

再起動したらルートファイルシステムが含まれるパーティションのサイズ(ここでは /dev/sda3)が拡張されていることを確認します。

[root@localhost ~]# lsblk
NAME            MAJ:MIN RM  SIZE RO TYPE MOUNTPOINT
sda               8:0    0 14.9G  0 disk 
├─sda1            8:1    0  200M  0 part /boot/efi
├─sda2            8:2    0    1G  0 part /boot
└─sda3            8:3    0 13.7G  0 part 
  ├─centos-root 253:0    0  5.5G  0 lvm  /
  └─centos-swap 253:1    0  764M  0 lvm  [SWAP]
sr0              11:0    1 1024M  0 rom  

この時点ではまだファイルシステムは拡張されていません。

[root@localhost ~]# df -h
Filesystem               Size  Used Avail Use% Mounted on
/dev/mapper/centos-root  5.5G  3.7G  1.9G  67% /
devtmpfs                 479M     0  479M   0% /dev
tmpfs                    496M     0  496M   0% /dev/shm
tmpfs                    496M  7.1M  489M   2% /run
tmpfs                    496M     0  496M   0% /sys/fs/cgroup
/dev/sda2               1014M  150M  865M  15% /boot
/dev/sda1                200M   12M  189M   6% /boot/efi
tmpfs                    100M     0  100M   0% /run/user/0

続いて、LVM の物理ボリュームを拡張します。pvsコマンドや pvdisplayコマンドで物理ボリュームのサイズを確認しておきます。

[root@localhost ~]# pvs
  PV         VG     Fmt  Attr PSize PFree
  /dev/sda3  centos lvm2 a--  6.25g    0 

pvresizeコマンドを使って物理ボリュームを拡張します。

[root@localhost ~]# pvresize /dev/sda3
  Physical volume "/dev/sda3" changed
  1 physical volume(s) resized or updated / 0 physical volume(s) not resized

再度 pvsコマンドや pvdisplayコマンドで物理ボリュームが拡張されていることを確認します。

[root@localhost ~]# pvs
  PV         VG     Fmt  Attr PSize  PFree
  /dev/sda3  centos lvm2 a--  13.70g 7.45g

物理ボリュームが拡張できたら論理ボリュームの拡張を行います。lvsコマンドや lvdisplayコマンドで論理ボリュームのサイズを確認しておきます。

[root@localhost ~]# lvs
  LV   VG     Attr       LSize   Pool Origin Data%  Meta%  Move Log Cpy%Sync Convert
  root centos -wi-ao----   5.50g                                                    
  swap centos -wi-ao---- 764.00m                                                    

lvextendコマンドで空き領域すべてを使って拡張します。--resizefsオプションを加えておくとファイルシステムの拡張まで一緒に行ってくれます。

[root@localhost ~]# lvextend -l +100%free --resizefs /dev/centos/root
  Size of logical volume centos/root changed from 5.50 GiB (1409 extents) to <12.96 GiB (3317 extents).
  Logical volume centos/root successfully resized.
meta-data=/dev/mapper/centos-root isize=512    agcount=4, agsize=360704 blks
         =                       sectsz=512   attr=2, projid32bit=1
         =                       crc=1        finobt=0 spinodes=0
data     =                       bsize=4096   blocks=1442816, imaxpct=25
         =                       sunit=0      swidth=0 blks
naming   =version 2              bsize=4096   ascii-ci=0 ftype=1
log      =internal               bsize=4096   blocks=2560, version=2
         =                       sectsz=512   sunit=0 blks, lazy-count=1
realtime =none                   extsz=4096   blocks=0, rtextents=0
data blocks changed from 1442816 to 3396608

lvsコマンドまたは lvdisplayコマンドで論理ボリュームが拡張されていることを確認します。

[root@localhost ~]# lvs
  LV   VG     Attr       LSize   Pool Origin Data%  Meta%  Move Log Cpy%Sync Convert
  root centos -wi-ao---- <12.96g                                                    
  swap centos -wi-ao---- 764.00m                                                    

最後にファイルシステムが拡張されていることが確認できれば作業完了です。

[root@localhost ~]# df -h
Filesystem               Size  Used Avail Use% Mounted on
/dev/mapper/centos-root   13G  3.7G  9.3G  29% /
devtmpfs                 479M     0  479M   0% /dev
tmpfs                    496M     0  496M   0% /dev/shm
tmpfs                    496M  7.1M  489M   2% /run
tmpfs                    496M     0  496M   0% /sys/fs/cgroup
/dev/sda2               1014M  150M  865M  15% /boot
/dev/sda1                200M   12M  189M   6% /boot/efi
tmpfs                    100M     0  100M   0% /run/user/0

macOS でルートファイルシステムを拡張したい

$
0
0

macOSSierra用にディスクイメージを 25 GB で作成したんだけど、Xcode入れようとしたら案の定足りなかったので拡張作業。

HFS+J の拡張は diskutil resizeVolumeで出来るが、実行してみると「diskutil repairDiskを行ってね」と言われるので拡張対象のディスクに対して repairDiskを実行する。

Terminal

diskutil repairDisk disk0

resizeVolumeでは拡張後のサイズを直接していすることも出来るけど、Rを指定すると自動的に最大サイズにしてくれるんだそうな。ディスクの指定はパーティションで指定する方法とボリューム名で指定する方法がある。この辺は事前に diskutil listで確認しておく。

Terminal

# パーティションで指定する場合
diskutil resizeVolume disk0s2 R

# ボリューム名で指定する場合
diskutil resizeVolume 'Macintosh HD' R

Wine 4.7 版 EasyWine Nihonshu をプレリリースしました

$
0
0

さっき GitHubに上げようとしたら「前回出したの去年の8月じゃん…」と驚いた作者です。こんにちは。

今年の GW は長かったので(といっても仕事してましたけど)久しぶりに MacBook Air Late 2010 をメンテしたので開発環境を作り直しました。自分が使ってたのが Mavericksだったんですが、気づいたら Yosemite、El CapitanSierra、High Sierra、Mojave と、どんどん新しいバージョンが出てたんですね(トリプル見たら x86_64-apple-darwin17とかになっててびっくり)。とりあえず私の MacBook Airが対応しているのが High Sierraまでなので High Sierraまで上げたんですが、最新の Xcodeで 32-bit がサポート対象外に。「まぁ Wine も 64-bit にする予定だし別にいいかな」と思ってたんですが、64-bit でコンパイルしてもほとんどの EXE ファイルが Internal Error で開けず。もしかしてローダが 32-bit でなければ開けないのでは?と予想しているところです。

とりあえず今年の GW を使ってバージョンを上げすぎない程度に Yosemite、El CapitanSierraを試し、Xcodeも 8 系と 9 系を試したり。かなり時間がかかりましたが、とりあえず High Sierraでも Xcode 8 系であれば 32-bit のコンパイルが出来そうだったのでこの辺で様子を見ようと思っています。条件付きで仮想環境への macOSのインストールもライセンス的に OK というのがわかったので Mojave でも開発は続けられそうです。(Windowsと違って macOSは外付け HDD でも起動出来るのでそれほど困らないですけどね)

今回の変更点としては Wine と依存関係のライブラリのバージョンアップがありますが、上記の通り開発環境が変わったため未知の不具合が出る可能性があります。また、今回のバージョンから EasyWine の多重起動ロックが外れています。EasyWine のアイコンがドックに常駐しないため、エラーで強制終了しているかどうかがわからなくなっていますが仕様です。それから macOSのメニューバーの表示/自動非表示のデフォルト動作が表示に変わりました。以前の動作の方が好みという場合は winemac_autohide.drv.soに差し替えてください。

現状の 64-bit 対応は 32-bit とのユニバーサルバイナリで生成する必要がありそうなのでなかなかハードルが高く、libtool にも手を焼きそうな気がしていますが、毎日コンパイルだけをして過ごしていた日々を思い出しながらなんとか達成したいなと思っています。

matome.naver.jp


ところで、今年中に Wine 系の情報をまとめた同人誌を作ろうと思っています。私自身はまだまだ東方にわか勢なのですが、macOSLinuxで東方系ゲームをプレイする内容をメインにしたいな、と。時期的には秋例大祭くらいでしょうか。

ただ、本来の推奨動作環境と異なる環境で実行することになるので上海アリス幻樂団様や黄昏フロンティア様の承諾を得たりする必要があるのかないのか…と少し悩んでいます。製作者様にご迷惑がかかるようなことがあれば私が作っているものだけでなく、Wine プロジェクトの印象が悪くなってしまうので難しいところです(「お前ごとき人間が本を出したところで社会への影響なぞ何もないわ!」って感じならいいのですけど)。皆が楽しく遊べるようになってくれるのが理想ですね。

魔理沙がエンジニアで登場する漫画も描きたいと思っていますが、そもそも例大祭に技術書枠あるのか…?🤔


宣伝

昨年10月に窓の杜さんで EasyWine を紹介していただきました。Windowsを使っていた時代は窓の杜さんに大変お世話になっていましたが、まさか自分が作ったものが載るとは思っていなかったのでびっくりしました。(しかも畑違いの macOSで)

forest.watch.impress.co.jp

64-bit アプリケーション対応版 Wine をリリースしました

$
0
0

3 TB の HDD が壊れかけて一部読み取れなくなったけど家の中の空き容量が 2 TB しかなくてさぁ困ったぞという状態の作者です。

タイトルの通りですが、64-bit の Windowsアプリケーションに対応した Wine をリリースしました。いつものところからどうぞ。

matome.naver.jp

GW くらいからずっと製作には入っていたんですが、新しい開発環境作ったりとか久しぶりにユニバーサルバイナリを作ったりとかで滅茶苦茶手こずりました。しかも Wine の 64-bit は 32-bit もコンパイルしなきゃいけないので 32-bit だけだった今までの倍のコンパイルが必要になるんです。50 回以上は Wine のコンパイルをしたと思います。本当に ccache 様様でした。この辺の苦労話は書いておかないと自分でも忘れそうなので後で書こうと思います。

新しい Wine というか Nihonshu と EasyWine は 64/32/16-bit の Windowsアプリケーションに対応しています。今後はこの形式でビルドしていくのでこれまでの 32-bit 版については開発を終了することにしました。ですので前回分が最後のリリースということになります。

最初は Wine 4.7 と 4.8 のソースを使ってたんですが、どうも Wine エクスプローラが落ちる…。依存関係やらコンパイル方法とか色々試したんですが改善せず、ソースを 4.0 まで戻したら普通に動きました。ですので今回の Wine のソースは Stable の 4.0.1 を使用しています。

以下、64-bit 対応版を Nihonshu64 と EasyWine64 とします。ざっくりした変更点とかはダウンロードのページでご確認ください。

まず、macOSの 対応状況ですが、上は Mojave で普通に動きました。あと、Mojave で 32-bit 版のコンパイルも出来たのでまだ暫くは開発も大丈夫かなと思っています。Mojave では Xcodeに 32-bit が含まれなくなったので MacPortsと Homebrew で Wine のビルドが出来なくなっているので Wine の導入が難しくなってるみたいですね(Homebrew の方はバイナリリリースで対応しているみたいですが)。下は El Capitanまでは確認しましたが Yosemite や Mavericksではどうだかわかりません。

今回からライブラリをユニバーサルバイナリ化したため、全体的にファイルサイズが増えています。圧縮ファイルで 100 MB 前後、展開すると 500 MB 近くになります。WINEPREFIX の作成にも 700 MB くらい容量を使いますのでストレージの空き容量にご注意ください。また、Geckoは 32-bit と 64-bit が必要になるのでインストールのダイアログが 2 回表示される点にも注意してください。

Nihonshu64 と EasyWine64 は 64-bit 専用ではなく、64-bit モードとして起動します。そのため、32-bit アプリケーションも動かすことが出来るというわけです。しかし、WINEPREFIX は 32-bit と 64-bit で共有出来ないため、既に WINEPREFIX が 32-bit 環境用として作成されている場合は新たに 64-bit 環境用の WINEPREFIX を作成しなくてはなりません。EasyWine ではどうしても WINEPREFIX を変える必要があったため、64-bit モードの場合は WINEPREFIX が ~/Library/Caches/Wine/prefixes/default64がになります。データの引き継ぎ等は出来ないため新しくアプリケーションをインストールするか、32-bit の WINEPREFIX からコピーするなどしてください。

公式のドキュメントにも載っていますが、Nihonshu64 ユーザであれば下記のように実行することで 32-bit の WINEPREFIX を作成することができます。

WINEARCH=win32 WINEPREFIX=$HOME/.wine32 nihonshu explore

EasyWine64 の場合はアプリケーション名を見て 32-bit と 64-bit の動作が切り替わるようになっています。デフォルトは EasyWine64.appですので 64-bit モードで動作します。これを EasyWine32.appなどにリネームすることで WINEARCH=win32の指定が出来ます。正確には EasyWine64.appという名前以外であればすべて 32-bit モードになります。

EasyWine64 はこれまで通り DMG形式での配布となりますが、ディスクイメージから直接すると激的に遅くなることが確認されていますので、必ずデスクトップなどの内蔵のストレージ等にコピーしてからご利用ください。相対パスで動作するようにしてあるので /Applicationsに入れる必要はありません。

既知のバグで一部で日本語の文字間幅がおかしくなっています(例えばメモ帳とか)。これは Linuxでも同じなのでフォント云々よりは Wine のソース側の問題かなと思っています。Windowsには詳しくないのでここがどのライブラリを使用しているのかわかりません。Wine 3.x 系のときは問題なかった気がするので Wine 4.x 系からですかね。

64-bit 対応版製作時の苦労話とか

clang のオプション変わってたのに気づかなかった

ずっと gccを使っていたので LDFLAGS-Wl,-arch,i386とか -Wl,-arch,x86_64って設定してました。最近の clang 使っててどうもユニバーサルライブラリが作れないなー?と思ってたら無視されてました。最近の Xcodeだと 32-bit 対応してないよという先入観があったので 3 日くらい気づかなかったと思います。

Mojave でも 32-bit の Wine はコンパイル出来るよ!古い Xcodeならね!

上記の clang のオプションの罠に気づかず、Xcode 8 系 と Xcode 9 系両方試しました。普通にユニバーサルで作れましたありがとうございました。

複数のバージョンの Xcode.app を使い分ける場合は xcode-select --switchで切り替えれば良いです。SDKROOT環境変数とかは設定した方が無難ですね。

mkdir -p /Applications/Xcode_9
cd /Applications/Xcode_9
xip -x Xcode9のアーカイブ.xip
xcode-select -s /Applications/Xcode_9/Xcode.app

ライブラリの話

ユニバーサルライブラリを作成するにあたって、gnutls とかのライブラリも増やそうかなぁと思ってたんですが、otoolDYLD_PRINT_LIBRARIESで確認したらどうも libjpeg とか libtiff、libusb あたりって使われてないんですよね…。macOSだとフレームワークが大量にあり、ImageIO.framework の中に libjpeg や libtiff 相当のものが入ってるので要らないのでは…ということでライブラリを最小限の libpng、freetype、Little-CMSに絞りました。もしかしたらこれによって何らかの不具合が出るかもしれません。gnutls のユニバーサルライブラリのコンパイルは本当にしんどいので出来ればやりたくないですね。いまはカメラ系もやっていないので gphoto とかはリクエストがあれば入れようかと思います。

freetypeはちょっと弄ったんですが、あまり違いがわからないですね。自分が持ってる MacBook Airの 1366x768 では今回新しく内蔵したフォントとは相性いいと思ったんですけど Retinaで見たらそれほどでもなかったです。Cairo とか使えれば滅茶苦茶綺麗になるんですけどね。

フォントの話

いままでなんとかヒラギノで置き換えようと思ってきたんですが、どうも細くて見づらい…。あとなんか新しい macOSになってシステムフォント増えてる…。もうシステムフォントでやりくりするのツライ…。

ってなったのでゴシック系だけフォントを差し替えました。IPAMona ではないです。ライセンスが面倒なので。しばらくこの辺は調整する時間が無さそうなので現状のままで我慢していただくか自分で何とかしてください。

ちなみに AviUtl は解像度の低いディスプレイで開くと一部のダイアログが画面外まで伸びるためボタンが押せないことがわかっています。

メニューバーが自動的に隠れるのを辞めた

不満を言う人がいるので…。まぁ不満がある人しかネットに書かないので不満しか目に入らないわけですが。

1366x768 のディスプレイだとメニューバーのせいで 1280x800 のウィンドウで下が切れるんですよ!Retinaとかセレブなディスプレイ使ってる人にはわからないでしょうよ!!!

という思いもありますが、パッチの整理も兼ねて一旦デフォルトに戻しました。ご意見あればマシュマロあたりにどうぞ。

ゲームパッド買いました

Wine でゲームパッドって試したことなかったので HORI の PS4用のワイヤレスコントローラーライトというのを買ってみました。これ色々と曲者だったので別記事で書く予定ですが、macOSでは多分使えません。Linuxでは有線でも無線でも使えます。ただ、有線だと 11 ボタンで、無線だと 15 ボタンになるという謎仕様です(他にもいっぱい謎仕様ある)。macOSでは Amazonで売ってる怪しい Buffalo のクラシックコントローラー(SFC)を使っています。アナログジョイスティックよりやっぱり十字キー派ですね。

私はいつまで Wine を作り続けるのか

ここまで読んだそこのあなた、物好きですね。残念ながらお得なクーポンコードとかはありませんよ。

以前も書きましたが私はもう Macユーザではないです。Linuxでも Wine は使っていますが、Windowsから送られてきた ZIP ファイルを 7-Zipで開く程度にしか使っていません。(最近ちょっと東方やるようになりましたけど…)

そろそろ皆さんも Nihonshu や EasyWine を卒業しませんか?

先日、公式の macOSバイナリの中身を覗いてみたんですよ。そしたらライブラリの日付とかが滅茶苦茶古いし、余計なファイルも入ったまま。まぁ動くんですけど。

Homebrew を使ったほうが皆さんのテクニカルスキルの向上にも繋がりますし、あちらにはメンテナンスしてくれる人もたくさんいます。それはもう世界中にたくさん。勝てるわけないじゃん。

CrossOver や PlayOnMac なんて素晴らしいアプリケーションもあるじゃないですか。おい、WineSkin さぼってんじゃないよ。

Wikipediaにも載っていないような怪しいソフトをいつまで使い続けますか?そのソフトは密造酒ではありませんか?

先日、知り合いに素で作者であることがバレました。別に隠してたわけでもないんですけど。いつ脅されるか気になって夜も寝られません。嘘です。ぐっすり寝てます。で、なんとなくそこでバレたのがそろそろ転機かな?みたいに思ったわけです。

今年は何か新しいことをしようと思ったので Wine の同人誌でも作ろうと思っていました。昔書いた記事とかも整理してそろそろ消したいので。改めて検証したりして、いまでも有効なものは同人誌の方にまとめようかな、と。

その作ろうとしてる同人誌は『Macでも東方で遊ぼう!』みたいな本になるかもしれないのですが、色々難しいかなと思っています。「同人誌なんだから好きなこと書けばいいじゃない」という意見もあるかと思いますが、製作者様の意図にそぐわないものは出せませんよね。

私は 2chとか日常的に見る人間ではないのですが、追加対応とかのためにたまにエゴサ的なことはしてます。2chを見ると「あぁ、2chのユーザーにも使ってくれてる人がいるのだなぁ」と嬉しく思います。それはいいんです。ですが、某初心者向け掲示板の書き込み内容のレベルがちょっとひどくて、回答者の方々に手間をかけるだけでなく、これはもしや問い合わせがいろんなソフト開発者様に行ってしまうのではないかと危惧しています。

ソフトの開発者様にも色々な方がいらっしゃると思います。「Macでも使ってもらえるなら歓迎ですよ」という方もいれば「動作環境に Windowsって書いてあるのになんでそういうことを広めようとするんですか?」という方もいらっしゃると思います。

以前、「EasyWine はバンドル化に対応していない」という書き込みを目にしたことがありますが、バンドル化の機能を実装しないのには理由があります。Wine は Windows OS を無限に複製出来ると言っても過言ではありません。安易にバンドル化の機能を実装するとバンドル化したそれを安易に配布してしまう人が必ず出ててくると思っているので実装していません。それを言ったら仮想化ソフトウェアは皆同じですし、既に Wine に関してはネット上にたくさんの情報があるので今更そんなこと考えても…と思ったりもするのですが。

この辺詳しい方がいらっしゃればマシュマロとかでご意見いただけたらな、と。

おっと、もう寝ないと(。ŏ﹏ŏ)

Viewing all 891 articles
Browse latest View live