Docker in Dockerを利用してCIを構築してみた

# はじめに
会社でCI環境を構築するためにGitLab CI/CDを勉強中です。
社内用途ではShared Executorを選択すれば十分なのですが、Dockerを触ってみたかったのでチュートリアルではDocker Executorを選択してみました。
本記事ではDocker Executor上でDockerコンテナを使用した環境構築を行います。
そのため、「Docker Executor上でDockerを使用する = "Docker in Docker(dind)"」を活用していきます。

チュートリアルにはGitLab実践ガイド(著：北山 晋吾さん)を使用しています。
第5章と第6章の内容です。
kindle unlimitedで読めるので、是非読んでみてください。
[GitLab実践ガイド](https://www.amazon.co.jp/dp/B079DL362C/ref=dp-kindle-redirect?_encoding=UTF8&btkr=1)

> なお、本記事では上記書籍のコードを引用している箇所が有ります。著作権侵害の意思は全くなく、あくまで試してみたい方や同じようなエラーに遭遇した方向けに状況や手順を詳細に説明する目的で引用しています。著作権に触れる場合はご指摘頂ければ即刻削除致しますのでご連絡ください。

# こんな人におすすめ
対象読者は以下の通りです。

* Docker in Dockerを使用してGitLab CI立ち上げたい人
* GitLab実践ガイドの読者
* Docker Executor内のDockerコンテナにアクセスできなくて困っている人
* CI環境で本番用のDockerイメージをDockerリポジトリにPushしたい人

# やったこと

Javaで作成したWebアプリケーションを使用してGitLab CI/CDを試していきます。
ゴールはDocker Executor内部でDockerコンテナ上にwebアプリケーションを配置し、Docker ExecutorからWebアプリーケーションにWebアクセスを行うことです。

<img width="300" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314894/5791964a-0ada-427d-56e6-3583bb81b80b.png">

本記事ではGitLab CI/CDのジョブを定義するために利用するgitlab-ci.ymlの構成をメインに記載します。
環境構築やWebアプリケーションの詳細等はGitLab実践ガイドをご覧ください。

# 前提知識
簡単に前提知識をまとめます。

## GitLab CI/CDとは
作成したジョブに従ってGitLabからビルドツールやテストツールと連携したCIを行う機能です。
Pushやマージと連携してCIを回すことができます。

GitLabにはDockerレジストリ機能もありますし、課題としてチケット管理することもできます。
GitLabを使用するメリットはリポジトリ管理、チケット管理、CI/CD、Dockerレジストリ管理まで用途に合わせて一つのシステムで構築できることだと思っています。

以下はGitLab関連の用語解説です。

### GitLab Runner
GitLab CI/CD上からの要求を受けて、実際にビルドしたりテストしたりするプロセスです。
Gitlab RunnerにはShared RunnerとSpecific Runnerの2種類があります。

#### Shared Runner  
複数のプロジェクトのジョブ実行を、全てのプロジェクト共通で使用できるRunnerで処理する方式です。
GitLab.comには予め使用できるRunnerが登録されています。
本記事ではRunnerの構成を一般化するためにShared Runnerを使用します。
<img width="300" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314894/a2df6431-4973-ec6d-1ae6-3045249dcf43.png">

#### Specific Runner
特定のプロジェクトのジョブのみを実行する方式です。
例えば、Specific Runnerであれば「GitLab.comにプロジェクトを展開しながら自身の仮想環境サーバーに構築したRunnerを登録する」といったことができます。
プロジェクト独自のRunnerを用意できるため色々設定をカスタマイズできることが利点です。
<img width="300" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314894/fcc4b73a-a0ea-6b52-91f4-4d7a97f17857.png">

### Executor
Runner上でジョブを実行するための実行方式です。
GitLab.com上のShared Runnerであれば、tag情報としてExecutorの種類が記載されています。
Runnerを登録するときに決定する必要があります。

Executorにはいくつか種類がありますが、ここでは2つだけ紹介します。

#### Shell Executor
Shell ExecutorはRunnerが走っているサーバー上で動作するExecutorです。
例えばLinux系にRunnerをインストールすればデフォルトでBashが動作し、ジョブを実行します。

#### Docker Executor
Docker Executorは、事前に用意したDocker環境のDocker Engineに対してDocker APIを通して接続することで動作するExecutorです。
環境を汚さずに、どのプラットフォーム上でも再現性のあるビルドやテストが実行できることがメリットです。

## Docker in Docker(dind)
Dockerコンテナ内部からDockerコンテナを使用する技術です。
当初「Dockerコンテナ内でDockerコマンド叩けば良いんでしょ」と思ってましたが、意外と難有りでややこしいです。
詳しい説明は以下記事に非常にわかりやすく記載されているため本記事では省略します。
[Dockerコンテナ内からDockerを使うことについて](https://esakat.github.io/esakat-blog/posts/docker-in-docker/)

# 環境

* GitLab動作環境 : GitLab.com
* Runner動作環境 : Shared Runner (shared-runners-manager-3.gitlab.com)

# 手順
## 事前準備
GitLab CI/CDのジョブは「.gitlab-ci.yml」というファイルで定義します。
このファイルに実行したいジョブを書いておき、プロジェクトのトップ階層に置いてコミットします。

今回は動的にジョブ内でDockerイメージをビルドするため、Dickerfileもプロジェクトのアプリケーション内に配置しておきます。

全体的な構成は以下のようになります。  

```
.
├── README.md
├── scripts
│   └── gitlab-ci.txt
└── web_demo
    ├── Dockerfile
    ├── bin
    │   └── （省略） 
    ├── build.gradle
    └── src
        ├── main
        │   └── (省略)
        └── test
            └── (省略)
```

## 共通設定 
最初に全フェーズで共通の設定を記載していきます。

設定パラメータは以下のようになります。

```yml:.gitlab-ci.yml
stages:
  - build
  - packaged
  - test

image: docker:latest

variables:
  APP_NAME: 'web_demo'
  CONTAINER_NAME: ${CI_PROJECT_NAME}_${APP_NAME}
  CONTAINER_IMAGE: ${CI_REGISTRY}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME}/${APP_NAME}
  DOCKER_DRIVER: overlay2

services:
  - docker:dind

```

* stages : 本スクリプトで実行するステージを指定します。
* image : ジョブを実行するExecutorのDockerイメージを指定します。
* services : docker内で使用するDockerサービスを指定します。
* variables : 定数を定義します。
    * APP_NAME : 今回使用するアプリ名
    * CONTAINER_NAME : アプリを配置するコンテナ名
    * CONTIANER_IMAGE : Dockerリポジトリに登録するイメージ名
    * DOCKER_DRIVER : ストレージドライバの選択

ストレージドライバの選択に関する公式ドキュメントはこちら。
今回はチュートリアル目的のため、参考書に従ってoverlay2を選択しました。
[ストレージ・ドライバの選択](http://docs.docker.jp/engine/userguide/storagedriver/selectadriver.html)

## ビルド
以下はビルドステージのジョブ定義です。

```yml:.gitlab-ci.yml
build_web_demo:
  stage: build
  image: gradle:4.4.1-jdk8
  script:
    - cd ./${APP_NAME}
    - gradle war
    - gradle test
  artifacts:
    paths:
      - ${APP_NAME}/build/libs/*.war
    expire_in: 60 min
  tags:
    - docker
    - linux
```


ビルドステージではdindは使用しません。
artifactsとしてビルド成果物を保持しています。
expire_inで指定した時間だけ一時的に保存され、この後のステージで使用することができます。

## パッケージ化
以下はパッケージ化ステージのジョブ定義です。

```yml:.gitlab-ci.yml
packaged_web_app:
  stage: packaged
  before_script:
    - docker login -u "gitlab-ci-token" -p "$CI_BUILD_TOKEN" ${CI_REGISTRY}
  script:
    - cd ./${APP_NAME}
    - docker build . -t ${CONTAINER_IMAGE}
    - docker push ${CONTAINER_IMAGE}
  tags:
    - docker
    - linux
```

Dockerイメージをビルドして、成果物をGitLab上のDockerリポジトリにPushしています。
dind使用時はジョブが変わるとDocker環境が初期化されるため、パッケージ化したイメージをテストステージで再利用するためにDockerリポジトリ使用しています。
Dockerリポジトリを使用しない場合はテストステージでDockerイメージが見つからない旨のエラーが発生します。

```
 $ docker run --name ${CONTAINER_NAME} -p 80:8080 -d ${APP_NAME}
 Unable to find image 'web_demo:latest' locally
 docker: Error response from daemon: pull access denied for web_demo, repository does not exist or may require 'docker login': denied: requested access to the resource is denied.
```

また、Dockerリポジトリは最初にログインしておかなければ'access forbidden'が発生します。  

## テスト

以下はテストプロセスのジョブ定義です。

```yml:.gitlab-ci.yml
test_web_demo:
  stage: test
  before_script:
    - docker login  -u "gitlab-ci-token" -p "$CI_BUILD_TOKEN" $CI_REGISTRY
    - apk update
    - apk add curl
  script:
    - docker run --name ${CONTAINER_NAME} -p 80:8080 -d ${CONTAINER_IMAGE}
    - sleep 10
    - curl docker:80/${APP_NAME}/hello
  tags:
    - docker
    - linux
```


注意するポイントはcurlコマンドのホスト名設定です。
Docker内で動作しているDockerコンテナのIPアドレスを動的に取得し、IPアドレスを指定してアクセスするとTimeoutエラーが発生します。
IPアドレスの取得は成功しているものの、アクセスできていないことがログからわかります。

```
 $ export CONTAINER_ADDRESS=$(docker inspect -f "{{.NetworkSettings.IPAddress}}" 
 ${CONTAINER_NAME})
 $ echo ${CONTAINER_ADDRESS}
 172.18.0.2
 $ curl http://${CONTAINER_ADDRESS}:80/${APP_NAME}/hello
   % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                  Dload  Upload   Total   Spent    Left  Speed
   0     0    0     0    0     0      0      0 --:--:--  0:00:31 --:--:--     0
 curl: (28) Failed to connect to 172.18.0.2 port 80: Operation timed out
```

この問題の解決方法として、ホスト名をdockerに変更します。
GitLabCIではservicesで定義したコンテナにアクセスする場合、定義した時の名前を使用する必要があります。
今回は'docker:dind'と定義していたため、dockerをホスト名として指定します。

以下はトラブルシューティングに活用したURLです。
[Accessing the services](https://docs.gitlab.com/ee/ci/docker/using_docker_images.html#accessing-the-services)
[Docker Container Networking with Docker-in-Docker](https://stackoverflow.com/questions/44830663/docker-container-networking-with-docker-in-docker)

```
 $ curl docker:80/${APP_NAME}/hello
   % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                  Dload  Upload   Total   Spent    Left  Speed
 100    13  100    13    0     0     55      0 --:--:-- --:--:-- --:--:--    55
 Hello, World!
```

以上で無事ビルドステージ、パッケージ化ステージ、テストステージを経てCIが成功しました。


# まとめ
今回はdindを使用してDockerイメージをビルドし、テストまでしてみました。
特にテストステージは調査に苦労したため、参考になったら嬉しいです。

調査した内容や書籍の内容をベースに載せていますが、専門外のため間違っている箇所があったら指摘して頂けたら幸いです。