はじめに
かなり前に、私はKubernetes上にエフェメラルな開発環境を構築して、そのコンテナの中で開発をしていることを記事にしていましたが、現在はそちらの内容や状況が変わったため、改めて記事にしようと思いました。
どの程度コンテナの中で開発をしたい!と考える方がいるのかわかりませんが、そういった方々の参考に少しでもなれば幸いです。
コンテナ上で開発する理由
私が開発する上で、試行錯誤する段階では、様々なアプリケーションをインストールしたり、ホストの設定を試しに変更しながら開発をしたくなる場面が多々ありました。また、自分の業務内容はKubernetes関連の開発業務が多く、クラスタ内で開発した方が何かと都合が良い場面もありました。例えばKubernetesクラスタの特定のノードのデバッグを実施する際に、いつも使っている開発環境をそのノード上に展開して直接デバッグできると非常に便利です。そういったことから、私はKubernetesの中で生活することにしました。普段はKubernetes上で動作するコンテナを開発マシンとして利用しており、そのコンテナへexecするような形で開発を行っています。
開発に利用するコンテナイメージ
私が開発に利用するコンテナは、コンテナというよりどちらかというとVMのように扱いたくなるものでした。よりVMっぽく扱えるようにするために、コンテナの中でsystemdを動かしています。コンテナ上でsystemdを動かす方法は、kubernetes-sigs/kindで利用されているノードイメージのDockerfileが参考になります。Dockerfileの全体はこちらを参照いただくとして、systemdを動かすのに重要な部分を抜粋すると、以下です。
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をインストールしたり、必要なアプリケーションをインストールしたものを開発用イメージとして自分は利用しています。
私の利用しているイメージはこちらで公開しています。
clipboardの同期
コマンドラインからテキストデータをクリップボードへリダイレクトしたいことはよくあるかと思います。例えばMacを利用している場合だとpbcopyを、Windowsではclip.exeを利用することでできます。しかし開発用のコンテナはリモート環境にあるKubernetesやdockerコンテナとして実行されているため、ローカルマシンのこれらのコマンドを実行することはできません。
そのため、クリップボード設定用制御文字列であるOSC52を利用しています。OSC52を利用すると、対応しているターミナルであれば、この制御文字列によりテキストデータをシステムのclipboardへ保存してくれます。有名なTerminalでは対応しているものが多くあり、iTerm2やWindows terminal、自分が愛用しているweztermやiPad用のSSHクライアントであるBlink shellなんかでも対応しています。
OSC52をpbcopyやclip.exeのようなコマンドで操作できるようにするために、以下の様なシェルスクリプトでを用意して利用しています。
#!/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というプラグインを利用しています。
credentialの受け渡し
この開発用コンテナでは、何らかの外部サービスに対してアクセスするためにそれらのcredentialが必要となるときが多々あります。開発のための環境であるため、特にgithub上のリポジトリのpush/pullのtokenはほぼ必須といっても過言ではありません。これらcredentialはコンテナへexecする時に環境変数として渡し、可能な限りディスク上に保存しないように運用しています。これはセキュリティを考えてとかそういうものではなく、自分は頻繁にtokenを更新するので、開発コンテナ上それぞれで更新する代わりに、ホストOS側で実施すれば、更新作業が完結するようにしたかったからです。
環境変数でcredentialを受け渡す方法として、dockerであればdocker exec
に-e
オプションがあり、簡単に環境変数を渡すことができます。しかし、kubectl exec
にはそのようなオプションがありません。そのため、開発用コンテナにあらかじめenv
コマンドをインストールしておき、以下のように実行することで環境変数を渡しています。
$ kubectl exec -it devcontainer -- env FOO=bar zsh
# echo $FOO
bar
ユーザ
Kubernetesではkubectl exec
を実行する時、私の知る限りユーザ名やユーザIDを指定できません。
上記で紹介したようにsystemdを実行するにはroot権限が必要であり、その場合kubectl exec
を実行すると、rootユーザとしてexecされますが、一般ユーザとして開発し、必要な時だけrootユーザとして振舞いたかったので、開発用イメージにあらかじめgosuコマンドをインストールしておき、exec時に操作するシェルのユーザを任意のユーザで実行できるようにしています。以下は先ほどのenvコマンドとgosuを組み合わせて、環境変数を渡しつつ、ユーザを変更してexecする例です。
$ kubectl exec -it devbox-default -- env A=BBB gosu devbox zsh
$ echo $A
BBB
$ whoami
devbox
上記のようにすることで、kubectl execするときのユーザと、コンテナのエントリポイントのユーザで別のユーザを指定できるようにしています。
開発用コンテナを操作するためのCLI
開発用コンテナのライフサイクルを管理するために、devkという名前の専用のCLIを作成しています。以下のような理由から専用のツールを作成しています。
- 前述の通り
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は、他にも自分が必要としているいろいろな機能を盛り込んでいるのですが、紹介しきれないため、ここでは簡単な紹介としました。
今後も自分の環境や求めるものが変わっていくことで、これらの開発環境を改善していくつもりです。どの程度の方にこの記事に需要があるかわかりませんが、また変化があれば記事にまとめようと思います。