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であったため、windowsとmacで環境構築方法を分けるのもモダンでない気がしたので、この際書いてみました。
実際にやってみて
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は初日から開発効率をあげられるものだと感じたし、今後コンテナ化は不可逆的な流れだと感じています。