k8s编程operator系列:
截至目前,云编码平台的基本功能已经实现了,有以下功能:
注册、登录
浏览工作空间模板
创建工作空间
运行工作空间
停止工作空间
删除工作空间
浏览工作空间
使用该平台,我们可以根据空间模板来创建出一个云开发环境。对于每个用户来说,允许存在的工作空间最大数量为20个,允许同时启动的工作空间数量为1个。用户的代码数据以及安装的插件都是可以保存下来的。
目前代码是完全开源的,希望感兴趣的童鞋可以给个star。
实现效果如下:
使用Go语言开发一个云编码平台
在前面的章节中已经实现了后端Pod的反向代理,在创建工作空间后,用户可以成功访问。接下来要解决的问题主要有两个:
如何保存用户的状态
如何访问到用户在工作空间中启动的http服务
由于K8s并不支持停止Pod的功能
,因此当用户需要停止工作空间
的时候,我们只能将对应的Pod删除
,待用户再次启动工作空间时再次创建Pod。但是将Pod删除
后,用户的数据、用户自己安装的软件和插件在再次启动Pod后都会没有
了,因为Pod一旦被删除
,其中的数据也不会保存
。那么要解决的问题就是如何将用户的数据保存下来。
K8s的数据持久化方式有很多种,比如EmptyDir
、HostPath
、NFS
等。
EmptyDir:
这种方式适用于在容器之间共享数据,一旦Pod被删除,EmptyDir也会被清空。HostPath:
这种方式在Pod删除后依然会保留数据,但是由于Pod下次运行的主机是不确定的,可能下次该Pod就被调度到别的主机上了。NFS:
这种方式比较适合用来保存用户数据,就算Pod下次被调度到别的节点,依然可以访问到之前的数据。
我们可以使用NFS来保存用户的数据
,在Pod启动前
将数据卷挂载到/user_data/目录下
,用户的数据
和用户安装的插件
都存放在该目录下
,这样的话,就算将Pod删除,数据依然存在。当用户再次启动工作空间时
,就将之前的数据卷挂载到Pod中
,这样就可以保存用户的数据和安装的插件了。但是有一个缺点就是用户安装的软件是无法保存的。
直接使用NFS挂载显然是比较麻烦的,因此我们可以使用K8s的PV和PVC。我们需要提前创建出一批PV,以待使用。
我们将PV的回收策略改为Recycle,这样当关联的PVC被删除后,PV中存放的数据将会K8s自动被清理,以待下次使用。
关于nfs的安装使用以及PV和PVC可以参考:Kubernetes(K8S)学习笔记
code-server默认的用户设置和插件安装目录在/root/.local/share/code-server/
中,插件的默认安装位置为/root/.local/share/code-server/extensions
目录中。在启动code-server时指定参数--user-data-dir可以修改用户设置保存的目录
,指定参数 --extensions-dir可以修改插件的保存位置
。
我们将NFS中的一个目录挂载到Pod的/user_data/目录下
,该目录中的内容就会被清空
。所以我们需要在Pod启动后将code-server的用户设置以及已经安装的插件复制到/user_data/.local/share/code-server中
,然后在启动code-server时
指定用户数据目录
和插件目录
。要想实现这个功能需要我们重新来构建Docker镜像
,容器的启动命令为启动一个初始化脚
本,脚本负责数据的拷贝以及之后code-server的启动
。
脚本代码如下:
#!/bin/bash
function graceful_exit () {
echo "receive SIGTERM, exiting..."
pid=$(ps -ef | grep code-server | awk '{print $2}')
kill -SIGTERM "$pid"
exit 0
}
trap graceful_exit SIGTERM
while :;do
# 创建用户工作空间
if [ ! -d "$USER_WORKSPACE" ]; then
echo "create $USER_WORKSPACE"
mkdir -p $USER_WORKSPACE
fi
# 第一次启动工作空间,拷贝code-server的数据
if [ ! -f "/user_data/.local/share/.first_start" ]; then
echo "copy code-server"
if [ ! -d "/user_data/.local/share" ]; then
mkdir -p /user_data/.local/share
fi
cp -r /root/.local/share/code-server-bak /user_data/.local/share/code-server
touch /user_data/.local/share/.first_start
fi
# 启动code-server
node_id=$(ps aux | grep -E "/.workspace/code-server/lib/node /.workspace/code-server --port 9999 --host 0.0.0.0 --auth none" | grep -v grep)
if [ -z "$node_id" ]; then
nohup code-server --port 9999 --host 0.0.0.0 \
--auth none --disable-update-check --locale zh-cn \
--user-data-dir /user_data/.local/share/code-server \
--extensions-dir /user_data/.local/share/code-server/extensions \
--open /user_data/workspace/ &
echo "start code-server success"
fi
sleep 3
done
脚本要做的事情主要有4个:
创建用户工作空间:
也就是用户通过浏览器访问的vs-code中的打开目录位置,即/user_data/workspace拷贝code-server的数据:
在第一次启动工作空间时,将/root/.local/share/目录下的code-server-bak备份数据拷贝到/user_data/.local/share/code-server目录中启动code-server:
在前面的初始化工作完成后,启动code-server,指定用户数据保存位置以及插件保存位置。优雅退出:
当删除Pod时,K8s会向容器发送SIGTERM信号以终止容器,默认最大宽限时间为30s,如果30s后容器的1号进程依然没有结束,那么就会发送SIGKILL信号来强行终止容器。但是由于shell脚本默认屏蔽SIGTERM信号,因此如果不做处理,每次删除Pod都需要30s。因此我们需要在脚本中捕获SIGTERM信号,同时在自己退出前通知code-server进程退出。Dockerfile的内容如下:
FROM ubuntu:20.04
WORKDIR /.workspace
# 拷贝code-server的可执行文件
COPY code-server-4.9.0-linux-amd64.tar.gz .
# 拷贝go sdk
COPY go1.19.4.linux-amd64.tar.gz .
# 拷贝Source Code Pro字体文件
COPY SourceCodeVariable* ./
# 拷贝运行脚本
COPY .init.sh /root/
# 拷贝code-server用户数据(用户数据、插件)
COPY code-server-bak /root/.local/share/code-server-bak
RUN apt-get -y update && \
apt-get -y install fontconfig && \
mkdir -p ~/.fonts/source-code-pro && \
cp SourceCodeVariable* ~/.fonts/source-code-pro && \
cd ~/.fonts/source-code-pro && \
fc-cache -f -v && \
cd /.workspace && \
rm -f SourceCodeVariable* && \
tar zxvf code-server-4.9.0-linux-amd64.tar.gz && \
mv code-server-4.9.0-linux-amd64 code-server &&\
rm -f code-server-4.9.0-linux-amd64.tar.gz && \
tar zxvf go1.19.4.linux-amd64.tar.gz -C /usr/local && \
rm -f go1.19.4.linux-amd64.tar.gz && \
mkdir -p /go/src /go/pkg /go/bin && \
apt-get -qq update && \
apt-get -qq install -y --no-install-recommends ca-certificates curl
# 拷贝go工具
COPY go_tools/* /go/bin/
ENV GO111MODULE on
ENV GOPROXY https://goproxy.cn,direct
ENV GOROOT /usr/local/go
ENV PATH /usr/local/go/bin:$PATH
ENV GOPATH /go
ENV PATH $GOPATH/bin:$PATH
ENV USER_WORKSPACE /user_data/workspace
ENV CODE_SERVER_DIR /.workspace/code-server
ENV PATH /.workspace/code-server/bin:$PATH
WORKDIR /root
EXPOSE 9999
# 执行脚本文件
CMD ["/bin/bash", ".init.sh"]
该Dockerfile用来构建包含go语言环境和code-server的镜像。
该镜像已经上传到了阿里云镜像仓库,地址:registry.cn-hangzhou.aliyuncs.com/k8s-cloud-ide/code-server-go1.19:v1.0
在③Code-Server Pod访问实现访问实现
中已经实现了对容器中的code-server的反向代理,但是还没有实现用户编写的http服务的反向代理。在测试的时候,我发现code-server已经帮我们实现了代理。
首先回顾一下是如何通过nginx的反向代理来访问到Pod中的code-server的,主要有下面几个步骤:
当我们在启动的工作空间中实现了一个http服务并启动后,code-server中自带的功能可以检测到我们监听了新的端口,然后询问是否在新的页面访问,如下图:
点击Open In Brower的按钮后,就会在打开新的页面访问:
但是发现在没有配置反向代理的情况下,竟然访问到了里面的服务。后来经过多次的调试我才发现原来code-server有代理的功能。
推测依据如下:
根据上面的地址http://192.168.44.100/ws/63f598a85e93db0001a38757/proxy/8080/
访问,首先会访问到nginx,然后nginx对路径进行解析,会将请求代理到http://PodIP:Port/proxy/8080
,这个路径显然是访问code-server的,但是竟然可以访问到我们自己的服务。因此推测,在访问/proxy/8080
时,code-server会将该请求代理到localhost:8080/
中,这样就访问到了我们自己的服务。
在第②章中实现了controller的初步功能,但是在第二章的实现中我们并没有使用CRD来实现,只是通过创建删除Pod来进行了实现,这种方案实现起来比较简单,功能已经基本实现了:https://github.com/mangohow/cloud-ide-k8s-controller
但是在本章中并不会讲解这种方式的实现,我们将使用KubeBuilder来构建一个Operator。Operator的代码:https://github.com/mangohow/cloud-ide-k8s-operator
Operator和Controller实现的功能是一样的,但是实现的方式不同。
首先,创建一个文件夹,并且初始化go mod
mkdir <yourproject>
cd <yourproject>
go mod init <projectname>
初始化项目:
kubebuilder init --domain <yourdomain>
创建api
在创建api时,要指定group、version和kind
在此我的kind指定为WorkSpace
kubebuilder create api --group <yourgroup> --version <version> --kind WorkSpace
项目创建完成后,我们需要完善api/
type WorkSpaceOperation string
const (
WorkSpaceStart WorkSpaceOperation = "Start"
WorkSpaceStop = "Stop"
)
type WorkSpacePhase string
const (
WorkspacePhaseRunning WorkSpacePhase = "Running"
WorkspacePhaseStopped = "Stopped"
)
type WorkSpaceSpec struct {
// Workspace machine specifications
Cpu string `json:"cpu,omitempty"`
Memory string `json:"memory,omitempty"`
Storage string `json:"storage,omitempty"`
// 硬件资源
Hardware string `json:"hardware,omitempty"`
// The image
Image string `json:"image,omitempty"`
// Exposed port
Port int32 `json:"port,omitempty"`
// Volume mount path
MountPath string `json:"mountPath"`
// The operation to do
Operation WorkSpaceOperation `json:"operation,omitempty"`
}
type WorkSpaceStatus struct {
Phase WorkSpacePhase `json:"phase,omitempty"`
}
//+kubebuilder:object:root=true
//+kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase`
// +kubebuilder:printcolumn:name="Hardware",type=string,JSONPath=`.spec.hardware`
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
// WorkSpace is the Schema for the workspaces API
type WorkSpace struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec WorkSpaceSpec `json:"spec,omitempty"`
Status WorkSpaceStatus `json:"status,omitempty"`
}
在WorkSpaceSpec中定义了多个字段:
Cpu、Memory、Storage
:表示该工作空间使用的cpu、内存和存储的规格Hardware
:是一个用于描述硬件资源的字段,用于在使用kubectl查询时显示信息Image
:pod使用的镜像Port
:pod中code-server监听的端口MountPath
:存储卷的挂载位置Operation
:要进行的操作,用于启动或者停止工作空间使用// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=.status.phase
我们可以告诉K8s在使用kubectl时可以显示的字段
同时,我们还使用了K8s的subresource,也就是status字段,在status中定义了一个Phase,该字段用于表示当前Workspace的状态,分别是运行状态
和停止状态
在使用status子资源时一定要加上//+kubebuilder:subresource:status
,正常情况下Kubebuilder创建的项目已经自动帮我们加上了
字段修改完成后,可以使用下面的命令来生成crd等的yaml文件:
make manifest
使用下面的命令可以将crd安装到集群中
# 安装CRD到集群
make install
# 卸载CRD
make uninstall
安装完成后,可以使用kubectl来查看crd
kubectl get crd
接下来我们就需要完善controller的功能了,也就是完善controllers包
下的workspace_controller.go
中的Reconcile
Reconcile
的意思是协调
,因此我们需要在Reconcile中做的事情就是协调Workspace的状态。
用户创建工作空间:
用户创建的工作空间就对应于我们的CR,也就是WorkSpace,当用户创建工作空间时,我们就创建出一个CR,这个可以在RPC中实现用户启动工作空间:
用户启动工作空间,就需要使用Update操作来更新Workspace中的Operation
字段为Start
。当用户更新了字段后,我们就可以获取到WorkSpace的最新状态,然后查看Operation字段,如果是Start,那么我们就需要创建出PVC和Pod,同时更新status为Running即可用户停止工作空间:
于启动工作空间相似,修改Operation字段为Stop,我们只需将Pod删除,同时更新status为Stopped即可用户删除工作空间:
当用户删除了工作空间后,我们就需要将Pod和PVC同时删除。Reconcile的流程如下:
controller的代码如下:
package controllers
var Mode string
const (
ModeRelease = "release"
ModDev = "dev"
)
// WorkSpaceReconciler reconciles a WorkSpace object
type WorkSpaceReconciler struct {
client.Client
Scheme *runtime.Scheme
}
func (r *WorkSpaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
_ = log.FromContext(ctx)
//先查询WorkSpace
wp := mv1.WorkSpace{}
err := r.Client.Get(context.Background(), req.NamespacedName, &wp)
// case 1、没有找到Workspace,说明WorkSpace被删除了,删除对应的Pod和PVC即可
if err != nil {
if errors.IsNotFound(err) {
if e1 := r.deletePod(req.NamespacedName); e1 != nil {
klog.Errorf("[Delete Workspace] delete pod error:%v", e1)
return ctrl.Result{Requeue: true}, e1
}
if e2 := r.deletePVC(req.NamespacedName); e2 != nil {
klog.Errorf("[Delete Workspace] delete pvc error:%v", e2)
return ctrl.Result{Requeue: true}, e2
}
return ctrl.Result{}, nil
}
klog.Errorf("get workspace error:%v", err)
return ctrl.Result{Requeue: true}, err
}
// 找到了WorkSpace,根据WorkSpace的Operation字段判断要进行的操作
switch wp.Spec.Operation {
// case2: 启动WorkSpace,检查PVC是否存在,如果不存在则创建
case mv1.WorkSpaceStart:
// 检查PVC是否存在,不存在则创建
err = r.createPVC(&wp, req.NamespacedName)
if err != nil {
klog.Errorf("[Start Workspace] create pvc error:%v", err)
return ctrl.Result{Requeue: true}, err
}
// 创建Pod
err = r.createPod(&wp, req.NamespacedName)
if err != nil {
klog.Errorf("[Start Workspace] create pod error:%v", err)
return ctrl.Result{Requeue: true}, err
}
r.updateStatus(&wp, mv1.WorkspacePhaseRunning)
// case3: 停止WorkSpace,删除Pod
case mv1.WorkSpaceStop:
//删除Pod
err = r.deletePod(req.NamespacedName)
if err != nil {
klog.Errorf("[Stop Workspace] delete pod error:%v", err)
return ctrl.Result{Requeue: true}, err
}
r.updateStatus(&wp, mv1.WorkspacePhaseStopped)
}
return ctrl.Result{}, nil
}
func (r WorkSpaceReconciler) updateStatus(wp *mv1.WorkSpace, phase mv1.WorkSpacePhase) {
wp.Status.Phase = phase
err := r.Client.Status().Update(context.Background(), wp)
if err != nil {
klog.Errorf("update status error:%v", err)
}
}
// 在Owns中使用过滤器,防止Pod和PVC状态发生改变时触发Reconcile方法
func (r *WorkSpaceReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
WithOptions(controller.Options{MaxConcurrentReconciles: 8}).
For(&mv1.WorkSpace{}).
Owns(&v1.Pod{}, builder.WithPredicates(predicatePod)).
Owns(&v1.PersistentVolumeClaim{}, builder.WithPredicates(predicatePVC)).
Complete(r)
}
func (r *WorkSpaceReconciler) createPod(space *mv1.WorkSpace, key client.ObjectKey) error {
// 1.检查Pod是否存在
exist, err := r.checkPodExist(key)
if err != nil {
return err
}
// Pod已存在,直接返回
if exist {
return nil
}
// 2.创建Pod
pod := r.constructPod(space)
// 设置控制器,如果设置了控制器,那么被控制的资源的变化也会被发送到队列中
//if err = controllerutil.SetControllerReference(space, pod, r.Scheme); err != nil {
// return err
//}
ctx, cancelFunc := context.WithTimeout(context.Background(), time.Second*30)
defer cancelFunc()
err = r.Client.Create(ctx, pod)
if err != nil {
// 如果Pod已经存在,直接返回
if errors.IsAlreadyExists(err) {
return nil
}
return err
}
return nil
}
// 构造一个Pod对象
func (r *WorkSpaceReconciler) constructPod(space *mv1.WorkSpace) *v1.Pod {
volumeName := "volume-user-workspace"
pod := &v1.Pod{
TypeMeta: metav1.TypeMeta{
Kind: "Pod",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: space.Name,
Namespace: space.Namespace,
Labels: map[string]string{
"app": "cloud-ide",
},
},
Spec: v1.PodSpec{
Volumes: []v1.Volume{
{
Name: volumeName,
VolumeSource: v1.VolumeSource{
PersistentVolumeClaim: &v1.PersistentVolumeClaimVolumeSource{
ClaimName: space.Name,
ReadOnly: false,
},
},
},
},
Containers: []v1.Container{
{
Name: space.Name,
Image: space.Spec.Image,
ImagePullPolicy: v1.PullIfNotPresent,
Ports: []v1.ContainerPort{
{
ContainerPort: space.Spec.Port,
},
},
// 容器挂载存储卷
VolumeMounts: []v1.VolumeMount{
{
Name: volumeName,
ReadOnly: false,
MountPath: space.Spec.MountPath,
},
},
},
},
},
}
if Mode == ModeRelease {
// 最小需求CPU2核、内存1Gi == 1 * 2^10
pod.Spec.Containers[0].Resources = v1.ResourceRequirements{
Requests: map[v1.ResourceName]resource.Quantity{
v1.ResourceCPU: resource.MustParse("2"),
v1.ResourceMemory: resource.MustParse("1Gi"),
},
Limits: map[v1.ResourceName]resource.Quantity{
v1.ResourceCPU: resource.MustParse(space.Spec.Cpu),
v1.ResourceMemory: resource.MustParse(space.Spec.Memory),
},
}
}
return pod
}
func (r *WorkSpaceReconciler) createPVC(space *mv1.WorkSpace, key client.ObjectKey) error {
// 1.先检查PVC是否已经存在
exist, err := r.checkPVCExist(key)
if err != nil {
// PVC已经存在
return err
}
// PVC已经存在,无需创建
if exist {
return nil
}
// 2.PVC不存在,创建PVC
pvc, err := r.constructPVC(space)
if err != nil {
klog.Errorf("construct pvc error:%v", err)
return err
}
// 设置了OwnerReference之后,PVC的状态发生变化,也会触发Reconcile方法
// 但是对于PVC来说,我们不希望它触发这个方法,因此我们可以使用过滤器来进行过滤
//if err = controllerutil.SetControllerReference(space, pvc, r.Scheme); err != nil {
// return err
//}
ctx, cancelFunc := context.WithTimeout(context.Background(), time.Second*30)
defer cancelFunc()
err = r.Client.Create(ctx, pvc)
if err != nil {
if errors.IsAlreadyExists(err) {
return nil
}
return err
}
return nil
}
// 构造PVC对象
func (r *WorkSpaceReconciler) constructPVC(space *mv1.WorkSpace) (*v1.PersistentVolumeClaim, error) {
quantity, err := resource.ParseQuantity(space.Spec.Storage)
if err != nil {
return nil, err
}
pvc := &v1.PersistentVolumeClaim{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "PersistentVolumeClaim",
},
ObjectMeta: metav1.ObjectMeta{
Name: space.Name,
Namespace: space.Namespace,
},
Spec: v1.PersistentVolumeClaimSpec{
AccessModes: []v1.PersistentVolumeAccessMode{v1.ReadWriteMany},
Resources: v1.ResourceRequirements{
Limits: v1.ResourceList{v1.ResourceStorage: quantity},
Requests: v1.ResourceList{v1.ResourceStorage: quantity},
},
},
}
return pvc, nil
}
func (r *WorkSpaceReconciler) checkPodExist(key client.ObjectKey) (bool, error) {
pod := &v1.Pod{}
// 先查询一下
err := r.Client.Get(context.Background(), key, pod)
if err != nil {
if errors.IsNotFound(err) {
return false, nil
}
klog.Errorf("get pod error:%v", err)
return false, err
}
return true, nil
}
func (r *WorkSpaceReconciler) deletePod(key client.ObjectKey) error {
exist, err := r.checkPodExist(key)
if err != nil {
return err
}
// Pod不存在,直接返回
if !exist {
return nil
}
pod := &v1.Pod{}
pod.Name = key.Name
pod.Namespace = key.Namespace
ctx, cancelFunc := context.WithTimeout(context.Background(), time.Second*35)
defer cancelFunc()
// 删除Pod
err = r.Client.Delete(ctx, pod)
if err != nil {
if errors.IsNotFound(err) {
return nil
}
klog.Errorf("delete pod error:%v", err)
return err
}
return nil
}
func (r *WorkSpaceReconciler) checkPVCExist(key client.ObjectKey) (bool, error) {
pvc := &v1.PersistentVolumeClaim{}
err := r.Client.Get(context.Background(), key, pvc)
if err != nil {
if errors.IsNotFound(err) {
return false, nil
}
klog.Errorf("get pvc error:%v", err)
return false, err
}
return true, nil
}
func (r *WorkSpaceReconciler) deletePVC(key client.ObjectKey) error {
exist, err := r.checkPVCExist(key)
if err != nil {
return err
}
// pvc不存在,无需再删除
if !exist {
return nil
}
pvc := &v1.PersistentVolumeClaim{}
pvc.Name = key.Name
pvc.Namespace = key.Namespace
ctx, cancelFunc := context.WithTimeout(context.Background(), time.Second*30)
defer cancelFunc()
err = r.Client.Delete(ctx, pvc)
if err != nil {
if errors.IsNotFound(err) {
return nil
}
klog.Errorf("delete pvc error:%v", err)
return err
}
return nil
}
由于我们在代码中创建PVC和Pod时,设置了它的控制器,因此当Pod和PVC的状态发生改变后也会触发Reconcile方法,但是我们并不希望这两个资源触发该方法,因此我们可以使用过滤器。
package controllers
import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/predicate"
)
// 过滤所有的PVC, 防止其触发Reconcile方法
var predicatePVC = predicate.NewPredicateFuncs(func(object client.Object) bool {
return false
})
var predicatePod = predicate.NewPredicateFuncs(func(object client.Object) bool {
return false
})
为了防止WebServer直接和Apiserver进行通信,我们使用grpc的方式使webserver和operator来进行通信来完成Workspace的创建、启动、停止删除、已经查询
至于为什么不直接在WebServer中使用client-go来和Apisever通信有的原因:webserver最好是保持无状态的,如果我们直接和Apiserver通信,就算使用informer的方式,那么如果我们部署多个web实例的话,也会造成Apiserver的较大压力
grpc的proto文件定义如下:
syntax = "proto3";
package pb;
option go_package = "./;pb";
// 工作空间的资源限制
message ResourceLimit {
string cpu = 1;
string Memory = 2;
string Storage = 3;
}
// 工作空间信息
message WorkspaceInfo {
string name = 1;
string namespace = 2;
string image = 3;
int32 port = 4;
string volumeMountPath = 5;
ResourceLimit resourceLimit = 6;
}
message Response {
int32 status = 1;
string message = 2;
}
message QueryOption {
string name = 1;
string namespace = 2;
}
// 工作空间的状态
message WorkspaceStatus {
int32 status = 1;
string message = 2;
}
// 工作空间运行信息
message WorkspaceRunningInfo {
string nodeName = 1;
string ip = 2;
int32 port = 3;
}
service CloudIdeService {
// 创建云IDE空间并等待Pod状态变为Running,第一次创建,需要挂载存储卷
rpc createSpace(WorkspaceInfo) returns (WorkspaceRunningInfo);
// 启动(创建)云IDE空间,非第一次创建,无需挂载存储卷,使用之前的存储卷
rpc startSpace(WorkspaceInfo) returns (WorkspaceRunningInfo);
// 删除云IDE空间,需要删除存储卷
rpc deleteSpace(QueryOption) returns (Response);
// 停止(删除)云工作空间,无需删除存储卷
rpc stopSpace(QueryOption) returns (Response);
// 获取Pod运行状态
rpc getPodSpaceStatus(QueryOption) returns (WorkspaceStatus);
// 获取云IDE空间Pod的信息
rpc getPodSpaceInfo(QueryOption) returns (WorkspaceRunningInfo);
}
最终要实现的接口如下:
type CloudIdeServiceServer interface {
// 创建云IDE空间并等待Pod状态变为Running,第一次创建,需要挂载存储卷
CreateSpace(context.Context, *WorkspaceInfo) (*WorkspaceRunningInfo, error)
// 启动(创建)云IDE空间,非第一次创建,无需挂载存储卷,使用之前的存储卷
StartSpace(context.Context, *WorkspaceInfo) (*WorkspaceRunningInfo, error)
// 删除云IDE空间,需要删除存储卷
DeleteSpace(context.Context, *QueryOption) (*Response, error)
// 停止(删除)云工作空间,无需删除存储卷
StopSpace(context.Context, *QueryOption) (*Response, error)
// 获取Pod运行状态
GetPodSpaceStatus(context.Context, *QueryOption) (*WorkspaceStatus, error)
// 获取云IDE空间Pod的信息
GetPodSpaceInfo(context.Context, *QueryOption) (*WorkspaceRunningInfo, error)
}
创建一个结构体来实现这个接口:
type WorkSpaceService struct {
client client.Client
}
func NewWorkSpaceService(c client.Client) *WorkSpaceService {
return &WorkSpaceService{
client: c,
}
}
var _ = pb.CloudIdeServiceServer(&WorkSpaceService{})
CreateSpace用于创建工作空间并启动,我们只需要创建出WorkSpace即可,将其Operation设置为Start,我们的controller就会自动创建出PVC和Pod
步骤如下:
代码如下:
// CreateSpace 创建并且启动Workspace,将Operation字段置为"Start",当Workspace被创建时,PVC和Pod也会被创建
func (s *WorkSpaceService) CreateSpace(ctx context.Context, info *pb.WorkspaceInfo) (*pb.WorkspaceRunningInfo, error) {
// 1.先查询workspace是否存在
var wp mv1.WorkSpace
exist := s.checkWorkspaceExist(ctx, client.ObjectKey{Name: info.Name, Namespace: info.Namespace}, &wp)
stus := status.New(codes.AlreadyExists, WorkspaceAlreadyExist)
if exist {
return EmptyWorkspaceRunningInfo, stus.Err()
}
// 2.如果不存在就创建
w := s.constructWorkspace(info)
if err := s.client.Create(ctx, w); err != nil {
if errors.IsAlreadyExists(err) {
return EmptyWorkspaceRunningInfo, stus.Err()
}
klog.Errorf("create workspace error:%v", err)
return EmptyWorkspaceRunningInfo, status.Error(codes.Unknown, err.Error())
}
// 3.等待Pod处于Running状态
return s.waitForPodRunning(ctx, client.ObjectKey{Name: w.Name, Namespace: w.Namespace}, w)
}
func (s *WorkSpaceService) waitForPodRunning(ctx context.Context, key client.ObjectKey, wp *mv1.WorkSpace) (*pb.WorkspaceRunningInfo, error) {
// 获取Pod的运行信息,可能会因为资源不足而导致Pod无法运行
// 最多重试四次,如果还不行,就停止工作空间
retry, maxRetry := 0, 5
sleepDuration := []time.Duration{1, 3, 5, 8, 12}
po := v1.Pod{}
loop:
for {
select {
case <-ctx.Done():
break loop
default:
if retry >= maxRetry {
break loop
}
// 先休眠,等待Pod被创建并且运行起来
time.Sleep(time.Second * sleepDuration[retry])
if err := s.client.Get(context.Background(), key, &po); err != nil {
if !errors.IsNotFound(err) {
klog.Errorf("get pod error:%v", err)
}
} else {
if po.Status.Phase == v1.PodRunning {
return &pb.WorkspaceRunningInfo{
NodeName: po.Spec.NodeName,
Ip: po.Status.PodIP,
Port: po.Spec.Containers[0].Ports[0].ContainerPort,
}, nil
}
}
retry++
}
}
// 5.处理错误情况,停止工作空间
s.StopSpace(ctx, &pb.QueryOption{
Name: wp.Name,
Namespace: wp.Namespace,
})
return EmptyWorkspaceRunningInfo, status.Error(codes.ResourceExhausted, WorkspaceStartFailed)
}
StartSpace的主要功能就是启动Pod,我们只需要更新Workspace的Operation字段为Start即可,controller会字段创建PVC和Pod
步骤如下:
注意:由于K8s使用的是乐观锁的并发控制,通过ResourceVersion字段来进行控制,因此当我们在更新资源时,可能会因为我们的资源已经被修改,从而会导致我们更新失败,因此在更新失败时,需要重新尝试,但是在更新前,必须要从缓存中获取资源的最新状态。但是client-go已经为我们提供了方便的接口,直接使用即可。关于K8s的并发控制可以自己查阅相关资料
代码如下:
// StartSpace 启动Workspace
func (s *WorkSpaceService) StartSpace(ctx context.Context, info *pb.WorkspaceInfo) (*pb.WorkspaceRunningInfo, error) {
// 1.先获取workspace,如果不存在返回错误
var wp mv1.WorkSpace
key := client.ObjectKey{Name: info.Name, Namespace: info.Namespace}
exist := s.checkWorkspaceExist(ctx, key, &wp)
if !exist {
return EmptyWorkspaceRunningInfo, status.Error(codes.NotFound, WorkspaceNotExist)
}
// 2.查询Pod是否存在,如果存在直接返回数据
pod := v1.Pod{}
if err := s.client.Get(context.Background(), key, &pod); err == nil {
return &pb.WorkspaceRunningInfo{
NodeName: pod.Spec.NodeName,
Ip: pod.Status.PodIP,
Port: pod.Spec.Containers[0].Ports[0].ContainerPort,
}, nil
}
// 3.更新Workspace,使用RetryOnConflict,当资源版本冲突时重试
err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
// 每次更新前要获取最新的版本
var p mv1.WorkSpace
exist := s.checkWorkspaceExist(ctx, key, &p)
if !exist {
return nil
}
// 更新workspace的Operation字段
wp.Spec.Operation = mv1.WorkSpaceStart
if err := s.client.Update(ctx, &wp); err != nil {
klog.Errorf("update workspace to start error:%v", err)
return err
}
return nil
})
if err != nil {
return EmptyWorkspaceRunningInfo, status.Error(codes.Unknown, err.Error())
}
if !exist {
return EmptyWorkspaceRunningInfo, status.Error(codes.NotFound, WorkspaceNotExist)
}
return s.waitForPodRunning(ctx, key, &wp)
}
StopSpace用来停止工作空间,同StartSpace相似,只需要更新Operation字段为Stop即可
代码如下:
// StopSpace 停止Workspace,只需要删除对应的Pod,因此修改Workspace的操作为Stop即可
func (s *WorkSpaceService) StopSpace(ctx context.Context, option *pb.QueryOption) (*pb.Response, error) {
// 使用Update时,可能由于版本冲突而导致失败,需要重试
exist := true
err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
var wp mv1.WorkSpace
exist = s.checkWorkspaceExist(ctx, client.ObjectKey{Name: option.Name, Namespace: option.Namespace}, &wp)
if !exist {
return nil
}
// 更新workspace的Operation字段
wp.Spec.Operation = mv1.WorkSpaceStop
if err := s.client.Update(ctx, &wp); err != nil {
klog.Errorf("update workspace to start error:%v", err)
return err
}
return nil
})
if err != nil {
return EmptyResponse, status.Error(codes.Unknown, err.Error())
}
if !exist {
return EmptyResponse, status.Error(codes.NotFound, WorkspaceNotExist)
}
return EmptyResponse, nil
}
DeleteSpace的功能为删除Workspace,只需要将对应的Workspace资源删除,controller会负责删除Pod和PVC
// DeleteSpace 只需要将workspace删除即可,controller会负责删除对应的Pod和PVC
func (s *WorkSpaceService) DeleteSpace(ctx context.Context, option *pb.QueryOption) (*pb.Response, error) {
// 先查询是否存在,如果不存在则也认为成功
var wp mv1.WorkSpace
exist := s.checkWorkspaceExist(ctx, client.ObjectKey{Name: option.Name, Namespace: option.Namespace}, &wp)
if !exist {
return EmptyResponse, nil
}
// 删除Workspace
if err := s.client.Delete(ctx, &wp); err != nil {
klog.Errorf("delete workspace error:%v", err)
return EmptyResponse, status.Error(codes.Unknown, err.Error())
}
return EmptyResponse, nil
}
// 获取WorkSpace的运行状态
func (s *WorkSpaceService) GetPodSpaceStatus(ctx context.Context, option *pb.QueryOption) (*pb.WorkspaceStatus, error) {
pod := v1.Pod{}
err := s.client.Get(ctx, client.ObjectKey{Name: option.Name, Namespace: option.Namespace}, &pod)
if err != nil {
if errors.IsNotFound(err) {
return EmptyWorkspaceStatus, status.Error(codes.NotFound, "workspace is not running")
}
klog.Errorf("get pod space status error:%v", err)
return &pb.WorkspaceStatus{Status: PodNotExist, Message: "NotExist"}, status.Error(codes.NotFound, err.Error())
}
return &pb.WorkspaceStatus{Status: PodExist, Message: string(pod.Status.Phase)}, nil
}
// 获取运行中的Pod的信息
func (s *WorkSpaceService) GetPodSpaceInfo(ctx context.Context, option *pb.QueryOption) (*pb.WorkspaceRunningInfo, error) {
pod := v1.Pod{}
err := s.client.Get(ctx, client.ObjectKey{Name: option.Name, Namespace: option.Namespace}, &pod)
if err != nil {
if errors.IsNotFound(err) {
return nil, status.Error(codes.NotFound, "workspace is not running")
}
klog.Errorf("get pod space info error:%v", err)
return EmptyWorkspaceRunningInfo, status.Error(codes.Unknown, err.Error())
}
return &pb.WorkspaceRunningInfo{
NodeName: pod.Spec.NodeName,
Ip: pod.Status.PodIP,
Port: pod.Spec.Containers[0].Ports[0].ContainerPort,
}, nil
}
func (s *WorkSpaceService) checkWorkspaceExist(ctx context.Context, key client.ObjectKey, w *mv1.WorkSpace) bool {
if err := s.client.Get(ctx, key, w); err != nil {
if errors.IsNotFound(err) {
return false
}
klog.Errorf("get workspace error:%v", err)
return false
}
return true
}
func (s *WorkSpaceService) constructWorkspace(space *pb.WorkspaceInfo) *mv1.WorkSpace {
hardware := fmt.Sprintf("%sC%s%s", space.ResourceLimit.Cpu,
strings.Split(space.ResourceLimit.Memory, "i")[0], strings.Split(space.ResourceLimit.Storage, "i")[0])
return &mv1.WorkSpace{
TypeMeta: metav1.TypeMeta{
APIVersion: "cloud-ide.mangohow.com/v1",
Kind: "WorkSpace",
},
ObjectMeta: metav1.ObjectMeta{
Name: space.Name,
Namespace: space.Namespace,
},
Spec: mv1.WorkSpaceSpec{
Cpu: space.ResourceLimit.Cpu,
Memory: space.ResourceLimit.Memory,
Storage: space.ResourceLimit.Storage,
Hardware: hardware,
Image: space.Image,
Port: space.Port,
MountPath: space.VolumeMountPath,
Operation: mv1.WorkSpaceStart,
},
}
}
web server要完善的功能就是与工作空间相关的服务,主要有以下方法:
代码参考:https://github.com/mangohow/cloud-ide-webserver
步骤如下:
代码如下:
// CreateWorkspace 创建云工作空间, 只涉及数据库操作
func (c *CloudCodeService) CreateWorkspace(req *reqtype.SpaceCreateOption, userId uint32) (*model.Space, error) {
// 1、验证创建的工作空间是否达到最大数量
count, err := c.dao.FindCountByUserId(userId)
if err != nil {
c.logger.Warnf("get space count error:%v", err)
return nil, ErrSpaceCreate
}
if count >= MaxSpaceCount {
return nil, ErrReachMaxSpaceCount
}
// 2、验证名称是否重复
if err := c.dao.FindByUserIdAndName(userId, req.Name); err == nil {
c.logger.Warnf("find space error:%v", err)
return nil, ErrNameDuplicate
}
// 3、从缓存中获取要创建的云空间的模板
tmpl := c.tmplCache.GetTmpl(req.TmplId)
if tmpl == nil {
c.logger.Warnf("get tmpl cache error:%v", err)
return nil, ErrReqParamInvalid
}
// 4、从缓存中获取要创建的云空间的规格
spec := c.specCache.Get(req.SpaceSpecId)
if spec == nil {
return nil, ErrReqParamInvalid
}
// 5、构造云工作空间结构
now := time.Now()
space := &model.Space{
UserId: userId,
TmplId: tmpl.Id,
SpecId: spec.Id,
Spec: *spec,
Name: req.Name,
Status: model.SpaceStatusUncreated,
CreateTime: now,
DeleteTime: now,
StopTime: now,
TotalTime: 0,
Sid: generateSID(),
}
//6、 添加到数据库
spaceId, err := c.dao.Insert(space)
if err != nil {
c.logger.Errorf("add space error:%v", err)
return nil, ErrSpaceCreate
}
space.Id = spaceId
return space, nil
}
步骤如下:
代码如下:
// StartWorkspace 启动云工作空间
func (c *CloudCodeService) StartWorkspace(id, userId uint32, uid string) (*model.Space, error) {
// 1、检查是否有其它工作空间正在运行, 同时只能有一个工作空间启动
isRunning, err := rdis.CheckHasRunningSpace(uid)
if err != nil {
return nil, ErrSpaceStart
}
if isRunning {
return nil, ErrOtherSpaceIsRunning
}
// 2.查询该工作空间是否存在
space, err := c.dao.FindByIdAndUserId(id, userId)
if err != nil {
c.logger.Warnf("find space error:%v", err)
return nil, ErrWorkSpaceNotExist
}
space.Id = id
space.UserId = userId
// 3.该工作空间是否是第一次启动
startFunc := c.rpc.StartSpace
switch space.Status {
case model.SpaceStatusDeleted:
return nil, ErrWorkSpaceNotExist
case model.SpaceStatusUncreated:
startFunc = c.rpc.CreateSpace
spec := c.specCache.Get(space.SpecId)
if spec == nil {
return nil, ErrSpaceStart
}
space.Spec = *spec
}
// 4.启动工作空间
ret, err := c.startWorkspace(&space, uid, startFunc)
if err != nil {
c.logger.Warnf("start workspace error:%v", err)
return nil, err
}
return ret, nil
}
// startWorkspace 启动工作空间
func (c *CloudCodeService) startWorkspace(space *model.Space, uid string, startFunc StartFunc) (*model.Space, error) {
// 1、获取空间模板
tmpl := c.tmplCache.GetTmpl(space.TmplId)
if tmpl == nil {
c.logger.Warnf("get tmpl cache error")
return nil, ErrSpaceStart
}
// 2、生成Pod信息
podName := c.generatePodName(space.Sid, uid)
pod := pb.WorkspaceInfo{
Name: podName,
Namespace: CloudCodeNamespace,
Image: tmpl.Image,
Port: DefaultPodPort,
VolumeMountPath: "/user_data/",
ResourceLimit: &pb.ResourceLimit{
Cpu: space.Spec.CpuSpec,
Memory: space.Spec.MemSpec,
Storage: space.Spec.StorageSpec,
},
}
var retErr error
loop:
for i := 0; i < 1; i++ {
// 3、请求k8s controller创建云空间
// 设置一分钟的超时时间
timeout, cancelFunc := context.WithTimeout(context.Background(), time.Minute)
defer cancelFunc()
spaceInfo, err := startFunc(timeout, &pod)
if err != nil {
s, ok := status.FromError(err)
if !ok {
return nil, err
}
switch s.Code() {
// 创建工作空间时,工作空间已存在,修改数据库中的status
case codes.AlreadyExists:
retErr = ErrSpaceAlreadyExist
break loop
// 启动工作空间时,工作空间不存在
case codes.NotFound:
return nil, ErrSpaceNotFound
// 资源耗尽,无法启动
case codes.ResourceExhausted:
return nil, ErrResourceExhausted
case codes.Unknown:
c.logger.Errorf("rpc start space error:%v", err)
return nil, ErrSpaceStart
}
}
// 访问路径为 http://domain/ws/uid/... ws: workspace
// 4、将相关信息保存到redis
host := spaceInfo.Ip + ":" + strconv.Itoa(int(spaceInfo.Port))
err = rdis.AddRunningSpace(uid, &model.RunningSpace{
Sid: space.Sid,
Host: host,
})
if err != nil {
c.logger.Errorf("add pod info to redis error, err:%v", err)
return nil, ErrSpaceStart
}
space.RunningStatus = model.RunningStatusRunning
}
// 5、修改数据库中的状态信息
if space.Status == model.SpaceStatusUncreated {
// 更新数据库
err := c.dao.UpdateStatusById(space.Id, model.SpaceStatusAvailable)
if err != nil {
c.logger.Warnf("update space status error:%v", err)
}
}
if retErr != nil {
return nil, retErr
}
return space, nil
}
CreateAndStartWorkspace就是CreateSpace和StartSpace的组合
// CreateAndStartWorkspace 创建并且启动云工作空间
func (c *CloudCodeService) CreateAndStartWorkspace(req *reqtype.SpaceCreateOption, userId uint32, uid string) (*model.Space, error) {
// 1、检查是否有其它工作空间正在运行, 同时只能有一个工作空间启动
isRunning, err := rdis.CheckHasRunningSpace(uid)
if err != nil {
return nil, ErrSpaceCreate
}
if isRunning {
return nil, ErrOtherSpaceIsRunning
}
// 2、创建工作空间
space, err := c.CreateWorkspace(req, userId)
if err != nil {
return nil, err
}
// 3、启动工作空间
return c.startWorkspace(space, uid, c.rpc.CreateSpace)
}
步骤如下:
代码如下:
// StopWorkspace 停止云工作空间
func (c *CloudCodeService) StopWorkspace(sid, uid string) error {
// 1、查询云工作空间是否正在运行并删除数据
isRunning, err := rdis.CheckRunningSpaceAndDelete(uid)
if err != nil {
c.logger.Warnf("check is running error:%v", err)
return err
}
if !isRunning {
return ErrWorkSpaceIsNotRunning
}
// 2、停止workspace
name := c.generatePodName(sid, uid)
_, err = c.rpc.StopSpace(context.Background(), &pb.QueryOption{
Name: name,
Namespace: CloudCodeNamespace,
})
if err != nil {
c.logger.Warnf("rpc delete space error:%v", err)
return err
}
return nil
}
步骤如下:
// DeleteWorkspace 删除云工作空间
func (c *CloudCodeService) DeleteWorkspace(id uint32, uid string) error {
// 1、检查该工作空间是否正在运行,如果正在运行就返回错误
space, err := c.dao.FindSidAndStatusById(id)
if err != nil {
c.logger.Warnf("find sid error:%v", err)
return err
}
// 从redis中查询
isRunning, err := rdis.CheckIsRunning(space.Sid)
if err != nil {
c.logger.Warnf("check is running error:%v", err)
return err
}
if isRunning {
return ErrWorkSpaceIsRunning
}
// 2、通知controller删除该workspace关联的资源
ctx, cancelFunc := context.WithTimeout(context.Background(), time.Second*30)
defer cancelFunc()
name := c.generatePodName(space.Sid, uid)
_, err = c.rpc.DeleteSpace(ctx, &pb.QueryOption{Name: name, Namespace: CloudCodeNamespace})
if err != nil {
c.logger.Warnf("delete workspace err:%v", err)
return err
}
// 3、从mysql中删除记录
return c.dao.DeleteSpaceById(id)
}
由于本人对前端并不是很熟悉,因此只是进行了简单的开发,代码:https://github.com/mangohow/cloud_ide_vue
开发过程也不在介绍,页面展示如下:
登录注册页面:
空间模板:
工作空间:
需要部署的组件如下:
Mysql:
用于保存用户信息,单实例部署,可以使用Deployment,使用PVC来挂载存储卷Redis:
存储工作空间的运行信息,单实例部署,使用Deployment,使用PVC来挂载存储卷Openresty:
工作空间的反向代理、页面静态资源访问、后端web反向代理,直接部署在机器上,不使用容器(更简单方便) Operator:
使用Deployment的方式部署,由于需要访问Apiserver,因此需要配置serviceasscountWeb:
使用Deployment的方式部署,配置文件使用ConfigMap来保存前端:
使用nginx来托管前端页面部署文件(里面包含了部署所需的yaml以及sql文件和nginx的配置文件):https://github.com/mangohow/cloud-ide-apps-deploy
参考Kubernetes(K8S)学习笔记 9.2章
修改application.yaml:
首先修改application.yaml中的email,添加你的senderEmail以及authCode,这个是注册时通过邮箱发送验证码的功能,如果没有可以打开你的qq邮箱,按下面的步骤开启
修改mysql_deploy.yaml:
修改PersistentVolume中的nfs配置,需要提前创建好path中的文件夹
修改redis_deploy.yaml:
同上面步骤
生成ConfigMap:
执行下面的命令
kubectl create cm cloud-ide-web-cm --from-file=application.yaml --dry-run=client -o yaml
然后将data部分复制到web_cm.yaml文件的data下:
到此,就可以直接创建出这些服务了,执行下面的命令:
kubectl apply -f .
报了下面的错没事,因为它吧我们的web配置文件当k8s的yaml执行了
查看部署情况:
已经成功部署
部署openresty可以选择使用k8s部署或者直接在机器上部署,我选择直接部署,更加方便。关于openresty的安装就不再介绍,网上很多教程,他本质就是一个nginx。
openresty安装后通常在/usr/local/openresty
修改nginx.conf
,把下面的两个地址修改为cloud-ide-web-svc的地址
修改proxy.lua
:将redis的地址修改为cloud-ide-redis-svc的地址
然后将配置文件和lua脚本放入nginx下,分别放入lua目录和conf目录下,重启nginx
编译前端,将生成的dist文件夹中的内容放入/usr/local/openresty/nginx/html
至此所有内容都已经部署,最后一步别忘了执行sql文件来创建数据库表
在K8S中运行cloud-ide-controller时需要配置role、serviceaccount和rolebinding。cloud-ide-controller运行在cloud-ide-apps命名空间下
,但是其要访问的资源在cloud-ide命名空间下
。当时将role、serviceAccount和rolebinding的命名空间都指定为了cloud-ide-apps或者cloud-ide,这两种都不行。第一种cloud-ide-controller一直失败重启,原因是没有对资源的操作权限;第二种,创建了deployment却不会创建pod,应该是因为sa和controller不在同一个命名空间下,因此找不到sa。正确的解决方法是:将role和rolebinding的命名空间指定为controller要操作的资源的命名空间(也就是cloud-ide),将sa的命名空间指定为controller运行的命名空间(也就是cloud-ide-apps)
再配置operator的role时,一直提示没有操作Pod的权限,找了半天问题,最终发现operator_role.yaml中的resources写成了pod
,正确的应该是pods
,当场气死。