64ビット time_t への移行の危険性
(blogs.gentoo.org)- 32ビット glibc システムでは 2038 年以降、現在時刻の取得や
stat()呼び出しが失敗する可能性があり、Gentoo には 64ビット time_t へ安全に移行する経路が必要 - glibc の time64 は Large File Support(LFS) と併用する必要があり、32ビット環境には既存 ABI、LFS ABI、LFS+time64 ABI が併存する
time_tが API、構造体、関数引数に含まれると、型幅の変化は ABI 破壊 につながり、time32 と time64 のバイナリを混在させるとランタイムの誤動作やセキュリティリスクが生じる- ソースベースのディストリビューションである Gentoo では、
@worldの再ビルド中に失敗や循環依存が起きるため、半分だけ移行されたシステム が残る可能性がある - 2024-09-30 の訂正以降、libdir の切り替えだけでは不十分となり、動的ローダーや複数言語のツールチェーンまで含めた time64 マーカー が中核的な制約として残っている
2038年問題と time64 移行の範囲
- 32ビット
time_tを使う 32ビットアプリケーションは、2038 年に現在時刻の代わりに-1エラーを受け取ったり、ファイルに対してstat()を実行できなくなったりする可能性がある - 移行の基本方針は
time_tを 64ビット型 に変えること- musl はすでに移行済み
- glibc はこれをオプションとしてサポートしている
- Debian など一部のディストリビューションはすでに移行している
- Gentoo のような ソースベースのディストリビューション では、ユーザーがシステムを自分で再ビルドするため、パッケージ同士が異なる ABI 状態にとどまる時間を減らす必要がある
- 中核的なリスクは、
time_tの幅の変更が ABI を壊す点にある- ライブラリ API に
time_tが含まれていれば、そのライブラリにリンクされるすべてのコードが同じ型幅を使わなければならない - 一部だけを time64 に切り替える方式は安全ではない
- ライブラリ API に
LFS と 3つの 32ビット下位 ABI
- 32ビットアーキテクチャでは、以前からファイル関連の型幅に問題があった
off_tはファイルオフセットに使われるino_tは inode 番号に使われる- もともと 32ビット幅だったため、2GiB を超えるファイルや 32ビット範囲を超える inode 番号が問題になっていた
- これを解決するため Large File Support(LFS) が導入された
off_tとino_tを 64ビット版に変更する- glibc では現在でも選択項目である
- 多くのパッケージが upstream 側で LFS を有効にし、ABI 破壊に対応してきたが、問題が完全に解決したわけではない
- glibc の time64 サポートは LFS の使用を要求 するため、ファイルサイズと時間の問題を同時に解決する構造になっている
- 32ビットシステムには 3 つの下位 ABI が存在する
- 既存 ABI: 32ビット型
- LFS: 64ビット
off_t、64ビットino_t、32ビットtime_t - time64: LFS + 64ビット
time_t
- 1つの glibc ビルドはこの 3 つのバリエーションと互換性を持つが、これらの型を API で使うライブラリでは 3 つのバリエーションを相互に混在させることはできない
ABI 変更が実際に壊れる仕組み
time_tが 32ビットから 64ビットに変わると構造体レイアウトが変わる- 例の構造体で
int a,time_t b,int cがこの順にある場合、cのオフセットは 32ビットtime_tと 64ビットtime_tで異なる - time32 と time64 のバイナリを混在させると、誤ったフィールドを読み書きしたり、範囲外アクセスが起きたりする可能性がある
- 例の構造体で
struct statのサイズも ABI によって異なる- 32ビット x86 glibc のデフォルト: 88 バイト
- LFS: 96 バイト
- LFS + time64: 108 バイト
- 構造体を使わなくても関数引数で問題が起きる
- x86 では関数引数がスタック経由で渡される
- 引数の 1 つが
time_tだと、それに続く引数のスタック位置が変わる
- 例の実験では、time32 プログラムが time64 で再ビルドされたライブラリにリンクされると値が壊れる
- 本来の出力は
a = 1、正常な時刻値、c = 3 - ライブラリだけを
-D_FILE_OFFSET_BITS=64 -D_TIME_BITS=64で再ビルドすると、bとcが誤って解釈される
- 本来の出力は
- 現在はこのような ABI 混在を防ぐ実効的な保護手段がなく、ランタイム破損 とセキュリティ問題が生じ得る
Gentoo での移行がより難しい理由
- バイナリ配布のディストリビューションでは、すべてのパッケージを再ビルドした後、ユーザーは比較的アトミックな段階でアップグレードする
- サードパーティのリポジトリやローカルビルドのプログラムがあると問題が起きることはあるが、全体の流れとしては比較的安全である
- Gentoo では
@worldを再ビルドしながら、その場で ABI を変更しなければならない- 2つのパッケージがそれぞれ再ビルドされる間に、相互に互換性のない ABI が混在する可能性がある
- 一部の再ビルド失敗によってシステムが 半分移行された状態 のまま残る可能性がある
- 循環依存のため依存パッケージを再ビルドするとビルドツールが壊れ、その後の再ビルドが不可能になることもある
検討中の緩和策
- 議論されている方向性は 3 つある
- 新しい ABI を既存の 32ビット ABI と区別するため、プラットフォームタプルである
CHOSTを変更する - 新しい ABI の libdir を変更し、再ビルドしたライブラリを既存ライブラリとは別にインストールする
- 異なる下位 ABI のバイナリがリンクされないように、バイナリレベルの ABI 識別 を導入する
- 新しい ABI を既存の 32ビット ABI と区別するため、プラットフォームタプルである
- 3 つの方法はある程度独立して実装できるが、一部は相互依存する可能性がある
- 記事中の例示文字列は、最終的な解決策で使われる実際の文字列とは限らない
CHOST で ABI を区別する
- プラットフォームタプルはツールチェーンが対象とするプラットフォームを識別するもので、Gentoo では multilib サポートのため ABI を一意に区別する用途にも使われる
- タプルはアーキテクチャ、vendor、オペレーティングシステム、libc の 4 つの部分から構成される
- 例:
i386-pc-linux-gnu - 例:
i686-pc-linux-gnu - 例:
i686-unknown-linux-gnu
- 例:
- 新しい ABI を導入する際には、vendor フィールドを変えるか、libc フィールドに ABI 表記を付け加える方式が使われてきた
- ARM hardfloat ABI では、過去に
armv7a-hardfloat-linux-gnueabiやarmv7a-unknown-linux-gnueabihfのような形式が使われていた
- ARM hardfloat ABI では、過去に
- time64 ABI にも似たような選択肢がある
i686-gentoo_t64-linux-gnui686-pc-linux-gnut64armv7a-gentoo_t64-linux-gnueabihfarmv7a-unknown-linux-gnueabihft64
- タプル変更には大量のパッチは不要だと思われる
- GNU ツールチェーンと GNU build system は、libc フィールド内で
gnuの後ろにある内容を無視する - Clang では、タプルに応じて正しい ABI を自動選択できるようにするパッチが必要になる
- GNU ツールチェーンと GNU build system は、libc フィールド内で
libdir の変更と preserved-libs
- libdir はライブラリのインストールディレクトリのデフォルト名
- 一般的なデフォルト値は
lib - 64ビット対応アーキテクチャでは慣例的に
lib64がよく使われる - x86 の x32 ABI は
libx32、MIPS n32 ABI はlib32を使う
- 一般的なデフォルト値は
- time64 用 32ビット ABI では、libdir を
libからlibt64のような値に変える案が検討されている - 別個の libdir は移行中の ABI 混在を減らす仕組みになる
- time64 実行ファイルが誤って time32 ライブラリにリンクされるリスクを減らす
- Portage の preserved-libs 機能により time32 ライブラリを保持できる
- 必要に応じて time32 + time64 の multilib プロファイルを提供し、既存の time32 事前ビルドアプリケーションとの互換性を維持できる
- preserved-libs があれば、既存の実行ファイルは再ビルドされるまで time32 ライブラリを使い続け、time64 で再ビルドされたライブラリは新しい libdir にインストールされる
- libdir の変更にはツールチェーンへのパッチが必要になる
- glibc は同じライブラリ群が複数の下位 ABI に有効であるため、特別扱いできる可能性がある
- time64 実行ファイルの
.interpが time64 用ld.soを参照するように、別個のld.soが必要になる可能性がある
- proper multilib サポートのためには、その ABI に固有のプラットフォームタプルも必要となる
バイナリレベルの非互換性の表示
- 異なる ABI のバイナリを混在させた場合、通常はリンカや動的ローダーがそれを防ぐべきである
- 64ビットプログラムを 32ビットライブラリにリンクすると、リンカは
file in wrong formatとして拒否する - 動的ローダーも
wrong ELF class: ELFCLASS32のようなエラーで拒否する
- 64ビットプログラムを 32ビットライブラリにリンクすると、リンカは
- 既存の ABI 識別には複数のメカニズムが使われている
ELFCLASS32とELFCLASS64EM_386とEM_X86_64のような machine identifier- ARM と MIPS の flags フィールド
- アーキテクチャ固有の attribute section
- time32 と time64 にも同様のメカニズムが必要だが、簡単ではない
- 再利用可能な汎用メカニズムが見当たらない
- 複数アーキテクチャに対応する解決策が必要である
- 新しい ELF note section を追加し、ツールチェーン側のサポートを実装する方法が現実的な候補に見える
- 保護手段をユーザーが無効化する可能性も考慮しなければならない
- ソースのない事前ビルドソフトウェアが
time_tを使う API を呼び出さないなら、システムライブラリと引き続き動作できる可能性がある - これを無条件にブロックする処方は、問題そのものより悪い場合がある
- ソースのない事前ビルドソフトウェアが
- 別個の libdir を使えば、比較的簡単な 非致命的 QA チェック を作れる
.interpにより time64 実行ファイルを識別する- time32 プログラムが
libt64からライブラリを読み込んでいないか確認する - time64 プログラムが
libから直接ライブラリを読み込んでいないか確認する
事前ビルド 32ビットアプリケーションの限界
- ソースからビルドされるパッケージとは別に、x86 と PowerPC には古い事前ビルドバイナリしか提供されないアプリケーションがある
- 特にプロプライエタリソフトウェアや古いゲームが該当する
- これらはシステムライブラリとの互換性問題と 2038年問題そのもの の両方を抱えている
- 互換性問題については既存の multilib 構造がある程度の解決策を提供する
- amd64 で 32ビットソフトウェアをサポートするため、すでに multilib レイアウトや複数ライブラリバージョンをビルドする仕組みがある
abi_x86_32とabi_x86_t64を区別する形に拡張できる- 両方の ABI をサポートする新しい multilib x86 プロファイルを作成できる
- 2038 年以降の 32ビットプログラム自体の失敗は、より難しい問題として残る
- faketime でシステム時刻を制御する方法がある
- 時刻を過去に戻した VM を実行する方法もある
2024-09-30 訂正: libdir だけでは不十分
- 当初の構想は楽観的すぎ、libdir の変更だけでは安定した分離は難しい
- すべての libdir が
ld.so.confに列挙されるため、ld.so内に libdir パスをハードコードする方式には依存できない- カスタム LLVM prefix でもすでにパスを調整しており、この場合にも特別な扱いが必要になる
- このため、libdir の変更は バイナリ非互換性の識別 に依存する可能性が高くなった
- 満たすべき基本目標は 3 つある
- 動的ローダーが time32 と time64 のバイナリを区別できること
- 明示的な time64 マーカーのないすべてのバイナリは、後方互換性のため time32 とみなされること
- 新しくビルドされるすべてのバイナリは明示的な time64 マーカーを持つこと。Rust のような非 C 環境でビルドされたバイナリも含む
- この目標は、複数言語にまたがる複数のツールチェーンへパッチを当てる必要がある規模の作業である
- Gentoo がローカルだけで維持するのは難しく、複数の当事者による協力が必要になる
- 対象アーキテクチャは概してレガシーと見なされるか、すでに十分なサポートがなくなっている場合がある
- 他のツールチェーンが正しい time64 実行ファイルを生成できるかも別の問題である
- C プログラムのように
_TIME_BITSに従うよう調整されていなければ、特定のtime_t幅をハードコードして壊れる可能性がある
- C プログラムのように
- 明示的な time64 マーカーのないすべてのバイナリが time32 ライブラリを使うことになるため、Gentoo は適切なマーカーを付けるようパッチされていないサードパーティ実行ファイルを実行できなくなる
- 目標を下げる代替案も検討されている
- すべての time64 実行ファイルに RPATH を注入し、time64 libdir を直接強制する
- この方法は動的ローダーが time32 ライブラリを使うことを完全には防げないが、大きな互換性問題なしに移行を助けられる
- 逆に、time64 libdir を恒久的には変えず、time32 libdir を一時的に変える案もある
- 既存プログラムに RPATH を注入し、libdir 名を変更する
- 新しい time64 ライブラリは既存の libdir にインストールする
- 新しい time64 プログラムは、time32 ライブラリを強制する RPATH を持たない
- すでに移行済みの他ディストリビューションとの互換性を維持できる利点がある
残された課題
- 3 つの解決策をすべて実装すれば、glibc を使う 32ビット Gentoo システムに対して、よりクリーンで比較的安全な移行経路を提供できる
- ただし、これらの解決策は主にソースからビルドされるパッケージに適用される
- 事前ビルドの 32ビットアプリケーションには、ABI 互換性が維持されても 2038年問題が残る
- 全体設計はまだ草案段階であり、実験、議論、パッチ投稿に応じて今後も変わり得る
1件のコメント
Hacker News のコメント
Gentoo には記事で取り上げられていない選択肢がいくつかあり、Gentoo のシステム設計上、作業量が大きいため省かれたように思える
.soバージョン管理を拡張し、依存パッケージの ABI 変化まで反映すること。通常、共有ライブラリはlibfoo.so.1.0.0のようにファイル名と内部バージョンにバージョン番号が入り、パッケージが自身の ABI 破壊を追跡する。64ビットtime_tをサポートするには、各.soの依存 ABI が制御するバージョン要素を追加する必要がある。記事の「別の libdir を使う」と結果は似ているが、将来の ABI 変更にも再利用できる基盤になり得る一方で、はるかに侵襲的である可能性が高い複数の新しいパッケージのビルドを予約してサンドボックス内でビルドし、新しいコンパイルは union を通じてまずサンドボックスを参照し、システムへフォールバックするようにできる。すべてビルドできたら成果物をパッケージングし、サンドボックスから実システムへ移せばよい。こうすれば Gentoo のアップデート全体をトランザクションのようにでき、他の面でも大きな利点が生まれる
/に設定されるROOTを変更すればよい@systemと@world全体を再ビルドして指定したサブディレクトリにインストールし、その後まとめて同期できる。可能ならライブセッションで行うのがよく、理論上は新しくインストールした場所のサブディレクトリに/をバインドマウントしてから chroot に入り、実際の親/へ同期する方法も可能であるhttps://devmanual.gentoo.org/ebuild-writing/variables/#root
Mac OS X が
off_tとino_tを扱った方法がヒントになるかもしれない。既存の呼び出しと構造体は動作を維持し、64接尾辞が付いた新しい呼び出しと型を追加し、プリプロセッサマクロで実際の参照先を選べるようにしていたが、直接使うことはまれだった代わりに OS と SDK がバージョン管理され、ビルド時にバイナリが実行される必要のある最も古い OS バージョンを指定できる。ヘッダはこれに基づいて適切なマクロを自動選択し、新 API/廃止 API の注釈も同じ仕組みで weak link や警告を生成していた。当初はプリプロセッサで処理していたが、今ではコンパイラが Apple の言う API availability をより精密に理解しているため、他のプラットフォームでも同じ方式が可能に見える
OS v.B 上で実行中でも、OS v.B 向けとして宣言されたアプリケーション X は、OS v.A 向けとして宣言されたアプリケーション Y とリンクできない可能性がある。実際、この方式はほとんどすべてのプラットフォームがすでに行っていることに近く、違うやり方をすれば既存バイナリ互換性が即座に壊れる
off_tのサイズごとに関数を複数組定義することはないだろうし、クライアントプログラムが望む型サイズに応じて正しい関数群をヘッダ内で透過的に選ぶスイッチもないだろうそれでも記事は、
off_tよりtime_tのほうが大きな問題だと強調している。もっともらしい理由は、time_tのほうがはるかに広く行き渡っているからである。off_tは比較的少数のインターフェースに関与する POSIX 型である一方、time_tは ISO C に属し、あちこちで使われている。さらに多くの C コードはtime_tがintと同じ幅の整数型だと仮定しているが、off_tについてそのような仮定をするケースはそれほど一般的ではないDebian も非常に苦痛だった。何人かは燃え尽きた可能性があり、多くの人がソースベースのディストリビューションを指して「あちらはとても簡単だろう」と言っていた
/usrマージよりはずっと良く見えたこうした問題で苦労しているのを見るたびに、FreeBSD の初期 amd64 ポート時にこの問題を推し進めておいて本当によかったと感じる。ABI の基本型を決めることができ、過去より未来を見ると決断したのだ。
amd64 には、この作業を容易にした興味深い特徴があった。関数呼び出し中に 32 ビットの関数引数が 64 ビットへ自動キャストされるため、64 ビットの
time_tを期待する関数に 32 ビットの時刻整数を渡しても、プラットフォーム初期の作業中はたいていそのまま動作した。そのため、細かな仕上げは後回しにできた。当時ほかの 64 ビットプラットフォームもあったが、64 ビットの
time_tはなく、FreeBSD/amd64 が 2003〜2005 年ごろ、その系統では最初だった。記憶では sparc64 も 64 ビットのtime_tに移行した。最大の問題は、当時 tzcode が 64 ビット安全ではなかったことだ。
struct tmの正規化アルゴリズムが、time_t(2^62)の日/月/年を反復計算しようとするような縮退条件に陥っていた。tzcode を大きく変更するより、おおむね 1900 年以前や 10000 年以降は失敗させるように処理したと記憶している。今ではずっと以前に upstream で修正されている可能性が高い。数年にわたり、サードパーティコードがファイルやネットワーク上のデータ構造で
int/long/time_tを雑に扱ったことによる 32/64 ビット時刻の混同を、モグラたたきのように処理したが、概して大きな問題ではなかった。初日から 64 ビットのtime_tを使ったことが大半の問題を避けさせてくれたし、最初からやるのは簡単だった。Linux は amd64/x86_64 ポートを始める際に、同じことをする大きな機会を逃した。付け加えると、当時 64 ビットの
ino_tは完了できなかった。32 ビットの inode 番号が、ファイルシステムのオンディスク構造、UFS ディレクトリ構造など、非常に多くの場所に露出していた。FreeBSD/amd64 が低い等級のプラットフォームだった時点で、ほかの tier-1 アーキテクチャを大きく揺るがさずに最初から対処する現実的な方法はなかった。作業は二度行ったが、最終的には別の人が仕上げ、mountpoint のパス長のような短すぎた定数もあわせて修正した。time_t、off_t、ino_tを使っていた。今の問題は、32 ビット Linux を 64 ビットtime_tに移行することだ。off_tもより大胆に扱い、2.0 から 64 ビットになっていた。Linux の 32 ビット版には、まだ古いサイズの痕跡が残っている。32 ビットの関数引数が呼び出し中に 64 ビットへ自動キャストされるという部分は、符号なし引数でのみ動作するものと理解している。
%ediにロードすると%rdiの上位部分がクリアされるためだ。x86-64 用の SysV ABI 仕様は、レジスタやスタック上のすべての値を 64 ビット全体の値へ拡張するとは述べておらず、ブール値に関する注記も下位 1 バイトだけが意味を持つという趣旨なので、これが一般規則であることを示唆している。time_tも 64 ビットへ移植したという意味なら、かなり驚きだ。Motorola 68000 や sparc32 のようなほかの 32 ビットアーキテクチャも 64 ビットのtime_tに移行したのか気になる。古い大型の 32 ビット Unix システムで将来の日付を扱うために、符号付き 32 ビット
time_tの libc 関数群を、符号なし 32 ビットtime_t対応関数に置き換えたことがある。これで 2038 年以降さらに 68 年を稼げたし、そのころには私はもういないだろう。欠点は Unix epoch である 1970 年以前の日付を表現できないことだが、スケジュール管理システムなので問題にはならなかった。過去の日付が重要なら、epoch を数十年ずらしたり、時間分解能を 1 秒から 2 秒に下げたりすることもできる。それぞれ微妙な問題があるので、ユースケース次第だ。
元の BSD マニュアルページでは、
tunefsの “Bugs” セクションに “You can tune a file system, but you can't tune a fish.” という有名なジョークがあったが、“Expert C Programming” によれば、このマニュアルページのソースコードでは、そのジョークの横に次のようなコメントがあったという。「これを削除すると、UNIX デーモンが今から
time_tが wrap around するまで、四つ足であなたを追い回すだろう。」70 年代にこの文が書かれたとき、2038 年は想像もできないほど遠い未来だったに違いない。
https://progforperf.github.io/Expert_C_Programming.pdf
最も強く感じたのは、努力は尊重するが、ユーザーの立場としては Debian のような ソースベースではないディストリビューションに移行して、この問題を終わらせたいということだ。
/と/usrパーティションにmkfs.ext4か使用しているファイルシステムを実行してからマウントし、stage3 を展開して chroot に入り、emerge $all-my-packages-that-where-installed-before-mkfsを実行すればよい。段階的にアップグレードする代わりに、新しい Gentoo のコピーをインストールできる。
サードパーティのクローズドソースソフトウェアがある場合、バイナリシステムでも依然として問題が起きる可能性がある。独立した段階で別途インストールされるファーストパーティパッケージでも問題が起こり得る。
C の専門家ではありませんが、
off_tのような型エイリアスは後から変更できるように導入されたものだと思っていました。ところが明確には機能していないように見えるので、自分の理解が間違っているのか気になりますoff_tのような typedef を使えば、通常コードを書き直す必要はありませんが、その型を使っているものはすべて再コンパイルする必要がありますoff_tの定義を変えた後で@worldをアトミックに再ビルドできるなら問題ありませんが、ソースベースのディストリビューションは@worldをアトミックに再ビルドせず、パッケージを一つずつ再ビルドしますすると
libc.soは 64 ビットのoff_tを使っているのに、gccは 32 ビットのoff_tを前提にビルドされていてgccが止まる、といった問題が起こり得ます。bash、coreutils、make、binutilsのように@worldの再ビルドに必要なパッケージが壊れることもあり、その時点で行き詰まります。だからこの種のアップグレードには注意が必要ですoff_tが構造体に入ったり、関数呼び出しで使われたり、プロトコルに組み込まれたりした瞬間に抽象化は消え、実際のサイズが重要になりますライブラリをロードしたりプロトコルで通信したりしながら古いコードと新しいコードを混在させると、オフセットがずれてクラッシュし始めます。結局、移行では全員がプログラムを「レガシー」と「移植済み、または少なくとも検証済み」に分けなければならず、非常に苦痛です
意味が似ているより大きな型に変えても壊れることがあります。単純な例として構造体のパディングがありますし、ポインタを整数に変換してから戻す使い方も多く、内部表現が変われば壊れざるを得ません。それが良い慣行かどうかは別として、珍しいことではありません。要点は ABI 互換性 です
off_tでビルドされたライブラリと 64 ビットのoff_tでビルドされたライブラリをリンクできないようにする仕組みはなく、結果の挙動は非常に予測不能になり得ます例の構造体では、32 ビットの
time_tのときcのオフセットが 8、64 ビット型のとき 12 だとされていますが、実際には 16 であるべきではないか と思います。bは 64 ビット境界にアラインされる必要があるので、aとbの間にパディングが入るはずです。むしろ著者の主張をさらに強める部分ですこうしたことをすべて見ると、Windows の奇妙な時刻表現、つまり 1601 年 1 月 1 日 00:00 GMT をグレゴリオ暦で見た時点から 100 ns 単位の 64 ビットで数える方式にも、小さな利点はあります。解像度も素晴らしく、銀河全体が征服される時代まで動作するでしょう