写在最前

https://coder.com/

在大规模项目的部署与开发过程中,传统基于 Docker 的单机容器管理方式已难以满足需求。随着服务规模扩大至数十甚至上百个容器,容器的生命周期、网络、资源及依赖关系的管理成本急剧上升,整体复杂度显著增加。

为解决这一问题,通常需要引入 Kubernetes 作为容器编排平台,对容器进行统一调度与管理。然而,在 Kubernetes 环境下,Pod 默认运行于集群内部的虚拟网络(Pod CIDR)中,仅集群节点可直接访问,外部开发人员无法直接连接 Pod 进行调试与开发。

一种常见但不理想的解决方式是将 Pod 切换为 hostNetwork 模式,使其直接绑定宿主机网络,从而实现访问能力。但该方案存在明显缺陷:端口资源无法复用,多个容器无法并行启动;Pod 重建后访问地址不稳定,开发人员难以及时定位服务入口,严重影响开发效率。

因此,在 Kubernetes 场景下,需要一种既能保持集群网络隔离性,又能为开发人员提供稳定、可控访问入口的开发模式与配套机制。

在此背景下,可以引入云端开发技术 Coder 作为解决方案。Coder 以开源服务的形式运行在 Kubernetes 集群中,将每位开发人员抽象为一个独立的 Pod,并允许本地开发工具(如 VS Code、IDEA)通过远程连接的方式直接进入容器内部进行开发。

由于开发环境本身运行在 Kubernetes 集群内,开发人员天然处于集群网络中,所有集群内资源均可直接访问,无需额外的网络打通或端口暴露配置。同时,该模式有效避免了本地开发环境与集群环境不一致的问题,极大的降低了环境复现和依赖冲突的成本。

此外,开发人员无需再维护复杂且易出错的本地 hosts 配置文件。无论身处何地,只要具备网络连接,即可快速接入统一的云端开发环境,彻底摆脱本地环境无法满足而导致程序无法开发的困境。

前置条件

1. docker 部署

2. kubernetes 部署

https://coder.com/docs/install/kubernetes

# 1. 创建namespace
kubectl create namespace coder

# 2. 使用helm部署postgresql 
helm repo add bitnami https://charts.bitnami.com/bitnami
helm install postgresql bitnami/postgresql \
    --namespace coder \
    --set image.repository=bitnamilegacy/postgresql \
    --set auth.username=coder \
    --set auth.password=coder \
    --set auth.database=coder \
    --set primary.persistence.size=10Gi

# 3. 将postgresql连接地址抽取为secret
kubectl create secret generic coder-db-url -n coder \
  --from-literal=url="postgres://coder:coder@postgresql.coder.svc.cluster.local:5432/coder?sslmode=disable"

# 4. 添加coder的helm仓库
helm repo add coder-v2 https://helm.coder.com/v2

# 5. 准备helm values.yaml
cat > values.yaml <<'EOF'
coder:
  # 默认他是LoadBalancer不符合我们的测试预期,先改成NodePort
  service:
    type: NodePort

  # You can specify any environment variables you'd like to pass to Coder
  # here. Coder consumes environment variables listed in
  # `coder server --help`, and these environment variables are also passed
  # to the workspace provisioner (so you can consume them in your Terraform
  # templates for auth keys etc.).
  #
  # Please keep in mind that you should not set `CODER_HTTP_ADDRESS`,
  # `CODER_TLS_ENABLE`, `CODER_TLS_CERT_FILE` or `CODER_TLS_KEY_FILE` as
  # they are already set by the Helm chart and will cause conflicts.
  env:
    - name: CODER_PG_CONNECTION_URL
      valueFrom:
        secretKeyRef:
          # You'll need to create a secret called coder-db-url with your
          # Postgres connection URL like:
          # postgres://coder:password@postgres:5432/coder?sslmode=disable
          name: coder-db-url
          key: url
    # For production deployments, we recommend configuring your own GitHub
    # OAuth2 provider and disabling the default one.
    - name: CODER_OAUTH2_GITHUB_DEFAULT_PROVIDER_ENABLE
      value: "false"

    # (Optional) For production deployments the access URL should be set.
    # If you're just trying Coder, access the dashboard via the service IP.
    # 测试推荐将它配置为主机IP与NodePort,这样从控制台页面点击调用window工具能直接传递正确的url过去,而不是集群内域名
    - name: CODER_ACCESS_URL
      alue: "http://172.31.0.88:31730/"

  #tls:
  #  secretNames:
  #    - my-tls-secret-name

EOF

# 6. 开始部署
helm install coder coder-v2/coder \
    --namespace coder \
    --values values.yaml \
    --version 2.29.1

# --------------------------------  到此已经部署完成了  --------------------------------

# x. 更新升级(后续可选)
helm upgrade coder coder-v2/coder \
  --namespace coder \
  --values values.yaml \
  --version 2.29.1

3. 配置流程

3.1 配置模板

第一个账号与密码为管理员账号,随后我们来到template页面,点击New template新建模板,我们需要先创建一个模板

不知道是不是bug,进来后没有这个Variables 的配置,需要先点击一次Save它才能出现

第一个参数选择False,第二参数为namespace,coder在创建隔离环境的时候会运行在你指定的namespace中

稍等片刻等它下载完内容Kubernetes (Deployment)即可完成初始化。

3.2 配置账号

在头部菜单中进入 Deployment 页面,并切换到 User 界面。在该页面中需要为开发人员创建账号,每个账号对应一名开发人员。如果有 N 名开发人员,则需创建 N 个对应的账号。这里以创建一个名为 test1 的开发人员账号作为示例进行演示。

账号创建完成后,点击右上角头像退出当前账号,并使用刚创建的 test1 开发人员账号重新登录,以继续后续操作。

3.3 创建workspace

Workspace 表示开发人员的工作空间。每位开发人员都需要创建一个独立的 Workspace,该开发人员后续产生的所有数据都会存放在该空间中,包括代码、配置以及运行过程中所使用的 CPU、内存等资源信息。

建议在创建时为 Workspace 配置较大的 CPU 规格,该值对应的是 Pod 的 resource limit,并不会在启动时立即占用全部资源,而是在实际运行过程中按需使用。

稍等片刻后,Workspace 工作空间即可完成初始化。此时可以看到系统会自动创建一个 coder 容器,并将其部署到 default 命名空间中。该命名空间正是你在模板配置中所指定的 namespace,所有由该 Workspace 生成的资源都会创建在这里。

现在可以尝试点击访问 code-server 服务,若成功访问,说明配置完全正确。code-server 服务是远程运行在容器中的,并与 Kubernetes 环境完美融合,网络访问几乎没有限制,可以实现无缝连接与访问。

3.4 支持IntelliJ IDEA

重头戏并不是使用 code-server 来开发前端,而是用于后端 Java 项目 的开发。接下来我们将配置 IDEA 连接到Coder以便进行后端开发,在此我们要预先安装Toolbox App工具,然后进去Manage Plugins里面下载Coder插件即可。

https://www.jetbrains.com/zh-cn/toolbox-app/

到这一步就可以完成了,点击下载IntelliJ IDEA,完成后从这个工具中连接coder并且打开idea即可进入远程开发环境中。

进来后随便创建了一个demo1的springboot项目,开启端口转发他会自动映射到你本机window的8080端口,就像本地开发一样。

最后再测试一下能不能curl通集群内域名来验证网络环境呢?结果是毋庸置疑的。

4. 故障排查

coder在初始化模板与工作空间时均需要到github中下载资源,如果你的环境无法直接访问 github 等外网资源,可以通过编辑 deployment 配置来为 coder 容器加入代理配置

kubectl edit deploy -n coder coder

containers:                                         
  - args:                                    
    - server                                          
    command:                                          
    - /opt/coder                                      
    env:                                              
    - name: HTTP_PROXY                                
      value: http://172.31.0.1:7890                   
    - name: HTTPS_PROXY                               
      value: http://172.31.0.1:7890                   
    - name: NO_PROXY                                  
      value: localhost,127.0.0.1,10.0.0.0/8,192.168.0.0/16,172.31.0.0/16,kubernetes.default.svc

5. 离线模式

5.x 离线terraform

# 
mkdir -p /data/terraform/config

# 配置文件
cat >/data/terraform/config/main.tf<<'EOF'
terraform {
  required_providers {
    coder = {
      source  = "coder/coder"
      version = "2.13.1"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "3.0.1"
    }
  }
}
EOF

# 下载,自行准备代理,国内似乎没法正常访问。你也可以准备一个外网机器
docker run --rm -it \
  --network host \
  --entrypoint "" \
  -e http_proxy=http://172.31.0.1:7890 \
  -e https_proxy=http://172.31.0.1:7890 \
  -v /data/terraform/config:/workspace \
  -v /data/terraform:/mirror \
  -w /workspace \
  hashicorp/terraform:1.13.4 \
  sh -c "terraform init && terraform providers mirror \
    -platform=linux_amd64 \
    -platform=linux_arm64 \
    /mirror"


# 检查内容是否正确
root@worker2:/data/terraform# ll
total 16
drwxr-xr-x 4 root root 4096 Feb 26 15:47 ./
drwxr-xr-x 4 root root 4096 Feb 26 15:30 ../
drwxr-xr-x 3 root root 4096 Feb 26 15:47 config/
drwxr-xr-x 4 root root 4096 Feb 26 15:47 registry.terraform.io/
root@worker2:/data/terraform# tree
.
├── config
│   └── main.tf
└── registry.terraform.io
    ├── coder
    │   └── coder
    │       ├── 2.13.1.json
    │       ├── index.json
    │       ├── terraform-provider-coder_2.13.1_linux_amd64.zip
    │       └── terraform-provider-coder_2.13.1_linux_arm64.zip
    └── hashicorp
        └── kubernetes
            ├── 3.0.1.json
            ├── index.json
            ├── terraform-provider-kubernetes_3.0.1_linux_amd64.zip
            └── terraform-provider-kubernetes_3.0.1_linux_arm64.zip

6 directories, 9 files
root@worker2:/data/terraform# du -sh registry.terraform.io/
49M     registry.terraform.io/

5.x 变更coder配置

这里我将简化操作,进入coder容器中需要生成~/.terraformrc配置文件(重启容器会丢失不建议,最好是生成configmap给挂载进去)

cat > ~/.terraformrc <<EOF
provider_installation {
  filesystem_mirror {
    path = "/data/terraform"
  }

  direct {
    exclude = ["*/*"]
  }
}
EOF

上面我们已经将coder所需要的两个provider提供者配置给离线下来了,这里我就简化后续操作,直接将这个/data/terraform离线目录给通过hostpath方式挂载进coder的Deployment中,这里也可以优化成为挂载网络nfs路径,这样容器无论去到哪个主机都能识别到。

最后进入到/data/terraform/config中执行terraform init初始化命令

coder-6b85bccb58-fqmnv:/data/terraform/config$ terraform init
Initializing the backend...
Initializing provider plugins...
- Reusing previous version of coder/coder from the dependency lock file
- Reusing previous version of hashicorp/kubernetes from the dependency lock file
- Using previously-installed coder/coder v2.13.1
- Using previously-installed hashicorp/kubernetes v3.0.1

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
coder-6b85bccb58-fqmnv:/data/terraform/config$ 

5.x 优化template(可选)

terraform {
  required_providers {
    coder = {
      source  = "coder/coder"
      version = "2.13.1"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "3.0.1"
    }
  }
}

provider "coder" {}

provider "kubernetes" {
  config_path = null
}

data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}

# 根据自身的服务器架构选择, 也是可以切换为arm64的,因为上面provide提供者已经下载好对应的arm64的了
resource "coder_agent" "main" {
  arch = "amd64"
  os   = "linux"
}

############################################
# PVC(持久化 home 目录)
############################################

resource "kubernetes_persistent_volume_claim" "home" {
  metadata {
    name      = "coder-${data.coder_workspace.me.id}-home"
    namespace = "coder"

    labels = {
      "app.kubernetes.io/name"     = "coder-pvc"
      "app.kubernetes.io/instance" = "coder-pvc-${data.coder_workspace.me.id}"
      "app.kubernetes.io/part-of"  = "coder"
      "com.coder.resource"         = "true"
      "com.coder.workspace.id"     = data.coder_workspace.me.id
      "com.coder.workspace.name"   = data.coder_workspace.me.name
      "com.coder.user.id"          = data.coder_workspace_owner.me.id
      "com.coder.user.username"    = data.coder_workspace_owner.me.name
    }

    annotations = {
      "com.coder.user.email" = data.coder_workspace_owner.me.email
    }
  }

  wait_until_bound = false

  spec {
    access_modes = ["ReadWriteOnce"]

    resources {
      requests = {
        storage = "20Gi"
      }
    }
  }
}

############################################
# Deployment
############################################

resource "kubernetes_deployment" "main" {
  count = data.coder_workspace.me.start_count

  depends_on = [
    kubernetes_persistent_volume_claim.home
  ]

  wait_for_rollout = false

  metadata {
    name      = "coder-${data.coder_workspace.me.id}"
    namespace = "coder"

    labels = {
      "app.kubernetes.io/name"     = "coder-workspace"
      "app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
      "app.kubernetes.io/part-of"  = "coder"
      "com.coder.resource"         = "true"
      "com.coder.workspace.id"     = data.coder_workspace.me.id
      "com.coder.workspace.name"   = data.coder_workspace.me.name
      "com.coder.user.id"          = data.coder_workspace_owner.me.id
      "com.coder.user.username"    = data.coder_workspace_owner.me.name
    }

    annotations = {
      "com.coder.user.email" = data.coder_workspace_owner.me.email
    }
  }

  spec {
    replicas = 1

    selector {
      match_labels = {
        "app.kubernetes.io/name"     = "coder-workspace"
        "app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
        "app.kubernetes.io/part-of"  = "coder"
        "com.coder.resource"         = "true"
        "com.coder.workspace.id"     = data.coder_workspace.me.id
        "com.coder.workspace.name"   = data.coder_workspace.me.name
        "com.coder.user.id"          = data.coder_workspace_owner.me.id
        "com.coder.user.username"    = data.coder_workspace_owner.me.name
      }
    }

    strategy {
      type = "Recreate"
    }

    template {
      metadata {
        labels = {
          "app.kubernetes.io/name"     = "coder-workspace"
          "app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
          "app.kubernetes.io/part-of"  = "coder"
          "com.coder.resource"         = "true"
          "com.coder.workspace.id"     = data.coder_workspace.me.id
          "com.coder.workspace.name"   = data.coder_workspace.me.name
          "com.coder.user.id"          = data.coder_workspace_owner.me.id
          "com.coder.user.username"    = data.coder_workspace_owner.me.name
        }
      }

      spec {
        security_context {
          run_as_user     = 1000
          fs_group        = 1000
          run_as_non_root = true
        }

        container {
          name              = "dev"
          image             = "codercom/enterprise-base:ubuntu"
          image_pull_policy = "IfNotPresent"
          command           = ["sh", "-c", coder_agent.main.init_script]

          security_context {
            run_as_user = 1000
          }

          env {
            name  = "CODER_AGENT_TOKEN"
            value = coder_agent.main.token
          }

          env {
            name  = "HTTP_PROXY"
            value = "http://172.31.0.1:7890"
          }

          env {
            name  = "HTTPS_PROXY"
            value = "http://172.31.0.1:7890"
          }

          env {
            name  = "NO_PROXY"
            value = "localhost,127.0.0.1,10.0.0.0/8,172.0.0.0/8,kubernetes.default.svc"
          }

          volume_mount {
            mount_path = "/home/coder"
            name       = "home"
            read_only  = false
          }
        }

        volume {
          name = "home"

          persistent_volume_claim {
            claim_name = kubernetes_persistent_volume_claim.home.metadata.0.name
            read_only  = false
          }
        }
      }
    }
  }
}

写在最后