GitHub ActionsでGoのソースコードをクロスコンパイルするときに、ビルドが失敗する理由とその対策

Mackerelチームでアプリケーションエンジニアをやっているid:lufiabbです。

Mackerelでは、ホストのメトリックを送信するためのエージェントや各種プラグインなどをOSSとして公開しています。現在の公式サポートはヘルプにある対応環境の通りですが、サポート外ではあるもののFreeBSDや32bit Windowsなどにもプログラムを提供している場合があります。

こういったOSSの一部、特にGo言語で書かれたプログラムのCIをGitHub Actionsへ移行した際に、32bitバイナリの生成についていくつか調べたことがありますが、あまりインターネットで見かけない情報だったので、今回は一般的な情報として共有してみようと思います。

この記事では、ディスクの残り容量を調べるプログラムの64bit版と32bit版をLinux用とWindows用にビルドする方法を例に、以下の場合にそれぞれ何が必要になるのかを見ていきます。

具体的な方が伝わりやすいと思うのでGoのコードも書いていますが、この記事の主題はビルド方法なので、サンプルコードの内容は雰囲気で読んでいただいても大丈夫です。なお、説明で使ったコードは以下のリポジトリで公開しています。段落ごとにコミットしているので、興味があれば眺めてみてください。

lufia / cross-compile-example

ビルド制約を含む場合

ビルド制約(Build constraint)は、ファイル名に_linux_amd64と入れたり、ファイルの先頭に// +build linux,amd64と書いて、ビルド対象となるファイルを切り替えるものです。

// main.go

func main() {
    root := Fsroot()
    fmt.Println(root)
}

上記のようにファイルシステムのルートディレクトリを取得する場合、UnixやPlan 9では次のように単純に/を返せばいいでしょう。

// +build linux

// fs_linux.go

package main

func Fsroot() string {
    return "/"
}

Windowsの場合は、ドライブレターを含むのでビルド制約で切り替えます。ドライブレターは変えられるので本当は環境変数SYSTEMDRIVEなどを使った方がいいのですが、以下はサンプルなのでCドライブで固定です。

// +build windows

// fs_windows.go

package main

func Fsroot() string {
    return `C:\`
}

この場合、GitHub Actionsのワークフローは素朴にmatrixを使うだけで済みます。

jobs:
  test:
    strategy:
      matrix:
        GOOS: ['linux', 'windows']
        GOARCH: ['amd64', '386']
        include:
        - GOOS: windows
          X: .exe
    runs-on: ubuntu-20.04
    steps:
    - uses: actions/checkout@v2
    - uses: actions/cache@v1
      with:
        path: ~/go/pkg/mod
        key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
        restore-keys: |
          ${{ runner.os }}-go-
    - uses: actions/setup-go@v2
      with:
        go-version: 1.16.x
    - run: |
        mkdir -p dist
        go build -o dist/hdb-$GOOS-$GOARCH$X
      env:
        GOOS: ${{ matrix.GOOS }}
        X: ${{ matrix.X }}
        GOARCH: ${{ matrix.GOARCH }}
      shell: bash
    - uses: actions/upload-artifact@v2
      with:
        name: dist
        path: dist/hdb-*

このワークフローが正常に終われば、Actionsにアップロードされたzipファイルに4つの実行ファイルが入っていることが確認できます。

なお、Go 1.4以前は$GOROOT/src/make.{bash,bat,rc}でクロスコンパイルのターゲットを用意しておく必要がありましたが、現在は不要です。

システムコールを使う場合

次に、システムコールやWin32 APIを使う場合です。

例として、ファイルパスを与えると、そのファイルが含まれるディスクの空き容量を返す関数を作ります。使う側はこんな雰囲気です。

// main.go
func main() {
    log.SetFlags(0)
    size, err := DiskAvail(Fsroot())
    if err != nil {
        log.Fatalln(err)
    }
    fmt.Println("avail size(GB)", size/1024/1024/1024)
}

ディスク容量を調べるため、Linuxの場合はStatfsシステムコールを使います。同じものはsyscallパッケージにもありますが、これはGoのサポートに必要なものしか含まないので、一般的な用途ならgolang.org/x/sysを使いましょう。

// fs_linux.go
import "golang.org/x/sys/unix"

func DiskAvail(path string) (uint64, error) {
    var s unix.Statfs_t
    if err := unix.Statfs(path, &s); err != nil {
        return 0, err
    }
    return s.Bavail * uint64(s.Bsize), nil
}

Windowsの場合は、Win32 APIを使います。以下の例では、uintptrにキャストするためunsafeを使っていますが、この用途であれば安全です。

// fs_windows.go
import (
    "unsafe"

    "golang.org/x/sys/windows"
)

var (
    modkernel32 = windows.MustLoadDLL("kernel32.dll")

    GetDiskFreeSpaceEx = modkernel32.MustFindProc("GetDiskFreeSpaceExW")
)

func DiskAvail(path string) (uint64, error) {
    var size int64
    upath := windows.StringToUTF16Ptr(path)
    if r, _, err := GetDiskFreeSpaceEx.Call(uintptr(unsafe.Pointer(upath)), 0, 0, uintptr(unsafe.Pointer(&size))); r == 0 {
        return 0, err
    }
    return uint64(size), nil
}

この場合も、前述したビルド制約の場合と同じワークフローで全てのバイナリをビルドできます。

Windows版のコードではDLLをロードしているのでcgoが必要かと思いましたが、どうやらそうでもないようです。

cgoを使う場合

cgoを使うと、GoからCのライブラリをリンクできます。上記の例ではunix.Statfsシステムコールを使いましたが、Cライブラリのstatvfsを使いたい場合があるかもしれません1。もっと実用的なものでは、グラフィックスライブラリを使う場合でしょうか。

cgoでは、import "C"した上で、次のようにコメントの中にCのコードを書きます。

/*
#include <stdlib.h>
*/
import "C"

これは#includeするだけのCコードですが、これをfs_linux.gofs_windows.goに入れてビルドしてみましょう。

undefinedエラーでビルドに失敗する

この場合、前述したワークフローのままでは、Linuxの64bit版を除いて以下のエラーで失敗します。

./main.go:11:15: undefined: DiskAvail
./main.go:11:25: undefined: Fsroot

この動作は、import "C"を含む場合の暗黙的なビルド制約と、クロスコンパイル時の環境変数$CGO_ENABLEDによるものです。まず、import "C"を含むファイルにはcgoビルド制約がセットされるため、CGO_ENABLED1の場合にのみビルド対象として含まれます。

ところが、$CGO_ENABLEDはビルドするホスト($GOHOSTOS$GOHOSTARCH)とターゲット($GOOS$GOARCH)が同じならば1がデフォルトですが、クロスコンパイルする場合にはデフォルトが0になります。

つまり、cgoの記述を含むファイルは、クロスコンパイルする場合にデフォルトでビルド対象から外されます。

上記のワークフローはubuntu-20.04でビルドしているので、32bit版のLinuxバイナリをビルドする場合にfs_linux.goはビルド対象に含まれません。同じように、Windowsでは32bitと64bitのどちらも、fs_windows.goが含まれなくなります。このため、一緒に定義している関数も定義されなくなって、上記のエラーになります。

次のように、ワークフローでCGO_ENABLED1にセットしましょう。

    - run: |
        mkdir -p dist
        go build -o dist/hdb-$GOOS-$GOARCH$X
      env:
        GOOS: ${{ matrix.os.GOOS }}
        X: ${{ matrix.os.X }}
        GOARCH: ${{ matrix.GOARCH }}
        CGO_ENABLED: 1
      shell: bash

unrecognized command line optionのエラー

次は、Windows版をビルドする場合に下記のエラーが発生すると思います。

gcc: error: unrecognized command line option ‘-mthreads’; did you mean ‘-pthread’?

Windowsでcgoを使う場合、Cコンパイラに-mthreadsオプションを渡すようになっていますが、このオプションはMinGWのgccで用意されているもので、Linuxにはありません。

-mthreadsは、gccのmanページによると次の記述があり、他にもWindowsのマルチスレッドライブラリを参照するように切り替えたりなどするようです。

Support thread-safe exception handling on MinGW. Programs that rely on thread-safe exception handling must compile and link all code with the -mthreads option.

Linuxにgcc-mingw-w64などをインストールしてMinGWのバイナリを作ることもできますが、GitHub ActionsにはWindowsホストも用意されていますし、Windowsホストの場合はWiXでインストーラを作ったりなどもできます。

このためMackerelのOSSではワークフローのジョブを分けて、Windowsホストを使うようにしました。こちらはLinuxの場合のジョブです(主な変更だけ)。

jobs:
  build-linux:
    runs-on: ubuntu-20.04
    steps:
    - run: |
        mkdir -p dist
        go build -o dist/hdb-linux-$GOARCH
      env:
        GOARCH: ${{ matrix.GOARCH }}
        CGO_ENABLED: 1
      shell: bash

こちらがWindowsの場合です(主な変更だけ)。

jobs:
  build-windows:
    runs-on: windows-2019
    steps:
    - run: |
        mkdir -p dist
        go build -o dist/hdb-windows-$GOARCH.exe
      env:
        GOARCH: ${{ matrix.GOARCH }}
        CGO_ENABLED: 1
      shell: bash

これで、LinuxとWindowsのどちらでも、64bit版はビルドが通るようになります。しかし、32bitの場合は両OSともまだエラーになります。

Linuxの32bit版バイナリを生成する

Linuxの32bit版バイナリを生成するときのエラーは、以下のような内容です。

In file included from _cgo_export.c:3:
/usr/include/stdlib.h:25:10: fatal error: bits/libc-header-start.h: No such file or directory
   25 | #include <bits/libc-header-start.h>
      |          ^~~~~~~~~~~~~~~~~~~~~~~~~~
compilation terminated.

このエラーでは、文字通りファイルがないと言っています。Linuxにはmultilibといって、異なるアーキテクチャのバイナリをビルドまたは実行する仕組みがあります。Ubuntuの場合、bits/libc-header-start.hgcc-multilibに含まれているので、これをインストールすれば解決します。

- run: |
  sudo apt-get install gcc-multilib g++-multilib

以上で、cgoを使う場合でもLinux版は32bitと64bitに対応できるようになります。

Windowsの32bit版バイナリを生成する

Windowsの32bit版バイナリを生成するときのエラーは、以下のような内容です。

C:/ProgramData/Chocolatey/lib/mingw/tools/install/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/8.1.0/../../../../x86_64-w64-mingw32/bin/ld.exe: skipping incompatible C:/ProgramData/Chocolatey/lib/mingw/tools/install/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/8.1.0/../../../../x86_64-w64-mingw32/lib/libmingwthrd.a when searching for -lmingwthrd
...このようなエラーがいっぱい...
collect2.exe: error: ld returned 1 exit status

../が含まれていて長いので、要約すると次のようになります。

/mingw64/x86_64-w64-mingw32/bin/ld.exe: skipping incompatible /mingw64/x86_64-w64-mingw32/lib/libmingwthrd.a when searching for -lmingwthrd

mingwthrdを検索してlibmingwthrd.aが見つかったけど、互換性がないのでスキップすると言っていますね。これは32bit版のバイナリをビルドするときのCコンパイラを、32bitターゲット用のi686-w64-mingw32-gccに変更するとよいです。ちなみに64bitターゲットのgccは、x86_64-w64-mingw32-gccです。

cgoは、特定環境用の環境変数$CC_FOR${GOOS}${GOARCH}があればCソースコードをコンパイルするときに使いますし、$CC_FOR_TARGETがあれば$CCに優先して使います2。この場合は次のように指定しましょう。

- run: |
    mkdir -p dist
    go build -o dist/hdb-windows-$GOARCH.exe
  env:
    GOARCH: ${{ matrix.GOARCH }}
    CGO_ENABLED: 1
    CC_FOR_windows_386: i686-w64-mingw32-gcc
  shell: bash

基本的にはこれでよいはずですが、エラーメッセージはまだ変わりません。対応を次で見ていきます。

ところで、x86_64-w64-mingw32-gccというコマンド名には32や64という数字がいくつか入っていますが、これは何でしょうか。ターゲットアーキテクチャを表す文字は、先頭のx86_64またはi686の部分だけで、他はターゲットに関係ないようです。もともとmingw32という名前で、64bit Windowsに対応したフォークがmingw-w64なのでその辺りが由来かなと思いますが、他にもパス部分にmingw32やmingw64表記などもあったりしていて分かりづらいですね。

Mingw-w64 - GCC for Windows 64 & 32 bits [mingw-w64]

mingw-w64-i686-gccパッケージのインストール

どうやらGitHub ActionsのWindows環境には、i686-w64-mingw32-gccは入っていないようです。MinGWに含まれるpacmanを使えばパッケージの追加はできるみたいですが、MSYS2公式のmsys2/setup-msys2アクションを使った方が簡単そうだったので、これを使うことにします。

MSYS2

setup-msys2で追加したMinGWはテンポラリディレクト置かれていて、gccにPATHは通っていないので、シェルの指定をshell: msys2 {0}とする必要があります。

結果、ワークフローは以下のように変わります(変更のある部分だけを抜粋)。

strategy:
  matrix:
    GOARCH: ['amd64', '386']
    include:
    - GOARCH: amd64
      MSYSTEM: MINGW64
    - GOARCH: '386'
      MSYSTEM: MINGW32
- uses: msys2/setup-msys2@v2
  with:
    msystem: ${{ matrix.MSYSTEM }}
    path-type: inherit
    install: mingw-w64-i686-gcc
- run: |
    mkdir -p dist
    go build -o dist/hdb-windows-$GOARCH.exe
  env:
    GOARCH: ${{ matrix.GOARCH }}
    CGO_ENABLED: 1
    CC_FOR_windows_386: i686-w64-mingw32-gcc
  shell: msys2 {0}

注意するべき点としては、msys2シェルを実行すると、デフォルトではWindowsでセットしているPATHを含みません。

Goの環境はactions/setup-goで入れていて、これはMSYS2のデフォルトではPATHに含まれないので、path-type: inheritとしてWindows側のPATHもmsys2シェルへ引き継ぐようにしています。また、mingw-w64-i686-gcc/mingw32以下にインストールされるので、MSYSTEM: MINGW32/mingw32/binをPATHへ追加しています。

これで、cgoを使う場合でもクロスコンパイルできるようになりました。とはいえcgoを使うと、それだけで環境を用意するのも大変だし、ビルドも遅くなるので、どうしても必要な場合を除いてcgoは使わない方が良いと思います。


  1. この程度ならGoで書いた方がいいとは思いますが。

  2. C++の場合はCXX_FOR_TARGETのように、環境変数名のCC部分がCXXと変わります。