mattintosh note

技術書典に出たい人生だった…

USB メモリから CentOS 7 が起動できない場合

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 と比べると全体的にもっさりした動き。

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

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 系に無いのが辛いがこれは試験に出ない)

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

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

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

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

YouTube Data API と Elasticsearch を使って Aggregation Query を学ぶ

最近 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

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

Python 3

MAX_LENGTH = 50

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

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

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

あとはこのリストをまとめて urllib.parse.urlencode() とかで変換するんですが、ポイントとしては fields(),safe キーワードで指定しておかなきゃいけないところですかね。Python のコードの方はスクラッチで書いたものなのでいまのところあんまり凝って書いてはいません。

Python 3

    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
    ])

では取れたデータを見てみます。実際には {"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つでしょうか。

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

Kibana のテーブルで表現するとこんな感じのデータです。Kibana ではここから Serial Diff や Derivative の最大値を取ってソートキーとしては恐らく使用できないと思うのですが、Elasticsearch に投げるクエリでは指定が可能です。

f:id:mattintosh4:20190318234415p:plain
Kibana - Visualize

まずは上の画像の通りのデータを取り出します。これは Aggregation Query で前日と当日の max を取り、それらの Bucket を Pipeline Aggregations の serial_diffderivative に渡します。

terms: snippet.title
|
+-- date_histogram: @timestamp
    |
    +-- [0]
    |   |
    |   +-- max: statistics.subscriberCount -----.
    |   |                                        |
    |   +-- max: statistics.viewCount --------.  +--> serial_diff: subscriberCount
    |                                         |  |
    +-- [1]                                   +--|--> serial_diff: viewCount
        |                                     |  |
        +-- max: statistics.subscriberCount -----'
        |                                     |
        +-- max: statistics.viewCount --------'

Pipeline Aggregation は何かの結果を元に計算をするので field ではなく buckets_path で任意で決めたフィールド名を指定します。では前日と当日の差分を出すクエリを書いてみます。

※ここでは例として snippet.title で チャンネル名を使っていますが、チャンネル名は変わることがあるので id でチャンネル ID を使った方がいいこともあります。

terms.order 部分は {"_term": "Sort Order"} でしたが {"_key": "Sort Order"} に変わるようです。

Query

{
  "size": 0,
  "query": {
    "query_string": {
      "default_field": "@timestamp",
      "query": "[now-1d/d TO now]"
    }
  },
  "aggs": {
    "チャンネル": {
      "terms": {
        "field": "snippet.title",
        "size": 2,
        "order": {
          "_term": "asc"
        }
      },
      "aggs": {
        "バケット": {
          "date_histogram": {
            "field": "@timestamp",
            "interval": "day"
          },
          "aggs": {
            "チャンネル登録者数": {
              "max": {
                "field": "statistics.subscriberCount"
              }
            },
            "チャンネル登録者数差分": {
              "serial_diff": {
                "buckets_path": "チャンネル登録者数"
              }
            },
            "再生回数": {
              "max": {
                "field": "statistics.viewCount"
              }
            },
            "再生回数差分": {
              "serial_diff": {
                "buckets_path": "再生回数"
              }
            }
          }
        }
      }
    }
  }
}

Result

{
  "took" : 4,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : 241,
    "max_score" : 0.0,
    "hits" : [ ]
  },
  "aggregations" : {
    "チャンネル" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 237,
      "buckets" : [
        {
          "key" : "A.I.Channel",
          "doc_count" : 2,
          "バケット" : {
            "buckets" : [
              {
                "key_as_string" : "2019-03-17T00:00:00.000Z",
                "key" : 1552780800000,
                "doc_count" : 1,
                "再生回数" : {
                  "value" : 1.96169102E8
                },
                "チャンネル登録者数" : {
                  "value" : 2475603.0
                }
              },
              {
                "key_as_string" : "2019-03-18T00:00:00.000Z",
                "key" : 1552867200000,
                "doc_count" : 1,
                "再生回数" : {
                  "value" : 1.9636056E8
                },
                "チャンネル登録者数" : {
                  "value" : 2476558.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 955.0
                },
                "再生回数差分" : {
                  "value" : 191458.0
                }
              }
            ]
          }
        },
        {
          "key" : "A.I.Games",
          "doc_count" : 2,
          "バケット" : {
            "buckets" : [
              {
                "key_as_string" : "2019-03-17T00:00:00.000Z",
                "key" : 1552780800000,
                "doc_count" : 1,
                "再生回数" : {
                  "value" : 9.7609238E7
                },
                "チャンネル登録者数" : {
                  "value" : 1306572.0
                }
              },
              {
                "key_as_string" : "2019-03-18T00:00:00.000Z",
                "key" : 1552867200000,
                "doc_count" : 1,
                "再生回数" : {
                  "value" : 9.7753671E7
                },
                "チャンネル登録者数" : {
                  "value" : 1307363.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 791.0
                },
                "再生回数差分" : {
                  "value" : 144433.0
                }
              }
            ]
          }
        }
      ]
    }
  }
}

Aggregations の結果を絞り込む

今度はグラフ用のデータを取り出す必要があったんですが、チャンネルの件数が多いので Aggregations の結果から「上位○○件」みたいな絞り込みをしようと思いました。やや複雑になりますが順番と書き方のコツさえ掴めばそんなに難しくはないはずです。

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

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

buckets_path> 記号を使っていますが、これは比較演算子ではなくて Aggregation Separator です。stats などを使った場合は Metric Separator の . を使って .avg のように指定するようです。色々試したら Aggregation Separator を . で置き換えても動作するみたいですが、Metric Separator を > で置き換えるとエラーになりました。詳しくは https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline.html#buckets-path-syntax を参照するとよいかと。

Query

{
  "size": 0,
  "query": {
    "query_string": {
      "default_field": "@timestamp",
      "query": "[now-7d/d TO now/d]"
    }
  },
  "aggs": {
    "チャンネル": {
      "terms": {
        "field": "snippet.title",
        "size": 500
      },
      "aggs": {
        "バケット": { ❶
          "date_histogram": {
            "field": "@timestamp",
            "interval": "day"
          },
          "aggs": {
            "チャンネル登録者数": { ❶
              "max": {
                "field": "statistics.subscriberCount",
                "missing": 0
              }
            },
            "チャンネル登録者数差分": { ❷
              "serial_diff": {
                "buckets_path": "チャンネル登録者数"
              }
            },
            "チャンネル登録者数差分累積和": { ❸
              "cumulative_sum": {
                  "buckets_path": "チャンネル登録者数差分"
                }
            }
          }
        },
        "最大チャンネル登録者差分(参考値)": {
          "max_bucket": {
            "buckets_path": "バケット>チャンネル登録者数差分"
          }
        },
        "最大チャンネル登録者差分累積和": { ❹
          "max_bucket": {
            "buckets_path": "バケット>チャンネル登録者数差分累積和"
          }
        },
        "ソート条件": { ❺
          "bucket_sort": {
            "sort": [
              {
                "最大チャンネル登録者差分累積和": {
                  "order": "desc"
                }
              }
            ],
            "from": 0,
            "size": 2 ❻
          }
        }
      }
    }
  }
}

※上記のクエリで [now-7d/d TO now/d] としていますが、執筆時点では5日分しかデータが集まっていないため結果は5日分となっています。

Serial Diff や Derivative の場合、バケットの最初のオブジェクトには差分の対象がないためキーが存在しないので注意です。

Result

{
  "took" : 60,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : 513,
    "max_score" : 0.0,
    "hits" : [ ]
  },
  "aggregations" : {
    "チャンネル" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {
          "key" : "由宇霧ちゃんねる",
          "doc_count" : 5,
          "バケット" : {
            "buckets" : [
              {
                "key_as_string" : "2019-03-14T00:00:00.000Z",
                "key" : 1552521600000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 15823.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 0.0
                }
              },
              {
                "key_as_string" : "2019-03-15T00:00:00.000Z",
                "key" : 1552608000000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 17682.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 1859.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 1859.0
                }
              },
              {
                "key_as_string" : "2019-03-16T00:00:00.000Z",
                "key" : 1552694400000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 22277.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 4595.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 6454.0
                }
              },
              {
                "key_as_string" : "2019-03-17T00:00:00.000Z",
                "key" : 1552780800000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 26961.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 4684.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 11138.0
                }
              },
              {
                "key_as_string" : "2019-03-18T00:00:00.000Z",
                "key" : 1552867200000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 35666.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 8705.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 19843.0
                }
              }
            ]
          },
          "最大チャンネル登録者差分(参考値)" : {
            "value" : 8705.0,
            "keys" : [
              "2019-03-18T00:00:00.000Z"
            ]
          },
          "最大チャンネル登録者差分累積和" : {
            "value" : 19843.0,
            "keys" : [
              "2019-03-18T00:00:00.000Z"
            ]
          }
        },
        {
          "key" : "御伽原 江良 / Otogibara Era【にじさんじ】",
          "doc_count" : 5,
          "バケット" : {
            "buckets" : [
              {
                "key_as_string" : "2019-03-14T00:00:00.000Z",
                "key" : 1552521600000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 20036.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 0.0
                }
              },
              {
                "key_as_string" : "2019-03-15T00:00:00.000Z",
                "key" : 1552608000000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 20951.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 915.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 915.0
                }
              },
              {
                "key_as_string" : "2019-03-16T00:00:00.000Z",
                "key" : 1552694400000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 24114.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 3163.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 4078.0
                }
              },
              {
                "key_as_string" : "2019-03-17T00:00:00.000Z",
                "key" : 1552780800000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 26696.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 2582.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 6660.0
                }
              },
              {
                "key_as_string" : "2019-03-18T00:00:00.000Z",
                "key" : 1552867200000,
                "doc_count" : 1,
                "チャンネル登録者数" : {
                  "value" : 28731.0
                },
                "チャンネル登録者数差分" : {
                  "value" : 2035.0
                },
                "チャンネル登録者数差分累積和" : {
                  "value" : 8695.0
                }
              }
            ]
          },
          "最大チャンネル登録者差分(参考値)" : {
            "value" : 3163.0,
            "keys" : [
              "2019-03-16T00:00:00.000Z"
            ]
          },
          "最大チャンネル登録者差分累積和" : {
            "value" : 8695.0,
            "keys" : [
              "2019-03-18T00:00:00.000Z"
            ]
          }
        }
      ]
    }
  }
}

上記の結果を Chart.js などでグラフにするとこうなります。(現在は Elasticsearch から全チャンネルのデータを取り出し JavaScript 側で数量を指定しています)

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

Cumulative Sum を取得する関係で Serial Diff を抽出しているので Serial Diff の値からヒートマップを作れたりもします。

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


次回は AWS と Vue.js について書く予定。