阅读视图

发现新文章,点击刷新页面。
☑️ ⭐

XDP 实现所有的 TCP 端口都接受 TCP 建立连接

一个 XDP 练习程序:作为 TCP 的 server 端,用 XDP 实现所有的 TCP 端口都接受 TCP 建立连接。(只是能够建立连接而已,无法支持后续的 TCP 数据传输,所以不具有实际意义,纯粹好玩。)

建立 TCP 连接需要实现 TCP 的三次握手,对于 server 端来说,要实现:

  • 收到 SYN 包,回复 SYN-ACK 包;
  • 收到 ACK 包,因为这里不再需要对客户端回复什么,所以这个包收到之后直接 DROP 即可。

回复 SYN-ACK 包就有些麻烦。XDP 不能主动发出包,它能做的就是在收到包的时候,决定对这个包执行何种 action,支持的 action 如下:

  • XDP_DROP
  • XDP_PASS
  • XDP_TX – 将数据包直接从接收的网卡原路回送出去,等同于 MAC 层 loopback,适用于构造 L2 层反射或快速回应场景。注意并不支持构造完全新包,只能修改现有包;
  • XDP_REDIRECT – 将数据包重定向到其他网卡或用户空间(如使用 AF_XDP),常用于 zero-copy 的高速转发;
  • XDP_ABORTED – 用于调试,表示程序异常终止,包被丢弃;

为了实现 TCP 的 SYN-ACK 回复,这里我们可以选择 XDP_TX ——在收到包之后,对包的内容进行一些修改,比如把 SYN flag 改成 SYN+ACK flag,然后把包重新回送出去,对方收到这个包,其实也不知道是 XDP 返回的还是 Linux kernel 返回的。在 XDP_TX 程序的机器上,Kernel 网络栈根本不知道这个包的存在。

XDP 程序直接从网卡的驱动返回包

现在的重点在于如何修改这个 TCP SYN 包,并将其回送,使对方认为它是一个合法的 SYN-ACK 包。

我们可以从下往上一层一层看:

  • Ether 层:只需要交换 Src MAC 地址和 Dst MAC 地址就可以了。这样的话,直接从 LAN 主机发过来的包会发回去 LAN IP,从 LAN 网关发来的包也会发回网关;
    • CRC 校验码一般是网卡硬件负责计算的,所以 Linux 代码不需要处理;
  • IP 层:交换 Src IP 和 Dst IP 即可。
    • IP checksum 这里也不需要我们手动添加,现在的路由器大部分都是不计算 checksum 的1
  • TCP 层:
    • 交换 Src Port 和 Dst Port;
    • Flags 把 SYN 和 ACK 都设置为 1;
    • 把 ACK 字段,设置为 ack = SYN 包的 seq + 1,以确认对端的 SYN。;
    • 填写 seq 字段,因为不涉及后续的数据传输了,这里使用一个固定值即可;
    • 重新计算 TCP checksum。重新计算 TCP checksum 是最麻烦的一步,因为在 eBPF/XDP 程序中不能依赖内核自动计算,需要手动构造伪头部(pseudo-header)并累加 TCP 包体数据。所以我们要用 XDP 的代码重新实现 TCP 的 checksum。还要让 XDP 的 verifier2 认为我们写的代码是安全的,所以复杂一些。

因为这个程序直接把收到的 TCP SYN 包远路反弹,就叫它 tcp_bounce.c 吧。(这周末刚去了一个叫 Bounce 的地方团建……)

XDP 程序的源代码如下:

#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/icmp.h>
#include <linux/tcp.h>
#include <linux/in.h>

#define MAX_CHECKING 4
#define MAX_CSUM_WORDS 750

static __always_inline __u32 sum16(const void* data, __u32 size, const void* data_end) {
    __u32 sum = 0;
    const __u16 *ptr = (const __u16 *)data;

    #pragma unroll
    for (int i = 0; i < MAX_CSUM_WORDS; ++i) {
        if ((const void *)(ptr + 1) > (data + size)) {
            break;
        }

        if ((const void *)(ptr + 1) > data_end) {
             return sum;
        }

        sum += *ptr;
        ptr++;
    }

    // Handle the potential odd byte at the end if size is odd
    if (size & 1) {
        const __u8 *byte_ptr = (const __u8 *)ptr; // ptr is now after the last full word

        // BPF Verifier check: Ensure the single byte read is within packet bounds
        if ((const void *)(byte_ptr + 1) <= data_end && (const void *)byte_ptr < data_end) {
            // In checksum calculation, the last odd byte is treated as the
            // high byte of a 16-bit word, padded with a zero low byte.
            // E.g., if the byte is 0xAB, it's treated as 0xAB00.
            sum += (__u16)(*byte_ptr) << 8;
        }
        // If the bounds check fails, we just return the sum calculated so far.
    }

    return sum;
}


SEC("xdp")
int tcp_bounce(struct xdp_md *ctx) {
    void *data = (void *)(long)ctx->data;
    void *data_end = (void *)(long)ctx->data_end;

    struct ethhdr *eth = data;
    if ((void *)eth + sizeof(*eth) > data_end)
        return XDP_PASS;  // not enough data

    if (eth->h_proto != bpf_htons(ETH_P_IP))
        return XDP_PASS;

    struct iphdr *iph = data + sizeof(*eth);
    if ((void *)iph + sizeof(*iph) > data_end)
        return XDP_PASS;

    if (iph->protocol != IPPROTO_TCP)
        return XDP_PASS;

    //check ip len
    int ip_hdr_len = iph->ihl*4;
    if((void *)iph + ip_hdr_len > data_end)
        return XDP_PASS;

    // convert to TCP
    struct tcphdr *tcph = (void *)iph + ip_hdr_len;
    if ((void *)tcph + sizeof(*tcph) > data_end)
        return XDP_PASS;

    if (!(tcph->syn) || tcph->ack)
        return XDP_DROP;

    // swap MAC addresses
    __u8 tmp_mac[ETH_ALEN];
    __builtin_memcpy(tmp_mac, eth->h_source, ETH_ALEN);
    __builtin_memcpy(eth->h_source, eth->h_dest, ETH_ALEN);
    __builtin_memcpy(eth->h_dest, tmp_mac, ETH_ALEN);

    // swap IP addresses
    __be32 tmp_ip = iph->saddr;
    iph->saddr = iph->daddr;
    iph->daddr = tmp_ip;

    // TCP
    // swap port
    __be16 tmpsrcport = tcph->source;
    tcph->source = tcph->dest;
    tcph->dest = tmpsrcport;

    // syn+ack
    tcph->ack = 1;
    __u32 ack_seq = bpf_ntohl(tcph->seq) + 1;
    tcph->ack_seq = bpf_htonl(ack_seq);


    // checksum pseudo header
    __u32 csum = 0;
    tcph->check = (__be16)csum;

    if ((void *)&iph->saddr + 8 > data_end)
        return XDP_PASS;
    csum = bpf_csum_diff(0, 0, (__be32 *)&iph->saddr, 8, csum);
    __u16 tcp_len = bpf_ntohs(iph->tot_len) - ip_hdr_len;
    csum += (__u32)(bpf_htons(IPPROTO_TCP) << 16) | bpf_htons(tcp_len);

    csum += sum16(tcph, tcp_len, data_end);

    while (csum >> 16)
        csum = (csum & 0xFFFF) + (csum >> 16);

    tcph->check = (__be16)~csum;

    return XDP_TX;
}

char _license[] SEC("license") = "GPL";

安装编译 XDP 程序需要的依赖:

apt-get install -y clang llvm libelf-dev libpcap-dev build-essential  m4 pkg-config \
  linux-headers-$(uname -r) \
  linux-tools-generic tcpdump linux-tools-common \
  xdp-tools

安装 libc 开发包依赖,如果是 x86 操作系统:apt-get install -y libc6-dev-i386;如果是 ARM 操作系统:apt-get install -y libc6-dev-arm64-cross.

编译程序:

clang -O2 -target bpf -g -c tcp_bounce.c -o tcp_bounce.o  -I /usr/include/aarch64-linux-gnu/

把 xdp 程序加载到网卡上:

xdp-loader load eth1 tcp_bounce.o --mode skb

然后从另一台机器对这个加载了 XDP 程序 tcp_bounce.o 发起 TCP 连接,对于任意端口,可以观察到连接建立成功了:

用 nc 随机对两个端口建立连接

也可以用 for 循环批量对端口建立连接,都可以连通。

for i in {5000..5010}; do nc -vz 172.16.199.22 ${i};done

XDP 的性能很高,客户端用 10000 个线程同时建立 TCP 连接,服务端的 XDP 程序使用了连 10% 都不到的 CPU。(Again,但是没有什么实际意义)

  1. 网络中的环路和防环技术 有提到过,IPv6 是直接取消了 checksum 字段。 ↩
  2. eBPF verifier ↩
☑️ ⭐

Django 全局禁用外键

Django ORM 是我最喜欢的 ORM,它自带了全套数据库管理的解决方案,开箱即用。但是到了某一家公司里就有些水土不服。比如分享了如何 在你家公司使用 Django Migrate。这次我们来说说外键。

什么是外键

关系型数据库之所以叫「关系型」,因为维护数据之间的「关系」是它们的一大 Feature。

外键就是维护关系的基石。

比如我们创建两个表,一个是 students 学生表,一个是 enrollments 选课表。

MySQL root@(none):foreignkey_example1> select * from students;
+----+------+
| id | name |
+----+------+
| 1  | 张三 |
| 2  | 李四 |
+----+------+
2 rows in set
Time: 0.002s

MySQL root@(none):foreignkey_example1> select * from enrollments;
+----+------------+--------+
| id | student_id | course |
+----+------------+--------+
| 1  | 1          | 数学   |
| 2  | 2          | 语文   |
| 4  | 1          | 英语   |
+----+------------+--------+
3 rows in set
Time: 0.002s

选课表的 student_idstudent.id 关联。那么外键在这里为我们做了什么呢?

enrollments 创建的 SQL 如下:

CREATE TABLE `enrollments` (
  `id` int NOT NULL AUTO_INCREMENT,
  `student_id` int NOT NULL,
  `course` varchar(50) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `student_id` (`student_id`),
  CONSTRAINT `enrollments_ibfk_1` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`)
)

其中 CONSTRAINT enrollments_ibfk_1 FOREIGN KEY (student_id) REFERENCES 就是外键的意思。这样确保 enrollments 表中的 student_id 必须来自 students 表中的 idenrollments.student_id 里的值,必须是 students.id 表中已经存在的值。
否则数据库会报错,防止插入无效的数据。

如果我们试图插入一条不存在的 student_id,数据库会拒绝插入:

MySQL root@(none):foreignkey_example1> INSERT INTO enrollments (student_id, course) VALUES (3, '英语');
                                    ->
(1452, 'Cannot add or update a child row: a foreign key constraint fails (`foreignkey_example1`.`enrollments`, CONSTRAINT `enrollments_ibfk_1` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`))')

使用外键的好处有:

  • 数据库帮我们维护数据的完整性,不会存在孤儿数据,不会因为编程错误插入错误数据;
  • 可以实现级联删除,比如 ON DELETE CASCADE,上面的例子中当我们从 students 表删除 id=2 的学生,在 enrollments 表相关的数据也同事会被删除;
  • 清晰的业务逻辑表达,在数据库表定义就有二者的关联关系,在语义上就比较好维护。还有一些数据库工具可以直接根据我们表定义中的 FOREIGN KEY 关系来画出来表之间的关系,在入手一个新的项目的时候,非常有用。
使用 ChartDB 可视化例子中的表关系1

为什么 DBA 不喜欢外键?

很多大公司的数据库都是禁用外键的,FOREIGN KEY (student_id) REFERENCES 这种 DDL 语句执行会直接失败。这样,数据库的表从结构上看不再有关系,每一个表都是独立的表而已,enrollments 表的 student_id Column 只是一个 INT 值,不再和其他的表关联。

为什么要把这个好东西禁用呢?

主要原因是不好维护。修改表结构和运维的时候,因为外键的存在,都会有很多限制。分库分表也不好实现。如果每一个表都是一个单独的表,没有关系,那 DBA 运维起来就方便很多了。

外键也会稍微降低性能。因为每次更新数据的时候,数据库都要去检查外键约束。

退一步讲,其实数据的完整性可以通过业务来保证,级联删除这些东西也做到业务的逻辑代码中。这样看来,使用外键就像是把一部分业务逻辑交给数据库去做了,本质上和存储过程差不多。

所以,互联网公司的数据库一般都是没有 REFERENCES 权限的。

Revoke REFERENCE 权限如下这样操作:

REVOKE REFERENCES ON testdb1_nofk.* FROM 'testuser1'@'localhost';

这样之后,如果在执行 Django migration 的时候,会遇到权限错误:

django.db.utils.OperationalError: (1142, "REFERENCES command denied to user 'testuser1'@'localhost' for table 'testdb1_nofk.django_content_type'")

Django migration 如何不使用外键

在声明 Model 的时候,使用 ForeignKey 要设置 db_constraint=False2。这样在生成的 migration 就不会带外键约束了。

Django migration 如何全局禁用外键

每一个 ForeignKey 都要写这个参数,太繁琐了。况且,Django 会内置一些 table 存储用户和 migration 等信息,对这些内置 table 修改 DDL 比较困难。

Django 的内置 tables:

+-------------------------------+
| Tables_in_test_nofk           |
+-------------------------------+
| auth_group                    |
| auth_group_permissions        |
| auth_permission               |
| auth_user                     |
| auth_user_groups              |
| auth_user_user_permissions    |
| django_admin_log              |
| django_content_type           |
| django_migrations             |
| django_session                |
+-------------------------------+

在 Github 看到一个项目3,发现 Django 的 ORM 里面是有 feature set 声明的,其实,我们只要修改 ORM 的 MySQL 引擎,声明数据库不支持外键,ORM 在生成 DDL 的时候,就不会带有 FOREIGN KEY REFERENCE 了。

核心的原理是继承 Django 的 MySQL 引擎,写自己的引擎,改动内容其实就是一行 supports_foreign_keys = False

具体的方法如下。

新建一个 mysql_engine,位置在 Django 项目的目录下,和其他的 app 平级。这样 mysql_engine 就可以在 Django 项目中 import 了。

├── project_dir
│   ├── __init__.py
│   ├── __pycache__
│   ├── asgi.py
│   ├── settings
│   ├── urls.py
│   └── wsgi.py
├── manage.py
├── app_dir
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── migrations
│   ├── models.py
│   ├── serializers.py
│   ├── tests.py
│   └── views.py
└── mysql_engine
    ├── base.py
    └── features.py

我们要写自己的 mysql engine。为什么不直接使用 django_psdb_engine 项目呢?因为 django_psdb_engine 是继承自 Django 原生的 engine,就无法使用 django_prometheus4 的功能了。ORM 扩展的方式是继承,这就导致如果两个功能都是继承自同一个基类,那么只能在两个功能之间二选一了,或者自己直接基于其中一个功能去实现另一个功能。所以不如链式调用好,如 CoreDNS5 的 plugin,可以包装无限层,接口统一,任意插件可以在之间插拔。Django 自己的 middleware 机制也是这样。

engine 里面主要写两个文件。

base.py

from django_prometheus.db.backends.mysql.base import DatabaseWrapper as MysqlDatabaseWrapper
from .features import DatabaseFeatures


class DatabaseWrapper(MysqlDatabaseWrapper):
    vendor = 'laixintao'
    features_class = DatabaseFeatures

features.py

from django_prometheus.db.backends.mysql.base import (
    DatabaseFeatures as MysqlBaseDatabaseFeatures,
)


class DatabaseFeatures(MysqlBaseDatabaseFeatures):
    supports_foreign_keys = False

最后,在 settings.py 中,直接把 ENGINE 改成自己的这个包 "ENGINE": "mysql_engine"

DATABASES = {
    "default": {
        "ENGINE": "mysql_engine",
        "NAME": "testdb1_nofk",
        "USER": "testuser1",
        'HOST': 'localhost',
        'PORT': '',
        'OPTIONS': {
            'unix_socket': '/tmp/mysql.sock',
        },
    }
}

这样之后就完成了。

python manage.py makemgirations 命令不受影响。

python manage.py migrate 命令现在不会对 ForeignKey 生成 REFERENCE 了。

Django 的 migrate 可以正常执行,即使 Django 内置的 table 也不会带有 REFERENCE。

python3 corelink/manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, meta, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK

查看一个 table 的创建命令:

MySQL root@(none):testdb1_nofk> show create table auth_user_groups \G
***************************[ 1. row ]***************************
Table        | auth_user_groups
Create Table | CREATE TABLE `auth_user_groups` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `user_id` int NOT NULL,
  `group_id` int NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `auth_user_groups_user_id_group_id_94350c0c_uniq` (`user_id`,`group_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

可以确认是没有 REFERENCE 的。

  1. chartdb 工具:https://app.chartdb.io/,其他类似的工具还有很多,比如 https://dbdiagram.io/ ↩
  2. db_constraint=False 文档:https://docs.djangoproject.com/en/5.2/ref/models/fields/#django.db.models.ForeignKey.db_constraint ↩
  3. https://github.com/planetscale/django_psdb_engine ↩
  4. https://github.com/korfuri/django-prometheus ↩
  5. https://www.kawabangga.com/posts/4728 ↩

☑️ ⭐

请求为什么超时了?

小明是一名网络工程师,有一天,同事报告问题说:自己的程序发送 HTTP 请求在测试环境好好的,但是在线上环境就总是超时,而且很容易复现,需要网络工程师的帮助。

这里的场景是,在线上运行环境,去用 HTTP 请求一个第三方(在这个例子中,是 example.com 提供的服务)。

首先,小明和同事一起复现了问题,确定超时确实存在,然后他们在请求发送方进行抓包,在抓包的同时又复现了一次超时的情况。拿到抓包文件,小明一看,立即就发现问题了所在了……

请下载这个文件并分析超时问题的根因。(如果没有头绪,可以打开这个提示

==计算机网络实用技术 目录==

这篇文章是计算机网络实用技术系列文章中的一篇,这个系列正在连载中,我计划用这个系列的文章来分享一些网络抓包分析的实用技术。这些文章都是总结了我的工作经历中遇到的问题,经过精心构造和编写,每个文件附带抓包文件,通过实战来学习网路分析。

如果本文对您有帮助,欢迎扫博客右侧二维码打赏支持,正是订阅者的支持,让我公开写这个系列成为可能,感谢!

没有链接的目录还没有写完,敬请期待……

  1. 序章
  2. 抓包技术以及技巧
  3. 理解网络的分层模型
  4. 数据是如何路由的
  5. 网络问题排查的思路和技巧
  6. 不可以用路由器?答案和解析
  7. 网工闯了什么祸?答案和解析
  8. 延迟增加了多少?答案和解析
  9. 压测的时候 QPS 为什么上不去?答案和解析
  10. 重新认识 TCP 的握手和挥手答案和解析
  11. TCP 下载速度为什么这么慢?答案和解析
  12. 请求为什么超时了?答案和解析
  13. 0.01% 的概率超时问题答案和解析
  14. 后记:学习网络的一点经验分享
与本博客的其他页面不同,本页面使用 署名-非商业性使用-禁止演绎 4.0 国际 协议。
☑️ ⭐

用 LD_PRELOAD 写魔法程序

我和我的同事们排查网路问题非常喜欢 MTR,它是 traceroute 和 ping 的结合,可以快速告诉我们一个网络包的路径。是哪一跳丢包,或者延迟太高。

这些路径使用 IP 地址的形式表示的。没有人能记住这么多 IP 地址,所以我们需要有意义的名字。我在公司里写了一个平台,集成了其他的二十多个系统,给一个 IP,能查询出来这个 IP 对应的网络设备,容器,物理机,虚拟机等等。

复制 IP 到这个系统中查看结果,还是有些不方便,于是就想能不能让 MTR 直接展示设备的名字。

最终效果如图,可以展示 mtr 路径中所有的网络设备的名字。敏感信息已经隐藏。

MTR 支持 DNS PTR 反查,如果查到记录,会优先展示名字。这些名字在公网上通常没有什么意义。我们的内网 DNS 没有支持 PTR,所以这个 PTR 记录在默认的情况下也没有什么用。如果通过 DNS 系统来支持 PTR 记录的话,成本就有些大了,得对 DNS 做一些改造,DNS 又是一个比较重要的系统。那能不能有一个影响比较小的旁路系统来做到这个 feature 呢?

看了下 MTR 的代码,MTR 对 DNS PTR 的支持是通过 libc 的函数 getnameinfo(3) 来实现的。那么我就可以用 LD_PRELOAD 这个 hack,自己写一个 getnameinfo(3) 编译成 so,告诉 MTR 在寻找 getnameinfo(3) 的时候,先寻找我的 so 文件。这样,我就可以自己定义 getnameinfo(3) 的行为了。(就像魔法一样)

其实,proxychains1 程序也是用这种方式工作的,你只要在运行的命令前面添加 proxychains,proxychains 就会对后面运行的命令注入 LD_PRELOAD 环境变量,从而让程序调用的 socket API 是 proxychains 定义的,然后 proxychains 就会对 socket 做一些代理转发。

POC

可以写一个最简单的程序验证这样是否可行。

我们写一个最简单的函数,声明和 getnameinfo(3) 一模一样。

#include <stdio.h>
#include <netdb.h>
#include <string.h>
#include <sys/socket.h>

int getnameinfo(const struct sockaddr *__restrict addr, socklen_t addrlen,
                char *__restrict host, socklen_t hostlen,
                char *__restrict serv, socklen_t servlen, int flags) {

    strncpy(host, "kawabangga.com", hostlen);

    return 0;
}

不过,这个函数无论对于什么 ip,都会返回 kawabangga.com. 然后编译,运行 traceroute 程序。

编译命令:gcc -shared -fPIC -o libmylib.so mylib.c -ldl

$ LD_PRELOAD=./libmylib.so traceroute 1.1.1.1
traceroute to 1.1.1.1 (kawabangga.com), 30 hops max, 60 byte packets
 1  kawabangga.com (kawabangga.com)  0.248 ms  0.386 ms  0.360 ms
 2  kawabangga.com (kawabangga.com)  5.223 ms  5.412 ms  5.375 ms
 3  kawabangga.com (kawabangga.com)  8.764 ms  9.206 ms  10.454 ms
 4  kawabangga.com (kawabangga.com)  10.403 ms  10.001 ms  10.321 ms
 5  kawabangga.com (kawabangga.com)  11.578 ms  11.826 ms kawabangga.com (kawabangga.com)  13.214 ms
 6  kawabangga.com (kawabangga.com)  12.858 ms kawabangga.com (kawabangga.com)  14.859 ms kawabangga.com (kawabangga.com)  13.905 ms
 7  kawabangga.com (kawabangga.com)  13.204 ms  8.645 ms kawabangga.com (kawabangga.com)  10.036 ms
 8  kawabangga.com (kawabangga.com)  10.166 ms  8.282 ms  9.155 ms
 9  kawabangga.com (kawabangga.com)  7.035 ms kawabangga.com (kawabangga.com)  8.533 ms kawabangga.com (kawabangga.com)  21.939 ms
10  kawabangga.com (kawabangga.com)  8.789 ms  7.832 ms  8.259 ms

可以看到,traceroute 显示每一跳的名字都是 kawabangga.com 了。

用 Go 语言 POC

我比较倾向于用 Go 语言来实现逻辑,而不是用 C 语言。

Go 语言也是支持编译到 shared lib 的2。hello world 代码如下:

/*
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>

*/
import "C"
import (
        "unsafe"
)

//export getnameinfo
func getnameinfo(sa *C.struct_sockaddr, salen C.socklen_t, host *C.char, hostlen C.size_t, serv *C.char, servlen C.size_t, flags C.int) C.int {
        hostStr := "foobar"

        hostCString := C.CString(hostStr)
        defer C.free(unsafe.Pointer(hostCString))
        C.strncpy(host, hostCString, hostlen)

        servStr := "80"
        servCString := C.CString(servStr)
        defer C.free(unsafe.Pointer(servCString))
        C.strncpy(serv, servCString, servlen)

        return C.int(0)
}

func main() {}

编译命令是:go build -o libmylib.so -buildmode=c-shared mylib.go

程序运行的命令一样,也可以看到 getnameinfo(3) 被成功 hook 了。

剩下的只需要在程序里面写逻辑代码就可以了,应该很简单(实际发现不简单)。

遇到问题 1: log 不打印

因为 traceroute 和 mtr 这种程序都是往 stdout 打印的,我开发代码又需要用 print 来调试,所以为了不干扰正常输出,就把日志打印到一个文件,通过 ENV 来控制日志是否需要打印,以及打印的日志路径。

结果就遇到了问题:日志打印在 traceroute 中是正常的,但是在 mtr 中看不到日志。

因为程序是「寄生」在 mtr 的代码中的,而且在 traceroute 中没有问题,所以应该和 mtr 的代码有关。

去看了一下代码,发现 mtr 和 traceroute 不一样的地方是:mtr 是用了异步的方式来执行 getnameinfo 函数,因为这个函数可能使用 DNS PTR 记录,涉及到网络请求,耗时可能很长。所以在调用的时候,mtr 会 fork 一个进程,专门执行这个函数。fork 出来的进程使用 PIPE 和主进程通信,并且 fork 之后就把除了 stdin, stdout, stderr 和 PIPE 之外的 fd 都关闭了。跟着关闭的,也包括我们的日志文件 fd。

解决办法就是修改了一下程序,不写日志到文件了,而是写到 stderr。在 debug 的时候,就 mtr 2>/tmp/stderr.log 这样,就可以了。

问题2: Golang 程序卡住

之前的 POC 代码运行正常,我把它改成通过 HTTP 请求 IP 信息服务的时候,居然就出问题了。mtr 显示的是 IP 而不是名字。从现象看,是函数执行失败了。

但是失败在哪里呢?

经过一段时间的排查,发现了这么几个现象:

  1. 每次程序运行之后,都有一些 mtr 进程残留在系统中没有结束;
  2. traceroute 还是正常的,但是 mtr 每次都会出问题;
  3. 不断加 print 来 debug,发现程序的问题出现在发送 HTTP 请求的地方,但是把这个地方的代码改成直接返回固定字符串,程序就正常了;

使用 gdb 去 debug,backtrace 如下,也看不出什么信息。

Golang 程序的 backtrace

看起来这个线程好像没有什么事情可以做。

花了一天排查无果,问了朋友,最后发现这个问题:Goroutines cause deadlocks after fork() when run in shared library #155383,而且开发人员的回复是:This is to be expected. It’s almost impossible for multithreaded Go runtime to handle arbitrary forks.

而 mtr 正好执行了 fork,所以这算是一个 Golang 的 runtime 问题——如果以 shared-lib 的方式运行,那么主程序是不能 fork 的,如果 fork,Go runtime 中的 goroutine 管理与多线程模型,fork 后线程状态的不一致可能会导致无法正常恢复,从而触发死锁。

最后,我把程序的逻辑用 C 语言实现了一下,就没有问题了。把它打包成 deb 包发布到了内网中。打包推荐用 nfpm4,非常方便,传统的用 apt 工具链打包太复杂了。

  1. Proxychains 项目:https://github.com/rofl0r/proxychains-ng,我的博客:编译安装proxychains4 ↩
  2. Fun building shared libraries in Go, https://medium.com/@walkert/fun-building-shared-libraries-in-go-639500a6a669 ↩
  3. https://github.com/golang/go/issues/15538 ↩
  4. https://github.com/goreleaser/nfpm ↩
☑️ ⭐

Keepalived 脑裂问题排查

用户报告问题,说在虚拟机里面启动的 Keepalived 有脑裂问题,一启动就开始脑裂了。

Keepalived1 是一个基于 LVS 的负载均衡和高可用框架。负载均衡主要是通过 VRRPv2 协议来实现的。VRRP 协议2在这个博客中介绍过,主要场景是两个路由器可以通过 VRRP 协议来协商出来一个 master,对外提供服务,当 master 挂的时候,slave 会成为 master 继续提供服务。

VRRP 的全称是 Virtual Router Redundancy Protocol,Keepalived 可不是 router,为什么用这个协议呢3?简单来说,Keepalived 要提供的是一个高可用的 VIP 服务,而 Virtual Router 本质上也是一个 VIP 来给其他的 Host 当作网关,这样一想,就很合理了。

Keepalived 实例之间无法达成一致,肯定是 VRRP 协商失败,而 VRRP 又是机器简单的协议,只有一种包的类型。出现 2 个 master 节点,那就肯定是 slave 的节点收不到 master 的 VRRP 协议包,认为 master 挂了,所以站出来当 master。

首先,用 tcpdump 检查了一下两个 keepalived,确实都在发送 VRRP 的包。而且VRRP 包的内容,如 auth,组 id,权重等,都正确。并且都只看得到自己发送出去的包,无法收到对方的包。

那接下来就来排查为什么这个包无法发送给 VRRP 的 slave。

虚拟机环境的网络简化如下。

虚拟机环境的网络架构

这个图看起来复杂,其实逻辑很简单。物理机 Host 使用 bonding 连接交换机,在 bond0 接口上配置 VLAN 封装,和交换机做 trunking,这一层对物理机内的网络使用几乎是透明的。然后虚拟机内网络使用 TAP4,所有 VM 内 eth0 发送的包会出现在 macvtap1 上,然后从 Host 的 bond0.1000 出去。 macvtap 其实就是 TAP 设备加上一个 bridge5,macvtap 基于 macvlan driver,使用一个 module 解决了原来 TAP + Bridge 的工作。

下一步就是定位 VRRP 包丢在了哪里,虽然涉及的网络 interface 很多,但是一个一个来排查就行了。首先给这个网络做了个简单的体检套餐,发现 ICMP 还是 TCP 都是一点问题没有,只有 VRRP 协议有问题。VRRP 协议是基于组播 Multicast 的,直觉上觉得可能是什么 ACL 把组播网络给 DROP 了。

从 Keepalived 发包的实例开始抓包,下一步直接抓发包的物理机接口 eth0 (二分查找定位么),确认 VRRP 正确发出去了,然后去收包的物理机上抓 eth0,也收到了,直接排除了交换机的问题。再抓 bond0.1000 接口,也抓得到,排除了 bonding 和 VLAN 问题。

下一步是 macvtap1@bond0.1000,也抓得到,然后去收包的 VM2 抓包 eth0,抓不到。那丢包就发生在 macvtap1 -> eth0 中。感觉已经接近真相了!

实际上并没有……

定位到这里之后,后面花了几个小时来研究为什么 macvtap1 的包没有转发到 eth0 中。

期间研究了 macvtap 设备的原理,libvirt 相关的文档,中间还有 chatGPT 这个半吊子瞎出主意,甚至把相关的 sysctl 参数都看了一遍,rp_filterxx_forwarding 之类的,仍然没有解决问题。这些没有用的排查就不记录了。

物理机的 macvtap 的 TAP 设备是由 qemu libvirt 接管的,负责转发到虚拟机中去。在研究 libvirt 和 multicast 流量的时候,发现了这个问题和回答6,虽然没有直接解决我的问题,但是感觉脑中一道闪电划过,有一个重要的本质问题被我忽略了——VRRP 是组播网络

单播是点对点发,广播是点对所有点群发,组播的特点是点对多点群发,「多点」指的是哪些点呢?怎么知道多点包括了哪些点呢?本质的原理是「订阅」,订阅了特定流量则会收到,如果不订阅,则收不到。

在网络上,多个设备之间订阅组播是通过 IGMP 协议7实现的。在同一个设备上,就是设备自己的实现了。

有了这个想法之后,我马上在 VM 中和 Host 中运行了 ip maddr show dev 命令进行验证。结果如下。

在 VM 中的运行结果
在 Host 中的运行结果

这条命令的含义是,列出来当前这个接口订阅的组播流量。通过结果可以看到,在 VM 中订阅了 VRRP 协议规定的组播地址,但是在物理机的 macvtap 接口上,就没有订阅这个地址。所以物理机的接口在收到发给 224.0.0.1 的 VRRP 流量之后,会认为当前这个接口没有订阅过这个组播,所以不需要这些流量,直接忽略。这是组播协议预期的工作方式,所以当我在排查接口的丢包参数的时候,都没有发现什么异常,因为这不算做是丢包吧,而算作是正常的处理方式。

那么为什么会出现这种情况呢?

按照组播协议的工作方式,当需要组播流量的时候,需要向操作系统通过 syscall 来发出「订阅」8,因为协议是由操作系统来处理的。但是我们这里存在 2 个操作系统,VM 是一个操作系统,Host 是一个操作系统。如上图所示,虽然 VM 知道订阅了这个组播地址,但是 Host 操作系统并不知情,两个系统是隔离的。所以当 Host 收到组播流量的时候,直接忽略了。

解决办法是,对接口设置 ip link set dev macvtap8 allmulticast on,意思是告诉接口,把所有的 multicast 都给收了,这样 VM 内的接口决定处理还是忽略,就正常了。(libvirt 也有 trustGuestRxFilters9 的配置选项)

VRRP 脑裂问题需要避免,在物理网络中,网关之间的 vrrp keepalive 会使用专用的 keepalive 线路,并且多条物理线路做 LACP 高可用。

  1. https://keepalived.readthedocs.io/en/latest/introduction.html ↩
  2. 数据中心网络高可用技术之从服务器到网关:首跳冗余协议 VRRP ↩
  3. Keepalived 的系统设计:https://keepalived.readthedocs.io/en/latest/software_design.html ↩
  4. TAP 设备在 VPN 和虚拟机网络中比较常见:https://en.wikipedia.org/wiki/TUN/TAP ↩
  5. https://virt.kernelnewbies.org/MacVTap ↩
  6. https://superuser.com/questions/944678/how-to-configure-macvtap-to-let-it-pass-multicast-packet-correctly ↩
  7. https://en.wikipedia.org/wiki/Internet_Group_Management_Protocol ↩
  8. 如何使用组播的教程 https://tldp.org/HOWTO/Multicast-HOWTO-6.html ↩
  9. https://libvirt.org/formatnetwork.html ↩
☑️ ⭐

菠萝

体检的诊所送了我们两张 Toast Box 的券,Toast Box 和 BreadTalk (面包新语)是一家,是专卖吐司的新加坡经典早餐店。

新加坡注重效率,不像澳大利亚,买一杯咖啡都要和你寒暄一下今天怎么样,打算做什么。一般来说,早餐店的店员甚至都不会说出一个没有用的字。而且不管你是不是外国人,都只会用本地的语言要你点餐,也不会跟你解释。

但我可是来了新加坡 4 年1了,已经身经百战。

店员看了看券,说,”Coffee?”

“Kopi and Kopi C Kosong, Shao”,我已经把南洋咖啡的逻辑熟记于心,难不倒我,甚至还帮欣点好了。

南洋咖啡的逻辑

店员说,”Toast?”

我点了个最贵的,店员说这个不能用券。

我问能点啥。

“Kaya, Butter, Bo Luo.”

Kaya 齁甜,我点了个 Butter。但是 Bo Luo 是什么玩意?我想,哪有人在吐司上放菠萝的,难道美国人的夏威夷披萨启发了新加坡人吗?这也能吃?虽然很不可思议,但是麦当劳在新加坡推出了菠萝汉堡,如果菠萝汉堡能合法,那么菠萝吐司应该也不奇怪了吧。不过,真的会有点这种东西吗?

这样想着,欣说,”Bo Luo”.

我震惊,她是怎么想的。

在等餐的时候,我还在做着思想斗争,这菠萝会酸吗?菠萝是热过的还是鲜菠萝?早上吃这玩意胃能受的了吗?

漫长的两分钟过去了,Kaya 和 Bo Luo 好了。

我长舒了一口气——“太好了,是菠萝包。”

“你以为呢?” 欣说。

麦当劳的菠萝汉堡
  1. 十六个夏天(为什么会有人在这种文章用上脚注?) ↩
☑️ ⭐

Docker 命令行小技巧:runlike

事情要从上周的一次事故说起,我们用 docker 部署的程序有一点问题,要马上回滚到上一个版本。

这个 docker 是一个比较复杂的和 BPF 有关的程序,启动时候需要设置很多 mount 和 environments,docker run 的命令特别长。所以我用 Ansible 来配置好这些变量,然后启动 docker,一个实例要花费 3~5 分钟才能启动。

同事突然说,某实例他手动启动了,当时我就震惊了,怎么手速这么快?!

请教了一下,原来是用的 runlike 工具,项目地址是:https://github.com/lavie/runlike

这个工具的原理是,docker inspect <container-name> | runlike --stdin ,就会生成这个容器的 docker run 命令。这个思路简直太棒了。就和 Chrome 的 copy as cURL 功能一样好用!

☑️ ⭐

钟表

一首小诗~

钟表店里摆满了各种各样的钟表
每一个的时间都不一样
顾客问
怎么商品的时间不对也不调?
店主说
我这里每一个钟表呀
在世界上都有一个属于它的角落
它的时间是对的

☑️ ⭐

数据中心网络高可用技术之从交换机到交换机:MLAG, 堆叠技术

上一篇文章结束对链路聚合的讨论之后,我们发现一个问题——我们只能用多条线连接到同一个 switch 上面,这样万一这个交换机挂了,连接这个这个交换机的机器(通常是一个 Rack)就一起消失了。

MLAG 技术

MLAG(Multi-Chassis Link Aggregation) 可以提供跨设备的链路聚合功能。

思科模块化交换机

Multi-Chassis 这个词很有意思,为什么叫做 Chassis (机箱)而不叫座 Multi-Switch LAG 呢?因为这个功能不仅仅是运行在 Switch 上,还记得在理解网络的分层模型中说的吗?二层和三层设备的界限已经越来越模糊了,三层设备也可以有这个功能。

现在的网络设备都是模块化的,一个机箱上面可以根据自己的需求插不同的线卡,卡上面甚至还能装不同的模块,满足不同的需求。机箱可以认为是网络设备单元,以「机箱」来说,意思就是运行于不同网络设备之间的功能。

MLAG 是一个设备的 feature,而不是协议,所以在不同厂商的产品中,MLAG,Peer Link 等术语会有所不同。

多交换机 MLAG

对于客户端 Linux 来说,不知道对端是两个设备,在 Linux 的视角下,自己的多条线路连接的就是同一个设备。

在交换机侧,就需要跨设备完成 LACP 信息的同步,两个交换机设备之间需要协调好,A 的 3号端口 和 B 的 3号端口是同一个链路聚合组(LAG)。所以两个交换机之间需要一条线,来沟通控制面的信息。这条线就叫做 Peer Link。在 Peer Link 上如何传输控制信息,取决于不同厂商对于 MLAG 的实现。某些厂商的设备使用 ICCP (Inter-Control Center Communications Protocol) 来进行不同的机箱之间的连接。

这样,就可以完成从服务器到交换机的高可用了,服务器网卡、网线、交换机接口、交换机系统、交换机整机,任意一个地方出现问题,都有冗余路线,不会对服务造成太大影响。

数据中心广泛使用的就是 MLAG。

堆叠技术

在交换机之间的高可用,还有一种技术,就是交换机堆叠。这个功能在不同的厂商里也是不同的名字,比如华为的 istack,思科的 StackWise.

简单来说,这个功能可以让多个交换机虚拟成一个。只有一个主操作系统在运行,其他的交换机就像主交换机扩展出来的板卡一样。堆叠之后只有一个管理 IP 和 MAC 地址,只需要登录一个系统进行配置操作。

4个交换机完成堆叠之后,相当于一个交换机有了 4 倍的端口

堆叠之后逻辑上就是一个交换机,所以服务器可以直接连接到多个物理交换机,从逻辑上看,交换机侧也是同一个了。

交换机堆叠连接服务器

堆叠也能实现故障快速切换,在正常情况下也能充分利用线路的带宽,配置简单。但是和 MLAG 相比,稳定性上来说 MLAG 更高,因为堆叠交换机只有一个控制面,如果主交换机出现故障,比如堆叠失效,整个堆叠集群都会出错。MLAG 的故障域更小,交换机坏也就坏一台。这也带来很多维护便利,从升级维护上说,MLAG 可以让我们一台一台地操作交换机升级而不影响服务,堆叠就会更加麻烦一些。从部署上,MLAG 不受距离显示,堆叠的话,两个交换机距离越远,出错的概率越大。

说起来,思科还有一个 VDC 技术(Virtual Device Context), 支持把一个设备虚拟成多个。这些技术又是把一个物理设备拆成多个又是把多个合成一个的,可真有意思。

这个系列的二层技术介绍的差不多了,我们下一篇就开始聊三层技术。

Until next time!

数据中心网络高可用技术系列

  1. 数据中心网络高可用技术:序
  2. 数据中心网络高可用技术之从服务器到交换机:active-backup
  3. 数据中心网络高可用技术之从服务器到交换机:balance-tlb 和 balance-alb
  4. 数据中心网络高可用技术之从服务器到交换机:链路聚合 (balance-xor, balance-rr, broadcast)
  5. 数据中心网络高可用技术之从服务器到交换机:802.3 ad
  6. 数据中心网络高可用技术之从交换机到交换机:MLAG, 堆叠技术
  7. 数据中心网络高可用技术之从服务器到网关:VRRP
☑️ ⭐

数据中心网络高可用技术:序

数据中心的网络和家用网络有很大不同,家用网络一个小路由器就够了,挂了的话,就忍受一下没有网络的时间,然后去网上下单再买一个换上。数据中心可不行,所有的东西都要设计成高可用的。

这篇文章来讲讲在数据中心网络用到的技术,主要是和高可用相关的。

本文谈到的网络,重点是3层及以下。因为 4 层是端对端的,如今主要是在服务端软件进行高可用设计,我们在四层负载均衡漫谈里讨论了这些技术并且分析了很多案例。

意义和必要性

高可用指的是,任何一个组件坏了,都不影响其他的服务。比如说,服务器的电源坏了,还要能继续提供服务。怎么做到呢?再加一个电源。高可用的本质就是加机器(冗余硬件),笑。

现在的软件都是高可用设计了,无状态的可以随意扩展,有状态的比如 Etcd 可以部署多个实例,实例之间自己选举,自动 Failover,是不是对机器的可用性要求就低了?

假设一台服务器运行1年不出问题的概率是 99%,那么如果部署一万台服务器的话,1年之内就会有 1% * 10000 = 100 台会出现问题。如果部署的足够多,那么小概率发生的事情在绝对数量上就不会小。如果机器的可用性不够高,那么即使像 Etcd 3 副本部署,同时有 2 台出现故障的概率也会变大。而且,同一个批次的硬件很可能在大约同一时间出现故障,我就见过多次 RAID 里同时坏两块盘的情况。

高可用设计对维护操作也很友好。为了不剧透网络部分好玩的内容,我们还是拿电源举例。现在的服务器大部分是 Dual PSU (Dual Power supply unit,双电源)设计,支持热插拔。如果一个电源坏了,另一个直接继续提供服务,技术人员可以拔掉坏的,换上新的,不需要停机时间。否则的话就麻烦了,需要联系机器负责人,机器负责人联系上面运行的进程负责人,大家开始迁移服务,然后才能操作。

像双电源的设计还可以缩小故障影响范围,比如两个电源单位,分别去连接两条电线。假设一个强电线路挂了,就和电源单位挂了一样,另一个电源直接上岗。如果没有的话,可能大批的服务器就下线了。

说到底,最重要的是交付的服务质量。对于数据中心来说,交付的服务就是机器,提高服务质量就是最大程度提高机器的可用性。向客户提供服务的时候,尽最大努力提高服务的质量;使用别人的服务的时候,要假设别人的服务可能挂掉。所以 PaaS 平台使用这些机器,要假设机器会挂,使用其他技术手段提高 PaaS 的可用性,提供给别人用;SaaS 使用 PaaS 的时候,也假设 PaaS 也可能会挂,通过技术提高自己软件服务的可用性。

所以在数据中心中,机器都尽可能设计成了高可用的:硬盘用 RAID 做容器,电源做冗余,网络做冗余。这个博客使用的虚拟机,已经 1117 天没有关机了,可用性非常高。

kawabangga.com 所在的服务器运行时间

这个系列文章就来谈谈网络是怎么实现高可用的。比如,从服务器到交换机的链路如何做到高可用,从交换机到交换机,从服务器到网关路由器,等等。

明确一下网络高可用的目标。服务器最终的目标是提供服务,服务通过应用层网络协议提供出去:HTTP,webRTC 等等。这些协议都是基于 TCP 或者 UDP,由于四层的高可用技术主要是四层负载均衡解决了,四层负载均衡一般使用的是商用服务器,在这些服务器前面的网络设备主要工作在二层和三层,对于这些设备来说,四层网络就是这些设备要传输的数据。这些设备要做的就是:确保四层数据的传输是高可用的,或者说,尽量在三层 IP 协议上保证可用性,这也是 IP 协议的设计:Best effort.

最后我们这样总结一下这些技术的最终目标:在服务器上 ping 一个互联网 IP,中间的硬件无论哪一个挂了,高可用的网络设计都要保证能够继续 ping 的通(中间可能会有短暂的丢包)。

基础知识回顾

在讨论这些好玩的技术之前,我们先重新认识一下机房中的设备,和这些设备的工作方式。和网络相关的,主要就是服务器、交换机、和路由器了。

服务器

服务器是最常见的终端设备了,也是开发者最熟悉的设备。它没有网络的转发功能,是其他网络设备的主要用户。

在讨论服务器的时候,我们要从两个方面分开讨论:

  1. 发出流量,又叫 Transmit, outbound;
  2. 接收流量,又叫 Receive, inbound;

流量都是通过网络单元的形式发送出去的,在三层叫做包,二层叫做帧。服务器拿 Linux 来距离,在发送数据的时候,无论是 TCP 还是 UDP,都是先封装成三层数据包,再封装成二层数据帧,最后通过物理层让网卡发送数据。不是所有的数据都是基于三层协议发送的,但是数据一定是通过二层协议发送出去的,因为二层是连接物理层的电信号和数字信号的地方。数据中心的二层协议一般就是 Ethernet 了。

数据封装成三层包很简单,因为应用发送数据的时候已经指定了 IP 地址,kernel 根据这些信息封装添加 I P header 就完成了。

封装成二层也很简单,主要是添加二层 Header 和计算 CRC。Header 中最重要的是放上自己的 MAC 地址和目标的 MAC 地址。怎么知道目标 MAC 地址应该写啥呢?首先根据要发送的目标 IP 和自己的子网判断目标 IP 是否和自己在同一个子网,如果在同一个子网,那么目标 MAC 地址就写目标 IP 的 MAC 地址;如果不在同一个子网,就写默认网关 (default gateway) 的 MAC 地址。那又怎么知道目标 IP 或者默认网关的 MAC 地址是什么呢?机器本地是没有办法知道的,所以要通过网络协议询问,即 ARP 协议。

想知道一个 IP 对应的 MAC 地址,就设置一个问题,问「谁有 IP xxx 的 MAC 地址?如果知道,请回复给 IP yyy,yyy 的 MAC 地址是 B。」前面提到 ARP 也是基于二层的,我们就得把这个问题封装到二层中,来源 MAC 就是自己的 MAC,B,目标 MAC 地址是 FF:FF:FF:FF:FF:FF,即广播给所有的人。这时候所有人会收到这个 ARP 问题,但是只有 IP 是 xxx 的会回复,告知其 MAC 地址。其他人虽然不回复,但是也会收到这个 ARP 问题,通过这个 ARP 问题,所有的人都知道了:「哦,虽然不需要我回答,但是我也获得了 yyy 的 MAC 地址,我先记下来,说不定以后用得到呢。」

通过 ARP 协议拿到了 MAC 地址,就可以完成二层的封装了,最后通过物理层发送出去。ARP 协议的目的就是找到 IP 和 MAC 地址的对应,这个记录会在机器上缓存一段时间,减少 ARP 查询量,也能减少 ARP 带来的延迟。

接收流量的行为很简单,网卡从物理层收到网络数据,解析成二层数据帧,判断目标 MAC 地址是否是发给我的,如果不是就丢弃,如果是,就把数据交给 kernel。如果是广播报,就根据协议判断是否需要回复,如上面提到的 ARP。

交换机

交换机和路由器都是流量转发设备,对它们来说,流量就没有发出和接收的区别了,它们的工作都是将收到的网络包转发出去。交换机工作在二层,路由器工作在三层。

交换机的转发逻辑很简单,它只看二层的 header 来完成转发。交换机有一个 MAC address table,是 MAC 地址和交换机端口的对应,交换机启动的时候 MAC address table 是空的,也不需要配置和导入数据,交换机可以自己「学习」。收到一个二层数据帧,交换机做的事情如下:

  1. 把来源 MAC 地址和来源端口号记录到 MAC address table 中,这就是交换机的「学习」过程,它从每一个收到的包进行学习;
  2. 根据目标 MAC 查找自己的 MAC address table;
  3. 如果找到目标 MAC 对应的端口号,就从这个端口把数据转发出去;
  4. 如果找不到,就转发到除了来源端口之外的其他端口(参考上文服务器收到帧的操作,如果不是发给自己的,会丢弃);
交换机通过收到的包学习 MAC 地址和端口的对应关系

MAC 地址表中,一个 MAC 地址只能对应唯一一个端口,不能对应多个端口。即,表示这个 MAC 转发到这个端口;但是一个端口可以对应多个 MAC 地址,或者说,多个 MAC 地址可以对应到同一个端口,因为一个端口后面不仅仅可以是服务器,也可以是交换机。

路由器

路由器工作在三层,但是三层必须要依靠二层承载才能工作,所以路由器里面也有一个二层实现。在每次收发数据包的时候,它的工作方式是和服务器一样的,都是通过 ARP 询问 MAC 地址,然后把要发送的三层包添加二层 header(主要是 MAC 地址),发送出去。对于交换机的视角来说,路由器和服务器一样,都是它的「客户」。

在转发数据包的时候,路由器每次拿到一个 IP 包,就检查自己的路由表,找到一个出口端口,然后从这个端口发送出去。

它和交换机一样,主要的工作是「转发」,只不过交换机参考的是 MAC 与端口的对应表,路由器参考的是 IP 与端口的对应表。

路由表怎么来呢?路由器有两个「面」:控制面和数据面。控制面负责生成路由表,数据面负责将进来的包经过查找路由表转发出去。

路由表有两种方式生成,一种是静态的配置。其实 Linux 服务器也有一个路由表,通过 ip route 命令可以看,只不过里面的项目很少,最重要的是默认网关,默认网关一般使用静态配置。但是对于路由器来说,端口太多,只有一个默认网关是不够的,在数据中心中,基本上不使用静态配置的方式。

另一种就是动态协商,比如 EIGRP,BGP,OSPF 等等。简单来说,就是路由器之间互相交换自己能够连接到的子网的信息,路由器根据这些信息生成路由表。

讲了这么多无聊的东西,我们终于可以开始讨论有趣的高可用方案了。不过无聊归无聊,后面用到的技术其实就是对这些基础行为的巧妙利用(或者说,滥用?)。所以复习一下还是有必要的。

下一篇文章,我们从分析如何让服务器到交换机的连接做到高可用开始讨论。

数据中心网络高可用技术系列

  1. 数据中心网络高可用技术:序
  2. 数据中心网络高可用技术之从服务器到交换机:active-backup
  3. 数据中心网络高可用技术之从服务器到交换机:balance-tlb 和 balance-alb
  4. 数据中心网络高可用技术之从服务器到交换机:链路聚合 (balance-xor, balance-rr, broadcast)
  5. 数据中心网络高可用技术之从服务器到交换机:802.3 ad
  6. 数据中心网络高可用技术之从交换机到交换机:MLAG, 堆叠技术
  7. 数据中心网络高可用技术之从服务器到网关:VRRP
☑️ ⭐

Hot Potato Routing

Hot Potato Routing——烫手的山芋。指的是 ISP 在路由转发的时候,不会选择最优的路线,而是会选择最快能把这个包转出自己的自治域的路线。

如下图的路由中,假设有 3 个 ISP,分别控制 3 个自治系统AS(Autonomous System)。如果有一个包要从 A 转发到 X,那么在 AS1 中,A 路由器将会把包转发给 B,B 如果采用 Hot Potato Routing,将会直接把包转发给 D,完整的路线是 B-D-G-F-X,图中蓝色线路所示。而实际上最短的路径应该是 A-B-E-F-X(假设途中所有线路的 metric 相等,我们只看跳数)。

Hot Potato Routing例子,从 A 到 X 会走 A-B-D-G-F-X 路线

B 路由器之所以这样做,是因为在运营商的网络里,质量是第二要考虑的内容,Policy 才是第一要考虑的。路由器 B 已经知道全局最优路线是 E,但是如果选择全局最优路线,那么就需要经过 AS1 的另一台路由器 E,即 B-E 要转发这个包,但是如果直接丢给 D,E 就不用做转发了,这样,AS1 的工作就最少。

维基百科中说,Hot Potato Routing 是大部分运营商的路由策略。Hot-potato routing (or “closest exit routing”) is the normal behavior generally employed by most ISPs.

每个人都在用最自私的策略来降低自己的工作量,但是却让全局的工作量增加了,自己的工作量实际上也是增加了的,整体的服务质量却降低了——用户的请求需要绕得更远才能到达目标。

这不就是很多大公司的工作方式吗?

大公司给每个人规定了 KPI(就不提 OKR 了,OKR 在大部分公司的实践其实就是 KPI)。收到新的任务,每个人做的最优选择就是把和 KPI 不相关的工作「转发」出去。

刚加入蚂蚁金服的时候,带我的师兄让我找 A 同事要一个测试环境,来熟悉我要接手的业务。于是我去找 A,A 让我找 B,B 让我找 C——一直找了 6 个人,给我一点小小的震撼,我上家公司技术人员才不过 20 人,现在入职 1 天就认识了 6 个人,不愧是大公司。最神奇的是,最后一个人也没给我答案,而是让我找第一个人 A,居然出现环路了。

在蚂蚁的时光,有一半是在「闭关室」度过的。「闭关」也是一个我第一次经历、现在想想很可笑的事情。「闭关」就是一个团队不在原来的工位办公了,大家都搬到一个会议室里面工作,会议室预定几个月。美名为「项目攻关」。别人过来问问题,提需求,我们都可以说「我们在闭关,需要专心做我们的项目。」但是我们的工作也会遇到问题,需要其他的团队配合,于是我们去找其他团队合作,其他团队也是「不好意思,我们在闭关。」最后就成了你也在闭关,他也在闭关,大家都在闭关。

我觉得最理想的工作方式是像 DNS Recursive Resolver 一样。有人来问我问题,我应该给他解答。如果我不知道答案,那么我应该去问知道答案的人,得到答案告诉原来的提问者,这样一来我也知道了答案,下次如果有人来问,我就可以直接回答了,就像 DNS Recursive Resolver 的缓存一样。如果比较忙,我没有时间去问下一个人,应该拉一个群,加上提问者和能解决问题的人,由来解释一下问题和背景。因为我经过和提问者的沟通,多少理解需求和背景了,很多提问者无法一次性把我们需要的背景解释清楚,我现在如果在群里一次性说明背景,就可以减少提问者和其他人的沟通成本。如果每个人都这么做的话,那么所有的问题都可以用最快的方式解决,整体花费的工作量就可以变小。

但是实际是行不通的,因为公司给每个人都安排好了 KPI。这些事情不在 KPI 里面,做这些工作实际上是不被绩效系统承认的,每个人要专注于完成自己的 KPI。更糟糕的是,如果做一个糟糕的「转发者」,一问三不知只会转发,那么收到的请求就越来越少,别人知道,问你不会得到答案,下次就不会问了。相反,如果这次别人问你得到了满意的答案,那么下次他还会来问你,每次都会来问你,收到的问题会越来越多,提问者甚至会变成「转发者」,他知道你能回答某些方面的问题,那么别人来问他的时候,他会立即转发给你。

也许 Hot Potato Routing 才是在大公司的生存之道,只有在小而美的公司才能做到用不自私的工作方式来工作。当初那些有理想的前同事,现在几乎都已经离职了(当然,没有离职的也有有理想的)。

☑️ ⭐

四层负载均衡分析:GitHub GLB

今天我们来赏析 GitHub 的四层 LB 设计。

GitHub 的 GLB 是开源产品(当然了!),从架构上看,和之前介绍的 Cloudflare Unimog 很像,因为 GLB 是 Unimog 实现的重要参考。后面还会介绍一个叫做 Beamer 的四层负载均衡,也是参考了 GLB。所以 GLB 的连接保持设计创新性很强。

GLB 技术总览

被借鉴最多的就是 GLB 的连接保持技术,所以我们直接从最精彩的开始讨论。

连接保持技术

Cloudflare Unimog 的连接保持方案和 GitHub GLB 几乎一致,所以我们在之前几乎都已经讨论过了。

简单总结一下:GLB 作为四层负载均衡,在 GLB 实例之间不需要同步任何信息。在转发的时候,每一个 GLB 根据 TCP 连接五元组 hash,独立作出决策,选中一个 RS 进行转发。

转发的过程是:

  1. GLB 收到一个包,根据包的五元组计算 hash(不管是不是 SYN,都一样对待);
  2. 根据 hash 查找转发表,找到对应的 2 个 RS,一个是主 RS 一个是备 RS,然后转发到主 RS;
  3. 主 RS 收到包之后,检查这个包是不是属于自己机器上的连接,如果是,就交给协议栈处理,如果不是,就转发到备 RS(备 RS 的地址记录在 GLB 发过来的包中)。
添加 GLB 的同时添加机器也没有问题,可以「二次转发」,每一个包都有「第二次机会」(图来自 GitHub)

对比 Google Maglev 保持连接的方案有「两层」来保证同一个 flow 到同一个 RS 上:每一个 LB 实例都根据 SYN 包记录连接对应的 RS,即 connection table;然后使用一致性 hash 尽可能让相同的五元组选择相同的 RS。是属于「 connection table + hash查转发表」的方式。

而 GLB 的方案中不存在任何的状态保存,SYN 包和其他的包都可以使用一样的逻辑来转发,第一次转发不对就转发第二次,可以认为是「hash查转发表+hash查转发表」。Maglev 论文中提到了一些特殊情况,比如遇到 SYN DDoS 攻击的时候可能造成内存问题,在 GLB 这里就没有。

除了不用保存数据,这个转发方案和 Google Maglev 相比还有一个优点:Maglev 论文中提到,如果 Maglev 数量有变化,RS 数量也有变化,这样就会导致之前的 TCP 连接的包被发送到一个新的 Maglev 上,这个新的 Maglev connection table 中没有保存这个连接的状态,经过自己的 hash 计算选择 RS 会和之前的不一样(因为 RS 数量变化导致 hash 结果会有可能不一样),这时候连接就断了。GLB 就没有这个问题,GLB 实例可以和 RS 同时做变化。

转发表的生成

在这个方案中,转发表的生成是关键的一步。

按照转发表转发,图中 Proxy 其实是本文的 RS(图来自 GitHub

转发表要满足一下几个条件:

  • 在 RS (就是图中的 proxy)修改的时候,只有变化的 RS 在表中会修改,没有变化的 RS 在表中的位置不变。即不能对整个表完全重新 hash;
  • 表的生成不依赖外部的状态;
  • 每一行的两个 RS 不应该相同(不然的话就相当于没有备 RS 了);
  • 所有 RS 在表中出现的次数应该是大致相同的 (负载均衡);

实现方式是类似 Rendezvous hashing:对于每一行,将行号+ RS IP 进行 Hash 的到一个数字,作为「分数」,所有的 RS 在这一行按照分数排序,取前两名,作为主 RS 和 备 RS 放到表中。

然后按照以上的四个条件来分析:

  • 如果添加 RS,那么只有新 RS 排名第一的相关的行需要修改,其他的行不会改变;
  • 生成这个表只会依赖 RS 的 IP;
  • 每一行的两个 RS 不可能相同,因为取的前两名;
  • Hash 算法可以保证每一个 IP 当第一名的概率是几乎一样的;

不过要注意的是:在想要删除 RS 的时候,要交换主 RS 和 备 RS 的位置,这样,主 RS 换到备就不会有新连接了,等残留的连接都结束,就可以下线了;在添加 RS 的时候,每次只能添加1个,因为如果一次添加两个,那么这两个 RS 如果出现在同一行的第一名和第二名,之前的 RS 就会没来得及 drain 就没了,那么之前的 RS 的连接都会断掉。

转发架构和封装

GLB 也是使用的 DSR 转发架构,在这个系列之前的文章已经介绍过了,这里不重复了。

LB 到 RS 的转发, GLB 一开始使用的是用 GRE 封装然后放到 FOU 里面,现在直接换成了 GUE。上文提到的备 RS 的 IP 地址可以放到自定义的 GUE header 里面。

为什么不用 IPIP 来做封装呢?IPIP 是把一个 IP 包放到另一个 IP 里面做转发,看起来 header 更少。但是这样的话就没有地方放备 RS IP 了,唯一可行的地方是 underlay 的 IP 包的 option 里面。这会导致一个问题,就是路由器不认识这个 option,会涉及到需要 CPU 来处理,速度就更慢(叫做 Layer 2 slow path)。

为什么封装到 UDP 里面,而不是 IP 里面呢?如果是放到 UDP 包里面,那么对于负责转发的路由器来说,这个包就是一个普通的 UDP 包,可以按照四元组做 hash。如果是 IP 的话,对于路由器来说只能看到 IP 的数据,不会去解析内层的 overlay 的包内容,中间的路由器,以及 NIC,都会放到同一个 queue 中,如果一个 IP 对的流量太大的话,就会有性能瓶颈。

转发实现

GLB 是基于 DPDK 实现的。

因为设计上是无状态的,所以可以用 DPDK Packet Distributor 把工作散到任意数量的 CPU 上,并行执行,扩展性很强。

官方博客中提到支持 TCP over IPv4 or IPv6,也支持 ICMP,支持 PMTUD。没提到 UDP,应该是不支持 UDP?GitHub 的业务涉及 UDP 的应该不多。

使用 DPDK 就有一个问题:流量都被 GLB 接管了,那么那些非数据面的流量怎么办?比如 sshd 等程序,这些程序是用 Kernel socket API 编写的,不支持 DPDK 的接口。

一种方法是安排单独的网卡接口,专门用于这些应用。DPDK 的流量走单独的网卡,控制面走单独的网卡。

GLB 是用了 Flow Bifurcation,就是可以将一个物理网卡虚拟成多个虚拟网卡,Kernel 协议栈和 DPDK 流量分别走不同的虚拟网卡。硬件网卡可以将流量区分出来走哪一个虚拟网卡,这部分功能几乎是不占用 CPU 的,所以不会有额外的资源消耗,也能达到线速。

Flow Bifurcation 可以使用下面两种硬件功能来实现:

  • SR-IOV 是一个 PCI 标准,支持将一个物理卡虚拟出多个虚拟卡。云厂商虚拟机场景用的比较多。虚拟卡都有单独的 queue,MAC 地址和 IP 地址,物理卡可以根据 MAC 地址将流量分到不同的虚拟卡中;
  • 大部分的 NIC 都支持编程 Packet classification filtering,让硬件来将不同的流量分到不同的 queue;
图片来自 dpdk

其他部分

测试

使用 DPDK 的 Environment Abstraction Layer (EAL) ,可以让基于 libpcap 的 interface 像物理卡一样,不需要专用物理网卡就可以做端到端测试,配合 Linux 的 Virtual Device 功能和 Python 的 Scapy 编程库,在任意 Linux 系统上就可以跑测试,VM 都可以。

测试环境架构,图来源

健康检查

在 GLB 实例上运行健康检查程序,从实际的 tunnel 去检查后端的端口,如果认为不健康,就直接交换主 RS 和备 RS。这样新连接会去好的 RS,旧连接可以尝试不健康的 RS,最大努力保持连接。如果健康检查失败是 False Positive 也不要紧,只是影响包的转发路径而已。

RS 上的二次转发

基于 Netfilter 和 IPtables 实现:如果是 SYN 或者连接在本地存在,就接受,否则就转发到 备 RS。

参考资料:

四层负载均衡系列文章

☑️ ⭐

网工闯了什么祸?

上一篇很多读者一下就发现了答案,暂时先不写答案和分析,卖个关子,继续出一题。下一篇一起揭晓答案。

小王(就是你!)在一家创业型互联网公司上班。公司为了保证产品的稳定性,在上线之前会现在测试环境运行代码,保证没有问题,再发布到正式环境。

小王的公司比较拮据,为了省钱,公司购买了一些陈旧的二手设备,运行测试环境。虽然性能比较差,但是毕竟测试环境只有开发人员的测试流量,所以没有什么问题。

随着部署的东西越来越多,原来一个机架已经不够用了,他们就准备扩展一个新的机架。网工效率很高,连夜操作,设备马上上线了。网工比较邋遢,通电了就下班了。

第二天小王来一看,测试环境网络不通了。这种情况一般人直接去打网工了。但是小王不是,小王总是抓住任何一个检验自己能力的机会,用有限的环境得到尽可能多的信息,推理出最可能的根因,然后再去找相关的同事解决。而不是直接去问同事:「我这里网络不通快给我看看是什么问题。」

现在的情况是:

  • 小王发现请求发给另一个服务总是超时;
  • 小王去 ping 了一下另一个服务的地址,当前的机器地址是 10.0.0.1,去 ping 目标地址 10.0.0.4 发现是不通的;
  • 于是小王保持当前的 ping,然后10.0.0.1 的机器上抓包,命令是 tcpdump -i eth0,得到的抓包文件如下。

请下载这个文件,分析抓包内容,解释:当前的网络出现了什么问题?

欢迎在评论区留下你的想法。

目录

这个系列正在连载中,没有链接的目录还没有写完,敬请期待……

  1. 序章
  2. 抓包技术以及技巧
  3. 理解网络的分层模型
  4. 数据是如何路由的
  5. 网络问题排查的思路和技巧
  6. 不可以用路由器?
  7. 网工闯了什么祸?
  8. 网络中的环路和防环技术
  9. 延迟增加了多少?
  10. TCP 延迟分析
  11. 重新认识 TCP 的握手和挥手
  12. 重新认识 TCP 的握手和挥手:答案和解析
  13. 后记:学习网络的一点经验分享
与本博客的其他页面不同,本页面使用 署名-非商业性使用-禁止演绎 4.0 国际 协议。
如果本文对您有帮助,欢迎打赏支持,正是订阅者的支持,让我公开写这个系列成为可能,感谢!
☑️ ⭐

填报志愿

妹妹打电话来问我怎么填报志愿。

在我回忆我们县里的几个高中,分别有何优劣,分数线分别是多少,应该选哪一个的时候——她却说,“我觉得模具设计不错,哥你知道这个吗?”

我一下都没反应过来,我问:“你不是中考吗?中考考完了不是上高中吗?“

“老师说没希望考上高中,让填职业高中的志愿。”

妹妹在年级排名 20 名左右,我一直以为这个成绩不错,上高中,去参加高考是没有问题的。没想到,我们现在镇上的这个中学, 一共有100个学生,能考上高中的只有个位数。我中考的时候,这个学校还没有这么差,我以前的同学,只要是在认真学习的,都上了高中。

于是我和她分析这些专业都怎么样。发现自己对这些行业一窍不通。像护理,汽修,工程造价,环境,食品。都不清楚要干什么。唯一对计算机行业可能还比较懂,但是又想到山东县城的计算机行业……应该怎么就业呢?我现在回去怕是也找不到什么工作。我了解的计算机世界可能和职高的计算机教育完全不是一回事。

回想起来我高考报志愿的时候,庆幸当时知道自己喜欢学习计算机专业。而且这么多年,对这个行业的兴趣依然不减,每天都在学习新的内容。周围也有很多同行,某一天突然发现自己并不喜欢编程,转行做其他的,反而做的风生水起的。

妹问我计算机怎么样。我想了想,觉得现在就业形势很严峻了。对高中就是职高的情况来说,会尤其难。以前的工作中也遇到过技术能力很强的候选人,因为大学不是本科,无法通过录取。甚至本科机会也不多。所以如果职高选择这个专业的话,必须走职高高考,或者专升本这条路。而且得是在大城市才行,二线以下的城市,机会都太少。计算机行业的特点是,小部分人的工作可以服务于全国,甚至全世界人民,所以岗位实际上很少,竞争非常激烈。不像是护理,汽修之类的,到哪里都有岗位需求。

我们觉得自己喜欢一个事情,可能并不是真的喜欢。当不了解、光听名字来判断,那种喜欢是一回事,真正去花时间去学习,又是另一回事了。计算机的领域也有很多,安全,网络,写代码,或是做算法,基础设施,我也时常迷茫,不知道喜欢做些什么。喜欢做出来能拿给人看的程序,学习了前端,后来发现自己居然更喜欢命令行的程序;大学最不喜欢的就是网络,后来发现网络这么有意思。

最后,我跟她说,你距离参加工作还有 5 年左右,5 年之后的社会谁也不知道什么样,什么专业会火也说不定。所以选择一个你感兴趣的来学吧,有了兴趣可以学好,无论是哪一行,学好了,不会差到哪里去的。别人的意见只能作参考,决定还是要自己来做才行。

☑️ ⭐

去伊豆

在高楼林立的东京呆够了,我们去了南边的伊豆半岛。

一如东京城市的交通一样,去伊豆的路线也有很多,也很复杂。为了省点脑筋,我们在一个 JR 车站,在一个热心的工作人员帮助下,买了舞女号列车的票(也叫踊子号,日语:踊り子/おどりこ Odoriko),这个列车的名字可能也是从川端康成的小说,《伊豆的舞娘》里面取来的吧。价钱不便宜,折合 230 多人民币一个人,好处是换乘少,速度快一些。

在列车上时而能看到一些海景。大约 2 个小时就到了伊豆高原。我们这一天的终点是下田市。所以在等待下一班列车的时间,打算去填饱肚子。

伊豆的特产是金目鲷鱼,我们在附近找了一家饭店,走路过去。

一路上非常安静,出了偶尔路过的汽车,一点声音都没有。天很蓝,植物绿油油的。

伊豆高原的路

一会就到了我们找的饭店。店家看起来很忙,门口已经排了很多人。等到一个服务员,我说我们想在这里吃饭,他拿出来记录排队的板子问我们,几个人?我说 2 个。他又问,几条狗?我惊讶,我说我没有狗。他说,哦不好意思,我们这家是和狗一起吃的饭店,如果没有狗请去隔壁那一家。这时候我才发现在外面排队的食客都带着狗。

有狗才能来吃的饭店

不过也好,隔壁这家没有人在排队。

欣点了鲷鱼的套餐,我点了一份带刺身的套餐。鲷鱼有一些甜,挺好吃。

鲷鱼定食

回到车站的路上,碰到了另一辆舞女号。

我们接下来乘坐的电车是伊豆急行线,这条线路的电车有两辆是特殊装饰的列车。一辆叫做金目绸鱼号,另一辆叫黑船电车。代表了伊豆的特色。鲷鱼是伊豆的特产,比较好理解。黑船电车呢?查了一下,发现特别有意思。我甚至不能理解。

简单来说,江户时代的日本和清朝一样,也是闭关锁国的政策。美国为了发展亚洲捕鲸事业,想要日本开放国门,德川幕府不同意。后来,美国人又来了,开着四艘涂成黑色船舰,载 63 门大炮,要求德川幕府打开国门,一直闭关锁国的日本国明都吓傻了,无计可施,只能签下屈辱的、不平等的《神奈川条约》。但是这个黑船事件,给日本埋下了希望改变的种子,最终促使了明治维新。今天,下田市将这个黑船事件看作是值得纪念的,自豪的事情。所以在下田可以看到非常多黑船事件的影子,黑船电车就是其一。

所以说,就是被迫打开国门,但是下田市将其看作是自豪的事情,大办黑船电车,黑船游览,黑船祭,必须要让世界知道这段历史!?

黑船电车是观海列车,座位不是朝向列车行进的方向,而是朝向窗户。景致非常特别。

观海列车
站台的一位老人

来到下田市,一出站台就看到满大街的日本国旗和美国国旗并肩挂在路边。非常奇特。

街上的美国国旗和日本国旗

在街上逛了一会,我们打算租自行车骑一骑。找到了一家店,里面是一位年纪很大的老爷爷。一点英文都不懂。我拿出 Google 翻译,他拿着放大镜,我一句他一句的,沟通起来,障碍竟然不是很大。

我说,我想租自行车。

他笑着说,你想去哪里?

海边吧。

我的自行车没有电,海边的路上上下下,不好骑。(说的也是,伊豆半岛是个高原,起起伏伏。)

我说,那去下田公园吧。

他笑着说,那里没什么可看的。

那去哪里呢?

就在城市转转吧。

于是老爷爷帮我们两个人挑好了自行车,还扶住车让我们上去试试高度,没问题了之后,我付给他钱,他说,回来再给钱。

骑上车之后,我们随便在地图上找了一个看起来是海港的地方,超那里骑过去。歪打正着,一路上风景非常美,而且很安静,除了鸟叫什么声音都听不到。

在路上看到了黑船。黑船游览在下田市已经成了一项观光活动
骑行的路上看到的海边

骑到尽头,发现这个位置是一个海洋馆。于是开始折返。

回到自行车的店,店里只有一个日本老奶奶了,这次不需要用 Google 翻译,她就明白我们是还车的。出门的时候,我用仅回的几句日语说谢谢,老奶奶回谢谢并且鞠躬,我说谢谢,她又鞠躬说谢谢。(出来之后,欣说你说谢谢她就鞠躬,直到你不说了。我这时才发现,早知道就不说那么多了)

归还自行车之后,我们打算去坐公交车去海滩。伊豆的公交车很少,一个小时一趟,有的甚至要两个小时。

不过等待是值得的,到了海边之后,发现这里的海滩好美。

白浜神社
去冲浪的人
另一个一千多年历史的神社

晚上住的民宿比较偏僻,所以打算在附近吃了晚饭再回去。找餐厅费了一番劲,好像很多餐厅都没有什么人,有一些甚至门都没开,像是倒闭了,很冷清。最后找到一个像是家庭餐厅的地方吃了拉面,味道不错,很温馨的馆子。

吃饱之后,坐火车去民宿。从车站出来,发现周围居然黑的伸手不见五指!连一个路灯都没有,非常吓人。开着手电筒一路小跑到了民宿。

这晚住的民宿是日本传统的房间,跟多啦A梦里面的一样。

住的民宿

睡了一个好觉,第二天的天气不是很好,我们趁着雨还没下下来,先去了大室山。

大室山是传说中新海诚《你的名字》里面那个山的取景地,很漂亮,和富士山一样是一个近乎完美的圆锥形。

大室山上

然后去了山脚下的仙人掌动物园。

这个动物园很赞,之前去过的动物园,动物们都在睡觉,要么就是懒洋洋的。这个动物园很多动物就在路上走,甚至可以伸手摸。

细尾獴
水豚

水豚可太可爱了,在路上走着走着就自己定住了,心事重重的样子。

可爱的水豚吓得小孩哇哇哭

动物园地图有一条路上画着孔雀,这孔雀可太有意思了,它就在地图画的这条路上来回走。见人多的时候,就从容地走到人群中间开屏,还会转几圈让所有人都看到,真像一个专业的艺术家!

孔雀

逛到一半开始下大雨了,雨比天气预报的还要大。我们算准了下一班公交车,坐车回到了伊豆高原,然后搭舞女号回到了东京。

❌