EDOCODEにおけるDocker化の歩みとその周辺

これは [EDOCODE Advent Calendar 2022](https://adventar.org/calendars/7645) 12月6日の記事です。

Wano株式会社とそのグループ会社のEDOCODE株式会社のCTOの加藤です。サーバサイドをメインに担当しています。
[Wanoの方でも Advent Calendar](https://qiita.com/advent-calendar/2022/wano-group)をやっていますので、そちらもどうぞ。僕は[4日にProduct-Led Growth](https://ktat.hatenadiary.jp/entry/2022/12/04/171031)について書いています。

EDOCODEでは、ポイントモールとPUSHCODEいう2つのサービスをメインに作っていますが、こちらの記事ではポイントモールについての話になります。

## ポイントモールの構成の簡単な紹介

### システムの概要

EDOCODEではポイントモールのシステムを作っています。これは、1つのサイトではなく、ポイントモールを作りたい会社のために、同様の共通基盤の上で、複数社に提供できるようにしています。

それらの管理運営するための管理サイトは1つですが、各社に提供するアプリケーションは、個別で動いています。
ただメインのコードベース(99%以上)は同じものを利用していて、各モールごとにカスタマイズ(ログイン情報連携等)が多少あります。各社ごとにプロセス数の調整等もあるので、結果として複数のアプリとして動いています。

なお、10年以上稼働しているシステムなので、この記事中にも多分にレガシーなところはあります。
この記事に書かれているDocker化も含めて、改善の途上になります。

### Gitリポジトリの構成

後の内容にも関わるので、Gitリポジトリの構成について、説明します。
ポイントモールには大きく2つのリポジトリがあります(管理サイト用等は除いています)。

1. 共通のコードが入っているリポジトリ
2. 各モールごとのリポジトリ

2の方に、各モールでのみで使うコードやテンプレートファイルや画像等が入っています。

なお、弊社ではGitLabをセルフホストして使っています。

### 簡単な構成

下記のような、nginxは静的ファイルを返し、動的コンテンツをPerl(Plack Application)が返すという、下記のような昔ながらの構成になっていますが、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/11040/5c17dadf-31c8-3ed8-539f-dd77b7f5bfcc.png)

nginx/DBは共通でアプリケーションが複数あるような感じなのが、多少特徴的なところです。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/11040/09de2520-62e6-2d5e-5a83-6f09dcf4ad02.png)

なお、クライアントの関係でオンプレ環境となっています(将来的に、Cloudに変わる可能性はあります)。

## 最終的な全体像



## Docker Image を作る対象

前述の構成ですので、Docker化するにあたりDocker image を作るものは、

- nginx
- Perl Application x モール分

ということになります。

## Docker化の思い出

というわけで、前述の構成でDocker化するにあたって行ったことを、記憶を頼りに、つらつらと書いていきたいと思います。

### nginx の Image はそんなに更新する必要がない & nginx のコンテナとPerlのコンテナで共有するものがある

Plack の Docker image は、モールの更新のタイミングで image を作り直しますが、nginxは別に更新する必要がありません。なのですが、画像等は、nignxからサーブする必要があります。
なので、nginxからもリポジトリの画像部分を読める必要があります。ですが、リポジトリ自体は PerlのDocker Imageに含めたい...。

下記のままでは、nginxから静的ファイルがサーブできないです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/11040/075d621f-cdc0-d6ef-c017-a1da5669abf8.png)

ホストにリポジトリの内容を全部置いて、mount ということもできなくはないですが、それだと、Deployのときに、ソースをhostにコピー？という感じでDocker化したにも関わらず....モヤモヤします。

下記は、ちょっと...嫌ですね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/11040/da902ed1-fb91-c902-e906-7b99a270026b.png)


そのため、これがベストな方法かはわかりませんが、Perl の Docker image にソースコードを含め、Deploy 時には、一部ファイル(静的ファイル)をホストファイルにコピーし、nginxはそのパスをmountする、といったことをしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/11040/c242feec-9e59-2816-b9fe-3d6561f06a40.png)


やってることを順番に書くと、下記のようになります。

1. Perl の Docker image を最新化してコンテナを動かす
1. モールのソースコード上の静的ファイル(/path/to/static)を nignxのイメージが mountしているパス(path/to/nginx/mounted)に`docker cp -a /path/to/static - | tar -x -C path/to/nginx/mounted` 書き出す
1. その後に、同じことを別のテンポラリなパスに行う
1. `rsync` を `--delete` オプション付きで、テンポラリのファイルからnginxがmountしているパスにコピー(2番目の処理で削除したファイルが残ってしまうので、rsyncで削除している)
1. 最後に、テンポラリなパスは削除

という形で、nginxが利用する静的ファイルをホストから共有するようにしました。

### nginx を無停止で切り替える

一番良いのは、Load Balancerから外すことで、それも選択肢としてはありというか一番妥当かもしれません。
nginxの乗っているhostサーバをまるっと外して、nginxを立ち上げ直して、Load Balancerの配下に戻す...という感じですね。ただ、1モールのためにやるのに、全モールをLoad Balancerから外すのもなぁ、というところがあります。

今回は下記のアプローチを考えました(まだ、本番に適用はしていません)。

1. nginx の reuseport を使う
1. docker-compose に 同じ nignx の設定を2つ設定する
1. 起動スクリプト中でnginxのコンテナを2つ立ち上げて、最終的には1つにする

なお、うちのケースだとそういうことはまず無いですが、nginxでパスのルール等に破壊的な変更が入る場合は、素直にLoad Balancerから外すということをしたほうが良いかと思います。

#### 1. nginx の resuseportを使う

reuseportを使うと、同じポートで2つのnginxが起動することが許容されます。

```nginx:nginx.conf
listen 80 reuseport;
```

上記のように、listen に対して、resuseport を指定します。

#### 2. docker-compose.yml に2つnginxを用意する

そして、下記のように service として 同じ nginxを2個設定します。

```yaml:docker-compose.yml
version: '3'
services:
  nginx: &nginx
    restart: always
    ...
  nginx2:
    << : *nginx
    restart: no
```

#### 3. 起動スクリプト中でnginxを2つ立ち上げて、最終的には1つにする

1. image を pull する
1. `docker-compose up nginx2` で2つ目のnginxコンテナ(最新版)を起動する
1. 立ち上がっった nignx2の動作確認をする(curlでたたく)
1. `docker-compoase kill -s QUIT nginx` で動いてnginx(古い阪)を終了させる
1. `docker-compose up nginx` でnginxコンテナ(最新版)を起動する
1. 立ち上がっった nignxの動作確認をする(curlでたたく)
1. `docker-compoase kill -s QUIT nginx2` で動いてnginx2を終了させる
1. 最新版の nginxが一つだけ残る

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/11040/556b2ef6-c21e-ba16-c7c5-e87b1c6565e8.png)


これで、ほぼ無停止でnginxを切り替えることができます。nginx2を落とさずに動かし続けても問題ないといえばないですが、リソースの無駄なので落としています。

`ほぼ`と書いているのは、これを動かしている最中に、whileでcurlで叩きまくっていたのですが、
微妙にエラーになるときがありました。QUITはgraceful shutdownなので、エラーは起きないと思うのですが、原因はさぐれていません。

### GitLab CIでDocker imageを作る

通常の GitLab CIのRunnerでは、Docker imageを作ることができません。
Runnerの設定変更が必要です。

`/etc/gitlab-runner/config.toml` を編集します。

```ini:/etc/gitlab-runner/config.toml
[[runners]]
  name = "********"
  url = "https://your.gilab.example.com/"
  token = "**********"
  executor = "docker"
  [runners.custom_build_dir]
  [runners.cache]
    [runners.cache.s3]
    [runners.cache.gcs]
    [runners.cache.azure]
  [runners.docker]
    tls_verify = false
    image = "docker:20.10.10"
    privileged = true
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
    volumes = ["/certs/client", "/cache"]
    shm_size = 0
```

通常の設定と変更したのは、下記の2行になります。

- `privileged = true`
- `volumes = ["/certs/client", "/cache"]`

### Projectに特定のRunner(Specific Runner)を使用するようにする

GitLabはRunnerを動かすと、色々なプロジェクトで使われます(Shared Runner)。

ですが、先程のように、`priviledged = true` みたいな設定をしたものを色々なプロジェクトで使われるのは微妙ですし、そもそも、Docker imageを作成するRunnerは、先程の設定をしたものでしかうまく動かないため、特定のRunner(Specific Runner)をプロジェクトに設定する必要があります。

Specific Runner を登録する場合は、CI/CDの設定画面で、Specific Runner用のトークンが取得できるので、GitLab のRunnerをregisterするときに、そのトークンを登録してやれば、Specific Runnerとして登録できます。

もしくは、Docker imageを作る用のグループがある、とかでしたら、Group Runnerという手もあります。グループの設定画面から、先程と同様にGroup Runner用のトークンが取得できるので、それをRunnerのregister時に登録すれば、Group Runnerとして登録されます。

```sh
$ docker exec -it gitlab-runner gitlab-runner register
Runtime platform                                    arch=amd64 os=linux pid=64 revision=a987417a version=12.2.0
Running in system-mode.

Please enter the gitlab-ci coordinator URL (e.g. https://gitlab.com/):
https://gitlab.example.com/
Please enter the gitlab-ci token for this runner:
トークンを入力するときに、Specific用か、Group用を入れる
Please enter the gitlab-ci description for this runner:
[.........]: 名前を適当につける
Please enter the gitlab-ci tags for this runner (comma separated):
タグを適当につける
Registering runner... succeeded                     runner=.......
Please enter the executor: docker, docker-ssh, ssh, docker+machine, docker-ssh+machine, custom, parallels, shell, virtualbox, kubernetes:
docker
Please enter the default Docker image (e.g. ruby:2.6):
docker
Runner registered successfully. Feel free to start it, but if it's running already the config should be automatically reloaded!
```

### Docker imageの自動生成

#### a. Dockerfile, docker-compose.yml 用のリポジトリのCI

`Dockerfile`や、`docker-compose.yml`を更新した際には、imageを再作成したいので、そちらにCIを設定します。
なお、GitLabにはコンテナレジストリの機能もあるため、GitLabのコンテナレジストリに生成したイメージを登録しています。

branch(main/production)ごとに作るイメージを変えており、 イメージもモールごとにありますので、branch数 x モール数分の設定が必要になります。

ただ、いくつかの変数を除いて、CIの内容は同じなので、
以下のようにテンプレートを定義して埋め込むようにしています。

```yaml
.template_for_build: &template_for_build
  image: docker:20.10.10
  services:
    - docker:20.10.10-dind
  before_script:
      - docker login -u siteadmin -p $CONTAINER_REGISTER_TOKEN $CI_REGISTRY
      - apk add docker docker-compose
      - export BUILD_DATE=$(date +"%Y%m%d-%H%M")
      - export DEPLOY_TOKEN=$SITEADMIN_ACCESS_TOKEN_RO_REPOSITORY

.template_for_deploy: &template_for_deploy
  image: debian:bullseye-slim
  before_script:
      - apt-get update
      - which ssh-agent || apt-get install openssh-client -y
      - eval $(ssh-agent -s)
      - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
      - mkdir -p ~/.ssh
      - chmod 700 ~/.ssh
      - echo "$SSH_CONFIG" > ~/.ssh/config
      - echo "$KNOWN_HOSTS" > ~/.ssh/known_hosts
      - echo 'nameserver 8.8.8.8' > /etc/resolv.conf
      - echo '127.0.0.1 docker' >> /etc/hosts
```

このようなテンプレートを用意して、

```yaml
build-qa-A:
  stage: build
  <<: *template_for_build
  script:
    - export MALL_NAME=A
    - ./script/make_image_mall.sh
  only:
    - build/A/qa

deploy-qa-A:
  <<: *template_for_deploy
  stage: deploy
  script:
    - ssh server-to-deploy release-command.sh A
  only:
    - build/A/qa
    - deploy/A/qa
```

同じようなものが、複数ある感じです。

#### b. ソースコードのリポジトリにpushしたときに走るCI

テスト環境にコードを反映する際にも、イメージを生成し直す必要があります。
GitLabではAPIで、pipelineをtriggerすることができるので、先程のイメージ生成用のCIを下記のようなコマンドで叩いてやれば、イメージの生成ができます。

```sh
curl -X 'POST' -F token=トークン -F ref=build/モール名/qa https://your.gitlab.example.com/api/v4/projects/***/trigger/pipeline
```

リポジトリに変更をPushした際に、CIの中で上記のようなコマンドを実行してやれば、新しいイメージを作成して、コンテナリポジトリに登録することができます。

### Docker imageを作る際に、latest のタグを付けたい

GitLabのコンテナレジストリに登録する際に、タグとして、`imageの作成日+時間`を使っていましたが、それとは別に`latest` タグも作りたいと思いました。

自動で作られるようなことはないので、かなりそのままの方法ですが、単純に２回登録するようにしています。

```sh
#!/bin/sh -e

build_and_push () {
   docker-compose build --build-arg ....
   docker push $CI_REGISTRY/pmall/pm-docker/plackup-$MALL_NAME-$ENV_NMAE:$BUILD_DATE
}

cd docker-compose/$MALL_NAME
ln -sf .env.$GLUE_ENV .env

# BUILD_DATEが空の場合は、日付と時分をタグとする
if [ "$BUILD_DATE" = "" ]; then
  export BUILD_DATE=$(date +"%Y%m%d-%H%M")
fi

# BUILD_DATEにlastest が設定されている場合日付ごとのバージョンは作らない
if [ "$BUILD_DATE" != "latest" ]; then
  build_and_push
fi

export BUILD_DATE=latest
build_and_push
```

### これらをテストする基盤としてのLXD

Docker自体の話とは少しずれますが、なんやかんやとDocker imageは作れるものの、それを動かすオンプレ環境をエミュレートする環境が必要になります。

これを実現するために、[LXD](https://linuxcontainers.org/)を使っています。`Containers and virtualization tools` とあるように、コンテナもしくは、VMとして、LinuxのOS自体を動かせます。

```
テストサーバ
  +- Docker
  |   |- Load Balancer(nginx)
  |
  +- WebServer1(VM)
  |   |- Docke
  |       |- nginx
  |       |- Mall A
  |       |- Mall B
  |       |- ...
  |
  +- WebServer2(VM)
  |   |- Docker
  |       |- nginx
  |       |- Mall A
  |       |- Mall B
  |       |- ...
  |
```

オンプレ環境の本物のLoad Balancerは用意するのが難しいので、代わりにホストの下のDockerで、nginxを動かしています。

その下に各サーバをLXDのVMとして立ち上げています。

LXD以下のVMに関しては、本番のLANのIPが振られています。Global IPをプログラム的に指定しているところは特に無いので、かなり本番に近い状態のテスト環境が作れる状態になっています。

#### LXDで陥ったトラブル

なぜかVMがいきなり落ちるみたいなことが何回かありました。
その場合、下記のような手順で回復はするのですが、まぁ、微妙ですよね...と。

1. VMにログインできなくなる
1. `lxc stop VM名` 止まらない
2. `lxc stop -f VM名` 止まった
3. `lxc start VM名` 動くが `lxc exec VM名 /bin/bash` できない
4. `lxc console VM名` で`(intramfs)` となる
5. `umount /dev/sda1` と `fsck -y /dev/sda1` を行う
6. 終わったら ubuntu が起動した。

原因としては、VMのFileSystemを`btrfs`にしていたのですが、どうもVMの場合は、`btrfs`に指定しては駄目ということが、後ほど判明しました。

https://github.com/lxc/lxd/pull/9635/files

構築後にドキュメントに追加されたものなので、不運としか言いようが無いですが...。

現在は、ストレジプールを`dir`に変更して動かしています。

### Dockerのネットワークの割当指定

テスト環境でDockerコンテナをいくつも立ち上げていると、急にDBに接続できなくなるタイミングがありました。

```sh
$ /sbin/ip/addr
```

で調べると、`192.168.16.1` のIPが割り振られているものがありました。
そのデバイスを、`dokcer network ls` で探すと、

```sh
$ docker network ls | grep bff00
bff007e98d04   モール名_default           bridge    local
```

のように出てきました。

```sh
$ docker network inspect モール名_default
```

を実行すると、`IPAM`の項目に、ネットワークが書かれており、

```json
        "IPAM": {
            "Driver": "default",
            "Options": null,
            "Config": [
                {
                    "Subnet": "192.168.16.0/20",
                    "Gateway": "192.168.16.1"
                }
            ]
        },

```

`192.168.16.1` - `192.168.31.254` までの範囲のネットワークを確保していますが、この中にホストが属しているネットワーク内に存在するDBのIPが含まれていました。なので、コンテナを立ち上げたタイミングでDBに接続できなくなったというわけです。

Dockerコンテナは、デフォルトでは、下記のようにプライベートネットワークを順番に割り振っていきます。

- `172.17.0.0/16`
- `172.18.0.0/16`
- ...
- `172.31.0.0/16`
- `192.168.0.0/20`
- `192.168.16.0/20`
- ...

ですので、Dockerコンテナをたくさん立ち上げる場合、hostが属している既存のネットワークとconflictして、思わぬ動作をしてしまうことがありえます。

下記のように、ネットワークのサイズを少なくすれば、`172.16.1.0/24`, `172.16.2.0/24` のように割り振られます(/24でも大きいと思いますが、実用上十分だと思います)。

```json:/etc/docker/daemon.json
{
  "default-address-pools":
    [
      {"base":"172.16.0.0/12", "size":24}
    ]
}
```

### Perlのバージョンアップ

かなり古いPerlを使っていたのですが、Perl 5.34.0 まで上げました(今は、5.34.1出てますが)。

バージョンアップで、色々と変更するところが大量に...というわけは、全然なかったです。
Perlは互換性を大事にしているので、大きく変わるところはなく、どちらかと言うとシンタックス的にバグっていたけど、なぜか動いていた(バージョンアップしてチェックが厳しくなった)ところを修正という感じになりました。

下記にどんなものを変えたのが一例を...と思ったのですが、大別すると3パターンしかなかった...ので、全例を。

#### a. バグった正規表現

```perl
$text =~ /^(\d{4})(\d2)(\d{2)(?:(\d{2})(\d{2})(\d{2}))?/;
```

`}` 抜けてるやん...という感じなのですが、何故か動いていました...。正規表現のチェックが厳しくというか正しくなって、ワーニングになるようになりました。

他にも、下記のような`{` をエスケープすべきなのに、エスケープしてないものもワーニングが出るようになっていました。

```perl
m#{{(\w+)}}#
```

#### b. `if not defined %var` がエラーになる

`%hash` や `@array` に、 `defined` をつけているところがありました。これはエラーになります。

単に下記で良いですね。

```perl
if (not %var) {
  
}

if (not @array) {

}
```

#### c. DBIx::Classのfromの指定が変わった

[DBIx::Class](https://metacpan.org/pod/DBIx::Class)を利用しているのですが、Perlのバージョンアップに伴い、モジュールのバージョンアップも必要でした。
それに伴い、`from` に渡す構造が変わっていました(そのままでも動くのですが、ワーニングが出ました)。ワーニングなんで無視してもいいといえば良いのですが、直しました。古いバージョンでも新しいバージョンでも動かす必要があったので、元の構造を渡して、バージョンによって、構造を変更する…という対応にしました(修正を最小にしたかったのと、構造変換であればテストが書きやすいので)。

```perl
sub fix_dbix_from {
  my $from = shift;
  if ($DBIx::Class::VERSION >= 0.082842) {
    foreach my $i (1 .. (@$from - 1)) {
      if (ref $from->[$i] eq 'ARRAY') {
        my $from_part = $from->[$i];
        foreach my $key (keys %{$from_part->[1]}) {
          $from_part->[1]->{$key} = { -ident => $from_part->[1]->{$key} };
        }
      } else {
        Carp::cluck(Data::Dumper::Dumper($from));
      }
    }
  }
  return $from;
}

sub fix_dbix_from_part {
  my $from_part = shift;
  if ($DBIx::Class::VERSION >= 0.082842) {
    foreach my $key (keys %{$from_part->[1]}) {
      $from_part->[1]->{$key} = { -ident => $from_part->[1]->{$key} };
    }
  }
  return $from_part;
}
```

#### d. モジュールの更新

4つあるやんという感じですが、これが一番大変でした。地道に使っているモジュールを洗い出して(cpanfileに書いてるものが大半でしたが、書いてないものもあった)、install script を書きました。

[Carmel](https://metacpan.org/pod/Carmel)を使うというのも考えたのですが、一旦、[cpm](https://metacpan.org/dist/App-cpm/view/script/cpm)でインストールするscriptを書くことにしました。Carmel使うのは今後の課題とします。

ただの地道な作業なので、書くべきことは特に無いです。頑張るしか無い。

ただ、モジュールのバージョンが変わったから、エラーが出るみたいなのは、あまり(まったく?)無かった印象です。前述のDBIx::Classくらいでしょうか(これもワーニングですが)。

## おしまい

他にも色々あったと思うのですが、思い出せるのはこれくらいです。

本番構築の際にAnsibleを使っていて、まじでやばい設定とかが入っていた肝が冷えた...とかもありましたが、それはまた別の話(書く気はないです)。

とても苦労しましたが、やって良かったです。


現在 EDOCODE では、エンジニア・デザイナー・プロダクトマネジャーを募集しています。
ご興味のある方は、こちらの採用ページも是非ご覧ください。
https://go.edocode.co.jp/jobs