何もしていないのに CI が壊れた

ある日、Dependabot が作成したライブラリアップデートの PR がコケていました。
コケていたのは VRT (Visual Regression Test) 、そしてその時私は別のプロジェクトを触っていました。

「まあライブラリのアップデートで見た目が崩れる事もあるよね、見た目となるとサクッと修正出来ない可能性が高いし一旦置いておくか。」

としばらく放置していました。

数か月後
CI がコケていたプロジェクトを触る機会がやってきました。

「そういえば、CI コケていたんだったなー、原因はなんだろな・・・ん？？見た目が変わったことでコケているわけではないぞ、、、しかもローカルだと通るんだけど！？？」

何もしていないのに GitHub Actions の CI が壊れた！

---

この記事で扱っている CI がコケた理由は私が扱っている環境等に依存するものでおそらくあまり遭遇しない物だと思います・・・つまり完全に誰得記事となっております。
なのでこの記事では問題の調査方法に焦点を当てて書いてみようと思います。
原因と解決方法だけ知りたい人（そんな人いない気がするけど）は次のセクションのみを読めば OK です。

# 原因と解決策

GitHub Actions でテストを回していました。
そのテスト内で API へのリクエストを mock するために **Node.js の http モジュールを用いてサーバーを立てている**箇所があり、そこで立てているサーバーが GitHub Actions の runner のとあるバージョンから **IPv6 で立つようになった**のが原因でした。

Node.js でサーバーを立てている部分はライブラリのコード内でこちらから制御できないため（やろうと思えば方法はなくはないが・・・）、Docker 起動時にオプションを渡して IPv6 を無効にする (`--sysctl net.ipv6.conf.all.disable_ipv6=1`) ことで対処しました。

```shell
$ docker run --rm --sysctl net.ipv6.conf.all.disable_ipv6=1 -t -v `pwd`:/work vrt bash -c 'npx playwright test'
```

# 原因調査としてやったこと

ここからは原因を探り当てるまでにやったことを紹介したいと思います。

折角ならとライブラリが提供している experimental な機能を使っているプロジェクトだったこともあり、原因の調査に難航しました。

結果としてあまり詳しくない領域に踏み込むことになったので稚拙な部分もあるかもしれませんが、調査方法などデバッグの際にも役に立つと思いますので誰かの参考になれば幸いです。

## 現象が発生した環境

説明にあたり環境に依存する部分が出てくるのでざっくりと紹介します。

今回のプロジェクトは Web フロントエンドのプロジェクトでフレームワークとして [Next.js](https://nextjs.org/) を使用しているものです。本プロジェクトでは [Playwright](https://playwright.dev/) で VRT を行っており、 CI で落ちるようになったのはこの VRT です。

さらにその VRT では Next.js の Experimental test mode for Playwright を使用しており、API リクエストを mock するために [MSW](https://mswjs.io/) を使用しています。
構成は以前書いた Qiita の記事とほぼ同じなので詳しくは下記リンクの記事をご覧ください。

https://qiita.com/Shilaca/items/076ce2fb2bcd8e1d9eb0

また Playwright を動かすにあたり動作環境ごとに差が出ないようにするために Docker を使用しています。

今回の現象が発生した各ライブラリのバージョンは以下です。
```json:package.jsonの抜粋
  "dependencies": {
    "next": "14.1.1"
  },
  "devDependencies": {
    "@playwright/test": "1.42.1",
    "msw": "1.3.0"
  }
```

:::note info
Next.js の canary バージョンで試したら今回の現象は発生しませんでした。
そのため v15 がリリースされたらこちらの現象は解決するかもしれません。
:::

## 調査開始！

それでは当時の調査の流れを見ていきます。

まずテストのログを見ると、テスト自体が立ち上がらない状態ではなく、一部のテストケースのみ失敗している状態でした。更に詳しく見ると、失敗しているテストケースは MSW による mock を使用しているモノのみで他のテストケースは成功していました。

また Dependabot が作成している PR が複数ある状態で、よく見てみるとある日を境に失敗するようになったことがわかりました。

**状況**

- 失敗しているのは VRT を行っている CI のみ
  - その中でも失敗しているのは MSW を用いたテストケースのみ
- ある日(6/12 ごろ)を境に失敗するようになった

### 調査その 1: CI 環境とローカル環境でライブラリ等のバージョンを出来るだけ合わせる

まず初めに考えたのはライブラリのマイナーバージョンアップ等が原因の可能性です。
マイナーバージョンの変更は破壊的な変更を含まないというていではありますが、意外と壊れる印象があります。

また関連ライブラリのリポジトリへリリース情報を見に行くとちょうど Next.js のアップデートがリリースされていました。

https://github.com/vercel/next.js/releases/tag/v14.2.4

とりあえず関連ライブラリである Next.js, Playwright, MSW の package.json でのバージョン指定を固定値に変更して push 経過を見守ります。

結果は変わらず、CI 上でのみテストがコケる状態でした。

### 調査その 2: GitHub Actions の runner を変えてみる

ライブラリのバージョンが問題ではないのであれば OS に問題があるのではないかと次は runner 周りを見てみることにしました。

とりあえず過去の CI のログをあさりに行きます。

![成功していた時の runner の情報](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3584802/cf005f51-0897-02c0-225d-476102bb8d98.jpeg)

![失敗し始めた時の runner の情報](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3584802/51a0b133-57f1-22b9-e2cd-92bc16508235.jpeg)

成功していた時と失敗していた時の runner の情報を見比べると、どちらも Ubuntu 22.04.4 を使っていることは変わらないのですが、 Image の Version が違う事がわかりました。

- 成功していた時: `20240603.1.0`
- 失敗し始めた時: `20240609.1.0`

ざっと調べた限りではこの Image の Version を指定する方法はなさそうでしたので、とりあえず Ubuntu の 20 と 24 で試してみましたが、状況は変わらずでした。

更に、ローカルマシンは Mac でしたので macos runner も試してみましたが、結果は変わらずでした。

余談：macos runner には docker がインストールされていないので、そのあたりから見る必要があり、また何とか docker をインストールできたと思ったらかなり低速でとても使用できるものではありませんでした。

### 調査その 3: ライブラリのコードにログを仕込む

調査その 2 の時点でだいぶ、万策尽きたなという諦めの気持ちが強くなっていたのですが、そういえばまだライブラリのコードにログを仕込んでいないと思い、それに手を伸ばしました。

ローカルであればそのまま node_modules のコードを直接書き換えて console.log 等を仕込むことができます。ですが今回調査しなければならないのは CI 環境です。 node_modules は自動でインストールされるので直接書き換えるのは至難の業です。
そこで [patch-package](https://www.npmjs.com/package/patch-package) というライブラリを使用します。これを使用することでライブラリのインストール後に任意の変更を加える処理を行うことができます。詳しくは patch-package の README をご覧ください。

patch-package を用いてライブラリの怪しい部分にログを仕込みまくり、ローカル環境との差分を調査します。

そして、ついに、、見つけました！

Next.js の Experimental test mode のコードで Node.js の http モジュールを使用してサーバーを立てている部分で明確な差分を見つけることができました。

ローカルでは IPv4 でサーバーが立ち上がっているのに対し、CI では IPv6 でサーバーが立ち上がっていました。

```typescript
await new Promise((resolve) => {
  server.listen(0, "localhost", () => {
    resolve(undefined);
  });
});
```

> https://github.com/vercel/next.js/blob/v14.1.1/packages/next/src/experimental/testmode/proxy/server.ts#L61

server.listen で `'localhost'` を指定すると IPv4, IPv6 のどちらで立ち上がるか分かわかりません。ログを仕込んだところローカル環境では IPv4 で、CI 環境では IPv6 で立ち上がることがわかりました。

```shell:仕込んだログのCI環境での出力結果
create proxy server, address:  { address: '::1', family: 'IPv6', port: 42941 }
```

試しにローカル環境でこの server.listen に渡している `'localhost'` 部分を IPv6 のローカルホスト相当のアドレス `'::1'` を指定すると CI で起こっていた現象を再現することができました。
逆に `'127.0.0.1'` を指定すると CI 上でもテストが通ることを確認できました。

これで原因はこの IP アドレスににあると特定することができました。

またそれと同時に以下のような issue が上がっている事にも気づきました。
https://github.com/actions/runner-images/issues/10088

この issue で挙げられている ubuntu runner の image バージョンがちょうど CI がコケるようになったときの image バージョンと一致したのです。より IP アドレスが原因であると裏付けることができました。

### 今回選んだ解決策

ライブラリの問題の部分を patch-package を用いて書き換えることで対応は可能です（実際確認済み）が、その方法は採用しませんでした。何故なら対象の箇所は Experimental な機能で今後破壊的変更がされる可能性が大いにあるからです。

そこで何とかいい塩梅に対応できる手段はないかなと調べていると sysctl を用いて Docker コンテナの IPv6 を無効にすることができると知りました。

https://stackoverflow.com/questions/30750271/disable-ip-v6-in-docker-container

今回はこの方法を採用することにしました。

# おわりに

Experimental な機能を使うときから、こういうことが起こるんじゃないかと覚悟をしていました。正直、ある程度調べた段階で「あーこれはもう私の手には負えないかも、分からん・・・」と諦めモードになっていましたが、ちゃんと順を追って調べると意外となんとかなるものです。ちょっと詰まったときは少し時間を置くとふと天啓（？）が降りてくるもので、、まあ冷静に詰めて諦めないことが大事ですね。

なんだかんだ今回の現象はあまり詳しくない分野に踏み込む切っ掛けにもなって勉強になったし結果オーライです。