DockerイメージをGitLab CIでビルドしてRegistryにプッシュするまでを自動化する

DockerイメージをGitLab CIでビルドしてプライベートレジストリにプッシュするまでを自動化する方法を紹介する。

## ディレクトリ構成

ディレクトリ構成はこんなかんじで、dockerfilesディレクトリ配下にDockerfileを置くが、そのディレクトリ構成は`Registryのドメイン名/ユーザ名/リポジトリ名/タグ名`の命名規則に従うようにする。

```
├── .gitlab-ci.yml ... GitLab CI設定ファイル
├── docker-image.sh ... Dockerイメージをビルド&プッシュするスクリプト
└── dockerfiles ... Dockerfile置き場
    └── registry.example.com ... Dockerレジストリ名
        ├── sato ... ユーザ名
        │   ├── mysql ... リポジトリ名
        │   │   └── latest ... タグ名
        │   │       └── Dockerfile
        │   └── php
        │       ├── 7.1
        │       │   └── Dockerfile
        │       └── 7.2
        │           └── Dockerfile
        └── tanaka
            ├── mysql
            │   └── latest
            │       └── Dockerfile
            └── php
                ├── 7.1
                │   └── Dockerfile
                └── 7.2
                    └── Dockerfile
```

## Dockerイメージをビルド&プッシュするスクリプトを作る

`docker-image.sh`というファイル名でDockerイメージをビルドしてプッシュするスクリプトを作る。

### docker-image.shの使い方

* イメージをビルドする
    * `./docker-image.sh build [Dockerファイルがあるディレクトリのパス]`
* ビルド済みのイメージをプッシュする
    * `./docker-image.sh push [Dockerファイルがあるディレクトリのパス]`
* イメージをビルドしてからプッシュする
    * `./docker-image.sh release [Dockerファイルがあるディレクトリのパス]`

```
NAME:
    docker-image.sh - Build and push Docker images

USAGE:
    docker-image.sh command [arguments...]

COMMANDS:
    build    Build a Docker image
    push     Push a Docker image
    release  Build and push a Docker image

NOTE:
    This command determines the docker image name base on directory naming
    convention. Dockerfile must be placed in the structured directory by
    "registry-domain/user-name/repository-name/tag" format like below:

    <registry domain>/
        <user name>/
            <repository name>/
                <tag>/
                    Dockerfile

    For example:

    registry.example.com/
        alice/
            php/
                7.1/
                    Dockerfile
                7.2/
                    Dockerfile
```

### このスクリプトの機能

イメージのタグに自動的にGitのコミットハッシュやGitLab CIのビルド番号をつけてくれる。つまり、いつどこでビルドしたからがわかる。Dockerイメージのイメージ名は次のような規則でつけられる:

```
${registry}/${user}/${repo}:${version}-${git_commit_date}-${git_commit_hash}-${build_job_id}
```

イメージ名の例:

```
registry.example.com/suin/php:7.1-20181121-78daafc-24409
```

Dockerfileが`ARG`でビルド日時などを受け取るようになっていれば、Dockerイメージにメタ情報をLABELでつけることができる。[Label Schema](http://label-schema.org/rc1/)に対応することもできる。このスクリプトに対応したDockerfileの書き方例:

```dockerfile:Dockerfile
FROM php:7.1-alpine

ARG DESCRIPTION="PHP 7.1 environment"
ARG BUILD_DATE
ARG VCS_REF
ARG CI
ARG BUILD_USERNAME
ARG BUILD_JOB_ID
ARG BUILD_JOB_URL

LABEL org.label-schema.description=$DESCRIPTION \
    org.label-schema.build-date=$BUILD_DATE \
    org.label-schema.vcs-ref=$VCS_REF \
    org.label-schema.schema-version="1.0" \
    com.example.ci=$CI \
    com.example.build-username=$BUILD_USERNAME \
    com.example.build-job-id=$BUILD_JOB_ID \
    com.example.build-job-url=$BUILD_JOB_URL
```

ちなみに、つけたLABELは`docker inspect`で参照できる。

```shell-session
$ docker inspect registry.example.com/suin/php:7.1-20181121-24368df-24398 | jq '.[0].Config.Labels'
{
  "com.example.build-job-id": "24398",
  "com.example.build-job-url": "http://gitlab.example.com/suin/devops/-/jobs/24398",
  "com.example.build-username": "suin",
  "com.example.ci": "true",
  "org.label-schema.build-date": "2018-11-21T08:07:12Z",
  "org.label-schema.description": "PHP 7.1 environment",
  "org.label-schema.schema-version": "1.0",
  "org.label-schema.vcs-ref": "24368df"
}
```


### docker-image.shのソースコード

```docker-image.sh
#!/bin/bash

set -eu

readonly APP_NAME=$(basename $0)
readonly CI=${CI:-false}
readonly BUILD_USERNAME=${GITLAB_USER_LOGIN:-$(whoami)}
readonly BUILD_JOB_ID=${CI_JOB_ID:-no_build_job_id}
readonly BUILD_JOB_URL=${CI_JOB_URL:-no_build_job_url}

function get_repository_path {
    local readonly repository_dir=$1
    echo "$(cd ${repository_dir} && pwd)"
}

function get_docker_image_naming_info {
    local readonly context=$1
    echo ${context//\// } | rev | cut -d' ' -f1-4 | rev
}

function get_git_commit_info {
    git log -1 --format='%cd %h' --date=format:'%Y%m%d'
}

function get_docker_image_name {
    local readonly context=$1
    format_docker_image_name \
        $(get_docker_image_naming_info ${context}) \
        $(get_git_commit_info) \
        ${BUILD_JOB_ID}
}

function format_docker_image_name {
    local readonly registry=$1
    local readonly user=$2
    local readonly repo=$3
    local readonly version=$4
    local readonly git_commit_date=$5
    local readonly git_commit_hash=$6
    local readonly build_job_id=$7
    echo "${registry}/${user}/${repo}:${version}-${git_commit_date}-${git_commit_hash}-${build_job_id}"
}

function build_docker_image {
    local readonly context=$1
    local readonly image_name=$2
    docker build \
        -t ${image_name} \
        --build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
        --build-arg VCS_REF=$(git rev-parse --short HEAD) \
        --build-arg CI=${CI} \
        --build-arg BUILD_USERNAME=${BUILD_USERNAME} \
        --build-arg BUILD_JOB_ID=${BUILD_JOB_ID} \
        --build-arg BUILD_JOB_URL=${BUILD_JOB_URL} \
        ${context} || return 1
}

function push_docker_image {
    local readonly image_name=$1
    docker push ${image_name} || return 1
}

function sub_build {
    local readonly HELP=$(cat << EOS
NAME:
   ${APP_NAME} build - Build Docker images

USAGE:
   ${APP_NAME} build [dockerfile_dir]
EOS
)
    local readonly REPOSITORY_DIR=${1:-}
    [[ -z ${1+x} ]] && echo "${HELP}" && return 1

    local readonly REPOSITORY_PATH=$(get_repository_path ${REPOSITORY_DIR})
    local readonly DOCKER_IMAGE_NAME=$(get_docker_image_name ${REPOSITORY_PATH})
    build_docker_image ${REPOSITORY_PATH} ${DOCKER_IMAGE_NAME}
}

function sub_push {
    local readonly HELP=$(cat << EOS
NAME:
   ${APP_NAME} push - Push Docker images

USAGE:
   ${APP_NAME} push [dockerfile_dir]
EOS
)
    local readonly REPOSITORY_DIR=${1:-}
    [[ -z ${1+x} ]] && echo "${HELP}" && return 1

    local readonly REPOSITORY_PATH=$(get_repository_path ${REPOSITORY_DIR})
    local readonly DOCKER_IMAGE_NAME=$(get_docker_image_name ${REPOSITORY_PATH})
    push_docker_image ${DOCKER_IMAGE_NAME}
}

function sub_release {
    local readonly HELP=$(cat << EOS
NAME:
   ${APP_NAME} release - Build and push Docker images

USAGE:
   ${APP_NAME} release [dockerfile_dir]
EOS
)
    local readonly REPOSITORY_DIR=${1:-}
    [[ -z ${1+x} ]] && echo "${HELP}" && return 1

    local readonly REPOSITORY_PATH=$(get_repository_path ${REPOSITORY_DIR})
    local readonly DOCKER_IMAGE_NAME=$(get_docker_image_name ${REPOSITORY_PATH})
    build_docker_image ${REPOSITORY_PATH} ${DOCKER_IMAGE_NAME}
    push_docker_image ${DOCKER_IMAGE_NAME}
}

function main {
    readonly SUBCOMMAND="sub_${1:-}"
    shift || true

    if type "${SUBCOMMAND}" >/dev/null 2>&1 ; then
        ${SUBCOMMAND} "$@"
    else
        cat << EOS
NAME:
    ${APP_NAME} - Build and push Docker images

USAGE:
    ${APP_NAME} command [arguments...]

COMMANDS:
    build    Build a Docker image
    push     Push a Docker image
    release  Build and push a Docker image

NOTE:
    This command determines the docker image name base on directory naming
    convention. Dockerfile must be placed in the structured directory by
    "registry-domain/user-name/repository-name/tag" format like below:

    <registry domain>/
        <user name>/
            <repository name>/
                <tag>/
                    Dockerfile

    For example:

    registry.example.com/
        alice/
            php/
                7.1/
                    Dockerfile
                7.2/
                    Dockerfile
EOS
    fi
}

main "$@"
````

## GitLab CIの設定

buildとreleaseの2つのフェーズを作り、buildはmasterブランチ以外で実行されるようにし、releaseはmasterブランチのみで実行されるように設定しておく。こうしておくと、マージリクエストを受け取ったときは、GitLab CIでdocker buildだけが走り、ビルドが正しくできるかをチェックできる。そして、そのマージリクエストをmasterにマージしたら、もう一度docker buildが走り、最後にdocker pushが実行され、ビルドされたDockerイメージがDocker Registryに登録される。


.gitlab-ci.ymlの設定例:

```yaml:.gitlab-ci.yml
---
image: suin/docker:latest

variables:
  DOCKER_DRIVER: overlay2

services:
  - docker:dind

stages:
  - build
  - release

build:php71:
  stage: build
  script:
    - time ./docker-image.sh build dockerfiles/registry.example.com/tanaka/php/7.1
  except:
    refs:
      - master

build:php72:
  stage: build
  script:
    - time ./docker-image.sh build dockerfiles/registry.example.com/tanaka/php/7.2
  except:
    refs:
      - master

build:php73:
  stage: build
  script:
    - time ./docker-image.sh build dockerfiles/registry.example.com/tanaka/php/7.3
  except:
    refs:
      - master

release:php71:
  stage: release
  script:
    - time ./docker-image.sh release dockerfiles/registry.example.com/tanaka/php/7.1
  only:
    refs:
      - master

release:php72:
  stage: release
  script:
    - time ./docker-image.sh release dockerfiles/registry.example.com/tanaka/php/7.2
  only:
    refs:
      - master

release:php73:
  stage: release
  script:
    - time ./docker-image.sh release dockerfiles/registry.example.com/tanaka/php/7.3
  only:
    refs:
      - master
```