Windows環境でdevcontainerにほぼ引き篭れる構成を作った

Dockerって環境を汚さないで使えて便利ですよね。
vscodeっていろんな言語の開発環境を手軽に作れて便利ですよね。
ということでRemote Developmentによる作業コンテナ(devcontainer)を使ってコンテナ側に引き篭って開発できる環境を作ってみたので、環境構成と要点をここに書き出してみようと思います。

# 注意事項

この記事は

- ホストOSがWindows環境で
- vscodeとDockerコンテナを使って
- なるべくUnix系に寄せて使えるようにする

前提での環境構築のお話になっています。
ホストOSにLinuxやMacを使っていたりコンテナを使わなかったりホストはWindows上のシェル操作で十分な場合は、この記事は適合しない内容が多分に含まれています。

また、個々のツールや環境のセットアップ操作などはほぼ書かれない予定なので、必要に応じて他の記事を参照してください。

# 環境

- ホスト
    - Windows 10
- WSL
    - WSL2
    - Ubuntu 20.04
- Docker
    - Docker version 20.10.17, build 100c701
    - Docker Compose version v2.10.2
- devcontainer
    - Debian bullseye

:::note info
私の場合はGoの開発環境としてdevcontainerを作ったのでDebianベース(`golang:1.19-bullseye`)になっていますが、他のディストリビューションでも同じことはできると思います
:::

# TL;DR;

- ワークスペースはサービスのコンテナとは独立してdevcontainer上に構築する
- サービス用のコンテナとdevcontainerを同じネットワークにつなぐ
- DockerはDooDで操作し、ワークスペースの配置はWSL上とDocker上でパスを合わせる

# 経緯とか変遷の流れ

vscode + Remote Developmentを扱い始めた際、既存のコンテナにアタッチしてそこにvscodeのワークスペースを構築して使っていました。

```plantuml
@startuml
!define ICONURL https://raw.githubusercontent.com/tupadr3/plantuml-icon-font-sprites/v2.4.0
!includeurl ICONURL/common.puml
!includeurl ICONURL/devicons2/visualstudio.puml
!includeurl ICONURL/devicons2/docker.puml

DEV2_VISUALSTUDIO(vscode,"",rectangle,#0078d7) #ffffe0 {
    component "Remote Development" as dev
}
DEV2_DOCKER(docker,"",rectangle,#0db7ed) #ffffe0 {
    agent "ワークスペース\nin サービスコンテナ" as svc
}

dev -> svc : Attach to Running Container
@enduml
```

## ワークスペースのサービスコンテナからの分離

サービスコンテナ上にワークスペースを構築すると、

- ワークスペースに追加のツールなどをインストールするためには手動で行う必要がある
    - DockerFileに書いてしまうとコミット時に差分を除く必要が出てしまう
        - 全作業者が環境構成を共有するならありだけど普通はそうじゃない(と思っている)
    - 別ボリュームでマウントすれば使い回せるけどdocker-compose.ymlに書いてしまうと以下略
- サービスコンテナを破棄する度にツールの再インストールが必要になる
    - コンテナは中身がよくわからなくて困ったら手軽に再作成できるのが売りの一つ
    - 別ボリュームやホストに退避したりで使い回せるようにしたこともあったけど、たまに回収し忘れると再度入れ直したりするのが手間

というデメリットがありました。

この点を解消するために、ワークスペースを稼働中のサービスコンテナから切り離してホスト側で構築し直すようにしました。
この構成では通常のファイル操作などはvscodeからWindows上で行うことになりますが、コンテナにも同じファイルをマウントすることで変更が反映されるので問題なく作業できますし、vscode上で解決できない操作はサービスコンテナに対して `docker exec` などで直接操作もできます。

```plantuml
@startuml
!define ICONURL https://raw.githubusercontent.com/tupadr3/plantuml-icon-font-sprites/v2.4.0
!includeurl ICONURL/common.puml
!includeurl ICONURL/devicons2/windows8_original.puml
!includeurl ICONURL/devicons2/visualstudio.puml
!includeurl ICONURL/devicons2/docker.puml

DEV2_VISUALSTUDIO(vscode,"",rectangle,#0078d7) #ffffe0
DEV2_WINDOWS8_ORIGINAL(windows,"",rectangle,#00e0e0) #ffffe0 {
    agent "ワークスペース\non Windows" as ws
}
DEV2_DOCKER(docker,"",rectangle,#0db7ed) #ffffe0 {
    agent "サービスコンテナ" as svc
}

vscode -> ws : open
ws -> svc : docker exec
@enduml
```

## ワークスペースをWindows => devcontainerへ

Windowsにワークスペースを配置することで、

- Docker関連のコマンドや周辺のCUIツールはWindows用でそろえる必要がある
- ワークスペースのシェルはPowerShellかbatになる
    - bashとかzshが動かない(git bashは挙動がやや残念、、)
    - コンテナ内ではUnix系シェルなので操作やスクリプトが統一できない

という別の課題が出てきました。

この点を解消するためには、ワークスペースをUnix系に配置する必要があります。
vscodeではRemote Developmentを使うとワークスペースをDockerコンテナ(devcontainer)上に構築できます。
この際、デフォルトではvscodeのプロジェクト(.devcontainerの一つ上のディレクトリ)がワークスペースとしてdevcontainer上にマウントされるので、あえてコンテナ内に作業用のファイルを配置する必要がなくほぼ透過的にコンテナ内でファイルを操作できます。

```plantuml
@startuml
!define ICONURL https://raw.githubusercontent.com/tupadr3/plantuml-icon-font-sprites/v2.4.0
!includeurl ICONURL/common.puml
!includeurl ICONURL/devicons2/windows8_original.puml
!includeurl ICONURL/devicons2/visualstudio.puml
!includeurl ICONURL/devicons2/docker.puml

DEV2_WINDOWS8_ORIGINAL(windows,"",rectangle,#00e0e0) #ffffe0 {
    agent "ワークスペース\non Windows" as wsh
}
DEV2_VISUALSTUDIO(vscode,"",rectangle,#0078d7) #ffffe0 {
    component "Remote Developemnt" as dev
}
DEV2_DOCKER(docker,"",rectangle,#0db7ed) #ffffe0 {
    agent "ワークスペース\non devcontainer" as wsc
    agent "サービスコンテナ" as svc
}

wsh -> dev
dev -> wsc : mount
wsc .> svc : ???
@enduml
```

:::note info
Remote Development経由でdevcontainerのワークスペースをvscodeで開いた場合、gitの認証情報はホストでの情報を引き継いで利用できるようです。

```bash:devcontainer上ではcredential.helperは関数で定義されている
$ git config --global -l | grep credential
credential.helper=!f() { /home/vscode/.vscode-server/bin/e7f30e38c5a4efafeec8ad52861eb772a9ee4dfb/node /tmp/vscode-remote-containers-3cf86db779288bfea4d1bb570de933a68c3055de.js $*; }; f
```

仕組みとしてはおそらく上記のところで、認証処理をRemote Containersの拡張機能に含まれるスクリプトに紐付けてホストの認証情報に問い合わせてるのだと思われます。
ちなみに私の環境ではpushの際に以下のエラーが出ていますが、特にpushが失敗するわけでもないので今のところは手当していません。

```bash:git pushでエラーが出ているがその後問題なくリモートに反映されている
$ git push
<3>init: (1388) ERROR: UtilConnectUnix:467: connect failed 111
<3>init: (1391) ERROR: UtilConnectUnix:467: connect failed 111
Enumerating objects: 7, done.
Counting objects: 100% (7/7), done.
Delta compression using up to 8 threads
Compressing objects: 100% (4/4), done.
Writing objects: 100% (6/6), 1.04 KiB | 1.04 MiB/s, done.
Total 6 (delta 1), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (1/1), completed with 1 local object.
To https://github.com/aaaaaaa/bbbbbb
   04f2bcc..7749211  develop -> develop
```
:::

## devcontainerとサービスコンテナを同じネットワークで接続する

さて、ワークスペースをUnix系に押し込めることに成功しましたが、このままだとdevcontainerからサービスコンテナに対して通信することができません。
例えばmysqlのコンテナを立てた時にdevcontainer上から直接データをのぞいたり、ユニットテストがDBを触ることがあってもdevcontainer上で直接テストを行ったりしたいですよね？
devcontainerとサービスコンテナを通信できるようにするためにはdocker用のネットワークを作成し、それぞれのコンテナをネットワークでつなげてやる必要があります。

```bash:local-nw の名称でネットワークを作成する
$ docker network create local-nw
```

サービスコンテナの起動時にオプションでネットワークを指定することで、コンテナをネットワークに接続できます。

```bash:mysqldをネットワークに接続して起動する
$ docker run -d --name mysql --network=local-nw mysql:8.0 mysqld
```

docker-composeを使う場合はexternal属性を指定することで作成済のネットワークを指定できます。

```yaml
services:
  mysql:
    image: mysql:8.0
    container_name: mysql
    networks: local
    command: mysqld

networks:
  local:
    name: local-nw
    external: true
```

```plantuml
@startuml
!define ICONURL https://raw.githubusercontent.com/tupadr3/plantuml-icon-font-sprites/v2.4.0
!includeurl ICONURL/common.puml
!includeurl ICONURL/devicons2/windows8_original.puml
!includeurl ICONURL/devicons2/visualstudio.puml
!includeurl ICONURL/devicons2/docker.puml

DEV2_WINDOWS8_ORIGINAL(windows,"",rectangle,#00e0e0) #ffffe0 {
    agent "ワークスペース\non Windows" as wsh
}
DEV2_VISUALSTUDIO(vscode,"",rectangle,#0078d7) #ffffe0 {
    component "Remote Developemnt" as dev
}
DEV2_DOCKER(docker,"",rectangle,#0db7ed) #ffffe0 {
    agent "ワークスペース\non devcontainer" as wsc
    agent "サービスコンテナ" as svc
}

wsh - dev
dev -> wsc : mount
wsc-> svc : mysql
@enduml
```

:::note warn
docker-compose内で作成したネットワークにdevcontainerを接続することも可能です。
この場合はdocker-composeのプロジェクトに連動してネットワークが作成されるため、事前に `docker network create` をしておく必要がありません。

```yaml:docker-compose.yamlで新規にネットワークを定義
networks:
  local
```

```js:devcontainer.jsonではdocker-composeで構築したネットワークを参照
// ネットワーク名はdocker-composeのプロジェクト名 + 内部で指定したネットワーク名になる
{
  "runArgs": [ "--networks=hoge_local" ]
}
```

ただしこの構成の場合は

- devcontainer起動中に `docker-compose down` するとネットワーク削除がエラーになる
    - ネットワークが消えないだけで特に実害はなさそうだが、、
- `docker-compose up` でネットワークを作成してからでないとdevcontainerの起動ができない

などの問題があるため、 **個人的にはあまりお勧めしません。**

ただし、チーム開発などの場合は `docker-compose up` の前に `docker network create` が必要になるというのが、初期構築の際の負荷になる場合もあります。
上記のようなデメリットがあることを理解した上であれば作業負荷を低減するために、devcontainerをdocker-compose上のネットワークに接続する構成というのも選択肢の一つにはなりうることもあります。
:::

## ホスト側のワークスペースの配置パスをWindows => WSLへ

pythonやrubyなどスクリプトがそのまま動く言語ではdevcontainerのワークスペースと同じ階層をサービスコンテナにもマウントすることで編集内容をそのまま動作させることができますが、GoやTypeScriptなどのコンパイルやトランスパイルが必要な言語ではソースの編集後にもう一処理追加してやる必要があります。
そしてソースを修正後のコンパイルは手動で実行するのではなく、Goの場合はair、TypeScriptの場合はdev-serverなどを用いていわゆるホットリロードによって対処することが多いと思います。
Unix系環境ではホットリロードの仕組みをinotifyのファイルイベントをトリガーに動作する仕組みが多いですが、ここで問題になるのがWindowsからコンテナにマウントしたファイル群は編集してもinotifyのイベント通知が飛んできません。

:::note info
Windowsの制限と言うよりはWSL2の上drvfsを利用した際の制限。
Docker Desktopを用いてWindows側のファイルをコンテナにマウントする場合、一度WSL上でdrvfsを用いて `/mnt/C` 配下に配置されるので、Docker側ではそのファイルパスをベースにマウントすることができます。
:::

:::note warn
WSL1 + drvfsではinotifyの通知をサポートしていたようですが、WSL2では通知を受け取ることはありません。
:::

この点を解消するためには、ワークスペースのファイルをWindows側のファイルパスに配置しない、つまりWSL側で `/mnt` 以外のパスに配置するようにします。
具体的にはWindowsからは `\\wsl$\[distribution name]` でWSLのディストリビューション配下のファイルパスにアクセスできるのでそこを経由してファイルを配置するか、WSLにログインして以下でファイルを移動することで実現します。

```bash:WSL上でファイルを移動する
# 権限によっては失敗するのでcpのほうがよいかも
$ mv /mnt/C/Users/[user]/some-project /home/[user]/some-project
```

このとき、WSL側でログインされるユーザーが操作できるパスにワークスペースのファイル群を配置する必要があります。

```plantuml
@startuml
!define ICONURL https://raw.githubusercontent.com/tupadr3/plantuml-icon-font-sprites/v2.4.0
!includeurl ICONURL/common.puml
!includeurl ICONURL/devicons2/ubuntu.puml
!includeurl ICONURL/devicons2/visualstudio.puml
!includeurl ICONURL/devicons2/docker.puml

DEV2_UBUNTU(ubuntu,"",rectangle,#dd4814) #ffffe0 {
    agent "ワークスペース\non ubuntu on WSL" as wsh
}
DEV2_VISUALSTUDIO(vscode,"",rectangle,#0078d7) #ffffe0 {
    component "Remote Developemnt" as dev
}
DEV2_DOCKER(docker,"",rectangle,#0db7ed) #ffffe0 {
    agent "ワークスペース\non devcontainer" as wsc
    agent "サービスコンテナ" as svc
}

wsh - dev
dev -> wsc : mount
wsc-> svc : inotify\nnotification
@enduml
```

## devcontainerからdockerを操作できるようにする

WSLからサービスコンテナにファイルをマウントすることで、inotifyの通知を受け取ることができるようになりました。
これで大体の作業はdevcontainer内で完結できるようになったのですが、今のところではコンテナのログを見たり作り直したりするのにはホストに切り替えて操作を行う必要があります。
そのため、Dockerの操作についてもdevcontainer内で行えるようにしていきます。

### Dockerクライアントのインストール

まず、dockerコマンドとdocker-composeコマンドをdevcontainer内で利用できるようにクライアントをインストールします。
devcontainerのOSとしてDebian以外を選択した場合はgpgの周りを適宜調整してください。
(Ubuntuで構築したなら `debian` となっている部分を `ubuntu` と変えれば動きます)


```dockerfile:devcontainer用のDockerfileに記述する
# dockerクライアントとdocker-composeをインストール
RUN mkdir -p /etc/apt/keyrings \
    && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
    && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null \
    && apt update -y && apt autoremove -y \
    && apt install -y docker-ce-cli docker-compose-plugin

# aptで取得されるdocker-composeは古いので新しいバージョンに置き換える
ARG DCVERSION="2.10.2"
ENV DCURL="https://github.com/docker/compose/releases/download/v${DCVERSION}/docker-compose-linux-x86_64"
RUN curl -L ${DCURL} -o /usr/local/bin/docker-compose && chmod +x /usr/local/bin/docker-compose
```

### Dockerクライアントの通信先をホストのDockerデーモンに向ける

Dockerのデーモンが動いているのはdevcontainer内でなくWSL環境です。
そのためDockerのクライアントが操作する先のデーモンをWSLに向けます。
Dockerのクライアントから外部のデーモンに通信するためにはデーモン側でTCP接続を許可してDOCKER_HOST環境変数を指定するのがオーソドックスですが、Dockerデーモン自体が稼働させているコンテナからホストのデーモンを操作する場合に限りDocker outside of Docker(DooD)という仕組みを使って簡易に対応できます。

```js:devcontainer.jsonでホストのsockをコンテナにマウントすることでホストのデーモンと通信できる
	"mounts": [
		"type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock"
	],
```

:::note warn
DooDについて調べると
```bash
docker run -v /var/run/docker:/var/run/docker ...
```
のように省略記法で説明されている場合がありますが、devcontainerで使う場合は `mounts` 要素は `--mount` オプションに相当するもので省略記法は使えないので注意してください。

これとは別にdocker-compose.yamlを用意してマウント定義をする場合は、volumesで定義することになるので省略記法での指定もできるようです。

```yaml
services:
  app:
    volumes:
      - /var/run/docker:/var/run/docker 
```
:::

### WSL上のワークスペースの配置をコンテナ内とそろうように移動する

ここまででdevcontainer内からの `docker-compose ps` や `docker-compose logs mysql` のコマンドは動くようになりますが、`docker-compose down` とした後に `docker-compose up (-d)` とするとコンテナが起動できないことがあり、起動してもコンテナ内の処理で失敗することがあります。
ここで失敗するのはホストから **相対パスで指定したパス** をマウントしている場合で、ワークスペースのパスがWSL上とdevcontainer上でずれているとdevcontainer上のdockerコマンドからはマウントすることができなくなります。
(ここを後で強調して説明するために、前の手順ではあえて失敗するパスにワークスペースを配置していました)

この点を解消するためには、WSL上でのワークスペースのファイルをコンテナにマウントされた時と同じ配置になるように移動します。
devcontainerではデフォルトで `/workspaces/[プロジェクト名]` でワークスペースをマウントするので、WSL上でも同じように配置します。

```bash:WSL上でプロジェクトの配置を変更する
$ mv /home/[user]/some-project /workspaces/some-project
```

:::note warn
もしWSL上でこのパスをすでに利用していた場合は、別のパスに配置した上でマウント先を変更する必要があります。

```bash:WSL上で配置を変更する
$ mv /home/[user]/some-project /dev-workspace/some-project
```

```js:devcontainer.jsonでワークスペースのマウント先と配置を変更する
    "workspaceMount": "src=/dev-workspace/some-project,target=/dev-workspace/some-project,type=bind",
    "workspaceFolder": "/dev-workspace/some-project"
```

:::

# 最終的な設定ファイル

これらを踏まえてdevcontainerの環境を作った結果、以下のようになりました。

```dockerfile:.devcontainer/Dockerfile
ARG GO_VERISION="1.19-bullseye"

# [Choice] Go version (use -bullseye variants on local arm64/Apple Silicon): 1, 1.19, 1.18, 1-bullseye, 1.19-bullseye, 1.18-bullseye, 1-buster, 1.19-buster, 1.18-buster
FROM mcr.microsoft.com/vscode/devcontainers/go:0-${GO_VERISION}

# [Choice] Node.js version: none, lts/*, 18, 16, 14
ARG NODE_VERSION="none"
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi

# [Optional] Uncomment this section to install additional OS packages.
RUN apt update -y \
    && export DEBIAN_FRONTEND=noninteractive \
    && apt autoremove -y \
    && apt update -y \
    && apt -y install --no-install-recommends \
    curl \
    git \
    gnupg

# Docker
RUN mkdir -p /etc/apt/keyrings \
    && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
    && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null \
    && apt update -y && apt autoremove -y \
    && apt install -y docker-ce-cli docker-compose-plugin

# docker-composeは古いので指定したバージョンに更新
ARG DCVERSION="2.10.2"
ENV DCURL="https://github.com/docker/compose/releases/download/v${DCVERSION}/docker-compose-linux-x86_64"
RUN curl -L ${DCURL} -o /usr/local/bin/docker-compose && chmod +x /usr/local/bin/docker-compose

USER vscode
```

```js:.devcontainer/devcontainer.json
{
	"name": "Go",
	"build": {
		"dockerfile": "Dockerfile",
		"args": {
			"GO_VERISION": "1.19-bullseye",
			"NODE_VERSION": "18"
		}
	},
	"runArgs": [
		"--cap-add=SYS_PTRACE",
		"--security-opt",
		"seccomp=unconfined",
		"--network=local-nw"
	],
	"mounts": [
		"type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock"
	],
	"remoteUser": "vscode"
}
```

# 終わりに

今までは同じワークスペースでも操作に応じてvscodeを複数開くと言ったことをやっていましたが、これでdevcontainerの起動だけをホスト側で行えばその他のことはdevcontainer内で完結できるようになりました。
まだ作ったばかりで使用感は完全には測れていませんが、vscodeのプロセスが減らせてマシンが軽くなったのとコンテキストスイッチが減って集中が続くようになっただけでも、この構成にした甲斐はあるかなと感じています。

もっとこう作った方がいいとかご意見ありましたらコメントいただければと思います。