Mac 用 Docker デスクトップでの時間ドリフトへの対処

Docker Desktop for Mac は、macOS がネイティブ コンテナーをサポートしていないため、ヘルパー LinuxKit VM で Docker エンジンと Linux コンテナーを実行します。 ヘルパー VM には、ホストのクロックとは別の独自の内部クロックがあります。 2つのクロックが離れると、突然、時刻またはファイルのタイムスタンプに依存するコマンドの動作が異なる場合があります。 たとえば、ソース ファイルが変更された後でも、ソース ファイルの変更時刻 (通常はホストに書き込まれる) がバイナリの変更時刻 (通常は VM に書き込まれる) よりも古い場合、"make" は共有ボリューム間で正常に動作しなくなります ("docker run -v")。 時間の浚渦は、次のような問題を読むことでわかるように、非常にイライラする可能性があります https://github.com/docker/for-mac/issues/2076.

待ってください、VMには(仮想)ハードウェアリアルタイムクロック(RTC)がありませんか?

ヘルパー VM が起動すると、クロックは最初に "hwclock -s" の明示的な呼び出しによって同期されます。 ハイパーキットの仮想RTC.残念ながら、RTCの読み取りは(物理ハードウェアと仮想の両方で)低速な操作であるため、Linuxカーネルは、他のタイミング情報ソースの上に独自の内部クロックを構築します。 クロックソース {くらっく.最も信頼性の高いのは、通常、最後のCPUリセット以降のCPUサイクル数をカウントして時間を測定するCPUタイムスタンプカウンタ(「tsc」)クロックソースです。 TSCカウンタはベンチマークによく使用され、現在のTSC値がテスト実行の開始時に(命令を介して rdtsc )読み取られ、テスト実行の終了時に再度読み取られます。 次に、2 つの値を減算して、コードが CPU サイクルで実行されるのにかかった時間を生成できます。 ただし、これらのカウンターを絶対物理時間の信頼できるソースとして長期間使用しようとすると、特に VM で実行する場合に問題があります。

  • あるんだ TSC周波数を検出する信頼できる方法はありません:これがないと、結果を秒に変換するためにカウンター値を何で割るかわかりません。
  • 一部の電源管理テクノロジは、TSC周波数を動的に変更します。
  • カウンタは、物理 CPU がリセットされると (ホストのサスペンド/レジュームなど)、0 に戻る可能性があります。
  • 仮想CPUが1つの物理CPUコアでの実行を停止し、後で別のCPUコアで実行を開始すると、TSCカウンターが突然前後にジャンプする可能性があります。

TSC カウンターを使用することの信頼性の低さは、この Mac 用 Docker デスクトップのインストールで確認できます。

$ docker run --rm --privileged alpine /bin/dmesg |grep クロックソース
...
[3.486187]クロックソース:クロックソースtscに切り替えました
[6963.789123]クロックソース:CPU3の計時ウォッチドッグ:スキューが大きすぎるため、クロックソース「tsc」を不安定としてマークします。
[ 6963.792264] クロックソース: 'hpet' wd_now: 388d8fc2 wd_last: 377f3b7c マスク: ffffffff
[ 6963.794806] クロックソース: 'TSC' cs_now: 104A0911EC5A cs_last: 10492CCC2AEC マスク: ふふふふふふふ
[6963.797812]クロックソース:クロックソースhpetに切り替えました

多くのハイパーバイザーは、明示的な「準仮想化クロック」インターフェイスを提供し、TSC値を秒に正しく変換できるように十分な追加情報をVMに提供することで、これらの問題を解決します。 残念ながら、MacのHypervisor.frameworkは、このような準仮想化されたタイムソースの実装を可能にするのに十分な情報(特にサスペンド/レジュームオーバー)を提供しないため、Appleに問題を報告し、回避策を探しました。

これは実際にはどれほど悪いですか?

VMとホストの間の時間ドリフトを測定する簡単なツールを作成しました–ソースは ここは.時刻同期ソフトウェアをインストールせずに小さなLinuxKitテストVMを作成し、VMの起動後の「自然な」クロックドリフトを測定しました。

タイムドリフト1

グラフ上の各線は、異なるテスト実行のクロックドリフトを示しています。 グラフから、VMの時間は、経過するホスト時間の3秒ごとに約2ミリ秒失われているように見えます。 合計ドリフトが約1秒(約1500秒または25分後)に達すると、非常に迷惑になり始めます。

OK、NTPをオンにして忘れることはできますか?

ネットワークタイムプロトコル(NTP)は、クロックの同期を維持するように設計されているため、理想的です。 問題は、

  • どのクライアントですか?
  • どのサーバーですか?

他の人と同じように「デフォルト」pool.ntp.org を使用するのはどうですか?

多くのマシンやデバイスは、NTPサーバーとして無料 pool.ntp.org を使用しています。 これはいくつかの理由で私たちにとって悪い考えです。

  • それは彼らの[ガイドライン](http://www.pool.ntp.org/en/vendors.html)(ベンダーとして登録することはできましたが)
  • NTPサーバープール内のクロック自体が適切に同期されているという保証はありません
  • 人々はMacが予期しないUDPトラフィックを送信するのが好きではありません。彼らはそれがマルウェアの侵入であることを恐れています
  • とにかく。。。VMをランダムな物理ラボの原子時計と同期させるのではなく、ホストと同期させます(タイムスタンプが機能するように)。 ホスト自体が "リアルタイム" から 30 分離れている場合は、VM も "リアルタイム" から 30 分離れるようにする必要があります。

したがって、Dockerデスクトップでは、ホスト上で独自のNTPサーバーを実行し、ホストのクロックを提供する必要があります。

どのサーバー実装を使用する必要がありますか?

NTP プロトコルは、堅牢でグローバルにスケーラブルになるように設計されています。 正確な時計ハードウェア(原子時計や原子時計からの信号を含むGPSフィードなど)を備えたサーバーは比較的まれであるため、他のすべてのホストがそれらに直接接続できるわけではありません。 NTPサーバは、下位の「階層」がすぐ上の階層と同期し、エンドユーザーとデバイスが下位のサーバと同期する階層に配置されます。 私たちのユースケースには1つのサーバーと1つのクライアントしか含まれていないため、これはすべて完全に不要であり、で説明されているように「簡略化されたNTP」を使用します。 RFC2030 これにより、クライアント(この場合はVM)がサーバー(この場合はホスト)とすぐに同期できるようになります。

どのNTPクライアントを使用する必要がありますか(そしてそれは重要ですか)?

Docker Desktop の初期バージョンが含まれています オープンNTPD から アップストリーム LinuxKit パッケージ.次のグラフは、openntpd が最初の 10000 秒間実行され、その後ビジーボックス NTP クライアントに切り替える 1 つの VM ブートでの時間ドリフトを示しています。

タイムドリフト2

この図は、openntpdが実行されている状態でクロックがまだ大幅にドリフトしていることを示していますが、busyboxを実行することで「修正」されています。 これを理解するには、まずNTPクライアントがLinuxカーネルクロックを調整する方法を理解することが重要です。

  • 調整時間 (3) –これはデルタを受け入れます(例: -10s) を指定し、システムクロックを徐々に調整するようにカーネルに指示し、クロックを突然前方 (または後方、単調クロックを使用しないタイミングループで問題を引き起こす可能性があります) しないようにします
  • アジティメックス (2) –これにより、カーネルクロック*レート*自体を調整して、私たちが苦しんでいるような体系的なドリフトに対処できます
  • 一日の設定時刻 (2) –これにより、すぐに時計が指定された時間にバンプします

の433行目を見ると オープンNTPD NTP.C (申し訳ありませんが、CVSwebに直接リンクはありません)次に、OpenNTPDがadjtimeを使用して定期的にクロックにデルタを追加し、ドリフトを修正しようとしていることがわかります。 これは openntpd ログでも確認できます。 では、なぜこれが効果的ではなかったのでしょうか。

次のグラフは、adjtime(+10s) と adjtime(-10s) の呼び出しによって自然なクロック ドリフトがどのように影響を受けるかを示しています。

タイムドリフト3

私たちが経験している「自然な」ドリフトは非常に大きいので、「adjtime」だけでは補うことができないようです。 busyboxのパフォーマンスが向上する理由は、「adjtimex」を使用してクロックレート自体を調整するためです。

次のグラフは、カーネル・クロック周波数の変化を示しています(timex.freq) アジタイメックスを使用して表示します。 最初の10000ではopenntpdを使用し(したがって、APIを使用しないため調整は0です)、残りのグラフではbusyboxを使用しました。

レート 1

調整値が平坦で、最初のスパイクが上向きになった後も非常に負のままであることに注意してください。 私が最初にこのグラフを見たとき、私はがっかりしたことを認めなければなりません - クロックレートが常に安定したままであるためにマイクロ管理されていたので、私は上下にジグザグに何かを見たいと思っていました。

カーネル周波数オフセットの最終値について何か特別なことはありますか?

残念ながらそれは特別です。 から 調整 (2) マンページ:

ADJ_FREQUENCY
             周波数オフセットの設定元 buf.freq. Linux 2.6.26 以降では、
             指定された値は (-32768000, +32768000) の範囲にクランプされます。

したがって、busyboxは体系的なドリフトを修正するために最大量(-32768000)だけクロックを遅くしたようです。 adjtimex(8) のマンページ によると、値 65536 は 1ppm に相当するので、32768000 は 500ppm に相当する。システマティックドリフトの当初の推定値は3秒ごとに2ms、つまり約666ppmであったことを思い出してください。 これは良くありません:これは、adjtimexがそれを補うためにできることの限界にあり、おそらく追加の調整を提供するためにadjtimeにも依存していることを意味します。 残念ながら、すべてのテストは1台のマシンで行われており、adjtimex + adjtimeでさえドリフトに対処できない別のシステム(おそらく異なる省電力動作)を想像するのは簡単です。

では、どうすればよいのでしょうか。

NTPクライアントがadjtimeやadjtimexなどのAPIを使用する主な理由は、彼らが

  • 単調性:つまり、タイミングに単調な時計を使用していないプログラムでバグを引き起こす可能性があるため、時間が逆行しないようにするためです うるう秒がクラウドフレアDNSに影響を与えた方法と理由;そして
  • 滑らかさ:つまり、突然前方にジャンプしたり、一度に多くのタイミングループ、cronジョブなどをトリガーしたりすることはありません。

Dockerデスクトップは、開発者がラップトップやデスクトップでコードをビルドおよびテストするために使用されます。 開発者は日常的に IDE を使用してホスト上のファイルを編集し、 を使用してコンテナ docker run -vにファイルをビルドします。 これには、VMのクロックがホストのクロックと同期されている必要があり、そうしない make と、変更されたソースファイルを正しく再構築できません。

オプション1:カーネルの「ティック」を調整する

聞いたところでは アジティメックス(8) カーネルの "tick"を調整することが可能です。

各カーネルティック割り込みのシステム時刻に追加するマイクロ秒数を設定します。 USER_HZ=100のカーネルの場合、毎秒100ティックあるはずなので、 ヴァル 10000に近いはずです。 増加 ヴァル 1 だけ、システムクロックを約 100 ppm高速化します。

体系的なドリフトがわかっている(または測定できる)場合は、「ティック」を使用して粗い調整を行い、busybox NTPに残りのドリフトを管理させることができます。

オプション2:定期的に時計を前方にバンプします 一日の設定時刻 (2)

VMのクロックが常に実際の物理クロックよりも遅いと想定し(仮想化されている、vCPUが定期的にスケジュール解除されているため)、滑らかさを気にしない場合は、呼び出すNTPクライアントを使用できます。 一日の設定時刻 (2) 定期的にクロックをすぐに再同期します。

選択

オプション1が最良の結果をもたらす可能性がありますが、私たちはそれをシンプルに保ち、オプション2を採用することにしました。 一日の設定時刻 (2) カーネルティックを測定して調整しようとするのではなく。 VM クロックは常にホスト クロックよりも低速で動作すると仮定しますが、実行速度が遅い量を正確に測定したり、時間の経過や異なるハードウェア設定間で速度低下が一定であると仮定したりする必要はありません。 解決策は非常にシンプルで理解しやすいです。 VMクロックはホストと密接に同期している必要があり、単調である必要がありますが、あまりスムーズではありません。

というNTPクライアントを使用します ティッカー Alpine Linuxの創設者であるNatanael Copaによって書かれました(Docker DesktopでAlpineを広く使用していることを考えると、まったくの偶然です)。 SNTPCは、n秒ごとにsettimeofdayを呼び出すように構成でき、次の結果が得られます。

タイムドリフト 5

グラフからわかるように、30 秒ごとに VM クロックが 20 ミリ秒遅れ、その後前方にバンプされます。 VM クロックは常にホストよりも低速で実行されているため、VM クロックは常に前方にジャンプしますが、後方にジャンプすることはありません。

ほんの一瞬、30秒ごとにhwclock -sを実行することはできませんでしたか?

UDP で通信する単純な NTP クライアントとサーバを 30 秒ごとに実行する代わりに、 hwclock -s を実行してハードウェア RTC と同期を 30 秒ごとに実行することもできます。 共有メモリに効率的にキューイングされるUDPトラフィックとは異なり、メモリはハイパーバイザにトラップを読み取り、vCPUをブロックするため、RTCの読み取りは非効率的です。しかし、コードは単純で、30秒に一度の高価な操作はそれほど悪くありません。 それは実際に時計をどれだけうまく同期させるでしょうか?

タイムドリフト6

 

残念ながら、HyperKit Linux VMでhwclock -sを実行しても、クロックの同期は約1秒以内にしか維持できず、コードを編集してすぐに再コンパイルするときに非常に顕著になります。 したがって、NTPに固執します。

最終設計

最終的なデザインは次のようになります。

 

タイムドリフト7

VM では、sntpc プロセスはポート 123 (NTP ポート) で UDP を、ホスト上の vpnkit プロセスによって管理されるゲートウェイで実行されている仮想 NTP サーバーに送信します。 NTP トラフィックは、ローカルホストで実行されているカスタム SNTP サーバに転送され、ローカルホストは gettimeofday を実行して応答します。 sntpc プロセスは応答を受信し、ラウンドトリップ時間の推定値を減算して現地時間を計算し、settimeofday を呼び出してクロックを正しい値にバンプします。

まとめ

  • コンピュータの計時は難しいです(特に仮想コンピュータではそうです)
  • macOS Hypervisor.frameworkには体系的なドリフトの深刻な原因があります。 ハイパーキット、リナックスシステム。
  • 時間ドリフトを最小限に抑えることは、makeなどのビルドツールでファイルのタイムスタンプが使用される開発者のユースケースにとって重要です
  • 開発者のユースケースでは、時計が突然前進してもかまいません
  • 標準プロトコル (SNTP) と既存のクライアント (sntpc) を使用したシンプルな設計に落ち着きました
  • 新しいデザインは非常にシンプルで堅牢でなければなりません:クロックドリフトレートが将来速くなったとしても(Hypervisor.frameworkやHyperKitのバグのため)、クロックは同期を保ちます。
  • 新しいコードは、安定したチャネルとエッジチャネルの両方で出荷されています(18.05現在)