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は初日から開発効率をあげられるものだと感じたし、今後コンテナ化は不可逆的な流れだと感じています。