OSSプロジェクトにLambCIを導入しようとした話

最近はLambStatusというOSSを作っているのですが、今回は、このプロジェクトでCIを回すためにLambCIを導入しようとした話です。結果は失敗に終わったのですが、つまづいた箇所とかが参考になるかもしれないので残しておきます。

LambCIとは

LambCIは、AWS LambdaベースのCIシステムです。〜CIというとCircleCIみたいなクラウドサービスっぽいですが、クラウドサービスではなく、自分で構築して運用していくものです。

以下のMediumの記事が一時期話題になったので、聞いたことがある方もいるかもしれません。

medium.com

なぜ今更そんな自前運用のCIシステムかというと、LambCIがいわゆるサーバレスアーキテクチャで作られていることが大きいと思います。Jenkins等のCIシステムを自前運用していくのはなかなか大変ですが、サーバレスなら自前運用の辛さをかなり軽減できるかもしれない、また、CI系のクラウドサービスはビルドの並列度を上げると料金が結構高くなりますが、サーバレスなら並列度を上げても全体の料金はほぼ変わらなそう、などのサーバレスを活かした特徴が注目されているようです。

より詳しい紹介やアーキテクチャについては、上記の記事を参照して下さい。

LambCIをOSSプロジェクトで使ってみる

今回LambCIを導入するOSSプロジェクトはJavascriptで作っていて、テストはkarma、mocha、electron等を利用して動かしています。実際のテストや設定はこちらから確認できます。

LambCIのインストールについては公式のREADMEに詳しく書かれているので省略して、設定ファイル.lambci.jsの作成から始めます。レポジトリのトップディレクトリに、以下のような設定ファイル.lambci.jsを作成しました。

module.exports = {
  cmd: 'cd packages/frontend && npm install && npm run test',
  build: true
}

GitHubにPushしたところ、AWS Lambdaでのビルド中に、以下のようなエラーが出ました。

f:id:ks888:20161119150257p:plain

末尾にError: ENOSPC: no space left on device, writeと書かれています。npm install中に、ディスクサイズが足りなくなったようです。ローカル環境で確認してみると、babelやelectron等のパッケージにより、依存パッケージだけで700MB程度のディスクを消費していました。AWS Lambdaの一時ディスク容量は512MBなので、これが原因のようです。

まぁディスク容量に限らず、AWS Lambdaには色々制限があります。LambCIは、AWS Lambdaの制限によりビルドがうまく行かないときは、ECS (EC2 Container Service)との組み合わせを勧めています。ディスク制限の回避は難しそうなので、素直にECSを使ってみます。

LambCIとEC2 Container Serviceを組み合わせて使ってみる

LambCIとECSを組み合わせる場合は、以下のレポジトリを利用します。

https://github.com/lambci/ecs

こちらはまだREADMEがあまり書かれていないので、導入手順を紹介します。

1.CloudFormationスタックの立ち上げ

こちらのテンプレートを使って、AWSコンソールからCloudFormationスタックを立ち上げます。これで、ECSクラスタ等が立ち上がります。今回はt2.smallインスタンスタイプを使いました。

2.CloudFormationテンプレートとスタックの更新

(ECSではなく)LambCI本体の立ち上げに使用したCloudFormationテンプレートを更新します。LambCIのGitHubサイトからテンプレートをダウンロードして、LambdaExecutionという名前のIAM Roleリソースに、以下のポリシーを書き加えます。

{
  "PolicyName": "RunECSTask",
  "PolicyDocument": {
    "Statement": {
      "Effect": "Allow",
      "Action": "ecs:RunTask",
      "Resource": "arn:aws:ecs:*:*:task-definition/lambci-ecs-BuildTask-XXXXXXXXXXXXX"
    }
  }

XXXXXXXXXXXXXの箇所は、Step.1で作成されたECSのタスク名で置き換えてください。

書き換えたテンプレートを利用して、AWSコンソールからLambCIのCloudFormationスタックを更新します。

3.設定ファイルの更新と追加

.lambci.jsに対して、ビルドに使用するクラスタ名などを以下のように指定します。

module.exports = {
  ... 既存の設定 ...
  docker: {
    cluster: 'lambci-ecs-Cluster-XXXXXXXXXXXXX',
    task: 'lambci-ecs-BuildTask-XXXXXXXXXXXXX'
  }
}

XXXXXXXXXXXXXの箇所は、Step.1で作成したECSのクラスタ名とタスク名です。

これに加えて、Dockerfile.testというファイルをレポジトリのトップディレクトリに作成します。これは、ビルド処理を動かすDockerイメージをビルドするためのファイルです。

今回は以下のようなDockerfile.testファイルを作成しました。

FROM node:4.3.2

RUN apt-get update && apt-get install -y xvfb x11-xkb-utils xfonts-100dpi xfonts-75dpi xfonts-scalable \
  xfonts-cyrillic x11-apps clang libdbus-1-dev libgtk2.0-dev libnotify-dev libgnome-keyring-dev \
  libgconf2-dev libasound2-dev libcap-dev libcups2-dev libxtst-dev libxss1 libnss3-dev \
  gcc-multilib g++-multilib libgconf2-4 gtk2-engines-pixbuf

ADD packages/frontend/package.json /frontend_build/

RUN cd /frontend_build && npm install

ADD . .

RUN rm -rf packages/frontend/node_modules && mv /frontend_build/node_modules ./packages/frontend

CMD cd packages/frontend && xvfb-run -a --server-args="-screen 0 1024x768x24" npm run test

キャッシュを効かせるためにpakcage.jsonをごにょごにょしているため少しややこしいですが、基本的にはnpm installnpm run testをしているだけです。これに加えて、非GUI環境でelectronを動かすため、aptで色々入れています。

実際の設定ファイルは、こちらで確認できます。

これらのファイルをGitHubにPushすると、ECS上でのビルドが実行されます。なお、S3上のビルド結果画面には以下のようなログしか表示されないため、ビルド結果はCloudWatch LogsのECSのログで確認します。

f:id:ks888:20161119151548p:plain

初回は20分ほどかかりましたが、無事にビルドが成功しました。t2.smallインスタンスタイプを利用したので仕方ないと思いますが、同等のビルドをCIサービスのwerckerで動かすと7分ほどで終わったので、ちょっと遅めです。

LambCI導入をやめた理由

ここまでは進めたのですが、しばらくはクラウドサービスを使おうかな、と思っています。理由としては以下です。

  • ECS上でビルドした結果がS3に反映されない

    ECSを利用しない場合、ビルド結果はS3に渡されるため、S3を通してビルド結果を他の開発者と共有できます。同様にビルド結果を示すバッジ画像も、S3を通してREADME等に表示できます。しかしECSを利用すると、ビルド結果がS3に反映されないため、ずっとビルド中の状態になってしまいます。ビルド結果はCloudWatch Logsからわかるのですが、CloudWatch Logsの閲覧権限をパブリックに共有するのは気が進みません。

  • Lambdaの代替としてECSを使いたくない

    今回はLambdaの代わりにECSを使おうとしましたが、Lambdaに期待することをECSで実現するのは無理があると感じました。

    例えば、今回は最初t2.microインスタンスクラスタを立ち上げたのですが、これだとビルドが何回繰り返しても通りませんでした。インスタンスの性能が足りないのか思い、試しにt2.smallにしてみたところ、通りました。また、クラスタを立ち上げる度にECS Agentが古いから更新しろと警告してきます(更新は数クリックで済みますし、AMIを適切に作り直せば警告は消えると思いますが)。こういう問題は、始めからECSを利用するつもりなら何でもない話ですが、Lambdaを使うつもりだった立場からすると、ドウシテコウナッタ感があります。

    また、LambCIは、ビルド並列度を上げても全体の料金がほぼ変わらないところや、ビルドが実行されてないときは料金がほぼかからないところが利点です。しかし、ECSだとその恩恵にあずかるのは難しくなります。オートスケールを組み合わせれば多少ましになるかもしれませんが、それでも、1時間単位課金と100ms単位課金ではだいぶ料金が変わってくる気がします。

1つ目はそれほど大きな理由ではないし、回避策もあると思うのですが、2つ目の理由は結構厳しいと思い、導入をやめました。Lambdaの制限が緩和されて、ECSなくてもいけそう!と思えた段階で、改めて導入しようかと思います。

ちなみに、今はwerckerを使っています。こんな設定ファイルを書くといい感じにテストしてくれます。

Go言語のインターフェースの値は、メモリ上でどう表現されているか

前回の記事では、Go言語のスライスと文字列の値がメモリ上でどう表現されているか見てみました。今回はインターフェースへの理解を深めるべく、インターフェースの値がメモリ上でどう表現されているかGDBでみてみます。

インターフェースの値

まず、インターフェースの構造体は次のようになっています(runtime/runtime2.go#L71)。

type iface struct {
    tab  *itab
    data unsafe.Pointer
}

itabは、インターフェースの型情報や、メソッドの配列等を保持する構造体です。この構造体の定義は、後でGDBを動かすときに確認します。dataはインターフェースを実装した構造体へのポインタを保持しています。

早速、実際の値をGDBで見てみます。今回は例として次のようなコードを使います。環境は前回と同じく、OSがMac OS X 64bit、Goのバージョンが1.6.2です。

package main

import (
  "fmt"
)

type Calc interface {
  Add() int
  Mul() int
}

type Nums struct {
  a, b int
}

func (ns *Nums) Add() int {
  return ns.a + ns.b
}

func (ns *Nums) Mul() int {
  return ns.a * ns.b
}

func Print(c Calc) {
  fmt.Println(c.Add())
  fmt.Println(c.Mul())
}

func main() {
  ns := &Nums{1, 2}
  Print(ns)
}

GoのバイナリをGDBから扱いやすくするため、次のようにgcflagsオプションをつけてビルドします。

go build -gcflags "-N -l" interface.go

GDBを起動して、インターフェースの値をみてみます。// 以降の文字は後で書き足しています。

% gdb ./interface
...省略...
(gdb) break main.main  // main関数にbreakpointを設置
Breakpoint 1 at 0x2340: file /Users/yagami/src/go-lib/src/github.com/ks888/hello/interface.go, line 29.
(gdb) break main.Print  // Print関数にbreakpointを設置
Breakpoint 2 at 0x20c0: file /Users/yagami/src/go-lib/src/github.com/ks888/hello/interface.go, line 24.
(gdb) run  // 実行
Starting program: /Users/yagami/src/go-lib/src/github.com/ks888/hello/interface
[New Thread 0x1313 of process 1307]
[New Thread 0x1403 of process 1307]
[New Thread 0x1503 of process 1307]

Breakpoint 1, main.main () at /Users/yagami/src/go-lib/src/github.com/ks888/hello/interface.go:29
29      func main() {  // breakpointで止まった行
(gdb) next  // 次の行へ
30        ns := &Nums{1, 2}
(gdb) next  // 次の行へ
31        Print(ns)  // 今はこの行の実行前
(gdb) print ns  // 変数ns(*Nums)の値を確認(1)
$2 = (struct main.Nums *) 0xc82000a300
(gdb) x/2gx 0x000000c82000a300  // *ns(Nums)の値を確認
0xc82000a300:   0x0000000000000001      0x0000000000000002
(gdb) continue  // 実行を続ける
Continuing.

Breakpoint 2, main.Print (c=...) at /Users/yagami/src/go-lib/src/github.com/ks888/hello/interface.go:24
24      func Print(c Calc) {  // breakpointで止まった行
(gdb) next  // 次の行へ
25        fmt.Println(c.Add())
(gdb) print &c  // 変数c(*Calc)のアドレスを確認
$3 = (main.Calc *) 0xc820035ef0
(gdb) x/2gx 0xc820035ef0  // 変数c(Calc)の値を確認(2)
0xc820035ef0:   0x00000000002121c0      0x000000c82000a300

ポイントとしては、(1)で確認したNums構造体へのポインタ値0x000000c82000a300が、(2)で確認したCalc構造体の値に含まれていることです。ここから、iface構造体のdataフィールドには、Nums構造体へのポインタが格納されていることがわかります。

次は、iface構造体のtabフィールドが指すitab構造体を確認してみます。

itab構造体は次のように定義されています(runtime/runtime2.go#L514)。

type itab struct {
    inter  *interfacetype
    _type  *_type
    link   *itab
    bad    int32
    unused int32
    fun    [1]uintptr // variable sized
}

各フィールドは次のような意味です。

  • inter: インターフェースの型についての情報。
  • _type: インターフェースを実装した型についての情報。
  • link: 他のitab構造体へのポインタ。itab構造体の値をハッシュテーブルにキャッシュするときに使用。
  • bad: _typeの型がインターフェースを正しく実装できているかどうかを示します。メソッドが足りない場合はフラグが立ちます。
  • unused: たぶんアラインメント用です。
  • fun: メソッドの配列。正確には、インターフェースを実装した型がもつメソッドへのポインタの配列。この定義では長さ1の配列になっていますが、実際はメソッド数分の配列になっています。itab構造体の値を生成するときに、足りない分は補ってメモリ確保されます。

この構造体の値のうち、funフィールドの値をGDBで確認してみます。先程の続きから実行していきます。

(gdb) x/6gx 0x00000000002121c0  // tabフィールドの値を確認
0x2121c0:       0x00000000000da2e0      0x00000000000da920
0x2121d0:       0x0000000000000000      0x0000000000000000
0x2121e0:       0x0000000000002040      0x0000000000002080
(gdb) x/gx 0x0000000000002040  // メソッドの配列の1番目の値を確認(3)
0x2040 <main.(*Nums).Add>:      0x000000102444c748
(gdb) x/gx 0x0000000000002080  // メソッドの配列の2番目の値を確認(4)
0x2080 <main.(*Nums).Mul>:      0x000000102444c748

(3)、(4)から確認できる通り、配列の1番目にはNums構造体のAddメソッドへのポインタ、配列の2番目にはNums構造体のMulメソッドへのポインタが格納されています。

ちなみに今回は割愛しましたが、inter_typeフィールドについては、runtime/type.goで構造体の定義を確認できます。よければ値と合わせて確認してみてください。

インターフェース値の生成

ついでに、itab構造体のfunフィールド(メソッドの配列)がどう生成されているか確認してみます。

itab構造体の値はruntimeパッケージのtyp2Itab関数で生成されています(runtime/iface.go#L122)。

func typ2Itab(t *_type, inter *interfacetype, cache **itab) *itab {
    tab := getitab(inter, t, false)
    atomicstorep(unsafe.Pointer(cache), unsafe.Pointer(tab))
    return tab
}

getitab関数でitab構造体の値を生成しているようなので、そちらもみてみます(runtime/iface.go#L22)。日本語のコメントは僕が挿入したものです。

func getitab(inter *interfacetype, typ *_type, canfail bool) *itab {
    // 省略

    x := typ.x  // インターフェースを実装した型のメソッドリストを保持するuncommontype構造体

    // 省略

    var m *itab

    // 省略

    // itab構造体のメモリ確保。インターフェースのメソッド数に合わせて上乗せで確保している
    m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*sys.PtrSize, 0, &memstats.other_sys))
    m.inter = inter
    m._type = typ

search:
    // both inter and typ have method sorted by name,
    // and interface names are unique,
    // so can iterate over both in lock step;
    // the loop is O(ni+nt) not O(ni*nt).
    ni := len(inter.mhdr)  // インターフェースのメソッド数
    nt := len(x.mhdr)  // インターフェースを実装した構造体のメソッド数
    j := 0
    for k := 0; k < ni; k++ {
        i := &inter.mhdr[k]  // インターフェースのメソッド
        iname := i.name
        ipkgpath := i.pkgpath
        itype := i._type
        for ; j < nt; j++ {
            t := &x.mhdr[j]  // インターフェースを実装した構造体のメソッド
            // 名前、型、パッケージが一致しているか調べる
            if t.mtyp == itype && (t.name == iname || *t.name == *iname) && t.pkgpath == ipkgpath {
                // 一致するメソッドが見つかったので、itab構造体にメソッドへのポインタを書き込む
                if m != nil {
                    *(*unsafe.Pointer)(add(unsafe.Pointer(&m.fun[0]), uintptr(k)*sys.PtrSize)) = t.ifn
                }
                goto nextimethod
            }
        }
        // 一致するメソッドが見つからなかった場合の処理
        // 省略
        m.bad = 1
        break
    nextimethod:
    }

    // 省略
    return m
}

メソッドへのポインタの配列は、二重forループのところで埋めています。コードの通りですが、インターフェースのメソッドと、インターフェースを実装した構造体のメソッドをマッチングし、マッチしたら配列にメソッドへのポインタを書き込みます。

ちなみに省略しましたが、itab構造体はハッシュテーブルでキャッシュされています。なのでインターフェース値を作成する度にマッチングをする必要はありません。

感想

type *Interface is pointer to interface, not interfaceというエラーを調べていたときに見つけたGo開発者Russ Coxさんのブログ記事をかなり参考にしました。GDBで値を確認しつつGo言語のソースコードを読むと、Goの内部構造が少しみえてきて面白いです。

ちなみに今回活躍したGDBですが、Go言語ではあまりうまく動かないと言われています。実際使っていても、stepコマンドを出したらGo内部の関数に飛んでしまったりしました。実アプリでデバッガを使いたい場合には、delve等のGo向けデバッガを使ったほうがよさそうです。

GDBでGo言語のスライスの値をのぞいてみた

最近Go言語をよく触っています。触っているうちに、スライス、文字列、インターフェースなどの値がメモリ上でどう表現されているか知っておくと実装やデバッグ時に役立ちそうだなーと思うことが度々でてきました。そこでGo言語本体のソースコードを読みつつ、そのあたりをGDBでみてみました。そのときのメモです。今回はまず、スライスと文字列についてです。

使用するデバッガ

そもそもGo言語のデバッガについてよく知らなかったので、どんなのがあるか調べてみました。有名なのは次の3つのようです。

  • godebug
  • delve
  • GDB

機能的にはdelveが一番リッチな感じでしたが、今回は簡単にメモリを覗けそうなGDBにしました。

ちなみにOSはMac OS X 64bit、Goのバージョンは1.6.2です。

スライスの値

スライスは次のような構造体をもっています(ソース)。

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

arrayは配列へのポインタ、lenはスライスが保持している値のリストの長さ、capは(arrayが指すアドレスを始点とした)配列の長さです。lencapの違いについてはGo Blogを読むのがいいかと思います。

スライスの値をGDBでみるにあたり、シンプルな例として次のようなコードを使います。

package main

func main() {
  s := []int{1, 2, 3}
  _ = s
}

GoのバイナリをGDBから扱いやすくするため、次のようにgcflagsオプションをつけてビルドします。

go build -gcflags "-N -l" slice1.go

GDBを起動して、スライスの値をみてみます。// 以降の文字は後で書き足しています。

% gdb ./slice1
...省略...
(gdb) break main.main  // main関数にbreakpointを設置
Breakpoint 1 at 0x2040: file /Users/yagami/src/go-lib/src/github.com/ks888/hello/slice1.go, line 3.
(gdb) run  // 実行
Starting program: /Users/yagami/src/go-lib/src/github.com/ks888/hello/slice1
[New Thread 0x1313 of process 64589]
[New Thread 0x1403 of process 64589]
[New Thread 0x1503 of process 64589]

Breakpoint 1, main.main () at /Users/yagami/src/go-lib/src/github.com/ks888/hello/slice1.go:3
3       func main() {  // breakpointで止まった行
(gdb) next  // 次の行へ
4         s := []int{1, 2, 3}
(gdb) next  // 次の行へ
6       }  // 今はこの行の実行前
(gdb) print &s  // 変数sのアドレスを確認
$1 = (struct []int *) 0xc820035f30
(gdb) x/3gx 0xc820035f30  // 変数sの値を確認(1)
0xc820035f30:   0x000000c820035f10      0x0000000000000003
0xc820035f40:   0x0000000000000003
(gdb) x/3gx 0xc820035f10  // arrayの中身を確認
0xc820035f10:   0x0000000000000001      0x0000000000000002
0xc820035f20:   0x0000000000000003

(1)の結果から、変数sに配列へのポインタ、長さ、キャパシティが格納されているのがわかります。

スライスを関数の引数にする

次に、関数の引数にスライスを与えたとき、スライスがどうなるか確認してみます。次のコードを使います。

package main

func add4(s []int) []int {
  s = append(s, 4)
  return s
}

func main() {
  s := []int{1, 2, 3}
  s = add4(s)
}

ビルドして、GDBを起動します。

% gdb ./slice2
...省略...
(gdb) break main.main  // main関数にbreakpointを設置
Breakpoint 1 at 0x2170: file /Users/yagami/src/go-lib/src/github.com/ks888/hello/slice2.go, line 8.
(gdb) run  // 実行
Starting program: /Users/yagami/src/go-lib/src/github.com/ks888/hello/slice2
[New Thread 0x1313 of process 68743]
[New Thread 0x1403 of process 68743]
[New Thread 0x1503 of process 68743]

Breakpoint 1, main.main () at /Users/yagami/src/go-lib/src/github.com/ks888/hello/slice2.go:8
8       func main() {  // breakpointで止まった行
(gdb) next  // 次の行へ
9        s := []int{1, 2, 3}
(gdb) next  // 次の行へ
10        s = add4(s)  // 今はこの行の実行前
(gdb) print &s  // main関数内の変数sのアドレスを確認
$1 = (struct []int *) 0xc820035f30
(gdb) step  // add4関数に入る
main.add4 (s=..., ~r1=...) at /Users/yagami/src/go-lib/src/github.com/ks888/hello/slice2.go:3
3       func add4(s []int) []int {
(gdb) next  // 次の行へ
4         s = append(s, 4)
(gdb) next  // 次の行へ
5         return s  // 今はこの行の実行前
(gdb) print &s  // add4関数内の変数sのアドレスを確認
$3 = (struct []int *) 0xc820035ee0
(gdb) x/3gx 0xc820035ee0  // add4関数内の変数sの値を確認(3)
0xc820035ee0:   0x000000c82000e060      0x0000000000000004
0xc820035ef0:   0x0000000000000006
(gdb) x/3gx 0xc820035f30  // main関数内の変数sの値を確認(4)
0xc820035f30:   0x000000c820035f10      0x0000000000000003
0xc820035f40:   0x0000000000000003

ポイントとしては、add4関数内の変数sの値(3)と、main関数内の変数sの値(4)が異なっているところです。add4関数内の変数sは、appendによりlenが4になっています。また、append時に配列の再確保が行われ、caparrayの値も変わっています。一方で、main関数内の変数sはそのままです。ここから、関数の引数にスライスを与えると、スライスの値はコピーされることがわかります。

文字列の値

ついでに文字列の値もみてみます。文字列は次のような構造体をもっています(ソース)。

type stringStruct struct {
    str unsafe.Pointer
    len int
}

strは配列へのポインタ、lenは配列の長さです。

今度は次のコードを使います。

package main

func main() {
  str := "Hello World"
  _ = str
}

ビルドして、GDBで見てみます。

% gdb ./string
(gdb) break main.main  // main関数にbreakpointを設置
Breakpoint 1 at 0x2040: file /Users/yagami/src/go-lib/src/github.com/ks888/hello/string.go, line 3.
(gdb) run  // 実行
Starting program: /Users/yagami/src/go-lib/src/github.com/ks888/hello/string
[New Thread 0x1313 of process 74658]
[New Thread 0x1403 of process 74658]
[New Thread 0x1503 of process 74658]

Breakpoint 1, main.main () at /Users/yagami/src/go-lib/src/github.com/ks888/hello/string.go:3
3       func main() {  // breakpointで止まった行
(gdb) next  // 次の行へ
4         str := "Hello World"
(gdb) next  // 次の行へ
6       }
(gdb) print &str  // 変数strのアドレスを確認
$2 = (struct string *) 0xc82003bf38
(gdb) x/2gx 0xc82003bf38  // 変数strの値を確認
0xc82003bf38:   0x0000000000071ca0      0x000000000000000b
(gdb) x/2gx 0x0000000000071ca0  // 変数str内のポインタの値を確認
0x71ca0 <go.string.*+8024>:     0x6f57206f6c6c6548      0x0000000000646c72

変数strの値をみると、配列へのポインタ(0x0000000000071ca0)と、配列の長さ(0x000000000000000b)が格納されています。また、ポインタの指す先をみてみると、リトルエンディアンな上16進数で見づらいですが、Hello Worldの文字列が確認できます。

感想

こういう風に理解しておくと、スライスで迷いにくくなるのでは、と期待しています。次はインターフェースがメモリ上でどう表現されているかについて書こうと思います。

参考にした記事