ECSとGoとDocker multistage build

Speeeエンジニアの義田(@yoppiblog)です。SpeeeではUZOUというアドネットワークである広告配信システムを開発・運用しています。 今回は広告配信システムを裏で支えるバッチのDocker imageの作り方のtipsをお届けしようと思います。

GoのバッチをECSで動かす

UZOUのシステムは広範囲に渡ってGoを採用しており、広告配信サーバや配信する広告を選定するバッチもGoで書いて運用しています。 UZOUを導入されるメディアさんが増えるにつれて、処理するデータ量が段階的に増加していくシステムなので、常にバッチサーバを稼働させ続けるよりは、バッチ動作時に必要な台数だけ起動して実行するほうがコストパフォーマンスが良くメンテナンスも不要なため、ECS(Amazon EC2 Container Service)で動作させています。

GoはクロスコンパイルをGoのtool chain上で実行可能なので、開発マシンやデプロイサーバでカジュアルに本番のプラットフォームに合わせて実行ファイルを作成できることが強みです。 しかし、そのバッチでCGOを使っているとクロスコンパイルは基本的に実行できず、本番環境のプラットフォーム上でビルドしなければなりません。 UZOUでは広告配信するにあたり、記事や広告を形態素解析するフェーズがあり、形態素解析器はMeCabを採用しています。そのためMeCabのshared libraryをGoから扱わなければならず、CGOが必要になるというわけです。

GoのDocker imageを作る

方法としては、

  • ECS上で動作するDocker imageにCGOのビルド環境を整備(Goやgcc等)してそこでビルドしてそのまま使用する

といったものが考えられ、愚直に書くと次のようなDockerfileになると思います。

FROM amazonlinux

ENV GOPATH /go
ENV PATH /usr/local/go/bin:/go/bin:$PATH

RUN curl https://storage.googleapis.com/golang/go1.8.3.linux-amd64.tar.gz > /go1.8.3.linux-amd64.tar.gz && \
    tar zxf /go1.8.3.linux-amd64.tar.gz -C /usr/local/ && \
    yum update -y && \
    yum install -y epel-release && \
    rpm -ivh http://packages.groonga.org/centos/groonga-release-1.3.0-1.noarch.rpm && \
    yum install -y \
      mecab \
      mecab-devel \
      mecab-ipadic \
      gcc \
      git \
      make && \
    go get -u github.com/golang/dep/cmd/dep

WORKDIR /go/src/path/to/repository

COPY . /go/src/path/to/repository

RUN go build -o /app app.go

CMD ["/app"]

しかし、この方法はDockerのstageが大きくなるのでなるべく避けたいところですし、Goを採用しているメリットの一つである、実行ファイル(+設定ファイル等)さえあれば動作できる、という点を薄れさせてしまいます。

そこで、

  • ビルド用imageでGoプログラムをビルドし実行ファイルを作成したあと、ECSで動作させるimageビルド時に実行ファイルをCOPYする

方法を採用する人も多いのではと思います。

base/Dockerfile

FROM amazonlinux

RUN yum update -y && \
    yum install -y epel-release && \
    rpm -ivh http://packages.groonga.org/centos/groonga-release-1.3.0-1.noarch.rpm && \
    yum install -y \
      mecab \
      mecab-devel \
      mecab-ipadic

build/Dockerfile

FROM amazonlinux

ENV GOPATH /go
ENV PATH /usr/local/go/bin:/go/bin:$PATH

RUN curl https://storage.googleapis.com/golang/go1.8.3.linux-amd64.tar.gz > /go1.8.3.linux-amd64.tar.gz && \
    tar zxf /go1.8.3.linux-amd64.tar.gz -C /usr/local/ && \
    yum update -y && \
    yum install -y epel-release && \
    rpm -ivh http://packages.groonga.org/centos/groonga-release-1.3.0-1.noarch.rpm && \
    yum install -y \
      mecab \
      mecab-devel \
      mecab-ipadic \
      gcc \
      git \
      make && \
    go get -u github.com/golang/dep/cmd/dep

WORKDIR /go/src/path/to/repository

COPY . /go/src/path/to/repository

RUN go build -o /app app.go

app/Dockerfile

FROM base:latest

COPY ./dest/app /

CMD ["/app"]

docker_build.sh

# 実行ファイルをコンパイル
docker build -f docker/build/Dockerfile -t build .

# 実行ファイルをホストにコピー
docker create --name extract build:latest
docker cp extract:/go/src/github.com/path/to/repository/dest/app dest/app
docker rm -f extract

# ECSで動作させるimageを作成
docker build -f docker/app/Dockerfile -t app .

こうするとDockerfileだけで完結せず、(その部分はshell script化とかすると思うのですが)少し見通しの悪い形になってしまいます。*1

Docker multistage build

そこで、Docker 17.05 から導入されたDocker multistage buildを適用してみましょう。 この機能を使うと、先程のDcokerファイルをひとつにまとめられます。

FROM golang:1.8.3

RUN yum update -y && \
    yum install -y epel-release && \
    rpm -ivh http://packages.groonga.org/centos/groonga-release-1.3.0-1.noarch.rpm && \
    yum install -y \
      mecab \
      mecab-devel \
      mecab-ipadic \
      gcc \
      git \
      make && \
    go get -u github.com/golang/dep/cmd/dep

WORKDIR /go/src/path/to/repository

COPY . /go/src/path/to/repository

RUN go build -o dest/app app.go


FROM base:latest

COPY --from=0 /go/src/path/to/repository/dest/app /app

CMD ["/app"]
  • FROM をDockerfile内に複数書けるようになり、
  • それぞれの FROM は違うベースを指定でき、
  • それぞれのstageは新規で作成され、
  • 各stage間でのファイルのコピーも可能

といった機能を備えているので、今まで分割されていたものが一つになり見通しも良くなったと思います。

ところで、2つめのFROMCOPY--from=0 のように引数を付けて実行しています。これはstageが0番目のもの、つまりDockerfileの先頭の FROM のstageを示しています。 stage数が少ない場合はindex指定でも問題ありませんが、多くなるとindexで指定するのはDockerファイル全体を精査しなければなりませんし、stageを入れ替えた場合indexも再番しなくてはならず運用が逆に手間になるかもしれません。 そのため、各stageに名前を付けられるように実装されています。

FROM golang:1.8.3 as builder

RUN yum update -y && \
    yum install -y epel-release && \
    rpm -ivh http://packages.groonga.org/centos/groonga-release-1.3.0-1.noarch.rpm && \
    yum install -y \
      mecab \
      mecab-devel \
      mecab-ipadic \
      gcc \
      git \
      make && \
    go get -u github.com/golang/dep/cmd/dep

WORKDIR /go/src/path/to/repository

COPY . /go/src/path/to/repository

RUN go build -o dest/app app.go


FROM base:latest

COPY --from=builder /go/src/path/to/repository/dest/app /app

CMD ["/app"]

FROM {base} as {name}as で指定したものがstage名となり、他のstageから参照できるようになります。

まとめ

Docker multistage buildを使ってCGOを使うGoアプリケーションのDockerfileを整理しました。 煩雑になりがちなDockerfileの見通しがよくなったので運用負荷を下げられそうですね。

*1:ちなみに、このようにビルドするものと実行するものとでimageを分けることを builder pattern と呼ぶそうです。