MacBook を Runner として使って GitLab CI / CD する

この記事は [ユニークビジョン株式会社 Advent Calendar 2018](https://qiita.com/advent-calendar/2018/uniquevision) の 14 日目の記事です。

## GitLab CI 、良いと思います
会社では社内で余っていた MacBook Air を Runner として活用して GitLab CI / CD しています。その際に行った設定について書きます。
GitLab CI は[ドキュメント](https://docs.gitlab.com/ee/ci/README.html)も充実しており、yaml ファイルでの設定もシンプルで CI サービスとして基本的な機能は揃っていそうに見えます。オンプレで CI を回したい場合、一考に値するサービスなのではないかと思います。

### 環境
* GitLab CE 10.1.0

#### Runner 環境
* MacBook Air (13-inch, Early 2015) ２台。メモリのみ盛られており 8GB。
* macOS 10.14
* Docker for Mac Version 2.0.0.0-mac81 (29211)
* gitlab-runner 11.4.2 (cf91d5e1)

## config.toml

[本家ドキュメント](https://docs.gitlab.com/runner/install/osx.html)の通りに Runner を登録すると、`~/.gitlab-runner/config.toml` が生成されているのでそれを編集して Runner の設定を行います。現在以下の設定で運用しています。

```toml:~/.gitlab-runner/config.toml
concurrent = 4
check_interval = 0

[session_server]
  session_timeout = 1800

[[runners]]
  name = "MacBook Air (sticker: XXX)"
  url = "https://ourgitlab.example.com/"
  token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
  executor = "docker"
  builds_dir = "/Users/ouruser/Work/builds"
  [runners.docker]
    pull_policy = "if-not-present"
    tls_verify = false
    image = "docker:latest"
    privileged = false
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
    volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock:rw", "/Users/ouruser/Work/builds:/Users/ouruser/Work/builds:rw"]
    shm_size = 0
  [runners.cache]
    [runners.cache.s3]
    [runners.cache.gcs]
```

### dind
CI パイプライン上での処理がホスト PC を汚さないよう、`executor` には docker を指定しました。CI パイプラインでのテストには `docker-compose` コマンドを使用するので、Docker in Docker (dind) となります。[dind には２つの方法がある](https://qiita.com/sugiyasu-qr/items/85a1bedb6458d4573407)ようですが、`/var/run/docker.sock` を executor にマウントすることで、 macOS 上の Docker daemon を共有する方法をとりました。

### Job 実行時にファイルが見つからない問題
`docker-compose run ...` でテストを行ったところ、CI 実行時に `git fetch` したはずのファイルが見つからず、`no such file or directory.` を吐いてテストの実行に失敗する現象に困りました。この現象は `volumes` でビルドに使うディレクトリ（`builds_dir` で指定）と同じフォルダをホスト PC に作成し、 executor 上の同一のパスにマウントすることで回避できました。dind でホスト PC から見ると並列に並んでいるコンテナ同士で親子関係を持つような構造になっており、孫にあたる Rails アプリのコンテナがホスト PC のファイルを参照しようとして親にあたる executor のコンテナのファイルを参照したことで起こったエラーだと思っています。

### 並列数
`concurrent` は 2~8 まで試しましたが 4 に落ち着きました。Docker for Mac の設定では Docker Engine のリソースを 4 CPU と 4GB のメモリに制限しています。

## CI パイプラインの設定
git リポジトリのルートディレクトリに `.gitlab-ci.yml` を作成することで CI パイプラインの設定を行うことができます。
runner 用に Docker イメージを用意しており、`docker-compose` や `kubectl` 等 Job の実行に必要なコマンドを入れています。それを使用することで Job ごとに `apk add` 等が走らないので、 Job にかかる時間が短縮できます。
`docker-compose` コマンドを実行した際に、CI の Job ごとに別々の Docker ネットワークでそれぞれのコンテナ群を動かすために `COMPOSE_PROJECT_NAME` が Job 毎に異なるように環境変数を設定します。また環境変数に `COMPOSE_FILE` を指定することで CI 用の docker-compose ファイルを毎度指定する必要をなくせます。

```yaml:.gitlab-ci.yml
image: ourproject-gitlab-runner:1.0.1
variables:
  COMPOSE_FILE: docker-compose.ci.yml
  COMPOSE_PROJECT_NAME: project$CI_JOB_ID
```

## CD を導入する
CD は以下のような Job を作って実施しています。初めて CD の設定をしたので拙いと思いますが説明します。とりあえず動いています。
`script` を何行にも渡って書いていますが、いずれ他のスクリプト等に切り出したいです。

```yaml:.gitlab-ci.yml
prepare_env_for_deploy:
  stage: prepare_deploy
  only:
    - /^\d+\.\d+\.\d+(\.\d+)?-yet$/
  artifacts:
    paths:
    - $DEPLOY_ENV_FILE
  script:
    - |-
      NEW_VERSION=${CI_COMMIT_TAG%-yet}
      PREV_TAGGED_COMMIT_SHA=$(make get-previous-staging-deployed-commit-sha PREVIOUS_STAGING_DEPLOYED_VER=$(make get-previous-staging-deployed-ver))
      WEB_CHANGED_FILE_COUNT=$(git diff $PREV_TAGGED_COMMIT_SHA --name-only apps/web | wc -l)
      PREV_WEB_IMAGE_VERSION=$(make --no-print-directory -C infra get-previous-web-image-version)
      if [ $WEB_CHANGED_FILE_COUNT -gt 0 ]; then
        WEB_IMAGE_VERSION=$(echo $PREV_WEB_IMAGE_VERSION | $INCREMENT_VER_CMD)
      else
        WEB_IMAGE_VERSION=$PREV_WEB_IMAGE_VERSION
      fi
      echo "export NEW_VERSION=$NEW_VERSION" > $DEPLOY_ENV_FILE
      echo "export WEB_CHANGED_FILE_COUNT=$WEB_CHANGED_FILE_COUNT" >> $DEPLOY_ENV_FILE
      echo "export WEB_IMAGE_VERSION=$WEB_IMAGE_VERSION" >> $DEPLOY_ENV_FILE
      cat $DEPLOY_ENV_FILE

deploy_staging:
  stage: deploy_staging
  tags:
    - wired-network
  dependencies:
    - prepare_env_for_deploy
  script: # Upload docker images -> Deploy CloudFormation -> Apply K8s manifests -> Run SQLs
    - |-
      source $DEPLOY_ENV_FILE
      export ENV=staging
      $DOCKER_COMPOSE_BUILD_WEB_CMD
      $DOCKER_COMPOSE_BUILD_ALL_CMD
      if [ $WEB_CHANGED_FILE_COUNT -gt 0 ]; then
        make -C infra upload-webassets-and-image
      fi
      make -C infra cfn-deploy kubectl-apply
      make -C infra run-sql DB_HOST=$DB_HOST_STAGING
  after_script:
    - docker system prune -f --volumes

git_tag:
  stage: git_tag
  dependencies:
    - prepare_env_for_deploy
  only:
    - /^\d+\.\d+\.\d+(\.\d+)?-yet$/
  script:
    - |-
      source $DEPLOY_ENV_FILE
      git config user.name "GitLab CI"
      git config user.email "gitlabci@example.co.jp"
      mkdir -p ~/.ssh
      echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa
      chmod 600 ~/.ssh/id_rsa
      ssh-keyscan -p $GITLAB_SSH_PORT $GITLAB_HOST >> ~/.ssh/known_hosts
      git tag $NEW_VERSION
      git push ssh://git@$GITLAB_HOST:$GITLAB_SSH_PORT/$CI_PROJECT_PATH.git --tags

deploy_production:
  stage: deploy_production
  only:
    - /^\d+\.\d+\.\d+(\.\d+)?-yet$/
  when: manual
  dependencies:
    - prepare_env_for_deploy
  ...
```

### デプロイのタイミング
master ブランチが更新される度にステージング環境へのデプロイを行いたいところですが、そんなに並列実行数に余裕が無いので `0.0.0.1-yet` のようなフォーマットのタグがついたコミットについてのみステージング環境にデプロイするようにしています。このタグは各エンジニアが簡単なスクリプトを叩くことで push されます。`-yet` は未だデプロイしていないことを現す気分でいます。ステージング環境へのデプロイに成功した後にバージョン番号のみのタグを push します。
本番環境へのデプロイは手動で Job を動かすことで実行します。

### デプロイに必要な変数を保存しておく
`prepare_deploy` という `stage` でデプロイに必要な変数を `artifacts` に保存しておき、デプロイの Job の `dependencies` でそれを読み込むようにしています。こうすることでステージング環境と本番環境へのデプロイ時に同じ変数を参照することができます。

### デプロイ
前回のデプロイから差異があった場合にのみ新しい Docker イメージを作成して push するようにします。
有線 LAN 接続をしている runner には `wired-network` タグを付与しており、デプロイはそのタグがついた runner からのみ行っています。

### 溜まっていく古い Docker リソースを消す
ホスト PC のストレージが枯渇しないように `docker system prune -f --volumes` を実行します。これはストレージが枯渇しない程度の頻度で行えばよいと思っており、適当にステージング環境へのデプロイが終わった後に実行しています。

## これから
来年はパイプラインをもう少しスマートにして、Slack から CD をトリガするなど ChatOps を導入していけたら良いと思っています。
明日は [@nariTota](https://qiita.com/nariTota) さんです。