docker でGAE + Python2.7環境を構築し、ついでにCircleCI2.0で動かす

なぜdockerを使うのか

GAEのStandard EnvironmentsではPython2.7を使うことでインフラ環境をセットアップするコストなく稼働するサービスをすぐに立ち上げられます。また展開されたアプリケーションはトラフィックの量に応じて自動でサーバの台数(インスタンス)が増えたり減ったりするので、サービスの可用性を担保するのに必要な開発・運用コストもがくっと小さいのが特徴です。

Docker Containerとして振る舞うGAE Flexible Environmentsはdockerの設定などを意識する必要がありますが、Standardでは基本的に不要です。 しかし、以下のような都合で今回dockerを用意することにしました。

1. Circle CIへの対応

Circle CIは1.0と2.0があり、2.0は標準でdockerの設定を使えるのが特徴です。逆にいうと利用にはdockerでサーバ構築手順をコード化することが必須です。

2. windowsを使う同僚が入った

私は開発ではMacBookを利用していますが、同僚がwindowsであったため、windowsmacで環境構築方法を分けるのもモダンでない気がしたので、この際書いてみました。

実際にやってみて

dockerを体系的に実務で使ったのは初めてでしたが(いままで誰かが書いたdocker-composeを引き継いでコマンドだけ動かす程度はやっていました)、概念を理解すれば思った以上に簡単で便利でした。 デバッグ時にREPLを立ち上げるとdocker環境が挟まることで入力時の補完が効かない(Ctrl + R, ↑とか) とかがあったものの、概ね快適です。

作業手順

appengineのDockerfileを作る

まずappengine環境(python + GCPのライブラリ群)が入ったイメージを作っていきます。Dockerfileを書きます。

.docker/appengine/Dockerfile の中身が以下。

FROM debian:jessie-slim
LABEL mantainer "DevTokyo, Inc"

ENV PATH=/usr/lib/google-cloud-sdk/bin:/usr/lib/google-cloud-sdk/platform/google_appengine:$PATH
ENV PYTHONPATH=$PYTHONPATH:/usr/local/lib/python2.7/site-packages
RUN cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime

ARG APT_MIRROR=httpredir.debian.org
RUN sed -ri "s/(deb|httpredir).debian.org/${APT_MIRROR}/g" /etc/apt/sources.list
RUN apt-get -y update && apt-get -y upgrade
RUN apt-get install -yqq --no-install-suggests \
  curl \
  gcc \
  git \
  libc6-dev \
  make \
  openssh-client \
  build-essential \
  python-setuptools \
  libatlas-base-dev \
  libssl-dev \
  gfortran \
  libffi-dev \
  libxml2-dev \
  libxslt1-dev \
  zlib1g-dev \
  libpng-dev \
  libfreetype6-dev \
  ncurses-dev \
  libncurses5-dev \
  g++ \
  sqlite3 \
  libsqlite3-dev \
  libhdf5-dev \
  python-lxml \
  unzip && \
  rm -rf /var/lib/apt/lists/*

ENV PYTHON_VERSION 2.7.12

RUN set -ex \
  && curl -fSL "https://www.python.org/ftp/python/$PYTHON_VERSION/Python-$PYTHON_VERSION.tar.xz" -o python.tar.xz \
  && curl -fSL "https://www.python.org/ftp/python/$PYTHON_VERSION/Python-$PYTHON_VERSION.tar.xz.asc" -o python.tar.xz.asc \
  && mkdir -p /usr/src/python \
  && tar -xJC /usr/src/python --strip-components=1 -f python.tar.xz \
  && rm python.tar.xz* \
  && cd /usr/src/python \
  && ./configure --enable-shared --enable-unicode=ucs4 \
  && make -j$(nproc) \
  && make install \
  && ldconfig \
  && curl -fSL 'https://bootstrap.pypa.io/get-pip.py' | python2 \
  && pip install --no-cache-dir --upgrade \
  && find /usr/local \
  \( -type d -a -name test -o -name tests \) \
  -o \( -type f -a -name '*.pyc' -o -name '*.pyo' \) \
  -exec rm -rf '{}' + \
  && rm -rf /usr/src/python


RUN curl https://sdk.cloud.google.com | bash -s -- --disable-prompts --install-dir=/usr/lib && \
  gcloud config set core/disable_usage_reporting true && \
  gcloud config set component_manager/disable_update_check true && \
  gcloud config set metrics/environment github_docker_image && \
  \
  gcloud components install \
                    app-engine-python \
                    beta \
                    app-engine-python-extras
RUN chmod +x \
  /usr/lib/google-cloud-sdk/platform/google_appengine/dev_appserver.py \
  /usr/lib/google-cloud-sdk/platform/google_appengine/remote_api_shell.py \
  /usr/lib/google-cloud-sdk/platform/google_appengine/appcfg.py \
  /usr/lib/google-cloud-sdk/platform/google_appengine/backends_conversion.py \
  /usr/lib/google-cloud-sdk/platform/google_appengine/bulkload_client.py \
  /usr/lib/google-cloud-sdk/platform/google_appengine/bulkloader.py \
  /usr/lib/google-cloud-sdk/platform/google_appengine/download_appstats.py \
  /usr/lib/google-cloud-sdk/platform/google_appengine/endpointscfg.py

ENV APP_HOME /devtokyo
RUN mkdir $APP_HOME
WORKDIR $APP_HOME
VOLUME $APP_HOME

Dockerfileは少し書いたら

$ docker build .docker/appengine -t test

とすればちゃんとビルドできるかテストできます。Dockerfileの中身を解析してSTEPごとに実行していくので、すでにパスしたSTEPであればキャッシュを利用して高速にビルドできます。なのであまり変更がないものを上にもって来ると良いでしょう。

docker-compose.ymlを作る

docker-composeはDockerのプロセスをまとめて管理してくれるサービスです。典型的にはRuby(Rails)のサーバのプロセス、MySQLのプロセス、memcached、nodeなど、ウェブサービス開発で一通り必要なプロセスを分割統治できるようになります。 詳しく知らないのですがDockerはミドルウェアごとにプロセスを立ち上げ、それをまとめて管理するのが良い設計のようです。そうすることで、ミドルウェアごとの依存関係を考慮せずに済み、memcacheやnodeなどそれぞれのイメージを公式が配布しているものなど好きなように選択できます。

今回、nodeのサービスを追加してdocker-compose.ymlを作りました。

version: '3'
services:
  appengine:
    build:
      context: ./
      dockerfile: .docker/appengine/Dockerfile
    command: bash -c "dev_appserver.py front.local.yaml admin.local.yaml task.local.yaml --host=0.0.0.0 --enable_host_checking=false --datastore_path=data/datastore"
    stdin_open: true
    tty: true
    ports:
      - "8080:8080"
      - "8081:8081"
      - "8082:8082"
      - "8000:8000"
    volumes:
      - .:/devtokyo
      - lib:/devtokyo/lib
    container_name: appengine
    image: appengine

  node:
    build:
      context: ./
      dockerfile: .docker/node/Dockerfile
    command: yarn start
    volumes:
      - .:/devtokyo
      - node_modules:/devtokyo/node_modules
    ports:
      - "4001:4001"
    container_name: node
    image: node

  go:
    build:
      context: ./
      dockerfile: .docker/go/Dockerfile
    command: go run main.go
    volumes:
      - .:/devtokyo

volumes:
  lib:
    driver: local
  node_modules:
    driver: local

これであとは

$ docker-compose run --rm node yarn start
$ docker-compose run --rm appengine pip install -t lib -r requirements.txt

$ docker-compose up -d

のように各サービスごとにコマンドを実行したり、サービスをまとめて展開できます。

circleCIの設定

circleCIでは .circle/config.yml というファイルを作ります。

version: 2
jobs:
  build:
    machine: true
    working_directory: ~/devtokyo
    steps:
      - checkout
      - restore_cache:
          key: docker-{{ checksum "docker-compose.yml" }}-{{ checksum ".docker/appengine/Dockerfile" }}
          paths: ~/caches/images.tar
      - run:
          name: Check cache file, if not exists then pull images and generate cache.
          command: |
            if [ ! -f ~/caches/images.tar ]; then
              docker-compose build appengine
              mkdir -p ~/caches
              docker save -o ~/caches/images.tar $(docker history -q appengine | tail -n +2 | grep -v \<missing\> | tr '\n' ' ')
            else
              docker load -i ~/caches/images.tar
            fi
      - run: docker-compose build appengine
      - save_cache:
          key: docker-{{ checksum "docker-compose.yml" }}-{{ checksum ".docker/appengine/Dockerfile" }}
          paths: ~/caches/images.tar
      - restore_cache:
          key: pip-{{ checksum "requirements.txt" }}
          paths: ~/caches/pip.tar
      - run:
          name: pip install
          command: |
            if [ ! -f ~/caches/pip.tar ]; then
              docker-compose run appengine pip install -t lib -r requirements.txt
              # 圧縮
              tar -cvf ~/caches/pip.tar -C ~/devtokyo lib
            else
              # 解凍
              tar -xvf ~/caches/pip.tar -C ~/devtokyo/
            fi
      - run:
          name: put service account file
          command: |
            mkdir -p ~/devtokyo/cert
            echo $SERVICE_ACCOUNT_DEV | base64 -d > ~/devtokyo/cert/devtokyo-dev.json
      - run:
          name: Run tests
          command: docker-compose run appengine python -m unittest discover

いろいろありますが上から見てきます。 まずcircleCIはこのファイルを見て、上からステップを実行していきます。 restore_cache は文字通り、キャッシュがあればそれを復元します。キャッシュは任意に作れますが、一番はDockerのビルドをキャッシュすることです。これがあるのとないので、毎回Dockerのビルドが必要になってしまうと5,6分は余計にかかってしまいます。 もちろんDockerfileやdocker-composeに変更があったときはキャッシュを捨ててほしいので、 circleCI側で提供している checksum という関数にファイル名を渡してやります。これでファイルに変更があった際にキャッシュのキーが変わるようです。 cacheが読み込めていれば、 docker-compose build appengine は高速に終了します。

次に時間がかかるのはpipライブラリのインストールです。これもキャッシュがないとけっこう時間がかかります。本当は -t lib を使わなければ requirements.txt の変更分だけをインストールすることができるようなのですが、tオプションがある場合だとpipコマンドがうまく対応できないみたいです。なのでrequirements.txt をキャッシュのキーにして、変更がない際はスキップするようにしました。

次に、以下のようなコードでサービスアカウントを書き込んでいます。

mkdir -p ~/devtokyo/cert
 echo $SERVICE_ACCOUNT_DEV | base64 -d > ~/devtokyo/cert/devtokyo-dev.json

これはサービスアカウントの鍵ですが、アカウントの権限によっては強力な権限を持っているわけなので、一般的にはレポジトリから除外します。今回実装しているサービスではこの鍵を使ってBigQueryに接続する部分があり、それがテスト環境でも必要だったので環境変数から鍵のファイルを作っています。

このへんを参考にしました。 medium.com

で、ようやく最後にテストを実行するコマンドがあります。

command: docker-compose run appengine python -m unittest discover

まとめ

一見するとハードルは高いようですが、Dockerfileの作成から地道に少しずつやってみると意外と簡単でした。あまり知識がない状態からでも2日程度で済みました。 インフラのコード化とかテストとか、開発のための開発という気がしていて避けていたのですが、Dockerは初日から開発効率をあげられるものだと感じたし、今後コンテナ化は不可逆的な流れだと感じています。