kawasin73のブログ

技術記事とかいろんなことをかくブログです

UDP の Length はなんのためにあるのか?

1 バイトの無駄も許さない。どうも、かわしんです。1024 回繰り返すと 1 KB の無駄になります。

先週 TCP/IP スタックを自作した 1 のですが、その講義中ずっと気になっていたことがあったのでそれを深掘りします。

TCP / IP スタックを自作した人なら誰でも感じると思うのですが、TCP の後に UDP を見ると、なぜか UDP のヘッダには Length というフィールドがあることに気づきます。

                  0      7 8     15 16    23 24    31
                 +--------+--------+--------+--------+
                 |     Source      |   Destination   |
                 |      Port       |      Port       |
                 +--------+--------+--------+--------+
                 |                 |                 |
                 |     Length      |    Checksum     |
                 +--------+--------+--------+--------+
                 |
                 |          data octets ...
                 +---------------- ...

                      User Datagram Header Format

https://tools.ietf.org/html/rfc768

多分、UDP を見ただけだとふーんって感じで流してしまうと思うのですが、TCP を実装した後の冴えた頭ではどうしても引っかかります。

TCP のヘッダには長さを表すフィールドがないのです。

    0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |          Source Port          |       Destination Port        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                        Sequence Number                        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Acknowledgment Number                      |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |  Data |           |U|A|P|R|S|F|                               |
   | Offset| Reserved  |R|C|S|S|Y|I|            Window             |
   |       |           |G|K|H|T|N|N|                               |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |           Checksum            |         Urgent Pointer        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Options                    |    Padding    |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                             data                              |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

                            TCP Header Format

          Note that one tick mark represents one bit position.

                               Figure 3.

https://tools.ietf.org/html/rfc793#section-3.1

では、TCP はどのようにデータの長さを知るのかというと IP ヘッダに含まれる Total Length というフィールド 2 によって全体の長さを知り、そこから IP ヘッダ自体の長さを引くことで TCP セグメント全体の長さを知ります。

だったら思うわけですね、UDP の Length フィールドいらないじゃん って。TCP みたいに IP ヘッダの長さから UDP セグメントの長さを知ればいいじゃんって。

UDP ヘッダは 8 バイト固定長なので IP の Total Length から IP ヘッダの長さと UDP ヘッダの長さを引けば UDPペイロードの長さがわかるはずです。UDPLength フィールドをなくすことで 1 セグメントあたり 2 バイトも 多くのデータを送ることができます。

これがすごく気になったので、なぜ UDP ヘッダに Length フィールドがあるのか、その理由を求めてネットの海をさまよいました。

なぜ UDP には Length フィールドがあるのか

とりあえず RFC を読んでみます。UDPRFCRFC 768 - User Datagram Protocol です。わずか 3 ページと短いです。

さて、この RFC では Length については以下のように記述されており、ここからはなぜ Length フィールドができたのかを推し量ることはできません。

Length is the length in octets of this user datagram including this header and the data. (This means the minimum value of the length is eight.)

そこで、なぜ UDP に Length フィールドがあるのかをググってみました。やはり、いろんな人が同じ疑問を持っていたらしく、様々なところで議論されていました。

が、結論から言えば、なぜそうなったのかはわかりませんでした 。どれも推測の域を出ることができず、確固たるソースをもってこの疑問に答えているものは1つもありませんでした。

ですが、いい線をいってるなと思う説がいくつかありましたのでここに紹介します。

ヘッダサイズを 32 ビットの倍数に揃えるために、余った 16 ビットを Length フィールドに割り当てた

32 ビットにアラインされていた方がハードウェアとして扱いやすいために 16 ビット余った領域にいい感じな Length を割り当てられたという説です。確かに理由の1つではありそうです。

これはどちらかというと UDPLength フィールドは冗長であることを認める主張です。なくてもいいので。

UDP Lite プロトコルでは Length フィールドは上書きされている。だから Length フィールドは必要ない

UDP を発展させたプロトコルとして UDP Lite というプロトコルがあるそうです。これは、UDP が領域の一部に間違いがあった場合にセグメント全体を破棄してしまうのに対して、チェックサムを計算する領域を指定することでデータの一部に間違いがあっても有効なセグメントとするプロトコルです。

UDPLength フィールドは Checksum Coverage になり、ヘッダを含める UDP Lite セグメントの先頭の範囲だけチェックサムを計算するようになります。おそらく UDP のヘッダやデータに含まれるアプリケーションプロトコルのヘッダ領域だけは正しいことを確認して、データの部分はエラー訂正を行うことを目論んでいるのだと思いました。

確かに、Length フィールドは上書きされているので冗長で必要なかったということがわかります。

UDP-Lite - Wikipedia

UDPLength と IP からの Length が食い違うことでデータの Validation ができる

それはチェックサムでやるのではないでしょうか・・・。IP からの Length を信用しない場合は、TCP も信用できなくなってしまいます。

ですが、データの信頼性を向上させるための Validation の1つにはなります。

IP に依存せず UDP だけで完結するべきだから Length の情報もヘッダに含めるべきだ

この流派には 2 つあるように感じました。

  • UDP は IP に依存せずどんなプロトコルの上でも動けるように IP の Length には依存するべきでない
  • UDPTCP と違ってデータグラムである。カーネル内では1メッセージごとバッファリングされるから Length の情報は必要になる

まず、前者ですが UDP の pseudo header は IP ヘッダ由来の Length に依存するため、この指摘は半分妥当ではありません。もしチェックサムを無効にする場合は確かにその通りだと思います。

UDPRFC の「IP Interface」の項を読むと以下のように書かれており、UDP は IP の Length は必須とはしていないようです。つまり、pseudo header の LengthUDP Header から計算できるということなのでしょうか。

The UDP module must be able to determine the source and destination internet addresses and the protocol field from the internet header.

一方で TCPRFC の「TCP/Lower-Level Interface」では、下層として IP と同等の機能を提供すればどのプロトコルでも許容するとしています。その IP として要求する機能については以下のように記述しています。

Any lower level protocol will have to provide the source address,
destination address, and protocol fields, and some way to determine
the "TCP length", both to provide the functional equivlent service
of IP and to be used in the TCP checksum.

https://tools.ietf.org/html/rfc793#page-51

ここでは、明確に TCP length を要求しているので、UDP では IP レイヤーからの length は必須としていないと考えることができます。

次に後者のカーネル内での扱われ方に着目した説ですが、これも一理あります。

TCP はストリームなので、1セグメント1セグメントの情報はストリーム(Receive Buffer)にデータがコピーされた時点で破棄されます。

一方で UDP はデータグラムなので、アプリケーションがソケットから読むときに1セグメントごとを読み出します。そのため 1 セグメントごとの長さなどの情報は、読み出されるまで本質的にカーネル内に保存される必要があります。

この考え方は一理ありますが、カーネル側で新しいメモリ領域を確保してそこで長さを管理すればいいのでそこまで強い理由にはならないのかなと思いました。

まとめ

僕の結論としては、UDP の下層を IP に限定する場合は UDPLength フィールドは不必要です。 32 ビットアラインのための埋め合わせの意味合いが強いと思います。

UDP を IP に限定しない汎用的なプロトコルと考える場合は、下層が Length を提供する機能を持たない可能性があるため、Length フィールドは必要になると言えます。

だいたいこんな感じでした。この辺りに知見のある方は僕のツイッターアカウント @kawasin73 まで、ぜひ教えていただければと思います。

参考文献

僕が読んだ記事は以下の通りです。

stackoverflow.com

stackoverflow.com

これは、UDP の Length というよりは、pseudo header の Length との重複のことをいっているみたいでした。

kplug-list.kernel-panic.narkive.com

FIN -> FIN/ACK -> ACK という TCP の幻想

掴んで離さぬコネクション。どうも、かわしんです。しがみつかずに適切なタイミングで離しましょう。

この1週間で RFC を読みながら TCP/IP プロトコルスタックを自作した 1 のですが、その時にコネクションの終了処理でハマったので後学のために書き残しておきます。

一言でまとめると FIN -> FIN/ACK -> ACK は間違っていて、正しくは FIN/ACK -> FIN/ACK -> ACK であったという話です。

ちなみに、僕が自作した TCP/IP プロトコルスタックはこれです。

github.com

現象

それは TCP のリスナーと close 処理が出来上がってコネクション管理のテストをしていた時のことでした。

自作 TCP スタックでポートを Listen して Accept したらすぐにサーバ側からコネクションを切断するというテストコードを書いて実行し、Linux 内の telnet から接続して即座に接続が切断されるかどうかをテストしていました。

しかし、接続には成功するのですが なぜか接続は切断されません 。サーバからは確かに FIN セグメントが送られているのに telnet からは ACKFIN/ACK が送信されていないのです。 netstat で確認しても telnet のコネクションは ESTABLISHED 状態のままです。

逆に telnet から切断する時のパケットを観察すると切断時には FIN セグメントではなく FIN/ACK セグメントを送っているようでした。そのため、自作プロトコルスタックの CLOSE 処理で FIN ではなく FIN/ACK セグメントを送るようにすると無事 telnet から FIN/ACK が返ってきてコネクションの切断ができるようになりました

RFC を読み直す

TCP の基本的な仕様は RFC 793 にまとめられていますが、その中の 3.9 Event Processing の CLOSE Call では、終了命令がユーザから来た時に FIN セグメントを送信するように規定しています。

    ESTABLISHED STATE

      Queue this until all preceding SENDs have been segmentized, then
      form a FIN segment and send it.  In any case, enter FIN-WAIT-1
      state.

僕はこの規定を読んで実装していたのですが、うまく動きません。

そこで 3.5 Closing a Connection も読むとその中の実例ではコネクションを切断する側は、FIN ではなく FIN/ACK を送っています。

      TCP A                                                TCP B

  1.  ESTABLISHED                                          ESTABLISHED

  2.  (Close)
      FIN-WAIT-1  --> <SEQ=100><ACK=300><CTL=FIN,ACK>  --> CLOSE-WAIT

  3.  FIN-WAIT-2  <-- <SEQ=300><ACK=101><CTL=ACK>      <-- CLOSE-WAIT

  4.                                                       (Close)
      TIME-WAIT   <-- <SEQ=300><ACK=101><CTL=FIN,ACK>  <-- LAST-ACK

  5.  TIME-WAIT   --> <SEQ=101><ACK=301><CTL=ACK>      --> CLOSED

  6.  (2 MSL)
      CLOSED

                         Normal Close Sequence

                               Figure 13.

どうやら FIN/ACK を送ることが想定されているようです。

次に受信側の仕様を見てみます。3.9 Event Processing の SEGMENT ARRIVES をみると Step 5 の fifth check the ACK field では以下のように記述されています。

    fifth check the ACK field,

      if the ACK bit is off drop the segment and return

FIN フラグのチェックは、eighth, check the FIN bit, と Step 8 であるため、そこに到達する前に ACK の付いてないセグメントは無視されてしまう ようです。

そのため、コネクションの終了処理では、FIN ではなく FIN/ACK を送らなくてはなりません。 それなら FIN segment を送ると書かずに FIN/ACK を送るというように明確に書いておいてくれ

まとめ

FIN -> FIN/ACK -> ACK のフローは、正しくは FIN/ACK -> FIN/ACK -> ACK でした。

ESTABLISHED 状態になった後は基本的に全てのセグメントに ACK をつける必要があります。FIN 単体のセグメントは不正なセグメントとみなされて無視されてしまいます。

StackOverflow でも同じような質問がされていました。( linux - FIN omitted, FIN-ACK sent - Stack Overflow )その回答では以下のように説明されています。

1. client: FIN  (will not send more) 
2. server: ACK (received the FIN)
.. server: sends more data..., client ACKs these data 
3. server: FIN (will not send more)
4. client: ACK (received the FIN)

Note that the packet you see in step#1 might have an ACK inside too. But this ACK just acknowledges data send before by the server.

FIN には ACK をつけることができると言っていますが 大嘘 です。むしろ ACK は必須で、つけないと無視されてしまいます。 RFC をよく読めばわかることです。(僕もよく読んでないので人のことはあまり言えませんが)

世の中には雰囲気で回答して雰囲気で理解して approve していることが多いのだなと感じます。ちゃんと 1 次ソースをあたらないと嘘の情報に惑わされてしまうといういい教訓になりました。

TCP/IP プロトコルスタックを自作した

RFC は裏切らない。どうも、かわしんです。僕は RFC に裏切られました。

さて、今週の頭から4日間開催された KLab Expert Camp に参加して、TCP/IP プロトコルスタックを実装してきました。今回はその体験記を書いていこうと思います。

成果物ですが、こちらになります。

github.com

ネットワークデバイスの抽象化、EthernetARP、IP、TCP を実装しました。使用言語は C 言語です。詳しくは後半で説明します。

KLab Expert Camp とは

今回参加したのは KLab 株式会社の実践的なインターンプログラムである KLab Expert Camp です。記念すべき第 1 回目として「TCP/IPプロトコルスタック自作開発」が開催されました。

応募ページはこれです。

https://klab-hr.snar.jp/jobboard/detail.aspx?id=vtS4F0TN0tQ

実はこのインターンは 21 卒以降の学生が対象であり大学4年の僕は対象外なのですが、どうしても参加したいとツイッター上で言ったところ、講師の 山本さん のご好意で特別に参加させていただけることになりました。本当にありがとうございました。

このインターンプログラムは講師の山本さんが実装して公開されている学習用の TCP/IP スタック microps がベースになっています。(マイクロピーエスと呼ぶらしいです)

github.com

プログラム自体は、山本さんの講義を受けながら microps の実装を理解する「基本コース」と、microps をベースに各自自由に何かを作る「発展コース」の 2 つを選ぶことができました。

僕は「発展コース」を選び microps を写経しながら TCP の実装を改善していました。

そして、無事修了することができました。

f:id:kawasin73:20190830155259j:plain

最後の修了証もそうですが、KLab さんのインターンはすごく待遇がよくて、懇親会での寿司やピザ、お昼ご飯、交通費全支給、遠方からの参加者の宿泊の手配など運営の方の気配りと予算がすごくて、すごくよかったので学生のみんなは参加したほうがいいと思います。(宣伝)

頑張ったこと

インターンが始まる 2 日前の 24 日から実装を始めて、全部で 6 日間実装していました。

まず、microps の下のレイヤーから写経して、都度テストで動くことを確認しながら進めていきました。やりたいことは TCP での通信だったので必要のない ICMP や IP のルーティング機能、DHCP などは飛ばしています。

僕が実装した順番はコミットログを見るとわかりやすいと思います。

github.com

IP を実装した後に ARP を実装したのですが、案外 ARP の実装が量が多くて大変でした。

全体として 5000 行くらいを書いていたことになります。

f:id:kawasin73:20190830164805p:plain

TCPRFC を実装する

全体的に microps はコメントは少ないですが実装の内容はシンプルでわかりやすかったです。また、ネットワークデバイスの抽象化などの設計もよくできていて参考になりました。しかし、TCP の実装をみるとシンプルな分色々な処理が TODO になっていたので仕様に忠実に実装するために、tcp.c は自力で RFC を読みながら実装しました。

実は昔に DMM の時のメンターさんから RFC を読んで実装する訓練をした方がいいとアドバイスされていて、ずっとやってみたかったのでちょうどいいきっかけになりました。RFC を実装することは、1 次ソースを読むことでTCP の正確な仕様を理解できるだけでなく、標準化されたドキュメントの書き方を学べたり、仕様を忠実に実装する訓練にもなります。

TCPRFC は複数にわたりますが、基礎は RFC 793 に全て書いてあります。

そして、RFC 793 の 3.9 Event Processing には TCP の基本的な処理がそれぞれのイベントごとそしてコネクションの状態ごとに全て記述してあります。中身は英語ですが、If xxxxxx then set yyyy to zzzz のように擬似コードっぽく書かれているのでそれをそのまま C 言語に書き直しました。

一番辛かったのは 65 ページ から始まる SEGMENT ARRIVESイベントのイベントハンドリングを実装していた時です。相手からの TCP セグメントのヘッダを読んで処理を行うのですが、このイベントだけで RFC の 12 ページを使って記述されています。長い。

そして、このイベントでは全部で 8 ステップに渡ってヘッダのフラグなどを検査して内部状態を変えたり TCP セグメントを送信したりします。TCP はコネクションごとにステートマシンで表され、 LISTEN, SYN-SENT, SYN-RECEIVED, ESTABLISHED, FIN-WAIT-1, FIN-WAIT-2, CLOSE-WAIT, CLOSING, LAST-ACK, TIME-WAIT, CLOSED の 11 個のうちのどれか状態を持ちます。そのため、8 ステップの各ステップでそれぞれの状態ごとに処理を書かなくてはいけません。複数の状態で処理が共通していたりするので実際には 88 種類よりは少ないですが、それでも十分多いです。正直最後の方は心が折れかけました。

実装した機能

正直 TCP の最低限の機能を実装するので精一杯でパフォーマンスの向上などはできませんでした。microps から発展させたことはだいたいこんな感じです。

  • tcp_rx のイベントハンドリングの処理を RFC に忠実に実装した
  • tcp_api_send で 1500 バイトまでの送信にしか対応していないのを、セグメント化に対応して任意の長さのバイト列を送信できるようにした
  • フロー制御を実装した
  • 再送タイムアウトだけでなく、ユーザタイムアウト、TIME-WAIT タイムアウトに対応した

最初は輻輳制御アルゴリズムをいくつか試してみるとか息巻いていたんですが、結局は輻輳制御をするところまでたどり着けませんでした。

また、microps のいくつかのバグを発見してフィードバックのプルリクエストを作成しました。

感想

C でのバイナリプロトコルの処理方法 を学ぶことができたのがよかったです。TCP は固定長のヘッダ(オプションによって拡張されますが)であるため、セグメントポインタをヘッダ構造体のポインタにキャストすることでゼロコピーでヘッダを解析できます。

もちろんエンディアンはネットワークバイトオーダなので利用時に変換をしないといけないですが、このバイト列にヘッダ構造体を被せるだけでヘッダのパースができる感覚は新鮮で、さすが生のメモリを操作する C だなと思いました。

microps では複数のネットワークデバイスの種類に対応しているのですが C 言語での抽象化 の方法を学びました。以下のようなオペレーションの関数ポインタを集めた構造体をデバイスの種類ごとに定義して多態性を実現します。

struct rawdev_ops {
  int (*open)(struct rawdev *dev);
  void (*close)(struct rawdev *dev);
  void (*rx)(struct rawdev *dev, void (*callback)(uint8_t *, size_t, void *),
             void *arg, int timeout);
  ssize_t (*tx)(struct rawdev *dev, const uint8_t *buf, size_t len);
  int (*addr)(struct rawdev *dev, uint8_t *dst, size_t size);
};

そして、案外 RFC は曖昧 であることも知れました。RFC自然言語で書かれているためどうしても曖昧さがあり、その解釈を間違えると正しく動きません。

ここのコメント にまとめてありますが、SYN-RECEIVED 状態で ACK を受け取った時に ESTABLISHED 状態に移行したあと、次のステップを処理すると思っていたのですが実は同じステップの ESTABLISHED 状態の処理を繰り返すことが期待されていたようでした。switch - case 文で break するか fall through するかはこの記述だけでは曖昧でした。

  if the ACK bit is on

    SYN-RECEIVED STATE

      If SND.UNA =< SEG.ACK =< SND.NXT then enter ESTABLISHED state
      and continue processing.

        If the segment acknowledgment is not acceptable, form a
        reset segment

RFC には裏切られることもあるので注意が必要です。

まとめ

RFC を読んで 6 日間で TCP/IP スタックを作り上げるのは結構大変でしたがなんとか形になるものが完成してよかったです。

また、このような素晴らしいインターンプログラムを素晴らしいサポートで開催していただいた KLab 株式会社の皆さんには感謝申し上げます。ありがとうございました。


最終日の成果発表の資料です。

ロバストなプロトコルを設計して実装した

どうも、ロバストと愉快な仲間たちのロバストこと、かわしんです。そうです、私がロバストです。

さて、8 月 13 日から 8 月 17 日までの 4 泊 5 日で セキュリティネクストキャンプ に参加してきました。

セキュリティネクストキャンプとは、セキュリティ・キャンプ全国大会 と同時期に開催されるネクストな感じのセキュリティキャンプです。 全国大会は 22 才までの一生に一度しか参加できないのですが、もう一度参加したいという卒業生をターゲットに今年から新しく始まったそうです。このネクストキャンプは 25 才までの学生であれば(詳しくは公式の募集要項を確認してください)卒業生でなくても同等の技術スキルがあれば参加することができます。

応募には審査があり、今年は 13 人の応募から 6 人が選ばれたそうです。

僕はセキュリティキャンプの存在を去年知った時にはすでに 24 才になっており参加できないことを残念に思っていたのですが、今年ネクストキャンプの存在を Twitter で知り参加することにしました。

講義としては、暗号化アルゴリズムの AES を FPGA に実装したり、XSS 文字列の検知するエンジンを機械学習で実装したり、複数の CPU アーキテクチャエミュレータに実装された特殊命令のアルゴリズムを解析してそれをエミュレータに実装し直したりなど、主に手を動かして演習するタイプの講義が多く非常に勉強になりました。

参考 : セキュリティ・ネクストキャンプ2019 プログラム:IPA 独立行政法人 情報処理推進機構

実習講義が多い分、キャンプ開催前の事前学習やキャンプ中の宿題などタスクが多く眠れない充実した楽しい 5 日間を過ごすことができました。

ロバストプロトコルを考案せよ

さて、その授業の中で一番僕が楽しみにしていたのがこの「ロバストなプロトコルを考案せよ」という講義です。

これは、10BASE-T の LAN ケーブルの 4 対のツイストペアの 1 つにノイズを載せ、そのケーブルを使ってなるべく多くのファイル転送ができるようなプロトコルを考えて実装し、最終日にコンテストを行うものでした。僕はこのコンテストで1位をとり、ロバスト賞をいただきました。なので、僕こそがロバストです。

ファイルとしては事前に用意された 1000 個の 100KB 程度のランダムなバイナリファイルを転送し、チェックサムを確認して内容が正しいファイルのみをカウントします。間違ったファイルによるペナルティはありません。また出力するファイル名は元のファイルと異なっていても問題ないというレギュレーションでした。

ノイズはだいたい ping パケットが 50% 失敗する程度のノイズが発生させられます。

イメージとしてはこんな感じです。ノイズインジェクターブラックボックスになっており、ピンを接続して利用します。

f:id:kawasin73:20190819220451j:plain

この画像のケーブルは講師の今岡先生が作成してくださったものですが、事前学習で自分でケーブルの被膜を剥ぐのは結構難しかったです。

僕の Robustp

この課題に対して僕は Robustp という独自プロトコルを設計して実装しました。(正確には実装しながら設計を進化させていますが)

github.com

これは、簡単にいうと UDP の上に TCP や QUIC のようなプロトコルを載せている感じです。

設計

f:id:kawasin73:20190819221652p:plain

Robustp は内部で 2 つのレイヤーに別れています。TCP を参考にしたファイル単位でセグメントの位置やファイルの完成などを管理する ファイル層 と、セグメントを TransID という一意で単調増加する ID で管理して再送やウィンドウ制御を行う トランスポート層 です。

ファイル層 は、各ファイルは一意な Fileno が割り当てられた FileContext 構造体で表現され、ファイルデータを分割したセグメントは Offset が割り当てられた FileSegment 構造体で一意に管理されます。これは TCP を参考にしており、TCP のポート番号が Fileno に対応し、TCP のシーケンス番号が Offset に対応しています。

ファイル層は各ファイルのどのデータが送信済み・受信済みであるのかとファイルのデータが揃っているのかを管理します。ファイル層では TCP と同じように Partial ACK を実現し、受信した全てのパケットの位置を ACK メッセージに詰めて応答し、無駄な再送を防ぎます。

トランスポート層FileSegment をラップした TransSegmentTransId を紐づけて管理します。それぞれの TransSegmentWindowManager 内のウィンドウバッファで管理され、WindowManager は、ウィンドウサイズ制御RTTの計測再送タイマー欠損したACKの即時再送 などの機能を提供します。TransId は QUIC での Delivery Order Id の考え方を参考にして実装されており、再送が必要な場合は新しい TransId を割り当てて再送することで RTT 計測のロジックがシンプルになります。

最初は TCP を参考にファイル層のみで実装をして FilenoOffset (実装当時は Seqno だった)でセグメントを一意に管理して再送を行なっていましたが、再送パケットの RTT 計測ができなくなる課題を抱えていて悩んでいました。

その時にちょうど ぺトロン 君に QUIC の論文 1 を教えてもらい、読んでみると QUIC では再送などを管理する Delivery Order Id とデータの位置を管理する Stream Offset を分離しているという考え方を知りました。それを TransID という形でトランスポート層を分離して実装しました。

Add TransId · kawasin73/robustp@c585c66 · GitHub

ちょうどこのコミットで対応していますが、ファイル層の操作をうまく抽象化していたのでそこまで載せ替えるのは大変ではなかったです。

それぞれのメッセージは、Ethernet の 1 フレームに収まるように 起動時にコマンド引数で渡される MTU(1500 バイト)からセグメントのサイズを調整します。複数のフレームに UDP パケットが分割されると 1 つのフレームが落ちただけでパケット全体をロスしてしまうのを防ぐためです。

ファイル層とトランスポート層の間はシンプルな 4 つのインターフェイスだけが定義されています。

ファイル層からトランスポート層へは SendersendThread() メソッド内に定義された sendSegment() マクロを呼び出します。このマクロは、WindowManager.Push() メソッドを呼び出してセグメントをウィンドウに追加してパケットを送信します。

トランスポート層からファイル層へは以下の 3 つのインターフェイスFileSegment に定義されています。

  • TransId 付きのデータメッセージを作成する PackMsg()
  • ACK メッセージを受信した時に呼び出す Ack()
  • セグメントを再送する時に Partial ACK によってファイル層が知っている再送の必要がないかどうかの情報を問い合わせる IsCompleted()

この辺りの機能の分離とインターフェイス設計は綺麗にできたと自分では満足しています。

ヘッダはこんな感じになっています。

f:id:kawasin73:20190820012418p:plain

赤い部分がファイル層のためのヘッダで、青い部分がトランスポート層のためのヘッダです。どちらかというと TransId を先頭の方に持ってきたかったのですが、これは歴史的経緯です。変更するのがめんどくさかったのでこのままにしました。

コンポーネント構成と提供する機能

f:id:kawasin73:20190819225517p:plain

送信側のコンポーネントはこのように設計されて実装されています。

上の FileContextFileSegment がファイル層、WindowManagerEnqueuerReceiverトランスポート層です。FileSegmentWindowManager からのファイル層へのインターフェイスになっています。

WindowManager の導入が一番難しかったです。このコミットで対応していますが、WindowManager にどの機能(タイマーや RTT 収集など)を含めるかを悩みました。

Add WindowManager · kawasin73/robustp@35836c5 · GitHub

CongestionControlAlgorithm による輻輳制御アルゴリズムの抽象化

TCP では様々な輻輳制御アルゴリズムが提案されています。Robustp ではそれを CongestionControlAlgorithm というインターフェイスで抽象化して輻輳制御アルゴリズムを差し替え可能にしています。

それぞれのアルゴリズムは以下の 2 つのメソッドを実装したものであり、最終的には本番では、Vegas という RTT の遅延を元にしたアルゴリズムにデータロスト時のペナルティを加えた独自のアルゴリズムを実装してコンテストに挑みました。

type CongestionControlAlgorithm interface {
    Add(status uint8, sendAt time.Time, rtt time.Duration)
    WindowSize() int
}

RTOCalclater による RTO 計算の抽象化

また、RTT(Round-Trip Time)から再送タイマーの長さ RTO(Retransmission Time Out)を算出するアルゴリズムTCP では複数提案されています。

これも RTOCalclater というインターフェイスで抽象化して差し替え可能にしています。本番では実験する時間がなくてシンプルに RTT の倍の時間を RTO とする RTOCalclater を設定しました。

type RTOCalclater interface {
    Update(rtt float64, rttTable []float64) time.Duration
}

再送メッセージがウィンドウを溢れた場合の再送バッファ

ウィンドウサイズは RTT やメッセージのロストによって小さくなったり大きくなったりします。そのため、パケットの再送時にはウィンドウサイズが小さくなって再送できない場合があります。その場合は Enqueuer 内の再送バッファに一時的に貯めておいて、ウィンドウサイズを忠実に守るように工夫をしました。

欠損 ACK の即時再送

今回のコンテストの特徴として 1 本のケーブルのみを占有して利用します。送信したメッセージは全て同じ通信路を通るためメッセージの順序の入れ替わりはありえません。

TCP では 3 回同じ ACK を受け取ったらそれ以前のメッセージはロストしたものとして再送する高速再送アルゴリズムが提案されていますが、今回の講義では 1 つでも TransId が飛んだ ACK メッセージが返ってきた場合はそれ以前のメッセージはロストしたものとして即時に再送するナイーブな実装になっています。

もし IP ネットワークの上をメッセージが流れる場合はもうちょっと緩めの判定をする必要があります。

整合性の検査

データペイロードがノイズによって書き換わっていないかの検証は、UDPチェックサム機能を利用して、Robustp 自体では実装していません。UDPチェックサムで整合性の検証をサボることができたのは IP ソケットではなく UDP ソケットを選んだ理由の1つです。

ここまでの機能の設計と実装は、DMM でのインターンの時に学んだシンプルな実装を愚直に積み重ねていく経験がなかったらできていなかったと思います。DMM でインターンして身に付けることができたスキルを自分で実感しました。

Go の採用

この Robustp は Go で実装しました。

この一番の理由は クロスプラットフォーム に対応しており OS ごとの違いを抽象化して隠蔽してくれているからです。コンテストは貸与された Windows PC で行うレギュレーションがあったので、開発を macOS で行う僕としてはクロスプラットフォームは必須でした。Go では UDP ソケットは net.UDPConn という構造体で抽象化されています。

クロスプラットフォームでありながら OS に近い機能のインターフェイスを提供してくれているので今回の課題にはぴったりでした。

GC 言語なので ガバガバとメモリを確保できる気楽さ もあります。今回は速さを競い、プログラムのメモリ量などは気にしないので普段のプロダクトよりも気楽にメモリを確保するプログラムを書いていました。

Queue を C などで実現しようとすると、リングバッファや要素のずらしなどをしないといけませんが、Go ではスライスで以下のシンプルな表記で Queue が実装できて新しいバッファの確保などは Go のスライスが行なってくれます。もちろんスライスの拡張時にコピーやメモリの確保・破棄が起こるのでリングバッファほどのパフォーマンスは出ませんが、初期のプロトタイプの実装では素早く Queue を実現できます。

var queue []Item
// push
queue = append(queue, Item{})
// pop
item, queue = queue[0], queue[1:]

コンパイルされるため そこそこ速い ということもあります。もちろん C ほどの速度は出ませんが 10BASE-T を使う以上、極上の速さは求められないと考えて Go にしました。

並列・並行処理が簡単 に行えることも Go の便利な点です。1 プロセスでクライアント側の送信スレッド、受信スレッドと、サーバ側の送受信スレッドの 3 並列のプログラムにする必要があったので便利でした。

結果

60 秒間の間に送信する試行をクライアント側ポートとサーバ側ポートを変えて 2 回行いました。

結果は、1回目が 399 ファイルの転送に成功して全てのファイルの中身が壊れておらず、2 回目は 475 ファイルの転送に成功して全てのファイルが壊れていないという結果になり、堂々の 1 位を受賞しました。

3 位は TCP の上に grpc を載せて転送して 65 ファイルと 36 ファイル、4 ~ 6 位の転送ファイル数は 1 桁だったので圧勝と言えます。

僕が特に面白かったのは 2 位の aso 君で、1 回目は 347 ファイルを転送して 163 ファイルが正常なファイルで、2 回目は 799 ファイルを転送して792 ファイルが正常という驚異のパフォーマンスを見せていました。残念ながら 2 回目の試行は制限時間内に終わらなかったため参考記録ですが、驚異の結果です。

彼は、プロトコルなどなく、各ファイルを 50 KB の 2 つのブロックに分割し UDP パケットにして片っ端から送りつけ続け、全ファイルを送り終わったらまた先頭から送り直し、受け側では連続する2つの UDP パケットを結合して保存するだけでレスポンスはしないというシンプルなものでした。

何も考えないでファイルを片っ端から送り続けてそのうちのいくつかが正常ファイルだったらいいねという大胆なこんなんプロトコルじゃねーだろとも言えるシステムですが、不正ファイルが無視されるというレギュレーションを逆手に取ったスマートな方法だったと思います。これには度肝を抜かれました。

今回のケーブルはツイストペアの 1 対にだけノイズを載せます。全二重通信では、上りか下りのどちらかだけにノイズがのるため、ノイズがのらない方が上りになる 2 回目では圧倒的なパフォーマンスが得られたようです。そりゃそうか。

やり残したこと

事前学習で実質プログラムを書いていたのはキャンプが始まる前日の1日で、キャンプ中も他の講義の宿題で忙しく隙間時間で実装していたので実質 2~3 日分くらいしか実装していません。(最終日の夜は朝の5時半まで徹夜して時間を稼ぎました)

なので、もちろん効率の悪い部分だったり、やりきれなかった機能が残っています。ざっと羅列していきます。

  • writev によるメモリコピーの削減
    • ファイルデータメモリから送信バッファへコピーしているのを writev などによって効率化できれば速くなっていたはず
  • Receiver がセグメントサイズを暗黙知に依存している
    • プロトコルヘッダーはバイト単位なので任意のセグメント分けができるが、実装を簡単にするためにクライアント・サーバの両方に同じ MTU を渡し、MTU を元に共通のセグメントサイズを持つという実装にしていた
    • 実際の世界ではセグメントサイズは一定ではないはずなので任意のセグメントサイズに対応できるように区間木などのデータ構造を導入する
  • window をリングバッファにする
    • Go のスライスを使った簡易な Queue になっているのでリングバッファに書き換える
    • リングバッファによってメモリコピーやメモリアロケーションが減るはず
  • TransId のローテーション
    • 今回は 60 秒程度しか動かさないので TransId はインクリメントさせるだけだった。32 bit 整数なのでローテーションに対応する必要がある。
  • 輻輳制御アルゴリズムをもっと試す
    • Cubic とか試してみたかった
    • 体力の限界で Vegas アルゴリズムを実装するところで諦めてしまった
  • ファイル送信完了の仕組み
    • ファイル送信が完了したらサーバ側の FileContext を削除できるようにするとメモリ効率が上がる。コンテストではメモリリークは関係ないので対応していなかった
    • 実現するためにはファイル転送の開始と終了をクライアントとサーバ側で同期する必要がある。SYN/ACK, FIN/ACK などの TCP でのコネクション管理をファイルごとにする必要がある
  • ファイル名も送る
    • プロトコルの中にファイル名も送る仕組みを入れると実用的になる
  • リンク層を相手にプロトコル構築
    • そもそも IP + UDP の上に乗っかっているので MAC アドレスを直接指定して送信しあう TCP/IP スタックのようなものを実装しても良かった
    • プログラムの規模が大きくなるので実装コスト的に UDP で甘えてしまった
    • UDP ではチェックサムが異常なファイルは全て捨てていたが、データ訂正の仕組みを入れるなどもできる

最後に

疲れました。とっても。でもとても楽しかったです。

来週の月曜(8/26)からは Klab での TCP/IP プロトコルスタック自作のインターンを受講してきます。奇しくも今回の経験がめちゃめちゃ役に立ちそうです。

また、マスタリング TCP / IP を本屋さんに買いに行った時に出会ったこの本がものすごく役に立ったので共有しておきます。

TCP技術入門 ――進化を続ける基本プロトコル (WEB+DB PRESS plusシリーズ)

TCP技術入門 ――進化を続ける基本プロトコル (WEB+DB PRESS plusシリーズ)

それでは。

JWT はユーザに渡すな

JWT とかいてジョットと詠む。どうも、かわしんです。タイトルは釣りです。

この記事は、JWT はログアウトが大変 という内容です。以下で挙げるデメリットを許容できるなら JWT をユーザに渡してもいいと思います。

ログアウトとは、管理者による強制ログアウトや、ユーザのパスワードの変更による全トークンの無効化をさします。ログアウトによって全てまたは一部のトークンが無効化されるユースケースです。

JWT におけるログアウト

JWT はログアウトが大変です。なんかいい方法がないものかと調べていましたが、以下の記事に落ち着きました。

javascript - Invalidating JSON Web Tokens - Stack Overflow

この記事の結論としては、いい方法などない というものです。残念でした。

JWT をログアウトに対応させる方法は ブラックリストを用意するトークンの有効期限を短くする の 2 つだけです。

NOTE : ここではマイクロサービスのことが念頭にあります。

ブラックリスト方式

ブラックリストを用意する方法では、無効化されたトークンをブラックリストに保存します。

ブラックリストトークンは、そのトークンの一番最後の有効期限まで保存し続けます。そのあとは TTL なりで削除しても大丈夫です。

そして、各サービスは JWT の検証の際にブラックリストにアクセスして JWT が無効化されていないかをチェックします。

これでは、JWT のメリットであるトークンの検証の分散・独立・ステートレス性が失われてしまいます。

一方で、ブラックリストに保存するのは無効化されたトークンだけなので、ブラックリストのストレージサイズは全てのトークンを中央に保存するよりは小さくなります。検索も速くなるでしょう。

有効期限を短く設定する方式

トークンの有効期限を短く設定し、頻繁に JWT の更新をさせることで、JWT の更新時に無効化されたトークンの更新を弾く方法です。

JWT のメリットであるトークンの検証の分散・独立・ステートレス性は保たれます。

一方で有効期限を短くすることでどの程度短くするかによりますが、トークンの更新リクエストが頻繁に発生することになります。

また、最大のデメリットとして 即時無効化ができない という重大な欠点があります。セキュリティ的にまずいです。多分 JWT を使ってるサービスはこれを許容してるんだと思いますが、実装を手抜きするために仕様を捻じ曲げてセキュリティ的なデメリットを容認してしまうのは個人的にはしたくないです。

マイクロサービスにおける JWT

さて、マイクロサービスにおける認証を調べているとクライアントからのアクセストークンを API Gateway で内部のトークンに変換する方法がしばしば紹介されています。(Microservices Authentication and Authorization Solutionsマイクロサービスシステムにおける認証ストラテジ など)

最初はなんでこんな複雑なことをするんだと思っていたのですが、JWT のログアウトの問題を調べているうちに納得しました。

内部のトークンが JWT の場合、Gateway で利用する変換テーブルからアクセストークンを削除すれば JWT はなくなり使われなくなってしまうので、即時ログアウトが実現できます。

JWT は Gateway 内部のプライベートなネットワークの中でのみ利用されるため JWT 自体はログアウトのことを考える必要がありません。

Gateway の中でアクセストークンの変換が高速にできるのかよという疑問はありますが、そこはクラウドプロバイダーの謎テクノロジーが解決しているのでしょう。

マイクロサービスの文脈では複数のサービスを利用することやサービスが別のサービスに連鎖的にリクエストすることがありえます。各サービスが中央の認証テーブルにアクセスするコストが JWT を使うことにより削減されます。これは連携するサービスの数が多いほどこの効果は大きくなるはずです。

JWT をユーザに渡さず、プライベートネットワークの内部だけで使うという方法は一般的なユースケースに適っていて、いい JWT の使い方だと思います。Gateway 周りの実装がじょっとめんどくさそうですが。

55 日かけて OS を作った

整骨院は保険がきく。どうも、かわしんです。 突発的な不調の場合は整骨院は保険が適用され 3 割負担になります。慢性的なもの(肩こり)には適用されません。

さて、5 月 20 日に以下のように宣言して作り始めた OS が、昨日ようやくひと段落したのでまとめの記事を書くことにしました。

かの有名な「30日でできる! OS自作入門」(通称 : 30日OS本)をただ写経しただけなのですが、すごく勉強になりました。

この本は 30 日とうたっていますが、僕は 55 日かかりました。ただ忙しくて毎日はできなかっただけなので実働自体は 30 日なのですが、これも僕の実力です。

毎日 1 日分だけを進めて全て自分の手で打ち込み、サンプルコードからのコピペはしないという縛りで実装しました。もちろん、自分が書いたコードはコピペ可としました。そうしないと辛すぎるので。

だいたい 1 日分は 2 ~ 3 時間くらいかかりました。写経するだけですが、本の中身を読んで理解したり、写し間違えによるバグを直したりすると結構時間がかかります。

いつも、学校から帰ってきて仕事を始める前の息抜きに OS を作っていましたが、結局 OS を作るだけで1日が終わってしまうことが多々ありました。そのくらい重いです。

できたもの

出来上がった OS はこれです。

f:id:kawasin73:20190714140358p:plain

僕のプロフィール JPEG 画像を表示して、インベーダーゲームを表示してるだけですが、この裏で 1 文字 1 文字打ち込んだコードが動いています。

ソースコードはこれです。自分の名前をつけようと kos (kawasinOS) と名付けましたが、めんどくさくなったので途中からは本に書いてある通り Haribote OS の名前で実装しています。

リポジトリ名が kos-sample となっているのはその名残です。-sample とつけているあたり、またもう1つ OS を作る野望が見え隠れしますが疲れてしまったので次があるかは未定です。

github.com

感想

知識だけでない実践

OS の仕組み自体は大学の授業で習っていたので、ページングやスケジューリングアルゴリズム、プロセスの仕組み、ユーザランドカーネルランド、CPUの保護、ファイルシステムなどの理論は知っていました。しかし、それを具体的にどのように実装するかは曖昧なままでした。

多分、すでに OS のコードベースがあってそれに機能追加やパフォーマンス改善をする OS 開発をすることはできたと思いますが、何もない状態から 0 ベースで作り上げることは無理だったと思います。

この 30 日 OS 本は、本当に 0 から作り上げていく本なので、どのようにコンピュータが動いているのか全てを理解したかった僕にはうってつけでした。

コンピュータと C 言語の本質

普段ソフトウェアを作るときのインターフェースは システムコール であり OS を相手取って操作します。また、システムコールの上に構築されたライブラリの関数呼び出しも利用します。

一方で、OS を作るときのインターフェースは CPU命令 であり、CPU を相手取って操作します。PIC (Programable Interrupt Controller) や PIT (Programable Interval Timer) やマウス・キーボードなどのデバイスなど、全て CPU 経由で操作します。CPU にはそのための命令が全て用意されており、その振る舞いも全て仕様が決まっています。

ソフトウェア開発との違いはインターフェイスがすでに C 言語の関数の形で用意されている点です(もちろんその関数自体はアセンブリで作られていますが)。OS 開発で必要なインターフェイスである CPU 命令は C 言語のインターフェイスを持っていないため、自分でアセンブリを書いて C 言語で扱える関数を用意 します。この辺りは、正月に作った C コンパイラ 1 の知識が役に立ちました。

そして、自分でアセンブリによって作った関数を C 言語から呼び出して OS を構築していきます。ここで C 言語は何も機能を持っておらず、所詮メモリを操作して関数を呼び出しているだけ のことしかできないのだと理解しました。なんでも自由にできる万能な言語だと思っていた C 言語が、メモリ操作関数呼び出し だけしかできない低機能野郎だったと知れたのは意外な発見でした。

この辺りの C 言語の本質、CPU と OS との関わりなどは、大学の授業では知ることができませんがこの 30 日 OS 本を読むことで理解できました。

プログラミングの方法

また、30 日 OS 本では 基本的なプログラミングの方法 が学べます。この方法は DMM でインターンしてる時 2 にメンターさんに教えてもらった方法なのですが、この本でも全く同じ方法でプログラミングされておりプログラミングの基本中の基本とはこのことなのかと思いました。

普段フレームワークありきでアプリケーションなどを作っている場合にはなんとなく作ってもなんとなく動くものができてしまいますが、正しく動くメンテナブルなソフトウェア を 0 から作る場合にはこの基本は重要です。といっても大したことではないのですが。

まず、main 関数に全ての処理を愚直に書き連ね ます(30 日 OS 本では、HariMain ですが)。関数定義などはしません。main 関数一本勝負です(あまりに冗長なところは関数化してもいいですが)。そして、実際に動かしてみて正しく動くことを確認します。実際に 30 日 OS 本では、数百行の HariMain 関数まで育てています。

次に、main 関数の中身を眺めて共通化できそうなところを探して、関数に抽出 します。その時 1 つの関数には 1 つの役割のみを担わせるように注意します。それによって main 関数が読みやすくなります。また、同じ分野の処理は同じファイルにまとめるなどの分割を行うとよりわかりやすくなります。

ここで重要なのはある関数を変更した時に別の分野の関数に影響しないことです。これにより、手戻りが無くなります。正しく役割を分割して関数に抽出できていれば別ファイルの関数群を気にする事なく機能追加を行うことができます。

関数を抽出する時には、カプセル化 して実装の詳細を隠蔽することが重要です。これはよく言われていることですが、Haribote OS のコードを見るとうまく隠蔽されているように思います。一部(ビジュアル系など)は構造体のメンバにアクセスしてしまっているので完全ではないですが、程よく隠蔽されている感じでしょうか。

関数は引数に、コンテキストとなる構造体(コンソール系の処理なら struct CONSOLE など)を 1 つと新しい状態を表すデータを複数受け取ります。コンテキストとなる構造体の中身の操作は関数に隠蔽されます。

また、重要なこととして 最適化は一番最後 に行うということがあります。まず最初は 効率が悪くてもいいのでシンプルで正しく動くコード を実直に実装します。実際、30 日 OS 本では 平気で線形探索 をバンバンします。データ構造とアルゴリズムのへったくれもないです。関数に抽出するなどした後、実際に動かして遅い部分にデータ構造とアルゴリズムを適用して最適化していきます。

ただ写経しているだけでもこれだけのプログラミングの基本を学ぶことができるので、30 日 OS 本はいい本です。

TIPS

あとは、工夫したことや、新しく 30 日 OS 本をやる人へ向けたアドバイスなどを残しておきます。

macOS での開発する時の参考

30 日 OS 本は Windows での開発を前提とされています。完成品となるサンプルコードが本に CD で添付されており、github などでも公開されています。macOS で動かしたリポジトリが以下に公開されており、各チャプターごとのスナップショットがディレクトリわけしてあって、写経に便利なので参考にしていました。

github.com

確か、何かのデータが置かれているアドレスの値が本に書かれている値ではうまく動かなくて、このリポジトリの値を参考にしたらうまくいったということがあったような気がします。どの値だったかは忘れました。

また、専用のコンパイラgccベースらしい)やツールなどを揃える必要がありますが、Windows 向けのツールを macOS 向けに移植されたものを利用しているようです。

残念ながらその公開リンクはリンク切れしていましたが、こちらのリポジトリで公開されていました。GitHub - HariboteOS/z_tools_osx

そのソースコードがこちらのリポジトリで公開されていたので、それをコンパイルしてツール群を利用することにしました。GitHub - HariboteOS/tolsrc: Tool set for developing Haribote OS .

しかし、Xcode 10 からは 32 bit 環境向けのコンパイルは Deprecated になっていたため、解決策を調査して報告してプルリクエストを出し、マージしてもらいました。

github.com

30 日 OS 本の最初には 起動イメージバイナリをバイナリエディタでベタ書き するという洗礼があります。これを手打ちするわけですが、バイナリエディタには iHex を利用しました。

CPU が速すぎる

この本が出版された 2006 年当時の CPU で QEMU を動かすことを想定しているため、本の各所で「動かすとカクカクする」といった表現がされています。本の中ではそれを解決するために最適化を行う章が組み込まれています。

しかし、現代の CPU は当時に比べるととても速くなり、また QEMU のソフトウェアとしての品質も上がっているためだと思うのですが、ぶっちゃけカクカクしない という場面がところどころあり、時代の進歩を感じました。

一方で、CPU が速すぎるためにうまくいかなかった ところがあります。途中のタイマーのパフォーマンス計測のあたりだったと思うのですが、for ループの中で何度も io_cli() (割り込み禁止)と io_sti() (割り込み許可)を繰り返し呼び出す部分があります。

この処理の間に別の処理(グラフィックスのメモリ書き込みなど)を行なっている分には問題はないのですが、この 2 つの関数を交互に高速に呼び出すと QEMU が猛烈に遅くなりキーボードやマウスの割り込みをほとんど受け付けなくなります。CPU が速すぎてこの2つの関数呼び出しの数が多くなりすぎたため、逆に QEMU の動作が遅くなってしまうのが意外で面白かったです。

首をいわした

写経は肉体労働です。本を机に広げてその内容をディスプレイを見ながら打ち込んで行くのですが、僕の作業環境はこんな感じでした。

f:id:kawasin73:20190714171935j:plain

本の中身を首を下に向けて読んで、また首をあげてディスプレイをみるということを繰り返していたので、おかげさまで首をいわしました。プログラマの職業病です。

最初から、左の画面にサンプルコードを表示して、正面のディスプレイに写経することをすれば首の移動は左右だったので負担も少なかったのではないかと後悔しています。皆さんも気をつけてください。

Twitter をフィルタリングする Twilter を作った

あなたとわたしと Twitter。どうも、かわしんです。

今週の頭の日曜月曜火曜の 3 日間かけて Twilter というコマンド(?)サービス(?)を作ったので、その紹介をしたいと思います。

Github リポジトリはこちらです。star をつけていただけると喜びます。

また、良かったらご自身で運用してみてください。

github.com

なぜ Twilter を作ったのか

僕のツイッターライフ

僕は Twitter を情報収集と娯楽のために利用していますが、僕の性格として全ての情報を把握しておきたいため、フォローしている人の全てのツイートを読んでいます。

残念ながら Twitter の公式アプリは、勝手にツイートの順番を入れ替えたり、勝手にオススメのツイートだけをフィルタリングして上に表示したり、時間が経ってアプリを開くと勝手にスクロール位置を最新のツイートに変えてしまったりします。

余計なお世話にも程があります。僕はタイムラインの全てのツイートを確認したいのに勝手にスクロール位置やツイートの順番を変えられると、どこまで確認していたかがわからなくなってしまいます。

そこで、僕は Tweetbot という iOS アプリを利用してタイムラインの購読をしています。

このアプリは有料アプリですが、タイムラインの順番やスクロール位置を勝手に変えることはしません。また、複数の iOSバイス間でスクロール位置を同期してくれるので、家で iPad で読んで通学途中で iPhone で読むみたいな場合でもシームレスにタイムラインの確認ができます。さらに、キャッシュなどの最適化を行なっているため通信状況が悪くてもツイートの確認ができるのもいいところです。

僕みたいな異常な完璧主義の方にオススメのアプリです。

さて、全てのツイートの監視をしている僕ですが時間は有限であるため、ツイートが増えるスピードがツイートを読むスピードを超えてしまうと未読のツイートが無限に増えてしまいます。

そのため、ツイート数とその人が垂れ流す情報の有用さのバランスを元にフォローしている人を定期的に整理して、ツイートの流速調整を行っていました。

今は 100 フォロー以下を目安にやっていますが、割ときつくて、70 ~ 80 フォローくらいがちょうどいい流速なのではないかと思っています。

迎える限界

そんなツイッターライフを送っていた僕ですが、ある日僕のルールの限界を感じさせるユーザーに出会います。

重曹 さんです。この方の代表作はこれです。死ぬほど面白い。複数のツイートに分割されているのでリプライを辿っていってください。

「なかのやま」さん「NZK」さん「重曹」さんのセンスの塊の3名で構成される LINE グループのチャット内容を時々ツイートされるのですが、これが死ぬほど面白いのでフォローして LINE チャット画面を楽しみにしていました。

しかし、重曹さんの毎日のツイート数はかなり多く、僕のタイムラインが爆発してしまいました。LINE グループのツイートの数に比べてその他のツイートが多すぎるため僕のルールではフォローを外すしかないのですが、どうしてもこのセンスあふれる LINE グループの画像は見たいという板挟みの状態になってしまいました。

その時の僕の心の叫びがこれです。

ということで、Twilter を作ることにしました。

Twilter とは何か

Twilter は Twi t ter を F ilter してくれるサービスです。安直な名前なのですでに Twilter という名前のアプリは色々あるようです。しかし、それらはブラウザのエクステンションでツイッターのウェブサイトで表示を消す程度のことしかしてくれないようなので、自分で Twilter を作ることにしました。

機能としては以下の通りです。

  • 特定のユーザーのツイートを定期的に確認し、
  • 設定された条件にマッチするツイートをフィルタリングし、
  • そのツイートを連携している Twitterアカウントでリツイートする

というものです。その連携している Twitter アカウントだけを自分の Twitter アカウントからフォローすれば、フィルタリングされたツイートを自分のアカウントのタイムラインで確認することができます。

こんな形で使います。この設定では、重曹さんのリツイートでないオリジナルの画像ツイートのみをフィルタリングします。

$ export TWITTER_CONSUMER_KEY=xxxxxxxxxxxxxxxxxx
$ export TWITTER_CONSUMER_SECRET=xxxxxxxxxxxxxxxxxx
$ export TWITTER_ACCESS_TOKEN=xxxxxxxxxxxxxxxxxx
$ export TWITTER_ACCESS_TOKEN_SECRET=xxxxxxxxxxxxxxxxxx
$ export REDIS_URL=redis://user:password@host_name:6379

$ twilter -target "YTmoyashifes:and(photo,not(rt))"

このコマンド自体はフォアグラウンドで動きますので、デーモン化は dockersystemd などで行なってください。

docker イメージは https://hub.docker.com/r/kawasin73/twilter で公開しています。

注意事項

まず注意事項を記載しておきますが、リツイートを行うアカウントは 鍵アカウント(Private Account)にすることを強く推奨します。

相手ユーザーに多くのリツイートによる通知が表示されて迷惑になってしまうことを防ぐためです。鍵アカウントであれば相手ユーザーはリツイートされたことがわかりません。

また、監視できる対象ユーザーはツイートをリツイート可能なパブリックアカウントのみです。鍵アカウントは対象外ですので注意してください。

オプション一覧

オプションはこんな感じになっています。

$ twilter -h
Usage of /usr/local/bin/twilter:
  -fallback int
        start filtering tweets fallback minutes ago if no checkpoint (minutes) (default 10)
  -interval int
        interval between monitoring (minutes) (default 10)
  -target value
        list of targets. target format = "<screen_name>:<filter>[/<filter>]"  filter format = "<filter_name>[(<attribute>[,<attribute>])]"
  -timeout int
        timeout for each monitoring + retweet loop (minutes) (default 5)

本当は設定ファイルから読み込めたら便利なんですが、Docker で運用する場合に面倒になるのと、ファイルフォーマットの策定など諸々めんどくさかったので対応していません。

フィルターについて

フィルタ設定は、target オプションによって設定します。複数の target を設定することもできます。

target<twitter_id>:<filter>[/<filter>[/...]] のフォーマットで指定します。フィルターは / によって区切ることで複数設定できます。(本当は | で区切りたかったけど、シェルのパイプと認識されてしまうため / にしました)

これらのフィルターのうち1つでもマッチするものがあればリツイートされます。

フィルターは以下の種類があります。

  • rt : リツイートされているツイート
  • qt : 引用リツイートされているツイート
  • photo : 画像付きツイート
  • video : 動画付きツイート
  • not(<filter>) : 引数のフィルターにマッチしないツイート
  • and(<filter>[,<filter>[,...]]) : 引数のフィルターの全てにマッチするツイート
  • or(<filter>[,<filter>[,...]]) : 引数のフィルターのどれか1つでもマッチするツイート

まだ実装していませんが、特定のキーワードやハッシュタグを含むツイートのフィルターも作る予定です。

ここで特に特徴的なのは、最後の notandor フィルターの 3 つだと思っています。

この3つのフィルターを組み合わせることで比較的柔軟に色々な種類のフィルターを定義することができます。また、提供するフィルターの種類もプリミティブなものだけで済むので実装コストも抑えられるのもいいところです。

Redis との連携

Twilter はデフォルトでは起動時刻から fallback に設定された分だけ前のツイートからフィルタリングを始めます。これは再起動した場合に停止していた間のツイートを取りこぼさないためです。

Twilter はオプション機能として、Redis を用意して環境変数REDIS_URL に接続情報を登録するとツイートの確認状況を Redis に保存します。再起動した場合は Redis に保存されているツイートより後のツイートからフィルタリングを始めます。これにより無駄にフィルタリングをせずに済みます。

Redis との連携機能はオプション機能なので、 Redis がなくても Twilter を利用することができます。

Rate Limit

Twitter にはリクエスト数の Rate Limit があり一定時間にできる API リクエストの数が決まっています。

Twilter ではリクエスト数制限に引っかかった場合でも自動で適切な時間待って再送する 再送処理を実装 していますが、これは一時的にバーストした場合のためのものなのであらかじめ適切にリクエスト数の見積もりと設定をする必要があります。

ユーザーごとのツイート一覧を取得する API のリクエスト数制限は 900 requests / 15 minutes の制限と、 2019 年 6 月 19 日からは 100,000 requests / day の制限があります。

GET statuses/user_timeline — Twitter Developers

Twilter では Twitter アカウントを監視する間隔を interval オプションで指定できます。デフォルトは 10 分になっています。単位は minute です。

ユーザーのツイート一覧取得は、1 回のリクエストあたり最大 200 ツイートを取得できるので、各自それに合わせて interval の値を設定してください。

また、リツイート投稿 API のリクエスト数制限は 300 requests / 3 hours です。リツイート投稿が多くならないようにユーザーの選定とフィルターの中身を設定してください。

POST statuses/retweet/:id — Twitter Developers

アクセストークンについて

Twitter のアクセストークンを取得するには開発者アカウントが必要です。

Twitter は気づいたら開発者アカウントを取得するために 200 字の作文を複数箇所行わなければならないようになってしまっていて、ハードルが上がっていて悲しいです。が、背に腹は変えられないので頑張って英語作文しましょう。

多分、インストール手順の中で Twitter の開発者アカウントを作成するのが一番難しいステップ です。本当に悲しい。

取得したアクセストークンは、TWITTER_CONSUMER_KEY TWITTER_CONSUMER_SECRET TWITTER_ACCESS_TOKEN TWITTER_ACCESS_TOKEN_SECRET環境変数に設定して利用します。

TWITTER_CONSUMER_KEYTWITTER_CONSUMER_SECRET には開発者アカウントの Consumer Key と Secret を登録してください。

TWITTER_ACCESS_TOKENTWITTER_ACCESS_TOKEN_SECRET には、開発者アカウントから発行されたリツイートを行う鍵アカウントの Access Token と Secret を登録してください。

多くの場合は開発者アカウントの Twitter アカウントとリツイートを行う Twitter アカウントは 別のもの を使うと思います。その場合はダッシュボードから TWITTER_ACCESS_TOKENTWITTER_ACCESS_TOKEN_SECRET を生成することができません。

twitter-auth というコマンドラインツールがアクセストークンの発行を簡単に行なってくれるため、利用することを推奨します。

github.com

最後に

と、まぁ、こんな感じです。

Twilter は自宅の kubernetes on Mac mini サーバーで運用しています。じわざわリツイートがタイムラインに流れてくるのを「ちゃんと動いているぞ」と思いながら眺めています。

情報をうまくフィルタリングして時間を有効活用できるようになりました。

Twilter では定期的にツイッターを監視するタスクスケジューリングの部分に一年前に 管理上手のうさちゃん を作った時に作ったタスクスケジューラ htask を使っています。

github.com

うまく汎用的なライブラリを再利用できて、ニヤニヤしてます。

では、良い Twitter ライフを !