1クリックで呼び出せるクラウド開発環境をCoderで構築する
あらすじ (Motivate)
そもそも私が「クラウド(リモート)開発環境がほしい!!」と思ったきっかけは長期間の入院でした。
入院先に持ち込めるのはせいぜいノートパソコン1台で、メモリもそこまで潤沢ではないのでWSLが動かず趣味の開発が思うようにできませんでした。適当なLinuxを入れても良かったのですが、そうするとゲームが動かないし...(最近は互換レイヤーがあるけど原神みたいなソシャゲはほぼ動かない)といった具合で決定的な解決策が用意できないままでした。
退院したあとしばらくクラウド開発環境熱は冷めていましたが、大学に進学してみて家以外での空き時間が増え、「外で趣味のプロジェクトを触りたい」と思う機会がまた増えました。
また扱う言語やツールが増えるにつれて「環境を汚したくない」と感じる機会がとても増えました。
その後GitHub Codespacesなるものの存在を知りをしばらく試してみました。確かに便利でちょっとしたプロジェクトを扱うにはぴったりですが、無償枠だと性能が微妙に足りず重いプロジェクトを扱うには少し不便でした。
そんな中見つけたのがCoderというソフトです。オープンソースで開発されていて、Goで書かれています。一言で説明するなら「セルフホスト型のGitHub Codespacesの代替」ですが実際にはそれ以上の機能を持っている優れ物です。
用語
基本となる概念と用語を押さえておかないと少し理解しずらいのでまとめておきます。
Template
開発環境のテンプレートです。今回はオンプレでもクラウドでも使いやすいDockerをランタイム(実行環境)として使ったテンプレートを使用しますが、公式ドキュメントにもある通りGCPやAzure、AWSのVMやコンテナをランタイムとして使うこともできます。
Workspace
WorkspaceはTemplateから作られるコンテナです。ここにリモート接続して実際に作業を行います。Dockerをランタイムとして選んだ場合、実体はDockerコンテナになります。個々のWorkspaceは隔離されています。環境が汚れても削除して同一Templateから作り直せばまたクリーンな環境が用意できます。Templateで指定したディレクトリ(通常はホームディレクトリ)はVolumeとして永続化されるため、サーバーを再起動しても明示的にWorkspaceごと削除しない限り作業内容は消えません。
またDockerとVolumeを使った仕組みのおかげで、ベースとなっているTemplateの内容が更新された場合でもWorkspaceを削除せずにTemplateの更新内容を適用することができます。
筆者がDocker以外のランタイムを使ったテンプレートを試していないため、GCPなどのVMを使ったテンプレートを選んだ場合どのようにして永続化やコンテナのライフサイクルの管理がされるのかは分かりませんでした。知っている人がいたら教えてください...
構築
Coderのインストール
今回はCoderの本体はスクリプトでインストールし、Workspaceの実行にはDockerを使う方法で紹介します。
# Dockerのインストール(既にされてる場合省略可能)
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install docker
# Coderのインストール
curl -L https://coder.com/install.sh | sh
# Dockerを使用する権限を付与
sudo gpasswd -a coder docker
# サービスの有効化
sudo systemctl enable --now coder
DockerでCoder本体もインストールしたい場合などは公式ドキュメントの手順に従ってください。
リバースプロキシの設定
127.0.0.1:3000
でlistenしているので適当なリバースプロキシを設定してアクセスできるようにします。
初期設定
メールアドレス、ユーザー名を聞かれるので入力します。ここではGitHubアカウントの(GitHub以外のGitサービスを使ってる場合そのサービスの)登録に使ってるユーザー名とメールアドレス を入力してください。全く無関係なアドレスでも動きますが、テンプレートを書くときにこちらの方が圧倒的に便利です。また当然ですがパスワードは使い回さずに強力なものを設定してください。
SSHキー登録
ヘッダーのアカウントメニューから"Account" -> "SSH Keys"にアクセスします。生成されたSSHの公開鍵が出てくるのでそれをGitHubに登録します。認証用と署名用、どちらにも登録してください。
Template作成
Templatesページから "Create Template" -> "Choose a starter template" を選択し "Docker Containers" を元にテンプレートを作成します。
名前を適当に設定したら3点メニューから"Edit files"を選択します。
以下の内容を貼り付けてください。標準のテンプレートではCoderがフォークしたcode-serverを使っていますがこれだとCopilotや一部の拡張機能が使えないのでMicrosoft公式のVSCodeを入れています。今回のサンプルでは以下のような環境を構築します。使う言語などに応じて調整してください。
- GoのコンパイラとVSCodeの拡張機能を入れる
- nvmでnode20と22を入れる
- Coderが生成した鍵でGitコミットの署名をするように設定する
- 他ESLintやSvelteの拡張機能を入れる
-
https://github.com/[coderユーザー名]/[Workspace作成時に入力されたプロジェクト名]
をCloneしてそれをVSCodeのブラウザ版で開けるようにする
build/Dockerfile
FROM ubuntu
RUN apt-get update \
&& apt-get install -y \
curl \
git \
gpg \
jq \
golang \
vim \
wget \
nginx \
&& rm -rf /var/lib/apt/lists/*
ARG USER=coder
RUN useradd --no-create-home --shell /bin/bash ${USER} \
&& chown -R ${USER}:${USER} /var/lib/nginx /var/log/nginx/
USER ${USER}
WORKDIR /home/${USER}
main.tf
localsのsettings
には任意でお好みのVSCodeの設定のjsonを貼り付けます。extensions
などは必要に応じて調整してください。
terraform {
required_providers {
coder = {
source = "coder/coder"
}
docker = {
source = "kreuzwerker/docker"
}
}
}
module "git-commit-signing" {
source = "registry.coder.com/modules/git-commit-signing/coder"
version = "1.0.11"
agent_id = coder_agent.main.id
}
module "nodejs" {
source = "registry.coder.com/modules/nodejs/coder"
version = "1.0.10"
agent_id = coder_agent.main.id
node_versions = [
"20",
"22",
"node"
]
default_node_version = "20"
}
locals {
username = data.coder_workspace_owner.me.name
settings = {}
extensions = [
"github.copilot",
"github.copilot-chat",
"akamud.vscode-theme-onelight",
"pkief.material-icon-theme",
"dbaeumer.vscode-eslint",
"golang.go",
"rust-lang.rust-analyzer",
"svelte.svelte-vscode",
"prisma.prisma"
]
}
data "coder_provisioner" "me" {
}
provider "docker" {
}
data "coder_workspace" "me" {
}
data "coder_workspace_owner" "me" {}
resource "coder_agent" "main" {
arch = data.coder_provisioner.me.arch
os = "linux"
startup_script = <<-EOT
set -e
# Prepare user home with default files on first start.
if [ ! -f ~/.init_done ]; then
cp -rT /etc/skel ~
touch ~/.init_done
fi
# install and start code-server
# curl -fsSL https://code-server.dev/install.sh | sh -s -- --method=standalone --prefix=/tmp/code-server --version 4.19.1
# ~/.vscode-web/bin/code-server --auth none --port 13337 --accept-server-license-terms >/tmp/code-server.log 2>&1 &
EOT
# These environment variables allow you to make Git commits right away after creating a
# workspace. Note that they take precedence over configuration defined in ~/.gitconfig!
# You can remove this block if you'd prefer to configure Git manually or using
# dotfiles. (see docs/dotfiles.md)
env = {
GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}"
GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}"
}
# The following metadata blocks are optional. They are used to display
# information about your workspace in the dashboard. You can remove them
# if you don't want to display any information.
# For basic resources, you can use the `coder stat` command.
# If you need more control, you can write your own script.
metadata {
display_name = "CPU Usage"
key = "0_cpu_usage"
script = "coder stat cpu"
interval = 10
timeout = 1
}
metadata {
display_name = "RAM Usage"
key = "1_ram_usage"
script = "coder stat mem"
interval = 10
timeout = 1
}
metadata {
display_name = "Home Disk"
key = "3_home_disk"
script = "coder stat disk --path $${HOME}"
interval = 60
timeout = 1
}
metadata {
display_name = "CPU Usage (Host)"
key = "4_cpu_usage_host"
script = "coder stat cpu --host"
interval = 10
timeout = 1
}
metadata {
display_name = "Memory Usage (Host)"
key = "5_mem_usage_host"
script = "coder stat mem --host"
interval = 10
timeout = 1
}
metadata {
display_name = "Load Average (Host)"
key = "6_load_host"
# get load avg scaled by number of cores
script = <<EOT
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
EOT
interval = 60
timeout = 1
}
metadata {
display_name = "Swap Usage (Host)"
key = "7_swap_host"
script = <<EOT
free -b | awk '/^Swap/ { printf("%.1f/%.1f", $3/1024.0/1024.0/1024.0, $2/1024.0/1024.0/1024.0) }'
EOT
interval = 10
timeout = 1
}
}
resource "coder_script" "vscode-web" {
agent_id = coder_agent.main.id
display_name = "VS Code Web"
icon = "/icon/code.svg"
script = templatefile("${path.module}/run.sh", {
LOG_PATH : "/tmp/vscode-web.log",
INSTALL_PREFIX : "/home/${local.username}/.vscode-web",
EXTENSIONS : join(",", local.extensions),
TELEMETRY_LEVEL : "off",
// This is necessary otherwise the quotes are stripped!
SETTINGS : replace(jsonencode(local.settings), "\"", "\\\""),
OFFLINE : false,
USE_CACHED : false,
EXTENSIONS_DIR : "",
FOLDER : "/home/${local.username}",
AUTO_INSTALL_EXTENSIONS : true,
URL_PREFIX: "/@${local.username}/${data.coder_workspace.me.name}.main/apps/vscode-web",
USERNAME: local.username,
PROJECT_NAME: data.coder_parameter.clone_project.value,
})
run_on_start = true
}
resource "coder_app" "vscode-web" {
agent_id = coder_agent.main.id
slug = "vscode-web"
display_name = "VS Code Web"
url = "http://localhost:3000?folder=/home/${local.username}/${data.coder_parameter.clone_project.value}"
icon = "/icon/code.svg"
subdomain = false
share = "owner"
order = null
healthcheck {
url = "http://localhost:3000/healthz"
interval = 5
threshold = 6
}
}
resource "docker_volume" "home_volume" {
name = "coder-${data.coder_workspace.me.id}-home"
# Protect the volume from being deleted due to changes in attributes.
lifecycle {
ignore_changes = all
}
# Add labels in Docker to keep track of orphan resources.
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
# This field becomes outdated if the workspace is renamed but can
# be useful for debugging or cleaning out dangling volumes.
labels {
label = "coder.workspace_name_at_creation"
value = data.coder_workspace.me.name
}
}
resource "docker_image" "main" {
name = "coder-${data.coder_workspace.me.id}"
build {
context = "./build"
build_args = {
USER = local.username
}
}
triggers = {
dir_sha1 = sha1(join("", [for f in fileset(path.module, "build/*") : filesha1(f)]))
}
}
resource "docker_container" "workspace" {
count = data.coder_workspace.me.start_count
image = docker_image.main.name
# Uses lower() to avoid Docker restriction on container names.
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
# Hostname makes the shell more user friendly: coder@my-workspace:~$
hostname = data.coder_workspace.me.name
# Use the docker gateway if the access URL is 127.0.0.1
entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")]
env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
host {
host = "host.docker.internal"
ip = "host-gateway"
}
volumes {
container_path = "/home/${local.username}"
volume_name = docker_volume.home_volume.name
read_only = false
}
# Add labels in Docker to keep track of orphan resources.
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
labels {
label = "coder.workspace_name"
value = data.coder_workspace.me.name
}
}
data "coder_parameter" "clone_project" {
name = "Project name"
description = "Project name to clone from a GitHub repository"
icon = "/icon/github.svg"
type = "string"
}
run.sh (なければ作成)
#!/usr/bin/env bash
BOLD='\033[0;1m'
EXTENSIONS=("${EXTENSIONS}")
VSCODE_WEB="${INSTALL_PREFIX}/bin/code-server"
# Set extension directory
EXTENSION_ARG=""
if [ -n "${EXTENSIONS_DIR}" ]; then
EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
fi
run_vscode_web() {
echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG --port 13338 --host 127.0.0.1 --server-base-path ${URL_PREFIX} --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..."
echo "Check logs at ${LOG_PATH}!"
"$VSCODE_WEB" serve-local "$EXTENSION_ARG" --port 13338 --host 127.0.0.1 --server-base-path "${URL_PREFIX}" --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 &
}
# Check if the settings file exists...
if [ ! -f ~/.vscode-server/data/Machine/settings.json ]; then
echo "⚙️ Creating settings file..."
mkdir -p ~/.vscode-server/data/Machine
echo "${SETTINGS}" > ~/.vscode-server/data/Machine/settings.json
fi
# Check if vscode-server is already installed for offline or cached mode
if [ -f "$VSCODE_WEB" ]; then
if [ "${OFFLINE}" = true ] || [ "${USE_CACHED}" = true ]; then
echo "🥳 Found a copy of VS Code Web"
run_vscode_web
exit 0
fi
fi
# Offline mode always expects a copy of vscode-server to be present
if [ "${OFFLINE}" = true ]; then
echo "Failed to find a copy of VS Code Web"
exit 1
fi
# Create install prefix
mkdir -p ${INSTALL_PREFIX}
printf "$${BOLD}Installing Microsoft Visual Studio Code Server!\n"
# Download and extract vscode-server
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH="x64" ;;
aarch64) ARCH="arm64" ;;
*)
echo "Unsupported architecture"
exit 1
;;
esac
HASH=$(curl -fsSL https://update.code.visualstudio.com/api/commits/stable/server-linux-$ARCH-web | cut -d '"' -f 2)
output=$(curl -fsSL https://vscode.download.prss.microsoft.com/dbazure/download/stable/$HASH/vscode-server-linux-$ARCH-web.tar.gz | tar -xz -C ${INSTALL_PREFIX} --strip-components 1)
if [ $? -ne 0 ]; then
echo "Failed to install Microsoft Visual Studio Code Server: $output"
exit 1
fi
printf "$${BOLD}VS Code Web has been installed.\n"
# Install each extension...
IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}"
for extension in "$${EXTENSIONLIST[@]}"; do
if [ -z "$extension" ]; then
continue
fi
printf "🧩 Installing extension $${CODE}$extension$${RESET}...\n"
output=$($VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force)
if [ $? -ne 0 ]; then
echo "Failed to install extension: $extension: $output"
exit 1
fi
done
if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
if ! command -v jq > /dev/null; then
echo "jq is required to install extensions from a workspace file."
exit 0
fi
WORKSPACE_DIR="$HOME"
if [ -n "${FOLDER}" ]; then
WORKSPACE_DIR="${FOLDER}"
fi
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
extensions=$(jq -r '.recommendations[]' "$WORKSPACE_DIR"/.vscode/extensions.json)
for extension in $extensions; do
$VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force
done
fi
fi
# Config nginx
cat > "${FOLDER}/.nginx.conf" <<EOF
error_log /tmp/nginx.error.log warn;
pid /tmp/nginx.pid;
events {}
http {
map \$http_upgrade \$connection_upgrade {
default upgrade;
'' close;
}
server {
listen 3000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection \$connection_upgrade;
location = / {
proxy_pass http://127.0.0.1:13338URL_PREFIX_PATH_PLACEHOLDER;
}
location / {
proxy_pass http://127.0.0.1:13338URL_PREFIX_PATH_PLACEHOLDER/;
}
}
}
EOF
sed -i "s|URL_PREFIX_PATH_PLACEHOLDER|${URL_PREFIX}|g" "${FOLDER}/.nginx.conf"
cat "${FOLDER}/.nginx.conf"
nginx -c "${FOLDER}/.nginx.conf"
# Clone project (使ってるサービスに合わせて適当に変更)
ssh-keyscan github.com >> ~/.ssh/known_hosts
if [ ! -d "./${PROJECT_NAME}" ]; then
git clone "git@github.com:${USERNAME}/${PROJECT_NAME}.git"
fi
run_vscode_web
TemplateのPublish
"build"をクリックして通ったら"publish"でテンプレートを利用可能にします。
Workspaceの作成
"Workspace" ページの "Create Workspace..." をクリックして先ほど作ったテンプレートからWorkspaceを作成します。
テンプレートの該当部分を改変していなければ "Project name" の入力を求められるはずなので自分のGitHubリポジトリにあるプロジェクトを適当に指定します。
作成には少し時間がかかることがありますが気長に待ちましょう。
VSCode Webの起動
このような緑の枠の画面になれば成功です。"VS Code Web"をクリックすればこんな感じで開発環境が立ち上がってきます。
あとは先ほど作成したテンプレートを煮るなり焼くなりして、自分の好みの開発環境を整備してください。
おまけ1: 直接接続ができないときのSTUN遅すぎる問題を解決する
/etc/coder.d/coder.env
に以下の内容を追記します。
CODER_DERP_CONFIG_URL=https://controlplane.tailscale.com/derpmap/default
CoderではなくTailscaleの運営しているDERPサーバーを使ってプロキシすることでだいぶレイテンシーが減ってくれます。
おまけ2: Cloudflare Accessと公式クライアントのSSHを共存させて、Webアプリ開発時のポート転送問題を解決する
Coder本体にcoder.app
下のドメインでホストしてくれるトンネル機能があるのでそれを使ってポートを転送することもできます。ただこれだとアメリカのサーバーを経由することになってしまい、速度がとても微妙です。
前のセクションのSTUN遅すぎる問題の対策をしてトンネル経由でもそれなりに速度が出るようにした上で、クライアント側でSSHを設定してトンネルを張ることでだいぶ高速になりますが一つ問題が残ります。Cloudflare Accessなどを使ってるとそのままでは認証が通らず使えません。ヘッダーを設定する機能とService Authを使って認証を行う必要があります。
Step1: Cloudflare Access側でService Authの設定をする
Cloudflare ZeroTrustの微妙に使いにくいダッシュボードと格闘してサービストークンを発行します。やり方は調べれば出てきます。以前Immichで同じことやろうとしたときに自分が書いた記事も参考になるかもしれません。
Step2: 環境変数を設定
クライアント側のbashrcなりzshrcなりに以下の内容を追記します。
export CODER_HEADER="CF-Access-Client-Id=[発行したクライアントID],CF-Access-Client-Secret=[発行したシークレット]"
Step3: SSHを設定してトンネルを張る
公式ドキュメントの通りに設定してトンネルを貼ります。
nexryai@M2-Mini ~ % coder login https://coder.nexryai.me/
Attempting to authenticate with config URL: 'https://coder.nexryai.me/'
Your browser has been opened to visit:
https://coder.nexryai.me/cli-auth
> Paste your token here:
> Welcome to Coder, nexryai! You're authenticated.
nexryai@M2-Mini ~ % coder config-ssh
> The following changes will be made to your SSH configuration:
* Update the coder section in /Users/nexryai/.ssh/config
Continue? (yes/no) yes
Updated "/Users/nexryai/.ssh/config"
You should now be able to ssh into your workspace.
For example, try running:
$ ssh coder.hoge-fuge
# 127.0.0.1:8080 <== Workspaceのコンテナ:8080
nexryai@M2-Mini ~ % ssh -L 8080:localhost:8080 coder.hoge-fuge
Discussion