地方エンジニアの学習日記

興味ある技術の雑なメモだったりを書いてくブログ。たまに日記とガジェット紹介。

【Linux】TCPの再送制御あたりのソースを眺める

概要

ブラウザからWebサーバを経由しAPサーバにアクセスする場合の通信に関する説明をざっくりまとめてみました。再送制御あたりがメインです。

記事を書こうと思った背景

AWSの資格学習をしていてRDSあたりのフェールオーバ ーあたりで「DNSベースでやるのはいいけどJVMやらサードパーティで提供されてるコネクションプールなんかで良くないこと起きそう?」って思ったのが記事書こうと思った動機です。

LinuxTCPの再送制御あたりの知識って断片的な知識だけで実はきちんと理解してないことに気づいたのでAWSの資格勉強の息抜きに調べてみた。*.hをメインに眺めるだけで突っ込んだTCPあたりの実装はそんなに読まない方針でいく。

そもそもTCPの再送制御ってなんだって話は以下が詳しかった。

TCP/IP - TCP 順序制御、再送制御

実験環境/Linux tcpのソースバージョン

実験環境は以下

$ uname -a
Linux choco001 3.10.0-1127.13.1.el7.x86_64 #1 SMP Tue Jun 23 15:46:38 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux

実装自体は以下のあたりからメインで眺めていく。

linux/tcp.c at master · torvalds/linux · GitHub

ヘッダーは以下。

linux/tcp.h at master · torvalds/linux · GitHub

関連するカーネルパラメータ

以下に今回の再送制御関連のパラメータをあげます。基本的にこの辺のパラメータは以下の構造体を使って設定している。案の定巨大な構造体なので気になるところ以外はオールスルーで行きます。

linux/ipv4.h at master · torvalds/linux · GitHub

tcp_syn_retries

TCP 接続を行うために最初の SYN パケットの再送信を試みる回数

接続先が落ちてるケースでtcpのsynをリトライする回数。1回目のsynから2回目のsynを投げるまでの時間は1秒。tcp.hには以下のように定義されている。

#define TCP_TIMEOUT_INIT ((unsigned)(1*HZ))   /* RFC6298 2.1 initial RTO value    */

static inline u32 tcp_timeout_init(struct sock *sk)
{
    int timeout;

    timeout = tcp_call_bpf(sk, BPF_SOCK_OPS_TIMEOUT_INIT, 0, NULL);

    if (timeout <= 0)
        timeout = TCP_TIMEOUT_INIT;
    return timeout;
}

この値を変えたいケースはDNSベースでフェールオーバ をサポートしてるDBなんかで名前をアプリ側で引いた際に既に落ちてるノードのIPが帰ってきたらアプリにエラーが通知されるまでリトライ分の時間がかかってしまう。

(クライアントTCPがSYNセグメントに対する応答を受信しなかった場合ETIMEOUTが返されアプリ側で再送してるケースはここでは考慮しない。)

リトライ分の時間の計算はちょっと厄介で調べたけどいまいちピントは来なかった。リトライアルゴリズム自体はLinuxもエクスポネンシャルバックオフ(のようなアルゴリズム)を使って指数関数的にリトライ間隔時間を増やして実装している。

RFCあたりにもっと詳しく書いてるらしいが解読はできず。。。

RFC 6298 - Computing TCP's Retransmission Timer 日本語訳

ちなみにこの辺の値はカーネルにハードコードされているので自前で値を変える場合はビルドしてあげる必要がある。このレイヤで書き換えるぐらいならアプリ側でハンドリングすればいいだけの話の気もする。

#define TCP_RTO_MAX ((unsigned)(120*HZ))
#define TCP_RTO_MIN    ((unsigned)(HZ/5))

接続先次第でこの辺を動的に変えてる記事があったので貼っておきます。システムコール自体をhookしてやってるようです。恐ろしい。。Webサーバのインターネット側とバックエンド側で、TCPの再送タイムアウトを変えたいような場合と言うのはなんとなく分かるけど自分たちで実装するとは。。。

TCPの再送タイムアウトを制御したい - Qiita

tcp_retries1

TCP 接続要求に対する応答の再送を試みる回数を指定

tcp_retries2

TCP パケットの再送を試みる回数を指定

tcp_retries2 は確立された接続の再送信タイムアウトになります。タイムアウトを短くしないといけないような状況の時は再送回数を調整して短くすることでアプリケーションの無駄な待ち時間を減らすことができます。(ESTABLISHな状態で相手が死んだケースでのリトライ回数)

net.ipv4.tcp_retries2 = 15

デフォルト値は15となっていてこの辺はRFCにも推奨値なんかが乗ってたりするみたいです。ESTABLISHなコネクションで接続先がRSTを投げずに死んだ場合はこの値だけリトライします。

https://jprs.jp/tech/material/rfc/RFC1122-ja.txt

    This value influences the timeout of an alive TCP connection,
    when RTO retransmissions remain unacknowledged.
    Given a value of N, a hypothetical TCP connection following
    exponential backoff with an initial RTO of TCP_RTO_MIN would
    retransmit N times before killing the connection at the (N+1)th RTO.

    The default value of 15 yields a hypothetical timeout of 924.6
    seconds and is a lower bound for the effective timeout.
    TCP will effectively time out at the first RTO which exceeds the
    hypothetical timeout.

    RFC 1122 recommends at least 100 seconds for the timeout,
    which corresponds to a value of at least 8.
net.ipv4.tcp_keepalive_time

接続がアイドル状態になってから、keep-alive プローブを送信するまでの時間を秒単位で指定

まとめ

  • DBがフェイルオーバする際にFINが送られるならアプリ側では(下手にDNSキャッシュを持ってなければ)特にやることはなさそう
  • DBが不慮の停止でFINを送信できない場合は注意が必要
  • (1) コネクションプールしてるならきちんと死活監視をする。アプリでやるにしろOSでやるにしろ間隔は短めにするのが良さそう
  • (2) アプリ側のクラッシュも考慮するならDB側でも対処が必要

参考リンク

(おまけ) Linuxにおける選択的応答SACKの脆弱性

tcp_sack

RFC2018 による選択的確認応答 (SACK) を有効にする

SACKとは受信者が順序がバラバラのセグメントを受信したことを送信者に通知することを可能にする仕組み。「私はシーケンス番号10まですべてを受け取り、次に11を期待しますが、20-30も受け取りました」みたいなことを可能にする。この機能を使った脆弱性

ブログ: TCP SACK Panicについて知っておくべきこと

【memcached】スピンロックでatomic操作を実現するためには

概要

マルチプロセス環境配下における、同一レコードへの書き込みが大量に発生するwrite heabyな環境でmemcachedの更新をどうやってatomicに行うかを考えた時に出てきたスピンロック的なことやってみた記事。

memcachedとは書いたもののCAS操作を提供しているミドルウェアなら当てはまるしロック機構をクライアントで頑張ってるアプリケーションでも似たような話になると思う。

想定シナリオ

ユーザアクセスなり何かしらの動作が発生するたびに何かをインクリメントしたいケースがあったとしてcasを用いてある程度の原子性を持たせて実装しようとすると以下のようになる。

$ret = $mem->gets('test');
($cas, $val) = @$ret;
$val++;
$mem->cas('test', $cas, $val));

ここで問題となるのは以下が失敗する可能性のある命令であるということ。例えばこのコードを同時に並列で動かしている場合にcas値を使った更新では一番先に更新を行ったプロセス以外は全て失敗となって何もしないということになる

$mem->cas('test', $cas, $val));

さくらインターネットさんのサンプル含めこの操作自体について調べていると以下のようにリトライすれば問題ないと言った記事があった。ざっくりいうと更新が成功するまでredoでループを繰り返していくというもの。マルチプロセスで動いている場合でも更新が成功するまでcas値の取得->更新を繰り返すのでインクリメントは必ず行われるよねと言った話。

foreach (1 .. $num) {
    $ret = $mem->gets('test');
    ($cas, $val) = @$ret;
    $val++;
    unless ($mem->cas('test', $cas, $val)) {
        warn "$val update failed. retrying.\n";
        redo;
    }
}

research.sakura.ad.jp

ここで面白いなと思ったのがこの「更新が成功するまでredoを行う」という部分。(memcachedのCAS値の確認をロックと見立てれば) これはLinuxカーネルなんかでもよく使われるスピンロックに似ている!と思った。

ちなみにここではincrを例にしてますがmemcahced自体に備わっているincr命令はatomicでこっちが使えるケースなら今回取り上げる話は正直不要な話になります。

www.w3big.com

スピンロックとは

スピンロックとは共有リソースが2つ以上のプロセスによって同時に変更されるのを防ぐ方法です。複数プロセスが同時に同じリソースに更新を行おうとした際に最初のプロセスがロックを取得しレコードに関する更新権を得た状態になった際に、以降のプロセスはロックを取得できない状態となります。

この時に2つ目以降のプロセスはロックを獲得するために以下の動作をとることができます。

  • ループしてロック獲得の処理
  • sleepしてロック獲得の処理
  • イベント発生をOS側に通知してもらうために依頼

ここで取り上げるのが「ループしてロック獲得の処理」これが一般的にいうスピンロックとなる。ループで何もしないという動作でロック獲得を待ち獲得できたら処理を行うという流れです。Linuxだとハードウェア関連の操作を行う際にこの辺はよく出てきたりします。

github.com

ちなみにこれ自体はLinux以外のOSでも利用されている,一般的な排他制御機構となります。実装例とかは基本linux以外で使ってるケースをあまり見ないので使う機会があまり無いのかもしれません。(ここはよくわからない)

スピンロックのメリット/デメリットは

スレッドが休止状態にならない。と言うのがメリットです。memcachedの上記のサンプルで言うならこのコード自体はLinuxのスケジューラが意図的に実行を取り上げない限りは実行可能状態で継続します。この場合CPUリソースはプロセスが放さないのでコンテキストスイッチが発生しない分高速にロックを獲得することができます。

デメリットはそのままでスレッドが休止状態にならないのでCPUリソースを食い続けます。また、スピンロックを保持したスレッドがCPU待ち状態になると、このスレッドが再度スケジューリングされるまで、スピンロックが解放されません。このため、スピンロック待ちの頻度が高くなりアプリケーション性能は劣化したりします。

そしてここで挙げたメリットであるコンテキストスイッチが発生しないのはスケジューラのプリエンプトが指定できるカーネル空間での話でユーザ空間として実装しようとしている今回のアプリケーションでは効果がだいぶ薄まります。

stackoverflow.com

preempt_disableをカーネルはどこでやってるのか

カーネルスペースで実行されている場合、スピンロックを取得すると、実際にプリエンプションが無効になりますみたいな話を上で書いたけど結局どうやってんねんって思ったので調べた。

ざっくり言うとcurrent_thread_info()->preempt_disable_countをインクリメントしてプリエンプションを抑止

void preempt_disable(void)
{
    BUG_ON(preempt_disable_count < 0 || preempt_disable_count == INT_MAX);

    if (preempt_disable_count++)
        return;

    thread_cpu_id = nondet_int();
    assume(thread_cpu_id >= 0);
    assume(thread_cpu_id < NR_CPUS);
    lock_impl_lock(&cpu_preemption_locks[thread_cpu_id]);
}

elixir.bootlin.com

スピンロックの実装自体はこんな感じで書かれています。ロックを取得するまでループすると言った流れを取っているのがわかります。(プリエンプションの無効化なんかはここではなく呼び出し元でやってるのでしょうか。この辺はよくわからなかった。)。なんとなくでおった際のコメント入れてみました。

static __always_inline void arch_spin_lock(arch_spinlock_t *lock)
{
    register struct __raw_tickets inc = { .tail = TICKET_LOCK_INC };

    inc = xadd(&lock->tickets, inc);
    if (likely(inc.head == inc.tail))
        goto out;

    for (;;) {
        unsigned count = SPIN_THRESHOLD;

        // inc.tail(元々)とinc.head(最新)を比較(__tickets_equal)
        do { // 一致するまでSPIN_THRESHOLD回繰り返す
            inc.head = READ_ONCE(lock->tickets.head);
            if (__tickets_equal(inc.head, inc.tail))
                goto clear_slowpath;  // 一致したら(goto clear_slowpath)で後処理
            cpu_relax();
        } while (--count);
        __ticket_lock_spinning(lock, inc.tail);
    }
clear_slowpath:
    __ticket_check_and_clear_slowpath(lock, inc.head);  // tailに1を加算し前の値を記録してロック操作を完了する
out:
    barrier();  /* make sure nothing creeps before the lock is taken */
}

感想

スピンロックはユーザ空間で実装するのはメリットが少なそうなので素直にblockingなりで実装しよう。ロック期間が十分に小さい場合でも結局スケジューリング次第では期間が短いかの測定も難しそうだし。

ただユーザ空間で動くpthreadにspinlockなるものを見つけたのでいつかこれは試してみたい。(Cで今後がっつり開発する機会はないはずだけど。。)

int  pthread_spin_destroy(pthread_spinlock_t *lock);
#include <pthread.h>

pthread_spinlock_t lock;
int ret;

ret = pthread_spin_destroy(&lock); /* spinlock is destroyed */

###

int  pthread_spin_trylock(pthread_spinlock_t *lock);
#include <pthread.h>

pthread_spinlock_t lock;
int ret;

ret = pthread_spin_trylock(&lock); /* try to lock the spin lock */

ざっとみた感じpthreadでspinlockをやるにはCで書く必要がありそうな感じだしpythonやらから呼ぶのも大変そうなのでロック獲得をユーザ空間でやる場合にスピンロック使おうってケースはまあほぼなさそう。ポスグレの参考記事なんかも出てきたけどあれはあれでどうやって実装してるんだろう。

amachang.hatenablog.com

【Ansible】Ansibleにおける冪等性とは

概要

仕事でひたすらplaybook書いてたら「冪等生ってなんだ?」ってなったので調べたメモ。

そもそも冪等性とは

同じ操作を何度繰り返しても、同じ結果が得られるという性質です。以下あたりが詳しく書かれてます。

qiita.com

よくある冪等生の説明の数式ですが冪等生がある関数fは何回実行しても結果が同一という性質があります。例えば絶対値を求めるabs()なんかがこの性質を持った関数です。

f() = f(f())

冪等性がない関数の場合は上記の式は成り立ちません。上の記事にあるようなsqrt()なんかがそうです。

sqrt(16) ≠ sqrt(sqrt(16))

Ansibleにおける冪等性の話

Ansibleといえば冪等性の話題が必ずと言っていいほど挙げられます。Ansibleによる冪等性はサーバの状態を保つことです。ここでポイントになるのがサーバの状態を保つとはどういうことなのかという点です。

Ansibleの公式のモジュールは冪等性を保つような実装をされていますが厳密な意味で上記で挙げたような冪等性を保つような実装はなされていません。以下がその例です。

  tasks:
    - name: yum
      yum:
        name: httpd
        state: installed

上記のtaskは実際何回実行してもhttpdがインストールされた状態というのは保たれます。これは一見して冪等性を保っている実装に見えますが実際にはyumを実行するとことでyum独自のdbファイルへのアクセスや書き込みが裏では行われます。

アクセスが発生するだけで困るケースやモジュールの先で使うようなログファイルが変更されるのが困るケースがあるかないかはいったん置いておきます。

ここで言いたいのはあくまでもAnsibleが実行するtasksの冪等性は完全なものではなくモジュールの実装次第で冪等性のカバーする範囲が変わってしまうという点です。

f() = f(f())

Ansibleがいう冪等性は上記を満たすものではなく関数に特定範囲の値を取りうるパラメータeを与えた場合に冪等生が成り立つ以下の式となる。todo: 式考える (eは「httpdがインストールされている状態」のような値を取りうる)

f(x, e) = f(f(x, e), e)

Ansibleに置いていえばこのサーバの状態が違う場合は冪等性という性質は成り立たなくなる。例えば特定のディレクトリAが存在する状態をe1。存在しない状態をe2としたらそれぞれのタスクは同一の結果を得られないような場合があります。

f(x, e1) = f(x, e2)

モジュールないで何かをビルドしたりするケースやOS設定時のパラメータファイルの存在有無のタスクなんかがこれに当てはまります。

httpにおける冪等性

冪等性の記事でhttpのgetとかdeleteとかも割とよく見かけます。httpリクエストの場合はRFCでも言及されていて割と参考になったりしたので書いておきます。

Like the definition of safe, the idempotent property only applies to what has been requested by the user; 
a server is free to log each request separately, retain a revision control history, 
or implement other non-idempotent side effects for each idempotent request.

google翻訳する

セーフの定義と同様に、べき等プロパティはユーザーが要求したものにのみ適用されます。
サーバーは、各要求を個別にログに記録したり、リビジョン管理履歴を保持したり、
各べき等要求に対して他の非べき等の副作用を実装したりすることができます。

ざっくりまとめるとユーザが要求したものについては冪等性を担保するけどその先でサーバがなんかするのは担保しないよというもの。Ansibleでも似たような性質が見れるのでこれがしっくりきた。

Ansibleの場合はさっきあげたタスクを例にすると「httpdをインストールされた状態は保つけどyumが使うDBやその他システムファイルは関与しないよ」と言った考えになります。

この辺はissueとかでも議論されていて見解としては全てのモジュールが冪等じゃないしこの辺は今後も厳密にやってく気はないと言った感じで決着がついていた。

github.com

whenで冪等性

ansible-lintでshellモジュールを使うとwhenなしで使うと怒られる。これは上のAnsibleの思想(というかhttpの冪等性の思想?)によるものなんじゃないかと思う。

shellモジュールは特性上ユーザが自由にスクリプトを書いて実行できるので冪等性のない書き方をひたすらやって実行の都度chengedステータスが出るようになってしまう。

Ansibleの冪等性は状態というパラメータを持つ必要があるのでshellモジュールでこの状態を表す方法としてwhenが必要と言っているのかなって思った。

【Apache】httpd 2.2と2.4で微妙に違うrewrite rule

適当なrewrite ruleを使ってproxyする設定を書いた。以下の挙動が2.2と2.4で微妙に違うことに気づいたのでメモ

config

<VirtualHost *:80>
    ServerName www.test.com
    SSLProxyEngine On
    RewriteEngine on
    RewriteRule ^/(.*) $1 [P,L,QSA]
</VirtualHost>

vhostのwww.test.comへのアクセス時にhttp://www.test.com/https://www.google.comみたいな感じのリクエスト時にRewriteRuleを元にgoogleのtopが表示されるのが期待動作。

2.2 -> 200

< HTTP/1.1 200 OK
< Date: Thu, 01 Apr 2021 14:17:03 GMT
< Server: gws
< Expires: -1
< Cache-Control: private, max-age=0
< Content-Type: text/html; charset=ISO-8859-1
< P3P: CP="This is not a P3P policy! See g.co/p3phelp for more info."
< X-XSS-Protection: 0
< X-Frame-Options: SAMEORIGIN
< Alt-Svc: h3-29=":443"; ma=2592000,h3-T051=":443"; ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; ma=2592000; v="46,43"
< Accept-Ranges: none
< Vary: Accept-Encoding
< Set-Cookie: 1P_JAR=2021-04-01-14; expires=Sat, 01-May-2021 14:17:03 GMT; path=/; domain=.google.com; Secure
< Set-Cookie: NID=212=HzIcDm6x02fFuJ5dT_0LD_Rmwc6YiZCxfz8o-4h0cbQhY4ZvB_A8PBgRL6fh8GVRJCzhRDrI0xqWvmh0h5S1QiwODURn92C8A9NVjTPSOaxJdOojbql5s8DVgqOpJd5uWRfZ0DDZVa_39-zTS0LjegvPM00WGiOHHacnacRGIJI; expires=Fri, 01-Oct-2021 14:17:03 GMT; path=/; domain=.google.com; HttpOnly
< Transfer-Encoding: chunked

2.4 -> 301

< HTTP/1.1 301 Moved Permanently
< Date: Thu, 01 Apr 2021 14:20:39 GMT
< Server: nginx/1.18.0
< Content-Type: text/html; charset=UTF-8
< X-DIS-Request-ID: c9ea127df498f905de72f58aca50d697
< Location: https://www.test.com/https:/www.google.com
< Transfer-Encoding: chunked

2.4の方が301になっているがこれはPフラグがうまく機能していない模様。ログをdebugで起動してみると確かにproxyがうまくいっていない。

[Thu Apr 01 14:23:34.393360 2021] [proxy:debug] [pid 9:tid 140575798086456] proxy_util.c(2392): [client 172.17.0.1:61890] AH00944: connecting http://www.test.com/https:/www.google.com to www.test.com:80
[Thu Apr 01 14:23:34.395300 2021] [proxy:debug] [pid 9:tid 140575798086456] proxy_util.c(2615): [client 172.17.0.1:61890] AH00947: connected /https:/www.google.com to www.test.com:80

動作が違う理由は良くわかっていない。ただセキュリティ的に言えば2.2の動作はクライアントの動き次第ではproxy先への攻撃に加担するような形になってしまうので2.4のような動きが正しいのかもしれない。

2.4で同じようなことをするなら一旦キャプチャして改めてフルぱすでproxyするようなconfignになると思う。