☁️

1クリックで呼び出せるクラウド開発環境をCoderで構築する

2024/08/08に公開

あらすじ (Motivate)

そもそも私が「クラウド(リモート)開発環境がほしい!!」と思ったきっかけは長期間の入院でした。
入院先に持ち込めるのはせいぜいノートパソコン1台で、メモリもそこまで潤沢ではないのでWSLが動かず趣味の開発が思うようにできませんでした。適当なLinuxを入れても良かったのですが、そうするとゲームが動かないし...(最近は互換レイヤーがあるけど原神みたいなソシャゲはほぼ動かない)といった具合で決定的な解決策が用意できないままでした。
退院したあとしばらくクラウド開発環境熱は冷めていましたが、大学に進学してみて家以外での空き時間が増え、「外で趣味のプロジェクトを触りたい」と思う機会がまた増えました。
また扱う言語やツールが増えるにつれて「環境を汚したくない」と感じる機会がとても増えました。
その後GitHub Codespacesなるものの存在を知りをしばらく試してみました。確かに便利でちょっとしたプロジェクトを扱うにはぴったりですが、無償枠だと性能が微妙に足りず重いプロジェクトを扱うには少し不便でした。
そんな中見つけたのがCoderというソフトです。オープンソースで開発されていて、Goで書かれています。一言で説明するなら「セルフホスト型のGitHub Codespacesの代替」ですが実際にはそれ以上の機能を持っている優れ物です。

https://coder.com/

用語

基本となる概念と用語を押さえておかないと少し理解しずらいのでまとめておきます。

Template

開発環境のテンプレートです。今回はオンプレでもクラウドでも使いやすいDockerをランタイム(実行環境)として使ったテンプレートを使用しますが、公式ドキュメントにもある通りGCPやAzure、AWSのVMやコンテナをランタイムとして使うこともできます。
https://coder.com/docs/templates/creating#starter-templates

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で同じことやろうとしたときに自分が書いた記事も参考になるかもしれません。
https://log.sda1.net/blog/immich-mobile-app-and-cloudflare-access/

Step2: 環境変数を設定

クライアント側のbashrcなりzshrcなりに以下の内容を追記します。

export CODER_HEADER="CF-Access-Client-Id=[発行したクライアントID],CF-Access-Client-Secret=[発行したシークレット]"
Step3: SSHを設定してトンネルを張る

公式ドキュメントの通りに設定してトンネルを貼ります。
https://coder.com/docs/ides#ssh-configuration

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