bundler 2.2.x でのエラーを乗り越えたい

2020年12月に、 Rails アプリの Docker イメージをビルドする CI が突然エラーを起こしたことがありました。これは bundler 2.2.0 がリリースされたことに起因していたため、イメージに入れる bundler を一旦 2.1.4 に固定することで対処しました。

そのままずっと問題に蓋をしていたものの、 bundler は既に 2.2.33 や 2.3.15 が出ているため、きちんと当時のエラーについて調べてバージョンアップしようと思い立ちました。

エラーが何だったのか、 bundler の [changelog](https://github.com/rubygems/rubygems/blob/v3.2.33/bundler/CHANGELOG.md) などを見てもすぐにはわからなかったので、**実際に様々なバージョンで実験して探りました**。（ x86-64 の Linux および macOS を対象としています）

## TL;DR
bundler 2.2 からは、 Gemfile.lock に Ruby のプラットフォームの情報（ `x86_64-linux` や `x86_64-darwin-18` など）を記載して活用しようとします。特に初期の頃はこれがうまく働かず、 gem のバイナリ配布版を選ばずソース版からコンパイルしようとしてしまうことがありました。

対策として以下を実施すれば、多くの場合にうまく動くはずです。

- bundler 2.2.3 以上を使う[^bundled]
- Gemfile.lock に適切なプラットフォームを書き込む
	- 開発環境だけでなくデプロイ環境の分も明示的に追加が必要
	- bundler 2.2.0 までにある `ruby` は不要なら抜いておく

```bash
gem install bundler -v '>= 2.2.3' --conservative

gem env platform | tr ':' '\n'  # 現在のプラットフォームを表示
bundle lock --add-platform x86_64-linux  # 必要なだけ追加
bundle lock --remove-platform ruby
```

[^bundled]: Ruby 3.0.0 に付属しているのが bundler 2.2.3 のため、デフォルトのものを使っているなら改めてインストールしなくても大丈夫です

## 実験
### 環境準備
実験は Ubuntu 18.04 上で、 Ruby 2.7.4 を用いて行いました。ただし後で、開発環境が macOS の場合を模倣します。

プラットフォーム毎にパッケージが用意されている gem でエラーが発生しうるので、今回は [libv8](https://rubygems.org/gems/libv8) だけで試します。

```ruby:Gemfile
source "https://rubygems.org"
gem "libv8"
```

また、様々なバージョンの bundler で試したいので、まとめてインストールしておきます。（結果的には 2.2.3 まであれば十分でした）

```bash
# bundler 2.1.4, 2.2.0〜33, 2.3.0〜15 をインストール
gem install --no-document bundler:{2.1.4,2.2.{0..33},2.3.{0..15}}

# 複数入れてある場合、バージョンを指定して実行できる
bundle _2.3.7_ -v
```

### (1) Gemfile.lock が無い場合
新しく Rails アプリを作るときなどは、 Gemfile.lock はまだ無く Gemfile から生成することになります。この結果が bundler バージョンによって異なるか確認しておきます。

```bash
set -eu
versions=(2.1.4 2.2.{0..33} 2.3.{0..15})
gf0=Gemfile  # 準備で作ったファイル

rm -rf .bundle/
bundle config set --local path 'vendor/bundle'

for v in ${versions[@]}
do
    echo "#--- bundler ${v} ---#"
    gf=${gf0}-${v}
    cp ${gf0} ${gf}
    bundle _${v}_ install --gemfile=${gf} || true
    echo
done
```

生成された Gemfile-\${v}.lock は以下のようになりました。この3パターンを以降の実験で使います。

| version | PLATFORMS |
|:--|:--|
| 2.1.4 | `ruby` のみ |
| 2.2.0 | `ruby` と `x86_64-linux` |
| 2.2.1 以上 | `x86_64-linux` のみ |

```text:Gemfile-2.2.0.lock
GEM
  remote: https://rubygems.org/
  specs:
    libv8 (8.4.255.0)
    libv8 (8.4.255.0-x86_64-linux)

PLATFORMS
  ruby
  x86_64-linux

DEPENDENCIES
  libv8

BUNDLED WITH
   2.2.0
```

### (2) Gemfile.lock がある場合
新しい開発環境に gem をインストールするような場合です。少しややこしい場合を想定し、「 Gemfile.lock は macOS 上で生成した」「新しい環境は Linux 」という状況を再現して実験します（ Gemfile.lock 内のプラットフォーム情報を無理やり書き換えておきます）。

```bash
set -eu
versions=(2.1.4 2.2.{0..33} 2.3.{0..15})
v0=2.1.4
gf0=Gemfile-${v0}  # 実験(1)で作ったファイル

rm -rf .bundle/
bundle config set --local path 'vendor/bundle'

for v in ${versions[@]}
do
    echo "#--- bundler ${v0} --> ${v} ---#"
    gf=${gf0}-${v}
    cp ${gf0}      ${gf}
    cp ${gf0}.lock ${gf}.lock
    sed -i -e 's/x86_64-linux/x86_64-darwin-18/g' ${gf}.lock
    bundle _${v}_ install --gemfile=${gf} || true
    echo
done
```

#### 初回が 2.1.4
2.1.4 で作った Gemfile.lock の PLATFORMS には `ruby` しか書かれていません。そのため開発環境を変えた影響はありません。

2.2.0 〜 2.2.2 では、 PLATFORMS に現在の環境 `x86_64-linux` が追加されました。 2.2.3 以上では何も変更されませんでした。

```text:Gemfile-2.1.4-2.2.2.lock
GEM
  remote: https://rubygems.org/
  specs:
    libv8 (8.4.255.0)
    libv8 (8.4.255.0-x86_64-linux)

PLATFORMS
  ruby
  x86_64-linux

DEPENDENCIES
  libv8

BUNDLED WITH
   2.2.2
```

#### 初回が 2.2.0
初期状態では PLATFORMS に `ruby` と `x86_64-darwin-18` が書かれています。

2.2.0 〜 2.2.2 では、 PLATFORMS に現在の環境 `x86_64-linux` が追加されました。 2.2.3 以上では **gem をコンパイルしようとしました**（手元ではコンパイル失敗しました）。

#### 初回が 2.2.1
初期状態では PLATFORMS に `x86_64-darwin-18` のみが書かれています。

どのバージョンでも PLATFORMS に情報が追加されました。バージョン毎の追加内容は実験(1)と同じでした。

```text:Gemfile-2.2.1-2.2.0.lock
GEM
  remote: https://rubygems.org/
  specs:
    libv8 (8.4.255.0)
    libv8 (8.4.255.0-x86_64-darwin-18)
    libv8 (8.4.255.0-x86_64-linux)

PLATFORMS
  ruby
  x86_64-darwin-18
  x86_64-linux

DEPENDENCIES
  libv8

BUNDLED WITH
   2.2.1
```

### (3) deploymentを指定した場合
デプロイ環境で gem をインストールする際は、開発時に想定しているものがそのまま使われるべきです。そのために Gemfile.lock を変更しないよう `deployment` または `frozen` を指定します。変更しないという制約により、実験(2)とは異なった結果になります。

```bash
set -eu
versions=(2.1.4 2.2.{0..33} 2.3.{0..15})
v0=2.1.4
gf0=Gemfile-${v0}  # 実験(1)で作ったファイル

rm -rf .bundle/
bundle config set --local deployment 'true'  # ここを追加
bundle config set --local path 'vendor/bundle'

for v in ${versions[@]}
do
    echo "#--- bundler ${v0} --> ${v} (deployment) ---#"
    gf=${gf0}-${v}-deployment
    cp ${gf0}      ${gf}
    cp ${gf0}.lock ${gf}.lock
    sed -i -e 's/x86_64-linux/x86_64-darwin-18/g' ${gf}.lock
    bundle _${v}_ install --gemfile=${gf} || true
    echo
done
```

#### 初回が 2.1.4
PLATFORMS に `ruby` のみが書かれています。

2.2.0 では **gem をコンパイルしようとしました**（手元ではコンパイル失敗しました）。他では問題なくインストールされました。

#### 初回が 2.2.0
PLATFORMS に `ruby` と `x86_64-darwin-18` が書かれています。

2.2.0 *以上*では **gem をコンパイルしようとしました**（手元ではコンパイル失敗しました）。したがって 2.1.4 のみで問題なくインストールされました。

#### 初回が 2.2.1
PLATFORMS に `x86_64-darwin-18` のみが書かれています。

2.1.4 では普通に `x86_64-linux` がインストールされましたが、 2.2.0 〜 2.2.2 では `x86_64-darwin-18` が**誤インストール**されました。 2.2.3 以上では**プラットフォーム不一致のエラーが出ました**。

```
#--- bundler 2.2.1 --> 2.2.0 (deployment) ---#
Warning: the running version of Bundler (2.2.0) is older than the version that created the lockfile (2.2.1). We suggest you to upgrade to the version that created the lockfile by running `gem install bundler:2.2.1`.
Fetching gem metadata from https://rubygems.org/.
Using bundler 2.2.0
Fetching libv8 8.4.255.0 (x86_64-darwin-18)
Installing libv8 8.4.255.0 (x86_64-darwin-18)
Bundle complete! 1 Gemfile dependency, 2 gems now installed.
Bundled gems are installed into `./vendor/bundle`

#--- bundler 2.2.1 --> 2.2.3 (deployment) ---#
Your bundle only supports platforms ["x86_64-darwin-18"] but your local platform is x86_64-linux.
Add the current platform to the lockfile with `bundle lock --add-platform x86_64-linux` and try again.
```

## 動作の背景
実験結果をもとに、 bundler の [changelog](https://github.com/rubygems/rubygems/blob/v3.2.33/bundler/CHANGELOG.md) から動作変更の内容を探しました。

### 2.2.0
2.2 系から、 Gemfile.lock にプラットフォームの情報を持つようになりました。

しかし、 2.1.4 までに作られた Gemfile.lock は `ruby` しか書いていません。これにより 2.2.0 は「厳密に `ruby` のパッケージを選ぶ」という動作をし、ソース版をダウンロードしてコンパイルしてしまいます。

### 2.2.1
2.2.0 で判明した問題への暫定対応として、「 2.2 以前に作られた Gemfile.lock は 2.2 以前の方法で解釈する」ようになりました。これにより deployment (frozen) であっても昔の Gemfile.lock は問題なく使えます。
https://github.com/rubygems/rubygems/pull/4127

一方で、 2.2 以降に作られた Gemfile.lock に対しては記載のプラットフォームを守ろうとします。 2.2.0 が書き込んだ `ruby` だけが一致すればそれを使おうとするため、やはりソース版をダウンロードしてコンパイルしてしまいます。

さらに 2.2.1 から PLATFORMS に `ruby` を書かなくなったため、一致するプラットフォームが無い場合が出てきます。このときに誤ったバージョンをインストールしてしまう問題が報告されました。
https://github.com/rubygems/rubygems/issues/4166

### 2.2.3
開発環境とデプロイ環境とでプラットフォームが異なる場合の問題が修正されました。一致するプラットフォームが無いときは明確にエラーを出します。
https://github.com/rubygems/rubygems/pull/4172

また、 2.2 以前の Gemfile.lock に対する互換性が上がったようです。（本記事では実験(1)の「初回が 2.1.4 」において、不要な更新が発生しなくなっています）

## 対処法
bundler 2.2.x に由来するエラーは、だいたい **Gemfile.lock 生成時と参照時の環境の違い**によって起きます。最も単純な対処法は両者で bundler バージョンもプラットフォームも一致させることです。

とはいえ開発環境が macOS 、デプロイ環境が Linux といったことは普通にあります。このような場合は、「 Gemfile.lock にプラットフォームの情報を持つようになった」ということを理解して対処法を考えます。

- 使用する bundler は、動作の安定した 2.2.3 以降が良いです[^bundled]
- 開発環境のプラットフォームを追加します： `bundle lock --add-platform <name>`
	- 新しく Gemfile.lock を生成する場合と同じものを入れておくのが良いと思います
	- 特に 2.2.0 まであった `ruby` は、デプロイ環境でソース版を選ぶ原因になりうるので、必要ない限りは抜いておきます
- デプロイ環境のプラットフォームも追加します
	- 2.2.3 以降なら、足りない場合はエラーの中で必要なコマンドを教えてくれます