モバイルゲームエンジニアがDockerに入門した話

## はじめに

Dockerを未だに良く理解できていないモバイルゲーム系エンジニア(主にこれまでクライアントサイド、ついでに開発環境整備もやる人)が、書籍「[たった1日で基本が身に付く! Docker/Kubernetes超入門](https://booklive.jp/product/index/title_id/824059/vol_no/001)」を読みつつDockerに入門してみました。その書評と個人的な覚書です。

## 入門の動機

ローカル環境・テスト環境・CI/CD環境(主にCI)の構築をモダンなやり方でできるようにしたいためです。

ゲーム開発初期段階ではUnityであれば[Cloud Build](https://unity.com/ja/features/cloud-build)が利用できるため、すぐにビルド・単体テストの実行・およびチーム内にビルドしたアプリを共有することができます。これはこれで開発初期段階では確かに便利なのですが、実際の開発では

1. メンバーがPull Requestを作成
2. チームメンバーのレビュー
3. マージ前もしくはマージ後に自動テストを実行
4. 結果をSlackに通知

といったワークフローに加えChatOps連携などのワークフローも必要になることがこれまで多かったため、Cloud Buildだけではちょっときつい。また、CIまわりは過去に以下のような体験やチーム内で発生した問題がありました。

- Mac MiniやMac Proに直接Jenkinsをインストールして運用されており、マシンが壊れた際の復旧に時間がかかった
    - 直接インストールして運用せざるを得ない場合は最低限MacのTime Machineバックアップを取ること
- ビルドにはマシンパワーが必要でプロジェクト進行具合によっては混み合いがちになり易い
    - マシン台数を調整したいが物理マシンだとスケールし辛い
- マシンの構成管理ができていない場合マシンの環境がバラついてしまう
    - マシンの導入時期によってもバラつく
    - Ansibleで構成管理することで少しマシに
- Jenkinsのバージョンアップやプラグインの更新など頻繁に発生するため、メンテナンスコストが高い・属人的になり易い
    - 気づいた頃には警告だらけになる
        - 業務の隙をみて都度潰すしかなかった
    - JenkinsのバージョンはLTS版を使う
    - ジョブの管理はJenkinsfileで管理できるようになったおかげでいくらかマシになった
- ChatOps連携するためにプラグイン的にcoffeeスクリプトでHubot連携する方法が採られた
    - 簡単にChatOps連携するための当時の流行り
    - 突然のcoffeeスクリプト！
    - これを嫌ってごりっとGoでbotを書く現場もあって妙な共感を覚えたり
- 上記を踏まえチーム内にメンテナーを育成する必要がある
    - レガシーな環境だと誰も近づきたくなくなる
- 総じて辛い体験（うっ...頭が

一方で近年GitHub ActionsやAmazon EC2 Macインスタンスの登場・CI/CDサービスの発展によって以前より選択肢が広がりました。このようなエコシステムに乗るためにもDockerに触れていると入っていきやすいように感じたため、重い腰を上げて入門することにしました。←動機

## 書評

紹介した書籍はタイトルに「たった1日で」と書いてありますが、気にしてはいけません。集中して1〜2週間程度、仕事の合間を縫って読むのであれば2〜3週間程度は見た方が良いです。
一方でDockerの基本からWebアプリケーション開発、AnsibleによるDockerホストの構築・Dockerを利用したJenkinsサーバー構築、k8sはおまけ程度といった塩梅で広く浅く解説されているため、さくっと全体感を掴むにはちょうど良さそうでした。
ただし、私が読んだのは2021/11であり2020/6頃の情報のためか、記載されている説明に不足感があったりサンプルが既に動作しない構成となっているようです。特に第6章のCI/CDのサンプルの実行については、私は断念しました（後述）。あくまでも全体感を掴む目的で、ハンズオンしつつサンプルを調整していく過程を通じて入門した気になれました。

## 各章の感想や注意

__第1章 Dockerを使ってみよう__

Docker Desktopの導入や概念的な説明と簡単なコマンド実行例なので、特に躓くことはなく読めました。

書籍の動作検証はWindowｓ 10で行れたようです。私の試行環境はmacOS(Big Sur)でした。そのため、Docker Desktop for Mac(以下、DDfM)で試行したのですが、`HyperKit`や`VPNKit`といった仮想環境上で動作するため特有の制約があります。この制約を理解するには併せてDockerドキュメントの特に[ネットワーク構築機能 - 既知の制限、利用例、回避方法](https://docs.docker.jp/docker-for-mac/networking.html#mac-known-limitations-use-cases-and-workarounds)に予め目を通した方が良さそうです(ということに後で気づきました)。

__第2章 イメージの利用と開発を体験しよう__

Ansibleはsshで疎通できること前提である一方で利用するイメージであるCentOS7で`sshd`がそのままだとインストール・起動していないため、どうにかする必要がありました。そこで、推奨はされないものの下記のような方法で乗り切ることにしました。

1. `openssh-server`,`openssh-clients`(`ssh-copy-id`を使用するため)をインスール
2. `sshd`を起動するために`systemctl`を使う
    - そのままだと「Failed to get D-Bus connection: Operation not permitted」となるため、`--privileged`オプションを付け、`/sbin/init`(`systemd`へのシンボリックリンク)経由で起動
3. playbookの修正
    - CentOS7に`firewalld`がインストールされていないため、そのままplaybookを実行するとエラーになる
    - `firewalld`を`yum`モジュールでインストールするよう修正
    - 面倒な場合は直接`yum -y install firewalld`

__第3章 Dockerネットワークとストレージを理解しよう__

Dockerエンジンのネットワーク・名前解決や`bind`や`volume`の使い方の章です。

Dockerネットワークに関しては明示的にブリッジを作成してコンテナ起動時に指定すればコンテナ間で疎通することを確認できましたが、試しにデフォルトのブリッジ(bridge)に所属している場合でも確認したところ疎通できませんでした(何故だろう)。

__第4章 Dockerfileでイメージを作成しよう__

コマンドベースではなくDockerfileを使ったイメージのビルドに関する章です。
躓きポイントはありませんでした。

__第5章 Composeを使ってマルチコンテナアプリを作ろう__

第1章〜第４章までの知識を総動員したWebアプリ開発であれば一番実践的な章だと思います。
各章の要素がミックスされていて面白い章です。
躓きポイントはありませんでした。

__第6章　DockerアプリでCI/CDしよう__

一番気になっていた章だったのですが、結論から言えば断念しました。以下、躓いた点。

Jenkinsのイメージは[jenkins/jenkins](https://hub.docker.com/r/jenkins/jenkins)で公開されている公式イメージのCentOS版を利用するサンプルとなっています。
Jenkinsのイメージから作成されるコンテナは`/sbin/init`(`systemd`へのシンボリックリンク)ではなく、`/sbin/tini`で起動されるようです。これはゾンビプロセスの刈り取りとプロセスのSIGTERMなどのシグナルを正しく扱えるようにするためだそうです(Jenkinsで実行されるスクリプトがゾンビプロセスを作る可能性があり、コンテナ終了時にゾンビプロセスが残らないよう綺麗に終了するため。いわゆるPID 1問題)。PID　1に関する解説はされているものの`/sbin/tini`の説明がされていないため、理解に苦しみました。

また、公式イメージでは当然`sshd`の相乗りなんてしておらず、`sbin/tini`で起動することからJenkinsサーバーのホスト側で`systemd`が起動しないため、第2章で乗り切った方法のように`openssh-server`をインストールして`sshd`は起動しようとしても上手くいきませんでした。
一方で説明やサンプルではJenkinsサーバーのホストで`sshd`が起動していることが前提となっています。そのため、サンプルを試そうとしても途中で手詰まりとなってしまいました。入門レベルである私の理解不足かもしれませんが、そうではない気もします。

また、Jenkins初回起動時に推奨プラグインのインストールが多数失敗しました。この点に関してはJenkinsのバージョンに起因していそうだったので、使用するjenkins/jenkinsのイメージのタグを調整すれば良さそうです。

書籍で使用されていたイメージのバージョン: `jenkins/jenkins:2.190.1-centos`
成功したイメージのバージョン: `jenkins/jenkins:2.319-centos7`(ltsの方が良いかも)

なお、Jenkinsコンテナのビルドをやり直したい場合は`docker-compose.yml`内で`volumes`指定がlocalになっているため、そのままコンテナを停止・再ビルドしてもJenkinsの初回起動画面になりません。当該volumeを消して作業をやり直す必要があることに注意。

私個人としてはJenkinsに頼らずCI/CDする方向性を模索していますが、Jenkins on Dockerをやりたいのであれば次のステップとして別の書籍やリソースを参考にした方が良さそうです。

__第7章　Kubernetesを理解しよう__

私の関心を満たすためにはDocker入門までで十分だったのですが、「Kubernetesも最近よく聞くけどサッパリ分からん...」と思っていたこともあり、せっかくなので触っておくことにしました。その方がインフラエンジニアとのコミュニケーションが捗るということを期待しつつ。

この章に関しては、`minikube`をCentOS7上に展開する際には下記のような修正をする必要がありました。ローカルで十分試すことができるためリモートにインストールする必要はありませんが、下記のようなことをすればインストール自体はできたため記載しておきます。

1. `minikube`インストール時に失敗しないようDocker Desktopの設定からメモリ割り当てを2GB以上に変更した上で`run`時に`-m 4g`などと指定
    - minkubeの動作環境として2GB以上のメモリが必要
    - DDfMの場合デフォルト設定だと2GBとなっているが、オーバーヘッドのためか少し足りなくなってしまいエラーとなる
2. playbookの調整
  - `ssh`まわりの設定はマニュアルで設定するためコメントアウト
  - `conntrack`,`sudo`,`iproute`がインストールされるよう調整
  - `minikube`のバージョン調整
    - `latest`になっているところ、うまくいかなかったため`v1.16.0`などにダウングレード
  - `client.crt`, `client.key`のパスが`.minikube`直下ではなくなったようなので`./minikube/profiles/minikube`以下に変更

上記のplaybookの調整をした上でmacOS側から下記を実行。

```
# Minikubeをホストするコンテナを特権モードで起動
docker run -it --name minikube_host --privileged -d -m 4g centos:centos7 /sbin/init

# サンプルを転送
# 送信元パスを./で終わらせることにより、ディレクトリ自体ではなくディレクトリ以下の内容をコピーする
docker container cp ansible/minikube/. minikube_host:/root/
```

立ち上がったコンテナ(ここでは`minikube_host`とします)にログインして下記を実行。

```
# ログイン
docker container exec -it minikube_host bash

# 必要なパッケージのインストール
yum -y install epel-release
yum -y install openssh-server openssh-clients ansible

# ssh-copy-id時にパスワードを聞かれるので適当に変更(もしくはsshd_configを調整する)
# ここでは簡単に済ませてるためrootのパスワードを変更
echo 'root:a' | chpasswd

# sshの準備
cd ~
mkdir .ssh && chmod 700 .ssh
mv ./id_rsa* .ssh && chmod 600 .ssh/id_rsa && chmod 644 .ssh/id_rsa.pub

systemctl start sshd
ssh-copy-id 127.0.0.1

# 後ははAnsibleに任せる
ansible-playbook -i 127.0.0.1, ./pb_centos7.yml
```

上記手順でCentOS7上に`minikube`をインストールできたので続いて`nginx`のPod/Serviceを展開しましたが、コンテナのポートフォワーディングや`minikube start`時に指定するポートフォワーディングを試行しても、`localhost:8080`や`localhost:30000`等からアクセスできませんでした。DDfMの制約に引っかかっているのかもしれません。イマイチ理解できなかったため一旦スルーして、DDfM同梱の`kubectl`を使ってローカル環境で実行する方法に切り替えました。

__第8章　Kubernetesを利用しよう__

セクション1は特に躓くことなく試せました。

セクション2に関しては、些細な事ですがサンプルのHTML内記載のVersionや`docker-compose.yml`および`app.yml`内のイメージのタグは予め2となっており、一方バージョンやタグが1想定で解説が開始されているため、少し混乱しました(途中で1=>2にする例があり、その結果がサンプルコード)。サンプルから改変して試行する場合はバージョンやタグを2から3へと読み替えるなどして試せば良いです。
コマンド実行後にブラウザを連打することでローリングアップデートの特徴であるアプリのバージョンが徐々に切り替わっていく様が観察できて面白いです。

また、このセクションの冒頭にあったブルー/グリーンデプロイメントなどにも触れるとあったものの、実際紹介されていたのはローリングアップデートであり、ブルー/グリーンデプロイメントに関しては「興味があれば調べてください」と締めくくられてしまっているのが残念な点でした。

自分がk8sを使うことは当分ないもののDevOps界隈を垣間見ることはできたのではないかと思います。

## コマンド覚書

以下、コマンドや覚書。

- [dockerコマンドリファレンスはこちら](https://docs.docker.jp/engine/reference/commandline/toc.html)
- [kubectlのコマンドリファレンスはこちら](https://kubernetes.io/docs/reference/generated/kubectl/kubectl-commands)

### イメージのタグ一覧をAPIで取得

Docker Hubでタグを見れば良いが、コマンドラインでタグ一覧を取得した場合はRegistry APIを利用して下記で取得できる。

```
curl -s https://registry.hub.docker.com/v2/repositories/{USER}/{IMAGE}/tags | jq -r '.results[].name'
```

JSONのパースのため[jq](https://stedolan.github.io/jq/)で整形している。

### Docker コンテナ作成・イメージ作成

ベースイメージから新規イメージを作成する一連の流れ。

__ベースイメージからコンテナ作成__

```
docker container run --rm --name {CONTAINER_NAME} -d {BASE_IMAGE_NAME} tail -f /dev/null
```
`rm`オプションを付けておくと、コンテナ終了時に削除までしてくれる。付けない場合明示的にコンテナを削除しないと同名で再度コンテナ作成することができないので面倒。

`tail -f /dev/null`はコンテナが即終了しないよう待機させるため。この方法は良く使われるらしい。

__コンテナに対してコマンド実行__

```
docker container exec {CONTAINER_NAME} {COMMAND}
```

- `COMMAND` pythonでflaskをインストールするのであれば`pip install flask==1.1.1`など。

__コンテナにファイル転送__

```
docker container cp {LOCAL_PATH} {CONTAINER_NAME}:{REMOTE_PATH}
```

- `LOCAL_PATH` 転送したいファイルへのパス
- `REMOTE_PATH` 転送先のパス。rootユーザーのホームディレクトリに転送する場合は`example_host:/root/`といった具合。

__コンテナの停止__

```
docker container stop {CONTAINER_NAME}
```

__イメージの作成__

```
docker container commit {CONTAINER_NAME} {IMAGE_NAME}
```

__イメージをDocker HubにPush__

```
docker image push {IMAGE_NAME}:{TAG}
```

__コンテナの動作確認__


```
docker container run --name {CONTAINER_NAME} -p {LOCAL_PORT}:{REMOTE_PORT} -d -e {ENV_NAME_1}={ENV_VALUE_1} -e {ENV_NAME_2}={ENV_VALUE_2} {IMAGE_NAME} {COMMAND}
```

- `-p` ポートフォワーディング
    - 例: `-p 8080:80`
    - Dockerホスト側の8080ポートをコンテナ内の80ポートに紐付け)
- `-e` 環境変数の設定。複数指定可能。
- `COMMAND` コンテナ上で実行したいコマンド

__コンテナにログイン__


Alpine Linuxの場合は下記。

```
docker container exec -it {CONTAINER_NAME} ash
```

Alpine Linuxのデフォルトシェルは`bash`ではなく`ash`。
`centos`であれば`bash`が使えるため下記。

```
docker container exec -it {CONTAINER_NAME} bash
```

あるいは、

```
docker container exec -it {CONTAINER_NAME} /bin/sh
```

__Dockerホストにログイン__

DDfMの場合、HyperKit/LinuxKit(VM)上でDockerホストが動作するらしく混乱した。
例えばログを探す際に`docker container inspect　{CONTAINER_ID} | grep "LogPath"`すると`/var/lib/docker`以下のパスが取得できたが、macOS上のターミナルから`ls /var/lib/docker`としても存在しない。

そこで下記のようにすると、Dockerホストにログインできる。この場合、`/var/lib/docker`のディレクトリが確認できる。

```
docker run -it --rm --privileged --pid=host justincormack/nsenter1
```

`screen`コマンドで入ることもできるらしいが、上記が簡単とのこと。

__CentOS7上でssd使用する__

centos7イメージで`systemctl`が使用できない。
そのため、下記で`sshd`を起動することができない。

```
systemctl start sshd
```

`privileged`オプションを付けて`/sbin/init`経由で起動する必要がある。

```
 docker container run --name ansible_host -d --privileged centos:centos7 /sbin/init
 ```

`privileged`オプションなしで`sysytemd`を使いたい場合は[公式のSystemd integration](https://hub.docker.com/_/centos)が参考になる。

__ボリューム作成__

```
docker volume create {VOLUME_NAME}
```

__ボリュームのマウント__

```
docker container run --rm -d --name {CONTAINER_NAME} --mount source={VOLUME_NAME},target={DST_VOLUME_PATH} {IMAGE_NAME}
```

`--mount source=...,target=...`がポイント。

__ボリュームのバインド__

```
docker container run --rm -d --name {CONTAINER_NAME} -p 8080:80 --mount type=bind,source={SRC_VOLUME_PATH},target={DST_VOLUME_PATH} {IMAGE_NAME}
```

readonlyにする場合はtargetの後に`,readonly`などとすれば良い。

__ボリューム一覧確認__

```
docker volume ls
```

__ボリュームのインスペクト__

```
docker volume inspect {VOLUME_NAME}
```

__ボリュームの削除__

```
docker volume rm {VOLUME_NAME}
```

__Dockerfileからのビルド__

```
docker image build -t {IMAGE_NAME:TAG_NAME} {DOCKERFILE_PATH}
```

`DOCKERFILE_PATH` Dockerfileが配置されているディレクトリへのパス

### Docker Network

同一ネットワークであるコンテナであれば名前解決される。
名前解決の流れは自分自身のコンテナに問い合わせ、さらにDocker Engineに問い合わせる流れ(コンテナはネームサーバーを内包する)。

```
docker network create {NETWORK_NAME}
```

__ネットワークの一覧__

```
docker network ls
```

__ネットワークの削除__

```
docker network rm {NETWORK_NAME}
```

__ネットワークを指定してコンテナ起動__

```
docker container run　--network {NETWORK_NAME}
```

Composeで指定する場合は`docker-compose.yml`の`network`キーで指定する。

__名前解決の確認__

ネームサーバーは下記で確認できる。

```
docker container exec {CONTAINER_NAME} cat /etc/resolv.conf
```

名前解決できるかは`nslookup`を利用して下記で確認可能。

```
docker container exec {CONTAINER_NAME_1} nslookup {CONTAINER_NAME_2} {NAME_SERVER_IP_ADDRESS}
```



### Docker Compose 

複数のコンテナのオーケストレーションのためにはComposeを利用する。
k8sはとても複雑だが、Composeは比較的簡単。
`docker-compose.yml`で構成を定義する。
`docker-compose.yml`で変数を埋め込む場合は別途.envファイルを利用する。

__ビルド__

```
docker-compose build　--no-cache
```

ビルド時間を短くするには`no-cache`オプションを付けなくても良いが、付けておいた方が安全。

__起動__

```
docker-compose up -d --build
```

- `-d` バックグラウンド実行(デタッチ)
- `--build` イメージをビルドして実行

変更を反映するには都度ビルドする必要がある。開発のイテレーション速度を上げるためにホットリロードするためにはymlでvolumesを指定してbindする方法がある。その場合は開発用と本番用のymlを切り替える。`-f`でymlの指定が可能。


__ymlで定義されたサービスの確認__

```
docker-compose ps
```

`docker container ls`でも確認できるが、複数のコンテナにまたがる操作は`docker-compose`のコマンド体系を利用した方が良い。


__サービスの停止・再開__

```
docker-compose stop
```

`stop`で停止した場合は、下記で再開可能。

```
docker-compose start
```

再開を必要とせず、コンテナの破棄までしたい場合は下記。

```
docker-compose down
```

`down`するとネットワークも破棄もされる一方でボリュームは残ることに注意。
ボリュームが不要の場合は別途破棄する必要がある。

## Kubernetes

リソースの種類とリソース名を指定することがdockerコマンドラインの感覚と違う点。通常マニフェストファイル(yml)で管理するが、コマンドは覚えておくと開発時に便利。

なお、マニフェストファイルはPod自体の構成するマニフェストファイル(`kind`が`Pod`)と外部にサービスを公開するマニフェストファイル(`kind`が`Service`)で構成する。
サービスの種類としては例えば下記がある。


|リソースの種類|用途|
|----------|----|
|ClusterIP|クラスタ内通信をするためのサービス|
|NodePort|クラスタ内通信+外部からの通信を受け付けるサービス|
|LoadBalancer|クラウドサービスで割り振られるIPと連携するサービス|

`LoadBalancer`は現状クラウドによっては使えないケースがあるらしい。

__podの作成__

```
kubectl run {POD_NAME} --generator=run-pod/v1 --hostport=8080 --port=80 --image=nginx
```

__podの一覧__

```
kubectl get pods
```

__podのログ確認__

```
kubectl logs {POD_NAME}
```

__podの詳細確認__

```
kubectl describe pods {POD_NAME}
```

```
kubectl get pods {POD_NAME} -o yaml
```

__podでコマンドを実行__

```
kubectl exec {POD_NAME} {COMMAND}
```

__podにログイン__

```
kubectl exec -it {POD_NAME} bash
```

__コマンドに実行__

```
kubectl exec {POD_NAME} hostname
```

__podの削除__

```
kubectl delete pod {POD_NAME}
```

リソースの種類の指定で`pods`であったり`pod`であったりと揺らぎが気になったため、違いがあるのか調べたところ特に違いはないらしい。

- https://stackoverflow.com/questions/55904235/kubectl-pod-vs-pods
- https://kubernetes.io/docs/reference/kubectl/overview/#syntax

__マニフェストファイルの適用__

```
kubectl apply -f {FILE_PATH}
```

`-f`は複数指定可能。

__リソースの削除__

```
kubectl delete -f {FILE_PATH}
```

`-f`は複数指定可能。

__レプリカセットの確認__

```
kubectl get replicaset
```

リソース`Deployment`を使用した際に指定した`replicas`分だけ余分に`pod`が起動する。
障害発生等で`pod`の数が減った場合に自動的に望ましいpod数に調整される。
ただしDBをこれで水平展開してしまうと整合性が無くなるため、従来通りマネージドサービスを利用するか仕組みを作り込む必要がある。

__ローリングアップデートの結果確認__

```
kubectl rollout status deployment {RESOURCE_NAME}
```

__ロールバック__

```
kubectl rollout undo deployment {RESOURCE_NAME}
```

## 終わりに

Dockerに入門することで「Dockerは、サーバー・インフラエンジニアが使っている便利なやつ」という認識から一歩脱け出すことができました。一方`privileged`オプションに頼りつつ学習を進めてしまいましたが、実務で適用するのは怖いところです。

また調査中に気づいたのですが、CIに関しては[GameCI](https://game.ci/)というサービスも出てきたようです(執筆時点でv2.0 alpha)。なんと「Free for everyone, forever」とのことらしいです。以前までDocker Hubに[gableroux/unity3d](https://hub.docker.com/r/gableroux/unity3d/)イメージがありUnity Test Runnerを(Linuxビルドで)実行する風潮があったようなのですが、その後継の様子。Unity開発でGitHub Actionsを導入したいという要件や、自前でパイプライン構築するコストがかけられない場合はこのサービスを利用した方が幸せかもしれません。動向に要注目です。
fastlaneでのアプリ配信自動化までやりたい場合は@mogmetさんの[こちらの記事](https://qiita.com/mogmet/items/1fafdf4e88bdd122b9a0)が参考になりそうです。