• Precondition
  • 什么是 Kube Proxy
  • Kube Proxy 原理
    • 部署环境
    • 搭建一个GuestBook 例子
    • 分析iptables
      • 1. 创建iptables实现外网通过nodePort访问
      • 2. 分析k8s的 iptables
        • 2.1 集群内部通过cluster ip 访问到Pod
        • 2.1.1 iptables分析
        • 2.1.2 抓包分析
          • 2.1.1.1 在haofan-test-1上 直接访问 cluster ip: port
          • 2.1.1.2 在redis pod 内上 直接访问 cluster ip: port
        • 2.2 集群外部通过node ip 访问到Pod
          • 2.2.1 iptables分析
          • 2.2.2 抓包分析
        • 2.4 总结
    • Refer

Precondition

要理解这篇文章之前,需要先对下面这些技术有些基本的理解。

  • Iptables: http://www.zsythink.net/archives/1199
  • K8S 相关
  • Linux 路由相关
  • SNAT: 是路由之后进行的; DNAT: 是路由之前进行的

什么是 Kube Proxy

kube-proxy是k8s的一个核心组件。每台机器上都运行一个kube-proxy服务,它监听API server中service和endpoint的变化情况,并通过iptables等来为后端服务配置负载均衡。 带来的问题是:如果集群存在上万的Service/Endpoint, 则需要非常多的iptables roules,这会让性能降低。

Kube Proxy 原理

部署环境

3 master + 2 work 节点,只有两个work节点部署kubelet 和 kube-proxy。2个work节点 ip:

haofan-test-1 haofan-test-2
192.168.3.233 192.168.3.232

搭建一个GuestBook 例子

kubectl apply -f guestbook-all-in-one.yaml
guestbook-all-in-one.yaml 如下:

apiVersion: v1
kind: Service
metadata:name: redis-masterlabels:app: redisrole: mastertier: backend
spec:ports:- port: 6379targetPort: 6379selector:app: redisrole: mastertier: backend
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:name: redis-master
spec:replicas: 1template:metadata:labels:app: redisrole: mastertier: backendspec:containers:- name: masterimage: hub.baidubce.com/public/guestbook-redis-master:e2e  # or just image: redisresources:requests:cpu: 100mmemory: 100Miports:- containerPort: 6379
---
apiVersion: v1
kind: Service
metadata:name: redis-slavelabels:app: redisrole: slavetier: backend
spec:ports:- port: 6379selector:app: redisrole: slavetier: backend
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:name: redis-slave
spec:selector:matchLabels:app: redisrole: slavetier: backendreplicas: 2template:metadata:labels:app: redisrole: slavetier: backendspec:containers:- name: slaveimage: hub.baidubce.com/public/guestbook-redis-slave:v1resources:requests:cpu: 100mmemory: 100Mienv:- name: GET_HOSTS_FROMvalue: dns# Using `GET_HOSTS_FROM=dns` requires your cluster to# provide a dns service. As of Kubernetes 1.3, DNS is a built-in# service launched automatically. However, if the cluster you are using# does not have a built-in DNS service, you can instead# instead access an environment variable to find the master# service's host. To do so, comment out the 'value: dns' line above, and# uncomment the line below:# value: envports:- containerPort: 6379
---
apiVersion: v1
kind: Service
metadata:name: frontendlabels:app: guestbooktier: frontend
spec:# comment or delete the following line if you want to use a LoadBalancertype: NodePort# if your cluster supports it, uncomment the following to automatically create# an external load-balanced IP for the frontend service.ports:- port: 80selector:app: guestbooktier: frontend
---
apiVersion: apps/v1beta2
kind: Deployment
metadata:name: frontend
spec:selector:matchLabels:app: guestbooktier: frontendreplicas: 3template:metadata:labels:app: guestbooktier: frontendspec:containers:- name: php-redisimage: hub.baidubce.com/public/guestbook-frontend:v4resources:requests:cpu: 100mmemory: 100Mienv:- name: GET_HOSTS_FROMvalue: dns# Using `GET_HOSTS_FROM=dns` requires your cluster to# provide a dns service. As of Kubernetes 1.3, DNS is a built-in# service launched automatically. However, if the cluster you are using# does not have a built-in DNS service, you can instead# instead access an environment variable to find the master# service's host. To do so, comment out the 'value: dns' line above, and# uncomment the line below:# value: envports:- containerPort: 80

分析iptables

查看新创建的service, frontend的容器端口是80, nodePort端口是30784

[root@haofan-test-2 ~]# kubectl get svc --all-namespaces
default    frontend                NodePort    172.16.92.224    <none>        80:30784/TCP        23h
default    redis-master            ClusterIP   172.16.155.140   <none>        6379/TCP            23h
default    redis-slave             ClusterIP   172.16.67.204    <none>        6379/TCP            23h

查看frontend pod的分布情况

[root@haofan-test-2 ~]# kubectl get pods -o wide -n production
NAME                             READY     STATUS    RESTARTS   AGE       IP            NODE            NOMINATED NODE
frontend-5798c4bfc7-7bt7k        1/1       Running   0          23h       172.18.1.22   192.168.3.233   <none>
frontend-5798c4bfc7-nbmvp        1/1       Running   0          23h       172.18.0.20   192.168.3.232   <none>
frontend-5798c4bfc7-v7889        1/1       Running   0          23h       172.18.1.23   192.168.3.233   <none>

1. 创建iptables实现外网通过nodePort访问

思考:

  1. 如果在集群中通过cluster_ip:port 是如何访问到frontend 172.18.1.22:80
    方法:
    要进行DNAT转换,因为数据包是经过本地协议发出 会经过nat的OUTPUT chain. 目的是访问cluster_ip:port时可以访问到pod ip。
iptables -t nat -I OUTPUT -p tcp -m
comment --comment "this is clusterip demo" -d 172.16.92.224/32 --dport 80 -j DNAT --to-destination 172.18.1.22:80
  1. 如果在集群外部中通过node_ip: port 是如何访问到frontend 172.18.1.22:80
    同样要进行DNAT转换,为了让消息能返回客户端,还需要进行SNAT转换。思考为什么要进行SNAT ?
iptables -t nat -I POSTROUTING -p tcp -d 172.18.1.22 --dport 80 -j MASQUERADE
iptables -t nat -I PREROUTING -p tcp -m comment --comment "this is nodeport demo" --dport 30784 -j DNAT --to-destination 172.18.1.22:80

MASQUERADE和SNAT的功能类似,只是SNAT需要明确指明源IP的的值,MASQUERADE会根据网卡IP自动更改,所以更实用一些。

所以说,如果在开发过程中,想外部访问某个应用(比如redis),但是呢碰巧这个应用的svc又没有开启nodeport,那你就可以模仿我刚才设置的iptable规则,从而达到不修改SVC也能通过外部应用访问。

这里为什么要进行SNAT呢 ?
原因是,为了支持从任一节点IP+NodePort都可以访问应用。

                                 client\ ^\ \v \(eth0:192.168.3.1)node 1 <--- node 2(eth0: 192.168.2.1)| ^   SNAT| |   --->v |endpoint

假设跳过上图的SNAT,只做DNAT。报文的确可以经过 node 2 转发到 node 1上的endpoint,source地址是client ip,但是endpoint如何应答呢?endpoint内的确有默认路由指向 node 1,但是如果没有做SNAT,应答时会直接从 node 1 发送给client。

这是一个三角流量!!

跳过SNAT后,node 1 在转发应答流量的时候,会将应答报文的源地址替换为 node 1的地址,这样的报文,client是不会接受的,连接将无法建立,因为TCP协议:client request报文的源地址 和 server的response报文的目的地址要相同,否则无法建立连接。

如果有了SNAT, node 2 到 node 1的packet 到了node1之后,source地址是node 2的 eth0 地址,这样在response后,就会从node1 到node2再转发出去,而不会直接通过node1转发出去。

所以,必须要做SNAT,必须要FULLNAT。

2. 分析k8s的 iptables

2.1 集群内部通过cluster ip 访问到Pod

2.1.1 iptables分析

  1. 数据包是通过本地协议发出的,然后需要更改NAT表,k8s只能在OUTPUT这个链上来动手

到达OUPUT chain后,要经过kube-services这个k8s自定义的链。

root@haofan-test-2 ~]# iptables -L OUTPUT
Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination
KUBE-SERVICES  all  --  anywhere             anywhere             ctstate NEW /* kubernetes service portals */

然后匹配到下面两条链:

[root@haofan-test-2 ~]# iptables -L KUBE-SERVICES -t nat -n --line-number | grep "80"
KUBE-MARK-MASQ  tcp  -- !172.18.0.0/16         172.18.1.23        /* default/frontend: cluster IP */ tcp dpt:80
KUBE-SVC-GYQQTB6TY565JPRW  tcp  --  0.0.0.0/0             172.18.1.23        /* default/frontend: cluster IP */ tcp dpt:80

第一条chain,是对源地址不是172.18.0.0/16的,目的地址是 172.18.1.23,目的端口是80打标签,标签后的packet进行SNAT,目的就是为了伪装所有访问 Service Cluster IP 的外部流量。

再看 KUBE-SVC-GYQQTB6TY565JPRW, 发现了probability,实现了svc能够随机访问到后端

[root@haofan-test-1 ~]# iptables -S -t nat | grep KUBE-SVC-GYQQTB6TY565JPRW
-A KUBE-SVC-GYQQTB6TY565JPRW -m comment --comment "production/frontend:" -m statistic --mode random --probability 0.33332999982 -j KUBE-SEP-ABPIR2RYBUVZX2WC
-A KUBE-SVC-GYQQTB6TY565JPRW -m comment --comment "production/frontend:" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-G27GUN3FGJW5PMGU
-A KUBE-SVC-GYQQTB6TY565JPRW -m comment --comment "production/frontend:" -j KUBE-SEP-P2WQD6E6PI2AV6SJ

因为有3个pod, 只看其中一个。发现有两条规则,第一条打标签0x4000是为了做SNAT,第二条实现DNAT

 [root@haofan-test-2 ~]# iptables -L KUBE-SEP-P2WQD6E6PI2AV6SJ -t nat -n
Chain KUBE-SEP-P2WQD6E6PI2AV6SJ (1 references)
target     prot opt source               destination
KUBE-MARK-MASQ  all  --  172.18.1.23          0.0.0.0/0            /* default/frontend: */
DNAT       tcp  --  0.0.0.0/0            0.0.0.0/0            /* default/frontend: */ tcp to:172.18.1.23:80
[root@haofan-test-1 ~]# iptables -S -t nat | grep KUBE-MARK-MASQ
-N KUBE-MARK-MASQ
-A KUBE-MARK-MASQ -j MARK --set-xmark 0x4000/0x4000

然后在KUBE-POSTROUTING链时,只对打上了标签的packet进行SNAT

root@haofan-test-1 ~]# iptables -S -t nat | grep 0x4000/0x4000
-A KUBE-MARK-MASQ -j MARK --set-xmark 0x4000/0x4000
-A KUBE-POSTROUTING -m comment --comment "kubernetes service traffic requiring SNAT" -m mark --mark 0x4000/0x4000 -j MASQUERADE

至此,一个访问cluster ip 最终访问到Pod的流程走完了。

2.1.2 抓包分析

抓包分析的时候,为方便,只部署了一个frontend实例, frontend实例只跑在haofan-test-1 node上。

2.1.1.1 在haofan-test-1上 直接访问 cluster ip: port
[root@haofan-test-1 ~]# curl 172.16.92.224:80

在frontend pod 中抓包如下,看到source地址是172.18.1.1,可以认为是容器网关地址。在haofan-test-1上直接curl 172.18.1.23:80, source地址其实是192.168.3.233,因为默认路由指向了eth0。匹配到了chain <是对源地址不是172.18.0.0/16的,目的地址是 172.18.1.23,目的端口是80打标签,标签后的packet进行SNAT> ,注意这是request packet的SNAT,不是response的SNAT。

 [root@haofan-test-1 ~]# kubectl exec -it frontend-5798c4bfc7-vrwcx bashroot@frontend-5798c4bfc7-vrwcx:/var/www/html# tcpdump port 80 -n1:35:24.027687 IP 172.18.1.23.80 > 172.18.1.1.34266: Flags [P.], seq 1:1185, ack 78, win 211, options [nop,nop,TS val 2316549007 ecr 2316549007], length 1184: HTTP: HTTP/1.1 200 OK01:35:24.027728 IP 172.18.1.1.34266 > 172.18.1.23.80: Flags [.], ack 1185, win 234, options [nop,nop,TS val 2316549008 ecr 2316549007], length 001:35:24.027893 IP 172.18.1.1.34266 > 172.18.1.23.80: Flags [F.], seq 78, ack 1185, win 234, options [nop,nop,TS val 2316549008 ecr 2316549007], length 001:35:24.027957 IP 172.18.1.23.80 > 172.18.1.1.34266: Flags [F.], seq 1185, ack 79, win 211, options [nop,nop,TS val 2316549008 ecr 2316549008], length 001:35:24.027984 IP 172.18.1.1.34266 > 172.18.1.23.80: Flags [.], ack 1186, win 234, options [nop,nop,TS val 2316549008 ecr 2316549008], length 0
2.1.1.2 在redis pod 内上 直接访问 cluster ip: port

可以看到并没有做SNAT,source 地址是redis的pod地址。

 [root@haofan-test-2 ~]# kubectl exec -it redis-slave-6566d8d846-n2phs bash
root@redis-slave-6566d8d846-n2phs:/data# curl 172.16.92.224:80root@frontend-5798c4bfc7-vrwcx:/var/www/html# tcpdump port 80 -n
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
01:56:01.425643 IP 172.18.1.52.41838 > 172.18.1.23.80: Flags [S], seq 4271004944, win 27200, options [mss 1360,sackOK,TS val 2317786405 ecr 0,nop,wscale 7], length 0
01:56:01.425723 IP 172.18.1.23.80 > 172.18.1.52.41838: Flags [S.], seq 3853670533, ack 4271004945, win 26960, options [mss 1360,sackOK,TS val 2317786406 ecr 2317786405,nop,wscale 7], length 0
01:56:01.425753 IP 172.18.1.52.41838 > 172.18.1.23.80: Flags [.], ack 1, win 213, options [nop,nop,TS val 2317786406 ecr 2317786406], length 0

2.2 集群外部通过node ip 访问到Pod

2.2.1 iptables分析

根据iptables, 肯定是对PREROUTING链动手脚

 [root@haofan-test-1 ~]# iptables -S -t nat | grep PREROUTING-A PREROUTING -m comment --comment "kubernetes service portals" -j KUBE-SERVICES[root@haofan-test-2 ~]# iptables -L PREROUTING -t nat
Chain PREROUTING (policy ACCEPT)
target     prot opt source               destination
KUBE-SERVICES  all  --  anywhere             anywhere             /* kubernetes service portals */

然后又调到KUBE-SERVICES这个chain上去了,但是因为根据具体的destination 地址,只能匹配到下面˙这条chain

[root@haofan-test-2 ~]# iptables -L KUBE-SERVICES -t nat -n
KUBE-NODEPORTS  all  --  0.0.0.0/0            0.0.0.0/0            /* kubernetes service nodeports; NOTE: this must be the last rule in this chain */ ADDRTYPE match dst-type LOCAL

KUBE-NODEPORTS最后还是调到之前的KUBE-SVC-PKCF2FTRAH6WFOQR

    [root@haofan-test-2 ~]# iptables -L KUBE-NODEPORTS -t nat --line-number
Chain KUBE-NODEPORTS (1 references)
num  target     prot opt source               destination
1    KUBE-MARK-MASQ  tcp  --  anywhere             anywhere             /* default/frontend: */ tcp dpt:30784
2    KUBE-SVC-PKCF2FTRAH6WFOQR  tcp  --  anywhere             anywhere             /* default/frontend: */ tcp dpt:30784

然后这里注意,2条规则并不是匹配完第一条,就不匹配第二条了,iptables匹配完第一条不匹配第二条是对于prerouting/input/output/postrouting 的规则,自定义的规则最终都会append到这4个chain上的, 也就是在内核中实际上是不同的chain。 第一条就是目的端口是30784的packet进行SNAT,第二条就不用说了。SNAT的目的前面已经说了,SNAT之后进入pod的packet的source地址是容器网关地址,抓包可以验证。

经过上面的描述,应该对网络packet数据转发有一个比较清楚的认识。

2.2.2 抓包分析

在haofan-test-1 节点上,访问node1 ip:node port, 可以看到source地址是172.18.1.1, 说明做了SNAT

[root@haofan-test-1 ~]# curl 192.168.3.233:32214
root@frontend-5798c4bfc7-vrwcx:/var/www/html# tcpdump port 80 -n
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
02:02:56.127994 IP 172.18.1.1.46634 > 172.18.1.23.80: Flags [S], seq 1822455295, win 43690, options [mss 65495,sackOK,TS val 2318201108 ecr 0,nop,wscale 7], length 0
02:02:56.128057 IP 172.18.1.23.80 > 172.18.1.1.46634: Flags [S.], seq 1731607107, ack 1822455296, win 26960, options [mss 1360,sackOK,TS val 2318201108 ecr 2318201108,nop,wscale 7], length 0
02:02:56.128088 IP 172.18.1.1.46634 > 172.18.1.23.80: Flags [.], ack 1, win 342, options [nop,nop,TS val 2318201108 ecr 2318201108], length 0

如果在haofan-test-1节点上,访问node2 ip:node port, 可以看到source地址是node2的IP,原因就是上面解释的为什么要做SNAT。

[root@haofan-test-1 ~]# curl 192.168.3.232:32214
root@frontend-5798c4bfc7-vrwcx:/var/www/html# tcpdump port 80 -n
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
02:21:58.521488 IP 192.168.3.232.49114 > 172.18.1.23.80: Flags [S], seq 2810163436, win 27200, options [mss 1360,sackOK,TS val 2319343500 ecr 0,nop,wscale 7], length 0
02:21:58.521561 IP 172.18.1.23.80 > 192.168.3.232.49114: Flags [S.], seq 3145056659, ack 2810163437, win 26960, options [mss 1360,sackOK,TS val 2319343501 ecr 2319343500,nop,wscale 7], length 0
02:21:58.522490 IP 192.168.3.232.49114 > 172.18.1.23.80: Flags [.], ack 1, win 213, options [nop,nop,TS val 2319343502 ecr 2319343501], length 0

2.4 总结

  1. (inbound)在PREROUTING阶段,将所有报文转发到KUBE-SERVICES
  2. (outbound)在OUTPUT阶段,将所有报文转发到KUBE-SERVICES
  3. (outbound)在POSTROUTING阶段,将所有报文转发到KUBE-POSTROUTING
  4. 图中共有三个地方看到了KUBE-MARK-MASQ,前2个的原因是为了防止上面所说的"三角流量", 最后一个的原因,是进行SNAT,将pod的地址,转成网关地址,类似容器网关地址,然后再通过SNAT转成node ip地址,最后转发出去。图中共有三个地方看到了KUBE-MARK-MASQ,前2个的原因是为了防止上面所说的"三角流量", 最后一个的原因,是进行SNAT,将pod的地址,转成网关地址,类似容器网关地址,然后再通过SNAT转成node ip地址,最后转发出去。
  5. 这样如上图描述,每添加一个有N个endpoints的nodeport类型service:port,新增(2 + 2 + (1 + 2) * N)条规则,新增1+N条链。

Refer

  1. https://zhuanlan.zhihu.com/p/28289080
  2. http://cizixs.com/2017/03/30/kubernetes-introduction-service-and-kube-proxy/
  3. refer: https://k8smeetup.github.io/docs/tutorials/services/source-ip/
    4.https://www.lijiaocn.com/%E9%A1%B9%E7%9B%AE/2017/03/27/Kubernetes-kube-proxy.html

K8S kube-proxy iptables 原理分析相关推荐

  1. K8S kube-proxy- iptable模式实现原理分析

    每台机器上都运行一个kube-proxy服务,它监听api-server 和endpoint变化情况,维护service和pod之间的一对多的关系,通过iptable或者ipvs为服务提供负载均衡的能 ...

  2. K8S kube-proxy ipvs 原理分析

    1 在k8s 设置ipvs模式 1.1 Perquisites 1.2 修改kube-proxy 启动参数 2 ipvs kube-proxy原理分析 2.1 集群内部发送出去的packet通过clu ...

  3. k8s组件说明:kubelet 和 kube proxy

    k8s的node节点需要安装三个组件:docker/kubelet/kube proxy pod是存储容器的容器,但容器不止docker一种. CRI:container runtime interf ...

  4. Kubernetes Kubeadm init 与 join 原理分析

    一.kubeadm概述 kubeadm是社区维护的Kubernetes集群一键部署利器,使用两条命令即可完成k8s集群中master节点以及node节点的部署,其底层原理是利用了k8s TLS boo ...

  5. kubeadm工作原理-kubeadm init原理分析-kubeadm join原理分析

    kubeadm概述 kubeadm是社区维护的Kubernetes集群一键部署利器,使用两条命令即可完成k8s集群中master节点以及node节点的部署,其底层原理是利用了k8s TLS boots ...

  6. Retrofit原理分析

    Retrofit原理分析 温故而知新 还记得retrofit的使用方法嘛,下面我们来回顾一下 接口定义 public interface GitHubService {@GET("users ...

  7. MyBatis运行原理(三)接口式编程及创建代理对象原理分析

    一.面向接口开发步骤 定义代理接口,将操作数据库的方法定义在代理接口中. 在SQL 映射文件中编写SQL 语句. 将SQL 映射文件注册在MyBatis 的全局配置文件中. 编写测试代码. 二.环境准 ...

  8. MyBatis(五)MyBatis整合Spring原理分析

    前面梳理了下MyBatis在单独使用时的工作流程和关键源码,现在看看MyBatis在和Spring整合的时候是怎么工作的 也先从使用开始 Spring整合MyBatis 1.引入依赖,除了MyBati ...

  9. web压测工具http_load原理分析

    一.前言 http_load是一款测试web服务器性能的开源工具,从下面的网址可以下载到最新版本的http_load: http://www.acme.com/software/http_load/  ...

最新文章

  1. SpringBoot之配置文件加载位置
  2. 输入一行字符,判断单词数
  3. TMG2010 之创建访问规则
  4. Hive时间是String格式截取字串和转换数据类型小贴士
  5. range函数python2和3区别_range函数python2和3区别
  6. 趣挨踢 | 阿里员工吐槽:我在阿里工作五年,面试一个小公司竟然挂了
  7. 二叉树最大深度:给定一个二叉树,找出其最大深度。 二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。
  8. mac下electron始终安装不成功解决办法
  9. [转载] Python 中 pass 语句的作用是什么?
  10. DBVisualizer 添加数据库JDBC驱动
  11. M1芯片Macbook最简单从11.3降级到11.2.3教程
  12. 每日一九度之题目1021:统计字符
  13. XUI 熟练使用之(四) ----------- 各种弹出对话框的详细介绍
  14. 注塑行业APS解决方案
  15. 【NLP】学不会打我 半小时学会基本操作 12 命名实例提取
  16. python中的newline_python open函数newline用法
  17. 国外程序员推荐:每个程序员都应读的书
  18. ftp服务器一直在转未响应,打开ftp服务器未响应
  19. 福大计算机课程表,福州大学研究生院-通知公告-福州大学课程表(非全日制工程硕士研究生2017年周末班公共课3-5月份 )...
  20. maven添加阿里镜像急速提升jar下载速度

热门文章

  1. php sapi 那些坑,安装PHP出现make: *** [sapi/cli/php] Error 1 解决办法
  2. matlab遗传算法外卖配送优化(新的约束条件)【matlab优化算法十六】
  3. 为什么我说,卖货直播平台开发的定位可以从这方面入手
  4. docker配置mysql 中间件 ProxySQL
  5. Excel 将两列合并变成第三列,中间加一个连字符
  6. 专业导师告诉你,有哪些51单片机教程值得大力推荐
  7. 51单片机教程:二相四线步进电机驱动
  8. 显示gsensor即时数据的apk 用gsensor来判断手机的静和动 气压计的测试应用
  9. 微信内部浏览器打开网页时提示外部浏览器打开 升级版
  10. 空气净化器哪个品牌口碑好 空气净化器除甲醛排行榜前十名