Go言語とDockerでインフラエンジニア向けのシンプルなCLIツール開発環境を作る

![ドリコム Advent Calendar 2017](https://tech.drecom.co.jp/wp-content/uploads/2017/11/eyecatch_1712AdventCalendar_header.png)

これは [ドリコム Advent Calendar 2017](https://adventar.org/calendars/2575) の12日目です。
11日目はzprodevさんによる、 [モバイルブラウザ 実機自動テスト](https://qiita.com/zprodev/items/3067a1e077d448827475) です。

# 自己紹介
こんにちは。ドリコムでインフラ周りをやっているひらしーです。
今回はUNIX系OSを扱うITインフラ運用を主な仕事とするエンジニア(以下インフラエンジニアとします)がGo言語とDockerを使ってシンプルなCLI環境から始める方法について書いてみました。

# サンプルの動作環境
本内容では以下の環境での動作を確認しています。

- OS: CentOS 7.2
- go: 1.9
- docker: 1.12.6

# 何故インフラエンジニアがGo言語を使えたほうが良いか
インフラエンジニアがITシステムを自動化や省力化する際には、シェルスクリプトやPerl,Ruby,Pythonといったスクリプト言語が良く使われますが、Go言語をインフラエンジニアが習得することで以下のメリットがあると思います。

## ワンバイナリの実行ファイルによるシンプルな運用
Go言語は各プラットフォーム用の実行ファイルをコンパイルして生成するため、実行環境や依存ライブラリの影響を受けづらい側面があります。
これは新旧パッケージやOSのバージョンが入り混じったサーバ、込み入った事情で容易に環境を変更できないサーバを数多く管理するような状況において効果的です。

## Go言語製OSS(特にインフラ系)の多さ
最近ではクラウド管理の[Terraform](https://github.com/hashicorp/terraform),[Consul](https://www.consul.io/?_ga=2.116844613.2140167126.1510206637-534153650.1510206637)や監視ツールの[prometheus](https://github.com/prometheus/prometheus),コンテナ型仮想環境の[Docker(moby)](https://github.com/moby/moby)といったインフラ系のOSSがGo言語を採用しており、これらの挙動調査やプラグインの作成のためGo言語の理解が必要なケースも増えています。

## Go言語製サービスの増加
言語仕様自体の優秀さやパフォーマンスから商用サービスでの採用も増えており、特にGo言語の開発元であるGoogle社のクラウドサービスである[Google Cloud Platform](https://cloud.google.com)での[GAE/Go](https://cloud.google.com/appengine/docs/go/)の大規模トラフィックでの採用事例も増えつつあります。
インフラエンジニアがこのようなサービスの運用を担当する際にGo言語を理解することで、解決できる問題の幅も広がります。

# Go言語の始め方
Go言語は公式のドキュメントが充実していますので、以下から初めてみるのが良いと思います。

- [公式(日本語訳)のチュートリアル](http://golang.jp/go_tutorial)
  - Go言語仕様自体シンプルなので一通り見るだけでかなり身に付きます。まずはここを見てGo言語環境のインストールから始めるのがセオリーと思います。
- [A Tour of Go](https://tour.golang.org/welcome/1)
  - すぐにコードを書いて出力を確認できるので今でもGo言語仕様の理解や詰まった時や自作関数のテストに重宝しています。

# Go言語のCLI環境構築
## インストール
Go言語のインストールはとても簡単です。CLIツールを作る程度であれば[公式サイト](http://golang.jp/install)の通りの手順で問題ないでしょう。

``` bash
# 環境変数のexport
export GOROOT=$HOME/go
export PATH=$PATH:$GOROOT/bin
export PATH=$PATH:/usr/local/go/bin

# 使いたいGo言語のバージョンのtar.gzを指定
wget https://redirector.gvt1.com/edgedl/go/go1.9.2.linux-amd64.tar.gz
tar -C /usr/local -xzf go1.9.2.linux-amd64.tar.gz
```

## 開発環境
Go言語は商用・非商用を問わず開発環境が豊富です。IDEだと[Goland](https://www.jetbrains.com/go/)、[Atom](https://atom.io/)、[Visual Studio](https://www.microsoft.com/ja-jp/dev/default.aspx)辺りを使う方が多いように思われます。
シンプルなCLIツール作成のみが目的であればvimと[vim-goプラグイン](https://github.com/fatih/vim-go)で十分だと思います。

# CLIツールの引数処理
プログラム言語によっては公式でCLIのフレームワークが充実していたり、デファクトスタンダードになっているパッケージ(Rubyの[Thor](http://whatisthor.com/)等)があります。Go言語の場合、以下が多く使われているように思います。

- [flag](https://golang.org/pkg/flag/)
  - Go言語デフォルトで用意されているパッケージ。凝ったものを作らないならばこれでOK
- [urfave/cli](https://github.com/urfave/cli)(旧名: codegangsta/cli)
  - シンプルなCLIフレームワーク。ヘルプ・ショートフラグ等サポート
- [spf13/cobra](https://github.com/spf13/cobra)
  - 数多くのOSSで採用されているCLIフレームワーク。引数の複雑な制約もサポート

小規模のツールであればデフォルトパッケージのflagで十分足りるので、最初から大規模な機能が必要な際以外は引数の解析・バリデーション処理が膨らんだタイミングでCLIフレームワークに差し替えるのがお勧めです。

## flag引数処理サンプルコード
CLIコマンド引数でバージョン情報を表示する引数をflagパッケージで実現してみます。

``` cli.go
package main

import (
        "flag"
        "fmt"
        "os"
)

func main() {
        var showVersion bool

        // --version又は-versionでバージョン情報を表示
        flag.BoolVar(&showVersion, "version", false, "print version")
        flag.Parse()

        if showVersion {
                fmt.Println(getVersion())

                // version引数があった場合はバージョン情報を表示して終了
                os.Exit(0)
        }

        // ツール自体の処理
        fmt.Println("This is CLI tool.")
}

func getVersion() string {
        return "0.0.1"
}
```

``` bash
# コンパイル
go build -o cli-tool cli.go
```

``` bash
# 実行
./cli-tool
>This is CLI tool
./cli-tool --version
0.0.1
```

# まずはMakefileから
JavaではAnt,Maven、RubyではRakeがそれぞれの言語でのビルドツールの定番ですが、Go言語では現状、昔ながらのMakefileが使われることが多いようです。
先程のソースコードをビルドするMakefileを作ってみます。

`Makefile`

``` Makefile
EXEC_FILE = cli-tool
VERSION = 0.0.1
BUILD_FLAGS = -ldflags "\
                -X main.Version=$(VERSION) \
                "
all: clean deps test build

fmt:
        go fmt ./...

deps:
        go get -d -v ./...

test:
        go test -v ./...

build: fmt
        go build $(BUILD_FLAGS) -o "$(EXEC_FILE)" .

clean:
        go clean

.PHONY: fmt clean deps build
```

先程と同じように以下のコマンドで実行ファイルを出力できます。

``` bash
make
```

ターゲットの説明
- fmt: `make fmt`コマンドにてソースコードをフォーマットします
- deps: `make deps`コマンドにてソースコード内でimportされているパッケージ依存を解決します
- test: `make test`コマンドにてテストを実行します
- build: `make build`コマンドにて実行ファイルを生成します
- clean: `make clean`コマンドにてgo buildコマンドで生成されたオブジェクトファイルを削除します
- all: 上記clean,deps,buildターゲットの順に実行します

# テストコード
Go言語はデフォルトパッケージでとてもシンプルなテスト機能をサポートしています。
先程のサンプルコードにバージョン情報出力時のフォーマットをチェックするテストを追加してみましょう。

``` cli_test.go
package main

import (
        "regexp"
        "testing"
)

// cli.goのテスト対象の関数の前に"Test"を付ける
func TestVersion(t *testing.T) {

        version := getVersion()

        // 文字列が数字のn.n.nでないとエラー
        matched, err := regexp.MatchString(`^[0-9]+.[0-9]+.[0-9]+`, string(version))
        if err != nil {
                t.Errorf("%v", err)
        } else if !matched {
                t.Errorf("version should n.n.n format: %s", string(version))
        }
}
```

以下のようにmake時に上記のテストを実行してくれるようになります。

``` bash
make
```

``` bash
# make実行結果
go clean
go get -d -v ./...
go test -v ./...
=== RUN   TestVersion
--- PASS: TestVersion (0.00s)
PASS
ok  	_/home/hiraishi_yosuke/repo/advent	0.002s
go fmt ./...
go build -ldflags " -X main.Version=0.0.1 " -o "cli-tool" .
```

# Dockerの活用
[Docker](https://www.docker.com/)はDocker社が提供しているオープンソースのコンテナ型のアプリケーション実行環境です。
CLIツール程度であれば各実行環境毎のテスト・CI環境を実現できます。
インストールは各OS毎に提供されているパッケージのインストールのみでOKです。

``` bash
sudo yum install -y docker
```

## テスト
Dockerを使うことで、差分のあるOSや環境における実行テストが簡単に実施できます。
ここではシンプルにDockerfileとシェルスクリプトのみでDockerコンテナの処理を実現してみます。

``` bash
# テスト用ディレクトリ作成
mkdir -p ./docker/test
```

`Dockerfile`

``` Dockerfile
# Dockerhubから最新のCentOS7を利用する
FROM centos:7
COPY cli-tool /tmp/cli-tool
CMD  /tmp/cli-tool
```

``` docker_test.sh
#!/bin/sh

PROJECT_NAME="cli-tool"
PLATFORM_NAME="centos7"
BIN_NAME=${PROJECT_NAME}
DOCKER_IMAGE_NAME="${PROJECT_NAME}/${PLATFORM_NAME}"
DOCKER_HOME_DIR="./docker/test"
DOCKER_CACHE_DIR="${HOME}/docker"
DOCKER_CACHE_IMAGE_PATH="${DOCKER_CACHE_DIR}/${PLATFORM_NAME}.tar"

# 既にイメージを起動していたら停止する
if sudo docker ps -a | grep "${DOCKER_IMAGE_NAME}" > /dev/null 2>&1; then
  sudo docker ps -a | grep "${DOCKER_IMAGE_NAME}" | awk  '{print $1}' | xargs sudo docker rm -f > /dev/null
fi

# 既にイメージをキャッシュしていたらそれを利用する
if file ${DOCKER_CACHE_IMAGE_PATH} | grep empty; then
  sudo docker load --input ${DOCKER_CACHE_IMAGE_PATH}
fi

# 実行ファイルをDockerコンテナ内にコピーして実行
cp "${BIN_NAME}" "${DOCKER_HOME_DIR}/${BIN_NAME}"

sudo docker build -t "${DOCKER_IMAGE_NAME}" "${DOCKER_HOME_DIR}"
mkdir -p ${DOCKER_CACHE_DIR}
sudo docker save "${DOCKER_IMAGE_NAME}" > ${DOCKER_CACHE_IMAGE_PATH}
sudo docker run -d "${DOCKER_IMAGE_NAME}"

cid=`sudo docker ps -a | grep "${DOCKER_IMAGE_NAME}" | awk  '{print $1}'`
sudo docker logs ${cid}

rm -f "${DOCKER_HOME_DIR}/${BIN_NAME}"
```

makeコマンドにてテストを追加するには以下を`.PHONY:~`の手前に追加します。

`Makefile`

``` Makefile
:
docker_test:
        @./docker_test.sh
:
```

``` bash
# makeにてDocker内実行テスト
make docker_test
```

## 実行ファイルのパッケージング
ビルドしたCLIツールを配布・設定する際には各OSの採用しているパッケージマネージャを利用するのが便利です。以下のようにパッケージング処理もDockerコンテナ内で行うことにより各OSに依存したパッケージングが容易になります。

``` bash
# パッケージング用ディレクトリ作成
mkdir -p ./docker/packaging
```

`Dockerfile`

``` Dockerfile
# Dockerhubから最新のCentOS7を利用する
FROM centos:7
ARG author="package-user"
ARG project_name="cli-tool"

# RPMパッケージ作成に必要なライブラリのインストール
RUN yum install -y yum install rpm-build yum-utils ncurses-devel glibc-devel gcc
RUN mkdir -p ~/rpm/{BUILD,RPMS,SOURCES,SPECS,SRPMS}

# RPMパッケージの各種パラメータ設定
RUN echo "%_topdir $HOME/rpm" > ~/.rpmmacros
RUN echo "%packager ${author}" >> ~/.rpmmacros

# specと実行ファイルをDockerコンテナ内にコピー
COPY ${project_name}.spec   /tmp/${project_name}.spec
COPY ${project_name}.tar.gz /tmp/${project_name}.tar.gz
RUN  cp /tmp/${project_name}.spec  ~/rpm/SPECS/${project_name}.spec
RUN  cp /tmp/${project_name}.tar.gz ~/rpm/SOURCES/${project_name}.tar.gz

# RPMパッケージ作成
RUN rpmbuild -bb ~/rpm/SPECS/${project_name}.spec
```

``` cli-tool.spec
Name:           cli-tool
Version:        0.0.1
Release:        1
License:        Unknown
BuildRoot:      %{_tmppath}/%{name}-%{version}-%{release}-buildroot
Source0:        %{name}.tar.gz
Summary:        awesome CLI tool
Group:          Applications/System
BuildArch:      x86_64

%define debug_package %{nil}

%description
awesome CLI tool

%prep
%setup -q -n packaging

%build

%install
rm -rf $RPM_BUILD_ROOT/*

%{__mkdir} -p ${RPM_BUILD_ROOT}%{_usr}/local/bin

%{__cp} -p \
${RPM_BUILD_DIR}/packaging/cli-tool \
${RPM_BUILD_ROOT}/usr/local/bin/cli-tool

%clean
rm -rf ${RPM_BUILD_ROOT}/*

%files
%defattr(-, root, root)

%attr(0755, root, root) %{_usr}/local/bin/cli-tool
```

``` docker_packaging.sh
#!/bin/sh

PROJECT_NAME="cli-tool"
PLATFORM_NAME="centos7"
BIN_NAME=${PROJECT_NAME}
SPEC_FILE="cli-tool.spec"
DOCKER_IMAGE_NAME="${PROJECT_NAME}/${PLATFORM_NAME}"
DOCKER_HOME_DIR="./docker/packaging"
DOCKER_PACKAGING_DIR="packaging"
DOCKER_CACHE_DIR="${HOME}/docker"
DOCKER_CACHE_IMAGE_PATH="${DOCKER_CACHE_DIR}/${PLATFORM_NAME}.tar"
GENERATED_RPM_PATH="/root/rpm/RPMS/x86_64/cli-tool-0.0.1-1.x86_64.rpm"

# 既にイメージを起動していたら停止する
if sudo docker ps -a | grep "${DOCKER_IMAGE_NAME}" > /dev/null 2>&1; then
  sudo docker ps -a | grep "${DOCKER_IMAGE_NAME}" | awk  '{print $1}' | xargs sudo docker rm -f > /dev/null
fi

# 実行ファイルのアーカイブ,specファイルをDockerコンテナ内にコピーして実行
mkdir -p "${DOCKER_PACKAGING_DIR}"
cp "${BIN_NAME}" "${DOCKER_PACKAGING_DIR}/${BIN_NAME}"
tar cvzf "${DOCKER_HOME_DIR}/${BIN_NAME}.tar.gz" "${DOCKER_PACKAGING_DIR}/${BIN_NAME}"
cp "${SPEC_FILE}" "${DOCKER_HOME_DIR}/${SPEC_FILE}"

sudo docker build -t "${DOCKER_IMAGE_NAME}" ${DOCKER_HOME_DIR}
mkdir -p ${DOCKER_CACHE_DIR}
sudo docker save "${DOCKER_IMAGE_NAME}" > ${DOCKER_CACHE_IMAGE_PATH}
sudo docker run -d --privileged "${DOCKER_IMAGE_NAME}"

cid=`sudo docker ps -a | grep "${DOCKER_IMAGE_NAME}" | awk  '{print $1}'`
sudo docker logs ${cid}

# Dockerイメージ内でビルドされたRPMパッケージをローカルにコピー
sudo docker cp "${cid}:${GENERATED_RPM_PATH}" cli-tool.rpm

sudo rm -f "${DOCKER_HOME_DIR}/${BIN_NAME}.tar.gz"
sudo rm -f "${DOCKER_HOME_DIR}/${SPEC_FILE}"
```

makeコマンドにてパッケージングを追加するには以下を`.PHONY:~`の手前に追加します。

`Makefile`

``` Makefile
:
docker_packaging:
        @./docker_packaging.sh
:
```

``` bash
# makeにてDocker内実行テスト
make docker_packaging
```


# 最後に
本記事ではインフラエンジニア向けのシンプルなCLIツール開発環境の作り方について記載しました。
[こちら](https://github.com/hiracy/go_docker_simple_cli)に今回の全てのソースコードがありますので是非参考にして頂ければと思います。