Kubernetes的编排文件支持2种格式:json和yaml,这两种格式是等价的。以RedHat的Openshift CaaS平台为例,用户会先编辑好自己的编排文件,然后丢到Openshift上去跑,这时候编排文件是固定的。但在一些类SaaS的平台,例如Databricks,CaaS这一层对用户来说是透明的,编排文件是由Databricks生成的,但由于具体的环境不同,会带来一些使用上的不便。

  1. 公共结构不能在YAML文件之间共享。可能需要在不同的环境(dev,staging,prod)、不同的运营商(AWS, Azure)中部署。

  2. YAML文件必须经常关联外部定义的实体的元数据,例如关系数据库,网络配置或SSL证书。

  3. 复杂的多层服务部署可能需要组合许多不同的资源或YAML文件。更新由许多这样的文件组成的部署变得繁重。

对于我司的大数据平台来说,使用Json也有一些不便:由于我司支持多租户,不同租户的编排文件不同,但其差异都是可预计的。当前的做法是在Java中直接生成编排代码,但这样带来的问题就是代码的可读性很差。当然由于修改并不复杂,实际上是可以使用scala的字符串插值来解决这个问题,但Scala又带来了混合编程的问题,不是每个人都能接受这种做法。

Databricks前期的做法也是使用scala来处理编排文件生成的问题,但从2015年开始,逐渐使用Jsonnet来替换之前的做法。在过去两年里,Jsonnet已经发展成为工程中的事实上的标准配置语言。

Jsonnet基础

Jsonnet是一种配置语言,可帮助您定义JSON数据。基本思想是,某些JSON字段可能会作为在编译时计算的变量或表达式留下。例如,JSON对象{"count": 4}可以表示为{count: 2 + 2}。您还可以使用可在编译期间引用的::声明隐藏的字段,例如{x:: 2, y:: 2, count: $.x + $.y}也可以计算为{"count": 4}

Jsonnet对象可以通过将对象(“+”)连接在一起来覆盖字段值来进行子类化,例如,我们定义以下内容。

local base = {
   x:: error "you must define the value of 'x'",
   y:: 2,  // this field has a default value
   count: $.x + $.y
};

然后,Jsonnet表达式(base + {x:: 10})将编译成{"count": 12}。实际上,Jsonnet编译器要求您重写x,因为默认值会引发错误。因此,您可以将base视为在Jsonnet中定义抽象基类。

Databricks还扩展了kubectl命令(kubecfg),该命令接收Jsonnet文件,将该文件编译成Json文件,并发送给kubernetes;之后kubernetes使用编译得到的json文件来创建或更新对象。

用Jsonnet组合Kubernetes对象

为了更好地了解Jsonnet如何与Kubernetes一起使用,我们来考虑为企业客户部署一个理想化的1单租户“Databricks平台”的任务。在这里,我们要创建两个不同但相关的服务部署:Webapp(用于交互式工作区)和Cluster manager(用于管理Spark群集)。此外,服务需要访问AWS RDS数据库来存储持久数据。

定义Kubernetes部署的模板

对于这个示例,我们将使用service-deployment.jsonnet.TEMPLATE,这是Databricks内部基本模板之一的简化​​版本,它们定义了一个Kubernetes 服务并将其部署在一起。实例化的Service和Deployment一起构成了可以接收网络流量的Kubernetes中的独立“生产服务”。

service-deployment.jsonnet.TEMPLATE这个文件就是下面要部署spark服务的Jsonnet模板文件。

// Template for a Kubernetes (service, deployment) pair.
{
  // Required arguments for this template
  serviceName:: error "serviceName must be specified",
  dockerImage:: error "dockerImage must be specified",

  // Optional arguments for this template.
  serviceConf:: {},
  resources:: {
    requests: {
      memory: "250Mi",
      cpu: "500m",
    },
  },
  numReplicas:: 1,

  // Defines a Kubernetes deployment
  local service = {
    kind: "Service",
    metadata: {
      name: $.serviceName,
    },
    spec: {
      selector: {
        serviceName: $.serviceName,
      }
      // Some required fields omitted for brevity -- you can refer to
      // https://kubernetes.io/docs/concepts/services-networking/service/
      // for more information on defining services.
    }
  },

  // Defines a Kubernetes service
  local deployment = {
    kind: "Deployment",
    metadata: {
      name: $.serviceName,
    },
    spec: {
      replicas: $.numReplicas,
      template: {
        metadata: {
          labels: {
            name: $.serviceName,
          },
        },
        spec: {
          containers: [
            {
              name: "default",
              image: $.dockerImage,
              resources: $.resources,
              env: {
                name: "SERVICE_CONF",
                value: std.manifestJson($.serviceConf),
              },
              // Some fields omitted for brevity -- you can refer to
              // https://kubernetes.io/docs/concepts/workloads/controllers/deployment/
              // for more information on defining deployments.
            }
          ],
        },
      },
    }
  },

  // Wrap up both the service and deployment in a Kubernetes resource list.
  apiVersion: "v1",
  kind: "List",
  items: [service, deployment],
}

示例1:每个服务部署一个文件

给定service-deployment.jsonnet.TEMPLATE,我们最简单的选择是为Webapp和Cluster管理器创建单独的Jsonnet文件。在每个文件中,我们必须导入基本模板,对其进行子类化,然后填写所需的参数。

我们指定服务名称,包含Docker映像以及包括RDS地址在内的一些服务特定配置。在此示例中,RDS地址是硬编码的,但是稍后将介绍如何从运行CloudFormation脚本的输出文件导入元数据。

以下文件描述了Manager服务(这里服务表示Kubernetes服务)。我们还在Jsonnet模板中通过serviceConf:: field模板传递到pod作为环境变量来构建服务特定的配置。我们发现以这种方式统一服务和Kubernetes配置是有用的:

simple/foocorp-manager.jsonnet:

local serviceDeployment = import "../service-deployment.jsonnet.TEMPLATE";

// The cluster manager deployment for foocorp.
serviceDeployment + {
  serviceName:: "foocorp-manager",
  dockerImage:: "manager:2.42-rc1",
  serviceConf:: {
    customerName: "foocorp",
    database: "user-db.databricks.us-west-2.rds.amazonaws.com",
  },
}

对于webapp服务来创建集群,它必须指定集群管理器的Kubernetes DNS地址(managerAddress),可以从Kubernetes服务名称提前确定:

simple/foocorp-webapp.jsonnet:

local serviceDeployment = import "../service-deployment.jsonnet.TEMPLATE";

// The webapp deployment for foocorp.
serviceDeployment + {
  serviceName:: "foocorp-webapp",
  dockerImage:: "webapp:2.42-rc1",
  serviceConf:: {
    customerName: "foocorp",
    database: "user-db.databricks.us-west-2.rds.amazonaws.com",
    managerAddress: "foocorp-manager.prod.svc.cluster.local",
  },
}

现在我们有了3个文件:模板文件service-deployment.jsonnet.TEMPLATE,manager编排文件foocorp-manager.jsonnet,webapp编排文件foocorp-webapp.jsonnet。具体文件可以git clone 示例代码repo

用jsonnet编译,可以得到最终的json编排文件。

jsonnet examples/databricks/simple/foocorp-webapp.jsonnet
jsonnet examples/databricks/simple/foocorp-manager.jsonnet

jsonnet命令行的安装,可以从Github jsonnet repo下载后make得到,当然前提是已经安装了g++(源码是c++写的)。

示例2:在单个文件中组合部署

每个用户总是需要webapp和service结合在一起提供服务,所以可以把他们定义在一个文件里,最终达到每个客户一个文件来描述其需求。

定义单独的模板shard.jsonnet.TEMPLATE(https://github.com/databricks/jsonnet-style-guide/blob/master/examples/databricks/shard-v1/shard.jsonnet.TEMPLATE),它同时组合Webapp和Cluster Manager部署而不重复参数。

这里的诀窍在于模板定义了一个Kubernetes“List”对象,它可以在一个JSON对象中包含多个Kubernetes资源。我们使用Jsonnet 标准库函数 合并由服务部署模板生成的子列表std.flattenArrays:

local serviceDeployment = import "../service-deployment.jsonnet.TEMPLATE";

{
  // Required arguments
  customerName:: error "customerName must be defined",
  release:: error "release must be defined",

  // Optional arguments
  commonConf:: {
    customerName: $.customerName,
    database: "user-db.databricks.us-west-2.rds.amazonaws.com",
  },

  local webapp = serviceDeployment + ...
  local manager = serviceDeployment + ...

  kind: "List",
  items: std.flattenArrays([webapp, manager]),
}

现在我们可以方便地定义FooCorp的整个部署,而不需要任何重复的数据,只能使用一个文件:

shard-v1 / foocorp-shard.jsonnet:

local shardTemplate = import "shard.jsonnet.TEMPLATE";

shardTemplate + {
  customerName:: "foocorp",
  release:: "2.42-rc1",
}

对于不同的用户来说,真正不同的也就是customerName和使用的镜像的版本了。

示例3:对多个环境进行子类化模板

针对不同的环境(dev, staging, prod),可以将Jsonnet进一步解构、重构以满足复杂环境的要求。这块直接看shard-v2部分的代码,不再贴了。

如何为我所用

像我们这种多租户的场景,使用jsonnet是很合适的,代码可读性更好。但很遗憾的是,jsonnet只提供了c api(make libjsonnet.so)和python api(sudo pip install jsonnet),并没有提供java的api,使用起来并不是很方便。

我试着用JNI去封装jsonnet的c api,但在调试的时候发现会段错误。调用c api的处理应该没问题,我单独写c程序验证过;但是通过jni来调用就会有问题,感觉像是嵌套层次太深造成的。源代码在这里,如果你有兴趣可以帮忙走读下代码找找原因,不胜感激。

另一个思路就是用java重写jsonnet。github上有这么一个项目,但它的实现是有问题的。

Ref

Declarative Infrastructure with the Jsonnet Templating Language