c-bata web

@c_bata_ のメモ。python と Go が多めです。

LLVMのauto-vectorizationとc2goasmによるGo Plan9 Assemblyの生成によるSIMD最適化

InfluxDBの開発チームはApache Arrowの技術に注目していて、ArrowのGo実装の開発にも積極的に参加しています。Stuart Carine (InfluxDBの開発チームメンバー)がApache ArrowのGo実装に取り入れたc2goasmとLLVMを使った最適化が以前話題になりました。

www.influxdata.com

c2goasm はClangで生成したアセンブリをGo Plan9 Assemblyに変換できる汎用的なコマンドラインツールです。しかしその活用事例はほとんどが、Goコンパイラーが行っていないSSEやAVX命令を用いたSIMD最適化です。この記事では Intel AVX (Advanced Vector Extensions) によるSIMD演算 - c-bata web でも解説したAVX2によるSIMD演算を実現するための c2goasm の使い方を整理します。最終的にどれくらい速くなるかでいうとfloat64のsum演算は10x以上高速化しました。

$ go test -bench .
goos: darwin
goarch: amd64
pkg: github.com/c-bata/sample-c2goasm
BenchmarkSumFloat64_256-4              5000000           282 ns/op
BenchmarkSumFloat64_1024-4             1000000          1234 ns/op
BenchmarkSumFloat64_8192-4              200000         10021 ns/op
BenchmarkSumFloat64_AVX2_256-4        50000000            23.5 ns/op
BenchmarkSumFloat64_AVX2_1024-4       20000000            95.9 ns/op
BenchmarkSumFloat64_AVX2_8192-4        2000000           904 ns/op
PASS
ok      github.com/c-bata/sample-c2goasm    10.911s

c2goasm

c2goasm の使い方は次のとおりです。

  1. C/C++で記述した関数からClangでアセンブリを生成
  2. Goのバインディング用のシグネチャを定義
  3. c2goasmのコマンドラインツールでGo Plan9 Assemblyを生成

ツールとしての作り込みが少し雑な印象はありハマりどころもありますが、この3つのステップを踏めばSIMDを使って最適化したC/C++のコードを少ないオーバーヘッドでGoから呼び出せます。

CgoによるCの関数呼び出し

Goにおいてデファクトスタンダードとなっている cgo は性能面において優れたソリューションではありません 1 。c2goasmで生成されたGo Plan9 Assemblyのサブルーチン呼び出しは、他のGoの関数呼び出しと同程度の効率で実行できるようです。

GithubリポジトリのREADMEにはcgoとの性能比較が紹介されています。 https://github.com/minio/c2goasm#benchmark-against-cgo

Cのコードの記述

float64のarrayに入った値の合計値を計算する関数を用意します。 SIMDによる演算で合計値の計算をするのは少しアルゴリズム的な工夫が必要です。

#include <immintrin.h>

void sum_float64_avx_intrinsics(double buf[], size_t len, double *res) {
    __m256d acc = _mm256_set1_pd(0);
    for (int i = 0; i < len; i += 4) {
        __m256d v = _mm256_load_pd(&buf[i]);
        acc = _mm256_add_pd(acc, v);
    }

    acc = _mm256_hadd_pd(acc, acc); // a[0] = a[0] + a[1], a[2] = a[2] + a[3]
    *res = _mm256_cvtsd_f64(acc) + _mm_cvtsd_f64(_mm256_extractf128_pd(acc, 1));
}

このようにintrinsicsを使って自分でかくこともできますが、実装が少し大変です。 LLVMのAuto-Vectorization にこの辺の最適化は任せられるなら楽ができます。

void sum_float64(double buf[], int len, double *res) {
    double acc = 0.0;
    for(int i = 0; i < len; i++) {
        acc += buf[i];
    }
    *res = acc;
}

これをClangでコンパイルしてみます。

$ clang -S -mavx2 -masm=intel -mno-red-zone -mstackrealign -mllvm -inline-threshold=1000 -fno-asynchronous-unwind-tables -fno-exceptions -fno-rtti -c sum_avx_intrinsics.c

オプションはこの辺を参考にしてください。

実行すると ~.sアセンブリファイルが生成されるので中を見るとこのような感じです。

 .section    __TEXT,__text,regular,pure_instructions
    .build_version macos, 10, 14
    .intel_syntax noprefix
    .globl  _sum_float64            ## -- Begin function sum_float64
    .p2align    4, 0x90
_sum_float64:                           ## @sum_float64
## %bb.0:
    ...
    vxorps  xmm0, xmm0, xmm0
    mov qword ptr [rsp + 40], rdi
    mov dword ptr [rsp + 36], esi
    mov qword ptr [rsp + 24], rdx
    vmovsd  qword ptr [rsp + 16], xmm0
    ...

vmovsdといった命令やレジスタ xmm0 を利用していることからLLVMSIMD命令を活用していることがわかります。サブルーチン名 _sum_float64 はGoの関数定義で必要なので覚えておきます。

※ xmm0レジスタは128 bitsしかないレジスタのため、64 bitsを消費するdoubleの演算は2要素しか一度に演算できません。おそらくSSEの命令を利用しています。ここで ymm0 など128 bitsのレジスタを使い AVX2の命令を呼び出してほしいのですが、Clangのバージョンや最適化オプションによりうまくいったりイカなかったりするようです。詳細はGithubをご覧ください。

Goの関数定義

package c2goasm_sample

import "unsafe"

//go:noescape
func __sum_float64(buf, len, res unsafe.Pointer)


func SumFloat64Avx2(a []float64) float64 {
    var (
        p1 = unsafe.Pointer(&a[0])
        p2 = unsafe.Pointer(uintptr(len(a)))
        res float64
    )
    __sum_float64(p1, p2, unsafe.Pointer(&res))
    return res
}

シグネチャアセンブリ内のサブルーチン名に _ を追加したものです。今回のようにもしサブルーチン名が _ から始まっていたら、 __ で始まることに注意してください。

https://github.com/minio/c2goasm/blob/0325a40cfd1fc6a5097e69eaf0292990eb6cee6a/arguments.go#L85

c2goasm実行

$ go get -u github.com/minio/asm2plan9s
$ go get -u github.com/minio/c2goasm
$ go get -u github.com/klauspost/asmfmt/cmd/asmfmt
$ c2goasm -a -f _lib/sum_avx_intrinsics.s sum_avx.s

これでGoアセンブリが生成されます。第2引数が出力ファイルですが、Goのファイル名を sum_avx.go としたときは拡張子を .s に変えただけの sum_avx.s を指定します。 https://github.com/minio/c2goasm/blob/0325a40cfd1fc6a5097e69eaf0292990eb6cee6a/c2goasm.go#L252

ここまでが一連の流れです。

ベンチマーク

Pure Goで実装した次の関数とベンチマークにより比較します。

package c2goasm_sample

func SumFloat64(a []float64) float64 {
    var sum float64
    for i := range a {
        sum += a[i]
    }
    return sum
}

ベンチマークのコードは次の通り。

package c2goasm_sample_test

import (
    "math/rand"
    "testing"
)

func init() {
    rand.Seed(0)
}

func initializeFloat64Array(n int) []float64 {
    var max float64 = 1024
    var min float64 = 0
    x := make([]float64, n)
    for i := 0; i < n; i++ {
        x[i] = rand.Float64() * (max - min) + min
    }
    return x
}

func benchmarkFloat64Sum(b *testing.B, n int) {
    x := initializeFloat64Array(n)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        SumFloat64(x)
    }
}

func benchmarkFloat64SumAvx2(b *testing.B, n int) {
    x := initializeFloat64Array(n)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        SumFloat64Avx2(x)
    }
}

func BenchmarkSumFloat64_256(b *testing.B) {
    benchmarkFloat64Sum(b, 256)
}

func BenchmarkSumFloat64_1024(b *testing.B) {
    benchmarkFloat64Sum(b, 1024)
}

func BenchmarkSumFloat64_8192(b *testing.B) {
    benchmarkFloat64Sum(b, 8192)
}

func BenchmarkSumFloat64_AVX2_256(b *testing.B) {
    benchmarkFloat64SumAvx2(b, 256)
}

func BenchmarkSumFloat64_AVX2_1024(b *testing.B) {
    benchmarkFloat64SumAvx2(b, 1024)
}

func BenchmarkSumFloat64_AVX2_8192(b *testing.B) {
    benchmarkFloat64SumAvx2(b, 8192)
}

実行結果

$ go test -bench .
goos: darwin
goarch: amd64
pkg: github.com/c-bata/sandbox-go/c2goasm
BenchmarkSumFloat64_256-4                5000000               277 ns/op
BenchmarkSumFloat64_1024-4               1000000              1205 ns/op
BenchmarkSumFloat64_8192-4                100000             10401 ns/op
BenchmarkSumFloat64_AVX2_256-4           2000000               768 ns/op
BenchmarkSumFloat64_AVX2_1024-4           500000              2872 ns/op
BenchmarkSumFloat64_AVX2_8192-4           100000             23946 ns/op
PASS
ok      github.com/c-bata/sandbox-go/c2goasm    10.474s

遅くなっちゃった...

追加調査

Apache Arrowでの利用事例がある以上、なにかコンパイラーオプションとかが理由で性能が十分に引き上げられていない。気になってる点は次のあたり

追記: うまくいきました。

Clang-7.0.1を利用する際には、次のコンパイルオプションを使用してください。

$ /usr/local/Cellar/llvm/7.0.1/bin/clang -S -O2 -mavx2 -masm=intel -mno-red-zone -mstackrealign -mllvm -inline-threshold=1000 -fno-asynchronous-unwind-tables -fno-exceptions -fno-rtti -c sum_float64.c

追記: SSEのコードはなぜ遅くなっていたのか。

SSEでも遅くはならないんじゃないかなというのが気になっていたけれど、なんとなく原因がイメージついた気がします。 InfluxDataの記事にあるとおりsumの計算を最適化してCで直接書くとAVX2のコードはこんな感じになります。

void sum_float64_avx_intrinsics(double buf[], size_t len, double *res) {
    __m256d acc = _mm256_set1_pd(0);
    for (int i = 0; i < len; i += 4) {
        __m256d v = _mm256_load_pd(&buf[i]);
        acc = _mm256_add_pd(acc, v);
    }

    acc = _mm256_hadd_pd(acc, acc); // a[0] = a[0] + a[1], a[2] = a[2] + a[3]
    *res = _mm256_cvtsd_f64(acc) + _mm_cvtsd_f64(_mm256_extractf128_pd(acc, 1));
}

重要なのはこの _mm256_hadd_pd の処理で こちらの説明にあるように詰め込まれている要素の加算 を担当します。128 bitsしかないSSE用のレジスタ xmm0 とかだと8 bytes 必要な double の値を詰め込んでも2つしかのらないので、それにたいして hadd_pd みたいな処理(これはAVX2の処理ですがそれに相当する処理がSSEにもあるのかと思います) をすると全く並列化されてないことになります。 細かいところはアセンブリが使ってる命令をみればはっきりしそうです。

プログラミング言語Go (ADDISON-WESLEY PROFESSIONAL COMPUTING SERIES)

プログラミング言語Go (ADDISON-WESLEY PROFESSIONAL COMPUTING SERIES)


  1. GoのポインタをCに渡せないようにポインタのバリデーション処理が走るなど、GC側の都合による性能劣化が発生するようです。詳細は Why cgo is slow @ CapitalGo 2018 - Speaker Deck をご覧ください。

Intel AVX (Advanced Vector Extensions) によるSIMD演算

SIMD(Single Instruction Multiple Data)は、名前の通り複数のデータを1命令で処理する方式を指します。各プロセッサコアに異なる命令を供給する機構が必要なMIMDと比べ、SIMDは必要なトランジスタ数が少なく小さな面積でプロセッサを設計することができるため、GPUにおける並列処理のパラダイムとしても主流になっています (SIMTは今は扱わない)。

Intel CPUのMMX命令やSSE命令、AVX命令はSIMD方式の演算命令になります。動画配信チームからML系の研究チームに異動したのですが、研究成果を実用レベルのアプリケーション実装に落とし込めるよう今回はこのAVX命令について整理しました。この記事の実行環境はOSがmacOS, cpuは intel x86_64 です。AVXの解説書としては日本語だと下記の書籍がかなり整理されていますが、基本的に x86 前提のコードなので参考にする際は適宜置き換えてください。

AVX命令入門―Intel CPUのSIMD命令を使い倒せ

AVX命令入門―Intel CPUのSIMD命令を使い倒せ

SSEを用いた行列の加算演算

x64ではインラインアセンブリが利用できないため、アセンブリ命令をマクロで記述できるようにしたイントリンシックを利用します。マクロを覚えるのが面倒でx64アセンブリに慣れているなら直接関数シンボルを記述してgccでオブジェクトファイルを生成し、ldでリンクする方法もとれなくはないはずです。ただしポータビリティの観点からも基本的にはイントリンシックを素直に覚えるのがいいでしょう。

SIMD命令の1つであるSSEをまずは使ってみます。ヘッダファイル xmmintrin.h にSSE用のアセンブリに展開するためのマクロが入っているので、そちらincludeして次のように行列加算を実行します。

#include <stdio.h>
#include <xmmintrin.h>

int main(void) {
    __m128 a = {1.0f, 2.0f, 3.0f, 4.0f};
    __m128 b = {1.1f, 2.1f, 3.1f, 4.1f};
    float c[4];
    __m128 ps = _mm_add_ps(a, b); // add
    _mm_storeu_ps(c, ps); // store

    printf("  source: (%5.1f, %5.1f, %5.1f, %5.1f)\n",
            a[0], a[1], a[2], a[3]);
    printf("  dest. : (%5.1f, %5.1f, %5.1f, %5.1f)\n",
           b[0], b[1], b[2], b[3]);
    printf("  result: (%5.1f, %5.1f, %5.1f, %5.1f)\n",
           c[0], c[1], c[2], c[3]);
    return 0;
}

SSEの命令では128ビットのレジスタが利用できます。浮動小数点数 float型は4 bytes(=32 bits)であることから4つの要素を一度に演算できます。これを実行してみます。

$ gcc -o avx -Wall -O0 main.c
$ ./avx-
  source: (  1.0,   2.0,   3.0,   4.0)
  dest. : (  1.1,   2.1,   3.1,   4.1)
  result: (  2.1,   4.1,   6.1,   8.1)

AVX (AVX2)を用いた行列の加算演算

SSEではSIMD用のレジスタが128ビットであったため、floatであれば4要素までしか一度に演算できませんでした。AVX命令はビット幅が256ビットへ拡張され、3オペランド命令書式が採用され演算性能を大幅に向上することができました。またメモリオペランドを用いた演算時にアドレスのアライメントを要求しなくなったためメモリ配置の自由度があがりました(その場合性能が少し低下します)。さらにその後追加されたAVX2では浮動小数点だけでなく、整数の演算がサポートされました。

基本的にAVX2がサポートされている環境ではこちらを利用することが望ましいでしょう。AVXやAVX2のアセンブリに展開するためのマクロを使うときは immintrin.h をincludeします。

#include <stdio.h>
#include <immintrin.h>

int main(void) {
    __m256 a = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f};
    __m256 b = {1.1f, 2.1f, 3.1f, 4.1f, 5.1f, 6.1f, 7.1f, 8.1f};
    __m256 c;

    c = _mm256_add_ps(a, b);

    printf("  source: (%5.1f, %5.1f, %5.1f, %5.1f, %5.1f, %5.1f, %5.1f, %5.1f)\n",
            a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7]);
    printf("  dest. : (%5.1f, %5.1f, %5.1f, %5.1f, %5.1f, %5.1f, %5.1f, %5.1f)\n",
            b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]);
    printf("  result: (%5.1f, %5.1f, %5.1f, %5.1f, %5.1f, %5.1f, %5.1f, %5.1f)\n",
           c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7]);
    return 0;
}

AVXではSIMD用のレジスタが256ビットになっているため、floatの演算であれば 256 bits / 32 bits(floatは4bytes) で8要素を一度に演算できます。浮動小数点 float ではなく倍精度浮動小数点 double を使いたいなら __m128d 、整数を使いたいなら __m128i を使用します。倍精度浮動小数点は8bytesを使うため4要素しか扱えないことに注意してください。

参照: https://www.codingame.com/playgrounds/283/sse-avx-vectorization/what-is-sse-and-avx

これをコンパイルしてみます。

$ gcc -o avx2 -Wall main.c-
main.c:9:9: error: always_inline function '_mm256_add_ps' requires target feature 'xsave', but would be inlined into function 'main' that is compiled without support for 'xsave'
    c = _mm256_add_ps(a, b);
        ^
1 error generated.

コンパイルに失敗しました。 こちらの記事 によるとCPUがサポートしている機能は次のコマンドにより確認できます。

$ sysctl -a | grep machdep.cpu.features
machdep.cpu.features: FPU VME DE PSE TSC MSR PAE MCE CX8 APIC SEP MTRR PGE MCA CMOV PAT PSE36 CLFSH DS ACPI MMX FXSR SSE SSE2 SS HTT TM PBE SSE3 PCLMULQDQ DTES64 MON DSCPL VMX EST TM2 SSSE3 FMA CX16 TPR PDCM SSE4.1 SSE4.2 x2APIC MOVBE POPCNT AES PCID XSAVE OSXSAVE SEGLIM64 TSCTMR AVX1.0 RDRAND F16C

たしかにSSEやSSE2、SSE4.1、SSE4.2、AVX1.0 は存在しますが、AVX2がここにはありません。ただしCPUとしてはAVX2をサポートされている場合があり、次のコマンドで出てきた命令は特別なコンパイラーオプションを与えることで使用できるようです。

$ sysctl -a | grep machdep.cpu.leaf7_features
machdep.cpu.leaf7_features: SMEP ERMS RDWRFSGS TSC_THREAD_OFFSET BMI1 AVX2 BMI2 INVPCID SMAP RDSEED ADX IPT SGX FPU_CSDS MPX CLFSOPT

AVX2 が何らかのコンパイラーオプションを与えることで使用できることがわかりました。こちらの記事 によるとgccでは -mavx2 オプションが利用できます。

$ gcc -o avx2 -mavx2 -Wall main.c 
$ ./avx2
  source: (  1.0,   2.0,   3.0,   4.0,   5.0,   6.0,   7.0,   8.0)
  dest. : (  1.1,   2.1,   3.1,   4.1,   5.1,   6.1,   7.1,   8.1)
  result: (  2.1,   4.1,   6.1,   8.1,  10.1,  12.1,  14.1,  16.1)

AVX2の命令と利用できるレジスタ解説

先程のAVX2を利用したプログラムのアセンブリを出力しておく。これをもとにAVX2のレジスタ構成と命令セットを解説

$ gcc -S -mavx2 -Wall -O0 avx2.c
$ less avx2.s

まず注目するべきなのは次のアセンブリ群。

LCPI0_0:
        .long   1065353216              ## float 1
        .long   1073741824              ## float 2
        .long   1077936128              ## float 3
        .long   1082130432              ## float 4
        .long   1084227584              ## float 5
        .long   1086324736              ## float 6
        .long   1088421888              ## float 7
        .long   1090519040              ## float 8

... 略

## %bb.0:
        ... 略
        vmovaps LCPI0_0(%rip), %ymm0    ## ymm0 = [1.000000e+00,2.000000e+00,3.000000e+00,4.000000e+00,5.000000e+00,6.000000e+00,7.000000e+00,8.000000e+00]
        vmovaps %ymm0, 160(%rsp)

オブジェクトファイル内の LCPI0_0 セクションに書き込まれたfloat配列の情報を ymm0 と呼ばれるCPU上のレジスタ領域にコピーしていることがCのコードから想像できる。レジスタ構成は次のようになる。

参照: https://de.wikipedia.org/wiki/Advanced_Vector_Extensions

vmovaps命令によるレジスタ配置が続いたあと、次の命令が存在する。

        vaddps  %ymm1, %ymm0, %ymm0

オペランドが3つあることから AVX の命令であること、 add という文字から加算演算を意味することが予測できる。Intelのページ によるとsingle-precisionな浮動小数点(つまりfloat)の加算演算を行いその結果を第3オペランドで指定したレジスタに格納する。つまりymm1とymm0とよばれる256bitsのレジスタ領域に格納されている8つのfloat値を加算し、その結果がymm0に格納されることを意味している。

加算演算はこれで完了なので、あとは適切にその結果を変数 c に格納すればいい。少しアセンブリが冗長なように見えなくもないがなにかそこはもう少し読むと理由があるかも。パフォーマンスが思ったほど上がらなければ確認したほうがいいかも。

発展: AVX-512

colfaxresearch.com

たまたま見つけたこちらのページによるとどうやらAVX2はSkylakeにおいてもはやLegacyな命令セットで、AVX-512は512 bitのレジスタ幅を持っているようです。AVX2に比べてかなりの性能向上が期待できます。ただ $ sysctl -a | grep machdep.cpu.leaf7_features の結果にAVX-512が現れていないので対応状況から調べる必要がありそうです。 simdjson などもまだAVX2を使っているようなのでなにか対応状況などの背景から利用が難しいのかも。

また図を見る限りAVX-512の中にもかなりいろいろな派生があるようで、まとめると長くなりそうなのでそちらの調査結果はまた別記事にて整理しようと思います。

Github Actionsでbranch作成/削除にフックしてFeature環境を構築する

最近Github Actionsを触る機会があったのですが、まだ自分のgithub accountはbetaのwait list待ちで業務で使ってるrepostioryでしか使えないので、使い方とかポイントを忘れないようにメモ。ついでにいくつか公式のactionにPR送ったり、KubernetesIngress Rulesを編集するためのActionを公開していたりもするので、こちらも紹介します。

Feature環境の自動作成

業務では開発中の機能を手軽にDev環境で確認するために、特定の命名規則に従ったブランチ名でGithubにpushすると、自動でdev環境のKubernetesクラスターにリリースしIngressでエンドポイントを用意して閲覧できるようにしています。これ自体はそんなに珍しくなくて検索するといくつか同じような記事が見つかります。

これまでもSpeeeさんの事例のようにwebhook eventを監視してoperationを行うサーバーを用意して解決することはできました。弊社ではCircle CIなどでfeature環境を作成したりもしています。 ただSpeeeさんの事例では自分たちでサーバーを用意して運用しないといけません。また弊社がこれまでやっていたようにCIサービスでfeature環境を作成する場合にはbranchの削除にトリガーできません。社内の別のチームではbotを立ててbranchの削除を監視してたりもしたみたいですが、これだけのためにbotたてるのも少し手間になります。

GithubのあらゆるイベントにトリガーできるGithub Actionsを使えば、branchがpushされたときにfeature環境を作成し、branchが削除されたときにfeature環境を削除するといったオペレーションを、自分たちでサーバーを管理することなく実現できます。業務ではKubernetesを使っているので、全体像としては次のような感じになります。

f:id:nwpct1:20190222231923j:plain
ざっくりした全体像。実際にはmicroserviceが30以上あり、それらのtraffic routingまではコントロールできる環境を用意できていないので、webのツールや動画配信サーバー(HLS playlist, MPEG-DASH manifest, MPEG-2 TSやFragmented MP4といったメディアセグメントを提供)など一部のコンポーネントだけがこの機能を有効にしています。

  1. feature-abc のように feature-*命名規則に従ってbranchを作成しGithubにPush
  2. Kubernetes Deploymentをbranch用に作成
  3. Kubernetes Serviceをfeatureブランチ用に新規で作成 1
  4. Ingress (ingress-gce) でエンドポイント作成 2
  5. Google Cloud DNSのRecordsetsの作成
  6. https://feature-abc-webapp.foo.com でアクセスして動作確認

これまではCIサービス側でブランチのpushにトリガーしてFeature環境を作成していました。ただCIサービスだと基本的にはbranchのdeleteにトリガーできません。

Github Actions

基本的な使い方は公式ドキュメントをみてください。

https://developer.github.com/actions/

いくつか悩んだり調べた中でメモしておきたいポイントを中心に残します。

credential情報の管理

外部に漏れては困る情報は Secret によりGithub RepositoryのSettingsで指定できます (参照 https://developer.t.com/actions/creating-workflows/storing-secrets/)。 Actionsを追加する際にも「Secret」というフィールドがありますが、そこから指定してもやってることは同じです。

実は1月頃はまだLimited Public Beta期間中だった当時はProduction SecretsをまだSecretsとして保存してはいけませんでした。 Limited Public Betaがとれたため、今回改めてGithub Actionsを調査することにしました。

f:id:nwpct1:20190222164218p:plain
github-action-secrets

ブランチ名のフィルター

pushイベントに対してすべてトリガーしてほしいわけではなく、特定の命名規則に従ったbranchでのみ実行してほしいものです。GITHUB_REFS という環境変数の中に refs/head/feature-A のような形式でブランチ名やタグ名が入っています。 refs/head/のprefixを削除して利用すればOKです。公式で用意されている↓のactionがこの操作をしてくれているのでこちらを利用しましょう。

bin/filter at master · actions/bin · GitHub

f:id:nwpct1:20190222164042p:plain
github-actions-filter

ただbranch削除時のfilterにはこの方法が使えません。 delete triggerは GITHUB_REF にdefault branchつまりmasterを指定が指定されています。環境変数からbranch名を取り出すことはできません。そのかわり GITHUB_EVENT_PATH 環境変数が示す場所にWebhookのevent情報がそのままjson形式で入っています。

delete でtriggerしたときは DeleteEvent の形式なので、 ref フィールドよりブランチ名が取り出せます。公式で用意してほしい機能なので↓にPRをだしました。

github.com

まだマージされていないので c-bata/bin/filter@master を指定して使っています。 deleted_branch feature-* のようにargsを指定すれば使えます。

マージされたので公式の actions/bin/filter@master を使用してください。そちらには deleted_tag フィルターも追加しています。

f:id:nwpct1:20190227010255p:plain
c-bata/actions/filter deleted_branch feature-*

GCPのService Accountからgcloudの認証を行う

f:id:nwpct1:20190227010437p:plain
Activate gcloud command using service-account

公式で用意されている↓のactionを用いることで実現できます。Service AccountはSecret GCLOUD_AUTHbase64 encodeしたservice accountのjsonファイルを与えればOKです (ex: base64 ./service-account.json )。

gcloud/auth at master · actions/gcloud · GitHub

少し驚いたのですがgcloudコマンドの実行は別のactionとして定義し、↓を利用して実行します。

gcloud/cli at master · actions/gcloud · GitHub

gcloudコマンドのcredential情報は、Homeディレクトリ以下に作成されます。Github Actionsは裏側で /github/workspace を常にマウントしそこをHomeディレクトリに設定しているようです。このディレクトリは次のactionでもそのままの状態で引き継がれます。gcloudの認証とgcloudコマンドの実行は別のactionでやるのがGithub Actionらしいやり方なようです。

kubectlの実行

gcloud authができるようになったので、kubernetes clusterのcredentials情報を取得してkubectlを実行します。既存でよさそうなものがなかったのですが、https://github.com/actions/gcloud で管理されるのがみんな幸せかと思うので PRを出しました。

Add github action for kubectl by c-bata · Pull Request #9 · actions/gcloud · GitHub

gcloudコマンドにならって、PROJECT_IDやZONE、K8S clusterをセットするactionとkubectlの実行用actionを分割しました。まだマージはされていないので c-bata/gcloud/kubectl-config@master および c-bata/gcloud/kubectl@master を指定して使っています。

f:id:nwpct1:20190227010829p:plain

ingress rules書き換えツールの実行

deploymentsやserviceをfeature環境ごとに個別に作っていたようにingressもfeature環境ごとにつくることもできるのですが、大きいチームだったので大量にFeature環境が立ち上がりLoadbalancerの作成上限に引っかかったことがありました。はingressを基本的に使いまわし、Spec.Rulesに振り分け設定を追加して webapp.foo.comfeature-a-webapp.foo.com を1つのLBで振り分けています。管理の都合上もその方がいいかなと思います。そのためのツールが GitHub - abema/github-actions-ingress-rules-editor: Edit ingress rules to build feature environments automatically on Github Actions. です。もともとnodeで書かれたscriptがあったのですが、client-goが使えるGoで書いたほうが楽だったので今回書き直しました。↓で公開しています。

GitHub - abema/github-actions-ingress-rules-editor: Edit ingress rules to build feature environments automatically on Github Actions.

c-bata/go-actions

github actionsの調査もかねてutilityライブラリ作りました。 正直使うほどでもないシーンが多いと思いますが、よければ使ってみてください。

GitHub - c-bata/go-actions: go-actions provides the utilities for Github Actions.

面倒だったこと

branchを削除したときにはmasterブランチのmain.workflowが参照され、実行されます。そのためbranchの削除にtriggerして何らかの処理を行いたいとき、一度そのbranchをmasterにマージして削除しないと動作確認ができません。

Add deleted_branch and deleted_tag filters by c-bata · Pull Request #42 · actions/bin · GitHub みたいな機能はとりあえず書いてmasterにマージしてbranchを削除して、問題があればまたbranchを作ってmasterにマージしてbranchを削除しないと確認できずmasterのcommit logが結構汚れます。仕事のrepositoryでそれをやることになったので申し訳ないなと思いながら開発してました。

おわりに

はやく自分のrepositoryでも使ってみたい


  1. 執筆時点では ingress-gce がClusterIPへのヒモ付に対応してないのでServiceTypeはNodePortを使用しています。NodePortの番号は特に指定していないのでKubernetes側にrandomに割り振ってもらっています。

  2. 次の手順でGoogle Cloud DNS Recordsetsを作りますが、もしそちらをterraformで管理して消し忘れとかをなくしたいのであれば、Google compute address(静的IPアドレス)の払い出しもTerraformで行って、Ingress側の metadata.annotations.kubernetes.io/ingress.global-static-ip-name で指定して使うのが管理の都合上いいかと思います。