Kubernetesを用いたGithub Codespacesのようなエフェメラルな開発環境

## はじめに

かなり前に、私はKubernetes上にエフェメラルな開発環境を構築して、その[コンテナの中で開発をしている](https://qiita.com/uesyn/items/166a7653a6cd5499d175)ことを記事にしていましたが、現在はそちらの内容や状況が変わったため、改めて記事にしようと思いました。
どの程度コンテナの中で開発をしたい！と考える方がいるのかわかりませんが、そういった方々の参考に少しでもなれば幸いです。

## コンテナ上で開発する理由

私が開発する上で、試行錯誤する段階では、様々なアプリケーションをインストールしたり、ホストの設定を試しに変更しながら開発をしたくなる場面が多々ありました。また、自分の業務内容はKubernetes関連の開発業務が多く、クラスタ内で開発した方が何かと都合が良い場面もありました。例えばKubernetesクラスタの特定のノードのデバッグを実施する際に、いつも使っている開発環境をそのノード上に展開して直接デバッグできると非常に便利です。そういったことから、私はKubernetesの中で生活することにしました。普段はKubernetes上で動作するコンテナを開発マシンとして利用しており、そのコンテナへexecするような形で開発を行っています。

## 開発に利用するコンテナイメージ

私が開発に利用するコンテナは、コンテナというよりどちらかというとVMのように扱いたくなるものでした。よりVMっぽく扱えるようにするために、コンテナの中でsystemdを動かしています。コンテナ上でsystemdを動かす方法は、[kubernetes-sigs/kind](https://github.com/kubernetes-sigs/kind)で利用されているノードイメージのDockerfileが参考になります。Dockerfileの全体は[こちら](https://github.com/uesyn/dotfiles/blob/main/images/devcontainer/Dockerfile
)を参照いただくとして、systemdを動かすのに重要な部分を抜粋すると、以下です。
 
```Dockerfile
FROM debian:bookworm-slim
RUN DEBIAN_FRONTEND=noninteractive apt update && apt install -y --no-install-recommends \
      systemd \
    && find /lib/systemd/system/sysinit.target.wants/ -name "systemd-tmpfiles-setup.service" -delete \
    && rm -f /lib/systemd/system/multi-user.target.wants/* \
    && rm -f /etc/systemd/system/*.wants/* \
    && rm -f /lib/systemd/system/local-fs.target.wants/* \
    && rm -f /lib/systemd/system/sockets.target.wants/*udev* \
    && rm -f /lib/systemd/system/sockets.target.wants/*initctl* \
    && rm -f /lib/systemd/system/basic.target.wants/* \
    && echo "ReadKMsg=no" >> /etc/systemd/journald.conf \
    && ln -s "$(which systemd)" /sbin/init
# add metadata, must be done after the squashing
# first tell systemd that it is in docker (it will check for the container env)
# https://systemd.io/CONTAINER_INTERFACE/
ENV container docker
# systemd exits on SIGRTMIN+3, not SIGTERM (which re-executes it)
# https://bugzilla.redhat.com/show_bug.cgi?id=1201657
STOPSIGNAL SIGRTMIN+3
ENTRYPOINT ["/sbin/init"]
```

あとはkindのノードイメージと同じようにdockerをインストールしたり、必要なアプリケーションをインストールしたものを開発用イメージとして自分は利用しています。
私の利用しているイメージは[こちら](https://github.com/uesyn/dotfiles/blob/main/images/devcontainer/Dockerfile)で公開しています。

## clipboardの同期

コマンドラインからテキストデータをクリップボードへリダイレクトしたいことはよくあるかと思います。例えばMacを利用している場合だとpbcopyを、Windowsではclip.exeを利用することでできます。しかし開発用のコンテナはリモート環境にあるKubernetesやdockerコンテナとして実行されているため、ローカルマシンのこれらのコマンドを実行することはできません。

そのため、クリップボード設定用制御文字列であるOSC52を利用しています。OSC52を利用すると、対応しているターミナルであれば、この制御文字列によりテキストデータをシステムのclipboardへ保存してくれます。有名なTerminalでは対応しているものが多くあり、iTerm2やWindows terminal、自分が愛用しているweztermやiPad用のSSHクライアントであるBlink shellなんかでも対応しています。
OSC52をpbcopyやclip.exeのようなコマンドで操作できるようにするために、以下の様なシェルスクリプトでを用意して利用しています。

```shell
#!/usr/bin/env bash

if [[ $# == 0 ]]; then
  payload=$(cat -)
else
  payload=$(echo -n "$1")
fi
b64_payload=$(printf "%s" "$payload" | base64 -w0)

# OSC52
printf "\e]52;c;%s\a" "$b64_payload"
```

またneovim上でも編集している内容をclipboardへ保存したくなる時があるため、OSC52を扱うための[ojroques/vim-oscyank](https://github.com/ojroques/vim-oscyank)というプラグインを利用しています。

## credentialの受け渡し

この開発用コンテナでは、何らかの外部サービスに対してアクセスするためにそれらのcredentialが必要となるときが多々あります。開発のための環境であるため、特にgithub上のリポジトリのpush/pullのtokenはほぼ必須といっても過言ではありません。これらcredentialはコンテナへexecする時に環境変数として渡し、可能な限りディスク上に保存しないように運用しています。これはセキュリティを考えてとかそういうものではなく、自分は頻繁にtokenを更新するので、開発コンテナ上それぞれで更新する代わりに、ホストOS側で実施すれば、更新作業が完結するようにしたかったからです。

環境変数でcredentialを受け渡す方法として、dockerであれば`docker exec`に`-e`オプションがあり、簡単に環境変数を渡すことができます。しかし、`kubectl exec`にはそのようなオプションがありません。そのため、開発用コンテナにあらかじめ`env`コマンドをインストールしておき、以下のように実行することで環境変数を渡しています。

```bash
$ kubectl exec -it devcontainer -- env FOO=bar zsh
# echo $FOO
bar
```

## ユーザ

Kubernetesでは`kubectl exec`を実行する時、私の知る限りユーザ名やユーザIDを指定できません。
上記で紹介したようにsystemdを実行するにはroot権限が必要であり、その場合`kubectl exec`を実行すると、rootユーザとしてexecされますが、一般ユーザとして開発し、必要な時だけrootユーザとして振舞いたかったので、開発用イメージにあらかじめ[gosu](https://github.com/tianon/gosu)コマンドをインストールしておき、exec時に操作するシェルのユーザを任意のユーザで実行できるようにしています。以下は先ほどのenvコマンドとgosuを組み合わせて、環境変数を渡しつつ、ユーザを変更してexecする例です。

```bash
$ kubectl exec -it devbox-default -- env A=BBB gosu devbox zsh
$ echo $A
BBB
$ whoami
devbox
```

上記のようにすることで、kubectl execするときのユーザと、コンテナのエントリポイントのユーザで別のユーザを指定できるようにしています。

## 開発用コンテナを操作するためのCLI

開発用コンテナのライフサイクルを管理するために、[devkという名前の専用のCLI](https://github.com/uesyn/dotfiles/tree/main/tools/devk)を作成しています。以下のような理由から専用のツールを作成しています。

- 前述の通り`kubectl exec`時にオプションを渡す工夫をしている
  - 環境変数の指定
  - ユーザの指定
- 開発コンテナのマニフェストはテンプレート化し、使いまわせるようにしたい
- exec時に同時にport-forwardも同時に実行したい
- 誤って開発コンテナを削除できないように保護したい
- 開発コンテナが正常に起動しない場合、関連するリソースのEventオブジェクトをまとめて見たい
- 開発コンテナ作成時に実行ノードを選択したい
- 開発コンテナ作成時にGPUをrequestsに含めるかどうか決定したい etc...

など様々な考慮事項があったためです。最初はシェルスクリプトで実装していたのですが、やりたいことが複雑化していき、途中でGoで書き直しました。以下はそのコマンドのヘルプです。

```
$ devk --help
CLI to manage devcontainers

Usage:
  devk [command]

Available Commands:
  completion  Generate the autocompletion script for the specified shell
  delete      Delete devcontainer
  event       Event devcontainer
  exec        Exec devcontainer
  help        Help about any command
  init        Init devcontainer command
  list        List devcontainer
  protect     Protect devcontainer
  restart     Restart devcontainer
  run         Run devcontainer
  start       Start devcontainer
  stop        Stop devcontainer
  unprotect   Unprotect devcontainer
  update      Update devcontainer

Flags:
      --config string             Path to devcontainer config file, available to overwrite with DEVK_CONFIG env (default "${HOME}/.config/devk/config.yaml")
      --devk-kubeconfig string    Path to devk kubeconfig file (default "${HOME}/.local/share/devk/kubeconfig")
      --devk-kubecontext string   Context name to use in a given devk-kubeconfig file
  -h, --help                      help for devcontainer
  -l, --loglevel string           log level (default "info")

Use "devcontainer [command] --help" for more information about a command.
```

このコマンドを通して開発用コンテナを操作しています。利用イメージとしては以下となります。

```
# 現在kubeconfigでcurrent-contextとなっているクラスタを開発用コンテナを動作させるクラスタとして設定する
$ devk init

# testという名前の開発用コンテナの作成
$ devk run --name test

# 現在作成されている開発用コンテナのリスト
$ devk list
NAME      NAMESPACE   READY   PHASE     NODE                 IPS          PROTECTED
default   default     true    Running   kind-control-plane   10.244.0.8   false
test      default     true    Running   kind-control-plane   10.244.0.6   false

# testという名前の開発用コンテナ関連のEventオブジェクトをwatchする
$ devk event --name test -w
LAST SEEN   TYPE      REASON             OBJECT            MESSAGE
15s         Warning   FailedScheduling   Pod/devbox-test   0/1 nodes are available: persistentvolumeclaim "data-test" not found. preemption: 0/1 nodes are available: 1 No preemption victims found for incoming pod..
10s         Normal    Scheduled          Pod/devbox-test   Successfully assigned default/devbox-test to kind-control-plane
9s          Normal    Pulling            Pod/devbox-test   Pulling image "ghcr.io/uesyn/devcontainer"
...

# testという名前の開発用コンテナへポート8080をフォワーディングしながらexecする
$ devk exec -p 8080:8080 --name test
🍎 ~　(←開発用コンテナで設定されているプロンプト)
```

作成される開発用コンテナやexec時に渡す環境変数などは1つの設定ファイルで設定します。
exec時のオプションは`exec`フィールドで設定し、ユーザ名、コマンド、環境変数の指定などができます。環境変数としてはホストの環境変数を参照したり、固定値を指定したりできるようにしています。
`podSpec`フィールドで開発用コンテナ(というかPodですが)のspecを定義し、`pvcs`や`cms`フィールドは依存しているPVCやConfigMapを定義します。これらは開発用コンテナが作成する時指定する`--name`がsuffixとして付与され、作成される開発用コンテナ毎に独立しています。

```
exec:
  user: devbox
  command:
  - zsh
  envs:
  - name: PROMPT_ICON
    raw: "🍎"
  - name: GITHUB_TOKEN
    hostEnv: GITHUB_TOKEN
podSpec:
  containers:
  - name: devbox
    image: ghcr.io/uesyn/devcontainer
    stdin: true
    tty: true
    workingDir: /home/devbox
    volumeMounts:
    - name: data
      mountPath: /var/lib/docker
      subPath: var-lib-docker
    - name: docker-config
      mountPath: /etc/docker
    securityContext:
      privileged: true
  volumes:
  - name: data
    persistentVolumeClaim:
      claimName: data
  - name: docker-config
    configMap:
      name: docker-config
pvcs:
- kind: PersistentVolumeClaim
  apiVersion: v1
  metadata:
    name: data
  spec:
    accessModes:
    - ReadWriteOnce
    resources:
      requests:
        storage: 50Gi
cms:
- apiVersion: v1
  kind: ConfigMap
  metadata:
    name: docker-config
  data:
    daemon.json: |
      {
        "bip": "172.31.100.1/24",
        "mtu": 1450,
        "dns-search": ["."]
      }
```

## 最後に

専用ツールであるdevkは、他にも自分が必要としているいろいろな機能を盛り込んでいるのですが、紹介しきれないため、ここでは簡単な紹介としました。
今後も自分の環境や求めるものが変わっていくことで、これらの開発環境を改善していくつもりです。どの程度の方にこの記事に需要があるかわかりませんが、また変化があれば記事にまとめようと思います。