GameWith Engineering Blog

GameWith のエンジニア、デザイナーが技術について日々発信していきます。

CircleCI 2.0 への移行の軌跡 - Sunsetting 1.0 -

ご無沙汰しております。GameWith でエンジニアマネージャーをしている @serima です。

8 月は弊社エンジニアが代わる代わる技術ブログを執筆してくれたので、出番が少なかったです。(とても嬉しい…)

まだ、読んでない方は是非こちらの記事もご覧ください。

CircleCI のレクイエム

さて、いよいよ 2018 年 8 月 31 日を迎えましたが、本日は何の日か覚えてますでしょうか?

はい、そうです。CircleCI 1.0 の終了日です。

f:id:serimaryo:20180831121832p:plain

circleci.com

こちらのサイトでは、CircleCI 1.0 のビルド終了までの時刻がカウントダウンされています...。

ちなみに GameWith には約 50 個のプライベートリポジトリが存在するのですが、プロジェクトで利用している CircleCI は先週ですべて CircleCI 2.0 への移行が完了しました。

もちろん全プロジェクトで CircleCI を使っているわけではないので、移行作業自体はそこまで大変ではありませんでした。

移行完了までの時系列

ここでは、最も主となるプロジェクトにおいての移行話を書きたいと思います。

2017 年 10 月頃

そもそもビルドに 3 分半〜 4 分ほどかかっていて、高速化したいねという話は以前から上がっていました。

また、CircleCI 2.0 にいずれ移行しなければならないのであれば、ギリギリになって対応するのではなく多少余裕を持って移行しておきたいという気持ちもあり、このタイミングで移行を行うことにしました。

以前、CircleCI 2.0 の resource_class で CPU と RAM のリソースを変更してみるという記事を公開しましたが、ちょうどその頃に得た知見でした。

Ansible + Packer で AMI を構築しているのですが、本来であれば Docker image はその資源を再利用して作成するのが良かったはずです。

ですが、このタイミングでは私自身が Docker を触るのがほぼ初めてだったこともあり、経験を積むという事も兼ねて Dockerfile からイメージを作成してみることにしました。

その際の Dockerfile がこちらです。 基本的に、依存関係はすべてひとつのコンテナに載せてしまう方法をとっています。 主に MySQL や memcached のコンテナを別で立てた場合の unit test 実行時のレイテンシを懸念しました。

また、弊社サービスでは一部 zstd を利用して圧縮を行っている箇所があることから、zstd のインストールも行っています。

(当時は NodeJS のバージョンが古かったのですが、現在は 8.x 系へバージョンが上がっています)

FROM ubuntu:14.04

RUN apt-get -y update && \
  apt-get install -y --no-install-recommends \
    apt-file \
    software-properties-common

RUN apt-file update && \
  LC_ALL=C.UTF-8 add-apt-repository -y ppa:ondrej/php

RUN echo 'mysql-server-5.6 mysql-server/root_password password' | debconf-set-selections && \
  echo 'mysql-server-5.6 mysql-server/root_password_again password' | debconf-set-selections

RUN apt-get -y update && \
  apt-get install -y --no-install-recommends \
    build-essential \
    php5.6 \
    php5.6-mcrypt \
    php5.6-mbstring \
    php5.6-curl \
    php5.6-cli \
    php5.6-mysql \
    php5.6-memcached \
    php5.6-redis \
    php5.6-xml \
    php5.6-dev \
    memcached \
    redis-server \
    mysql-server-5.6 \
    git \
    unzip \
    nodejs \
    npm \
  && \
  apt-get clean && \
  rm -rf /var/lib/apt/lists/*

RUN npm cache clean && \
  npm install n -g && \
  n 4.3.0

RUN apt-get purge -y nodejs npm

RUN git clone -b 0.4.5 --recursive --depth=1 https://github.com/kjdev/php-ext-zstd.git && \
  cd php-ext-zstd && phpize && ./configure && \
  make && make test NO_INTERACTION=1 && \
  make install && \
  echo "extension=zstd.so" > /etc/php/5.6/apache2/conf.d/25-zstd.ini && \
  echo "extension=zstd.so" > /etc/php/5.6/cli/conf.d/25-zstd.ini && \
  cd - && rm -rf php-ext-zstd

RUN curl -sS https://getcomposer.org/installer | sudo php -- --install-dir=/usr/local/bin --filename=composer

COPY my.cnf /etc/my.cnf
[mysqld]
pid-file = /var/run/mysqld/mysqld.pid
socket = /var/run/mysqld/mysqld.sock
datadir = /var/lib/mysql
log-error = /var/log/mysql/error.log
bind-address = 127.0.0.1
innodb_flush_log_at_trx_commit = 2
sync_binlog = 0
innodb_use_native_aio = 0
character-set-client-handshake = FALSE
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci

 [client]
default-character-set = utf8mb4

 [mysql]
default-character-set = utf8mb4

そして、こちらが .circleci/config.yml です。

この時点では、Workflows を利用した並列実行は主に下記 2 つの理由から行いませんでした。

  • Workflow 化した場合、Slack への通知がそれぞれのジョブ実行が別々で通知され、ビルド全体が pass したのか fail したのかが若干分かりづらかった
  • 2 並列実行で 2 連続 commit & push が行われた場合に、3 コンテナが利用できる課金プランだと、結果的には後から実行されたビルドの 1 job は待ち状態になってしまうため、スループット的にはあまり旨味がない
defaults: &defaults
  working_directory: ~/gamewith
  docker:
    - image: 305431254543.dkr.ecr.us-west-1.amazonaws.com/gamewith/ci:php5.6
      aws_auth:
        aws_access_key_id: XXXXX # ecr-docker-puller
        aws_secret_access_key: $ECR_AWS_SECRET_ACCESS_KEY

checkout: &checkout
  run:
    name: Checkout repository
    command: |
      echo "machine github.com login $GITHUB_TOKEN" > ~/.netrc
      repository_https_url=$(echo $CIRCLE_REPOSITORY_URL | sed -e "s|git@github.com:|https://github.com/|")
      git clone --depth=1 --branch $CIRCLE_BRANCH --single-branch $repository_https_url .
      git reset --hard $CIRCLE_SHA1 || (echo "The branch was updated after the build was executed. Please rebuild." 1>&2; false)
version: 2
jobs:
  build:
    <<: *defaults
    steps:
      - <<: *checkout
      - run:
          name: Start services
          command: /bin/bash .circleci/shell/start_services.sh
      - run:
          name: Ping to services
          command: |
            mysqladmin ping
            nc -v -w 1 localhost -z 11211
            redis-cli ping
      - restore_cache:
          keys:
            - v1-composer-dependency-{{ .Environment.COMMON_CACHE_KEY }}-{{ checksum "composer.lock" }}
            - v1-composer-dependency-{{ .Environment.COMMON_CACHE_KEY }}-
      - run: composer install --prefer-dist
      - save_cache:
          key: v1-composer-dependency-{{ .Environment.COMMON_CACHE_KEY }}-{{ checksum "composer.lock" }}
          paths:
            - fuel/vendor
      - restore_cache:
          keys:
            - v1-npm-dependency-{{ .Environment.COMMON_CACHE_KEY }}-{{ checksum "package.json" }}
            - v1-npm-dependency-{{ .Environment.COMMON_CACHE_KEY }}-
      - run: npm install
      - save_cache:
          key: v1-npm-dependency-{{ .Environment.COMMON_CACHE_KEY }}-{{ checksum "package.json" }}
          paths:
            - node_modules
      - run:
          name: php oil refine db:setup
          command: FUEL_ENV=test php oil refine db:setup
      - run:
          name: php oil test --group=App
          command: FUEL_ENV=test php oil test --group=App
      - run:
          name: ./node_modules/.bin/jshint js/
          command: ./node_modules/.bin/jshint js/
          when: always # 現状、php oil test は意図せず fail することもあるため、常に実行するように
      - run:
          name: ./tools/test/asset.sh
          command: ./tools/test/asset.sh
          when: always

また、以下が .circleci/shell/start_services.sh でして、MySQL などの service が running になるまで施行するというスクリプトになっています。

#!/bin/bash -eo pipefail

MAX_WAIT_TIME=4

# MySQL
WAIT_TIME_MYSQL=0
until [ $WAIT_TIME_MYSQL -eq $MAX_WAIT_TIME ]; do
  /etc/init.d/mysql start
  if mysqladmin ping >/dev/null; then
    echo "MySQL is running!"
    break
  fi
  echo "MySQL not running, trying to kick start it"
  sleep $WAIT_TIME_MYSQL
  let WAIT_TIME_MYSQL=WAIT_TIME_MYSQL+1
done

# start memcached
WAIT_TIME_MEMD=0
until [ $WAIT_TIME_MEMD -eq $MAX_WAIT_TIME ]; do
  /etc/init.d/memcached start
  if nc -v -w 1 localhost -z 11211 >/dev/null; then
    echo "memcached is running!"
    break
  fi
  echo "memcached not running, trying to kick start it"
  sleep $WAIT_TIME_MEMD
  let WAIT_TIME_MEMD=WAIT_TIME_MEMD+1
done

# start redis-server
WAIT_TIME_REDIS=0
until [ $WAIT_TIME_REDIS -eq $MAX_WAIT_TIME ]; do
  /etc/init.d/redis-server start
  if redis-cli ping >/dev/null; then
    echo "Redis is running!"
    break
  fi
  echo "Redis not running, trying to kick start it"
  sleep $WAIT_TIME_REDIS
  let WAIT_TIME_REDIS=WAIT_TIME_REDIS+1
done

if [ $WAIT_TIME_MYSQL -eq $MAX_WAIT_TIME ] || [ $WAIT_TIME_MEMD -eq $MAX_WAIT_TIME ] || [ $WAIT_TIME_REDIS -eq $MAX_WAIT_TIME ]; then
  echo "Maximum wait time has been exceeded"
  exit 1
fi

これで移行は完了です。約 3 分でビルドが完了するようになりました。

f:id:serimaryo:20180831103709p:plain

2018 年 3 月頃

その後しばらくして、CircleCI の課金プランを変更したこともあり、そろそろ並列化してもよいのではという機運がやってきました。

その際の .circleci/config.yml がこちらです。

defaults: &defaults
  working_directory: ~/gamewith
  docker:
    - image: 305431254543.dkr.ecr.us-west-1.amazonaws.com/gamewith/ci:0.0.1
      aws_auth:
        aws_access_key_id: XXXXX # ecr-docker-puller
        aws_secret_access_key: $ECR_AWS_SECRET_ACCESS_KEY

checkout: &checkout
  run:
    name: Checkout repository
    command: |
      echo "machine github.com login $GITHUB_TOKEN" > ~/.netrc
      repository_https_url=$(echo $CIRCLE_REPOSITORY_URL | sed -e "s|git@github.com:|https://github.com/|")
      git clone --depth=1 --branch $CIRCLE_BRANCH --single-branch $repository_https_url .
      git reset --hard $CIRCLE_SHA1 || (echo "The branch was updated after the build was executed. Please rebuild." 1>&2; false)
version: 2
jobs:
  build_php:
    <<: *defaults
    steps:
      - <<: *checkout
      - run:
          name: Start services
          command: /bin/bash .circleci/shell/start_services.sh
      - run:
          name: Ping to services
          command: |
            mysqladmin ping
            nc -v -w 1 localhost -z 11211
            redis-cli ping
      - restore_cache:
          keys:
            - v1-composer-dependency-{{ .Environment.COMMON_CACHE_KEY }}-{{ checksum "composer.lock" }}
            - v1-composer-dependency-{{ .Environment.COMMON_CACHE_KEY }}-
      - run: ./composer.phar install --prefer-dist
      - save_cache:
          key: v1-composer-dependency-{{ .Environment.COMMON_CACHE_KEY }}-{{ checksum "composer.lock" }}
          paths:
            - fuel/vendor
      - run:
          name: php oil refine db:setup
          command: FUEL_ENV=test php oil refine db:setup
      - run:
          name: php oil test --group=App
          command: FUEL_ENV=test php oil test --group=App
      - run:
          name: php oil test --group=NativeApp
          command: FUEL_ENV=test php oil test --group=NativeApp
  build_js:
    <<: *defaults
    steps:
      - <<: *checkout
      - restore_cache:
          keys:
            - v1-npm-dependency-{{ .Environment.COMMON_CACHE_KEY }}-{{ checksum "package.json" }}
            - v1-npm-dependency-{{ .Environment.COMMON_CACHE_KEY }}-
      - run: npm install
      - save_cache:
          key: v1-npm-dependency-{{ .Environment.COMMON_CACHE_KEY }}-{{ checksum "package.json" }}
          paths:
            - node_modules
      - run:
          name: ./node_modules/.bin/jshint js/
          command: ./node_modules/.bin/jshint js/
      - run:
          name: ./tools/test/asset.sh
          command: ./tools/test/asset.sh

workflows:
  version: 2
  build_php_and_js:
    jobs:
      - build_php
      - build_js

これによって、2 分〜 2 分半ほどでビルドが完了するようになりました。

f:id:serimaryo:20180831122024p:plain

2018 年 6 月頃

さて、次は脱 CI 専用 Dockerfile です。

既存の資源を活かし、Ansible で Docker image を作成するように変更しました。 ビルド時間の削減には効きませんでしたが、本番環境と同じ足回りでビルドを実行するというあるべき姿になりました。

また副次的な効果として開発環境の Docker 化の一歩目が始まりました。

苦労・工夫した点

  • DockerHub を利用せず、下記理由から Amazon ECS を利用します
    • 弊社では基本的に AWS を利用しています
    • DockerHub の複数人利用には organization を利用するのがベストですが、 private repo を作成するには課金が必要 (この理由が一番大きい)
    • pull するアカウントとして共通アカウントのようなものを用意しないといけない
  • ECS のリージョンは、ap-northeast-1 でなく、us-west-1 を利用しています
    • CircleCI 自体の実行環境が展開されているのがおそらく米国なので、ap-northeast-1 だと docker pull の速度が遅い
    • 有意な回数の検証は出来てないのでもしかしたら偶然かもしれません
  • リポジトリのチェックアウトは、shallow clone で行っています。CircleCI が提供する checkout ステップは純粋に git clone & fetch -> キャッシュ化しているだけなので、リポジトリが巨大だと非常に遅いです
  • 速度改善のため、ssh でなく、https で clone してます
  • 環境変数 COMMON_CACHE_KEY はこれまたクラウドワークスさんのやり方を真似たものです
    • キャッシュに万が一不整合が起きた時に、こちらの環境変数を動的に変更することで、全ブランチのキャッシュリフレッシュを可能にします
  • my.cnf をカスタマイズしないと php oil test が 1.0 の頃より 2 倍弱遅くなってしまいました
  • うまくビルドが通らないときなどは config.yml を修正 -> commit -> push を繰り返してしまいがちですが、思い切って最初から CircleCI の job に ssh してコマンドの実行手順を固めましょう
    • 何が原因でうまくパスしないのか、原因の切り分けをしていくのが結果的には早いです
      • CircleCI の環境依存なのか?
      • 実行に必要なミドルウェアがインストールされていない?
      • たとえば database への接続をするための config 自体が誤っているのか?

移行のちょっとしたヒント

まだ 2.0 への移行が済んでいないプロジェクトのオーナーの方々へ。

公式ドキュメントがとても充実しているので、まずはそこをあたるのが良いでしょう。 各言語ごとに Getting Started が用意されていたりするので、そこを足がかりに少しずつやってみると良いでしょう。

circleci.com

なお、job に ssh する方法はこちらです。

circleci.com

2.0 が出始めた当初、日本語のドキュメントや知見の公開は主にクラウドワークスさんや SmartHR さん、Tokyo Otaku Mode さんの技術ブログで行われていました。 しかし、今では日本語での移行体験談や tips もずいぶん多く公開されています。

壁にぶち当たったら、その都度愚直にググっていけば参考文献は見つかるはずです。

エピローグ

CircleCI は 2.0 の public beta の公開から約1年半という期間を経て、本日をもって 1.0 の終了となります。 運営側は、かなり前から事前告知を行い、我々開発者に対して寄り添ってきてくれました。

基本的には 2.0 への移行はメリットしかないと思うので、どこか早いタイミングでリソースを捻り出して、恩恵を受けられると良いのではないかと思っています。

今回紹介した 2.0 移行への軌跡は GameWith の主となるプロジェクトについてのお話でした。 GameWith のネイティブアプリや、サブプロジェクト、新規事業でも CircleCI を使い倒しているのですが、そちらの話は今回は割愛させていただきました。

GameWith では業務効率化のために CI/CD 周りにも力を割いています。

お決まり文句で恐縮ですが、興味のある方は以下からでも良いですし、私に直接 DM でもよいのでご連絡いただけると嬉しいです。

www.wantedly.com