はじめに (対象読者・この記事でわかること)

この記事は、GoでWeb APIやHTTP経由で画像を取得して、そのままimage.Decodeしようとして「image: unknown format」と怒られてしまった方、あるいはこれから実装しようとしている方向けです。
記事を読み終えると、以下のことがわかります。

  • なぜダウンロードしたbytes.Bufferが1回目のデコードで空になってしまうのか
  • io.Readerを「複数回」使いたいときの正しいリセット/複製のテクニック
  • io.TeeReaderbytes.Bufferを使って、ログ用・画像処理用の2つの処理をシンプルに共存させる方法

コードレベルで実装を急ぎたい方でも、サンプルコードをコピペするだけで即解決できます。

前提知識

  • Goの基礎文法(エラー処理はif err != nilスタイル)
  • net/httpパッケージを使った簡単なGETリクエスト
  • image.Decodeの存在を知っていること(使ったことがなくても可)

事象の概要:ダウンロードした画像が"unknown format"になる理由

Goで画像を扱うとき、次のように書くのが定石です。

Go
resp, _ := http.Get(url) img, _, err := image.Decode(resp.Body)

これで問題なく動く場合も多いのですが、以下のように「取得した画像を一度別の処理に回してからデコードしよう」とすると、突然image: unknown formatエラーが出ます。

Go
// 例:ダウンロードした画像をロギングしてからデコード data, _ := io.ReadAll(resp.Body) log.Printf("size=%d", len(data)) // ← ここで読み切る img, _, err := image.Decode(bytes.NewReader(data)) // ← ここでエラー

resp.Bodybytes.NewReader(data)io.Readerです。
Readerは「読み進めるとポインタが進む」ため、1回目で全部読み切ると2回目以降はもう「空っぽ」になってしまいます。
image.Decodeは先頭のマジックナンバーを見てフォーマット判定するため、空のReaderでは当然unknown formatと判断します。

具体的な手順と回避策:Readerを複数回使う3パターン

ステップ1:io.ReadAllでスライスを確保してリセットする最簡単パターン

小さい画像であれば、最初にまるごとメモリに載せて以降はbytes.NewReaderを何度でも作り直すのが手軽です。

Go
resp, err := http.Get(url) if err != nil { log.Fatal(err) } defer resp.Body.Close() // 1. 一度に全部読む data, err := io.ReadAll(resp.Body) if err != nil { log.Fatal(err) } // 2. ログ出力 log.Printf("download size=%d", len(data)) // 3. デコード img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { log.Fatal(err) }

メモリに余裕がある場合はこれで十分。ただし数百MBの画像を扱うとdataの確保が辛くなります。

ステップ2:io.TeeReaderでストリームを分岐する大容量対応パターン

巨大な画像を扱う場合、メモリに載せたくないこともあります。
io.TeeReaderを使うと「読み流しながら」別のWriterにコピーできるため、ダウンロードしながらファイルに保存したり、ハッシュを計算したりできます。

Go
// 保存先ファイル file, err := os.Create("downloaded.jpg") if err != nil { log.Fatal(err) } defer file.Close() // ダウンロード → ファイル保存 → decode まで一気通貫 resp, err := http.Get(url) if err != nil { log.Fatal(err) } defer resp.Body.Close() // MultiWriterで resp.Body をファイルとハッシュに分岐 mw := io.MultiWriter(file, sha256.New()) tr := io.TeeReader(resp.Body, mw) // ここで読み進められるが、tee先のmwにも同時に流れる img, _, err := image.Decode(tr) if err != nil { log.Fatal(err) }

TeeReaderは内部で一時バッファを持たず、即座にWriter側に流すため、メモリ効率が良いのが特徴です。

ステップ3:io.Seekerを実装するネットワーク再読み込みパターン(応用)

http.Response.Bodyio.ReadCloserである一方で、ローカルファイルならos.FileSeekメソッドを持っているように、一部のReaderは「先頭に戻って再読み込み」できます。
サーバがRangeリクエストに対応している場合、同じURLに再アクセスすれば先頭から読めるという手もあります(コードは省略)。

ハマりどころ:「空のReader」を疑う癖をつける

unknown formatEOFが出たとき、真っ先に疑うべきは「Readerの中身が本当に残っているか?」です。
以下のデバッグ一行を入れるだけで、原因特定が劇的に早くなります。

Go
if runtime.GOOS == "debug" { io.Copy(io.Discard, reader) } // 残量チェック

解決策:Readerの寿命を意識する設計

  1. 「読み終わった=もう使えない」と思って設計する
  2. 複数回使いたいときは - 小さいデータ → []byteに落としてbytes.NewReaderで作り直す - 大きいデータ → io.TeeReaderで分岐、あるいはSeek可能なReaderを使う
  3. エラーメッセージが抽象的でも「Readerが空」という視点を忘れない

まとめ

本記事では、GoでHTTP経由で画像を取得してimage.Decodeしようとした際にunknown formatエラーが出る仕組みと、それを回避する3つの実装パターンを紹介しました。

  • io.Readerは読み進めると消費される(読み返し不可)
  • 複数回処理するなら「メモリに載せて作り直す」「TeeReaderで分岐する」「Seekerを使う」のいずれかを選ぶ
  • デバッグ時はio.Copy(io.Discard, reader)で残量をチェックすると早期解決しやすい

この知識は画像に限らず、CSVやJSONなどあらゆるストリーム処理で同様の落とし穴が潜んでいます。
次回は「encoding/csvで巨大CSVを1億行処理する時のメモリ対策」について掘り下げていきます。

参考資料