Istio流量管理实现机制深度解析
https://zhaohuabing.com/post/2018-09-25-istio-traffic-management-impl-intro/TOC
- 前言
- Pilot高层架构
- Istio流量管理相关组件
- 数据面标准API
- Bookinfo 示例程序分析
- 小结
- 参考资料
前言
Istio作为一个service mesh开源项目,其中最重要的功能就是对网格中微服务之间的流量进行管理,包括服务发现,请求路由和服务间的可靠通信。Istio实现了service mesh的控制面,并整合Envoy开源项目作为数据面的sidecar,一起对流量进行控制。
Istio体系中流量管理配置下发以及流量规则如何在数据面生效的机制相对比较复杂,通过官方文档容易管中窥豹,难以了解其实现原理。本文尝试结合系统架构、配置文件和代码对Istio流量管理的架构和实现机制进行分析,以达到从整体上理解Pilot和Envoy的流量管理机制的目的。
Pilot高层架构
Istio控制面中负责流量管理的组件为Pilot,Pilot的高层架构如下图所示:
Pilot Architecture(来自Isio官网文档[1])
根据上图,Pilot主要实现了下述功能:
统一的服务模型
Pilot定义了网格中服务的标准模型,这个标准模型独立于各种底层平台。由于有了该标准模型,各个不同的平台可以通过适配器和Pilot对接,将自己特有的服务数据格式转换为标准格式,填充到Pilot的标准模型中。
例如Pilot中的Kubernetes适配器通过Kubernetes API服务器得到kubernetes中service和pod的相关信息,然后翻译为标准模型提供给Pilot使用。通过适配器模式,Pilot还可以从Mesos, Cloud Foundry, Consul等平台中获取服务信息,还可以开发适配器将其他提供服务发现的组件集成到Pilot中。
标准数据面 API
Pilo使用了一套起源于Envoy项目的标准数据面API[2]来将服务信息和流量规则下发到数据面的sidecar中。
通过采用该标准API,Istio将控制面和数据面进行了解耦,为多种数据面sidecar实现提供了可能性。事实上基于该标准API已经实现了多种Sidecar代理和Istio的集成,除Istio目前集成的Envoy外,还可以和Linkerd, Nginmesh等第三方通信代理进行集成,也可以基于该API自己编写Sidecar实现。
控制面和数据面解耦是Istio后来居上,风头超过Service mesh鼻祖Linkerd的一招妙棋。Istio站在了控制面的高度上,而Linkerd则成为了可选的一种sidecar实现,可谓降维打击的一个典型成功案例!
数据面标准API也有利于生态圈的建立,开源,商业的各种sidecar以后可能百花齐放,用户也可以根据自己的业务场景选择不同的sidecar和控制面集成,如高吞吐量的,低延迟的,高安全性的等等。有实力的大厂商可以根据该API定制自己的sidecar,例如蚂蚁金服开源的Golang版本的Sidecar MOSN(Modular Observable Smart Netstub)(SOFAMesh中Golang版本的Sidecar);小厂商则可以考虑采用成熟的开源项目或者提供服务的商业sidecar实现。
备注:Istio和Envoy项目联合制定了Envoy V2 API,并采用该API作为Istio控制面和数据面流量管理的标准接口。
业务DSL语言
Pilot还定义了一套DSL(Domain Specific Language)语言,DSL语言提供了面向业务的高层抽象,可以被运维人员理解和使用。运维人员使用该DSL定义流量规则并下发到Pilot,这些规则被Pilot翻译成数据面的配置,再通过标准API分发到Envoy实例,可以在运行期对微服务的流量进行控制和调整。
Pilot的规则DSL是采用K8S API Server中的Custom Resource (CRD)[3]实现的,因此和其他资源类型如Service Pod Deployment的创建和使用方法类似,都可以用Kubectl进行创建。
通过运用不同的流量规则,可以对网格中微服务进行精细化的流量控制,如按版本分流,断路器,故障注入,灰度发布等。
Istio流量管理相关组件
我们可以通过下图了解Istio流量管理涉及到的相关组件。虽然该图来自Istio Github old pilot repo, 但图中描述的组件及流程和目前Pilot的最新代码的架构基本是一致的。
Pilot Design Overview (来自Istio old_pilot_repo[4])
图例说明:图中红色的线表示控制流,黑色的线表示数据流。蓝色部分为和Pilot相关的组件。
从上图可以看到,Istio中和流量管理相关的有以下组件:
控制面组件
Discovery Services
对应的docker为gcr.io/istio-release/pilot,进程为pilot-discovery,该组件的功能包括:
- 从Service provider(如kubernetes或者consul)中获取服务信息
- 从K8S API Server中获取流量规则(K8S CRD Resource)
- 将服务信息和流量规则转化为数据面可以理解的格式,通过标准的数据面API下发到网格中的各个sidecar中。
K8S API Server
提供Pilot相关的CRD Resource的增、删、改、查。和Pilot相关的CRD有以下几种:
- Virtualservice:用于定义路由规则,如根据来源或 Header 制定规则,或在不同服务版本之间分拆流量。
- DestinationRule:定义目的服务的配置策略以及可路由子集。策略包括断路器、负载均衡以及 TLS 等。
- ServiceEntry:用 ServiceEntry 可以向Istio中加入附加的服务条目,以使网格内可以向istio 服务网格之外的服务发出请求。
- Gateway:为网格配置网关,以允许一个服务可以被网格外部访问。
- EnvoyFilter:可以为Envoy配置过滤器。由于Envoy已经支持Lua过滤器,因此可以通过EnvoyFilter启用Lua过滤器,动态改变Envoy的过滤链行为。我之前一直在考虑如何才能动态扩展Envoy的能力,EnvoyFilter提供了很灵活的扩展性。
数据面组件
在数据面有两个进程Pilot-agent和envoy,这两个进程被放在一个docker容器gcr.io/istio-release/proxyv2中。
Pilot-agent
该进程根据K8S API Server中的配置信息生成Envoy的配置文件,并负责启动Envoy进程。注意Envoy的大部分配置信息都是通过xDS接口从Pilot中动态获取的,因此Agent生成的只是用于初始化Envoy的少量静态配置。在后面的章节中,本文将对Agent生成的Envoy配置文件进行进一步分析。
Envoy
Envoy由Pilot-agent进程启动,启动后,Envoy读取Pilot-agent为它生成的配置文件,然后根据该文件的配置获取到Pilot的地址,通过数据面标准API的xDS接口从pilot拉取动态配置信息,包括路由(route),监听器(listener),服务集群(cluster)和服务端点(endpoint)。Envoy初始化完成后,就根据这些配置信息对微服务间的通信进行寻址和路由。
命令行工具
kubectl和Istioctl,由于Istio的配置是基于K8S的CRD,因此可以直接采用kubectl对这些资源进行操作。Istioctl则针对Istio对CRD的操作进行了一些封装。Istioctl支持的功能参见该表格。
数据面标准API
前面讲到,Pilot采用了一套标准的API来向数据面Sidecar提供服务发现,负载均衡池和路由表等流量管理的配置信息。该标准API的文档参见Envoy v2 API[5]。Data Plane API Protocol Buffer Definition[6])给出了v2 grpc接口相关的数据结构和接口定义。
(备注:Istio早期采用了Envoy v1 API,目前的版本中则使用V2 API,V1已被废弃)。
基本概念和术语
首先我们需要了解数据面API中涉及到的一些基本概念:
- Host:能够进行网络通信的实体(如移动设备、服务器上的应用程序)。在此文档中,主机是逻辑网络应用程序。一块物理硬件上可能运行有多个主机,只要它们是可以独立寻址的。在EDS接口中,也使用“Endpoint”来表示一个应用实例,对应一个IP+Port的组合。
- Downstream:下游主机连接到 Envoy,发送请求并接收响应。
- Upstream:上游主机接收来自 Envoy 的连接和请求,并返回响应。
- Listener:监听器是命名网地址(例如,端口、unix domain socket等),可以被下游客户端连接。Envoy 暴露一个或者多个监听器给下游主机连接。在Envoy中,Listener可以绑定到端口上直接对外服务,也可以不绑定到端口上,而是接收其他listener转发的请求。
- Cluster:集群是指 Envoy 连接到的逻辑上相同的一组上游主机。Envoy 通过服务发现来发现集群的成员。可以选择通过主动健康检查来确定集群成员的健康状态。Envoy 通过负载均衡策略决定将请求路由到哪个集群成员。
XDS服务接口
Istio数据面API定义了xDS服务接口,Pilot通过该接口向数据面sidecar下发动态配置信息,以对Mesh中的数据流量进行控制。xDS中的DS表示discovery service,即发现服务,表示xDS接口使用动态发现的方式提供数据面所需的配置数据。而x则是一个代词,表示有多种discover service。这些发现服务及对应的数据结构如下:
- LDS (Listener Discovery Service) envoy.api.v2.Listener
- CDS (Cluster Discovery Service) envoy.api.v2.RouteConfiguration
- EDS (Endpoint Discovery Service) envoy.api.v2.Cluster
- RDS (Route Discovery Service) envoy.api.v2.ClusterLoadAssignment
XDS服务接口的最终一致性考虑
xDS的几个接口是相互独立的,接口下发的配置数据是最终一致的。但在配置更新过程中,可能暂时出现各个接口的数据不匹配的情况,从而导致部分流量在更新过程中丢失。
设想这种场景:在CDS/EDS只知道cluster X的情况下,RDS的一条路由配置将指向Cluster X的流量调整到了Cluster Y。在CDS/EDS向Mesh中Envoy提供Cluster Y的更新前,这部分导向Cluster Y的流量将会因为Envoy不知道Cluster Y的信息而被丢弃。
对于某些应用来说,短暂的部分流量丢失是可以接受的,例如客户端重试可以解决该问题,并不影响业务逻辑。对于另一些场景来说,这种情况可能无法容忍。可以通过调整xDS接口的更新逻辑来避免该问题,对上面的情况,可以先通过CDS/EDS更新Y Cluster,然后再通过RDS将X的流量路由到Y。
一般来说,为了避免Envoy配置数据更新过程中出现流量丢失的情况,xDS接口应采用下面的顺序:
- CDS 首先更新Cluster数据(如果有变化)
- EDS 更新相应Cluster的Endpoint信息(如果有变化)
- LDS 更新CDS/EDS相应的Listener。
- RDS 最后更新新增Listener相关的Route配置。
- 删除不再使用的CDS cluster和 EDS endpoints。
ADS聚合发现服务
保证控制面下发数据一致性,避免流量在配置更新过程中丢失的另一个方式是使用ADS(Aggregated Discovery Services),即聚合的发现服务。ADS通过一个gRPC流来发布所有的配置更新,以保证各个xDS接口的调用顺序,避免由于xDS接口更新顺序导致的配置数据不一致问题。
关于XDS接口的详细介绍可参考xDS REST and gRPC protocol[7]
Bookinfo 示例程序分析
下面我们以Bookinfo为例对Istio中的流量管理实现机制,以及控制面和数据面的交互进行进一步分析。
Bookinfo程序结构
下图显示了Bookinfo示例程序中各个组件的IP地址,端口和调用关系,以用于后续的分析。
xDS接口调试方法
首先我们看看如何对xDS接口的相关数据进行查看和分析。Envoy v2接口采用了gRPC,由于gRPC是基于二进制的RPC协议,无法像V1的REST接口一样通过curl和浏览器进行进行分析。但我们还是可以通过Pilot和Envoy的调试接口查看xDS接口的相关数据。
Pilot调试方法
Pilot在9093端口提供了下述调试接口[8]下述方法查看xDS接口相关数据。
PILOT=istio-pilot.istio-system:9093
# What is sent to envoy
# Listeners and routes
curl $PILOT/debug/adsz
# Endpoints
curl $PILOT/debug/edsz
# Clusters
curl $PILOT/debug/cdsz
Envoy调试方法
Envoy提供了管理接口,缺省为localhost的15000端口,可以获取listener,cluster以及完整的配置数据导出功能。
kubectl exec productpage-v1-54b8b9f55-bx2dq -c istio-proxy curl http://127.0.0.1:15000/help
/: Admin home page
/certs: print certs on machine
/clusters: upstream cluster status
/config_dump: dump current Envoy configs (experimental)
/cpuprofiler: enable/disable the CPU profiler
/healthcheck/fail: cause the server to fail health checks
/healthcheck/ok: cause the server to pass health checks
/help: print out list of admin commands
/hot_restart_version: print the hot restart compatibility version
/listeners: print listener addresses
/logging: query/change logging levels
/quitquitquit: exit the server
/reset_counters: reset all counters to zero
/runtime: print runtime values
/runtime_modify: modify runtime values
/server_info: print server version/status information
/stats: print server stats
/stats/prometheus: print server stats in prometheus format
进入productpage pod 中的istio-proxy(Envoy) container,可以看到有下面的监听端口
- 9080: productpage进程对外提供的服务端口
- 15001: Envoy的入口监听器,iptable会将pod的流量导入该端口中由Envoy进行处理
- 15000: Envoy管理端口,该端口绑定在本地环回地址上,只能在Pod内访问。
kubectl exec t productpage-v1-54b8b9f55-bx2dq -c istio-proxy -- netstat -ln
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:9080 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:15000 0.0.0.0:* LISTEN 13/envoy
tcp 0 0 0.0.0.0:15001 0.0.0.0:* LISTEN 13/envoy
Envoy启动过程分析
Istio通过K8s的Admission webhook[9]机制实现了sidecar的自动注入,Mesh中的每个微服务会被加入Envoy相关的容器。下面是Productpage微服务的Pod内容,可见除productpage之外,Istio还在该Pod中注入了两个容器gcr.io/istio-release/proxy_init和gcr.io/istio-release/proxyv2。
备注:下面Pod description中只保留了需要关注的内容,删除了其它不重要的部分。为方便查看,本文中后续的其它配置文件以及命令行输出也会进行类似处理。
ubuntu@envoy-test:~$ kubectl describe pod productpage-v1-54b8b9f55-bx2dq
Name: productpage-v1-54b8b9f55-bx2dq
Namespace: default
Init Containers:
istio-init:
Image: gcr.io/istio-release/proxy_init:1.0.0
Args:
-p
15001
-u
1337
-m
REDIRECT
-i
*
-x
-b
9080,
-d
Containers:
productpage:
Image: istio/examples-bookinfo-productpage-v1:1.8.0
Port: 9080/TCP
istio-proxy:
Image: gcr.io/istio-release/proxyv2:1.0.0
Args:
proxy
sidecar
--configPath
/etc/istio/proxy
--binaryPath
/usr/local/bin/envoy
--serviceCluster
productpage
--drainDuration
45s
--parentShutdownDuration
1m0s
--discoveryAddress
istio-pilot.istio-system:15007
--discoveryRefreshDelay
1s
--zipkinAddress
zipkin.istio-system:9411
--connectTimeout
10s
--statsdUdpAddress
istio-statsd-prom-bridge.istio-system:9125
--proxyAdminPort
15000
--controlPlaneAuthPolicy
NONE
Proxy_init
Productpage的Pod中有一个InitContainer proxy_init,InitContrainer是K8S提供的机制,用于在Pod中执行一些初始化任务.在Initialcontainer执行完毕并退出后,才会启动Pod中的其它container。
我们看一下proxy_init容器中的内容:
ubuntu@envoy-test:~$ sudo docker inspect gcr.io/istio-release/proxy_init:1.0.0
[
{
"RepoTags": [
"gcr.io/istio-release/proxy_init:1.0.0"
],
"ContainerConfig": {
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
],
"Cmd": [
"/bin/sh",
"-c",
"#(nop) ",
"ENTRYPOINT [\"/usr/local/bin/istio-iptables.sh\"]"
],
"Entrypoint": [
"/usr/local/bin/istio-iptables.sh"
],
},
}
]
从上面的命令行输出可以看到,Proxy_init中执行的命令是istio-iptables.sh,该脚本源码较长,就不列出来了,有兴趣可以在Istio 源码仓库的tools/deb/istio-iptables.sh查看。
该脚本的作用是通过配置iptable来劫持Pod中的流量。结合前面Pod中该容器的命令行参数-p 15001,可以得知Pod中的数据流量被iptable拦截,并发向Envoy的15001端口。 -u 1337参数用于排除用户ID为1337,即Envoy自身的流量,以避免Iptable把Envoy发出的数据又重定向到Envoy,形成死循环。
Proxyv2
前面提到,该容器中有两个进程Pilot-agent和envoy。我们进入容器中看看这两个进程的相关信息。
ubuntu@envoy-test:~$ kubectl exec productpage-v1-54b8b9f55-bx2dq -c istio-proxy -- ps -ef
UID PID PPID C STIME TTY TIME CMD
istio-p+ 1 0 0 Sep06 ? 00:00:00 /usr/local/bin/pilot-agent proxy sidecar --configPath /etc/istio/proxy --binaryPath /usr/local/bin/envoy --serviceCluster productpage --drainDuration 45s --parentShutdownDuration 1m0s --discoveryAddress istio-pilot.istio-system:15007 --discoveryRefreshDelay 1s --zipkinAddress zipkin.istio-system:9411 --connectTimeout 10s --statsdUdpAddress istio-statsd-prom-bridge.istio-system:9125 --proxyAdminPort 15000 --controlPlaneAuthPolicy NONE
istio-p+ 13 1 0 Sep06 ? 00:47:37 /usr/local/bin/envoy -c /etc/istio/proxy/envoy-rev0.json --restart-epoch 0 --drain-time-s 45 --parent-shutdown-time-s 60 --service-cluster productpage --service-node sidecar~192.168.206.23~productpage-v1-54b8b9f55-bx2dq.default~default.svc.cluster.local --max-obj-name-len 189 -l warn --v2-config-only
Envoy的大部分配置都是dynamic resource,包括网格中服务相关的service cluster, listener, route规则等。这些dynamic resource是通过xDS接口从Istio控制面中动态获取的。但Envoy如何知道xDS server的地址呢?这是在Envoy初始化配置文件中以static resource的方式配置的。
Envoy初始配置文件
Pilot-agent进程根据启动参数和K8S API Server中的配置信息生成Envoy的初始配置文件,并负责启动Envoy进程。从ps命令输出可以看到Pilot-agent在启动Envoy进程时传入了pilot地址和zipkin地址,并为Envoy生成了一个初始化配置文件envoy-rev0.json
Pilot agent生成初始化配置文件的代码: https://github.com/istio/istio/blob/release-1.0/pkg/bootstrap/bootstrap_config.go 137行
// WriteBootstrap generates an envoy config based on config and epoch, and returns the filename.
// TODO: in v2 some of the LDS ports (port, http_port) should be configured in the bootstrap.
func WriteBootstrap(config *meshconfig.ProxyConfig, node string, epoch int, pilotSAN []string, opts map[string]interface{}) (string, error) {
if opts == nil {
opts = map[string]interface{}{}
}
if err := os.MkdirAll(config.ConfigPath, 0700); err != nil {
return "", err
}
// attempt to write file
fname := configFile(config.ConfigPath, epoch)
cfg := config.CustomConfigFile
if cfg == "" {
cfg = config.ProxyBootstrapTemplatePath
}
if cfg == "" {
cfg = DefaultCfgDir
}
......
if config.StatsdUdpAddress != "" {
h, p, err = GetHostPort("statsd UDP", config.StatsdUdpAddress)
if err != nil {
return "", err
}
StoreHostPort(h, p, "statsd", opts)
}
fout, err := os.Create(fname)
if err != nil {
return "", err
}
// Execute needs some sort of io.Writer
err = t.Execute(fout, opts)
return fname, err
}
可以使用下面的命令将productpage pod中该文件导出来查看其中的内容:
kubectl exec productpage-v1-54b8b9f55-bx2dq -c istio-proxy -- cat /etc/istio/proxy/envoy-rev0.json > envoy-rev0.json
配置文件的结构如图所示:
其中各个配置节点的内容如下:
Node
包含了Envoy所在节点相关信息。
"node": {
"id": "sidecar~192.168.206.23~productpage-v1-54b8b9f55-bx2dq.default~default.svc.cluster.local",
//用于标识envoy所代理的node(在k8s中对应为Pod)上的service cluster,来自于Envoy进程启动时的service-cluster参数
"cluster": "productpage",
"metadata": {
"INTERCEPTION_MODE": "REDIRECT",
"ISTIO_PROXY_SHA": "istio-proxy:6166ae7ebac7f630206b2fe4e6767516bf198313",
"ISTIO_PROXY_VERSION": "1.0.0",
"ISTIO_VERSION": "1.0.0",
"POD_NAME": "productpage-v1-54b8b9f55-bx2dq",
"istio": "sidecar"
}
}
Admin
配置Envoy的日志路径以及管理端口。
"admin": {
"access_log_path": "/dev/stdout",
"address": {
"socket_address": {
"address": "127.0.0.1",
"port_value": 15000
}
}
}
Dynamic_resources
配置动态资源,这里配置了ADS服务器。
"dynamic_resources": {
"lds_config": {
"ads": {}
},
"cds_config": {
"ads": {}
},
"ads_config": {
"api_type": "GRPC",
"refresh_delay": {"seconds": 1, "nanos": 0},
"grpc_services": [
{
"envoy_grpc": {
"cluster_name": "xds-grpc"
}
}
]
}
}```
Static_resources
配置静态资源,包括了xds-grpc和zipkin两个cluster。其中xds-grpc cluster对应前面dynamic_resources中ADS配置,指明了Envoy用于获取动态资源的服务器地址。
"static_resources": {
"clusters": [
{
"name": "xds-grpc",
"type": "STRICT_DNS",
"connect_timeout": {"seconds": 10, "nanos": 0},
"lb_policy": "ROUND_ROBIN",
"hosts": [
{
"socket_address": {"address": "istio-pilot.istio-system", "port_value": 15010}
}
],
"circuit_breakers": {
"thresholds": [
{
"priority": "default",
"max_connections": "100000",
"max_pending_requests": "100000",
"max_requests": "100000"
},
{
"priority": "high",
"max_connections": "100000",
"max_pending_requests": "100000",
"max_requests": "100000"
}]
},
"upstream_connection_options": {
"tcp_keepalive": {
"keepalive_time": 300
}
},
"http2_protocol_options": { }
} ,
{
"name": "zipkin",
"type": "STRICT_DNS",
"connect_timeout": {
"seconds": 1
},
"lb_policy": "ROUND_ROBIN",
"hosts": [
{
"socket_address": {"address": "zipkin.istio-system", "port_value": 9411}
}
]
}
]
}
Tracing
配置分布式链路跟踪。
"tracing": {
"http": {
"name": "envoy.zipkin",
"config": {
"collector_cluster": "zipkin"
}
}
}
Stats_sinks
这里配置的是和Envoy直连的metrics收集sink,和Mixer telemetry没有关系。Envoy自带stats格式的metrics上报。
"stats_sinks": [
{
"name": "envoy.statsd",
"config": {
"address": {
"socket_address": {"address": "10.103.219.158", "port_value": 9125}
}
}
}
]
在Gist https://gist.github.com/zhaohuabing/14191bdcf72e37bf700129561c3b41ae中可以查看该配置文件的完整内容。
Envoy配置分析
通过管理接口获取完整配置
从Envoy初始化配置文件中,我们可以大致看到Istio通过Envoy来实现服务发现和流量管理的基本原理。即控制面将xDS server信息通过static resource的方式配置到Envoy的初始化配置文件中,Envoy启动后通过xDS server获取到dynamic resource,包括网格中的service信息及路由规则。
Envoy配置初始化流程:
- Pilot-agent根据启动参数和K8S API Server中的配置信息生成Envoy的初始配置文件envoy-rev0.json,该文件告诉Envoy从xDS server中获取动态配置信息,并配置了xDS server的地址信息,即控制面的Pilot。
- Pilot-agent使用envoy-rev0.json启动Envoy进程。
- Envoy根据初始配置获得Pilot地址,采用xDS接口从Pilot获取到Listener,Cluster,Route等d动态配置信息。
- Envoy根据获取到的动态配置启动Listener,并根据Listener的配置,结合Route和Cluster对拦截到的流量进行处理。
可以看到,Envoy中实际生效的配置是由初始化配置文件中的静态配置和从Pilot获取的动态配置一起组成的。因此只对envoy-rev0 .json进行分析并不能看到Mesh中流量管理的全貌。那么有没有办法可以看到Envoy中实际生效的完整配置呢?答案是可以的,我们可以通过Envoy的管理接口来获取Envoy的完整配置。
kubectl exec -it productpage-v1-54b8b9f55-bx2dq -c istio-proxy curl http://127.0.0.1:15000/config_dump > config_dump
该文件内容长达近7000行,本文中就不贴出来了,在Gist https://gist.github.com/zhaohuabing/034ef87786d290a4e89cd6f5ad6fcc97 中可以查看到全文。
Envoy配置文件结构
文件中的配置节点包括:
Bootstrap
从名字可以大致猜出这是Envoy的初始化配置,打开该节点,可以看到文件中的内容和前一章节中介绍的envoy-rev0.json是一致的,这里不再赘述。
Clusters
在Envoy中,Cluster是一个服务集群,Cluster中包含一个到多个endpoint,每个endpoint都可以提供服务,Envoy根据负载均衡算法将请求发送到这些endpoint中。
在Productpage的clusters配置中包含static_clusters和dynamic_active_clusters两部分,其中static_clusters是来自于envoy-rev0.json的xDS server和zipkin server信息。dynamic_active_clusters是通过xDS接口从Istio控制面获取的动态服务信息。
Dynamic Cluster中有以下几类Cluster:
Outbound Cluster
这部分的Cluster占了绝大多数,该类Cluster对应于Envoy所在节点的外部服务。以details为例,对于Productpage来说,details是一个外部服务,因此其Cluster名称中包含outbound字样。
从details 服务对应的cluster配置中可以看到,其类型为EDS,即表示该Cluster的endpoint来自于动态发现,动态发现中eds_config则指向了ads,最终指向static Resource中配置的xds-grpc cluster,即Pilot的地址。
{
"version_info": "2018-09-06T09:34:19Z",
"cluster": {
"name": "outbound|9080||details.default.svc.cluster.local",
"type": "EDS",
"eds_cluster_config": {
"eds_config": {
"ads": {}
},
"service_name": "outbound|9080||details.default.svc.cluster.local"
},
"connect_timeout": "1s",
"circuit_breakers": {
"thresholds": [
{}
]
}
},
"last_updated": "2018-09-06T09:34:20.404Z"
}
可以通过Pilot的调试接口获取该Cluster的endpoint:
curl http://10.96.8.103:9093/debug/edsz > pilot_eds_dump
导出的文件长达1300多行,本文只贴出details服务相关的endpoint配置,完整文件参见:https://gist.github.com/zhaohuabing/a161d2f64746acd18097b74e6a5af551
从下面的文件内容可以看到,details cluster配置了1个endpoint地址,是details的pod ip。
{
"clusterName": "outbound|9080||details.default.svc.cluster.local",
"endpoints": [
{
"locality": {
},
"lbEndpoints": [
{
"endpoint": {
"address": {
"socketAddress": {
"address": "192.168.206.21",
"portValue": 9080
}
}
},
"metadata": {
"filterMetadata": {
"istio": {
"uid": "kubernetes://details-v1-6764bbc7f7-qwzdg.default"
}
}
}
}
]
}
]
}
Inbound Cluster
该类Cluster对应于Envoy所在节点上的服务。如果该服务接收到请求,当然就是一个入站请求。对于Productpage Pod上的Envoy,其对应的Inbound Cluster只有一个,即productpage。该cluster对应的host为127.0.0.1,即环回地址上productpage的监听端口。由于iptable规则中排除了127.0.0.1,入站请求通过该Inbound cluster处理后将跳过Envoy,直接发送给Productpage进程处理。
{
"version_info": "2018-09-14T01:44:05Z",
"cluster": {
"name": "inbound|9080||productpage.default.svc.cluster.local",
"connect_timeout": "1s",
"hosts": [
{
"socket_address": {
"address": "127.0.0.1",
"port_value": 9080
}
}
],
"circuit_breakers": {
"thresholds": [
{}
]
}
},
"last_updated": "2018-09-14T01:44:05.291Z"
}
BlackHoleCluster
这是一个特殊的Cluster,并没有配置后端处理请求的Host。如其名字所暗示的一样,请求进入后将被直接丢弃掉。如果一个请求没有找到其对的目的服务,则被发到cluste。
{
"version_info": "2018-09-06T09:34:19Z",
"cluster": {
"name": "BlackHoleCluster",
"connect_timeout": "5s"
},
"last_updated": "2018-09-06T09:34:20.408Z"
}
Listeners
Envoy采用listener来接收并处理downstream发过来的请求,listener的处理逻辑是插件式的,可以通过配置不同的filter来插入不同的处理逻辑。Istio就在Envoy中加入了用于policy check和metric report的Mixer filter。
Listener可以绑定到IP Socket或者Unix Domain Socket上,也可以不绑定到一个具体的端口上,而是接收从其他listener转发来的数据。Istio就是利用了Envoy listener的这一特点实现了将来发向不同服务的请求转交给不同的listener处理。
Virtual Listener
Envoy创建了一个在15001端口监听的入口监听器。Iptable将请求截取后发向15001端口,该监听器接收后并不进行业务处理,而是根据请求目的地分发给其他监听器处理。该监听器取名为”virtual”(虚拟)监听器也是这个原因。
Envoy是如何做到按服务分发的呢? 可以看到该Listener的配置项use_original_dest设置为true,该配置要求监听器将接收到的请求转交给和请求原目的地址关联的listener进行处理。
从其filter配置可以看到,如果找不到和请求目的地配置的listener进行转交,则请求将被发送到BlackHoleCluster,由于BlackHoleCluster并没有配置host,因此找不到对应目的地对应监听器的请求实际上会被丢弃。
{
"version_info": "2018-09-06T09:34:19Z",
"listener": {
"name": "virtual",
"address": {
"socket_address": {
"address": "0.0.0.0",
"port_value": 15001
}
},
"filter_chains": [
{
"filters": [
{
"name": "envoy.tcp_proxy",
"config": {
"stat_prefix": "BlackHoleCluster",
"cluster": "BlackHoleCluster"
}
}
]
}
],
"use_original_dst": true
},
"last_updated": "2018-09-06T09:34:26.262Z"
}
Inbound Listener
在Productpage Pod上的Envoy创建了Listener 192.168.206.23_9080,当外部调用Productpage服务的请求到达Pod上15001的”Virtual” Listener时,Virtual Listener根据请求目的地匹配到该Listener,请求将被转发过来。
{
"version_info": "2018-09-14T01:44:05Z",
"listener": {
"name": "192.168.206.23_9080",
"address": {
"socket_address": {
"address": "192.168.206.23",
"port_value": 9080
}
},
"filter_chains": [
{
"filters": [
{
"name": "mixer",
"config": {
"transport": {
"check_cluster": "outbound|9091||istio-policy.istio-system.svc.cluster.local",
"network_fail_policy": {
"policy": "FAIL_CLOSE"
},
"report_cluster": "outbound|9091||istio-telemetry.istio-system.svc.cluster.local",
"attributes_for_mixer_proxy": {
"attributes": {
"source.uid": {
"string_value": "kubernetes://productpage-v1-54b8b9f55-bx2dq.default"
}
}
}
},
"mixer_attributes": {
"attributes": {
"destination.port": {
"int64_value": "9080"
},
"context.reporter.uid": {
"string_value": "kubernetes://productpage-v1-54b8b9f55-bx2dq.default"
},
"destination.namespace": {
"string_value": "default"
},
"destination.ip": {
"bytes_value": "AAAAAAAAAAAAAP//wKjOFw=="
},
"destination.uid": {
"string_value": "kubernetes://productpage-v1-54b8b9f55-bx2dq.default"
},
"context.reporter.kind": {
"string_value": "inbound"
}
}
}
}
},
{
"name": "envoy.tcp_proxy",
"config": {
"stat_prefix": "inbound|9080||productpage.default.svc.cluster.local",
"cluster": "inbound|9080||productpage.default.svc.cluster.local"
}
}
]
}
],
"deprecated_v1": {
"bind_to_port": false
}
},
"last_updated": "2018-09-14T01:44:05.754Z"
}
从上面的配置”bind_to_port”: false可以得知该listener创建后并不会被绑定到tcp端口上直接接收网络上的数据,因此其所有请求都转发自15001端口。
该listener配置的envoy.tcp_proxy filter对应的cluster为“inbound|9080||productpage.default.svc.cluster.local”,该cluster配置的host为127.0.0.1:9080,因此Envoy会将该请求发向127.0.0.1:9080。由于iptable设置中127.0.0.1不会被拦截,该请求将发送到Productpage进程的9080端口进行业务处理。
除此以外,Listenter中还包含Mixer filter的配置信息,配置了策略检查(Mixer check)和Metrics上报(Mixer report)服务器地址,以及Mixer上报的一些attribute取值。
Outbound Listener
Envoy为网格中的外部服务按端口创建多个Listener,以用于处理出向请求。
Productpage Pod中的Envoy创建了多个Outbound Listener
- 0.0.0.0_9080 :处理对details,reviews和rating服务的出向请求
- 0.0.0.0_9411 :处理对zipkin的出向请求
- 0.0.0.0_15031 :处理对ingressgateway的出向请求
- 0.0.0.0_3000 :处理对grafana的出向请求
- 0.0.0.0_9093 :处理对citadel、galley、pilot、(Mixer)policy、(Mixer)telemetry的出向请求
- 0.0.0.0_15004 :处理对(Mixer)policy、(Mixer)telemetry的出向请求
- ……
除了9080这个Listener用于处理应用的业务之外,其他listener都是Istio用于处理自身组件之间通信使用的,有的控制面组件如Pilot,Mixer对应多个listener,是因为该组件有多个端口提供服务。
我们这里主要分析一下9080这个业务端口的Listenrer。和Outbound Listener一样,该Listener同样配置了”bind_to_port”: false属性,因此该listener也没有被绑定到tcp端口上,其接收到的所有请求都转发自15001端口的Virtual listener。
监听器name为0.0.0.0_9080,推测其含义应为匹配发向任意IP的9080的请求,从bookinfo程序结构可以看到该程序中的productpage,revirews,ratings,details四个service都是9080端口,那么Envoy如何区别处理这四个service呢?
首先需要区分入向(发送给productpage)请求和出向(发送给其他几个服务)请求:
- 发给productpage的入向请求,virtual listener根据其目的IP和Port首先匹配到192.168.206.23_9080这个listener上,不会进入0.0.0.0_9080 listener处理。
- 从productpage外发给reviews、details和ratings的出向请求,virtual listener无法找到和其目的IP完全匹配的listener,因此根据通配原则转交给0.0.0.0_9080处理。
备注:
1. 该转发逻辑为根据Envoy配置进行的推测,并未分析Envoy代码进行验证。欢迎了解Envoy代码和实现机制的朋友指正。
2.根据业务逻辑,实际上productpage并不会调用ratings服务,但Istio并不知道各个业务之间会如何调用,因此将所有的服务信息都下发到了Envoy中。这样做对效率和性能理论上有一定影响,存在一定的优化空间。
由于对应到reviews、details和Ratings三个服务,当0.0.0.0_9080接收到出向请求后,并不能直接发送到一个downstream cluster中,而是需要根据请求目的地进行不同的路由。
在该listener的配置中,我们可以看到并没有像inbound listener那样通过envoy.tcp_proxy直接指定一个downstream的cluster,而是通过rds配置了一个路由规则9080,在路由规则中再根据不同的请求目的地对请求进行处理。
{
"version_info": "2018-09-06T09:34:19Z",
"listener": {
"name": "0.0.0.0_9080",
"address": {
"socket_address": {
"address": "0.0.0.0",
"port_value": 9080
}
},
"filter_chains": [
{
"filters": [
{
"name": "envoy.http_connection_manager",
"config": {
"access_log": [
{
"name": "envoy.file_access_log",
"config": {
"path": "/dev/stdout"
}
}
],
"http_filters": [
{
"name": "mixer",
"config": {
......
}
},
{
"name": "envoy.cors"
},
{
"name": "envoy.fault"
},
{
"name": "envoy.router"
}
],
"tracing": {
"operation_name": "EGRESS",
"client_sampling": {
"value": 100
},
"overall_sampling": {
"value": 100
},
"random_sampling": {
"value": 100
}
},
"use_remote_address": false,
"stat_prefix": "0.0.0.0_9080",
"rds": {
"route_config_name": "9080",
"config_source": {
"ads": {}
}
},
"stream_idle_timeout": "0.000s",
"generate_request_id": true,
"upgrade_configs": [
{
"upgrade_type": "websocket"
}
]
}
}
]
}
],
"deprecated_v1": {
"bind_to_port": false
}
},
"last_updated": "2018-09-06T09:34:26.172Z"
},
Routes
配置Envoy的路由规则。Istio下发的缺省路由规则中对每个端口设置了一个路由规则,根据host来对请求进行路由分发。
下面是9080的路由配置,从文件中可以看到对应了3个virtual host,分别是details、ratings和reviews,这三个virtual host分别对应到不同的outbound cluster。
{
"version_info": "2018-09-14T01:38:20Z",
"route_config": {
"name": "9080",
"virtual_hosts": [
{
"name": "details.default.svc.cluster.local:9080",
"domains": [
"details.default.svc.cluster.local",
"details.default.svc.cluster.local:9080",
"details",
"details:9080",
"details.default.svc.cluster",
"details.default.svc.cluster:9080",
"details.default.svc",
"details.default.svc:9080",
"details.default",
"details.default:9080",
"10.101.163.201",
"10.101.163.201:9080"
],
"routes": [
{
"match": {
"prefix": "/"
},
"route": {
"cluster": "outbound|9080||details.default.svc.cluster.local",
"timeout": "0s",
"max_grpc_timeout": "0s"
},
"decorator": {
"operation": "details.default.svc.cluster.local:9080/*"
},
"per_filter_config": {
"mixer": {
......
}
}
}
]
},
{
"name": "ratings.default.svc.cluster.local:9080",
"domains": [
"ratings.default.svc.cluster.local",
"ratings.default.svc.cluster.local:9080",
"ratings",
"ratings:9080",
"ratings.default.svc.cluster",
"ratings.default.svc.cluster:9080",
"ratings.default.svc",
"ratings.default.svc:9080",
"ratings.default",
"ratings.default:9080",
"10.99.16.205",
"10.99.16.205:9080"
],
"routes": [
{
"match": {
"prefix": "/"
},
"route": {
"cluster": "outbound|9080||ratings.default.svc.cluster.local",
"timeout": "0s",
"max_grpc_timeout": "0s"
},
"decorator": {
"operation": "ratings.default.svc.cluster.local:9080/*"
},
"per_filter_config": {
"mixer": {
......
},
"disable_check_calls": true
}
}
}
]
},
{
"name": "reviews.default.svc.cluster.local:9080",
"domains": [
"reviews.default.svc.cluster.local",
"reviews.default.svc.cluster.local:9080",
"reviews",
"reviews:9080",
"reviews.default.svc.cluster",
"reviews.default.svc.cluster:9080",
"reviews.default.svc",
"reviews.default.svc:9080",
"reviews.default",
"reviews.default:9080",
"10.108.25.157",
"10.108.25.157:9080"
],
"routes": [
{
"match": {
"prefix": "/"
},
"route": {
"cluster": "outbound|9080||reviews.default.svc.cluster.local",
"timeout": "0s",
"max_grpc_timeout": "0s"
},
"decorator": {
"operation": "reviews.default.svc.cluster.local:9080/*"
},
"per_filter_config": {
"mixer": {
......
},
"disable_check_calls": true
}
}
}
]
}
],
"validate_clusters": false
},
"last_updated": "2018-09-27T07:17:50.242Z"
}
Bookinfo端到端调用分析
通过前面章节对Envoy配置文件的分析,我们了解到Istio控制面如何将服务和路由信息通过xDS接口下发到数据面中;并介绍了Envoy上生成的各种配置数据的结构,包括listener,cluster,route和endpoint。
下面我们来分析一个端到端的调用请求,通过调用请求的流程把这些配置串连起来,以从全局上理解Istio控制面的流量控制是如何在数据面的Envoy上实现的。
下图描述了一个Productpage服务调用Details服务的请求流程:
- Productpage发起对Details的调用:
http://details:9080/details/0
。 - 请求被Pod的iptable规则拦截,转发到15001端口。
- Envoy的Virtual Listener在15001端口上监听,收到了该请求。
请求被Virtual Listener根据原目标IP(通配)和端口(9080)转发到0.0.0.0_9080这个listener。
{
"version_info": "2018-09-06T09:34:19Z",
"listener": {
"name": "virtual",
"address": {
"socket_address": {
"address": "0.0.0.0",
"port_value": 15001
}
}
...... "use_original_dst": true //请求转发给和原始目的IP:Port匹配的listener
},
根据0.0.0.0_9080 listener的http_connection_manager filter配置,该请求采用“9080” route进行分发。
{
"version_info": "2018-09-06T09:34:19Z",
"listener": {
"name": "0.0.0.0_9080",
"address": {
"socket_address": {
"address": "0.0.0.0",
"port_value": 9080
}
},
"filter_chains": [
{
"filters": [
{
"name": "envoy.http_connection_manager",
"config": {
...... "rds": {
"route_config_name": "9080",
"config_source": {
"ads": {}
}
}, }
]
}
],
"deprecated_v1": {
"bind_to_port": false
}
},
"last_updated": "2018-09-06T09:34:26.172Z"
}, {
},
“9080”这个route的配置中,host name为details:9080的请求对应的cluster为outbound|9080||details.default.svc.cluster.local
{
"version_info": "2018-09-14T01:38:20Z",
"route_config": {
"name": "9080",
"virtual_hosts": [
{
"name": "details.default.svc.cluster.local:9080",
"domains": [
"details.default.svc.cluster.local",
"details.default.svc.cluster.local:9080",
"details",
"details:9080",
"details.default.svc.cluster",
"details.default.svc.cluster:9080",
"details.default.svc",
"details.default.svc:9080",
"details.default",
"details.default:9080",
"10.101.163.201",
"10.101.163.201:9080"
],
"routes": [
{
"match": {
"prefix": "/"
},
"route": {
"cluster": "outbound|9080||details.default.svc.cluster.local",
"timeout": "0s",
"max_grpc_timeout": "0s"
},
...... }
}
}
]
},
...... {
},
outbound|9080||details.default.svc.cluster.local cluster为动态资源,通过eds查询得到其endpoint为192.168.206.21:9080。
{
"clusterName": "outbound|9080||details.default.svc.cluster.local",
"endpoints": [
{
"locality": { },
"lbEndpoints": [
{
"endpoint": {
"address": {
"socketAddress": {
"address": "192.168.206.21",
"portValue": 9080
}
}
},
......
}
]
}
]
}
请求被转发到192.168.206.21,即Details服务所在的Pod,被iptable规则拦截,转发到15001端口。
Envoy的Virtual Listener在15001端口上监听,收到了该请求。
请求被Virtual Listener根据请求原目标地址IP(192.168.206.21)和端口(9080)转发到192.168.206.21_9080这个listener。
根据92.168.206.21_9080 listener的http_connection_manager filter配置,该请求对应的cluster为 inbound|9080||details.default.svc.cluster.local 。
{
"version_info": "2018-09-06T09:34:16Z",
"listener": {
"name": "192.168.206.21_9080",
"address": {
"socket_address": {
"address": "192.168.206.21",
"port_value": 9080
}
},
"filter_chains": [
{
"filters": [
{
"name": "envoy.http_connection_manager",
...... "route_config": {
"name": "inbound|9080||details.default.svc.cluster.local",
"validate_clusters": false,
"virtual_hosts": [
{
"name": "inbound|http|9080",
"routes": [
...... "route": {
"max_grpc_timeout": "0.000s",
"cluster": "inbound|9080||details.default.svc.cluster.local",
"timeout": "0.000s"
},
...... "match": {
"prefix": "/"
}
}
],
"domains": [
"*"
]
}
]
},
...... ]
}
}
]
}
],
"deprecated_v1": {
"bind_to_port": false
}
},
"last_updated": "2018-09-06T09:34:22.184Z"
}
inbound|9080||details.default.svc.cluster.local cluster配置的host为127.0.0.1:9080。
请求被转发到127.0.0.1:9080,即Details服务进行处理。
上述调用流程涉及的完整Envoy配置文件参见:
- Proudctpage:https://gist.github.com/zhaohuabing/034ef87786d290a4e89cd6f5ad6fcc97
- Details:https://gist.github.com/zhaohuabing/544d4d45447b65d10150e528a190f8ee
小结
本文介绍了Istio流量管理相关组件,Istio控制面和数据面之间的标准接口,以及Istio下发到Envoy的完整配置数据的结构和内容。然后通过Bookinfo示例程序的一个端到端调用分析了Envoy是如何实现服务网格中服务发现和路由转发的,希望能帮助大家透过概念更进一步深入理解Istio流量管理的实现机制。
参考资料
- Istio Traffic Managment Concept
- Data Plane API
- kubernetes Custom Resource
- Istio Pilot Design Overview
- Envoy V2 API Overview
- Data Plane API Protocol Buffer Definition
- xDS REST and gRPC protocolhttps://github.com/istio/istio/tree/master/pilot/pkg/proxy/envoy/v2
- Pilot Debug interface
- Istio Sidecar自动注入原理
Istio流量管理实现机制深度解析的更多相关文章
- Android图片编码机制深度解析(Bitmap,Skia,libJpeg)
问题 工作中遇到了Android中有关图片压缩保存的问题,发现这个问题还挺深,而且网上资料比较有限,因此自己深入研究了一下,算是把这个问题自顶至下全部搞懂了,在此记录. 相关的几个问题如下: 1.An ...
- 源码深度解析SpringMvc请求运行机制(转)
源码深度解析SpringMvc请求运行机制 本文依赖的是springmvc4.0.5.RELEASE,通过源码深度解析了解springMvc的请求运行机制.通过源码我们可以知道从客户端发送一个URL请 ...
- Flink 源码解析 —— 深度解析 Flink 序列化机制
Flink 序列化机制 https://t.zsxq.com/JaQfeMf 博客 1.Flink 从0到1学习 -- Apache Flink 介绍 2.Flink 从0到1学习 -- Mac 上搭 ...
- (转载)(收藏)OceanBase深度解析
一.OceanBase不需要高可靠服务器和高端存储 OceanBase是关系型数据库,包含内核+OceanBase云平台(OCP).与传统关系型数据库相比,最大的不同点, 是OceanBase是分布式 ...
- Kafka深度解析
本文转发自Jason’s Blog,原文链接 http://www.jasongj.com/2015/01/02/Kafka深度解析 背景介绍 Kafka简介 Kafka是一种分布式的,基于发布/订阅 ...
- java内存分配和String类型的深度解析
[尊重原创文章出自:http://my.oschina.net/xiaohui249/blog/170013] 摘要 从整体上介绍java内存的概念.构成以及分配机制,在此基础上深度解析java中的S ...
- String深度解析
文章出处:http://my.oschina.net/xiaohui249/blog/170013 一.引题 String类型是比较特殊的一种类型,同时也是面试经常被问到的一个知识点,本文结合java ...
- mybatis 3.x源码深度解析与最佳实践(最完整原创)
mybatis 3.x源码深度解析与最佳实践 1 环境准备 1.1 mybatis介绍以及框架源码的学习目标 1.2 本系列源码解析的方式 1.3 环境搭建 1.4 从Hello World开始 2 ...
- 并发编程(十五)——定时器 ScheduledThreadPoolExecutor 实现原理与源码深度解析
在上一篇线程池的文章<并发编程(十一)—— Java 线程池 实现原理与源码深度解析(一)>中从ThreadPoolExecutor源码分析了其运行机制.限于篇幅,留下了Scheduled ...
随机推荐
- 【Python】http.client库的用法
代码: # http.client测试,该库较底层,不常用 import http.client conn=None try: conn=http.client.HTTPSConnection(&qu ...
- XCODE修改IOS应用的名称
原地址:http://zengwu3915.blog.163.com/blog/static/2783489720136210252843/ 首先在左侧选择你的目标组中的项目文件.在中间选择“生成”选 ...
- Reimplementing event handler
Events in PyQt4 are processed often by reimplementing event handlers. #!/usr/bin/python # -*- coding ...
- canvas的api
Canvas API(画布)用于在网页实时生成图像,并且可以操作图像内容,基本上它是一个可以用JavaScript操作的位图(bitmap).使用前,首先需要新建一个canvas网页元素. 1 2 3 ...
- vue 父子组件传值的另外一种方式 provide inject
1.文档说明 https://cn.vuejs.org/v2/api/#provide-inject 2.实例 element ui的dropdown组件 dropdown.vue: provide( ...
- jquery获取含有某元素的的控件 “控件名[属性名=值]”
jquery获取含有某元素的的控件 “控件名[属性名=值]”. 如,获取 <input id="${cheackbox}" data-role="icheck&qu ...
- Python 常见错误及解决办法
错误: Traceback (most recent call last): File "I:/Papers/consumer/codeandpaper/RegressionandGBDTa ...
- 常用/常见Java Web 服务器/应用服务器Logo图文介绍
常用/常见应用服务器介绍:自己总结.整理一些常见的应用服务器,资料主要来源于网上. 常用Java Web 服务器Java Web应用程序需要部署在Java web服务器中运行,常用的Java ...
- Android 5.0最应该实现的8个期望
毫无疑问,Android 5 将是令人兴奋的操作系统,因为 Android4.0 至 4.4 版本之间并没有显著的差异,显然谷歌会在 5.0 版本中进行一些较大幅度的革新.那么,代号为“柠檬芝士蛋糕” ...
- express@4.0.*
$ sudo npm install -g express the installation goes fine. But when I try to create a project with ex ...