内容简介:参考:从Kubernetes 1.11开始,可使用CoreDNS作为Kubernetes的DNS插件进入GA状态,Kubernetes推荐使用CoreDNS作为集群内的DNS服务。 我们先看一下Kubernetes DNS服务的发展历程。Kubernetes 1.3之前的版本使用skyDNS作为DNS服务,这个有点久远了。Kubernetes的DNS服务由kube2sky、skyDNS、etcd组成。 kube2sky通过kube-apiserver监听集群中Service的变化,将生成的DNS记录信息更新
参考:
- 官方网站, https://coredns.io/
- CoreDNS安装, https://my.oschina.net/u/2306127/blog/1618543
- CoreDNS使用手册, https://coredns.io/manual/toc/
- CoreDNS源码, https://github.com/coredns
- CoreDNS配置, https://my.oschina.net/u/2306127/blog/1788566
一.Kubernetes DNS服务发展史
从Kubernetes 1.11开始,可使用CoreDNS作为Kubernetes的DNS插件进入GA状态,Kubernetes推荐使用CoreDNS作为集群内的DNS服务。 我们先看一下Kubernetes DNS服务的发展历程。
1.1 Kubernetes 1.3之前的版本 – skyDNS
Kubernetes 1.3之前的版本使用skyDNS作为DNS服务,这个有点久远了。Kubernetes的DNS服务由kube2sky、skyDNS、etcd组成。 kube2sky通过kube-apiserver监听集群中Service的变化,将生成的DNS记录信息更新到etcd中,而skyDNS将从etcd中获取数据对外提供DNS的查询服务。
1.2 Kubernetes 1.3版本开始 – kubeDNS
Kubernetes 1.3开始使用kubeDNS和dnsmasq替换了原来的kube2sky和skyDNS,不再使用etcd,而是将DNS记录直接存放在内存中,通过dnsmasq的缓存功能提高DNS的查询效率。下图是描述了Kubernetes使用kubeDNS实现服务发现的整体架构:
1.3 Kubernetes 1.11版本开始 – CoreDNS进入GA
从Kubernetes 1.11开始,可使用CoreDNS作为Kubernetes的DNS插件进入GA状态,Kubernetes推荐使用CoreDNS作为集群内的DNS服务。 CoreDNS从2017年初就成为了CNCF的的孵化项目,CoreDNS的特点就是十分灵活和可扩展的插件机制,
各种插件实现:
不同的功能,如重定向、定制DNS记录、记录日志等等。下图描述了CoreDNS的整体架构:
二、CoreDNS简介
Kubernetes包括用于服务发现的DNS服务器Kube-DNS。 该DNS服务器利用SkyDNS的库来为Kubernetes pod和服务提供DNS请求。SkyDNS2的作者,Miek Gieben,创建了一个新的DNS服务器,CoreDNS,它采用更模块化,可扩展的框架构建。 Infoblox已经与Miek合作,将此DNS服务器作为Kube-DNS的替代品。
CoreDNS利用作为Web服务器Caddy的一部分而开发的服务器框架。该框架具有非常灵活,可扩展的模型,用于通过各种中间件组件传递请求。这些中间件组件根据请求提供不同的操作,例如记录,重定向,修改或维护 。虽然它一开始作为Web服务器,但是Caddy并不是专门针对HTTP协议的,而是构建了一个基于CoreDNS的理想框架。
在这种灵活的模型中添加对Kubernetes的支持,相当于创建了一个Kubernetes中间件。该中间件使用Kubernetes API来满足针对特定Kubernetes pod或服务的DNS请求。而且由于Kube-DNS作为Kubernetes的另一项服务,kubelet和Kube-DNS之间没有紧密的绑定。您只需要将DNS服务的IP地址和域名传递给kubelet,而Kubernetes并不关心谁在实际处理该IP请求。
1、CoreDNS支持行为
1.0.0版本主要遵循Kube-DNS的当前行为。 CoreDNS的005及更高版本实现了完整的规范和更多功能。
- A记录(正常的Service分配了一个名为my-svc.my-namespace.svc.cluster.local的DNS A记录。 这解决了服务的集群IP)
- “headless”(没有集群IP)的Service也分配了一个名为my-svc.my-namespace.svc.cluster.local的DNS A记录。 与普通服务不同,这解决了Service选择了pods的一组IP。 客户预计将从这ip集合中消耗集合或使用标准循环选择。
- 针对名为正常或无头服务的端口创建的SRV记录,对于每个命名的端口,SRV记录的格式为_my-port-name._my-port-protocol.my-svc.my-namespace.svc.cluster.local。对于常规服务,这将解析为端口号和CNAME:my-svc.my-namespace.svc.cluster.local;对于无头服务,这解决了多个答案,一个用于支持服务的每个pod,并包含端口号还有格式为auto-generated-name.my-svc.my-namespace.svc.cluster.local 的pod的CNAME 。SRV记录包含它们中的“svc”段,对于省略“svc”段的旧式CNAME不支持。
- 作为Service一部分的endpoints的A记录(比如“pets”的记录)
- pod的Spec中描述的A记录
- 还有就是用来发现正在使用的DNS模式版本的TXT记录
所有群集中不需要pod A记录支持,默认情况下禁用。 此外,CoreDNS对此用例的支持超出了在Kube-DNS中找到的标准行为。
在Kube-DNS中,这些记录不反映集群的状态,例如,对w-x-y-z.namespace.pod.cluster.local的任何查询将返回带有w.x.y.z(ip)的A记录,即使该IP不属于指定的命名空间,甚至不属于集群地址空间。最初的想法是启用对* .namespace.pod.cluster.local这样的域使用通配符SSL证书。
CoreDNS集成了提供pod验证的选项,验证返回的IP地址w.x.y.z实际上是指定命名空间中的pod的IP。他防止在命名空间中欺骗DNS名称。 然而,它确实会大大增加CoreDNS实例的内存占用,因为现在它需要观察所有的pod,而不仅仅是服务端点。
2、架构
整个 CoreDNS 服务都建立在一个使用 Go 编写的 HTTP/2 Web 服务器 Caddy · GitHub 上,CoreDNS 整个项目可以作为一个 Caddy 的教科书用法。
CoreDNS 的大多数功能都是由插件来实现的,插件和服务本身都使用了 Caddy 提供的一些功能,所以项目本身也不是特别的复杂。
3、插件
作为基于 Caddy 的 Web 服务器,CoreDNS 实现了一个插件链的架构,将很多 DNS 相关的逻辑都抽象成了一层一层的插件,包括 Kubernetes 等功能,每一个插件都是一个遵循如下协议的结构体:
type ( Plugin func(Handler) Handler Handler interface { ServeDNS(context.Context, dns.ResponseWriter, *dns.Msg) (int, error) Name() string } )
所以只需要为插件实现 ServeDNS
以及 Name
这两个接口并且写一些用于配置的代码就可以将插件集成到 CoreDNS 中。
4、Corefile
另一个 CoreDNS 的特点就是它能够通过简单易懂的 DSL 定义 DNS 服务,在 Corefile 中就可以组合多个插件对外提供服务:
coredns.io:5300 { file db.coredns.io } example.io:53 { log errors file db.example.io } example.net:53 { file db.example.net } .:53 { kubernetes proxy . 8.8.8.8 log errors cache }
对于以上的配置文件,CoreDNS 会根据每一个代码块前面的区和端点对外暴露两个端点提供服务:
该配置文件对外暴露了两个 DNS 服务,其中一个监听在 5300 端口,另一个在 53 端口,请求这两个服务时会根据不同的域名选择不同区中的插件进行处理。
原理
CoreDNS 可以通过四种方式对外直接提供 DNS 服务,分别是 UDP、gRPC、HTTPS 和 TLS:
但是无论哪种类型的 DNS 服务,最终队会调用以下的 ServeDNS
方法,为服务的调用者提供 DNS 服务:
func (s *Server) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) { m, _ := edns.Version(r) ctx, _ := incrementDepthAndCheck(ctx) b := r.Question[0].Name var off int var end bool var dshandler *Config w = request.NewScrubWriter(r, w) for { if h, ok := s.zones[string(b[:l])]; ok { ctx = context.WithValue(ctx, plugin.ServerCtx{}, s.Addr) if r.Question[0].Qtype != dns.TypeDS { rcode, _ := h.pluginChain.ServeDNS(ctx, w, r) dshandler = h } off, end = dns.NextLabel(q, off) if end { break } } if r.Question[0].Qtype == dns.TypeDS && dshandler != nil && dshandler.pluginChain != nil { rcode, _ := dshandler.pluginChain.ServeDNS(ctx, w, r) plugin.ClientWrite(rcode) return } if h, ok := s.zones["."]; ok && h.pluginChain != nil { ctx = context.WithValue(ctx, plugin.ServerCtx{}, s.Addr) rcode, _ := h.pluginChain.ServeDNS(ctx, w, r) plugin.ClientWrite(rcode) return } }
在上述这个已经被简化的复杂函数中,最重要的就是调用了『插件链』的 ServeDNS
方法,将来源的请求交给一系列插件进行处理,如果我们使用以下的文件作为 Corefile:
example.org { file /usr/local/etc/coredns/example.org prometheus # enable metrics errors # show errors log # enable query logs }
那么在 CoreDNS 服务启动时,对于当前的 example.org
这个组,它会依次加载 file
、 log
、 errors
和 prometheus
几个插件,这里的顺序是由 zdirectives.go 文件定义的,启动的顺序是从下到上:
var Directives = []string{ // ... "prometheus", "errors", "log", // ... "file", // ... "whoami", "on", }
因为启动的时候会按照从下到上的顺序依次『包装』每一个插件,所以在真正调用时就是从上到下执行的,这就是因为 NewServer
方法中对插件进行了组合:
func NewServer(addr string, group []*Config) (*Server, error) { s := &Server{ Addr: addr, zones: make(map[string]*Config), connTimeout: 5 * time.Second, } for _, site := range group { s.zones[site.Zone] = site if site.registry != nil { for name := range enableChaos { if _, ok := site.registry[name]; ok { s.classChaos = true break } } } var stack plugin.Handler for i := len(site.Plugin) - 1; i >= 0; i-- { stack = site.Plugin[i](stack) site.registerHandler(stack) } site.pluginChain = stack } return s, nil }
对于 Corefile 里面的每一个配置组, NewServer
都会讲配置组中提及的插件按照一定的顺序组合起来,原理跟 Rack Middleware 的机制非常相似,插件 Plugin
其实就是一个出入参数都是 Handler
的函数:
type ( Plugin func(Handler) Handler Handler interface { ServeDNS(context.Context, dns.ResponseWriter, *dns.Msg) (int, error) Name() string } )
所以我们可以将它们叠成堆栈的方式对它们进行操作,这样在最后就会形成一个插件的调用链,在每个插件执行方法时都可以通过 NextOrFailure
函数调用下一个插件的 ServerDNS
方法:
func NextOrFailure(name string, next Handler, ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { if next != nil { if span := ot.SpanFromContext(ctx); span != nil { child := span.Tracer().StartSpan(next.Name(), ot.ChildOf(span.Context())) defer child.Finish() ctx = ot.ContextWithSpan(ctx, child) } return next.ServeDNS(ctx, w, r) } return dns.RcodeServerFailure, Error(name, errors.New("no next plugin found")) }
除了通过 ServeDNS
调用下一个插件之外,我们也可以调用 WriteMsg
方法并结束整个调用链。
从插件的堆叠到顺序调用以及错误处理,我们对 CoreDNS 的工作原理已经非常清楚了,接下来我们可以简单介绍几个插件的作用。
三、在kubernetes中部署coredns
1、下载coredns部署包并说明
https://github.com/coredns/deployment/tree/master/kubernetes
主要有几个文件:
deploy.sh是一个便捷的脚本,用于生成用于在当前运行标准kube-dns的集群上运行CoreDNS的清单。使用coredns.yaml.sed文件作为模板,它创建一个ConfigMap和一个CoreDNS deployment,然后更新 Kube-DNS service selector以使用CoreDNS deployment。 通过重新使用现有服务,服务请求不会中断。
脚本不会删除kube-dns的deployment或replication controller - 您必须手动执行:
kubectl delete --namespace=kube-system deployment kube-dns
要使用它,只需将它们放在同一目录中,然后运行deploy.sh脚本 ,将其传递给您的服务CIDR(10.3.0.0/24) 。 这将生成具有必要Corefile的ConfigMap。 它还将查找现有的kube-dns服务的集群IP。
[root@k8s-master conf.d]# etcdctl ls /k8s/network/subnets /k8s/network/subnets/10.0.24.0-24 /k8s/network/subnets/10.0.86.0-24 /k8s/network/subnets/10.0.35.0-24
(注意:以上原始脚本只适用于当前kubernetes集群含有kube-dns的情况,如果没有需要修改下脚本
#!/bin/bash
10.0.24.200
# Deploys CoreDNS to a cluster currently running Kube-DNS.
SERVICE_CIDR=$1
CLUSTER_DOMAIN=${2:-cluster.local}
YAML_TEMPLATE=${3:-`pwd`/coredns.yaml.sed}
YAML=${4:-`pwd`/coredns.yaml}
if [[ -z $SERVICE_CIDR ]]; then
echo "Usage: $0 SERVICE-CIDR [ CLUSTER-DOMAIN ] [ YAML-TEMPLATE ] [ YAML ]"
exit 1
fi
#CLUSTER_DNS_IP=$(kubectl get service --namespace kube-system kube-dns -o jsonpath="{.spec.clusterIP}")
CLUSTER_DNS_IP=
默认情况下CLUSTER_DNS_IP是自动获取kube-dns的集群ip的,但是由于没有部署kube-dns所以只能手动指定一个集群ip了。
执行: ./deploy.sh 10.0.0.0/24 cluster.local
以上脚本执行后可以看到预览的效果。
仔细观察上面的Corefile部分,这是一个在端口53上运行CoreDNS并为Kubernetes提供cluster.local域的示例
.:53 { errors log stdout health kubernetes cluster.local 10.3.0.0/24 proxy . /etc/resolv.conf cache 30 }
1)errors官方没有明确解释,后面研究
2)log stdout:日志中间件配置为将日志写入STDOUT
3)health:健康检查,提供了指定端口(默认为8080)上的HTTP端点,如果实例是健康的,则返回“OK”。
4)cluster.local:CoreDNS为kubernetes提供的域,10.3.0.0/24这告诉Kubernetes中间件它负责为反向区域提供PTR请求0.0.3.10.in-addr.arpa ..换句话说,这是允许反向DNS解析服务(我们经常使用到得DNS服务器里面有两个区域,即“正向查找区域”和“反向查找区域”,正向查找区域就是我们通常所说的域名解析,反向查找区域即是这里所说的IP反向解析,它的作用就是通过查询IP地址的PTR记录来得到该IP地址指向的域名,当然,要成功得到域名就必需要有该IP地址的PTR记录。PTR记录是邮件交换记录的一种,邮件交换记录中有A记录和PTR记录,A记录解析名字到地址,而PTR记录解析地址到名字。地址是指一个客户端的IP地址,名字是指一个客户的完全合格域名。通过对PTR记录的查询,达到反查的目的。)
5)proxy:这可以配置多个upstream 域名服务器,也可以用于延迟查找 /etc/resolv.conf 中定义的域名服务器
6)cache:这允许缓存两个响应结果,一个是肯定结果(即,查询返回一个结果)和否定结果(查询返回“没有这样的域”),具有单独的高速缓存大小和TTLs。
2、现在安装coredns到kubernetes中
# ./deploy.sh -r 10.0.0.0/16 -i 192.168.10.90 -d cluster.local -t coredns.yaml.sed -s > coredns.yaml
# .kubectl apply -f coredns.yaml
或者直接执行:
# ./deploy.sh -r 10.0.0.0/16 -i 192.168.10.90 -d cluster.local -t coredns.yaml.sed -s | kubectl apply -f -
查看service
[root@k8s-master coredns]# kubectl get service -l k8s-app=kube-dns --namespace=kube-system NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE kube-dns ClusterIP 192.168.10.90 <none> 53/UDP,53/TCP,9153/TCP 15d
查看coredns的Pod,确认所有Pod都处于Running状态:
kubectl get pods -n kube-system -l k8s-app=kube-dns
查看日志:# kubectl logs -f coredns-6cc7bf59f4-4rfq8 --namespace=kube-system
3、修改cluster-dns
修改master节点和所有node节点--cluster-dns配置,修改内容如红色所注,与上面的Corefile中的值对应。
4、测试CoreDNS
现在我们来创建一个wepapp的pod和service,测试一下coredns是否起作用
[root@k8s-master conf.d]# kubectl get pods -o wide
NAME READY STATUS RESTARTS AGE IP NODE
webapp-nrz4t 1/1 Running 0 1d 10.0.35.3 192.168.10.39
webapp-zp69q 1/1 Running 0 1d 10.0.24.4 192.168.10.50
[root@k8s-master ~]# kubectl get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 192.168.0.1 <none> 443/TCP 28d
webapp ClusterIP 192.168.14.242 <none> 9081/TCP 17d
webapp2 ClusterIP 192.168.22.2 <none> 9082/TCP 17d
[root@k8s-master ~]# curl 192.168.22.2:9082
Hello world
1、检查集群的pod /etc/resolv.conf是否生效:
首先进入这个集群内的另一个pod
kubectl exec -it webapp-nrz4t /bin/sh 或者 docker exec -it 0d0874df9e15 /bin/sh
$ cat /etc/resolv.conf
sh-4.2# cat /etc/resolv.conf
nameserver 192.168.10.90
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
2、curl测试服务:
curl webapp.default.svc.cluster.local:9081
3、在master的主机上修改 /etc/resolv.conf,增加一行:nameserver 192.168.10.90后执行
curl webapp.default.svc.cluster.local:9081
通过域名访问成功!
问题排查技巧
如果执行 nslookup 命令失败,检查如下内容:
1、先检查本地 DNS 配置
查看配置文件 resolv.conf。
按照如下方法(注意搜索路径可能会因为云提供商不同而变化)验证搜索路径和 Name Server 的建立:
nameserver 192.168.10.90
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
2、快速诊断
出现类似如下指示的错误,说明 kube-dns 插件或相关 Service 存在问题:
检查service是否正常运行:
kubectl get svc --namespace=kube-system
或者检查是否 DNS Pod 正在运行
使用 kubectl get pods
命令验证 DNS Pod 正在运行:
kubectl get pods --namespace=kube-system -l k8s-app=kube-dns
应该能够看到类似如下信息:
如果看到没有 Pod 运行,或 Pod 失败/结束,DNS 插件不能默认部署到当前的环境,必须手动部署。
或者查看service的Endpoint是否正常:
kubectl get ep kube-dns --namespace=kube-system
如果没有看到 Endpoint,查看 调试 Service 文档 中的 Endpoint 段内容。
3、检查 DNS Pod 中的错误信息
使用 kubectl logs
命令查看 DNS 后台进程的日志:
kubectl logs coredns-6cc7bf59f4-vj7cc -n kube-system
看到错误信息:
eflector.go:134] pkg/mod/k8s.io/client-go@v10.0.0+incompatible/tools/cache/reflector.go:95: Failed to list *v1.Namespace: Get https://192.168.0.1:443/api/v1/namespace
2.168.0.1:443/api/v1/endpoints?limit=500&resourceVersion=0: x509: certificate is valid for 192.168.10.50, 10.0.0.1, not 192.168.0.1
E0710 16:00:58.986103 1 reflector.go:134] pkg/mod/k8s.io/client-go@v10.0.0+incompatible/tools/cache/reflector.go:95: Failed to list *v1.Namespace: Get https://192.168.0.1:443/api/v1/namespaces?limit=500&resourceVersion=0: x509: certificate is valid for 192.168.10.50, 10.0.0.1, not 192.168.0.1
E0710 16:00:58.989633 1 reflector.go:134] pkg/mod/k8s.io/client-go@v10.0.0+incompatible/tools/cache/reflector.go:95: Failed to list *v1.Service: Get https://192.168.0.1:443/api/v1/services?limit=500&resourceVersion=0: x509: certificate is valid for 192.168.10.50, 10.0.0.1, not 192.168.0.1
E0710 16:00:58.991655 1 reflector.go:134] pkg/mod/k8s.io/client-go@v10.0.0+incompatible/tools/cache/reflector.go:95: Failed to list *v1.Endpoints: Get https://192.168.0.1:443/api/v1/endpoints?limit=500&resourceVersion=0: x509: certificate is valid for 192.168.10.50, 10.0.0.1, not 192.168.0.1
E0710 16:00:59.990732 1 reflector.go:134] pkg/mod/k8s.io/client-go@v10.0.0+inco
原因:192.168.0.1不在证书里面
解决:需要重新设置:apiserver证书
masterssl.cnf文件的示例如下:IP.3 = 192.168.0.1
[req] req_extensions = v3_req distinguished_name = req_distinguished_name [req_distinguished_name] [ v3_req ] basicConstraints = CA:FALSE keyUsage = nonRepudiation, digitalSignature, keyEncipherment subjectAltName = @alt_names [alt_names] DNS.1 = kubernetes DNS.2 = kubernetes.default DNS.3 = kubernetes.default.svc DNS.4 = kubernetes.default.svc.cluster.local IP.1 = ${K8S_SERVICE_IP} IP.2 = ${MASTER_IPV4} IP.3 = 192.168.0.1
错误信息:
E0712 02:25:45.326123 1 reflector.go:134] pkg/mod/k8s.io/client-go@v10.0.0+incompatible/tools/cache/reflector.go:95: Failed to list *v1.Service: Unauthorized
E0712 02:25:45.327038 1 reflector.go:134] pkg/mod/k8s.io/client-go@v10.0.0+incompatible/tools/cache/reflector.go:95: Failed to list *v1.Endpoints: Unauthorized
E0712 02:25:45.328029 1 reflector.go:134] pkg/mod/k8s.io/client-go@v10.0.0+incompatible/tools/cache/reflector.go:95: Failed to list *v1.Namespace: Unauthorized
E0712 02:25:46.327153 1 reflector.go:134] pkg/mod/k8s.io/client-go@v10.0.0+incompatible/tools/cache/reflector.go:95: Failed to list *v1.Service: Unauthorized
E0712 02:25:46.328210 1 reflector.go:134] pkg/mod/k8s.io/client-go@v10.0.0+incompatible/tools/cache/reflector.go:95: Failed to list *v1.Endpoints: Unauthorized
E0712 02:25:46.329160 1 reflector.go:134] pkg/mod/k8s.io/client-go@v10.0.0+incompatible/tools/cache/reflector.go:95: Failed to list *v1.Namespace: Unauthorized
E0712 02:25:47.328243 1 reflector.go:134] pkg/mod/k8s.io/client-go@v10.0.0+incompatible/tools/cache/reflector.go:95: Failed to list *v1.Service: Unauthorized
E0712 02:25:47.329143 1 reflector.go:134] pkg/mod/k8s.io/client-go@v10.0.0+incompatible/tools/cache/reflector.go:95: Failed to list *v1.Endpoints: Unauthorized
E0712 02:25:47.330086 1 reflector.go:134] pkg/mod/k8s.io/client-go@v10.0.0+incompatible/tools/cache/reflector.go:95: Failed to list *v1.Namespace: Unauthorized
原因:service account 的token认知不通过,是因为重新生成证书后,需要删除旧的token。
解决:
kubectl get secret -n kube-system
NAME TYPE DATA AGE
coredns-token-cdn9x kubernetes.io/service-account-token 3 3d
default-token-lht2v kubernetes.io/service-account-token 3 3d
kubectl delete secret coredns-token-cdn9x -n kube-system
secret "coredns-token-cdn9x" deleted
以上所述就是小编给大家介绍的《k8s实践(11) --服务发现CoreDNS详解》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- 非面试向跨域实践详解
- 从一个实例详解敏捷测试的最佳实践
- Nginx 从入门到实践,万字详解
- 爱奇艺 iOS 深度实践:SiriKit 详解应用篇
- 五大实例详解,携程 Redis 跨机房双向同步实践
- 五大实例详解,携程 Redis 跨机房双向同步实践
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
R for Data Science
Hadley Wickham、Garrett Grolemund / O'Reilly Media / 2016-12-25 / USD 39.99
http://r4ds.had.co.nz/一起来看看 《R for Data Science》 这本书的介绍吧!