Goの並列処理で「たまに失敗するテスト」をゼロにするまでの試行錯誤

  • golang
    golang
2026/01/15に公開

この投稿はでも表示されます。

パフォーマンス向上への期待と、CI環境で直面した技術的障壁

Markdownでドキュメントを管理する上で、リンク切れのチェックは欠かせない工程です。

私は現在、Go製のMarkdownリンター gomarklint を開発しています。このツールはマークダウンの構造やスタイルを検証するものですが、その中でも重要な機能の一つが「外部リンクの有効性チェック」です。

180ファイル、10万行のMarkdownをどう捌くか

本ツールのターゲットは、大規模なプロジェクトのドキュメント群です。 例えば、180ファイル、合計10万行を超えるようなMarkdownファイルを扱うケースを想定しています。
(私が運営してるこの技術ブログのボリューム)
これをシングルスレッドで一つずつ逐次的にチェックしていては、実行時間が膨大になり、開発体験を著しく損ないます。

Go言語の最大の強みは、Goroutineによる強力な並列処理にあります。「適切な並列化を行えば、膨大な外部リンクも瞬時に検証できるはずだ」――そう考え、私は実装の最適化に取り掛かりました。

顕在化した「不安定なテスト」という課題

当初の実装は、リンクを抽出して順次 go 文を起動する、至ってシンプルなものでした。ローカル環境でのベンチマークでは期待通りの速度を叩き出し、一見成功したかのように思えました。

しかし、真の課題はGitHub ActionsなどのCI環境に移行した際に現れました。

実行するたびに結果が変わる 「Flaky Test(不安定なテスト)」 です。10回に9回は成功するものの、残りの1回で原因不明のエラーを吐いて落ちる。デバッグを試みても、ローカルでは再現しない――。

この不気味な不安定さの正体は何なのか。

この問題を通じて、私は並列HTTPリクエストにおける「速度」と「安定性」の両立には、単なる並列化以上の「作法」が必要であることを痛感しました。本記事では、gomarklint の開発で直面した3つの技術的障壁と、それをどう解決していったかの軌跡を辿ります。

ステップ①:同一URLへの「波状攻撃」を防ぐ

並列化の第一段階として、抽出したリンクに対して順次 Goroutine を割り当てる実装を行いました。しかし、ここで最初の問題に直面します。

同じURLに対する冗長なリクエスト

Markdown ドキュメント内では、同じURL(例えば、プロジェクトのトップページや共通のドキュメント、GitHubリポジトリへのリンクなど)が繰り返し登場することが多々あります。

単純な並列化では、そのURLが出現した回数分だけ、ほぼ同時にHTTPリクエストを投げてしまいます。これが数百ファイル規模になると、同一ホストに対して短時間に大量のリクエストを集中させることになります。

これはリソースの無駄であるばかりか、相手サーバー側からは 「DoS攻撃」や「不審なアクセス」 とみなされ、接続を拒絶されたり(429 Too Many Requests)、IP単位でレート制限をかけられたりする原因となります。

sync.Map によるURLキャッシュの実装

この問題を解決するために、ファイル間をまたいで利用できる URLキャッシュ を導入しました。一度チェックが完了したURLの結果を保存し、2回目以降は通信を行わずにその結果を再利用する仕組みです。

Goで並列処理を行う際、通常の map はスレッドセーフではないため、複数の Goroutine から同時に読み書きするとパニックを引き起こします。そこで、標準ライブラリの sync.Map を採用しました。

urlCache := &sync.Map{}

if cachedStatus, ok := urlCache.Load(url); ok {
    status = cachedStatus.(int)
} else {
    status, err = checkURL(client, url)
    urlCache.Store(url, status)
}

導入後の気づき

このキャッシュ導入により、ネットワークトラフィックは劇的に削減されました。特に同じドメインへのリンクが多いドキュメント群では、実行速度がさらに向上し、相手サーバーへの負荷も「マナー」の範囲内に収めることができました。

しかし、これだけで「たまに失敗するテスト」が消えることはありませんでした。

ステップ②:物理的な「リソースの限界」を制御する(セマフォ)

URLキャッシュの導入により、同一URLへの重複リクエストは排除できました。しかし、チェック対象となるURLの「種類」自体が膨大な場合、新たな問題が浮上します。

数千のGoroutineがもたらすネットワークの飽和

例えば、1000種類の異なるURLが含まれるドキュメントをチェックする場合、依然として1000個のGoroutineがほぼ同時にHTTPリクエストを試みます。

この「瞬間的な大量接続」は、以下のようなリスクを引き起こします。

  • ローカルリソースの枯渇: OSが一度に開けるファイルディスクリプタ(ソケット)の上限に達し、接続エラーが発生する。
  • ネットワークの不安定化: 短時間に大量のパケットが流れることで、一部のリクエストがタイムアウトする。
  • CI環境の制約: GitHub Actionsなどの共有環境では、ネットワーク帯域や同時接続数に制限があることが多く、ローカルよりエラーが出やすくなる。

これが、CI環境でだけ「たまに」テストが落ちる原因の一つでした。

チャネルによる「セマフォ」の実装

Goでは、バッファ付きチャネルを利用して、同時に実行できるGoroutineの数を制限する 「セマフォ(Semaphore)」 パターンを簡単に実装できます。

maxConcurrency := 10
sem := make(chan struct{}, maxConcurrency)

for url, lines := range urlToLines {
    wg.Add(1)
    
    sem <- struct{}{} 

    go func(u string, lns []int) {
        defer wg.Done()
        defer func() { <-sem }()

    }(url, lines)
}

導入後の気づき

セマフォを導入したことで、リクエストが「一斉」ではなく「順番に」流れるようになりました。一見すると処理速度が落ちるように思えますが、実際にはタイムアウトによるエラーが激減し、結果として全体の実行時間はより安定しました。

「物理的なリソースには限界がある」という前提に立ち、クライアント側で 流量制限(Throttling) を行うことの重要性を痛感しました。

しかし、ここまで対策を講じても、まだテストが赤くなることがありました。

ステップ③:一瞬の「気まぐれ」を許容する(リトライ)

キャッシュで無駄を削り、セマフォで流量を絞りました。これで理論上は安定するはずですが、ネットワークの世界にはどうしても避けられない 「一時的な失敗(Transient Failure)」 が存在します。

「100%」は存在しないネットワークの不確実性

相手方のサーバーが一瞬だけ過負荷になったり、ネットワーク経路でパケットが消失したり、CI環境の不安定なWi-Fi(あるいは仮想ネットワーク)が瞬断したり……。こうした「たまたまその瞬間だけダメだった」というケースは、どれだけこちら側を完璧に制御しても防ぎようがありません。

これが、CI環境で発生する Flaky Test の最後の正体でした。

「賢いリトライ」と指数バックオフ

単にエラーが出たらすぐにやり直すだけでは、相手サーバーがダウンしている場合に負荷を追い打ちするだけになってしまいます。そこで重要になるのが、指数バックオフ(Exponential Backoff) を取り入れたリトライ戦略です。

また、「何度やっても無駄なエラー(404 Not Found など)」と「やり直す価値があるエラー(5xx サーバーエラーや通信タイムアウトなど)」を区別することも欠かせません。

func checkURLWithRetry(client *http.Client, url string) (int, error) {
    const maxRetries = 2
    const retryDelay = 2 * time.Second

    var status int
    var err error

    for i := 0; i <= maxRetries; i++ {
        if i > 0 {
            time.Sleep(retryDelay * time.Duration(i))
        }

        status, err = performCheck(client, url)

        if err == nil && (status < 400 || status == 404 || status == 401) {
            return status, nil
        }
        
    }
    return status, err
}

導入後の気づき

この「粘り強さ」を実装に組み込んだことで、ついに CI でのテスト結果が安定し始めました。一度の失敗で全てを投げ出すのではなく、「少し時間を置いてから聞き直す」 という猶予を持たせることが、分散システム(Web)と対話する上での礼儀であることを学びました。

全ての改善を台無しにする「中途半端なキャッシュ」の罠

さて、ここで本記事のタイトルにある「たまに失敗するテスト」をゼロにするためのもう一つの改善があります。

実は、ステップ①〜③までを実装しても、まだ私のテストは時折赤くなっていました。原因を突き詰めた結果、「何をキャッシュしているか」 という盲点に行き着きました。

当初、私は sync.Map に HTTPステータスコード(int型)のみ を保存していました。

  • 初回アクセス: ネットワークエラー(タイムアウト等)が発生。ステータスコードは 0、エラーは timeout となる。
  • キャッシュ保存: 0 だけをマップに保存。
  • 2回目(別ファイル等からのアクセス): キャッシュから 0 を取得。しかし、キャッシュには「エラーだった」という情報が残っていないため、呼び出し側は「エラーはないが、ステータスは0(成功でも失敗でもない)」という矛盾した状態を受け取る。

この不整合が、リンクチェックの判定ロジックを狂わせ、テストを不安定にさせていたのです。

解決策:結果を構造体で丸ごとキャッシュする

成功も失敗も、「そのURLをチェックした結果」として等しく扱う必要があります。そのためには、ステータスコードとエラーオブジェクトをセットにした構造体をキャッシュに詰め込む必要がありました。

type checkResult struct {
    status int
    err    error
}

urlCache.Store(url, &checkResult{status: status, err: err})

この Negative Caching(失敗結果のキャッシュ) を正しく実装したことで、並列実行時の整合性が完全に保たれるようになりました。

まとめ:安定した並列処理への終わりなき道

「キャッシュ」「セマフォ」「リトライ」。これら三種の神器を揃え、さらに「失敗結果も含めたキャッシュ」という負のキャッシュ(Negative Caching)対策を講じたことで、私の外部リンクチェッカーは飛躍的に安定しました。

しかし、これでもまだ、私の環境では「Flaky Test」を100%ゼロにできたとは言い切れていません。

まだ、何かが足りない

どれだけ対策を積み重ねても、ネットワークが絡む並列処理の「正解」に辿り着くのは容易ではありません。例えば、以下のようなケースは依然として懸念事項として残っています。

  • Cache Stampede(キャッシュの隙間): 同一URLに対して、最初のキャッシュが書き込まれる「一瞬の隙」に、2つ目、3つ目のGoroutineが同時にリクエストを開始してしまう問題。
  • http.Client の微調整: MaxIdleConnsPerHost などの低レイヤーな設定値が、並列実行時にどう影響しているのか。
  • CI環境固有の揺らぎ: 仮想化されたネットワーク環境における、予測不能なパケットロスや遅延。

並列処理の世界は奥が深く、一つの壁を越えるたびに新しい壁が現れる。それがこの開発の難しさであり、面白さでもあります。

知見をお貸しください(読者の方へのお願い)

現在、これらの処理を盛り込んだコードは gomarklint として公開しています。

もし、この記事を読んで「ここが怪しいのではないか」「Goの http.Client ならこういう設定をすべきだ」「Cache Stampede対策なら singleflight を使うべきだ」といった知見をお持ちの方がいれば、ぜひ教えていただけないでしょうか。

  • GitHubでのIssue / PR: 具体的な改善案やバグ報告は大歓迎です。
  • コメント / SNS: 「自分はこうして解決した」という経験談も非常に励みになります。

「爆速」と「絶対的な安定」。その両立を目指す旅はまだ続きます。あなたの知見で、このリンターをより堅牢なツールに育てる手助けをしていただければ幸いです。

https://github.com/shinagawa-web/gomarklint

Xでシェア
Facebookでシェア
LinkedInでシェア

記事に関するお問い合わせ📝

記事の内容に関するご質問、ご意見などは、下記よりお気軽にお問い合わせください。
ご質問フォームへ