にゃみかんてっくろぐ

猫か百合を見守る壁になりたい

VTuber が歌った曲をまとめたい 後半(実装編)

VTuber が歌った曲をまとめたい 前半(検討編) の続きです。

前半(検討編) のおさらい

  • VTuber のすべての動画から、過去どんな曲を歌っていたか列挙したい
  • 複数案を検討し、「動画コメント欄の時間指定コメントを使う」案で進むことにした

ざっくりイメージ

「チャンネル URL から曲目データベースができるまで」のざっくりイメージがこちらです。

f:id:no_clock:20210118225237p:plain

YouTube Data API v3 をフル活用し、 YouTube チャンネルを指定するだけで動画・コメントデータが抽出できるようにします。

ただし、「時間指定コメント」には曲以外のコメントも含まれるため、 データベース化するまでに手作業を挟みます。

データ抽出 (Ruby)

YouTube Data API Reference を参照してゴリゴリ書くだけです。書いたコードは GitHub にあります。

meliuta/utils/youtube-data-api-v3 at main · typewriter/meliuta · GitHub

以下は注意点です。

API クォータ

Quota usage | YouTube Data API Overview  |  Google Developers

  • YouTube Data API は標準で 10,000 queries / day に制限されています。
  • 今回使用する API のクォータ消費量はすべて 1 query / request のようです。日本語版ドキュメントにはパーツごとに追加消費がある旨の記述がありますが、英語版にはありません。実際に触ってみても追加消費があるように感じられませんでした。

チャンネル URL → アップロード済み動画 プレイリスト

Channels: list  |  YouTube Data API  |  Google Developers

  • チャンネル URL の表現方法は 3 種類あります。チャンネル ID (/channel/UCxxx) 、ユーザ名 (/user/xxx) 、 カスタムチャンネル URL (/c/xxx) です。このうち、カスタムチャンネル URL は非対応ですが、後述する Videos: list API に任意の動画 ID を投げるとチャンネル ID が取得できます。
  • 次のステップで用いるプレイリスト ID は、レスポンスに含まれる contentDetails.relatedPlaylists.uploads の値を使用します。

f:id:no_clock:20210118233847p:plain
チャンネル URL の例(上から OSTER projectテスロ&ロゼットあらゐけいいちMew Project

アップロード済み動画 プレイリスト → プレイリストアイテム

PlaylistItems: list  |  YouTube Data API  |  Google Developers

  • 50 を超える動画は 1 回では取得できません。レスポンスの nextPageToken がなくなるまで繰り返し取得する必要があります。
  • レスポンスに含まれる snippet.publishedAt はプレイリストへの追加日時であり、動画の公開日時とは異なります。
  • 次のステップで用いる動画 ID は、レスポンスに含まれる contentDetails.videoId の値を使用します。

プレイリストアイテム → 動画情報

Videos: list  |  YouTube Data API  |  Google Developers

  • レスポンスのサムネイル (snippet.thumbnails.(key)) のキーに注意します。日本語版は default, medium, high の 3 種類しかありませんが、英語版のドキュメントには standard, maxres も記述されており、こちらが正確です。大きい順に maxres, standard, high medium, default です。

動画情報 → コメント情報

CommentThreads: list  |  YouTube Data API  |  Google Developers

  • リクエストパラメタの orderrelevance にすると、 YouTube サイト上での「評価順 (Top comments)」と同じ順序になるようです。
  • PlaylistItems: list と同様、 100 を超えるコメントは 1 回では取得できません。レスポンスの nextPageToken を利用して繰り返し取得します。
  • 曲目を列挙したコメントは評価が高いケースも多いため、 API クォータを意識して途中で打ち切るのも手です。

コメント情報 → 時間指定コメントの抽出

f:id:no_clock:20210117033635p:plain
【歌】今度こそちょっとだけ歌う配信【戌亥とこ/にじさんじ】 コメント欄より引用

  • コメントの snippet.textOriginal正規表現 /(d+:\d+:\d+|\d+:\d+)/ に掛けるだけのシンプルな方法で抽出します。
  • コメントは自由形式で、曲名が同一行にあったり次行にあったりします。あまり最適化をせず、時間指定コメントをとにかく抽出することに割り切っています。

データベース化(手作業)

抽出したデータからデータベースを作っていきます。これは完全に手作業です。

  1. 曲名っぽい時間指定コメントを取捨選択
  2. 動画を再生して確認
  3. 曲名・アーティストなどを検索・記録

今回は素敵な素敵な「Google スプレッドシート」を使用しました。だいたい 1,800 コメントから 100 曲ほどをデータベース化できました。

f:id:no_clock:20210118230308p:plain
出来上がったデータベース (Google スプレッドシート)

なお、動画の時間指定リンクは https://www.youtube.com/watch?v=sshoDk2CQVQ&t=14118 のように、 t=秒数 を指定するだけです。

Web サイト化 (TypeScript, React)

こちらもゴリゴリ書くだけです。書いたコードは GitHub にあります。

API サーバを立てたくなかったため、フロントエンドでがんばる方向にしました。 Web サーバに生成物をポン置きするだけです。

  • データベースは tsv ファイル
  • TypeScript + React
    • tsv ファイルの取得・解析
    • material-table を使った表(検索・ページング機能含む)

出来上がった Web サイトがこちらです。

メリうた🐝 - メリッサ・キンレンカさんのお歌非公式まとめサイト

まとめ

  • YouTube Data API を用いてチャンネル URL から全動画の時間指定コメントを抽出しました。
  • 時間指定コメントをデータベース化し、 Web サイト化しました。

なお、開発にあたり レヴィ・エリファ アーカイブス から着想を得ました。この場を借りて御礼申し上げます。

VTuber が歌った曲をまとめたい 前半(検討編)

VTuber にハマり気味です。

その中で、「過去、どんな曲を歌っていたのかな」と知りたくなりました。その欲求を満たしていきます。

対象動画は「すべて」

対象の動画は「すべて」です。

ライブ配信する VTuber の配信スタイルは多種多様です。 はっきり「歌枠(カラオケ配信的なもの)」と題しているものもあれば、 そうでない配信で突発的にアカペラや歌が始まることもあります。

そのため、すべての動画(配信アーカイブ)を対象にします。

検討: 曲の列挙方法

曲の列挙方法はいくつか思い浮かびます。結論は [2.] ですが、そこへ至るために検討した内容を整理していきます。

方法 所要時間 実現可能性 網羅性
1. 配信アーカイブをすべて見る 長い 低い 高い
2. 動画コメント欄にある時間指定コメントを活用する 短い 高い 中くらい
3. ライブ配信のチャット欄メッセージを活用する - 不可 中くらい
4. YouTube の自動生成字幕を活用する - 不可 低い
5. 楽曲認識 API を使う 中くらい 中くらい 中くらい

1. 配信アーカイブをすべて見る

手作業で動画をすべて見ていく方法です。

これは非常に大変です。ほとんどの VTuber は配信アーカイブが 100 時間以上。 1000 時間を超える方も珍しくありません。

たくさん配信があるのは嬉しい限りですが、「曲を列挙する」ために見直すのはいささか辛いものがあります。

2. 動画コメント欄にある時間指定コメントを活用する

動画コメント欄にある、下記のような「時間指定コメント」を用いる方法です。

f:id:no_clock:20210117033801p:plain
【歌枠】ゆどうふってやっぱポン酢【メリッサ・キンレンカ/にじさんじ】 コメント欄より引用

YouTube Data API v3CommentThreads があり、 API で取得可能です。

網羅性はユーザのコメントに依存しますが、私の観測範囲では比較的高い確率で書かれているようです。

3. ライブ配信のチャット欄メッセージを活用する (不可)

ライブ配信の右側にあるチャット欄メッセージを用いる方法です。

f:id:no_clock:20210117024232p:plain
YouTube ライブ配信の画面構成

ただし、これは実現不可です。過去のチャット欄を取得する API がないためです。

API を確認すると、 YouTube Data API v3 Videos に次の記述があります。つまり、 activeLiveChatId があるのは配信中のみです。

liveStreamingDetails.activeLiveChatId

string
(省略) This field is filled only if the video is a currently live broadcast that has live chat. Once the broadcast transitions to complete this field will be removed and the live chat closed down. (省略)

activeLiveChatId がないため、チャットメッセージの取得 API (YouTube Live Streaming API / LiveChatMessages) は利用できません。

残るはスクレイピングですが、利用規約で禁止されています。

本サービスの利用には制限があり、以下の行為が禁止されています。

(省略)

3. 自動化された手段(ロボット、ボットネット、スクレーパなど)を使用して本サービスにアクセスすること。ただし、(a)公開されている検索エンジンYouTuberobots.txt ファイルに従って使用する場合、または(b)YouTube が事前に書面で許可している場合を除きます。

利用規約 (YouTube)

4. YouTube の自動生成字幕を活用する (不可)

自動生成されている字幕データを用いる方法です。

f:id:no_clock:20210117034221p:plain
【SNOW MIKU 公式曲】好き!雪!本気マジック feat. 初音ミク【Mitchie M】 (CC-BY 4.0)
(本動画の字幕は自動生成ではないが、字幕の表示例として掲載)

ただし、これも実現不可です。

YouTube Data API v3 Captions download で取得を試みると 403 が返却されます。

{
  "error": {
    "code": 403,
    "message": "The permissions associated with the request are not sufficient to download the caption track. The request might not be properly authorized, or the video order might not have enabled third-party contributions for this caption.",
    "errors": [
      {
        "message": "The permissions associated with the request are not sufficient to download the caption track. The request might not be properly authorized, or the video order might not have enabled third-party contributions for this caption.",
        "domain": "youtube.caption",
        "reason": "forbidden",
        "location": "id",
        "locationType": "parameter"
      }
    ]
  }
}

公式ドキュメントには明記されていませんが、本人もしくは字幕追加の協力者 (2020/9/28 をもって機能終了) でないとダウンロード出来ないのではないか、という話があるようです。

youtube api - Downloading captions always returns a 403 - Stack Overflow

スクレイピングは [3.] と同様で利用規約に引っかかります。

5. 楽曲認識 API を使う

音声データを API に投げて曲名を教えてもらう方法です。 ACRCloudAudD などが提供しています。ただ、いくつか問題があります。

認識精度。 別サービスですが Google アシスタントの「近くで流れている曲について調べる」機能をちょっと触った限りでは、一致率 (?) が低いものもありました。悪くはありませんが、十分とも言い難い印象です。

f:id:no_clock:20210117030806p:plain
ヴィーナスとジーザス (アカペラ配信) と KING (カバー) の認識結果

オリジナル曲の扱い。 楽曲データベースの構築方法はわかりませんが、 Google アシスタントだと何らかの商用配信が必要そうな気配があります。 YouTube にアップロードされているのみのオリジナル曲は認識されませんでした。

料金。 大量の配信アーカイブAPI に通すにはかなりのお金が掛かってしまいます。

前半のまとめ

ここまで、 VTuber が歌った曲を列挙する方法を検討してきました。表を再掲します。

方法 所要時間 実現可能性 網羅性
1. 配信アーカイブをすべて見る 長い 低い 高い
2. 動画コメント欄にある時間指定コメントを活用する 短い 高い 中くらい
3. ライブ配信のチャット欄メッセージを活用する - 不可 中くらい
4. YouTube の自動生成字幕を活用する - 不可 低い
5. 楽曲認識 API を使う 中くらい 中くらい 中くらい

挙げた方法の中では、「2. 動画コメント欄にある時間指定コメントを活用する」が比較的よさそうです。後半でこれを具現化していきます。

yarn/npm outdated は、古いパッケージがあると終了コード 1 を返すバージョンもある

コードリーディングのメモ。

まとめ

outdated コマンドで古いパッケージが見つかった場合の終了コード:

パッケージマネージャ バージョン リリース日 終了コード
Yarn >=0.26.0 2017/06/06 1
<0.26.0 - 0
npm >=7.0.0 2020/10/13 0
>=4.0.0 <7.0.0 2016/10/21 1
<4.0.0 - 0

※記事執筆時点の最新バージョンは、 Yarn 1.22.10 (2020/10/02), npm 7.3.0 (2020/12/19)

outdated コマンド

yarn outdated あるいは npm outdated は、 古いパッケージを表示してくれるコマンド。 upgrade コマンドと異なり、アップグレードされることはない。

$ npm outdated
Package                 Current  Wanted  Latest  Location
@holiday-jp/holiday_jp    2.2.3   2.3.0   2.3.0  yarn-update-check

これを CI で活用するとして、終了コードが 0 以外になればとてもお手軽だ。 outdated コマンドを CI で実行させておくだけで、最新バージョンがあると勝手に CI が失敗するようになる。

しかし、残念ながらドキュメントに終了コードの記載はない。実際の動きを確かめつつ、ソースコードで歴史を紐解く必要がある。

Yarn

Yarn は yarnpkg/yarn@c4f264f で終了コード 1 を出力するようになっている。タグを見る限り v0.26.0 (2017/6/6) のようだ。

yarn/outdated.js at master · yarnpkg/yarn · GitHub

export async function run(config: Config, reporter: Reporter, flags: Object, args: Array<string>): Promise<number> {
  // 略
  let deps = await PackageRequest.getOutdatedPackages(lockfile, install, config, reporter);
  // 略
  if (deps.length) {
    // 略
    return 1;
  }
  return 0;
}
$ yarn outdated
yarn outdated v1.22.10
info Color legend :
 "<red>"    : Major Update backward-incompatible updates
 "<yellow>" : Minor Update backward-compatible features
 "<green>"  : Patch Update backward-compatible bug fixes
Package                Current Wanted Latest Package Type URL
@holiday-jp/holiday_jp 2.2.3   2.2.3  2.3.0  dependencies https://github.com/holiday-jp/holiday_jp-js
Done in 0.11s.

$ echo $?
1

この変更の元となる Issue #3483 には、「 npm outdated はこういうとき 1 を返している」とある。

npm

>=4.0.0 <7.0.0

npm はリリースノートによれば v4.0.0 (2016/10/21) で終了コード 1 を出力するようになっている。

BRIEF OVERVIEW OF BREAKING CHANGES
(略)
- npm outdated exits with exit code 1 if it finds any outdated packages.

npm/outdated.js at v4.0.0 · npm/npm · GitHub

function outdated (args, silent, cb) {
  // 略
      if (er || silent || list.length === 0) return cb(er, list)
      // 略
      process.exitCode = 1
$ npm -v
6.14.0
$ npm outdated
Package                 Current  Wanted  Latest  Location
@holiday-jp/holiday_jp    2.2.3   2.2.3   2.3.0  yarn-update-check

$ echo $?
1

>=7.0.0

ただし、 v7.0.0 からはふたたび終了コード 0 を出力するようになっているようだ。リリースノートには特に破壊的変更のアナウンスは見当たらず、意図したものかそうでないものかを調べきれていない。

cli/outdated.js at v7.0.0 · npm/cli · GitHub

$ npm -v
7.3.0
$ npm outdated
Package                 Current  Wanted  Latest  Location                             Depended by
@holiday-jp/holiday_jp    2.2.3   2.2.3   2.3.0  node_modules/@holiday-jp/holiday_jp  node15

$ echo $?
0

おわりに

例えば grep のマニュアルには終了コードについて明記されている。

通常では、選択される行が見つかったときの終了ステータスは 0 であり、 見つからなかったときは 1 であり、エラーが起きた場合は 2 です。 ただし、 -q , --quiet , --silent といったオプションが使われていて、選択される行が見つかったときは、 エラーが起きたときでも終了ステータスは 0 です。

Man page of GREP

yarn/npm outdated のマニュアルには記載がない。そして、仕様として挙動が維持されるかは分からない。これをどこまでアテにしていいのだろうか、いささか不安が残る結果になってしまった。 --json オプションをつけて JSON を解釈したほうがいいだろうか。

2020 年を振り返る

以前、はじめて転職活動をした際に「過去をしっかり振り返っておくことが大事」と言われたことがあり、実感として確かに大事だと感じた。

ということで 2020 年にやったことを整理しておく。

技術的なもの

「言語いっぱい触った」と書いても情報量に欠けるので、より具体的なアウトプットを並べてみる。

仕事: SRE 寄り

転職直後は「まずはサーバサイド頑張ろ」という気持ちだったが、ここ 1 年半ほどは「チームでインフラ触っている人少ないしやってみよ」ってこと SRE に近い立ち位置でいる。

過去に「できること、やりたいこと、求められていることの 3 つが重なっていると良い」と聞いたことがあり、その重なるところにスポッと入ったイメージである。

AtCoder

AtCoder color 色 です」と言われても「すごい(よくわかってない)」という感じだったので、短期集中でガッと Beginner Contest にチャレンジした。

chokudai さん曰く『エンジニアとしてもある程度の安心感がある』とされる緑色に到達したところで満足して止めた。

コンテスト成績表 から各コンテストの提出状況を見れば分かるが、 D 問題が解けたり解けなかったりしている。 A~C 問題を短時間で解いてレートを稼いでいた節はある。

新規 Web サービス

今年は 2 つの Web サービスを開発・公開した。

メトロ遅延なう

フロントエンドは TypeScript, React, MUI (Material Design) 、サーバサイドは馴染みある Ruby, Sinatra で開発。いわゆるテレワークで使わなくなってしまったが、それまでは CX (Commuter Experience: 通勤体験) の向上に役立っていた。


Uchibi (おうち美術館)

フロントエンドは TypeScript, Vue 、サーバサイドはやはり Ruby, Sinatra 。 美術展が軒並み中止や延期となる中、「美術展に行くことで創作意欲が刺激されていたのかもしれない」と思い立って開発。最近はあまり見ていない…

GitHub - typewriter/home-museum: Virtual museum using public domain (CC0) collections

既存サービス: P2P 地震情報 16 周年

今月で 17 年目に突入。劇的な変化はないものの、地道に運用、地道に改善を進めている。

  • JSON API v2, WebSocket API を開発・公開。実装は Golang
  • Flutter (Dart) でスマートフォン版の統一を目論む。開発途中。
  • 地震感知情報の評価結果 (信頼度)」について、 Golang へ移植し MongoDB コレクションに登録することでデータベース化した。これまでは VB6 製のアプリケーションでリアルタイムにしか確認できなかった。

初コントリビュート

自動的に Let's Encrypt の証明書を取得して HTTPS リバースプロキシを立ててくれる https-portalIPv6 対応にした。

typewriter.hatenablog.jp

技術的であるがお気持ちが強いもの

Mac mini, WSL2

1 月に Mac mini を調達したが、 4 月に HDMI 出力の不調でそのまま眠りについている。仕事は Mac 、プライベートは Windows 、という両立が実現しているのは WSL2 によるところが大きい。

WSL2 は地味だが体験として非常によい。起動が非常に早く、 Windows Terminal もまともなターミナルとして機能している。この記事も、 Windows Terminal 上の Neovim で textlint を動かしながら書いた。


Zen 3

上記の通り Windows に戻ってしまったが、 7 年前に組んだ自作 PC だった。まあ早いと思っていたが、 Zen 3 である Ryzen 5 5600X と M.2 SSD へ換装したところさらに快適になった。

現在 Flutter でアプリケーション開発を進めているが、 Android Emulator の起動やアプリケーションのデバッグもストレスなく出来ている。

プライベートでの技術戦略

仕事に役立つかどうかは一切気にせずやっている。今年で言えば、 Crystal, Golang, Flutter (Dart), WSL2 あたりだ。 Golang は結局仕事でも使ったが。

面白くて楽しいというのが一番の理由。プラス面は色々あると思うが、「プログラミング言語は複数習得すべき」とか「言語だけでなく文化も学ぶ」の二番煎じになりそうなので端折る。

技術的でないもの

Oculus Quest

2 が出る前に購入。 VR 酔いするので長時間は難しいが、外出がしづらい中で「別の世界」がそこにある、という体験は不思議で楽しい。

もともと VR 枯山水作りたいという気持ちで購入したが、 Unity を少し触った結果「これゴールまで遠すぎるわ…」と思ってまだプレイする側から抜け出していない。

百合

渡月橋で限界オタクになり、朗読劇ささつで佐伯沙弥香を追体験した。

また、はるゆき百合雑談から、 VTuber (バーチャルライバー) の Crossick (当時はにじさんじ性癖コンビ) の沼にハマってしまった。末永く幸せでいてほしい。

おわりに

「来年は」という話だが、新卒就活の際に計画的偶発性理論を聞いて「これだ」と思ったため、来年も良い方向に進むよう日々積み重ねていく、としか言えない。めでたしめでたし。

システムコールトレーシング: 吸い込まれる標準入力を観察する

Linuxその2 Advent Calendar 2020 18 日目の記事です。


スクリプト書いてたんですが、なぜか途中で止まるんです」と言われた。

f:id:no_clock:20201217003234p:plain
止まっている様子(シンタックスハイライトが綺麗でないので画像で)

確かに yarn gen で止まっている。 yarn startdate も実行されていない。終了コードは 0 で、正常だ。

「ファイルとして実行してみて」と伝えた。

f:id:no_clock:20201217003428p:plain
最後まで実行される様子

最後まで実行された。

雰囲気で「たぶん 標準入力が吸い込まれている」と雑返答をしてしまったが、ここでその正体を明らかにしておきたい。

1. strace でシステムコールを記録する

ソースコードを読むより strace コマンドで挙動を追うのが好きなので、 bash に strace をアタッチしてシステムコールを記録させる。

$ strace -p 10088 -f 2>&1 | tee strace.bash.log
strace: Process 10088 attached
pselect6(1, [0], NULL, NULL, NULL, {[], 8}

## この状態で、アタッチ中の bash に対して「途中で止まる」スクリプトを入力する
## ```
## bash <<EOF
## date
## yarn gen
## yarn start
## date
## EOF
## ```
(大量のトレース結果)
## Ctrl+C でデタッチする

^Cstrace: Process 10088 detached
 <detached ...>
$ wc -l strace.bash.log
33677 strace.bash.log

2. システムコールから挙動を観察する

2-1. ヒアドキュメントの書き出し

clone システムコールで子プロセスが作成し、その子プロセスでヒアドキュメントを書き出している。

clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLDstrace: Process 11360 attached
, child_tidptr=0x7f6ed3e90a10) = 11360
[pid 11360] openat(AT_FDCWD, "/tmp/sh-thd.7rsuca", O_RDWR|O_CREAT|O_EXCL, 0600) = 3
[pid 11360] fchmod(3, 0600)             = 0
[pid 11360] fcntl(3, F_SETFD, FD_CLOEXEC) = 0
[pid 11360] dup(3)                      = 4
[pid 11360] fcntl(4, F_GETFL)           = 0x8002 (flags O_RDWR|O_LARGEFILE)
[pid 11360] fstat(4, {st_mode=S_IFREG|0600, st_size=0, ...}) = 0
[pid 11360] write(4, "date\nyarn gen\nyarn start\ndate\n", 30) = 30
[pid 11360] close(4)                    = 0

2-2. 標準入力をヒアドキュメントに差し替え

書き出したヒアドキュメントは、読み取り専用で開き直してファイルディスクリプタ 0 (stdin) に複製する。すなわち子プロセスの 標準入力をヒアドキュメントに差し替えている

[pid 11360] openat(AT_FDCWD, "/tmp/sh-thd.7rsuca", O_RDONLY) = 4
[pid 11360] close(3)                    = 0
[pid 11360] unlink("/tmp/sh-thd.7rsuca") = 0
[pid 11360] fchmod(4, 0400)             = 0
[pid 11360] dup2(4, 0)                  = 0
[pid 11360] close(4)                    = 0

2-3. bash を実行

execve で、この子プロセスが bash に置き換わる。

[pid 11360] execve("/usr/bin/bash", ["bash"], 0x5642bbf4ca80 /* 25 vars */) = 0

2-4. 標準入力を読み込み

標準入力 (実体はファイルである) の先頭にシークして一気に読み込む。

fstat でサイズを調べて、 read でそのサイズ分を一気に読んでいるようだ。

[pid 11360] fcntl(0, F_GETFL)           = 0x8000 (flags O_RDONLY|O_LARGEFILE)
[pid 11360] fstat(0, {st_mode=S_IFREG|0400, st_size=30, ...}) = 0
[pid 11360] lseek(0, 0, SEEK_CUR)       = 0
[pid 11360] read(0, "date\nyarn gen\nyarn start\ndate\n", 30) = 30

2-5. date コマンドを探す

最初は date コマンドであることが分かったので、 PATH を順番に探しているようだ。

[pid 11360] stat("/home/linuxbrew/.linuxbrew/bin/date", 0x7fffccf3db80) = -1 ENOENT (No such file or directory)
[pid 11360] stat("/usr/local/sbin/date", 0x7fffccf3db80) = -1 ENOENT (No such file or directory)
[pid 11360] stat("/usr/local/bin/date", 0x7fffccf3db80) = -1 ENOENT (No such file or directory)
[pid 11360] stat("/usr/sbin/date", 0x7fffccf3db80) = -1 ENOENT (No such file or directory)
[pid 11360] stat("/usr/bin/date", {st_mode=S_IFREG|0755, st_size=108920, ...}) = 0

2-6. 標準入力をシークしなおし、 date を実行する

ここがポイントである。 読みすぎた標準入力のシーク位置を巻き戻している。 次の read でちょうど yarn gen 以降が読み込める位置だ。

clone でまた子プロセスを生成し、 bash は子プロセス終了を待つ。子プロセスは execve によって date に置換され実行されているが、詳細は省く。

[pid 11360] lseek(0, -25, SEEK_CUR)     = 5
[pid 11360] clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLDstrace: Process 11361 attached
, child_tidptr=0x7f685563ca10) = 11361
[pid 11360] wait4(-1,  <unfinished ...>
[pid 11361] execve("/usr/bin/date", ["date"], 0x55df84d668b0 /* 25 vars */) = 0
(略)
[pid 11361] exit_group(0)               = ?
[pid 11361] +++ exited with 0 +++
[pid 11360] <... wait4 resumed>[{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 11361

2-7. 標準入力を再度読み込み

bash に戻ってきて、標準入力をまた読み込む。今度は yarn gen 以降を読み取る。

[pid 11360] read(0, "yarn gen\nyarn start\ndate\n", 30) = 25

2-8. yarn を探し、標準入力をシークし、実行し、そして…?

yarn コマンドが実行されるまでは date コマンドと同じ、コマンド検索 & シーク & 実行である。

シーク位置は、次に yarn start 以降が読み込まれる箇所だ。

[pid 11360] stat("/home/linuxbrew/.linuxbrew/bin/yarn", 0x7fffccf3db80) = -1 ENOENT (No such file or directory)
[pid 11360] stat("/home/typewriter/.nodenv/shims/yarn", {st_mode=S_IFREG|0755, st_size=405, ...}) = 0 
[pid 11360] lseek(0, -16, SEEK_CUR)     = 14
[pid 11360] clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLDstrace: Process 11362 attached
 <unfinished ...>
[pid 11360] wait4(-1,  <unfinished ...>
[pid 11362] execve("/home/typewriter/.nodenv/shims/yarn", ["yarn", "gen"], 0x55df84d66f70 /* 25 vars */) = 0

しかし、問題はこの先である。子プロセスを何度か作成し、 run-p コマンド(に置換されたプロセス)の子プロセス (pid: 11409) に辿り着く。 yarn start\ndate\n を読み込んでしまっている。

[pid 11362] clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLDstrace: Process 11400 attached
 <unfinished ...>
[pid 11400] clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLDstrace: Process 11401 attached
 <unfinished ...>
[pid 11401] execve("/home/typewriter/.nodenv/versions/12.18.3/bin/run-p", ["run-p", "-s", "--print-label", "gen:*"], 0x55dcfacf6f68 /* 58 vars */ <unfinished ...>
[pid 11401] clone(child_stack=0x7f6061052f70, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTIDstrace: Process 11409 attached
 <unfinished ...>
[pid 11409] read(0,  <unfinished ...>
(略)
[pid 11409] <... read resumed>"yarn start\ndate\n", 65536) = 16

2-9. 標準入力は空っぽ。完

yarn gen が終わり、次に標準入力を読むと、空になってしまっている。 yarn start と date が実行されないまま bash が終了する。

[pid 11362] +++ exited with 0 +++
[pid 11360] <... wait4 resumed>[{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 11362
[pid 11360] read(0, "", 30)             = 0
[pid 11360] exit_group(0)               = ?
[pid 11360] +++ exited with 0 +++
<... wait4 resumed>[{WIFEXITED(s) && WEXITSTATUS(s) == 0}], WSTOPPED|WCONTINUED, NULL) = 11360

観察した挙動をまとめる

標準入力にまつわる一連の挙動を整理するとこうだ。

f:id:no_clock:20201217231123p:plain
図: 標準入力の吸い込まれ具合(▽印はファイルのオフセット)

3. ドキュメントから挙動を解釈する

挙動は整理できたが、なぜこのような挙動をするのか、ドキュメントから解釈していく。

read, lseek によるファイルのオフセットの変更

read するとオフセットが進むが、 lseek で自由に変更できる。

seek に対応しているファイルでは、read は現在のファイルオフセットから行われ、ファイルオフセットは読み込んだバイト数分だけ進められる。

Man page of READ

lseek() 関数は、ファイルディスクリプター (descriptor) fd に対応するオープンされたファイルのオフセットを、 whence に基づき offset 引き数の位置へ以下のように変更する (略)

Man page of LSEEK

標準入力を共有している挙動

標準入力、標準出力、標準エラー出力は、ファイルディスクリプタ 0, 1, 2 に割り当てられている。

プログラムの起動時には、 ストリーム stdin, stdout, stderr に結びつけられているファイルディスクリプターの番号は、 それぞれ 0, 1, 2 である。

Man page of STDIN

そして、 clone システムコールのドキュメントを読むと、ファイルディスクリプタを共有または複製するとある。今回は、( run-p を除き) CLONE_FILES フラグが設定されていないため、 複製 されている。いずれにせよ、 bash の標準入力を date や yarn が共有するのは確かなようだ。

CLONE_FILES (Linux 2.0 以降)

CLONE_FILES が設定された場合、呼び出し元のプロセスと子プロセスはファイルディスクリプターの テーブルを共有する。 (略)

CLONE_FILES が設定されていない場合、子プロセスは、 clone() が実行された時点で、呼び出し元のプロセスがオープンしている全ての ファイルディスクリプターのコピーを継承する (子プロセスの複製されたファイルディスクリプターは、 対応する呼び出し元のプロセスのファイルディスクリプターと 同じファイル記述 (open(2) 参照) を参照する)。 これ以降に、呼び出し元のプロセスと子プロセスの一方が ファイルディスクリプターの操作 (ファイルディスクリプターの オープン・クローズや、ファイルディスクリプターフラグの変更) を行っても、もう一方のプロセスには影響を与えない。

Man page of CLONE

標準入力のシーク位置を共有している挙動

さきほどの clone のドキュメントには、「呼び出し元のプロセスのファイルディスクリプターと 同じファイル記述 (open(2) 参照) を参照する」とあった。「ファイル記述」とはなにか、 open システムコールのドキュメントを参照する。

ファイル記述とは、システム全体のオープン中のファイルのテーブルのエントリーである。 このオープンファイル記述は、ファイルオフセットとファイル状態フラグ (下記参照) が保持する。 ファイルディスクリプターはオープンファイルっ記述への参照である。 (※原文ママ)

Man page of OPEN

「ファイルディスクリプタ」と「ファイル記述」は名前が似ているが別物のようだ。図にするとこうだろうか?

f:id:no_clock:20201218001105p:plain
図: ファイルディスクリプタとファイル記述とファイルの関係

ここで重要なのは、どのプロセスも同じ「ファイル記述」を指していて、ファイルオフセットを共有しているということだ。

つまり、「たぶん 標準入力が吸い込まれている」の正体は、「bash と同じファイル記述を共有する孫プロセス run-p が標準入力を read したためにファイルオフセットが進んでしまい、 bash は標準入力から何も読み取れずに終了した」ということだった。

おまけ: ファイルとして実行した場合の挙動

ファイルとして実行した場合は、ファイルディスクリプタ 255 に割り当てて読み取っている。これにより、子プロセスが標準入力をいくら吸い取っても問題にはならない。 255 には何か意味があるようだが 、未調査のため割愛する。

[pid 12990] lseek(255, 0, SEEK_CUR)     = 0
[pid 12990] read(255, "#!/bin/bash\n\ndate\nyarn gen\nyarn "..., 43) = 43

まとめ

  • 標準入力をすげかえない限り、標準入力やそのオフセット(を含むファイル記述)は子プロセスと共有される。
  • 子プロセスが標準入力を read してしまうと、オフセットが進んで親プロセス bash が読みたかったコマンドを通り過ぎてしまう。

参考

同じドメインに A レコードが複数あるときのクライアントの挙動 (Chrome, Safari, curl)

おことわり: TCP/IP スタックの設定値などを深追いしておらず、「こう動いたが、根拠は調べていない」というレベルのものです。

いきなりまとめ

前提条件

同一ドメインに複数の A レコードを設定する。なお、 IP アドレスは RFC5737 で規定されている仕様書やドキュメント向けの IPv4 アドレスであり、接続はできない。

$ dig roundrobin.nyamikan.net A

; <<>> DiG 9.16.1-Ubuntu <<>> roundrobin.nyamikan.net A
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 49795
;; flags: qr rd ad; QUERY: 1, ANSWER: 4, AUTHORITY: 0, ADDITIONAL: 0
;; WARNING: recursion requested but not available

;; QUESTION SECTION:
;roundrobin.nyamikan.net.       IN      A

;; ANSWER SECTION:
roundrobin.nyamikan.net. 0      IN      A       203.0.113.1
roundrobin.nyamikan.net. 0      IN      A       203.0.113.3
roundrobin.nyamikan.net. 0      IN      A       203.0.113.4
roundrobin.nyamikan.net. 0      IN      A       203.0.113.2

確認方法

HTTP アクセスを試み、 Wireshark でパケットキャプチャして挙動を観測する。

  • 複数の IPv4 アドレスに接続しにいくか
  • 順番に接続しにいくか、同時に接続しにいくか
  • 接続間隔( 1 IPv4 アドレスあたりのタイムアウト)はどの程度か

確認結果

OS ツール 挙動
Windows 10
バージョン 20H2
OS ビルド 19042.630
Google Chrome
87.0.4280.66
アドレスバー入力
約 21 秒間隔で順番に接続試行
その後 ERR_CONNECTION_TIMED_OUT 表示
さらに自動的にリトライ
Windows 10
バージョン 20H2
OS ビルド 19042.630
Google Chrome
87.0.4280.66
Fetch API
約 21 秒間隔で順番に接続試行
その後エラー
Ubuntu 20.04.1 LTS
Windows Subsystem for Linux 2
curl 約 32 秒間隔で順番に接続試行
その後エラー
macOS Mojave
(バージョン 10.14.6)
Google Chrome
86.0.4240.198
アドレスバー入力
約 75 秒間隔で順番に接続試行
4 分経過時点で ERR_CONNECTION_TIMED_OUT 表示
さらに自動的にリトライ
macOS Mojave
(バージョン 10.14.6)
Google Chrome
86.0.4240.198
Fetch API
約 75 秒間隔で順番に接続試行
4 分経過時点でエラー
macOS Mojave
(バージョン 10.14.6)
Safari
13.1.2 (14609.3.5.1.5)
アドレスバー入力
0.25 秒間隔で同時に接続試行
約 70 秒でエラー
macOS Mojave
(バージョン 10.14.6)
Safari
13.1.2 (14609.3.5.1.5)
Fetch API
0.25 秒間隔で同時に接続試行
約 70 秒でエラー
macOS Mojave
(バージョン 10.14.6)
curl 約 75 秒間隔で順番に接続試行
その後エラー
  • いずれのツールも複数の IPv4 アドレスに接続試行していた。
  • 順番に接続試行するものが多く、 また試行間隔が長いものが多い。
  • macOS Safari が 0.25 秒間隔で同時に接続試行している点は興味深い。

不正確な推測、憶測

詳細な確認結果

続きを読む

「インデックスが作成されていないカラムを WHERE 句に書かない」コーディング規約と、暗黙的なコンテキスト

一部フィクションを含むポエムです。

XX システム SQL コーディング規約

性能劣化の可能性があるため、インデックスが作成されていないカラムを WHERE 句に書かないこと。
(インデックスの作成有無はテーブル定義書を参照)

こうしたコーディング規約が ある ところは、 SQL 文を書くメンバが、概ね文法のみを知っていればよい、と言える。 誤解を恐れずに言えば、 メンバは RDBMS をあまり知らなくてよい

では、逆の場合、コーディング規約が ない ところを考えたい。

SQL 文を書くメンバは、 RDBMS を知っていて当然」、つまり暗黙的なコンテキストを設定していると言えるだろうか。必ずしもそうではないように感じる。いくつかのケースが思いつく。

暗黙的なコンテキストとして「みな知っていて当然」としているケース。 SQL 文を書くメンバは 当然 RDBMS の検索処理を知っており、適切な SQL 文を書くことができる、というもの。

「誰かが知っていて、ツッコミが入るはず」としているケース。たとえば、 SQL 文にアーキテクトやレビュアのチェックが入り、リリースまでに適切な SQL 文になるはずである、といったもの。

無秩序なケース。リリースして火を吹く可能性があるが、そのリスクを受け入れる、というもの。


「みな知っていて当然」は状態として最高のように思えるが、人間は全知全能の神ではないため「当然」というのはプレッシャーにもなりうる。一方で、無秩序なケースはプレッシャーを感じなかったとしても将来大きな損害を被る恐れがある。

いまのチーム、いまのプロジェクト、いまの現場は、どのケースだろうか。リスクとリターンを天秤に乗せたときに、ベターな状態だろうか。