Docker Composeのextendsでcompose.yamlを共通化して見やすくする

### extendsについて
Compose Specの機能として [extends](https://github.com/compose-spec/compose-spec/blob/master/spec.md#extends) というものがあり、この機能を使うとComposeファイル内の別サービスを拡張し、オプションで設定を上書きするこができます。

例えば下記のような `compose.yaml` があったとして、 `web` と `admin` の差分が `ports` しかない
という場合に `extends`　をうまく活用することで、 `compose.yaml` の見た目をスッキリさせることができます。

### extends 活用前
```yaml
# compose.yaml

services:
  web:
    image: php:8.2.3-apache
    volumes:
      - ./index.php:/var/www/html/index.php
    ports:
      - 8080:80
    user: root
  admin:
    image: php:8.2.3-apache
    volumes:
      - ./index.php:/var/www/html/index.php
    ports:
      - 8888:80
    user: root
```

### extends 活用後
```yaml
# compose.yaml

services:
  web:
    image: php:8.2.3-apache
    volumes:
      - ./index.php:/var/www/html/index.php
    ports:
      - 8080:80
    user: root
  admin:
    extends: web
    ports:
      - 8888:80
```

実際にやってみるとかなり見通しが良くなったと思います。
ただ、この状態だと正常に動作しません。
実際に `extends活用後` の `compose.yaml`で`docker compose up -d`　を行うと8080ポートが競合を起こした旨のエラーが発生します。

```bash
$ docker compose up -d
[+] Running 2/3
 ⠿ Network private_default    Created                                                                                                                                                                                                  0.0s
 ⠿ Container private-admin-1  Started                                                                                                                                                                                                  0.5s
 ⠿ Container private-web-1    Starting                                                                                                                                                                                                 0.5s
Error response from daemon: Ports are not available: exposing port TCP 0.0.0.0:8080 -> 0.0.0.0:0: listen tcp 0.0.0.0:8080: bind: address already in use
```

直感的にextendsした側で`ports`を設定すればextends元の`ports`設定は上書きされてくれそうな感じがしますが、実際はextends元の`ports`設定がマージされます。
`docker compose convert` を実行して確認すると下記のような結果が得られます(`web`に`8080:80`と`8888:80`の設定がされていること確認できます)。
```yaml
# docker compose convert の結果

name: private
services:
  admin:
    extends:
      service: web
    image: php:8.2.3-apache
    networks:
      default: null
    ports:
    - mode: ingress
      target: 80
      published: "8080"
      protocol: tcp
    - mode: ingress
      target: 80
      published: "8888"
      protocol: tcp
~~~~~ 以下結果省略 ~~~~~
```

[spec.md](https://github.com/compose-spec/compose-spec/blob/master/spec.md#sequences) を確認すると、
以下のキーはシーケンシャルな値として扱う必要があり、マージの結果生じた重複はすべて削除され、ユニークな要素のみが含まれるようになります。
`cap_add, cap_drop, configs, deploy.placement.constraints, deploy.placement.preferences, deploy.reservations.generic_resources, device_cgroup_rules, expose, external_links, ports, secrets, security_opt`
と書かれていました。
つまり、これらのキーでextends元にある設定は必ず受け継がれるということになります。

### fileオプションを使う
それでは今回のようなports競合を起こしてしまうケースの場合にはextendsは使えないのかというと、そうではありません。
`extends`には`file`というオプションがあり、これを活用することでportsの競合を回避することが可能です。

それでは実際に先ほどの例に適用してみます。
まず、 `compose.yaml`を下記のように変更します。
`extends`に`file`オプションが追加して `compose.base.yaml` を読み込むようにしています。
また、 `base` サービスを拡張元に設定しています。
```yaml
# compose.yaml

services:
  web:
    extends:
      file: compose.base.yaml
      service: base
    ports:
      - 8080:80
  admin:
    extends:
      file: compose.base.yaml
      service: base
    ports:
      - 8888:80
```

次に`compose.base.yaml`を作成します(`compose.yaml` と同じディレクトリに配置してください)。
```yaml
compose.base.yaml

services:
  base:
    image: php:8.2.3-apache
    volumes:
      - ./index.php:/var/www/html/index.php
    user: root
```

`docker compose up -d && docker compose ps` を実行して動作確認。
無事起動することが確認できました。
```bash
$ docker compose up -d && docker compose ps
[+] Running 2/0
 ⠿ Container private-web-1    Running                                                                                                                                                                                                  0.0s
 ⠿ Container private-admin-1  Running                                                                                                                                                                                                  0.0s
NAME                IMAGE               COMMAND                  SERVICE             CREATED             STATUS              PORTS
private-admin-1     php:8.2.3-apache    "docker-php-entrypoi…"   admin               4 minutes ago       Up 4 minutes        0.0.0.0:8888->80/tcp
private-web-1       php:8.2.3-apache    "docker-php-entrypoi…"   web                 4 minutes ago       Up 4 minutes        0.0.0.0:8080->80/tcp
```

### 所感
最初から`file`を使っていれば`ports`競合を踏むこともなかったのですが、composeファイルが1つ増えるのが微妙かなと思い見事にハマりました。
仮に`file`を使わなくてもうまくいくパターンだとしても、チームでの運用を考えると`extends`を使うときは`file`必須で利用する運用に統一しておいた方がハマらなくて良さそうだなと思います。