网关建设

今天给大家介绍三种常见的四层负载均衡、网络转发方案,可用于四层的网关建设。

利用ipvs实现(需要后端服务能连通外部网络)

lb-4-ipvs

该方案需要后端服务器与前端client网络打通,GatewayIp可以采用主备的方式保证高可用

配置都在GatewayIp上,需要配置的如下:

1
2
3
4
ipvsadm -A -u $GatewayIp:$port -s rr -p 600
# -u表示为udp协议,-t表示为tcp协议
# rr 为均衡算法,roundroubin的意思,lc则代表最短连接数
ipvsadm -a -u $GatewayIp:$port -r $ServerIp:$port -m

Ipvs+Iptables实现

如果您不希望后端Server与客户端面对面打通,那么您可能会喜欢这种方式,将GatewayIP设置为ServerIp的默认网关,再由Snat转换将报文转换出去,这样子Server就不需要与客户端面对面打通了,图示如下:

lb-4-ipvs-iptables

配置默认路由也很简单

1
ip route add 客户端IP网段 via GateWayIp dev eth0

配置iptables

1
iptables -t nat -A POSTROUTING -m iprange -p udp --dst-range $client_ip_range -o eth1  -j SNAT  --to-source $GateWayIp

Ipvs+Iptables+Iptunnel实现

默认路由有一个限制,就是说Server与Gateway都在一个子网内,有过商用经验的大家都知道DMZ之类的说法,就是说应用服务器和网关服务器在诸如安全组,子网等等上需要隔离。假设你需要将应用服务器和网关放在不同的子网,上面的方案就搞不定啊,这个时候需要使用ip隧道的方式来跨子网,图示如下,仅仅后边红色路线的ip发生了变化,原来的报文被ip隧道Wrap:

lb-4-ipvs-iptables-iptunnel

配置ip 隧道倒也不难

1
ip tunnel add $tun_name mode ipip remote $remote_ip local $local_ip ttl 255

总结

以上三种方案均没有单点问题,且都兼容tcp,udp协议。GateWay处的单点问题,通过zk选主、etcd选主,keepalive等 + 浮动IP迁移的方式均能解决。大家可以根据自己的网规网设自由选择

SNI是一个TLS的扩展字段,经常用于访问域名跳转到不同的后端地址。

配置方式如下:打开nginx.conf文件,以ttbb/nginx:nake镜像为例/usr/local/openresty/nginx/conf/nginx.conf

如下为默认的nginx.conf配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117

#user nobody;
worker_processes 1;

#error_log logs/error.log;
#error_log logs/error.log notice;
#error_log logs/error.log info;

#pid logs/nginx.pid;


events {
worker_connections 1024;
}


http {
include mime.types;
default_type application/octet-stream;

#log_format main '$remote_addr - $remote_user [$time_local] "$request" '
# '$status $body_bytes_sent "$http_referer" '
# '"$http_user_agent" "$http_x_forwarded_for"';

#access_log logs/access.log main;

sendfile on;
#tcp_nopush on;

#keepalive_timeout 0;
keepalive_timeout 65;

#gzip on;

server {
listen 80;
server_name localhost;

#charset koi8-r;

#access_log logs/host.access.log main;

location / {
root html;
index index.html index.htm;
}

#error_page 404 /404.html;

# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}

# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}

# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# root html;
# fastcgi_pass 127.0.0.1:9000;
# fastcgi_index index.php;
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
# include fastcgi_params;
#}

# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}


# another virtual host using mix of IP-, name-, and port-based configuration
#
#server {
# listen 8000;
# listen somename:8080;
# server_name somename alias another.alias;

# location / {
# root html;
# index index.html index.htm;
# }
#}


# HTTPS server
#
#server {
# listen 443 ssl;
# server_name localhost;

# ssl_certificate cert.pem;
# ssl_certificate_key cert.key;

# ssl_session_cache shared:SSL:1m;
# ssl_session_timeout 5m;

# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;

# location / {
# root html;
# index index.html index.htm;
# }
#}

}

在最后面添加上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
stream {

map $ssl_preread_server_name $name {
backend.example.com backend;
default backend2;
}

upstream backend {
server 192.168.0.3:12345;
server 192.168.0.4:12345;
}

upstream backend2 {
server 127.0.0.1:8071;
}

server {
listen 12346;
proxy_pass $name;
ssl_preread on;
}
}

这个时候,我们已经开启了SNI转发的功能,如果你使用backend.example.com的域名访问服务器,就会转发到backend,如果使用其他域名,就会转发到backend2

测试的时候,让我们在/etc/hosts里进行设置,添加

1
127.0.0.1 backend.example.com

然后进行请求

1
curl https://backend.example.com:12346

这里注意请求要使用https,http协议或者是tcp可没有SNI的说法

nginx-sni-backend

发现请求的确实是backend

然后测试请求127.0.0.1:12346

1
curl https://127.0.0.1:12346

nginx-sni-127

准备工作

运行zookeeper

1
docker run -p 2181:2181 -d ttbb/zookeeper:stand-alone

代码参考

1
https://github.com/hezhangjian/maven-demo/tree/master/demo-zookeeper/src/main/java/com/github/hezhangjian/demo/zookeeper

第一次运行代码

创建一个临时有序Znode,程序维持一个小时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.github.hezhangjian.demo.zookeeper;

import com.github.hezhangjian.javatool.util.CommonUtil;
import com.github.hezhangjian.javatool.util.LogUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.curator.RetryPolicy;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.RetryNTimes;
import org.apache.zookeeper.CreateMode;

import java.util.concurrent.TimeUnit;

/**
* @author hezhangjian
*/
@Slf4j
public class TempOrderTest {

public static void main(String[] args) throws Exception {
LogUtil.configureLog();
RetryPolicy retryPolicy = new RetryNTimes(3, 5000);
CuratorFramework client = CuratorFrameworkFactory.builder().connectString("127.0.0.1:2181")
.sessionTimeoutMs(10000).retryPolicy(retryPolicy).build();
client.start();
String path = client.create().withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath("/seq", "World".getBytes());
log.info("path is [{}]", path);
CommonUtil.sleep(TimeUnit.HOURS, 1);
}

}

运行第一次

first-run

运行完之后zk

after-first-run

第二次运行代码

创建一个临时有序Znode,创建完成后立刻关闭

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.github.hezhangjian.demo.zookeeper;

import com.github.hezhangjian.javatool.util.CommonUtil;
import com.github.hezhangjian.javatool.util.LogUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.curator.RetryPolicy;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.RetryNTimes;
import org.apache.zookeeper.CreateMode;

import java.util.concurrent.TimeUnit;

/**
* @author hezhangjian
*/
@Slf4j
public class TempOrderTest2 {

public static void main(String[] args) throws Exception {
LogUtil.configureLog();
RetryPolicy retryPolicy = new RetryNTimes(3, 5000);
CuratorFramework client = CuratorFrameworkFactory.builder().connectString("127.0.0.1:2181")
.sessionTimeoutMs(10000).retryPolicy(retryPolicy).build();
client.start();
String path = client.create().creatingParentsIfNeeded().withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath("/temp/seq", "World".getBytes());
log.info("path is [{}]", path);
client.close();
}

}

运行第二次结果

second-run

运行后zk

after-second-run
因为是临时节点,所以存在一会后删除

第三次仍然运行第一次的代码

运行第三次结果

third-run

zk

after-third-run

第四次,修改了zkPath的值。值得一提的是,同一个ZkPath下似乎共享临时有序节点的最大值。如果修改zkPath从/temp/seq到/temp/seqX,出现的并不是预估的0000,而是0003

fourth-run
也就是意味着,zk的临时node序号添加是根据父目录下一个标志计数的

zk

after-fourth-run

Hash-based multipath routing

该特性在Linux4.4版本引入,一个难以被大家发现的好处是,基于源地址路由,没有做地址转换,并不会在nf_conntrack中添加记录
它是对源IP和目标IP进行哈希处理(端口不参与哈希的计算)计算选路。配置的命令如下:
weight代表权重

通过网关负载均衡

1
2
3
ip route add default  proto static scope global \
nexthop via <gw_1> weight 1 \
nexthop via <gw_2> weight 1

通过网卡负载均衡

1
2
3
ip route add default  proto static scope global \
nexthop dev <if_1> weight 1 \
nexthop dev <if_2> weight 1

在分布式微服务场景下,有太多的环节可以引发错误的处理(包括丢失或者重复处理),如果业务本身有幂等的特性,我们可以以较低的代价解决大部分问题。

​ 我们假设我们在k8s集群中维护着下面的系统,部署了数个网关实例,数个业务处理服务,一套Kafka集群,数个消费者服务,一个数据库,平时的业务流程是这样子的:

1
客户--->(step1) 网关--->(step2) 业务处理服务-->(step3)消息中间件如Kafka-->(step4)消费者消费-->(step5) 写入数据库

而我们希望做到的是,在海量数据量的情况下,仅仅付出较小的代价,使得客户的每一条消息都存入到我们的数据库,没有丢失或者重复。

在分布式的环境下,有很多地方都会出错,会引发消息的丢失或者重复,我们先假设上述系统没有做太多的可靠性加固,是如下工作的:

1
2
3
4
5
1. 客户简单地发送到网关
2. 网关发送到后端处理服务
3. 后端处理服务接收到请求,做简单校验后直接返回成功/失败,异步发送消息到Kafka
4. 消费者从Kafka拉取消息,拉取到消息就提交,不考虑写入数据库的成功或失败
5. 写入到数据库

举几个例子,那么我们会碰到类似这样的问题:

1
2
3
1. 客户发送到网关不考虑结果,即时我们返回失败,也不处理。显然会丢失消息,我们的系统并不能保证100%的消息处理成功
2. 网关在与业务处理服务的TCP的四次挥手阶段处理异常,导致业务处理成功(已发送到Kafka),但实际返回客户失败。导致消息重复
3. 消费者拉取到消息后,写入数据库失败,但此时offset已提交。消息丢失

我们先不考虑客户,先看平台侧,我们构筑了一个什么样的系统呢?

它既不能保证至少发一次(AT_LEAST_ONCE),也不能保证最多发一次(AT_MOST_ONCE),我们构筑了一个即可能多发也有可能少发的系统,当然,现在的基础设施很好,照着这样跑,可能丢失的数据也就是万中一二,但我们做技术,还是要有点追求,实现别人难以实现的事情才是我们的竞争力。

平台如何实现精确一次

实现端到端一次的技术核心是两阶段提交,以上述业务流程为例,实现了两阶段提交的流程应该是这样的:

1
2
3
4
1. 消费者从Kafka消费到一批数据,但并不commit提交
2. 消费者向数据库prepare这批数据
3. 消费者向Kafka提交Offset
4. 消费者向数据库commit这批数据

按照这个流程,还会有一些异常场景,比如

1
2
1.消费者向Kafka提交Offset后,突然宕机,重启的消费者无法恢复事务,消息丢失
2.消费者commit失败,消息丢失

第二种情况属于极端场景,因为我们执行的业务简单,只是insert操作,prepare成功,commit失败,可以打日志,报告警人工处理。但第一种情况是需要自动化解决的,因为我们不能对每条提交失败的事务都人工处理。那么我们需要的是:

我们需要消费者在进程级失败的时候,可以判断处于prepare阶段的事务是否需要恢复,这个可以对比Mysql,Mysql也使用了两阶段提交协议,每次重启的时候会判断redolog处于prepare状态,binlog是否完整,如果binlog完整,则恢复。这里redo log就像我们的数据库,binlog就是kafka。但我们现在的业务没有主键,每条消息都是独立的,我们无法区分,那些是要被正常放弃的事务,那些是重启的时候需要恢复的事务。这种情况我们无法处理。

但如果这时候我们有了主键,我们有了幂等,我们只要做到至少一次就可以保证平台达到端到端一次。因为多次向数据库插入相同的数据,并不会发生什么事。

平台如何实现至少一次

实现至少一次的核心技术是如果处理不成功就打死不提交,报告警等人工操作都不提交! 其他队列,缓存区,滑窗只不过是提升性能的手段。

1
2
3
4
1. 消费者从Kafka消费到一批数据,但并不commit提交
2. 消费者向数据库prepare这批数据
3. 消费者向数据库commit这批数据
4. 消费者向Kafka提交Offset

接下来我们来看与客户的通信,这次我们先看如何做到至少一次

与客户侧通信如何做到至少一次

前面说到客户可能不处理你的返回值,碰到这样的客户其实是你赚了,客户的系统连这个都不重视,那想必也不会在意你是否做到了端到端一次吧,丢失了数据想必也不是特别在意。一个端到端一次的系统,一定需要输入端和输出端的配合,正如我前面举例:

1
客户发送到网关不考虑结果,即时我们返回失败,也不处理。显然会丢失消息,

那么我们也需要客户的配合,客户检测到我们回复的结果是失败,重试一下,确保成功。现在我们已经实现了至少一次了吗?

没有,还记得我前面说的这个吗?

1
3. 后端处理服务接收到请求,做简单校验后直接返回成功/失败,异步发送消息到Kafka

我们需要后端处理服务接收到请求后,确保发送Kafka成功,再回复用户成功

与客户侧通信做到精确一次

假设前面的方案我都已经做了,在什么情况下我们会重复呢?

1
2
3
1. 网关在与业务处理服务的TCP的四次挥手阶段处理异常,导致业务处理成功(已发送到Kafka),但实际返回客户失败。导致消息重复
2. 业务处理服务与网关的TCP的四次挥手阶段处理异常,导致业务处理成功,但实际返回客户失败
3. 客户的系统重启,成功应答并没有成功传达

为了实现两阶段提交协议,客户在发送前需要确认这个消息是否已经处理过了,但是没有主键,我们无法提供给客户这样子的信息。(如果考虑性能的话,整个系统端到端的事务,基本与高吞吐无缘了)
而且,两阶段提交协议也需要客户做很多的工作,实际中也很难落地。

总结

  • 我们需要一个业务上的主键,它可以是组合主键(mysql, mongo),或者是single主键(更适合cassandra和redis),使得我们可以提供更高QOS的保证,为此,仅需付出极小的代码,可能仅仅是数据库的主键一致性检查。
  • 如果系统和业务无关,任谁也难言真正的端到端一次。Flink无疑是流系统里面端到端一次的佼佼者,但上面也有着诸多限制。

备注

  • 事实上,要实现精确一次,系统的每两个环节之间都要做两阶段提交,为行文方便,省去网关,业务处理服务之间的两阶段提交
  • 推荐书籍 《基于Apache Flink的流处理》

灵活部署要求

常见的微服务开发下,可能会对微服务的日志打印有一些要求.常见的一种模式是,将日志文件挂载在主机路径中,然后在主机上启动filebeat收集日志.

以我的一个demo工程rabbitmq-adapt为例:

1
2
容器内打印路径: /opt/sh/logs/file.log
容器外挂载路径: /opt/log/rabbitmq/file.log

但是这样子,如果我们需要在一个虚拟机上跑两个rabbitmq-adapt的时候,日志路径会重复,这种情况下,不仅我们在虚拟机很难定位问题,而且filebeat也很难给日志标记上不同实例的标签.

所以首先,我们得把日志路径按pod实例的方式隔离,选择k8s中HOSTNAME环境变量作为文件路径前缀是一个不错的选择

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
akka@AkkadeMacBook-Pro ~ % kubectl exec -it rabbitmq-58c5f4ff6b-zthg4 bash
[root@rabbitmq-58c5f4ff6b-zthg4 /]# env
LANG=en_US.UTF-8
HOSTNAME=rabbitmq-58c5f4ff6b-zthg4
KUBERNETES_PORT_443_TCP_PROTO=tcp
KUBERNETES_PORT_443_TCP_ADDR=10.96.0.1
KUBERNETES_PORT=tcp://10.96.0.1:443
POD_NAME=rabbitmq-58c5f4ff6b-zthg4
PWD=/
HOME=/root
NODE_NAME=minikube
KUBERNETES_SERVICE_PORT_HTTPS=443
KUBERNETES_PORT_443_TCP_PORT=443
KUBERNETES_PORT_443_TCP=tcp://10.96.0.1:443
TERM=xterm
SHLVL=1
KUBERNETES_SERVICE_PORT=443
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
POD_IP=172.17.0.2
KUBERNETES_SERVICE_HOST=10.96.0.1
LESSOPEN=||/usr/bin/lesspipe.sh %s
_=/usr/bin/env

k8s subPathExpr方案: 查阅资料,官方推荐的是这个方案

这个时候,你的映射关系是 /opt/sh/logs <==> /opt/log/rabbitmq/${POD_NAME}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
apiVersion: apps/v1
kind: Deployment
metadata:
name: rabbitmq
labels:
app: rabbitmq
spec:
replicas: 2
selector:
matchLabels:
app: rabbitmq
template:
metadata:
labels:
app: rabbitmq
spec:
containers:
- name: rabbitmq
env:
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
image: hezhangjian/rabbitmq-adapt:0.0.1
readinessProbe:
httpGet:
path: /readiness
port: 8083
initialDelaySeconds: 3
periodSeconds: 3
volumeMounts:
- mountPath: /opt/sh/log
name: rabbitmq-log
# subPath的方案很好,但对版本号要求很高,>=1.14
subPathExpr: $(POD_NAME)
resources:
limits:
memory: 4G
cpu: 1000m
requests:
memory: 500M
cpu: 250m
securityContext:
privileged: true
volumes:
# - name: rabbitmq-data
# hostPath:
# path: "/Users/akka/rabbitmq"
# type: DirectoryOrCreate
- name: rabbitmq-log
hostPath:
path: /opt/log/rabbitmq
type: DirectoryOrCreate


可以达到如下的效果:

1
minikube/                  rabbitmq-6df8f7565c-kh2h6/ rabbitmq-6df8f7565c-ss92l/

log4j2环境变量隔离方案:适用于在你的k8s版本还不够的情况下

这个时候,你的文件映射关系还是 /opt/sh/logs <==> /opt/log/rabbitmq

但是真正在打印日志的时候,把日志都打印到/opt/sh/logs/${POD_NAME}下,这样子也可以在一台vm上跑多个实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?xml version="1.0" encoding="UTF-8" ?>

<Configuration status="warn" monitorInterval="10">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{yyyy-MM-dd,HH:mm:ss,SSSXXX}(%r):%4p%X[%t#%T]%l-->%m%n"/>
</Console>
<File name="FILE" fileName="${env:HOSTNAME}/file.log">
<PatternLayout pattern="%d{yyyy-MM-dd,HH:mm:ss,SSSXXX}(%r):%4p%X[%t#%T]%l-->%m%n"/>
</File>
</Appenders>
<Loggers>
<Root level="INFO">
<AppenderRef ref="Console"/>
<AppenderRef ref="FILE"/>
</Root>
</Loggers>
</Configuration>

我们解决了两个容器在一个vm上打印日志的问题,紧接着,我们要分析filebeat的能力,filebeat能否区分这两个路径,把这两个路径打上不同的标签

运行并配置好filebeat,配置文件如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
filebeat.inputs:
- type: log
enabled: true
paths:
- /opt/sh/collect/log/es/*/*.log
tags: ["es"]
filebeat.config.modules:
path: ${path.config}/modules.d/*.yml
reload.enabled: false
reload.period: 10s
setup.template.settings:
index.number_of_shards: 1
fields:
fields_under_root: true
setup.kibana:
output.elasticsearch:
hosts: ["localhost:9200"]
processors:
- add_host_metadata: ~
- add_docker_metadata: ~
- add_kubernetes_metadata:
kube_config: /opt/sh/collect/log/config
logging.level: debug

,查询收集上来的数据

1
2
curl 127.0.0.1:9200/filebeat-7.5.1-2020.01.23-000001/_search?pretty
收集上来的数据存在hostname字段,能区分代表单个实例的信息.

使用ES processor添加实例id信息

1
2
3
4
5
6
7
8
9
10
11
12
curl -X PUT "localhost:9200/_ingest/pipeline/attach_instance?pretty" -H 'Content-Type: application/json' -d'
{
"processors": [
{
"grok": {
"field": "log.file.path",
"patterns": ["/opt/sh/collect/log/es/%{WORD:instanceId}/es.log"]
}
}
]
}
'

然后修改filebeat配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
filebeat.inputs:
- type: log
enabled: true
paths:
- /opt/sh/collect/log/es/*/*.log
tags: ["es"]
filebeat.config.modules:
path: ${path.config}/modules.d/*.yml
reload.enabled: false
reload.period: 10s
setup.template.settings:
index.number_of_shards: 1
fields:
fields_under_root: true
setup.kibana:
output.elasticsearch:
hosts: ["localhost:9200"]
pipeline: attach_instance
processors:
- add_host_metadata: ~
- add_docker_metadata: ~
- add_kubernetes_metadata:
kube_config: /opt/sh/collect/log/config
logging.level: debug

查询结果就会出现在instanceId字段

总结

三种方案均可以方便地实现同一vm上部署两个容器. 方案一只需要修改tosca模板,但要1.14版本才支持.方案二需要修改少量代码.

但前两种均不适合配置了hostnetwork,即独占主机的网络,原因,主机网络独占之后,两个容器的hostname都相同,实际中,已经独占网络的容器,还需要部署在一个节点的需求,应该比较少.

如果有,可以使用在es处处理文件路径,加上instanceId字段

业务需求分析与解决方案

在业务场景中,当需要利用ping命令对主机进行心跳探测时,直接在代码中fork进程执行ping命令虽然可行,但这种方法开销较大,并且处理流程易出错,与使用标准库相比缺乏优雅性。因此,本文探讨了使用Java的InetAddress类的isReachable方法作为替代方案。

根据资料指出,Java的InetAddress类在root用户权限下通过执行ping命令进行探测,在非root用户权限下则通过访问TCP端口7进行探测。为验证这一点,本文撰写了相应的demo代码并进行了测试(详见:GitHub - heart-beat)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import lombok.extern.slf4j.Slf4j;

import java.net.InetAddress;

@Slf4j
public class PingTestMain {

public static void main(String[] args) throws Exception {
String testIp = System.getProperty("TestIp");
InetAddress inetAddress = InetAddress.getByName(testIp);
boolean addressReachable = inetAddress.isReachable(500);
log.info("address is reachable is {}", addressReachable);
}

}

测试实验

root用户下执行程序

java-ping-root-success.png

java程序打印结果也是true

在普通用户权限下的测试

java-ping-user-fail.png

此时可以看到,我们的客户端程序向目标tcp7端口发送了一个报文,虽然java程序打印结果为true,但是因为收到了RST包导致的.在当今的网络安全要求下,7端口往往不会开放

在目标网络屏蔽TCP端口7的情况下执行程序

1
iptables -A INPUT -p tcp --dport 7 -j DROP

发送的报文没有收到RST包,此时java程序返回false.不是我们预期的结果

普通用户权限下携带特权的测试

进一步的研究发现,Java程序发送ping命令需要创建raw socket,这要求程序具有root权限或cap_net_raw权限。赋予Java程序创建raw socket的权限后重新测试,发现程序能够正确发送ping命令,达到预期效果。

1
2
setcap cap_net_raw+ep /usr/java/jdk-13.0.1/bin/java

发现如下报错

1
java: error while loading shared libraries: libjli.so: cannot open shared object file: No such file or directory

使用https://askubuntu.com/questions/334365/how-to-add-a-directory-to-linker-command-line-in-linux规避添加so文件权限

随后抓包,发现还是发送了ping命令,达到了我们预期的效果

总结

本文通过一系列测试得出结论,root用户权限下的Java程序会使用ping命令进行探测。若普通用户不具备相应权限,则会尝试探测TCP端口7,但在安全组未开启该端口的情况下会导致预期结果不一致。推荐赋予java程序特权,使得InetAddress类能够使用ping命令进行探测

如何使用显示过滤器

wireshark-display-filter1
或者按住 CTRL + F,输入显示过滤器
wireshark-display-filter2

二层显示过滤器举例

长度小于128字节的数据包

frame.len<=128

排除ARP流量

!arp

三层显示过滤器举例

只显示192.168.0.1 IP相关数据包

ip.addr==192.168.0.1

四层显示过滤器举例

排除RDP流量

!tcp.port==3389

具有SYN标志的TCP数据包

tcp.flags.syn==1

具有RST标志的TCP数据包

tcp.flags.rst==1

TCP确认时间较久

tcp.analysis.ack_rtt > 0.2 and tcp.len == 0
###启用TCP Relative Sequence Number的情况
如何启用?
Edit -> Preferences -> Protocols -> TCP Relative Sequence Numbers

握手被对方拒绝的包

tcp.flags.reset == 1 && tcp.seq == 1

客户端重传

tcp.flags.syn == 1 && tcp.analysis.retransmission

Tcp包含

tcp contains {str}

应用层显示过滤器举例

所有http流量

http

文本管理流量

tcp.port == 23 || tcp.port == 21

文本email流量

email || pop || imap

只显示访问某指定主机名的HTTP协议数据包

http.host == <”hostname”>

只显示包含HTTP GET方法的HTTP协议数据包

http.request.method == ‘GET’

只显示HTTP 客户端发起的包含指定URI请求的HTTP协议数据包

http.request.uri == <”Full request URI”>

只显示包含ZIP文件的数据包

http matches “.zip” && http.request.method == ‘GET’

如何使用捕获过滤器

点击捕获,选项,然后在所选择的捕获过滤器上输入对应的捕获表达式

wireshark-capture-filter1

wireshark-capture-filter2

抓包过滤器

  • type(类型) 限定符: 比如host,net,port限定符等
  • dir(方向) 限定符: src dst
  • Proto(协议类型)限定符: ether ip arp

二层过滤器举例

1
2
3
4
5
6
7
8
tcp dst port 135 //tcp协议,目标端口为135的数据包
ether host <Ethernet host> //让wireshark只抓取这个地址相关的以太网帧
ether dst <Ethernet host>
ether src <Ethernet src>
ether broadcast //Wireshark只抓取所有以太网广播流量
ether multicast //只抓取多播流量
ether proto <protocol>
vlan <vlan_id>

三层过滤器举例

1
2
3
4
5
6
7
8
ip #只抓取ipv4流量
ipv6
host 10.0.0.2
dest host <host>
src host <host>
broadcast #ip广播包
multicast #ip多播包
ip proto <protocol code> #ip数据包有多种类型,比如TCP(6), UDP(17) ICMP(1)

只抓取源于或者发往IPv6 2001::/16的数据包

net 2001::/16

只抓取ICMP流量

ip proto 1

只抓取ICMP echo request流量

icmp[icmptype]==icmp-echo
icmp[icmptype]==8

只抓取特定长度的IP数据包

ip[2:2] ==

只抓取具有特定TTL的IP数据包

ip[8] ==

抓取数据包的源和目的IP地址相同

ip[12:4] ==1 ip[16:4]

四层抓包过滤器举例

1
2
3
4
port <port>
dst port <port>
src port <port>
tcp portrange <p1>-<p2>

只抓取TCP中SYN或者FIN的数据包

tcp [tcpflags] & (tcp-syn | tcp-fin) != 0

只抓所有RST标记位置为1的TCP数据包

tcp[tcpflags] & (tcp-rst) != 0

tcp头部的常用标记位

  • SYN: 用来表示打开连接
  • FIN: 用来表示拆除连接
  • ACK: 用来确认收到的数据
  • RST: 用来表示立刻拆除连接
  • PSH: 用来表示应将数据提交给末端应用程序处理

抓取所有标记位都未置1的TCP流量

该报文可能用于端口探测,即如果
tcp[13] & 0x00 = 0

设置了URG位的TCP数据包

URG位,表示该数据包十分紧急,不进入缓冲区,直接送给进程
tcp[13] & 32 == 32

设置了ACK位的TCP数据包

tcp[13] & 16 == 16

设置了PSH位的TCP数据包

PSH代表这个消息要从缓冲区立刻发送给应用程序
tcp[13] & 8 == 8

设置了RST位的TCP数据包

tcp[13] & 4 == 4

设置了SYN位的TCP数据包

tcp[13] & 2 == 2

设置了FIN位的TCP数据包

tcp[13] & 1 == 1

TCP SYN-ACK数据包

tcp[13] == 18

抓取目的端口范围的数据包

tcp portrange 2000-2500

###tcpdump捕获过滤器

常见命令介绍

1
tcpdump -w hzj.pcap -s0 -iany port 1028

上面的命令代表
-w hzj.pcap 存储在hzj.pcap这个文件中
-s 0 代表抓取字节数不限制,在大多数linux系统下,默认捕获每个帧的前96个字节

tcpdump捕获一定范围的端口(9200-9400)

tcpdump portrange 9200-9400

tcpdump -r 可以阅读捕获的文件(建议拷贝到wireshark中分析)

WireShark安装

wireshark在windows和mac上的安装方式都比较简单,下面是Linux下的安装方式

1
2
3
4
5
sudo apt-add-repository ppa:wireshark-dev/stable
sudo apt-get update
sudo apt-get install wireshark
#以root权限启动
sudo wireshark

WireShark的名字解析

wireshark-name-resolve

  • L2层的名字解析,对Mac地址进行解析,返回机器名
  • L3层 ip解析为域名
  • L4层 端口号解析为协议端口号

Wireshark抓到的包更改时间格式

wireshark-time-format

查看EndPoint

点击Statistics->EndPoints,可以查看每一个捕获文件里的每个端点

wireshark-endpoint

查看网络会话

Statistics->Conversations. 查看地址A和地址B,以及每个设备发送或收到的数据包和字节数

wireshark-conversation

基于协议分层结构的统计数据

Statistics->Protocol Hierarchy

wireshark-protocol-hierarchy

跟随流功能

右键选中一个数据包,然后右键,follow。比如我在这里跟随一个tcp流

wireshark-tcp-stream

//这里也可以使用decode as解码功能,但是没有例子,暂不附图

查看IO图

Statistics->IO Graphs

wireshark-io-graph

双向时间图

Statistics->TCP Stream Graph -> Round Trip Time Graph
wireshark-rtt-graph

数据流图

Statistics->Flow Graph
wireshark-flow-graph

专家信息

Analyze->Expert Info Composite
wireshark-expert-info

触发的专家信息

对话消息

窗口更新 由接收者发送,用来通知发送者TCP接收窗口的大小已被改变

注意消息

TCP重传输 数据包丢失的结果,发生在收到重复的ACK,或者数据包的重传输计时器超时的时候

重复ACK 当一台主机没有收到下一个期望序列的数据包时,它会生成最近收到一次数据的重复ACK

零窗口探查ACK 用来响应零窗口探查数据包

窗口已满 用来通知传输主机及其接收者的TCP接收窗口已满

警告消息

上一段丢失 指明数据包丢失,发生在当数据流中一个期望的序列号被跳过时。

收到丢失数据包的ACK 发生在当一个数据包已经确认丢失但受到了其ACK数据包时

保活 当一个连接的保活数据包出现时触发

零窗口 当接收方已经达到TCP接收窗口大小时,发出一个零窗口通知,要求发送方停止传输数据

乱序 当数据包被乱序接收时,会利用序列号进行检测

快速重传输 一次重传会在收到一个重复ACK的20ms内进行

WireShark性能

Statistics -> Summary 查看平均速度

Analyze -> Expert Infos

Statistics -> TCP StreamGraph -> TCP Sequence Graph(Stenens)

TCP Previous segment not captured

在TCP传输过程中,同一台主机发出的数据段应该是连续的,即后一个包的Seq号等于前一个包的Seq + Len. 如果在网络包中没有找到,就会出现这个错误

TCP ACKed unseen segment

Wireshark发现被Ack的那个包没被wireshark捕获

TCP Out-of-Order

在TCP传输过程中,同一台主机发出的数据段应该是连续的,即后一个包的Seq号等于前一个包的Seq +
Len.当Wireshark发现后一个包的Seq号小于前一个包的Seq+Len 就乱序le

TCP Dup ACK

当乱序或者丢包的时候,接收方会收到Seq号比期望值大的包,每收到一个这种包就会Ack一次期望的Seq值

TCP Fast Retransmission

当发送方收到3个或以上TCP Dup ACK,就意识到之前发的包可能丢了,触发快速重传

TCP Retransmission

没有触发tcp超时重传,超时重传

TCP zerowindow

缓存区已满,不能再接收数据了

TCP window FUll

Wireshark检测到,发送方发送的数据会把接收方的接收窗口耗尽

0%