24
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

uvのキャッシュがアグレッシブすぎる

Posted at

はじめに

uv は速い。速さの一つが、キャッシュをアグレッシブに使っていることにあります。そのアグレッシブさゆえに、ユーザーがuvのキャッシュ機構を把握していないと、無関係なプロジェクトを知らず知らず破壊することがあります。

キャッシュを通じたプロジェクト破壊

uvのキャッシュは、デフォルトでプロジェクトを跨いで共有されます。特にLinuxやWindowsの場合 (macOSについては後述)、プロジェクトで uv add foo を実行してインストールしたパッケージ foo のファイルは、キャッシュファイルのハードリンクとなります。2回目以降の uv add が高速なのは、ハードリンクを作っているだけだからです。

それぞれ全く関係の無いプロジェクトAとプロジェクトBで、偶然同じパッケージ foo を利用している場合、プロジェクトA、B、さらにキャッシュを含めて全て同一実体のファイルを共有します。図にすると以下の通りです。

プロジェクトA配下で、実験的にパッケージfooのファイルを直接いじってデバッグしたとしましょう。その改造は、プロジェクトBにも波及します。この状態で、プロジェクトBでビルド、デプロイすると、A側で行ったデバッグ改造がB側に入り込んでデプロイされてしまいます。つまりA側の作業が、全く関係の無いB側のパッケージを破壊してしまったことなります。

実際にやってみる

実際にuvキャッシュを通じて外部からプロジェクトが破壊されるケースを見てみます。

例として、それぞれ無関係なプロジェクトAとプロジェクトBを作成します。A、Bともに外部パッケージとして、大人気の pycowsay を利用しているとしましょう。pycowsay とは、入力テキストを、アスキーアートの牛 (cow) が吹き出し付きで出力してくれるプログラムです。古くからある cowsay の簡易的なPython実装です。

以下では、本稿執筆時点で最新の uv 0.9.18 を使用します。実行環境として Dockerコンテナ ghcr.io/astral-sh/uv:python3.14-trixie を Ubuntu 24.04 on WSL 上で用います。※uvのキャッシュ機構に焦点を当てるため、Python自体のバイトコードキャッシュ (pycache) は無効化しています。

プロジェクトAの作成
(A) $ mkdir proj-a && cd $_
(A) $ uv init
(A) $ uv add pycowsay
プロジェクトBの作成
(B) $ mkdir proj-b && cd $_
(B) $ uv init
(B) $ uv add pycowsay

続けて、各プロジェクトで動作確認してみます。まずはAから。

Aで動作確認
(A) $ grep name pyproject.toml
name = "proj-a"
(A) $ uv run pycowsay hello A
/root/proj-a/.venv/lib/python3.14/site-packages/pycowsay/main.py:27: SyntaxWarning: "\ " is an invalid escape sequence. Such sequences will not work in the future. Did you mean "\\ "? A raw string is also an option.
  \   ^__^

  -------
< hello A >
  -------
   \   ^__^
    \  (oo)\_______
       (__)\       )\/\
           ||----w |
           ||     ||

何やら不穏なSyntaxWarningのメッセージが出ましたが、牛さんはちゃんと出力されました。

次にB。

Bで動作確認
(B) $ grep name pyproject.toml
name = "proj-b"
(B) $ uv run pycowsay hello B
/root/proj-b/.venv/lib/python3.14/site-packages/pycowsay/main.py:27: SyntaxWarning: "\ " is an invalid escape sequence. Such sequences will not work in the future. Did you mean "\\ "? A raw string is also an option.
  \   ^__^

  -------
< hello B >
  -------
   \   ^__^
    \  (oo)\_______
       (__)\       )\/\
           ||----w |
           ||     ||

こちらも同じ結果です。

さて、SyntaxWarningが気になります。メッセージを読む限りでpycowsay/main.pyの27行目あたりにシンタックス不正がありそうです。バグでしょうか? 牛さんのパワーを存分に発揮するためにもデバッグしてみましょう。

プロジェクトA
(A) $ cat -n .venv/lib/python3.14/site-packages/pycowsay/main.py | sed -n 22,35p
    22      output = dedent(
    23          """
    24        %s
    25      < %s >
    26        %s
    27         \   ^__^
    28          \  (oo)\_______
    29             (__)\       )\/\\
    30                 ||----w |
    31                 ||     ||
    32      """
    33          % (topbar, phrase, bottombar)
    34      )
    35      print(output)

わかりました! 27行目の 牛さんの耳の部分 \ ^__^ のバックスラッシュが不正なエスケープとみなされています (Python3.12以降、エスケープシーケンスとして不正なバックスラッシュに対してSyntaxWarningが出力されます)。警告メッセージのとおりですね (それ以外にも不正な箇所はありますが、最初のものが表示されています)。正しくは、メッセージの通り \\ ^__^とするか、raw文字列を使用する必要があります。ここでは簡易的にraw文字列を使って修正してみます。

修正パッチ
@@ -20,13 +20,13 @@
     topbar = "-" * len(phrase)
     bottombar = "-" * len(phrase)
     output = dedent(
-        """
+        r"""
       %s
     < %s >
       %s
        \   ^__^
         \  (oo)\_______
-           (__)\       )\/\\
+           (__)\       )\/\
                ||----w |
        
(A) $ uv run pycowsay hello A

  -------
< hello A >
  -------
   \   ^__^
    \  (oo)\_______
       (__)\       )\/\
           ||----w |
           ||     ||

やりました! 警告が消え、牛さんもスッキリです。

ですが、実はまだ他にも問題があります。見落としてしまいがちですが、この牛さんの乳頭は、 w と2つしかありません。一般に牛の乳頭は4つです。ww に修正しましょう! (乳頭の数の問題はオリジナルの cowsay に由来するものであり pycowsay自体の問題ではないことを補足しておきます。)

cowの写真
(写真: Keith Weller, U.S. Department of Agriculture)

(A) $ grep name pyproject.toml
name = "proj-a"
(A) $ vi  ※ wwに修正 (割愛します)
(A) $ uv run pycowsay hello A

  -------
< hello A >
  -------
   \   ^__^
    \  (oo)\_______
       (__)\       )\/\
           ||---ww |
           ||     ||

できました。この乳頭4つのwwバージョンを、通常のpycowsay と区別するために pycowwsay と呼ぶことにします。自分だけの呼び名を与えると特別感も増して良いですね。愛着もひとしおです。

さて、pycowwsay の修正に夢中になり、自分はプロジェクトBの開発メンバーでもあることを忘れていました。プロジェクトBに戻って、あらためて元のpycowsayの挙動を見てみましょう。B側の pycowsay は何も触っていないので、元のままのはずです。

プロジェクトB
(B) $ grep name pyproject.toml
name = "proj-b"
(B) $ uv run pycowsay hello B

  -------
< hello B >
  -------
   \   ^__^
    \  (oo)\_______
       (__)\       )\/\
           ||---ww |
           ||     ||

おお pycowwsay ! なぜお前がプロジェクトBに?? プロジェクトAは個人用として何をしてもいいが、チーム開発のプロジェクトBでパッケージを勝手に改造してしまったらまずい。百歩譲って SyntaxWarningが解消したのはよしとしても、危うく pycowwsay を同梱してリリースするところであった。

何が起きたか

以上のシナリオは、全く触っていない外部のプロジェクトを、知らずに破壊した事故の例です。

先述の通り、Linuxではuv管理のパッケージは、デフォルトでプロジェクト共通のキャッシュファイルへのハードリンクとなります。各プロジェクトおよびキャッシュすべて同一実体のファイルとなるので、一つのプロジェクトで行った変更は、別のプロジェクトへ波及してしまいます。プロジェクトの外部からパッケージ破壊を被る形となります。

ハードリンクで同一のファイル実体を参照しているのであれば、各ファイルは同一inode番号を持つはずです。確認してみましょう。

プロジェクトA, B配下のinode番号
$ ls -i -1 proj-[ab]/.venv/lib/python3.14/site-packages/pycowsay/main.py
255073 proj-a/.venv/lib/python3.14/site-packages/pycowsay/main.py
255073 proj-b/.venv/lib/python3.14/site-packages/pycowsay/main.py
uvキャッシュファイルのinode番号
$ find $(uv cache dir) -name main.py -exec ls -i {} \; | grep pycowsay
255073 /root/.cache/uv/archive-v0/wZekgNKFECmJdoReBM1mY/pycowsay/main.py

uvキャッシュディレクトリは uv cache dir コマンドで得られます。デフォルトで ~/.cache/uv となります。

上記の通り、プロジェクトA、Bおよびuvキャッシュのpycowsayソースファイルは、全て同一のinode番号 (255073) となっていることが確認できます。プロジェクトA配下の pycowsay/main.py に変更を加えた場合、それはプロジェクトBおよびキャッシュファイルに変更を加えることと同義になります。

ここで重要な点としては、キャッシュも同じinodeなので、キャッシュ自体もすでに破壊されているということです。冒頭で述べた通り、uvはキャッシュを積極的に利用します。この状態で、新規のプロジェクトで同一パッケージをインストールすると、初期状態で破壊されたパッケージがインストールされてしまいます。

新規にプロジェクトCを作って試してみましょう。

(C) $ mkdir proj-c && cd $_
(C) $ uv init
(C) $ uv add pycowsay
(C) $ uv run pycowsay hello C

  -------
< hello C >
  -------
   \   ^__^
    \  (oo)\_______
       (__)\       )\/\
           ||---ww |
           ||     ||

(C) $ ls -i .venv/lib/python3.14/site-packages/pycowsay/main.py
255073 .venv/lib/python3.14/site-packages/pycowsay/main.py

pycowwsay! やはり壊れています。

uvのキャッシュ利用について把握していないと、おそろしい結果につながることが分かると思います。

破壊されたパッケージの復旧手順

では、uv管理のパッケージをうっかり直接触ってしまい、しかも元の状態に戻せなくなった場合、どう復旧すればよいでしょうか。もちろん、プロジェクトでパッケージを削除して、再インストールしてもうまく行きません。根元のキャッシュから破壊されているので、壊れたまま再インストールされるだけです。

以下、uvでインストールした外部パッケージをうっかり直接触ってしまった場合の復旧手順を説明します。

  1. まずは、壊れたパッケージを利用しているプロジェクトを特定する

    既にプロジェクトが特定できていることも多いでしょうが、漏れなく列挙したい場合はざっと確認しましょう。方法はいろいろありますが、以下は一例です。

    $ find ~ -name pyproject.toml -exec grep -H '\<pycowsay\>' {} \;
    /root/proj-a/pyproject.toml:    "pycowsay>=0.0.0.2",
    /root/proj-b/pyproject.toml:    "pycowsay>=0.0.0.2",
    

    あるいは、上記で見たように破壊したファイルのinode番号が分かっている場合は、findコマンドの-inumオプションでinode番号をキーにファイルを探索することもできます。

    $ find ~ -inum 255073
    ./proj-a/.venv/lib/python3.14/site-packages/pycowsay/main.py
    ./proj-b/.venv/lib/python3.14/site-packages/pycowsay/main.py
    ./.cache/uv/archive-v0/wZekgNKFECmJdoReBM1mY/pycowsay/main.py
    

    proj-a および proj-bpycowsay を利用していることがわかりました。

  2. 壊れたキャッシュを削除する

    uv cache clean ${パッケージ名} で、キャッシュディレクトリから当該パッケージを削除できます。

    uvキャッシュを削除
    $ uv cache clean pycowsay
    Removed 14 files (8.3KiB)
    $ find $(uv cache dir) -name '*pycowsay*'
    $
    

    この時点ではキャッシュ側のハードリンクを削除しただけなので、プロジェクト側のファイルはまだ残っています。

    $ ls -i -1 proj-[ab]/.venv/lib/python3.14/site-packages/pycowsay/main.py
    255073 proj-a/.venv/lib/python3.14/site-packages/pycowsay/main.py
    255073 proj-b/.venv/lib/python3.14/site-packages/pycowsay/main.py
    

    パッケージ名を指定せずに uv cache clean とだけ実行した場合、キャッシュディレクトリ丸ごと削除され、無関係なパッケージも含めて全キャッシュが消えます。キャッシュファイルがunlinkされるだけで、ファイル実体は利用プロジェクト側のハードリンクとして残るので、プロジェクトへの直接的な影響はありませんが、誤って実行すると混乱の原因となるのでご注意ください。

  3. パッケージを再インストールする

    プロジェクト配下で、uv sync --reinstall でパッケージを再インストールできます。破壊されていないキャッシュがダウンロードされ、プロジェクトのパッケージは、きれいなキャッシュと同一ハードリンクで上書きされます。再インストールは各プロジェクトで実施する必要があります。

    以下は、ひとまずプロジェクトAのみ再インストールして復旧した例です。

    プロジェクトAで復旧
    (A) $ cd proj-a/
    (A) $ uv sync --reinstall
    Resolved 2 packages in 26ms
    Prepared 1 package in 143ms
    Uninstalled 1 package in 5ms
    Installed 1 package in 9ms
    ~ pycowsay==0.0.0.2
    
    (A) $ uv run pycowsay hello A
    /root/proj-a/.venv/lib/python3.14/site-packages/pycowsay/main.py:27: SyntaxWarning: "\ " is an invalid escape sequence. Such sequences will not work in the future. Did you mean "\\ "? A raw string is also an option.
      \   ^__^
    
      -------
    < hello A >
      -------
       \   ^__^
        \  (oo)\_______
           (__)\       )\/\
               ||----w |
               ||     ||
    

    無事復旧できました。

    inode番号も確認してみましょう。

    (A) $ ls -i -1 ../proj-[ab]/.venv/lib/python3.14/site-packages/pycowsay/main.py
    63858 ../proj-a/.venv/lib/python3.14/site-packages/pycowsay/main.py
    255073 ../proj-b/.venv/lib/python3.14/site-packages/pycowsay/main.py
    
    キャッシュのinode番号
    $ find $(uv cache dir) -name main.py -exec ls -i {} \; | grep pycowsay
    63858 /root/.cache/uv/archive-v0/WiBow8UWDjkheBctqFZia/pycowsay/main.py
    

    キャッシュ削除後にプロジェクトAでパッケージを再インストールしたので、キャッシュとAのinode番号が新しい63858に変わっています。プロジェクトB側はまだ復旧していないので古いinode番号のままです。プロジェクトBでも同様に再インストールすればハードリンクが上書きされ、復旧できます。

    再インストールの前に、必ず手順2のキャッシュ削除を行ってください。オプション名から誤解しやすいですが、uv sync --reinstall は、キャッシュが残っていればキャッシュからパッケージをインストールするので、キャッシュ削除をしていない場合、パッケージは壊れたままプロジェクト配下に再インストールされます。

そもそもキャッシュ破壊のリスクを無くしたい

方法1 キャッシュを無効化する

キャッシュ自体を無効化すれば、別プロジェクトとパッケージファイルを共有することがなくなるので、外部からパッケージを破壊されるリスクを排除できます。uv設定ファイルで no-cache = true を設定するか、uvコマンド実行時に --no-cache オプションを渡すことでキャッシュを使用せずにuvを運用できます。ただしキャッシュが効かなくなる分、速度は低下します。安全性と性能のトレードオフとお考えください。

uv.toml
no-cache = true

既にキャッシュを使用しているプロジェクトで、あとからキャッシュを無効化したい場合は、no-cacheを設定したあとパッケージを再インストールしてください。このケースでは再インストールでキャッシュを参照しないので、上記の復旧手順2のようなキャッシュ削除は不要です。

プロジェクトBでキャッシュを無効化
(B) $ grep name pyproject.toml
name = "proj-b"
(B) $ echo 'no-cache = true' >> uv.toml
(B) $ uv sync --reinstall
Resolved 2 packages in 26ms
Prepared 1 package in 109ms
Uninstalled 1 package in 4ms
Installed 1 package in 7ms
 ~ pycowsay==0.0.0.2

これで完了です。

プロジェクト配下のinode番号
$ ls -i -1 ../proj-[ab]/.venv/lib/python3.14/site-packages/pycowsay/main.py
 63858 ../proj-a/.venv/lib/python3.14/site-packages/pycowsay/main.py
149247 ../proj-b/.venv/lib/python3.14/site-packages/pycowsay/main.py
キャッシュのinode番号
$ find $(uv cache dir) -name main.py -exec ls -i {} \; | grep pycowsay
63858 /root/.cache/uv/archive-v0/WiBow8UWDjkheBctqFZia/pycowsay/main.py

inode番号はキャッシュ (63858) とも異なる、独立した値 (149247) となっていることが確認できます。

方法2 CoW (Copy-on-Write) を利用する

Btrfs 等、CoW (Copy-on-Write) をサポートするファイルシステムを利用している場合、uvキャッシュにCoWを適用することができます。プロジェクトからパッケージファイルを参照するだけであればキャッシュと同一データを参照し、パッケージに変更を加えようとするとデータがプロジェクト配下にコピーされ、変更は当該コピーに対して行われるようになります。パッケージファイルへの変更は、プロジェクト専用コピーに対してのみ行われるので、プロジェクト外へ影響しなくなります。読み取りだけなら効率的にキャッシュを使用し、変更時にコピーが作成され外部プロジェクトの破壊リスクを抑えられます。性能と安全性を両立できる選択肢となります。

キャッシュにCoWを適用するには、uv設定ファイルに link-mode = "clone" を指定します。

uv.toml
link-mode = "clone"

新規にBtrfs環境で動作確認してみます。

Btrfs
$ mount | grep btrfs
/dev/sda3 on / type btrfs (rw,relatime,seclabel,compress=zstd:1,space_cache=v2,subvolid=257,subvol=/root)
/dev/sda3 on /home type btrfs (rw,relatime,seclabel,compress=zstd:1,space_cache=v2,subvolid=256,subvol=/home)

(A) $ mkdir proj-a && cd $_
(A) $ uv init
(A) $ echo 'link-mode = "clone"' >> uv.toml  ※cloneを有効化
(A) $ uv add pycowsay

(A) $ vi  ※pycowsayをpycowwsayへ改造 (割愛)
(A) $ uv run pycowsay hello CoW A

  -----------
< hello CoW A >
  -----------
   \   ^__^
    \  (oo)\_______
       (__)\       )\/\
           ||---ww |
           ||     ||

プロジェクトAでパッケージを改造したのち、プロジェクトBで pycowsay をインストールしてみます。

(B) $ mkdir proj-b && cd $_
(B) $ uv init
(B) $ echo 'link-mode = "clone"' >> uv.toml
(B) $ uv add pycowsay
(B) $ uv run pycowsay hello CoW B
/root/proj-b/.venv/lib64/python3.12/site-packages/pycowsay/main.py:27: SyntaxWarning: invalid escape sequence '\ '
  \   ^__^

  -----------
< hello CoW B >
  -----------
   \   ^__^
    \  (oo)\_______
       (__)\       )\/\
           ||----w |
           ||     ||

Bの pycowsay は無事オリジナルのままです。Aで行った破壊は、Bに波及していません。Aによる改造時にA専用のコピーが作成され、元のキャッシュには影響しないからです。

macOSは、標準ファイルシステムAPFSがCoWをサポートしているので、uvも link-mode はデフォルトで clone を使おうとします。macOSでは、LinuxやWindowsと異なり、プロジェクト破壊のシナリオで見たようなケースは、意図的に設定を変えない限りは発生しません。

なお、link-modeには他の値も設定できます。現時点で設定可能な値は以下の通りです。

説明
clone コピーオンライトを利用する (macOSデフォルト)
copy キャッシュからコピーする (他のモードが使用できない場合 copyに縮退する)
hardlink ハードリンクでキャッシュを参照する (Linux, Windowsデフォルト)
symlink シンボリックリンクでキャッシュを参照する

copy 以外のモードを設定し、かつそのモードが使用できない場合は、copy モードで動作します。たとえば、uvキャッシュディレクトリと、プロジェクトディレクトリが異なるファイルシステム上に存在する場合、hardlink を使用できません。この場合 link-modehardlink を設定していても copy で動作します。すなわちパッケージを追加すると、ハードリンクではなく、キャッシュからファイルがコピーされます。同様にCoWをサポートしないファイルシステムで clone を設定すると、copy モードに縮退して動作します。

link-modeの設定値 symlink の注意点について補足します。symlink を使うと、キャッシュがファイル実体となり、プロジェクト配下にはキャッシュへのシンボリックリンクが作成されます。ハードリンク (hardlink) と比べて、ファイルシステムを跨いでキャッシュを効かせられる利点があります。しかしながらキャッシュからパッケージをクリーン (uv cache clean ${パッケージ名}) してしまうと、ファイル実体が消滅するので、そのキャッシュをsymlinkしている全プロジェクトが壊れます。もちろんクリーンのあと忘れずに再インストールしてキャッシュを復活させればよいのですが、一時的にであれプロジェクトが壊れた状態になる点に留意する必要があります。symlinkの使用は十分ご注意ください。hardlink の場合は、キャッシュを削除してもファイル実体は残り、プロジェクトから参照できるので、この問題はありません。

まとめ

  -----------------
< uv管理パッケージは絶対触らないで >
  -----------------
   \   ^__^
    \  (oo)\_______
       (__)\       )\/\
           ||---ww |
           ||     ||

参考資料

24
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?