熊清亮的博客
http://seealso.cn/atom.xml
2020-10-21T16:29:06.663341Z
Werkzeug
使用 show processlist 排查 MySQL 数据库卡顿问题
28
2020-10-20T11:37:09.042578Z
2020-10-20T11:37:09.042578Z
allen
一般遇到 MySQL 数据库卡顿问题时,都会用到 show [full] processlist 命令来查看当前 MySQL 数据库的状态,然后结合 kill 和 explain 命令来具体分析和解决问题。
<blockquote><p><strong> 摘要 </strong>:一般遇到 MySQL 数据库卡顿问题时,都会用到 show [full] processlist 命令来查看当前 MySQL 数据库的状态,然后结合 kill 和 explain 命令来具体分析和解决问题。</p>
</blockquote>
<p>一般遇到 MySQL 数据库卡顿问题时,都会用到 <a href="https://dev.mysql.com/doc/refman/8.0/en/show-processlist.html">show [full] processlist</a> 命令来查看当前 MySQL 数据库的状态,包括并发线程(连接)数,正在执行什么查询,查询耗时多久,是否有锁等待等。</p>
<p>当发现某些线程(连接)有问题(比如执行时间(Time)非常长)时,可以使用 <a href="https://dev.mysql.com/doc/refman/8.0/en/kill.html">kill</a> 命令断开连接,这样可以临时解决一些突发问题。</p>
<p>由于<code>show processlist</code>命令只是一个当前快照,所以每次执行都可能结果不一样,有时候一两次快照看不出什么问题,需要多执行几次看看。</p>
<figure class="highlight sql" data-lang="sql"><table><tbody><tr><td class="code"><pre class="hljs sql"><span></span><span class="n">mysql</span><span class="o">></span> <span class="k">show</span> <span class="n">processlist</span><span class="p">;</span>
<span class="o">+</span><span class="c1">-----+------+-----------+------+---------+------+-------+------------------+</span>
<span class="o">|</span> <span class="n">Id</span> <span class="o">|</span> <span class="k">User</span> <span class="o">|</span> <span class="k">Host</span> <span class="o">|</span> <span class="n">db</span> <span class="o">|</span> <span class="n">Command</span> <span class="o">|</span> <span class="n">Time</span> <span class="o">|</span> <span class="k">State</span> <span class="o">|</span> <span class="n">Info</span> <span class="o">|</span>
<span class="o">+</span><span class="c1">-----+------+-----------+------+---------+------+-------+------------------+</span>
<span class="o">|</span> <span class="mi">108</span> <span class="o">|</span> <span class="n">root</span> <span class="o">|</span> <span class="n">localhost</span> <span class="o">|</span> <span class="k">NULL</span> <span class="o">|</span> <span class="n">Query</span> <span class="o">|</span> <span class="mi">0</span> <span class="o">|</span> <span class="k">NULL</span> <span class="o">|</span> <span class="k">show</span> <span class="n">processlist</span> <span class="o">|</span>
<span class="o">+</span><span class="c1">-----+------+-----------+------+---------+------+-------+------------------+</span>
<span class="mi">1</span> <span class="k">row</span> <span class="k">in</span> <span class="k">set</span> <span class="p">(</span><span class="mi">0</span><span class="p">.</span><span class="mi">00</span> <span class="n">sec</span><span class="p">)</span>
</pre></table></figure>
<p>普通用户执行<code>show processlist</code>只能显示当前登录用户正在运行的线程,但是 root 用户或者被赋予 PROCESS 权限的用户,则能看到所有用户正在运行的线程。</p>
<p><code>show processlist</code>显示的信息都是来自 MySQL 系统表<code>information_schema.processlist</code>。所以使用下面的查询语句可以获得相同的结果:</p>
<figure class="highlight sql" data-lang="sql"><table><tbody><tr><td class="code"><pre class="hljs sql"><span></span><span class="n">mysql</span><span class="o">></span> <span class="k">select</span> <span class="o">*</span> <span class="k">from</span> <span class="n">information_schema</span><span class="p">.</span><span class="n">processlist</span><span class="p">;</span>
<span class="o">+</span><span class="c1">-----+------+-----------+------+---------+------+-----------+----------------------------------------------+</span>
<span class="o">|</span> <span class="n">ID</span> <span class="o">|</span> <span class="k">USER</span> <span class="o">|</span> <span class="k">HOST</span> <span class="o">|</span> <span class="n">DB</span> <span class="o">|</span> <span class="n">COMMAND</span> <span class="o">|</span> <span class="n">TIME</span> <span class="o">|</span> <span class="k">STATE</span> <span class="o">|</span> <span class="n">INFO</span> <span class="o">|</span>
<span class="o">+</span><span class="c1">-----+------+-----------+------+---------+------+-----------+----------------------------------------------+</span>
<span class="o">|</span> <span class="mi">108</span> <span class="o">|</span> <span class="n">root</span> <span class="o">|</span> <span class="n">localhost</span> <span class="o">|</span> <span class="k">NULL</span> <span class="o">|</span> <span class="n">Query</span> <span class="o">|</span> <span class="mi">0</span> <span class="o">|</span> <span class="n">executing</span> <span class="o">|</span> <span class="k">select</span> <span class="o">*</span> <span class="k">from</span> <span class="n">information_schema</span><span class="p">.</span><span class="n">processlist</span> <span class="o">|</span>
<span class="o">+</span><span class="c1">-----+------+-----------+------+---------+------+-----------+----------------------------------------------+</span>
<span class="mi">1</span> <span class="k">row</span> <span class="k">in</span> <span class="k">set</span> <span class="p">(</span><span class="mi">0</span><span class="p">.</span><span class="mi">00</span> <span class="n">sec</span><span class="p">)</span>
</pre></table></figure>
<h2 id="常用定位问题的SQL语句">常用定位问题的 SQL 语句</h2>
<h3 id="查证哪个客户端连接数最多">查证哪个客户端连接数最多</h3>
<p>按客户端 IP 分组,看哪个客户端的链接数最多:</p>
<figure class="highlight sql" data-lang="sql"><table><tbody><tr><td class="code"><pre class="hljs sql"><span></span><span class="k">select</span>
<span class="n">client_ip</span><span class="p">,</span>
<span class="k">count</span><span class="p">(</span><span class="n">client_ip</span><span class="p">)</span> <span class="k">as</span> <span class="n">client_num</span>
<span class="k">from</span> <span class="p">(</span>
<span class="k">select</span>
<span class="n">substring_index</span><span class="p">(</span><span class="k">host</span><span class="p">,</span> <span class="s1">':'</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span> <span class="k">as</span> <span class="n">client_ip</span>
<span class="k">from</span>
<span class="n">information_schema</span><span class="p">.</span><span class="n">processlist</span>
<span class="p">)</span> <span class="k">as</span> <span class="n">t</span>
<span class="k">group</span> <span class="k">by</span>
<span class="n">client_ip</span>
<span class="k">order</span> <span class="k">by</span>
<span class="n">client_num</span> <span class="k">desc</span>
<span class="p">;</span>
</pre></table></figure>
<h3 id="看那些语句执行时间最长">看那些语句执行时间最长</h3>
<p>按 Time 倒排序查看正在执行的线程,看看有没有执行时间特别长的线程:</p>
<figure class="highlight sql" data-lang="sql"><table><tbody><tr><td class="code"><pre class="hljs sql"><span></span><span class="k">select</span>
<span class="o">*</span>
<span class="k">from</span>
<span class="n">information_schema</span><span class="p">.</span><span class="n">processlist</span>
<span class="k">where</span>
<span class="n">Command</span> <span class="o">!=</span> <span class="s1">'Sleep'</span>
<span class="k">order</span> <span class="k">by</span>
<span class="n">Time</span> <span class="k">desc</span>
<span class="p">;</span>
</pre></table></figure>
<h3 id="生成慢查询kill语句">生成慢查询 kill 语句</h3>
<p>上面提到可以通过 kill 线程 ID 来杀掉卡住的线程,那么如果线程很多,需要一个一个来 kill 么?答案是不,下面的语句可以用来生成需要的 kill 语句:</p>
<p>找出所有执行时间超过 5 分钟的线程,拼凑出 kill 语句:</p>
<figure class="highlight sql" data-lang="sql"><table><tbody><tr><td class="code"><pre class="hljs sql"><span></span><span class="k">select</span>
<span class="n">concat</span><span class="p">(</span><span class="s1">'kill '</span><span class="p">,</span> <span class="n">id</span><span class="p">,</span> <span class="s1">';'</span><span class="p">)</span>
<span class="k">from</span>
<span class="n">information_schema</span><span class="p">.</span><span class="n">processlist</span>
<span class="k">where</span>
<span class="n">Command</span> <span class="o">!=</span> <span class="s1">'Sleep'</span> <span class="k">and</span> <span class="n">Time</span> <span class="o">></span> <span class="mi">5</span> <span class="o">*</span> <span class="mi">60</span>
<span class="k">order</span> <span class="k">by</span>
<span class="n">Time</span> <span class="k">desc</span>
<span class="p">;</span>
</pre></table></figure>
<h2 id="一些常见的问题">一些常见的问题</h2>
<h3 id="CPU报警">CPU 报警</h3>
<p>很可能是 SQL 里面有较多的计算导致。</p>
<h3 id="连接数超高">连接数超高</h3>
<p>很可能是有慢查询,然后导致很多的查询在排队,排查问题的时候可以看到” 事发现场 “类似的 SQL 语句一大片。导致慢查询一般是没有索引或者索引不好使,可以用<code>explain</code>分析一下 SQL 语句。</p>
<h2 id="showprocesslist各字段含义">show processlist 各字段含义</h2>
<p>各个字段的含义如下:</p>
<ul>
<li>Id: 线程的唯一标识,当这个线程有问题时,可以 <code>kill <Id></code> 将它杀掉。</li>
<li>User: 启动这个线程的用户。</li>
<li>Host: 客户端 IP 和端口号。结合 <code>ss -n | grep :<port></code> 命令,可以定位到是哪个进程发送的请求。</li>
<li>DB: 当前执行的命令是在哪个数据库。如果没有指定数据库,则为 NULL。</li>
<li>Command: 正在执行的命令。详见下文。</li>
<li>Time: 处于当前状态的时间。</li>
<li>State: 线程的状态,和 Command 对应,详见下文。</li>
<li>Info: 正在执行的语句或 NULL。默认显示前 100 个字符,使用 <code>show full processlist</code> 查看完整语句。</li>
</ul>
<p><code>Command</code>的取值 <a href="https://dev.mysql.com/doc/refman/8.0/en/thread-commands.html">参考官方文档</a> ,简单介绍如下:</p>
<ul>
<li>Binlog Dump: 主节点正在将二进制日志,同步到从节点</li>
<li>Change User: 正在执行一个 change-user 的操作</li>
<li>Close Stmt: 正在关闭一个 Prepared Statement 对象(预编译语句,JDBC 支持此类型 SQL)</li>
<li>Connect: 一个从节点连上了主节点</li>
<li>Connect Out: 一个从节点正在连主节点</li>
<li>Create DB: 正在执行一个 create-database 的操作</li>
<li>Daemon: 服务器内部线程,而不是来自客户端的链接</li>
<li>Debug: 线程正在生成调试信息</li>
<li>Delayed Insert: 该线程是一个延迟插入的处理程序</li>
<li>Drop DB: 正在执行一个 drop-database 的操作</li>
<li>Execute: 正在执行一个 Prepared Statement</li>
<li>Fetch: 正在从 Prepared Statement 中获取执行结果</li>
<li>Field List: 正在获取表的列信息</li>
<li>Init DB: 该线程正在选取一个默认的数据库</li>
<li>Kill: 正在执行 kill 语句,杀死指定线程</li>
<li>Long Data: 正在从 Prepared Statement 中检索 long data</li>
<li>Ping: 正在处理 server-ping 的请求</li>
<li>Prepare: 该线程正在准备一个 Prepared Statement</li>
<li>ProcessList: 该线程正在生成服务器线程相关信息</li>
<li>Query: 该线程正在执行一个语句</li>
<li>Quit: 该线程正在退出</li>
<li>Refresh:该线程正在刷表,日志或缓存;或者在重置状态变量,或者在复制服务器信息</li>
<li>Register Slave:正在注册从节点</li>
<li>Reset Stmt: 正在重置 Prepared Statement</li>
<li>Set Option: 正在设置或重置客户端的 statement-execution 选项</li>
<li>Shutdown: 正在关闭服务器</li>
<li>Sleep: 正在等待客户端向它发送执行语句</li>
<li>Statistics: 该线程正在生成 server-status 信息</li>
<li>Table Dump: 正在发送表的内容到从服务器</li>
<li>Time: 未使用</li>
</ul>
<p><code>State</code>的取值 <a href="https://dev.mysql.com/doc/refman/8.0/en/general-thread-states.html">参考官方文档</a> :</p>
<p>该命令中最关键的就是 State 列,主要状态有以下几种:</p>
<ul>
<li><p>Checking table</p>
<p> 正在检查数据表(这是自动的)。</p></li>
<li><p>Closing tables</p>
<p> 正在将表中修改的数据刷新到磁盘中,同时正在关闭已经用完的表。这是一个很快的操作,如果不是这样的话,就应该确认磁盘空间是否已经满了或者磁盘是否正处于重负中。</p></li>
<li><p>Connect Out</p>
<p> 复制从服务器正在连接主服务器。</p></li>
<li><p>Copying to tmp table on disk</p>
<p> 由于临时结果集大于 tmp_table_size,正在将临时表从内存存储转为磁盘存储以此节省内存。</p></li>
<li><p>Creating tmp table</p>
<p> 正在创建临时表以存放部分查询结果。</p></li>
<li><p>deleting from main table</p>
<p> 服务器正在执行多表删除中的第一部分,刚删除第一个表。</p></li>
<li><p>deleting from reference tables</p>
<p> 服务器正在执行多表删除中的第二部分,正在删除其他表的记录。</p></li>
<li><p>Flushing tables</p>
<p> 正在执行 FLUSH TABLES,等待其他线程关闭数据表。</p></li>
<li><p>Killed</p>
<p> 发送了一个 kill 请求给某线程,那么这个线程将会检查 kill 标志位,同时会放弃下一个 kill 请求。MySQL 会在每次的主循环中检查 kill 标志位,不过有些情况下该线程可能会过一小段才能死掉。如果该线程程被其他线程锁住了,那么 kill 请求会在锁释放时马上生效。</p></li>
<li><p>Locked</p>
<p> 被其他查询锁住了。</p></li>
<li><p>Sending data</p>
<p> 正在处理 SELECT 查询的记录,同时正在把结果发送给客户端。</p></li>
<li><p>Sorting for group</p>
<p> 正在为 GROUP BY 做排序。</p></li>
<li><p>Sorting for order</p>
<p> 正在为 ORDER BY 做排序。</p></li>
<li><p>Opening tables</p>
<p> 这个过程应该会很快,除非受到其他因素的干扰。例如,在执 ALTER TABLE 或 LOCK TABLE 语句行完以前,数据表无法被其他线程打开。正尝试打开一个表。</p></li>
<li><p>Removing duplicates</p>
<p> 正在执行一个 SELECT DISTINCT 方式的查询,但是 MySQL 无法在前一个阶段优化掉那些重复的记录。因此,MySQL 需要再次去掉重复的记录,然后再把结果发送给客户端。</p></li>
<li><p>Reopen table</p>
<p> 获得了对一个表的锁,但是必须在表结构修改之后才能获得这个锁。已经释放锁,关闭数据表,正尝试重新打开数据表。</p></li>
<li><p>Repair by sorting</p>
<p> 修复指令正在排序以创建索引。</p></li>
<li><p>Repair with keycache</p>
<p> 修复指令正在利用索引缓存一个一个地创建新索引。它会比 Repair by sorting 慢些。</p></li>
<li><p>Searching rows for update</p>
<p> 正在讲符合条件的记录找出来以备更新。它必须在 UPDATE 要修改相关的记录之前就完成了。</p></li>
<li><p>Sleeping</p>
<p> 正在等待客户端发送新请求,这个状态比较常见,一般是正常情况。</p></li>
<li><p>System lock</p>
<p> 正在等待取得一个外部的系统锁。如果当前没有运行多个 mysqld 服务器同时请求同一个表,那么可以通过增加 --skip-external-locking 参数来禁止外部系统锁。</p></li>
<li><p>Upgrading lock</p>
<p>INSERT DELAYED 正在尝试取得一个锁表以插入新记录。</p></li>
<li><p>Updating</p>
<p> 正在搜索匹配的记录,并且修改它们。</p></li>
<li><p>User Lock</p>
<p> 正在等待 GET_LOCK ()。</p></li>
<li><p>Waiting for tables</p>
<p> 该线程得到通知,数据表结构已经被修改了,需要重新打开数据表以取得新的结构。然后,为了能的重新打开数据表,必须等到所有其他线程关闭这个表。以下几种情况下会产生这个通知:FLUSH TABLES tbl_name, ALTER TABLE, RENAME TABLE, REPAIR TABLE, ANALYZE TABLE, 或 OPTIMIZE TABLE。</p></li>
<li><p>waiting for handler insert</p>
<p>INSERT DELAYED 已经处理完了所有待处理的插入操作,正在等待新的请求。
大部分状态对应很快的操作,只要有一个线程保持同一个状态好几秒钟,那么可能是有问题发生了,需要检查一下。
还有其他的状态没在上面中列出来,不过它们大部分只是在查看服务器是否有存在错误是才用得着。</p></li>
</ul>
<h2 id="参考资料">参考资料</h2>
<ul>
<li><a href="https://xu3352.github.io/mysql/2017/07/08/msyql-show-full-processlist"> 学会用 Mysql show processlist 排查问题 </a></li>
<li><a href="https://zhuanlan.zhihu.com/p/30743094">mysql: show processlist 详解 </a></li>
</ul>
趣谈网络协议(下)
26
2019-06-19T14:59:46.414264Z
2019-06-19T14:59:46.414264Z
allen
本文讲述网络协议在当下热门领域的应用,比如云计算、容器和微服务,并手把手带你使用新技术,进一步加深对网络协议概念的理解。
<blockquote><p><strong> 摘要 </strong>:本文讲述网络协议在当下热门领域的应用,比如云计算、容器和微服务,并手把手带你使用新技术,进一步加深对网络协议概念的理解。</p>
</blockquote>
<p>本文是趣谈网络协议第二部分,第一部分 <a href="/linux/network-protocol">在这里</a> 。</p>
<p>想成为技术牛人,先搞定网络协议。</p>
<h2 id="数据中心:我是开发商,自己拿地盖别墅">数据中心:我是开发商,自己拿地盖别墅</h2>
<p>数据中心用到了前面学过的所有知识,数据中心里面是一堆服务器。服务器被放在一个个叫作<strong>机架(Rack)</strong>的架子上面,出入口也是漏油器,在数据中心的边界,所以叫做<strong>边界路由器</strong>,为了高可用,边界路由器有多个,而且会连接多个运营商网络,防止一个运营商网络出问题。</p>
<p>交换机往往是放在机架顶端的,所以经常称为<strong>TOR(Top Of Rack)交换机</strong>。这一层的交换机常常称为<strong>接入层(Access Layer)</strong>。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/tor.jpg"><img src="https://app.yinxiang.com/shard/s51/res/94175a74-78d5-47eb-8979-fe931bfede32.png" alt=""></a></p>
<p>当一个机架放不下的时候,就需要多个机架,还需要有交换机将多个机架连接在一起。这些交换机对性能的要求更高,带宽也更大。这些交换机称为<strong>汇聚层交换机(AggregationLayer)</strong>。</p>
<p><strong>数据中心里面的每一个连接都是需要考虑高可用的</strong>。这里首先要考虑的是,如果一台机器只有一个网卡,上面连着一个网线,接入到 TOR 交换机上。如果网卡坏了,或者不小心网线掉了,机器就上不去了。所以,需要至少两个网卡、两个网线插到 TOR 交换机上,但是两个网卡要工作得像一张网卡一样,这就是常说的<strong>网卡绑定(bond)</strong>。</p>
<p>这就需要服务器和交换机都支持一种协议<strong>LACP(Link Aggregation Control Protocol)</strong>。它们互相通信,将多个网卡聚合称为一个网卡,多个网线聚合成一个网线,在网线之间可以进行负载均衡,也可以为了高可用作准备。</p>
<p>TOR 交换机也需要高可用,同理接入层和汇聚层的连接也需要高可用性,也不能单线连着。</p>
<p>最传统的方法是,部署两个接入交换机、两个汇聚交换机。服务器和两个接入交换机都连接,接入交换机和两个汇聚都连接,当然这样会形成环,所以需要启用 STP 协议,去除环,但是这样两个汇聚就只能一主一备了。STP 协议里我们学过,只有一条路会起作用。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/al1.jpg"><img src="https://app.yinxiang.com/shard/s51/res/26bb7800-4acf-4e4f-ac01-e01aa2c54250.png" alt=""></a></p>
<p>交换机有一种技术叫作堆叠,所以另一种方法是,将多个交换机形成一个逻辑的交换机,服务器通过多根线分配连到多个接入层交换机上,而接入层交换机多根线分别连接到多个交换机上,并且通过堆叠的私有协议,形成双活的连接方式。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/al2.jpg"><img src="https://app.yinxiang.com/shard/s51/res/85cb80fe-bfca-45c5-8d7a-274485bc46c1.png" alt=""></a></p>
<p>汇聚层将大量的计算节点相互连接在一起,形成一个集群。在这个集群里面,服务器之间通过二层互通,这个区域常称为一个<strong>POD(Point Of Delivery)</strong>,有时候也称为一个<strong>可用区(Available Zone)</strong>。</p>
<p>当节点数目再多的时候,一个可用区放不下,需要将多个可用区连在一起,连接多个可用区的交换机称为<strong>核心交换机</strong>。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/centerl.jpg"><img src="https://app.yinxiang.com/shard/s51/res/57ac2312-f49a-49d2-aa27-e41ebebe72f5/centerl.jpg" alt=""></a></p>
<p>核心交换机吞吐量更大,高可用要求更高,肯定需要堆叠,但是往往仅仅堆叠,不足以满足吞吐量,因而还是需要部署多组核心交换机。核心和汇聚交换机之间为了高可用,也是全互连模式的。</p>
<h2 id="VPN:朝中有人好做官">VPN:朝中有人好做官</h2>
<p>数据中心,里面很复杂,但是有的公司有多个数据中心,需要将多个数据中心连接起来,或者需要办公室和数据中心连接起来。怎么办?<br>
走公网不安全,租用专线成本高,使用 VPN,安全有不贵。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/vpn1.jpg"><img src="https://app.yinxiang.com/shard/s51/res/49c678c3-82e6-4548-a78b-4b5ae66ac169.png" alt=""></a></p>
<p><strong>VPN,全名 Virtual Private Network,虚拟专用网</strong>,就是利用开放的公众网络,建立专用数据传输通道,将远程的分支机构、移动办公人员等连接起来。</p>
<h3 id="VPN是如何工作的">VPN 是如何工作的</h3>
<p>VPN 通过隧道技术在公众网络上仿真一条点到点的专线,是通过利用一种协议来传输另外一种协议的技术,这里面涉及三种协议:<strong>乘客协议、隧道协议</strong>和<strong>承载协议</strong>。</p>
<p>以 IPsec 协议为例:</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/ipsec.jpg"><img src="https://app.yinxiang.com/shard/s51/res/dcff1ef4-d3a6-4a4e-8569-3e509ce35714.png" alt=""></a></p>
<p><strong>IPsec VPN</strong>。这是基于 IP 协议的<strong>安全隧道协议</strong>,为了保证在公网上面信息的安全,因而采取了一定的机制保证安全性。</p>
<ul>
<li>机制一:<strong> 私密性 </strong>,防止信息泄漏给未经授权的个人,通过加密把数据从明文变成无法读懂的密文,从而确保数据的私密性。前面讲 HTTPS 的时候,说过加密可以分为对称加密和非对称加密。对称加密速度快一些。<br>
而 VPN 一旦建立,需要传输大量数据,因而我们采取对称加密。但是同样,对称加密还是存在加密秘钥如何传输的问题,这里需要用到因特网密钥交换(IKE,Internet Key Exchange)协议。</li>
<li>机制二:<strong> 完整性 </strong>,数据没有被非法篡改,通过对数据进行 hash 运算,产生类似于指纹的数据摘要,以保证数据的完整性。</li>
<li>机制三:<strong> 真实性 </strong>,数据确实是由特定的对端发出,通过身份认证可以保证数据的真实性。</li>
</ul>
<p>如何保证对方就是真正的那个人呢?</p>
<ul>
<li>第一种方法就是 <strong> 预共享密钥 </strong>,也就是双方事先商量好一个暗号,比如 “天王盖地虎,宝塔镇河妖”,对上了,就说明是对的。</li>
<li>另外一种方法就是用 <strong> 数字签名来验证 </strong>。</li>
</ul>
<p>基于以上三个特性,组成了<strong>IPsec VPN 的协议簇</strong>。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/vpn2.jpg"><img src="https://app.yinxiang.com/shard/s51/res/5b472d7d-4664-4674-bd76-f0ebbb0f7f50.png" alt=""></a></p>
<ul>
<li>AH(Authentication Header),只能进行数据摘要,不能实现数据加密。</li>
<li>ESP(Encapsulating Security Payload),能够进行数据加密和数据摘要。</li>
<li>IKE 组件,用于 VPN 的双方要进行对称密钥的交换。</li>
<li>SA(Security Association)组件,用于 VPN 的双方对连接进行维护</li>
</ul>
<h3 id="IPsecVPN的建立过程">IPsec VPN 的建立过程</h3>
<p>分两个阶段。</p>
<p><strong>第一个阶段,建立 IKE 自己的 SA</strong>。这个 SA 用来维护一个通过身份认证和安全保护的通道,为第二个阶段提供服务。通过 DH(Diffie-Hellman)算法计算出一个对称密钥 K。</p>
<p>DH 算法是一个比较巧妙的算法。客户端和服务端约定两个公开的质数 p 和 q,然后客户端随机产生一个数 a 作为自己的私钥,服务端随机产生一个 b 作为自己的私钥,客户端可以根据 p、q 和 a 计算出公钥 A,服务端根据 p、q 和 b 计算出公钥 B,然后双方交换公钥 A 和 B。</p>
<p>到此客户端和服务端可以根据已有的信息,各自独立算出相同的结果 K,就是<strong>对称密钥</strong>。但是这个过程,对称密钥从来没有在通道上传输过,只传输了生成密钥的材料,通过这些材料,截获的人是无法算出的。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/dh.jpg"><img src="https://app.yinxiang.com/shard/s51/res/b61e3b0a-c34b-41ae-9727-bd7135f8f32d.png" alt=""></a></p>
<p><strong>第二个阶段,建立 IPsec SA</strong>。在这个 SA 里面,双方会生成一个随机的对称密钥 M,由 K 加密传给对方,然后使用 M 进行双方接下来通信的数据。对称密钥 M 是有过期时间的,会过一段时间,重新生成一次,从而防止被破解。</p>
<p>IPsec SA 里面有以下内容:</p>
<ul>
<li>SPI(Security Parameter Index),用于标识不同的连接</li>
<li>双方商量好的加密算法、哈希算法和封装模式</li>
<li>生存周期,超过这个周期,就需要重新生成一个 IPsec SA,重新生成对称密钥</li>
</ul>
<p>当 IPsec 建立好,接下来就可以开始打包封装传输了。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/ipsecpack.jpg"><img src="https://app.yinxiang.com/shard/s51/res/581ef8ff-b1c6-4346-add9-9e0886289995/ipsecpack.jpg" alt=""></a></p>
<p>左面是原始的 IP 包,在 IP 头里面,会指定上一层的协议为 TCP。ESP 要对 IP 包进行封装,因而 IP 头里面的上一层协议为 ESP。在 ESP 的正文里面,ESP 的头部有双方商讨好的 SPI,以及这次传输的序列号。</p>
<p>接下来全部是加密的内容。可以通过对称密钥进行解密,解密后在正文的最后,指明了里面的协议是什么。如果是 IP,则需要先解析 IP 头,然后解析 TCP 头,这是从隧道出来后解封装的过程。</p>
<p>有了 IPsec VPN 之后,客户端发送的明文的 IP 包,都会被加上 ESP 头和 IP 头,在公网上传输,由于加密,可以保证不被窃取,到了对端后,去掉 ESP 的头,进行解密。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/vpn3.jpg"><img src="https://app.yinxiang.com/shard/s51/res/aaa1a95e-ab58-40e2-a418-bf2c9277cbb7.png" alt=""></a></p>
<p>这种点对点的基于 IP 的 VPN,能满足互通的要求,但是速度往往比较慢,这是由底层 IP 协议的特性决定的。IP 不是面向连接的,是尽力而为的协议,每个 IP 包自由选择路径,到每一个路由器,都自己去找下一跳,丢了就丢了,是靠上一层 TCP 的重发来保证可靠性。</p>
<p>因为 IP 网络从设计的时候,就认为是不可靠的,所以即使同一个连接,也可能选择不同的道路,这样的好处是,一条道路崩溃的时候,总有其他的路可以走。当然,带来的代价就是,不断的路由查找,效率比较差。</p>
<p>和 IP 对应的另一种技术称为 ATM。这种协议和 IP 协议的不同在于,它是面向连接的。你可以说 TCP 也是面向连接的啊。这两个不同,ATM 和 IP 是一个层次的,和 TCP 不是一个层次的。</p>
<p>另外,TCP 所谓的面向连接,是不停地重试来保证成功,其实下层的 IP 还是不面向连接的,丢了就丢了。ATM 是传输之前先建立一个连接,形成一个虚拟的通路,一旦连接建立了,所有的包都按照相同的路径走,不会分头行事。</p>
<p>好处是不需要每次都查路由表的,虚拟路径已经建立,每个包都按相同的路径走,这样效率会高很多。但是一旦虚拟路径上的某个路由器坏了,则这个连接就断了,什么也发不过去了,因为其他的包还会按照原来的路径走,都掉坑里了,它们不会选择其他的路径走。</p>
<p><strong>多协议标签交换(MPLS,Multi-Protocol Label Switching)</strong>,将两者的优点结合起来。</p>
<h2 id="移动网络:去巴塞罗那,手机也上不了脸书">移动网络:去巴塞罗那,手机也上不了脸书</h2>
<h3 id="移动网络的发展历程">移动网络的发展历程</h3>
<p>你一定知道手机上网有 2G、3G、4G 的说法,究竟这都是什么意思呢?有一个通俗的说法就是:用 2G 看 txt,用 3G 看 jpg,用 4G 看 avi。</p>
<h4 id="2G网络">2G 网络</h4>
<p>在 2G 时代,上网使用的不是 IP 网络,而是电话网络,走模拟信号,专业名称为公共交换电话网(PSTN,Public Switched TelephoneNetwork)。</p>
<p>那手机不连网线,也不连电话线,它是怎么上网?</p>
<p>手机是通过收发无线信号来通信的,专业名称是 Mobile Station,简称 MS,需要嵌入 SIM。手机是客户端,而无线信号的服务端,就是基站子系统(BSS,Base Station SubsystemBSS)。</p>
<p><strong>无论无线通信如何无线,最终还是要连接到有线的网络里</strong>。</p>
<p>基站子系统分两部分,一部分对外提供无线通信,叫作基站收发信台(BTS,Base Transceiver Station),另一部分对内连接有线网络,叫作基站控制器(BSC,Base StationController)。<br>
基站收发信台通过无线收到数据后,转发给基站控制器。</p>
<p>这部分属于无线的部分,统称为无线接入网(RAN,Radio Access Network)。</p>
<p>基站控制器通过有线网络,连接到提供手机业务的运营商的数据中心,这部分称为核心网(CN,Core Network)。核心网还没有真的进入互联网,这部分还是主要提供手机业务,是手机业务的有线部分。</p>
<p>首先接待基站来的数据的是移动业务交换中心(MSC,Mobile Service Switching Center),它是进入核心网的入口,但是它不会让你直接连接到互联网上。</p>
<p>因为在让你的手机真正进入互联网之前,提供手机业务的运营商,需要认证是不是合法的手机接入。别你自己造了一张手机卡,就连接上来。鉴权中心(AUC,Authentication Center)和设备识别寄存器(EIR,Equipment Identity Register)主要是负责安全性的。</p>
<p>另外,需要看你是本地的号,还是外地的号,这个牵扯到计费的问题,异地收费还是很贵的。访问位置寄存器(VLR,Visit Location Register)是看你目前在的地方,归属位置寄存器 (HLR,Home Location Register)是看你的号码归属地。</p>
<p>当你的手机卡既合法又有钱的时候,才允许你上网,这个时候需要一个网关,连接核心网和真正的互联网。网关移动交换中心(GMSC ,Gateway Mobile Switching Center)就是干这个的,然后是真正的互连网。<br>
在 2G 时代,还是电话网络 PSTN。</p>
<p>数据中心里面的这些模块统称为网络子系统(NSS,Network and Switching Subsystem)。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/2g.jpg"><img src="https://app.yinxiang.com/shard/s51/res/5b9f59e4-4888-443e-91cb-e51fe9e160e7.png" alt=""></a></p>
<ul>
<li><strong> 手机通过无线信号连接基站 </strong>;</li>
<li><strong> 基站一面朝前接无线,一面朝后接核心网 </strong>;</li>
<li><strong> 核心网一面朝前接到基站请求,一是判断你是否合法,二是判断你是不是本地号,还有没有钱,一面通过网关连接电话网络 </strong>。</li>
</ul>
<h4 id="2.5G网络">2.5G 网络</h4>
<p>在原来电路交换的基础上,加入了分组交换业务,支持 Packet 的转发,从而支持 IP 网络。多了一个分组控制单元(PCU,Packet Control Unit),用以提供分组交换通道。</p>
<p>在核心网里面,有个朝前的接待员(SGSN,Service GPRS Supported Node)和朝后连接 IP 网络的网关型 GPRS 支持节点(GGSN,Gateway GPRS Supported Node)。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/2-5g.jpg"><img src="https://app.yinxiang.com/shard/s51/res/ed79c865-bbe3-4281-b09d-928df153d51f.png" alt=""></a></p>
<h4 id="3G网络">3G 网络</h4>
<p>线通信技术有了改进,大大增加了无线的带宽。</p>
<p>以 W-CDMA 为例,理论最高 2M 的下行速度,因而基站改变了,一面朝外的是 Node B,一面朝内连接核心网的是无线网络控制器(RNC,Radio Network Controller)。核心网以及连接的 IP 网络没有什么变化。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/3g.jpg"><img src="https://app.yinxiang.com/shard/s51/res/5ff1d276-1c76-44e2-a48b-c40afe7e6c4a.png" alt=""></a></p>
<h4 id="4G网络">4G 网络</h4>
<p>基站为 eNodeB,包含了原来 Node B 和 RNC 的功能,下行速度向百兆级别迈进。另外,核心网实现了控制面和数据面的分离,这个怎么理解呢?</p>
<p>在前面的核心网里面,有接待员 MSC 或者 SGSN,你会发现检查是否合法是它负责,转发数据也是它负责,也即控制面和数据面是合二为一的,这样灵活性比较差,因为控制面主要是指令,多是小包,往往需要高的及时性;数据面主要是流量,多是大包,往往需要吞吐量。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/4g.jpg"><img src="https://app.yinxiang.com/shard/s51/res/430b675e-9b75-41e0-b432-aa186c5de8af.png" alt=""></a></p>
<p>HSS 用于存储用户签约信息的数据库,其实就是你这个号码归属地是哪里的,以及一些认证信息。</p>
<p>MME 是核心控制网元,是控制面的核心,当手机通过 eNodeB 连上的时候,MME 会根据 HSS 的信息,判断你是否合法。如果允许连上来,MME 不负责具体的数据的流量,而是 MME 会选择数据面的 SGW 和 PGW,然后告诉 eNodeB,我允许你连上来了,你连接它们吧。</p>
<p>于是手机直接通过 eNodeB 连接 SGW,连上核心网,SGW 相当于数据面的接待员,并通过 PGW 连到 IP 网络。PGW 就是出口网关。在出口网关,有一个组件 PCRF,称为策略和计费控制单元,用来控制上网策略和流量的计费。</p>
<h3 id="手机上网流程">手机上网流程</h3>
<p>手机开机之后上网的流程,这个过程称为<strong>Attach</strong>。可以看出来,移动网络还是很复杂的。因为这个过程要建立很多的隧道,分配很多的隧道 ID:</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/phonenet.jpg"><img src="https://app.yinxiang.com/shard/s51/res/c5bfc1b7-eef8-4afc-86b5-b523d66bafd6.png" alt=""></a></p>
<ol>
<li>手机开机以后,在附近寻找基站 eNodeB,找到后给 eNodeB 发送 Attach Request,说 “我来啦,我要上网”。</li>
<li>eNodeB 将请求发给 MME,说 “有个手机要上网”。</li>
<li>MME 去请求手机,一是认证,二是鉴权,还会请求 HSS 看看有没有钱,看看是在哪里上网。</li>
<li>当 MME 通过了手机的认证之后,开始分配隧道,先告诉 SGW,说要创建一个会话(Create Session)。在这里面,会给 SGW 分配一个隧道 ID t1,并且请求 SGW 给自己也分配一个隧道 ID。</li>
<li>SGW 转头向 PGW 请求建立一个会话,为 PGW 的控制面分配一个隧道 ID t2,也给 PGW 的数据面分配一个隧道 ID t3,并且请求 PGW 给自己的控制面和数据面分配隧道 ID。</li>
<li>PGW 回复 SGW 说 “创建会话成功”,使用自己的控制面隧道 ID t2,回复里面携带着给 SGW 控制面分配的隧道 ID t4 和控制面的隧道 ID t5,至此 SGW 和 PGW 直接的隧道建设完成。<br>
双方请求对方,都要带着对方给自己分配的隧道 ID,从而标志是这个手机的请求。</li>
<li>接下来 SGW 回复 MME 说 “创建会话成功”,使用自己的隧道 ID t1 访问 MME,回复里面有给 MME 分配隧道 ID t6,也有 SGW 给 eNodeB 分配的隧道 ID t7。</li>
<li>当 MME 发现后面的隧道都建设成功之后,就告诉 eNodeB,“后面的隧道已经建设完毕,SGW 给你分配的隧道 ID 是 t7,你可以开始连上来了,但是你也要给 SGW 分配一个隧道 ID”。</li>
<li>eNodeB 告诉 MME 自己给 SGW 分配一个隧道,ID 为 t8。</li>
<li>MME 将 eNodeB 给 SGW 分配的隧道 ID t8 告知 SGW,从而前面的隧道也建设完毕。</li>
</ol>
<h3 id="异地上网问题">异地上网问题</h3>
<p>为什么要分 SGW 和 PGW 呢,一个 GW 不可以吗?SGW 是你本地的运营商的设备,而 PGW 是你所属的运营商的设备。</p>
<p>如果你在巴塞罗那,一下飞机,手机开机,周围搜寻到的肯定是巴塞罗那的 eNodeB。通过 MME 去查寻国内运营商的 HSS,看你是否合法,是否还有钱。如果允许上网,你的手机和巴塞罗那的 SGW 会建立一个隧道,然后巴塞罗那的 SGW 和国内运营商的 PGW 建立一个隧道,然后通过国内运营商的 PGW 上网。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/phonenet2.jpg"><img src="https://app.yinxiang.com/shard/s51/res/174718fc-89d1-422f-9165-356905bafbe5.png" alt=""></a></p>
<p>这样判断你是否能上网的在国内运营商的 HSS,控制你上网策略的是国内运营商的 PCRF,给手机分配的 IP 地址也是国内运营商的 PGW 负责的,给手机分配的 IP 地址也是国内运营商里统计的。运营商由于是在 PGW 里面统计的,这样你的上网流量全部通过国内运营商即可,只不过巴塞罗那运营商也要和国内运营商进行流量结算。由于你的上网策略是由国内运营商在 PCRF 中控制的,因而你还是上不了脸书。</p>
<h2 id="云中网络:自己拿地成本高,购买公寓更灵活">云中网络:自己拿地成本高,购买公寓更灵活</h2>
<p>数据中心里面堆着一大片一大片的机器,但是维护起来很麻烦。</p>
<h3 id="从物理机到虚拟机">从物理机到虚拟机</h3>
<p>为了解决这些问题,人们发明了一种叫虚拟机的东西,并基于它产生了云计算技术。</p>
<p>我们常把物理机比喻为自己拿地盖房子,而虚拟机则相当于购买公寓,更加灵活方面,随时可买可卖。</p>
<p>它用的是软件模拟硬件的方式。刚才说了,数据中心里面用的 qemu-kvm。从名字上来讲,emu 就是 Emulator(模拟器)的意思,主要会模拟 CPU、内存、网络、硬盘,使得虚拟机感觉自己在使用独立的设备,但是真正使用的时候,当然还是使用物理的设备。</p>
<p>简单比喻,虚拟化软件就像一个 “骗子”,向上 “骗” 虚拟机里面的应用,让它们感觉独享资源,其实自己啥都没有,全部向下从物理机里面弄。</p>
<h3 id="虚拟网卡的原理">虚拟网卡的原理</h3>
<p><a href="https://blog.shipengqi.top/images/network-protocol/vmnetcard.jpg"><img src="https://app.yinxiang.com/shard/s51/res/1c2a6da5-afd4-472c-ac98-fd81138d5355.png" alt=""></a></p>
<p>首先,虚拟机要有一张网卡。对于 qemu-kvm 来说,这是通过 Linux 上的一种 TUN/TAP 技术来实现的。</p>
<p>虚拟机是物理机上跑着的一个软件。这个软件可以像其他应用打开文件一样,打开一个称为 TUN/TAP 的 Char Dev(字符设备文件)。打开了这个字符设备文件之后,在物理机上就能看到一张虚拟 TAP 网卡。<br>
虚拟化软件作为 “骗子”,会将打开的这个文件,在虚拟机里面虚拟出一张网卡,让虚拟机里面的应用觉得它们真有一张网卡。于是,所有的网络包都往这里发。</p>
<p>当然,<strong>网络包会到虚拟化软件这里。它会将网络包转换成为文件流,写入字符设备,就像写一个文件一样。内核中 TUN/TAP 字符设备驱动会收到这个写入的文件流,交给 TUN/TAP 的虚拟网卡驱动。<br>
这个驱动将文件流再次转成网络包,交给 TCP/IP 协议栈,最终从虚拟 TAP 网卡发出来,成为标准的网络包</strong>。</p>
<p>就这样,几经转手,数据终于从虚拟机里面,发到了虚拟机外面。</p>
<h3 id="虚拟网卡连接到云中">虚拟网卡连接到云中</h3>
<p>虚拟 TAP 网卡怎么接入庞大的数据中心网络中。</p>
<p>云计算中的网络需要注意的点:</p>
<ul>
<li><strong> 共享 </strong>:尽管每个虚拟机都会有一个或者多个虚拟网卡,但是物理机上可能只有有限的网卡。那这么多虚拟网卡如何共享同一个出口?</li>
<li><strong> 隔离 </strong>:分两个方面,一个是安全隔离,两个虚拟机可能属于两个用户,那怎么保证一个用户的数据不被另一个用户窃听?一个是流量隔离,两个虚拟机,如果有一个疯狂下片,会不会导致另外一个上不了网?</li>
<li><strong> 互通 </strong>:分两个方面,一个是如果同一台机器上的两个虚拟机,属于同一个用户的话,这两个如何相互通信?另一个是如果不同物理机上的两个虚拟机,属于同一个用户的话,这两个如何相互通信?</li>
<li><strong> 灵活 </strong>:虚拟机和物理不同,会经常创建删除,从一个机器漂移到另一台机器,有的互通,有的不通,灵活性比物理网络要好的多,需要能够灵活配置。</li>
</ul>
<h4 id="共享与互通问题">共享与互通问题</h4>
<p>首先,一台物理机上有多个虚拟网卡,这些虚拟网卡如何连在一起,进行相互访问,并且可以访问外网?</p>
<p>你可以想象物理机就是你的宿舍,虚拟机就是你的个人电脑,这些电脑怎么连接起来,需要一个交换机。</p>
<p>在物理机上,应该有一个虚拟的交换机,在 Linux 上有个命令<code>brctl</code>,可以常见虚拟网桥<code>brctl addbr br0</code>。创建出来之后,将虚拟网卡连接到虚拟网桥上<code>brctl addif br0 tap0</code>,将两个虚拟机配置相同的子网网段,两台虚拟机就可以互相通信了。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/vmlink.jpg"><img src="https://app.yinxiang.com/shard/s51/res/80d8f522-fef1-460d-bf0e-c1c0ded22b21.png" alt=""></a></p>
<p>虚拟机如何连外网呢?</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/vmui.jpg"><img src="https://app.yinxiang.com/shard/s51/res/f9848b74-0101-45f8-961c-49f804040dc6.png" alt=""></a></p>
<p>这里面,host-only 的网络对应的,其实就是上面两个虚拟机连到一个 br0 虚拟网桥上,而且不考虑访问外部的场景,只要虚拟机之间能够相互访问就可以了。</p>
<p>如果要访问外部,往往有两种方式。</p>
<ol>
<li>一种方式称为 <strong> 桥接 </strong>。如果在桌面虚拟化软件上选择桥接网络,则在你的笔记本电脑上,就会形成下面的结构。</li>
</ol>
<p><a href="https://blog.shipengqi.top/images/network-protocol/vmlink2.jpg"><img src="https://app.yinxiang.com/shard/s51/res/1aa0e7c7-9ab7-450a-ad53-a401679ba908.png" alt=""></a></p>
<p>每个虚拟机都会有虚拟网卡,在你的笔记本电脑上,会发现多了几个网卡,其实是虚拟交换机。这个虚拟交换机将虚拟机连接在一起。在桥接模式下,物理网卡也连接到这个虚拟交换机上,物理网卡在桌面虚拟化软件上,在 “界面名称” 那里选定。</p>
<p>如果使用桥接网络,当你登录虚拟机里看 IP 地址的时候会发现,你的虚拟机的地址和你的笔记本电脑的,以及你旁边的同事的电脑的网段是一个网段。这是为什么呢?这其实相当于将物理机和虚拟机放在同一个网桥上,相当于这个网桥上有三台机器,是一个网段的,全部打平了。我将图画成下面的样子你就好理解了。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/vmlink3.jpg"><img src="https://app.yinxiang.com/shard/s51/res/05a13de4-e9a7-490c-b421-ca5d5914c3db.png" alt=""></a></p>
<p>在数据中心里面,采取的也是类似的技术,只不过都是 Linux,在每台机器上都创建网桥 br0,虚拟机的网卡都连到 br0 上,物理网卡也连到 br0 上,所有的 br0 都通过物理网卡出来连接到物理交换机上。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/vmlink4.jpg"><img src="https://app.yinxiang.com/shard/s51/res/2ca6e58d-e957-47a4-bea2-155660b66254/vmlink4.jpg" alt=""></a></p>
<p>在这种方式下,不但解决了同一台机器的互通问题,也解决了跨物理机的互通问题,因为都在一个二层网络里面,彼此用相同的网段访问就可以了。但是当规模很大的时候,会存在问题。</p>
<p>在一个二层网络里面,最大的问题是广播。一个数据中心的物理机已经很多了,广播已经非常严重,需要通过 VLAN 进行划分。如果使用了虚拟机,假设一台物理机里面创建 10 台虚拟机,全部在一个二层网络里面,那广播就会很严重,所以除非是你的桌面虚拟机或者数据中心规模非常小,才可以使用这种相对简单的方式。</p>
<ol>
<li>另外一种方式称为 <strong>NAT</strong>。如果在桌面虚拟化软件中使用 NAT 模式,在你的笔记本电脑上会出现如下的网络结构。</li>
</ol>
<p><a href="https://blog.shipengqi.top/images/network-protocol/vmnat.jpg"><img src="https://app.yinxiang.com/shard/s51/res/c69e65f3-f8ce-462b-8e6b-2200d7557de9.png" alt=""></a></p>
<p>在这种方式下,你登录到虚拟机里面查看 IP 地址,会发现虚拟机的网络是虚拟机的,物理机的网络是物理机的,两个不相同。虚拟机要想访问物理机的时候,需要将地址 NAT 成为物理机的地址。</p>
<p>除此之外,它还会在你的笔记本电脑里内置一个 DHCP 服务器,为笔记本电脑上的虚拟机动态分配 IP 地址。因为虚拟机的网络自成体系,需要进行 IP 管理。为什么桥接方式不需要呢?因为桥接将网络打平了,虚拟机的 IP 地址应该由物理网络的 DHCP 服务器分配。</p>
<p>在数据中心里面,也是使用类似的方式。这种方式更像是真的将你宿舍里面的情况,搬到一台物理机上来。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/vmnat2.jpg"><img src="https://app.yinxiang.com/shard/s51/res/e3c264d1-1b6d-4a77-ac60-657d7d19700a.png" alt=""></a></p>
<p>虚拟机是你的电脑,路由器和 DHCP Server 相当于家用路由器或者寝室长的电脑,物理网卡相当于你们宿舍的外网网口,用于访问互联网。所有电脑都通过内网网口连接到一个网桥 br0 上,虚拟机要想访问互联网,需要通过 br0 连到路由器上,然后通过路由器将请求 NAT 成为物理网络的地址,转发到物理网络。</p>
<h4 id="隔离问题">隔离问题</h4>
<p>如果一台机器上的两个虚拟机不属于同一个用户,怎么办?<code>brctl</code>创建的网桥也是支持 VLAN 功能的,可以设置两个虚拟机的 tag,这样在这个虚拟网桥上,两个虚拟机是不互通的。</p>
<p>但是如何跨物理机互通,并且实现 VLAN 的隔离?由于<code>brctl</code>创建的网桥上面的 tag 是没办法在网桥之外的范围内起作用的,于是我们需要寻找其他的方式。</p>
<p>有一个命令<strong>vconfig</strong>,可以基于物理网卡 eth0 创建带 VLAN 的虚拟网卡,所有从这个虚拟网卡出去的包,都带这个 VLAN,如果这样,跨物理机的互通和隔离就可以通过这个网卡来实现。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/vmnat3.jpg"><img src="https://app.yinxiang.com/shard/s51/res/cb0d72b5-2016-4832-8dcd-056305a9a633/vmnat3.jpg" alt=""></a></p>
<p><strong>云计算的关键技术是虚拟化,这里我们重点关注的是,虚拟网卡通过打开 TUN/TAP 字符设备的方式,将虚拟机内外连接起来</strong>。</p>
<p><strong>云中的网络重点关注四个方面,共享、隔离、互通、灵活。其中共享和互通有两种常用的方式,分别是桥接和 NAT,隔离可以通过 VLAN 的方式</strong>。</p>
<h2 id="软件定义网络:共享基础设施的小区物业管理办法">软件定义网络:共享基础设施的小区物业管理办法</h2>
<p>可以这样比喻,云计算就像大家一起住公寓,要共享小区里面的基础设施,其中网络就相当于小区里面的电梯、楼道、路、大门等,大家都走,往往会常出现问题,尤其在上班高峰期,出门的人太多,对小区的物业管理就带来了挑战。</p>
<p>如果物业管理人员有一套智能的控制系统,在物业监控室里就能看到小区里每个单元、每个电梯的人流情况,然后在监控室里面,只要通过远程控制的方式,拨弄一个手柄,电梯的速度就调整了,栅栏门就打开了,某个入口就改出口了。</p>
<p>这就是<strong>软件定义网络(SDN)</strong>。它主要有以下三个特点。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/sdn.jpg"><img src="https://app.yinxiang.com/shard/s51/res/5097b94f-bd5b-4e26-85d7-b25d363c0101.png" alt=""></a></p>
<ul>
<li><strong> 控制与转发分离 </strong>:转发平面就是一个个虚拟或者物理的网络设备,就像小区里面的一条条路。控制平面就是统一的控制中心,就像小区物业的监控室。它们原来是一起的,物业管理员要从监控室出来,到路上去管理设备,现在是分离的,路就是走人的,控制都在监控室。</li>
<li><strong> 控制平面与转发平面之间的开放接口 </strong>:控制器向上提供接口,被应用层调用,就像总控室提供按钮,让物业管理员使用。控制器向下调用接口,来控制网络设备,就像总控室会远程控制电梯的速度。这里经常使用两个名词,前面这个接口称为北向接口,后面这个接口称为南向接口,上北下南嘛。</li>
<li><strong> 逻辑上的集中控制 </strong>:逻辑上集中的控制平面可以控制多个转发面设备,也就是控制整个物理网络,因而可以获得全局的网络状态视图,并根据该全局网络状态视图实现对网络的优化控制,就像物业管理员在监控室能够看到整个小区的情况,并根据情况优化出入方案。</li>
</ul>
<h3 id="OpenFlow和OpenvSwitch">OpenFlow 和 OpenvSwitch</h3>
<p>一种开源的 SDN 实现方式。</p>
<p>OpenFlow 是 SDN 控制器和网络设备之间互通的南向接口协议,OpenvSwitch 用于创建软件的虚拟交换机。OpenvSwitch 是支持 OpenFlow 协议的,当然也有一些硬件交换机也支持 OpenFlow 协议。<br>
它们都可以被统一的 SDN 控制器管理,从而实现物理机和虚拟机的网络连通。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/sdn2.jpg"><img src="https://app.yinxiang.com/shard/s51/res/86b6497e-9b7a-44cc-a4bc-89b34fbc1cd0/sdn2.jpg" alt=""></a></p>
<p>SDN 控制器是如何通过 OpenFlow 协议控制网络的?</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/sdn3.jpg"><img src="https://app.yinxiang.com/shard/s51/res/5316fb47-4d75-42e4-9cbb-96568a4190c6.png" alt=""></a></p>
<p>在 OpenvSwitch 里面,有一个流表规则,任何通过这个交换机的包,都会经过这些规则进行处理,从而接收、转发、放弃。</p>
<h2 id="云中的网络安全:虽然不是土豪,也需要基本安全和保障">云中的网络安全:虽然不是土豪,也需要基本安全和保障</h2>
<p><strong>对于公有云上的虚拟机,建议是仅仅开放需要的端口,而将其他的端口一概关闭。这个时候,你只要通过安全措施守护好这个唯一的入口就可以了</strong>。采用的方式常常是用 ACL(Access Control List,访问控制列表)来控制 IP 和端口。</p>
<p>设置好了这些规则,只有指定的 IP 段能够访问指定的开放接口,就算有个有漏洞的后台进程在那里,也会被屏蔽,黑客进不来。在云平台上,这些规则的集合常称为<strong>安全组</strong>。那安全组怎么实现呢?</p>
<p>首先拿下 MAC 头看看,是不是我的。如果是,则拿下 IP 头来。得到目标 IP 之后呢,就开始进行路由判断。在路由判断之前,这个节点我们称为<strong>PREROUTING</strong>。如果发现 IP 是我的,包就应该是我的,就发给上面的传输层,这个节点叫作<strong>INPUT</strong>。如果发现 IP 不是我的,就需要转发出去,这个节点称为<strong>FORWARD</strong>。如果是我的,上层处理完毕完毕后,一般会返回一个处理结果,这个处理结果会发出去,这个节点称为<strong>OUTPUT</strong>,无论是 FORWARD 还是 OUTPUT,都是路由判断之后发生的,最后一个节点是<strong>POSTROUTING</strong>。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/routing.jpg"><img src="https://app.yinxiang.com/shard/s51/res/76b13275-693d-4d32-ac06-9053e3e538d0.png" alt=""></a></p>
<p>整个包的处理过程还是原来的过程,只不过为什么要格外关注这五个节点呢?</p>
<p>因为在 Linux 内核中,有一个框架叫 Netfilter。它可以在这些节点插入 hook 函数。这些函数可以截获数据包,对数据包进行干预。例如做一定的修改,然后决策是否接着交给 TCP/IP 协议栈处理;或者可以交回给协议栈,那就是<strong>ACCEPT</strong>;或者过滤掉,不再传输,就是<strong>DROP</strong>;还有就是<strong>QUEUE</strong>,发送给某个用户态进程处理。</p>
<p>有了这个 Netfilter 框架就太好了,你可以在 IP 转发的过程中,随时干预这个过程,只要你能实现这些 hook 函数。</p>
<p>一个著名的实现,就是<strong>内核模块 ip_tables</strong>。它在这五个节点上埋下函数,从而可以根据规则进行包的处理。按功能可分为四大类:连接跟踪(conntrack)、数据包的过滤(filter)、网络地址转换(nat)和数据包的修改(mangle)。其中连接跟踪是基础功能,被其他功能所依赖。其他三个可以实现包的过滤、修改和网络地址转换。</p>
<p>在用户态,还有一个你肯定知道的客户端程序 iptables,用命令行来干预内核的规则。内核的功能对应 iptables 的命令行来讲,就是表和链的概念。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/iptables.jpg"><img src="https://app.yinxiang.com/shard/s51/res/239f3ac4-a10b-4d5a-be30-2366ad3591c8/iptables.jpg" alt=""></a></p>
<p>iptables 的表分为四种:raw–>mangle–>nat–>filter。这四个优先级依次降低,raw 不常用,所以主要功能都在其他三种表里实现。每个表可以设置多个链。</p>
<p>filter 表处理过滤功能,主要包含三个链:</p>
<ul>
<li>INPUT 链:过滤所有目标地址是本机的数据包</li>
<li>FORWARD 链:过滤所有路过本机的数据包</li>
<li>OUTPUT 链:过滤所有由本机产生的数据包</li>
</ul>
<p>nat 表主要是处理网络地址转换,可以进行 Snat(改变数据包的源地址)、Dnat(改变数据包的目标地址),包含三个链:</p>
<ul>
<li>PREROUTING 链:可以在数据包到达防火墙时改变目标地址</li>
<li>OUTPUT 链:可以改变本地产生的数据包的目标地址</li>
<li>POSTROUTING 链:在数据包离开防火墙时改变数据包的源地址</li>
</ul>
<p>mangle 表主要是修改数据包,包含:</p>
<ul>
<li>PREROUTING 链</li>
<li>INPUT 链</li>
<li>FORWARD 链</li>
<li>OUTPUT 链</li>
<li>POSTROUTING 链</li>
</ul>
<p>将 iptables 的表和链加入到上面的过程图中,就形成了下面的图和过程。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/iptables2.jpg"><img src="https://app.yinxiang.com/shard/s51/res/55a61058-51f9-4af8-9920-9d0e05b564bd.png" alt=""></a></p>
<ol>
<li>数据包进入的时候,先进 mangle 表的 PREROUTING 链。在这里可以根据需要,改变数据包头内容之后,进入 nat 表的 PREROUTING 链,在这里可以根据需要做 Dnat,也就是目标地址转换。</li>
<li>进入路由判断,要判断是进入本地的还是转发的。</li>
<li>如果是进入本地的,就进入 INPUT 链,之后按条件过滤限制进入。</li>
<li>之后进入本机,再进入 OUTPUT 链,按条件过滤限制出去,离开本地。</li>
<li>如果是转发就进入 FORWARD 链,根据条件过滤限制转发。</li>
<li>之后进入 POSTROUTING 链,这里可以做 Snat,离开网络接口。</li>
</ol>
<p>有了 iptables 命令,我们就可以在云中实现一定的安全策略。例如我们可以处理前面的偷窥事件。首先我们将所有的门都关闭。</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>iptables -t filter -A INPUT -s <span class="m">0</span>.0.0.0/0.0.0.0 -d X.X.X.X -j DROP
</pre></table></figure>
<p><code>-s</code>表示源 IP 地址段,<code>-d</code>表示目标地址段,<code>DROP</code>表示丢弃,也即无论从哪里来的,要想访问我这台机器,全部拒绝,谁也黑不进来。</p>
<p>但是你发现坏了,ssh 也进不来了,都不能远程运维了,可以打开一下。</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>iptables -I INPUT -s <span class="m">0</span>.0.0.0/0.0.0.0 -d X.X.X.X -p tcp --dport <span class="m">22</span> -j ACCEPT
</pre></table></figure>
<p>如果这台机器是提供的是 web 服务,80 端口也应该打开,当然一旦打开,这个 80 端口就需要很好的防护,但是从规则角度还是要打开。</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>iptables -A INPUT -s <span class="m">0</span>.0.0.0/0.0.0.0 -d X.X.X.X -p tcp --dport <span class="m">80</span> -j ACCEPT
</pre></table></figure>
<p>这些规则都可以在虚拟机里,自己安装 iptables 自己配置。但是如果虚拟机数目非常多,都要配置,对于用户来讲就太麻烦了,能不能让云平台把这部分工作做掉呢?</p>
<p>当然可以了。在云平台上,一般允许一个或者多个虚拟机属于某个安全组,而属于不同安全组的虚拟机之间的访问以及外网访问虚拟机,都需要通过安全组进行过滤。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/securitygroup.jpg"><img src="https://app.yinxiang.com/shard/s51/res/47f535a0-197d-44f7-b9ba-6567e8596fe8/securitygroup.jpg" alt=""></a></p>
<p>例如图中,我们会创建一系列的网站,都是前端在 Tomcat 里面,对外开放 8080 端口。数据库使用 MySQL,开放 3306 端口。</p>
<p>为了方便运维,我们创建两个安全组,将 Tomcat 所在的虚拟机放在安全组 A 里面。在安全组 A 里面,允许任意 IP 地址<code>0.0.0.0/0</code>访问 8080 端口,但是对于 ssh 的 22 端口,仅仅允许管理员网段<code>203.0.113.0/24</code>访问。</p>
<p>我们将 MySQL 所在的虚拟机在安全组 B 里面。在安全组 B 里面,仅仅允许来自安全组 A 的机器访问 3306 端口,但是对于 ssh 的 22 端口,同样允许管理员网段 203.0.113.0/24 访问。</p>
<p>前面的章节我们说过,在设计云平台的时候,我们想让虚拟机之间的网络和物理网络进行隔离,但是虚拟机毕竟还是要通过物理网和外界通信的,因而需要在出物理网的时候,做一次<strong>网络地址转换,也即 nat</strong>,这个就可以用 iptables 来做。</p>
<p>我们学过,IP 头里面包含源 IP 地址和目标 IP 地址,这两种 IP 地址都可以转换成其他地址。<strong>转换源 IP 地址的,我们称为 Snat;转换目标 IP 地址的,我们称为 Dnat</strong>。</p>
<p>你有没有思考过这个问题,<strong>TCP 的访问都是一去一回的,而你在你家里连接 WIFI 的 IP 地址是一个私网 IP,<code>192.168.1.x</code>。当你通过你们家的路由器访问 163 网站之后,网站的返回结果如何能够到达你的笔记本电脑呢?肯定不能通过 <code>192.168.1.x</code>,这是个私网 IP,不具有公网上的定位能力,而且用这个网段的人很多,茫茫人海,怎么能够找到你呢?</strong></p>
<p>所以当你从你家里访问 163 网站的时候,在你路由器的出口,会做 Snat 的,运营商的出口也可能做 Snat,将你的私网 IP 地址,最终转换为公网 IP 地址,然后 163 网站就可以通过这个公网 IP 地址返回结果,然后再 nat 回来,直到到达你的笔记本电脑。</p>
<p>云平台里面的虚拟机也是这样子的,它只有私网 IP 地址,到达外网网口要做一次 Snat,转换成为机房网 IP,然后出数据中心的时候,再转换为公网 IP。</p>
<p>这里有一个问题是,在外网网口上做 Snat 的时候,是全部转换成一个机房网 IP 呢,还是每个虚拟机都对应一个机房网 IP,最终对应一个公网 IP 呢?前面也说过了,公网 IP 非常贵,虚拟机也很多,当然不能每个都有单独的机房网和公网 IP 了,于是这种 Snat 是一种特殊的 Snat,<strong>MASQUERADE(地址伪装)</strong>。</p>
<p>这种方式下,所有的虚拟机共享一个机房网和公网的 IP 地址,所有从外网网口出去的,都转换成为这个 IP 地址。那又一个问题来了,都变成一个公网 IP 了,当 163 网站返回结果的时候,给谁呢,再 nat 成为哪个私网的 IP 呢?</p>
<p>这就是 Netfilter 的<strong>连接跟踪(conntrack)</strong>功能了。对于 TCP 协议来讲,肯定是上来先建立一个连接,可以用 “源 / 目的 IP + 源 / 目的端口” 唯一标识一条连接,这个连接会放在 conntrack 表里面。当时是这台机器去请求 163 网站的,虽然源地址已经 Snat 成公网 IP 地址了,但是 conntrack 表里面还是有这个连接的记录的。当 163 网站返回数据的时候,会找到记录,从而找到正确的私网 IP 地址。</p>
<p>如果虚拟机做服务器呢?也就是说,如果虚拟机里面部署的就是 163 网站呢?</p>
<p>这个时候就需要给这个网站配置固定的物理网的 IP 地址和公网 IP 地址了。<strong>这时候就需要显示的配置 Snat 规则和 Dnat 规则了</strong>。</p>
<p>当外部访问进来的时候,外网网口会通过 Dnat 规则将公网 IP 地址转换为私网 IP 地址,到达虚拟机,虚拟机里面是 163 网站,返回结果,外网网口会通过 Snat 规则,将私网 IP 地址转换为那个分配给它的固定的公网 IP 地址。</p>
<ul>
<li>源地址转换 (Snat):<code>iptables -t nat -A -s 私网 IP -j Snat --to-source 外网 IP</code></li>
<li>目的地址转换 (Dnat):<code>iptables -t nat -A -PREROUTING -d 外网 IP -j Dnat --to-destination 私网 IP</code></li>
</ul>
<h2 id="云中的网络QoS:邻居疯狂下电影,我该怎么办?">云中的网络 QoS:邻居疯狂下电影,我该怎么办?</h2>
<p>你租房子的时候,有没有碰到这样的情况:本来合租共享 WIFI,一个人狂下小电影,从而你网都上不去,是不是很懊恼?</p>
<p>在云平台上,也有这种现象,好在有一种流量控制的技术,可以实现<strong>QoS(Quality of Service)</strong>,从而保障大多数用户的服务质量。</p>
<p>对于控制一台机器的网络的 QoS,分两个方向,一个是入方向,一个是出方向。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/qos.jpg"><img src="https://app.yinxiang.com/shard/s51/res/0e422529-9351-451c-9fc6-b4bb0287ea11.png" alt=""></a></p>
<p>其实我们能控制的只有出方向,通过 Shaping,将出的流量控制成自己想要的模样。而进入的方向是无法控制的,只能通过 Policy 将包丢弃。</p>
<h3 id="控制网络的QoS有哪些方式?">控制网络的 QoS 有哪些方式?</h3>
<p>在 Linux 下,可以通过 TC 控制网络的 QoS,主要就是通过队列的方式。</p>
<h4 id="无类别排队规则">无类别排队规则</h4>
<ol>
<li>pfifo_fast<br>
这是一种不把网络包分类的技术。<br>
<a href="https://blog.shipengqi.top/images/network-protocol/qospf.jpg"><img src="https://app.yinxiang.com/shard/s51/res/1c926ba4-5d68-4bcd-b957-e26e7014bf27/qospf.jpg" alt=""></a></li>
</ol>
<p>pfifo_fast 分为三个先入先出的队列,称为三个 Band。根据网络包里面 TOS,看这个包到底应该进入哪个队列。TOS 总共四位,每一位表示的意思不同,总共十六种类型。</p>
<p>通过命令行<code>tc qdisc show dev eth0</code>,可以输出结果 priomap,也是十六个数字。在 0 到 2 之间,和 TOS 的十六种类型对应起来,表示不同的 TOS 对应的不同的队列。其中 Band 0 优先级最高,发送完毕后才轮到 Band 1 发送,最后才是 Band 2。</p>
<ol>
<li><p> 随机公平队列 <br>
会建立很多的 FIFO 的队列,TCP Session 会计算 hash 值,通过 hash 值分配到某个队列。在队列的另一端,网络包会通过轮询策略从各个队列中取出发送。这样不会有一个 Session 占据所有的流量。</p></li>
<li><p> 令牌桶规则 <br>
<a href="https://blog.shipengqi.top/images/network-protocol/qostoken.jpg"><img src="https://app.yinxiang.com/shard/s51/res/5bcfa4dd-c123-4bc1-a266-69c39cad197a.png" alt=""></a></p></li>
</ol>
<p>所有的网络包排成队列进行发送,但不是到了队头就能发送,而是需要拿到令牌才能发送。令</p>
<p>牌根据设定的速度生成,所以即便队列很长,也是按照一定的速度进行发送的。</p>
<p>当没有包在队列中的时候,令牌还是以既定的速度生成,但是不是无限累积的,而是放满了桶为止。设置桶的大小为了避免下面的情况:当长时间没有网络包发送的时候,积累了大量的令牌,突然来了大量的网络包,每个都能得到令牌,造成瞬间流量大增。</p>
<h4 id="基于类别的队列规则">基于类别的队列规则</h4>
<ol>
<li>分层令牌桶规则(HTB,Hierarchical Token Bucket)</li>
</ol>
<p>HTB 往往是一棵树,接下来我举个具体的例子,通过 TC 如何构建一棵 HTB 树来带你理解。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/qoshtb.jpg"><img src="https://app.yinxiang.com/shard/s51/res/eabb26a5-3a38-4ea8-be88-173d06f55d77/qoshtb.jpg" alt=""></a></p>
<p>使用 TC 可以为某个网卡 eth0 创建一个 HTB 的队列规则,需要付给它一个句柄为(1:)。</p>
<p>这是整棵树的根节点,接下来会有分支。例如图中有三个分支,句柄分别为(:10)、(:11)、(:12)。最后的参数 default 12,表示默认发送给 1:12,也即发送给第三个分支。</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>tc qdisc add dev eth0 root handle <span class="m">1</span>: htb default <span class="m">12</span>
</pre></table></figure>
<p>对于这个网卡,需要规定发送的速度。一般有两个速度可以配置,一个是<strong>rate</strong>,表示一般情况下的速度;一个是<strong>ceil</strong>,表示最高情况下的速度。对于根节点来讲,这两个速度是一样的,于是创建一个 root class,速度为(rate=100kbps,ceil=100kbps)。</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>tc class add dev eth0 parent <span class="m">1</span>: classid <span class="m">1</span>:1 htb rate 100kbps ceil 100kbps
</pre></table></figure>
<p>接下来要创建分支,也即创建几个子 class。每个子 class 统一有两个速度。三个分支分别为(rate=30kbps,ceil=100kbps)、(rate=10kbps,ceil=100kbps)、(rate=60kbps,ceil=100kbps)。</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>tc class add dev eth0 parent <span class="m">1</span>:1 classid <span class="m">1</span>:10 htb rate 30kbps ceil 100kbps
tc class add dev eth0 parent <span class="m">1</span>:1 classid <span class="m">1</span>:11 htb rate 10kbps ceil 100kbps
tc class add dev eth0 parent <span class="m">1</span>:1 classid <span class="m">1</span>:12 htb rate 60kbps ceil 100kbps
</pre></table></figure>
<p>发现三个 rate 加起来,是整个网卡允许的最大速度。</p>
<p><strong>HTB 有个很好的特性,同一个 root class 下的子类可以相互借流量,如果不直接在队列规则下面创建一个 root class,而是直接创建三个 class,它们之间是不能相互借流量的</strong>。借流量的策略,可以使得当前不使用这个分支的流量的时候,可以借给另一个分支,从而不浪费带宽,使带宽发挥最大的作用。</p>
<p>创建叶子队列规则,分别为<strong>fifo</strong>和<strong>sfq</strong>。</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>tc qdisc add dev eth0 parent <span class="m">1</span>:10 handle <span class="m">20</span>: pfifo limit <span class="m">5</span>
tc qdisc add dev eth0 parent <span class="m">1</span>:11 handle <span class="m">30</span>: pfifo limit <span class="m">5</span>
tc qdisc add dev eth0 parent <span class="m">1</span>:12 handle <span class="m">40</span>: sfq perturb <span class="m">10</span>
</pre></table></figure>
<p>基于这个队列规则,我们还可以通过 TC 设定发送规则:从<code>1.2.3.4</code>来的,发送给 port 80 的包,从第一个分支 1:10 走;其他从<code>1.2.3.4</code>发送来的包从第二个分支 1:11 走;其他的走默认分支。</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>tc filter add dev eth0 protocol ip parent <span class="m">1</span>:0 prio <span class="m">1</span> u32 match ip src <span class="m">1</span>.2.3.4 match ip dport <span class="m">80</span> 0xffff flowid <span class="m">1</span>:10
tc filter add dev eth0 protocol ip parent <span class="m">1</span>:0 prio <span class="m">1</span> u32 match ip src <span class="m">1</span>.2.3.4 flowid <span class="m">1</span>:11
</pre></table></figure>
<h3 id="如何控制QoS?">如何控制 QoS?</h3>
<p>使用 OpenvSwitch 将云中的网卡连通在一起,那如何控制 QoS?<br>
OpenvSwitch 支持两种:</p>
<ol>
<li>对于进入的流量,可以设置策略 Ingress policy</li>
<li>对于发出的流量,可以设置 QoS 规则 Egress shaping,支持 HTB</li>
</ol>
<h2 id="云中网络的隔离GRE、VXLAN:虽然住一个小区,也要保护隐私">云中网络的隔离 GRE、VXLAN:虽然住一个小区,也要保护隐私</h2>
<p>云平台中的隔离问题,前面咱们用的策略一直都是 VLAN,但是我们也说过这种策略的问题,VLAN 只有 12 位,共 4096 个。当时设计的时候,看起来是够了,但是现在绝对不够用,怎么办呢?</p>
<p><strong>扩展</strong>,在原来包的格式的基础上扩展出一个头,里面包含足够用于区分租户的 ID,外层的包的格式尽量和传统的一样,依然兼容原来的格式。一旦遇到需要区分用户的地方,我们就用这个特殊的程序,来处理这个特殊的包的格式。</p>
<p>这个概念很像<strong>隧道理论</strong>,之前讲的 VPN 协议,扩展的包头主要是用于加密的,而我们现在需要的包头是要能够区分用户的。</p>
<p>底层的物理网络设备组成的网络我们称为<strong>Underlay 网络</strong>,而用于虚拟机和云中的这些技术组成的网络称为<strong>Overlay 网络</strong>,这是一种基于物理网络的虚拟化网络实现。</p>
<h3 id="GRE">GRE</h3>
<p><strong>GRE,全称 Generic Routing Encapsulation</strong>,它是一种 IP-over-IP 的隧道技术。它将 IP 包封装在 GRE 包里,外面加上 IP 头,在隧道的一端封装数据包,并在通路上进行传输,到另外一端的时候解封装。你可以认为 Tunnel 是一个虚拟的、点对点的连接。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/gre.jpg"><img src="https://app.yinxiang.com/shard/s51/res/86ff86fe-3d55-4865-8f25-ffc536cf7b27.png" alt=""></a></p>
<p>GRE 还需要有一个地方来封装和解封装 GRE 的包,这个地方往往是路由器或者有路由功能的 Linux 机器。</p>
<p>GRE 的问题:</p>
<ul>
<li>Tunnel 的数量问题。GRE 是一种点对点隧道,如果有三个网络,就需要在每两个网络之间建立一个隧道。如果网络数目增多,这样隧道的数目会呈指数性增长。</li>
<li>GRE 不支持组播,因此一个网络中的一个虚机发出一个广播帧后,GRE 会将其广播到所有与该节点有隧道连接的节点。</li>
<li>目前还是有很多防火墙和三层网络设备无法解析 GRE,因此它们无法对 GRE 封装包做合适地过滤和负载均衡。</li>
</ul>
<h3 id="VXLAN">VXLAN</h3>
<p>第二种 Overlay 的技术称为 VXLAN。和三层外面再套三层的 GRE 不同,VXLAN 则是从二层外面就套了一个 VXLAN 的头,这里面包含的 VXLAN ID 为 24 位,也够用了。在 VXLAN 头外面还封装了 UDP、IP,以及外层的 MAC 头。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/vxlan.jpg"><img src="https://app.yinxiang.com/shard/s51/res/253e967c-3d7d-4b62-ad01-5fa5ebf18350.png" alt=""></a></p>
<p>VXLAN 作为扩展性协议,也需要一个地方对 VXLAN 的包进行封装和解封装,实现这个功能的点称为 VTEP(VXLAN Tunnel Endpoint)。</p>
<p>VTEP 相当于虚拟机网络的管家。每台物理机上都可以有一个 VTEP。每个虚拟机启动的时候,都需要向这个 VTEP 管家注册,每个 VTEP 都知道自己上面注册了多少个虚拟机。当虚拟机要跨 VTEP 进行通信的时候,需要通过 VTEP 代理进行,由 VTEP 进行包的封装和解封装。</p>
<p>VXLAN 不是点对点的,而是支持通过组播的来定位目标机器的,而非一定是这一端发出,另一端接收。</p>
<p>当一个 VTEP 启动的时候,它们都需要通过 IGMP 协议。加入一个组播组,就像加入一个邮件列表,或者加入一个微信群一样,所有发到这个邮件列表里面的邮件,或者发送到微信群里面的消息,大家都能收到。而当每个物理机上的虚拟机启动之后,VTEP 就知道,有一个新的 VM 上线了,它归我管。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/vtep.jpg"><img src="https://app.yinxiang.com/shard/s51/res/90eded55-d216-4bab-9055-c7b316916406/vtep.jpg" alt=""></a></p>
<p>如图,虚拟机 1、2、3 属于云中同一个用户的虚拟机,因而需要分配相同的 VXLAN ID=101。在云的界面上,就可以知道它们的 IP 地址,于是可以在虚拟机 1 上 ping 虚拟机 2。<br>
虚拟机 1 发现,它不知道虚拟机 2 的 MAC 地址,因而包没办法发出去,于是要发送 ARP 广播。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/vtep2.jpg"><img src="https://app.yinxiang.com/shard/s51/res/eb84bfca-1f3c-489e-a293-11d920520390/vtep2.jpg" alt=""></a></p>
<p>ARP 请求到达 VTEP1 的时候,VTEP1 知道,我这里有一台虚拟机,要访问一台不归我管的虚拟机,需要知道 MAC 地址,可是我不知道啊,这该咋办呢?<br>
VTEP1 想,我不是加入了一个微信群么?可以在里面 @all 一下,问问虚拟机 2 归谁管。于是 VTEP1 将 ARP 请求封装在 VXLAN 里面,组播出去。<br>
当然在群里面,VTEP2 和 VTEP3 都收到了消息,因而都会解开 VXLAN 包看,里面是一个 ARP。<br>
VTEP3 在本地广播了半天,没人回,都说虚拟机 2 不归自己管。<br>
VTEP2 在本地广播,虚拟机 2 回了,说虚拟机 2 归我管,MAC 地址是这个。通过这次通信,VTEP2 也学到了,虚拟机 1 归 VTEP1 管,以后要找虚拟机 1,去找 VTEP1 就可以了。</p>
<p>VTEP2 将 ARP 的回复封装在 VXLAN 里面,这次不用组播了,直接发回给 VTEP1。<br>
VTEP1 解开 VXLAN 的包,发现是 ARP 的回复,于是发给虚拟机 1。通过这次通信,VTEP1 也学到了,虚拟机 2 归 VTEP2 管,以后找虚拟机 2,去找 VTEP2 就可以了。<br>
虚拟机 1 的 ARP 得到了回复,知道了虚拟机 2 的 MAC 地址,于是就可以发送包了。</p>
<p>虚拟机 1 发给虚拟机 2 的包到达 VTEP1,它当然记得刚才学的东西,要找虚拟机 2,就去 VTEP2,于是将包封装在 VXLAN 里面,外层加上 VTEP1 和 VTEP2 的 IP 地址,发送出去。<br>
网络包到达 VTEP2 之后,VTEP2 解开 VXLAN 封装,将包转发给虚拟机 2。</p>
<p>虚拟机 2 回复的包,到达 VTEP2 的时候,它当然也记得刚才学的东西,要找虚拟机 1,就去 VTEP1,于是将包封装在 VXLAN 里面,外层加上 VTEP1 和 VTEP2 的 IP 地址,也发送出去。<br>
网络包到达 VTEP1 之后,VTEP1 解开 VXLAN 封装,将包转发给虚拟机 1。</p>
<h2 id="容器网络:来去自由的日子,不买公寓去合租">容器网络:来去自由的日子,不买公寓去合租</h2>
<p>如果说虚拟机是买公寓,容器则相当于合租,有一定的隔离,但是隔离性没有那么好。</p>
<p>云计算解决了基础资源层的弹性伸缩,却没有解决 PaaS 层应用随基础资源层弹性伸缩而带来的批量、快速部署问题。于是,容器应运而生。</p>
<p><strong>容器的思想就是要变成软件交付的集装箱</strong>。集装箱的特点,<strong>一是打包,二是标准</strong>。比如在把货从一个码头运到另一个码头,使用集装箱把货物打包,就可以一整箱搬上船,在整箱搬到另一个码头。如果没有集装箱,就要先一件件搬上船码好,在一件件搬到另一个码头。</p>
<h3 id="容器如何对应用打包">容器如何对应用打包</h3>
<p>容器实现隔离主要用了两种技术,一种是<strong>看起来是隔离的技术</strong>,称为<strong>namespace</strong>,即每个 namespace 中的应用看到的是不同的 IP 地址、用户空间、程号等。<br>
一种是<strong>用起来是隔离的技术</strong>,称为<strong>cgroup</strong>,也即明明整台机器有很多的 CPU、内存,而一个应用只能用其中的一部分。</p>
<p>所谓镜像,就是将你焊好集装箱的那一刻,将集装箱的状态保存下来,然后将这一刻的状态保存成一系列文件。无论从哪里运行这个镜像,都能完整地还原当时的情况。</p>
<h3 id="namespace">namespace</h3>
<p>在 Linux 下很多的资源都是全局的。比如进程有全局的进程 ID,网络也有全局的路由表。但是,当一台 Linux 上跑多个进程的时候,如果我们觉得使用不同的路由策略,这些进程可能会冲突,那就需要将这个进程放在一个独立的 namespace 里面,这样就可以独立配置网络了</p>
<h3 id="cgroup">cgroup</h3>
<p>cgroup 全称 control groups,是 Linux 内核提供的一种可以限制、隔离进程使用的资源机制。</p>
<p>cgroup 能控制哪些资源呢?它有很多子系统:</p>
<ul>
<li>CPU 子系统使用调度程序为进程控制 CPU 的访问</li>
<li>cpuset,如果是多核心的 CPU,这个子系统会为进程分配单独的 CPU 和内存</li>
<li>memory 子系统,设置进程的内存限制以及产生内存资源报告</li>
<li>blkio 子系统,设置限制每个块设备的输入输出控制</li>
<li>net_cls,这个子系统使用等级识别符(classid)标记网络数据包,可允许 Linux 流量控制程序(tc)识别从具体 cgroup 中生成的数据包</li>
</ul>
<p>cgroup 提供了一个虚拟文件系统,作为进行分组管理和各子系统设置的用户接口。要使用 cgroup,必须挂载 cgroup 文件系统,一般情况下都是挂载到<code>/sys/fs/cgroup</code>目录下。</p>
<h3 id="容器网络中如何融入物理网络?">容器网络中如何融入物理网络?</h3>
<p>如果你使用 docker run 运行一个容器,你应该能看到这样一个拓扑结构。<br>
<a href="https://blog.shipengqi.top/images/network-protocol/dockernet.jpg"><img src="https://app.yinxiang.com/shard/s51/res/a319ffab-f243-4f24-8ad4-a4fa46e01dd8.png" alt=""></a></p>
<p>是不是和虚拟机很像?容器里面有张网卡,容器外有张网卡,容器外的网卡连到 docker0 网桥,通过这个网桥,容器直接实现相互访问。如果你用 brctl 查看 docker0 网桥,你会发现它上面连着一些网卡。其实这个网桥和用 brctl 创建的网桥没什么两样。</p>
<p>那连接容器和网桥的那个网卡和虚拟机一样吗?在虚拟机场景下,有一个虚拟化软件,通过 TUN/TAP 设备虚拟一个网卡给虚拟机,但是容器场景下并没有虚拟化软件,这该怎么办呢?在 Linux 下,可以创建一对<strong>veth pair</strong>的网卡,从一边发送包,另一边就能收到。</p>
<p>一台机器内部容器的如何访问外网?就是虚拟机里面的桥接模式和 NAT 模式。Docker 默认使用 NAT 模式。NAT 模式分为 SNAT 和 DNAT,如果是容器内部访问外部,就需要通过 SNAT。在宿主机上,有这么一条 iptables 规则:</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>-A POSTROUTING -s <span class="m">172</span>.17.0.0/16 ! -o docker0 -j MASQUERADE
</pre></table></figure>
<p>所有从容器内部发出来的包,都要做地址伪装,将源 IP 地址,转换为物理网卡的 IP 地址。如果有多个容器,所有的容器共享一个外网的 IP 地址,但是在 conntrack 表中,记录下这个出去的连接。当服务器返回结果的时候,到达物理机,会根据 conntrack 表中的规则,取出原来的私网 IP,通过 DNAT 将地址转换为私网 IP 地址,通过网桥 docker0 实现对内的访问。</p>
<p>Docker 有两种方式,一种是通过一个进程<strong>docker-proxy</strong>的方式,监听 10080,转换为 80 端口。</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>/usr/bin/docker-proxy -proto tcp -host-ip <span class="m">0</span>.0.0.0 -host-port <span class="m">10080</span> -container-ip <span class="m">172</span>.17.0.2 -container-port <span class="m">80</span>
</pre></table></figure>
<p>另外一种方式是通过 DNAT 方式,在 - A PREROUTING 阶段加一个规则,将到端口 10080 的 DNAT 称为容器的私有网络。</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>-A DOCKER -p tcp -m tcp --dport <span class="m">10080</span> -j DNAT --to-destination <span class="m">172</span>.17.0.2:80
</pre></table></figure>
<h2 id="容器网络之Flannel:每人一亩三分地">容器网络之 Flannel:每人一亩三分地</h2>
<p>容器作为集装箱,可以保证应用在不同的环境中快速迁移,提高迭代的效率。但是如果要形成容器集团军,还需要一个集团军作战的调度平台,这就是 Kubernetes。它可以灵活地将一个容器调度到任何一台机器上,并且当某个应用扛不住的时候,只要在 Kubernetes 上修改容器的副本数,一个应用马上就能变八个,而且都能提供服务。</p>
<p>集团军作战有个重要的问题,就是通信。这里面包含两个问题,第一个是集团军的 A 部队如何实时地知道 B 部队的位置变化,第二个是两个部队之间如何相互通信。</p>
<p>第一个问题<strong>位置变化,往往是通过一个称为注册中心的地方统一管理的。这个是应用自己做的。当一个应用启动的时候,将自己所在环境的 IP 地址和端口,注册到注册中心指挥部,这样其他的应用请求它的时候,到指挥部问一下它在哪里就好了</strong>。</p>
<p>接下来是如何相互通信的问题。NAT 这种模式,在多个主机的场景下,是存在很大问题的。在物理机 A 上的应用 A 看到的 IP 地址是容器 A 的,是<code>172.17.0.2</code>,在物理机 B 上的应用 B 看到的 IP 地址是容器 B 的,不巧也是<code>172.17.0.2</code>,当它们都注册到注册中心的时候,注册中心就是这个图里这样子。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/flannel1.jpg"><img src="https://app.yinxiang.com/shard/s51/res/938448de-f7ba-499b-beed-e1f006a4da0c.png" alt=""></a></p>
<p>应用 A 要访问应用 B,当应用 A 从注册中心将应用 B 的 IP 地址读出来的时候,就彻底困惑了,这不是自己访问自己吗?</p>
<p>怎么解决这个问题呢?一种办法是不去注册容器内的 IP 地址,而是注册所在物理机的 IP 地址,端口也要是物理机上映射的端口。但是一方面,大部分分布式框架都是容器诞生之前就有了,它们不会适配这种场景;另一方面,让容器内的应用意识到容器外的环境,本来就是非常不好的设计。</p>
<p>于是业界就涌现了大量的方案,Flannel 就是其中之一。</p>
<p>原文链接:<a href="https://blog.shipengqi.top/2018/12/12/network-protocol-2/">https://blog.shipengqi.top/2018/12/12/network-protocol-2/</a></p>
趣谈网络协议(上)
25
2019-06-19T14:44:36.931067Z
2019-06-19T14:44:36.931067Z
allen
计算机语言(C 语言,Java 等)是人类和计算机沟通的协议,能够教给一台计算机完成你的工作,但是,要想一大片机器互相协作、共同完成一件事,你需要用到网络协议。
<blockquote><p><strong> 摘要 </strong>:计算机语言(C 语言,Java 等)是人类和计算机沟通的协议,能够教给一台计算机完成你的工作,但是,要想一大片机器互相协作、共同完成一件事,你需要用到网络协议。</p>
</blockquote>
<p>想成为技术牛人,先搞定网络协议。</p>
<h2 id="为什么要学习网络协议?">为什么要学习网络协议?</h2>
<p>计算机语言(C 语言,Java 等)是人类和计算机沟通的协议,通过这种协议,计算机可以知道我们想让它做什么。但是这种协议计算机不能直接读懂,对于计算机,它只认识 0 和 1,所以计算机语言还需要编译之后,计算机才会读懂。</p>
<p>协议三要素:</p>
<ul>
<li>语法,就是这一段内容要符合一定的规则和格式。例如,括号要成对,结束要使用分号等。</li>
<li>语义,就是一段内容代表某种意义。例如数字减去数字是有意义的,数字减去文本一般来说就没有意义。</li>
<li>顺序,就是先干啥,后干啥。例如,先加上某个值,再减去某个值。</li>
</ul>
<p>计算机语言,能够教给一台计算机完成你的工作,但是,要想一大片机器互相协作、共同完成一件事,只教给一台机器做什么是不够的,你需要学会给一大片机器做什么。这就需要<strong>网络协议</strong>。</p>
<h2 id="网络分层的真实含义是什么?">网络分层的真实含义是什么?</h2>
<p>复杂的程序都要分层。比如,复杂的电商还会分数据库层、缓存层、Compose 层、Controller 层和接入层,每一层专注做本层的事情。</p>
<p>程序是如何工作的?<br>
<a href="https://blog.shipengqi.top/images/network-protocol/protocol-layers.jpg"><img src="https://app.yinxiang.com/shard/s51/res/af91e425-19e8-4fe5-8a90-cf4ea7b7ea7b/protocol-layers.jpg" alt=""></a></p>
<p>只要是在网络上跑的包,都是完整的。可以有下层没上层,绝对不可能有上层没下层。</p>
<p>例如:TCP 在三次握手的时候,IP 层和 MAC 层在做什么呢?当然是 TCP 发送每一个消息,都会带着 IP 层和 MAC 层了。因为,TCP 每发送一个消息,IP 层和 MAC 层的所有机制都要运行一遍。而你只看到 TCP 三次握手了,其实,IP 层和 MAC 层为此也忙活好久了。</p>
<p>所以,对 TCP 协议来说,三次握手也好,重试也好,只要想发出去包,就要有 IP 层和 MAC 层,不然是发不出去的。</p>
<p>所谓的<strong>二层设备、三层设备</strong>,都是这些设备上跑的程序不同而已。一个 HTTP 协议的包经过一个二层设备,二层设备收进去的是整个网络包。这里面 HTTP、TCP、IP、MAC 都有。什么叫二层设备呀,就是只把 MAC 头摘下来,看看到底是丢弃、转发,还是自己留着。那什么叫三层设备呢?就是把 MAC 头摘下来之后,再把 IP 头摘下来,看看到底是丢弃、转发,还是自己留着。</p>
<h2 id="ifconfig:最熟悉又陌生的命令行">ifconfig:最熟悉又陌生的命令行</h2>
<p>怎么查看 IP 地址?<br>
Windows 上是<code>ipconfig</code>,在 Linux 上是<code>ifconfig</code>,<code>ip addr</code>。</p>
<p><strong>IP 地址是一个网卡在网络世界的通讯地址,相当于我们现实世界的门牌号码。</strong>大部分的网卡都会有一个 IP 地址,当然,这不是必须的。</p>
<p>32 位的 IP 地址就被分成了 5 类:</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/ipaddr.jpg"><img src="https://app.yinxiang.com/shard/s51/res/b64e6f25-20a9-4f4c-a104-c8c3df511cc4/ipaddr.jpg" alt=""></a></p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/ipaddr-range.jpg"><img src="https://app.yinxiang.com/shard/s51/res/29e29f73-608d-4c38-9c96-1626f17b0225/ipaddr-range.jpg" alt=""></a></p>
<p>C 类地址能包含的最大主机数量只有 254 个,现在估计一个网吧都不够用。<br>
B 类地址能包含的最大主机数量又太多了。6 万多台机器放在一个网络下面,一般的企业基本达不到这个规模,闲着的地址就是浪费。</p>
<h3 id="无类型域间选路(CIDR)">无类型域间选路(CIDR)</h3>
<p>CIDR,打破了原来设计的几类地址的做法,将<code>32</code>位的 IP 地址一分为二,前面是网络号,后面是主机号。从哪里分呢?你如果注意观察的话可以看到,<code>10.100.122.2/24</code>,<br>
这个 IP 地址中有一个斜杠,斜杠后面有个数字<code>24</code>。这种地址表示形式,就是 CIDR。后面<code>24</code>的意思是,<code>32</code>位中,前<code>24</code>位是网络号,后<code>8</code>位是主机号。</p>
<p>伴随着 CIDR 存在的,一个是<strong>广播地址</strong>,<code>10.100.122.255</code>。如果发送这个地址,所有<code>10.100.122</code>网络里面的机器都可以收到。另一个是<strong>子网掩码</strong>,<code>255.255.255.0</code>。<br>
将子网掩码和 IP 地址进行<code>AND</code>计算,就可得到<strong>网络号</strong>。</p>
<h3 id="公有IP地址和私有IP地址">公有 IP 地址和私有 IP 地址</h3>
<p>在日常的工作中,几乎不用划分 A 类、B 类或者 C 类,所以时间长了,很多人就忘记了这个分类,而只记得 CIDR。但是有一点还是要注意的,就是公有 IP 地址和私有 IP 地址。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/ipaddr-range.jpg"><img src="https://app.yinxiang.com/shard/s51/res/29e29f73-608d-4c38-9c96-1626f17b0225/ipaddr-range.jpg" alt=""></a></p>
<p>表格最右列是私有 IP 地址段。平时我们看到的数据中心里,办公室、家里或学校的 IP 地址,一般都是私有 IP 地址段。因为这些地址允许组织内部的 IT 人员自己管理、自己分配,而且可以重复。因此,你学校的某个私有 IP 地址段和我学校的可以是一样的。</p>
<p>这就像每个小区有自己的楼编号和门牌号,你们小区可以叫 6 栋,我们小区也叫 6 栋,没有任何问题。但是一旦出了小区,就需要使用公有 IP 地址。</p>
<p><code>192.168.0.x</code>是最常用的私有 IP 地址。一般你家里地上网设备不会超过 256 个,所以<code>/24</code>基本就够了。有时候我们也能见到<code>/16 =</code>的 CIDR,这两种是最常见的。<br>
很明显<code>192.168.0</code>是网络号,整个网络里面的第一个地址<code>192.168.0.1</code>, 往往就是你这个<strong>私有网络的出口地址</strong>。例如你家的路由器地址就是<code>192.168.0.1</code>。<br>
<code>192.168.0.255</code>就是广播地址。一旦发送这个地址,整个<code>192.168.0</code>网络里面的所有机器都能收到。</p>
<h3 id="一个容易“犯错”的CIDR">一个容易 “犯错” 的 CIDR</h3>
<p><code>16.158.165.91/22</code>这个 CIDR。求一下这个网络的第一个地址、子网掩码和广播地址。</p>
<p>要是上来就写<code>16.158.165.1</code>,那就大错特错了。</p>
<p><code>/22</code>不是<code>8</code>的整数倍,不好办,只能先变成二进制来看。<code>16.158</code>的部分不会动,它占了前<code>16</code>位。中间的<code>165</code>,变为二进制为<code>10100101</code>。除了前面的<code>16</code>位,还剩<code>6</code>位。所以,这<code>8</code>位中前<code>6</code>位是网络号,<code>16.158.<101001></code>,而<code><01>.91</code>是机器号。第一个地址是<code>16.158.<101001><00>.1</code>,即<code>16.158.164.1</code>。子网掩码是<code>255.255.<111111><00>.0</code>,即<code>255.255.252.0</code>。广播地址为<code>16.158.<101001><11>.255</code>,即<code>16.158.167.255</code>。</p>
<p><strong>D 类是组播地址</strong>。使用这一类地址,属于某个组的机器都能收到。这有点类似在公司里面大家都加入了一个邮件组。发送邮件,加入这个组的都能收到。</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>root@test:~<span class="se">\#</span> ip addr
<span class="m">1</span>: lo: <LOOPBACK,UP,LOWER_UP> mtu <span class="m">65536</span> qdisc noqueue state UNKNOWN group default
link/loopback <span class="m">00</span>:00:00:00:00:00 brd <span class="m">00</span>:00:00:00:00:00
inet <span class="m">127</span>.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
<span class="m">2</span>: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu <span class="m">1500</span> qdisc pfifo_fast state UP group default qlen <span class="m">1000</span>
link/ether fa:16:3e:c7:79:75 brd ff:ff:ff:ff:ff:ff
inet <span class="m">10</span>.100.122.2/24 brd <span class="m">10</span>.100.122.255 scope global eth0
valid_lft forever preferred_lft forever
inet6 fe80::f816:3eff:fec7:7975/64 scope link
valid_lft forever preferred_lft forever
</pre></table></figure>
<p>上面的输出,IP 地址的后面有个<code>scope</code>,对于<code>eth0</code>这张网卡来讲,是<code>global</code>,说明这张网卡是可以对外的,可以接收来自各个地方的包。对于<code>lo</code>来讲,是<code>host</code>,说明这张网卡仅仅可以供本机相互通信。</p>
<p><code>lo</code>全称是<code>loopback</code>,又称环回接口,往往会被分配到<code>127.0.0.1</code>这个地址。这个地址用于本机通信,经过内核处理后直接返回,不会在任何网络中出现。这就是为什么你可以在浏览器通过访问<code>127.0.0.1</code>这个地址来访问本地服务,而且一般在你本机的<code>host</code>文件,会有<code>127.0.0.1 localhost</code>,这是个映射关系,访问<code>localhost</code>相当于<code>127.0.0.1</code>。</p>
<h3 id="MAC地址">MAC 地址</h3>
<p><code>link/ether fa:16:3e:c7:79:75 brd ff:ff:ff:ff:ff:ff</code>,这个被称为<strong>MAC 地址</strong>,网卡的物理地址,用十六进制,6 个 byte 表示。</p>
<p>MAC 地址既然全局唯一,不会有两个网卡有相同的 MAC 地址,那么为什么不直接用 MAC 地址来进行通信?</p>
<p><strong>一个网络包要从一个地方传到另一个地方,除了要有确定的地址,还需要有定位功能。</strong>IP 地址,才是有远程定位功能的。</p>
<p><strong>MAC 地址更像是身份证,是一个唯一的标识。</strong>它的唯一性设计是为了组网的时候,不同的网卡放在一个网络里面的时候,可以不用担心冲突。从硬件角度,保证不同的网卡有不同的标识。</p>
<p>例如,你去杭州市网商路 599 号 B 楼 6 层找刘超,你在路上问路,可能被问的人不知道 B 楼是哪个,但是可以给你指网商路怎么去。但是如果你问一个人,你知道这个身份证号的人在哪里吗?可想而知,没有人知道。</p>
<p>MAC 地址是有一定定位功能的,只不过范围非常有限。你可以根据 IP 地址,找到杭州市网商路 599 号 B 楼 6 层,但是依然找不到我,你就可以靠吼了,大声喊身份证 XXXX 的是哪位?我听到了,我就会站起来说,是我啊。</p>
<p>MAC 地址的通信范围比较小,局限在一个子网里面。例如,例如,从<code>192.168.0.2/24</code>访问<code>192.168.0.3/24</code>是可以用 MAC 地址的。一旦跨子网,即从<code>192.168.0.2/24</code>到<code>192.168.1.2/24</code>,MAC 地址就不行了,需要 IP 地址起作用了。</p>
<h3 id="网络设备的状态标识">网络设备的状态标识</h3>
<p><code><BROADCAST,MULTICAST,UP,LOWER_UP></code>是干什么的?这个叫作<code>net_device flags</code>,<strong>网络设备的状态标识</strong>。</p>
<ul>
<li><code>UP</code> 表示网卡处于启动的状态</li>
<li><code>BROADCAST</code> 表示这个网卡有广播地址,可以发送广播包</li>
<li><code>MULTICAST</code> 表示网卡可以发送多播包</li>
<li><code>LOWER_UP</code> 表示 <code>L1</code> 是启动的,也即网线插着呢。</li>
<li><code>MTU1500</code> 是指最大传输单元 MTU 为 1500,这是以太网的默认值。MTU 是二层 MAC 层的概念。MAC 层有 MAC 的头,以太网规定连 MAC 头带正文合起来,不允许超过 1500 个字节。<br>
正文里面有 IP 的头、TCP 的头、HTTP 的头。如果放不下,就需要分片来传输。</li>
</ul>
<h2 id="DHCP:IP是怎么来的,又是怎么没的?">DHCP:IP 是怎么来的,又是怎么没的?</h2>
<h3 id="如何配置IP地址">如何配置 IP 地址</h3>
<p>命令行自己配置一个地址。可以使用 ifconfig,也可以使用 ip addr。设置好了以后,用这两个命令,将网卡 up 一下,就可以开始工作了。</p>
<p>但是不能随便配置,例如<code>192.168.1.6</code>就在你这台机器的旁边,甚至是在同一个交换机上,而你把机器的地址设为了<code>16.158.23.6</code>。在这台机器上,你企图去<code>ping 192.168.1.6</code>,你看着它有自己的源 IP 地址<code>16.158.23.6</code>,也有目标 IP 地址<code>192.168.1.6</code>,但是包发不出去,这是因为 MAC 层还没填。<strong>IP 只有是一个网段的,它才会发送 ARP 请求,获取 MAC 地址</strong>。<br>
如果不是,<strong>它便不会直接将包发送到网络上,而是企图将包发送到网关</strong>。</p>
<p>如果你配置了网关的话,Linux 会获取网关的 MAC 地址,然后将包发出去。对于<code>192.168.1.6</code>这台机器来讲,虽然路过它家门的这个包,目标 IP 是它,但是无奈 MAC 地址不是它的,所以它的网卡是不会把包收进去的。如果没有配置网关,那包压根就发不出去。</p>
<p><strong>网关要和当前的网络至少一个网卡是同一个网段的</strong>,否则不会配置成功。</p>
<h3 id="动态主机配置协议(DHCP)">动态主机配置协议(DHCP)</h3>
<p>有了这个协议,网络管理员只需要配置一段共享的 IP 地址。每一台新接入的机器都通过 DHCP 协议,来这个共享的 IP 地址里申请,然后自动配置好就可以了。等人走了,或者用完了,还回去,这样其他的机器也能用。</p>
<p><strong>如果是数据中心里面的服务器,IP 一旦配置好,基本不会变,这就相当于买房自己装修。 DHCP 的方式就相当于租房。你不用装修,都是帮你配置好的。你暂时用一下,用完退租就可以了。</strong></p>
<h3 id="解析DHCP的工作方式">解析 DHCP 的工作方式</h3>
<p>一台机器新加入一个网络的时候,只知道自己的 MAC 地址。怎么办?先吼一句,我来啦,有人吗?这时候的沟通基本靠 “吼”。这一步,我们称为<strong>DHCP Discover</strong>。</p>
<p>第一步:<br>
新来的机器使用 IP 地址 0.0.0.0 发送了一个广播包,目的 IP 地址为 255.255.255.255。<br>
<a href="https://blog.shipengqi.top/images/network-protocol/dhcp1.jpg"><img src="https://app.yinxiang.com/shard/s51/res/900256b7-2445-4b71-8dd3-29fae750f1bf.png" alt=""></a></p>
<p>第二步:<br>
<strong>DHCP Server</strong>立刻能知道来了一个 “新人”,这个时候,我们可以体会 MAC 地址唯一的重要性了。当一台机器带着自己的 MAC 地址加入一个网络的时候,MAC 是它唯一的身份,<br>
如果连这个都重复了,就没办法配置了。<strong>只有 MAC 唯一,IP 管理员才能知道这是一个新人</strong>。租给它一个 IP 地址,这个过程我们称为<strong>DHCP Offer</strong>。同时,DHCP Server 为<br>
此客户<strong>保留为它提供的 IP 地址</strong>,从而不会为其他 DHCP 客户分配此 IP 地址。<strong>DHCP Offer 里面有新的分配的地址</strong>:<br>
<a href="https://blog.shipengqi.top/images/network-protocol/dhcp2.jpg"><img src="https://app.yinxiang.com/shard/s51/res/e0969149-54a5-416a-9a23-1b19b6d0c9bf.png" alt=""></a></p>
<p>DHCP Server 仍然使用广播地址作为目的地址,因为,此时请求分配 IP 的新人还没有自己的 IP。</p>
<p>第三步:<br>
如果有多个 DHCP Server,这台新机器会收到多个 IP 地址,选择其中一个 DHCP Offer,<strong>一般是最先到达的那个</strong>。并且会向网络发送一个 DHCP Request 广播数据包,包中包含客户端的 MAC 地址、接受的租约中的 IP 地址、提供此租约的 DHCP 服务器地址等,并告诉所有 DHCP Server 它将接受哪一台服务器提供的 IP 地址,告诉其他 DHCP 服务器请求撤销它们提供的 IP 地址,以便提供给下一个 IP 租用请求者。<br>
<a href="https://blog.shipengqi.top/images/network-protocol/dhcp3.jpg"><img src="https://app.yinxiang.com/shard/s51/res/a5be6798-bef0-47ff-86d7-711ffaefcbc5.png" alt=""></a></p>
<p>由于还没有得到 DHCP Server 的最后确认,客户端仍然使用<code>0.0.0.0</code>为源 IP 地址、<code>255.255.255.255</code>为目标地址进行广播。</p>
<p>第四步:<br>
DHCP Server 接收到客户机的 DHCP request 之后,会广播返回给客户机一个 DHCP ACK 消息包,表明已经接受客户机的选择,并将这一 IP 地址的合法租用信息和其他的配置信息都放入该广播包,发给客户机,欢迎它加入网络大家庭。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/dhcp4.jpg"><img src="https://app.yinxiang.com/shard/s51/res/9b5f5b45-8365-497e-9a63-ed7802fa05e1.png" alt=""></a></p>
<h3 id="IP地址的收回和续租">IP 地址的收回和续租</h3>
<p>客户机会在租期过去 50% 的时候,直接向为其提供 IP 地址的 DHCP Server 发送 DHCP request 消息包。客户机接收到该服务器回应的 DHCP ACK 消息包,会根据包中所提供的新的租期以及其他已经更新的 TCP/IP 参数,更新自己的配置。这样,IP 租用更新就完成了。</p>
<h2 id="从物理层到MAC层:如何在宿舍里自己组网玩联机游戏?">从物理层到 MAC 层:如何在宿舍里自己组网玩联机游戏?</h2>
<h3 id="第一层(物理层)">第一层(物理层)</h3>
<p>宿舍两个人的电脑怎么连接起来?可以使用路由器,但是路由器是在第三层上。我们先从第一层物理层开始说。</p>
<p>电脑连电脑。这种方式就是一根网线,有两个头。一头插在一台电脑的网卡上,另一头插在另一台电脑的网卡上。还需要配置这两台电脑的 IP 地址、子网掩码和默认网关。要想两台电脑能够通信,这三项必须配置成为一个网络,可以一个是<code>192.168.0.1/24</code>,另一个是<code>192.168.0.2/24</code>,否则是不通的。构成了一个最小的局域网,也即<strong>LAN</strong>。</p>
<p>两台电脑之间的网络包,包含 MAC 层吗?当然包含,要完整。IP 层要封装了 MAC 层才能将包放入物理层。</p>
<p>怎么把三台电脑连在一起呢?有一个叫作 Hub 的东西,也就是集线器。这种设备有多个口,可以将宿舍里的多台电脑连接起来。但是,和交换机不同,集线器没有大脑,它完全在物理层工作。它会将自己收到的每一个字节,都复制到其他端口上去。这是第一层物理层联通的方案。</p>
<h3 id="第二层(数据链路层)">第二层(数据链路层)</h3>
<p>Hub 采取的是广播的模式,如果每一台电脑发出的包,宿舍的每个电脑都能收到。这就需要解决几个问题:</p>
<ol>
<li>这个包是发给谁的?谁应该接收?</li>
<li>大家都在发,会不会产生混乱?有没有谁先发、谁后发的规则?</li>
<li>如果发送的时候出现了错误,怎么办?</li>
</ol>
<p>这几个问题都是第二层,数据链路层,也即 MAC 层要解决的问题。<strong>MAC 的全称是 Medium Access Control,即媒体访问控制</strong>。</p>
<p>第二个问题,有很多算法可以解决:</p>
<ul>
<li>方式一:分多个车道。每个车一个车道,你走你的,我走我的。这在计算机网络里叫作 <strong> 信道划分 </strong>;</li>
<li>方式二:今天单号出行,明天双号出行,轮着来。这在计算机网络里叫作 <strong> 轮流协议 </strong>;</li>
<li>方式三:不管三七二十一,有事儿先出门,发现特堵,就回去。错过高峰再出。我们叫作 <strong> 随机接入协议 </strong>。著名的以太网,用的就是这个方式。</li>
</ul>
<p>解决了第二个问题,就是解决了媒体接入控制的问题。</p>
<p>第一个问题:这里用到链路层地址,也被称为<strong>MAC 地址</strong>。</p>
<p>第二层的网络包格式:<br>
<a href="https://blog.shipengqi.top/images/network-protocol/macpack.jpg"><img src="https://app.yinxiang.com/shard/s51/res/d1e86a02-d170-4175-96e2-f9c9df617138.png" alt=""></a></p>
<p>有了这个目标 MAC 地址,数据包在链路上广播,MAC 的网卡才能发现,这个包是给它的。MAC 的网卡把包收进来,然后打开 IP 包,发现 IP 地址也是自己的,再打开 TCP 包,发现端口是自己,也就是 80,而 nginx 就是监听 80。</p>
<p>于是将请求提交给 nginx,nginx 返回一个网页。然后将网页需要发回请求的机器。然后层层封装,最后到 MAC 层。因为来的时候有源 MAC 地址,返回的时候,源 MAC 就变成了目标 MAC,再返给请求的机器。</p>
<p>第三个问题:<strong>CRC</strong>,也就是<strong>循环冗余检测</strong>。通过 XOR 异或的算法,来计算整个包是否在发送的过程中出现了错误。</p>
<h3 id="ARP协议">ARP 协议</h3>
<p>当源机器知道目标机器的时候,可以将目标地址放入包里面,如果不知道呢?一个广播的网络里面接入了 N 台机器,我怎么知道每个 MAC 地址是谁呢?这就是<strong>ARP 协议</strong>,也就是已知 IP 地址,求 MAC 地址的协议。</p>
<p>在一个局域网里面,当知道了 IP 地址,不知道 MAC 怎么办呢?靠 “吼”。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/arp.jpg"><img src="https://app.yinxiang.com/shard/s51/res/7d18e695-d8f4-46d2-94f7-20571be0e56e/arp.jpg" alt=""></a></p>
<p>为了避免每次都用 ARP 请求,机器本地也会进行 ARP 缓存。当然机器会不断地上线下线,IP 也可能会变,所以 ARP 的 MAC 地址缓存过一段时间就会过期。</p>
<h3 id="局域网">局域网</h3>
<p>Hub 组网的方式,一旦机器数目增多,问题就出现了。因为 Hub 是广播的,不管某个接口是否需要,所有的 Bit 都会被发送出去,然后让主机来判断是不是需要。这种方式路上的车少就没问题,车一多,产生冲突的概率就提高了。而且把不需要的包转发过去,纯属浪费。</p>
<p>这就需要二层设备,<strong>交换机</strong>。</p>
<p>因为每个口都只连接一台电脑,这台电脑又不怎么换 IP 和 MAC 地址,只要记住这台电脑的 MAC 地址,如果目标 MAC 地址不是这台电脑的,这个口就不用转发了。交换机怎么知道每个口的电脑的 MAC 地址呢?这需要交换机会学习。</p>
<p>一台 MAC1 电脑将一个包发送给另一台 MAC2 电脑,当这个包到达交换机的时候,一开始交换机也不知道 MAC2 的电脑在哪个口,所以没办法,它只能将包转发给出了来的那个口之外的其他所有的口。这个时候,交换机会记住 MAC1 是来自一个明确的口。以后有包的目的地址是 MAC1 的,直接发送到这个口就可以了。</p>
<p>交换机作为一个关卡一样,过了一段时间之后,就有了整个网络的一个结构了,这个时候,基本上不用广播了,全部可以准确转发。当然,每个机器的 IP 地址会变,所在的口也会变,因而交换机上的学习的结果,我们称为<strong>转发表</strong>,是有一个过期时间的。</p>
<h2 id="交换机与VLAN:办公室太复杂,我要回学校">交换机与 VLAN:办公室太复杂,我要回学校</h2>
<h3 id="拓扑结构是怎么形成的">拓扑结构是怎么形成的</h3>
<p>常见到的办公室大多是一排排桌子,每个桌子都有网口,一排就有十几个网口,一个楼层就会有几十个甚至上百个网口。如果算上所有楼层,这个场景自然比宿舍里的复杂多了。</p>
<p>这个时候,一个交换机肯定不够用,需要多台交换机,交换机之间连接起来,就形成一个稍微复杂的<strong>拓扑结构</strong>。</p>
<p>下图中,两台交换机连接着三个局域网,每个局域网上都有多台机器。如果机器 1 只知道机器 4 的 IP 地址,当它想要访问机器 4,把包发出去的时候,它必须要知道机器 4 的 MAC 地址。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/network-topology1.jpg"><img src="https://app.yinxiang.com/shard/s51/res/7cc81ee9-7022-48cc-a738-7760682c5ce1.png" alt=""></a></p>
<p>于是机器 1 发起广播,机器 2 收到这个广播,但是这不是找它的,所以没它什么事。交换机 A 一开始是不知道任何拓扑信息的,在它收到这个广播后,采取的策略是,除了广播包来的方向外,它还要转发给其他所有的网口。于是机器 3 也收到广播信息了,但是这和它也没什么关系。当然,交换机 B 也是能够收到广播信息的,但是这时候它也是不知道任何拓扑信息的,因而也是进行广播的策略,将包转发到局域网三。这个时候,机器 4 和机器 5 都收到了广播信息。机器 4 主动响应说,这是找我的,这是我的 MAC 地址。于是一个 ARP 请求就成功完成了。</p>
<p>在上面的过程中,交换机 A 和交换机 B 都是能够学习到这样的信息:机器 1 是在左边这个网口的。当了解到这些拓扑信息之后,情况就好转起来。当机器 2 要访问机器 1 的时候,机器 2 并不知道机器 1 的 MAC 地址,所以机器 2 会发起一个 ARP 请求。这个广播消息会到达机器 1 ,也同时会到达交换机 A 。这个时候交换机 A 已经知道机器 1 是不可能在右边的网口的,所以这个广播信息就不会广播到局域网二和局域网三。</p>
<p>当机器 3 要访问机器 1 的时候,也需要发起一个广播的 ARP 请求。这个时候交换机 A 和交换机 B 都能够收到这个广播请求。交换机 A 当然知道主机 A 是在左边这个网口的,所以会把广播消息转发到局域网一。同时,交换机 B 收到这个广播消息之后,由于它知道机器 1 是不在右边这个网口的,所以不会将消息广播到局域网三。</p>
<h3 id="如何解决常见的环路问题">如何解决常见的环路问题</h3>
<p>当整个拓扑结构复杂了,这么多网线,绕过来绕过去,不可避免地会出现一些意料不到的情况。其中常见的问题就是环路问题。</p>
<p>下面途中,就出现了环路。<br>
<a href="https://blog.shipengqi.top/images/network-protocol/network-topology2.jpg"><img src="https://app.yinxiang.com/shard/s51/res/fccab3a0-8c06-4ead-869e-5287324c3456.png" alt=""></a></p>
<p>想象一下机器 1 访问机器 2 的过程。一开始,机器 1 并不知道机器 2 的 MAC 地址,所以它需要发起一个 ARP 的广播。广播到达机器 2,机器 2 会把 MAC 地址返回来,看起来没有这两个交换机什么事情。</p>
<p>但是问题来了,这两个交换机还是都能够收到广播包的。交换机 A 一开始是不知道机器 2 在哪个局域网的,所以它会把广播消息放到局域网二,在局域网二广播的时候,交换机 B 右边这个网口也是能够收到广播消息的。交换机 B 会将这个广播息信息发送到局域网一。局域网一的这个广播消息,又会到达交换机 A 左边的这个接口。交换机 A 这个时候还是不知道机器 2 在哪个局域网,于是将广播包又转发到局域网二。左转左转左转,好像是个圈。</p>
<p>并且这种情况,<strong>交换机是学习不到拓扑结构的</strong>,为什么?</p>
<p>机器 1 的广播包到达交换机 A 和交换机 B 的时候,本来两个交换机都学会了机器 1 是在局域网一的,但是当交换机 A 将包广播到局域网二之后,交换机 B 右边的网口收到了来自交换机 A 的广播包。根据学习机制,这彻底损坏了交换机 B 的三观,刚才机器 1 还在左边的网口呢,怎么又出现在右边的网口呢?哦,那肯定是机器 1 换位置了,于是就误会了,交换机 B 就学会了,机器 1 是从右边这个网口来的,把刚才学习的那一条清理掉。<br>
同理,交换机 A 右边的网口,也能收到交换机 B 转发过来的广播包,同样也误会了,于是也学会了,机器 1 从右边的网口来,不是从左边的网口来。</p>
<p>然而当广播包从左边的局域网一广播的时候,两个交换机再次刷新三观,原来机器 1 是在左边的,过一会儿,又发现不对,是在右边的,过一会,又发现不对,是在左边的。</p>
<h3 id="破除环路,STP协议中那些难以理解的概念">破除环路,STP 协议中那些难以理解的概念</h3>
<p>计算机网络中,生成树的算法叫作 STP,全称 Spanning Tree Protocol。<br>
<a href="https://blog.shipengqi.top/images/network-protocol/network-topology3.jpg"><img src="https://app.yinxiang.com/shard/s51/res/07ed2340-39cc-4b65-ab51-5a9a3b5338fd.png" alt=""></a></p>
<ul>
<li><strong>Root Bridge</strong>,也就是 <strong> 根交换机 </strong>。这个比较容易理解,可以比喻为 “掌门” 交换机,是某棵树的老大,是掌门,最大的大哥。</li>
<li><strong>Designated Bridges</strong>,有的翻译为 <strong> 指定交换机 </strong>。这个比较难理解,可以想像成一个 “小弟”,对于树来说,就是一棵树的树枝。所谓 “指定” 的意思是,我拜谁做大哥,其他交换机通过这个交换机到达根交换机,也就相当于拜他做了大哥。这里注意是树枝,不是叶子,因为叶子往往是主机。</li>
<li><strong>Bridge Protocol Data Units (BPDU)</strong>,<strong> 网桥协议数据单元 </strong>。可以比喻为 “相互比较实力” 的协议。行走江湖,比的就是武功,拼的就是实力。当两个交换机碰见的时候,也就是相连的时候,就需要互相比一比内力了。BPDU 只有掌门能发,已经隶属于某个掌门的交换机只能传达掌门的指示。</li>
<li><strong>Priority Vector,优先级向量 </strong>。可以比喻为实力(值越小越牛)。实力是啥?就是一组 ID 数目,[Root Bridge ID, Root Path Cost, Bridge ID, and Port ID]。为什么这样设计呢?<br>
这是因为要看怎么来比实力。先看 Root Bridge ID。拿出老大的 ID 看看,发现掌门一样,那就是师兄弟;再比 Root Path Cost,也即我距离我的老大的距离,也就是拿和掌门关系比,看同一个门派内谁和老大关系铁;最后比 Bridge ID,比我自己的 ID,拿自己的本事比。</li>
</ul>
<h3 id="VLAN">VLAN</h3>
<p>机器多了,交换机也多了,就算交换机比 Hub 智能一些,但是还是难免有广播的问题,一大波机器,相关的部门、不相关的部门,广播一大堆,性能就下来了。公司有不同的部门,有的部门需要保密的,比如人事部门,肯定要讨论升职加薪的事儿。由于在同一个广播域里面,很多包都会在一个局域网里面飘啊飘,碰到了一个会抓包的程序员,就能抓到这些包,如果没有加密,就能看到这些敏感信息了。怎么办?两种办法:</p>
<ul>
<li>物理隔离,每个部门有单独的交换机,配置单独的子网,这样部门之间的沟通就需要路由器了。但是有的部门人多,有的人少,如果每个部门有单独的交换机,口多了浪费,少了又不够用。</li>
<li>虚拟隔离,也就是 VLAN。或者叫虚拟局域网。使用 VLAN,一个交换机上会连属于多个局域网的机器。</li>
</ul>
<p>交换机怎么区分哪个机器属于哪个局域网?<br>
<a href="https://blog.shipengqi.top/images/network-protocol/vlan1.jpg"><img src="https://app.yinxiang.com/shard/s51/res/997f6ec0-10b1-46bf-85ab-e4e40d85e69f.png" alt=""></a></p>
<p>只需要在原来的二层的头上加一个 TAG,里面有一个 VLAN ID,一共 12 位。</p>
<p>如果我们买的交换机是支持 VLAN 的,当这个交换机把二层的头取下来的时候,就能够识别这个 VLAN ID。这样只有相同 VLAN 的包,才会互相转发,不同 VLAN 的包,是看不到的。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/vlan2.jpg"><img src="https://app.yinxiang.com/shard/s51/res/3acb165f-d0c5-463b-b9d5-c0fb8b53d63a.png" alt=""></a></p>
<p>可以设置交换机每个口所属的 VLAN。如果某个口坐的是程序员,他们属于 VLAN 10;如果某个口坐的是人事,他们属于 VLAN 20;如果某个口坐的是财务,他们属于 VLAN30。这样,财务发的包,交换机只会转发到 VLAN 30 的口上。</p>
<p>对于交换机来讲,每个 VLAN 的口都是可以重新设置的。一个财务走了,把他所在的作为的口从 VLAN 30 移除掉,来了一个程序员,坐在财务的位置上,就把这个口设置为 VLAN 10,十分灵活。</p>
<p>对于支持 VLAN 的交换机,有一种口叫作<strong>Trunk 口</strong>。它可以转发属于任何 VLAN 的口。交换机之间可以通过这种口相互连接。</p>
<h2 id="ICMP与ping:投石问路的侦察兵">ICMP 与 ping:投石问路的侦察兵</h2>
<p>ping 是基于 ICMP 协议工作的。ICMP 全称 Internet Control Message Protocol,就是<strong>互联网控制报文协议</strong>。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/ping.jpg"><img src="https://app.yinxiang.com/shard/s51/res/9376d77a-efe5-446b-95a1-1efea85ee04f.png" alt=""></a></p>
<ul>
<li>ICMP 相当于网络世界的侦察兵。两种类型的 ICMP 报文,一种是主动探查的查询报文,一种异常报告的差错报文。</li>
<li>ping 使用查询报文,Traceroute 使用差错报文。</li>
</ul>
<h2 id="世界这么大,我想出网关:欧洲十国游与玄奘西行">世界这么大,我想出网关:欧洲十国游与玄奘西行</h2>
<h3 id="怎么在宿舍上网?">怎么在宿舍上网?</h3>
<p>路由器,路由器会有内网网口和外网网口。把外网网口的线插到校园网的网口上,将这个外网网口配置成和网管部的一样。内网网口连上你们宿舍的所有的电脑。这种情况下,如果你们宿舍的人要上网,就需要一直开着路由器。</p>
<p>在任何一台机器上,当要访问另一个 IP 地址的时候,都会使用 CIDR 和子网掩码先判断是否在同一个网段。</p>
<ul>
<li>如果是同一个网段,例如,你访问你旁边的兄弟的电脑,那就没网关什么事情,直接将源地址和目标地址放入 IP 头中,然后通过 ARP 获得 MAC 地址,将源 MAC 和目的 MAC 放入 MAC 头中,发出去就可以了。</li>
<li>如果不是同一网段,例如,你要访问你们校园网里面的 BBS,该怎么办?这就需要发往默认网关 Gateway。Gateway 的地址一定是和源 IP 地址是一个网段的。往往不是第一个,就是第二个。例如 <code>192.168.1.0/24</code> 这个网段,Gateway 往往会是 <code>192.168.1.1/24</code> 或者 <code>192.168.1.2/24</code>。</li>
</ul>
<p>如何发往默认网关呢?网关不是和源 IP 地址是一个网段的么?这个过程就和发往同一个网段的其他机器是一样的:将源地址和目标 IP 地址放入 IP 头中,通过 ARP 获得网关的 MAC 地址,将源 MAC 和网关的 MAC 放入 MAC 头中,发送出去。网关所在的端口,例如<code>192.168.1.1/24</code>将网络包收进来,然后接下来怎么做,就完全看网关的了。</p>
<p>网关往往是一个路由器,是一个三层转发的设备。啥叫三层设备?前面也说过了,就是把 MAC 头和 IP 头都取下来,然后根据里面的内容,看看接下来把包往哪里转发的设备。</p>
<p><strong>路由器是一台设备,它有五个网口或者网卡,相当于有五只手,分别连着五个局域网。每只手的 IP 地址都和局域网的 IP 地址相同的网段,每只手都是它握住的那个局域网的网关。</strong></p>
<h3 id="静态路由是什么?">静态路由是什么?</h3>
<p>静态路由,其实就是在路由器上,配置一条一条规则。这些规则包括:想访问 BBS 站(它肯定有个网段),从 2 号口出去,下一跳是 IP2;想访问教学视频站(它也有个自己的网段),从 3 号口出去,下一跳是 IP3,然后保存在路由器里。</p>
<h3 id="IP头和MAC头哪些变、哪些不变?">IP 头和 MAC 头哪些变、哪些不变?</h3>
<p>MAC 地址是一个局域网内才有效的地址。因而,MAC 地址只要过网关,就必定会改变,因为已经换了局域网。两者主要的区别在于 IP 地址是否改变。不改变 IP 地址的网关,<br>
我们称为<strong>转发网关</strong>;改变 IP 地址的网关,我们称为<strong>NAT 网关</strong>。</p>
<h4 id="“欧洲十国游”型">“欧洲十国游” 型</h4>
<p><a href="https://blog.shipengqi.top/images/network-protocol/gateway1.jpg"><img src="https://app.yinxiang.com/shard/s51/res/a38dccf7-33e0-4569-ab71-b99ca999182e.png" alt=""></a></p>
<p>服务器 A 要访问服务器 B。首先,<code>192.168.4.101</code>和我不在同一个网段的,需要先发给网关。那网关是谁呢?已经静态配置好了,网关是<code>192.168.1.1</code>。发送 ARP 获取网关的 MAC 地址,然后发送包。包的内容是这样的:</p>
<pre><code>源 MAC:服务器 A 的 MAC
目标 MAC:192.168.1.1 这个网口的 MAC
源 IP:192.168.1.101
目标 IP:192.168.4.101</code></pre>
<p>包到达<code>192.168.1.1</code>这个网口,发现 MAC 一致,将包收进来,开始思考往哪里转发。</p>
<p>在路由器 A 中配置了静态路由之后,要想访问<code>192.168.4.0/24</code>,要从<code>192.168.56.1</code>这个口出去,下一跳为<code>192.168.56.2</code>。发送 ARP 获取<code>192.168.56.2</code>的 MAC 地址,然后发送包。<br>
包的内容是这样的:</p>
<pre><code>源 MAC:192.168.56.1 的 MAC 地址
目标 MAC:192.168.56.2 的 MAC 地址
源 IP:192.168.1.101
目标 IP:192.168.4.101</code></pre>
<p>包到达<code>192.168.56.2</code>这个网口,发现 MAC 一致,将包收进来,开始思考往哪里转发。</p>
<p>路由器 B 中配置了静态路由,要想访问<code>192.168.4.0/24</code>,要从<code>192.168.4.1</code>这个口出去,没有下一跳了。因为我右手这个网卡,就是这个网段的,我是最后一跳了。发送 ARP 获取<code>192.168.4.101</code>的 MAC 地址,然后发送包。包的内容是这样的:</p>
<pre><code>源 MAC:192.168.4.1 的 MAC 地址
目标 MAC:192.168.4.101 的 MAC 地址
源 IP:192.168.1.101
目标 IP:192.168.4.101</code></pre>
<p>包到达服务器 B,MAC 地址匹配,将包收进来。</p>
<p>这个过程可以看出,每到一个新的局域网,MAC 都是要变的,但是 IP 地址都不变。在 IP 头里面,不会保存任何网关的 IP 地址。所谓的下一跳是,某个 IP 要将这个 IP 地址转换为 MAC 放入 MAC 头。</p>
<p>之所以将这种模式比喻称为欧洲十国游,是因为在整个过程中,IP 头里面的地址都是不变的。IP 地址在三个局域网都可见,在三个局域网之间的网段都不会冲突。在三个网段之间传输包,IP 头不改变。 这就像在欧洲各国之间旅游,一个签证就能搞定。</p>
<h4 id="“玄奘西行”型">“玄奘西行” 型</h4>
<p><a href="https://blog.shipengqi.top/images/network-protocol/gateway2.jpg"><img src="https://app.yinxiang.com/shard/s51/res/fbc3b82a-d64e-4f52-8010-76e7e7afa9fe.png" alt=""></a></p>
<p>遇见的第一个问题是,局域网之间没有商量过,各定各的网段,因而 IP 段冲突了。最左面大唐的地址是<code>192.168.1.101</code>,最右面印度的地址也是<code>192.168.1.101</code>,如果单从 IP 地址上看,简直是自己访问自己,其实是大唐的<code>192.168.1.101</code>要访问印度的<code>192.168.1.101</code>。怎么解决这个问题呢?既然局域网之间没有商量过,你们各管各的,那到国际上,也即中间的局域网里面,就需要使用另外的地址。就像出国,不能用咱们自己的身份证,而要改用护照一样,玄奘西游也要拿着专门取经的通关文牒,而不能用自己国家的身份证。</p>
<p>首先,目标服务器 B 在国际上要有一个国际的身份,我们给它一个<code>192.168.56.2</code>。在网关 B 上记下来,国际身份<code>192.168.56.2</code>对应国内身份<code>192.168.1.101</code>。凡是要访问<code>192.168.56.2</code>,都转成<code>192.168.1.101</code>。</p>
<p>源服务器 A 要访问目标服务器 B,要指定的目标地址为<code>192.168.56.2</code>。这是它的国际身份。<code>192.168.56.2</code>和我不是一个网段的,因而需要发给网关<code>192.168.1.1</code>,发送 ARP 获取网关的 MAC 地址,然后发送包。包的内容是这样的:</p>
<pre><code>源 MAC:服务器 A 的 MAC
目标 MAC:192.168.1.1 这个网口的 MAC
源 IP:192.168.1.101
目标 IP:192.168.56.2</code></pre>
<p>路由器 A 中配置了静态路由:要想访问<code>192.168.56.2/24</code>,要从<code>192.168.56.1</code>这个口出去,没有下一跳了,因为我右手这个网卡,就是这个网段的,我是最后一跳了。发送 ARP<br>
获取<code>192.168.56.2</code>的 MAC 地址。</p>
<p>当网络包发送到中间的局域网的时候,服务器 A 也需要有个国际身份,因而在国际上,源 IP 地址也不能用<code>192.168.1.101</code>,需要改成<code>192.168.56.1</code>。发送包的内容是这样的:</p>
<pre><code>源 MAC:192.168.56.1 的 MAC 地址
目标 MAC:192.168.56.2 的 MAC 地址
源 IP:192.168.56.1
目标 IP:192.168.56.2</code></pre>
<p>路由器 B 是一个 NAT 网关,它上面配置了,要访问国际身份<code>192.168.56.2</code>对应国内身份<code>192.168.1.101</code>,于是改为访问<code>192.168.1.101</code>。</p>
<p>路由器 B 中配置了静态路由:要想访问<code>192.168.1.0/24</code>,要从<code>192.168.1.1</code>这个口出去,没有下一跳了,因为我右手这个网卡,就是这个网段的,我是最后一跳了。发送 ARP 获取<code>192.168.1.101</code>的 MAC 地址,然后发送包。内容是这样的:</p>
<pre><code>源 MAC:192.168.1.1 的 MAC 地址
目标 MAC:192.168.1.101 的 MAC 地址
源 IP:192.168.56.1
目标 IP:192.168.1.101</code></pre>
<p>服务器 B 接收的包可以看出,源 IP 为服务器 A 的国际身份,因而发送返回包的时候,也发给这个国际身份,由路由器 A 做 NAT,转换为国内身份。</p>
<p>这个过程可以看出,IP 地址也会变。这个过程用英文说就是<strong>Network Address Translation</strong>,简称<strong>NAT</strong>。</p>
<p>第二种方式我们经常见,现在大家每家都有家用路由器,家里的网段都是<code>192.168.1.x</code>,所以你肯定访问不了你邻居家的这个私网的 IP 地址的。所以,当我们家里的包发出去的时候,都被家用路由器 NAT 成为了运营商的地址了。</p>
<h2 id="路由协议:西出网关无故人,敢问路在何方">路由协议:西出网关无故人,敢问路在何方</h2>
<h3 id="如何配置路由">如何配置路由</h3>
<p>路由器就是一台网络设备,它有多张网卡。当一个入口的网络包送到路由器时,它会根据一个本地的转发信息库,来决定如何正确地转发流量。这个转发信息库通常被称为<strong>路由表</strong>。</p>
<p>一张路由表中会有多条路由规则。每一条规则至少包含这三项信息。</p>
<ul>
<li>目的网络:这个包想去哪儿?</li>
<li>出口设备:将包从哪个口扔出去?</li>
<li>下一跳网关:下一个路由器的地址。</li>
</ul>
<p><strong>根据目的 IP 地址来配置路由</strong>,通过 route 命令和 ip route 命令。</p>
<h3 id="配置策略路由">配置策略路由</h3>
<p>除了可以根据目的 ip 地址配置路由外,还可以根据多个参数来配置路由,这就称为<strong>策略路由</strong>。</p>
<p>可以配置多个路由表,可以根据源 IP 地址、入口设备、TOS 等选择路由表,然后在路由表中查找路由。这样可以使得来自不同来源的包走不同的路由。</p>
<h3 id="动态路由算法">动态路由算法</h3>
<p>上面的都是静态路由。但是网络环境复杂并且多变,使用动态路由路由器,可以根据路由协议算法生成动态路由表,随网络运行状况的变化而变化。</p>
<p>可以想象唐僧西天取经,无论是一个国家内部,还是国家之间,我们都可以将复杂的路径,抽象为一种叫作图的数据结构。至于唐僧西行取经,肯定想走得路越少越好,道路越短越好,因而这就转化成为<strong>如何在途中找到最短路径的问题</strong>。</p>
<ol>
<li><p> 距离矢量路由算法 <br>
基于 Bellman-Ford 算法,这种算法的基本思路是,每个路由器都保存一个路由表,包含多行,每行对应网络中的一个路由器,每一行包含两部分信息,一个是要到目标路由器,<br>
从那条线出去,另一个是到目标路由器的距离。</p></li>
<li><p> 链路状态路由算法 </p></li>
</ol>
<p>基本思路是:当一个路由器启动的时候,首先是发现邻居,向邻居 say hello,邻居都回复。然后计算和邻居的距离,发送一个 echo,要求马上返回,除以二就是距离。然后将自<br>
己和邻居之间的链路状态包广播出去,发送到整个网络的每个路由器。这样每个路由器都能够收到它和邻居之间的关系的信息。因而,每个路由器都能在自己本地构建一个完整的图,然后针对这个图使用 Dijkstra 算法,找到两点之间的最短路径。</p>
<h2 id="UDP协议:因性善而简单,难免碰到“城会玩”">UDP 协议:因性善而简单,难免碰到 “城会玩”</h2>
<p><strong>所谓的建立连接,是为了在客户端和服务端维护连接,而建立一定的数据结构来维护双方交互的状态,用这样的数据结构来保证所谓的面向连接的特性</strong>。</p>
<ul>
<li>TCP 提供可靠交付。通过 TCP 连接传输的数据,无差错、不丢失、不重复、并且按序到达。</li>
<li>UDP 继承了 IP 包的特性,不保证不丢失,不保证按顺序到达。IP 包是没有任何可靠性保证的。</li>
<li>TCP 是面向字节流的。发送的时候发的是一个流,没头没尾。IP 包可不是一个流,而是一个个的 IP 包。之所以变成了流,这也是 TCP 自己的状态维护做的事情。</li>
<li>UDP 继承了 IP 的特性,基于数据报的,一个一个地发,一个一个地收。</li>
<li>TCP 是可以有拥塞控制的。它意识到包丢弃了或者网络的环境不好了,就会根据情况调整自己的行为,看看是不是发快了,要不要发慢点。</li>
<li>UDP 就不会,应用让我发,我就发,管它洪水滔天。</li>
<li>TCP 其实是一个有状态服务,通俗地讲就是有脑子的,里面精确地记着发送了没有,接收到没有,发送到哪个了,应该接收哪个了,错一点儿都不行。</li>
<li>UDP 则是无状态服务。</li>
</ul>
<p><strong>网络传输是以包为单位的,二层叫帧,网络层叫包,传输层叫段。我们笼统地称为包。包单独传输,自行选路,在不同的设备封装解封装,不保证到达。</strong>UDP 完全继承了这些特性。</p>
<h3 id="UDP包头">UDP 包头</h3>
<p><a href="https://blog.shipengqi.top/images/network-protocol/udppack.jpg"><img src="https://app.yinxiang.com/shard/s51/res/0aa51189-84c7-4e1d-98be-569d001de555.png" alt=""></a></p>
<p>IP 头里面有个 8 位协议,这里会存放,数据里面到底是 TCP 还是 UDP。解析玩 UDP,一台机器上跑着这么多的应用程序,应该给谁?无论应用程序使用 TCP 还是 UDP 传数据,都要监听一个端口。正是这个端口,用来区分应用程序。</p>
<h3 id="三个特点">三个特点</h3>
<ul>
<li>第一,沟通简单,相信网络通路默认就是很容易送达的,不容易被丢弃的。</li>
<li>第二,轻信他人。它不会建立连接,虽然有端口号,但是监听在这个地方,谁都可以传给他数据。也可以传给任何人数据。</li>
<li>第三,愣头青,做事不懂权变。不会根据网络的情况进行发包的拥塞控制,无论网络丢包丢成啥样了,它该怎么发还怎么发。</li>
</ul>
<h3 id="三个场景">三个场景</h3>
<ul>
<li>需要资源少,在网络情况比较好的内网,或者对于丢包不敏感的应用。</li>
<li>不需要一对一沟通,建立连接,而是可以广播的应用。</li>
<li>需要处理速度快,时延低,可以容忍少数丢包,但是要求即便网络拥塞,也毫不退缩,一往无前的时候。</li>
</ul>
<h2 id="TCP协议(上):因性恶而复杂,先恶后善反轻松">TCP 协议(上):因性恶而复杂,先恶后善反轻松</h2>
<h3 id="TCP包头">TCP 包头</h3>
<p><a href="https://blog.shipengqi.top/images/network-protocol/tcppack.jpg"><img src="https://app.yinxiang.com/shard/s51/res/97b75e7d-8226-42c2-bbce-2455bbe51d0d.png" alt=""></a></p>
<ul>
<li>包的序号,为了解决乱序的问题。</li>
<li>确认序号,发出去的包要确认,不然怎么知道有没有收到。没有收到就重发。为了解决不丢包的问题。</li>
<li>状态位,SYN 是发起一个连接,ACK 是回复,RST 是重连,FIN 是结束连接。</li>
<li>窗口大小,TCP 要做流量控制,双方各自声明一个窗口,表示自己当前能够的处理能力,避免发的太快或者太慢。TCP 还会做拥塞控制。</li>
</ul>
<h3 id="三次握手">三次握手</h3>
<p>为什么是三次,不是两次?为什么不是四次?因为通信双方都要保证通信可以有来有回。例如,A 和 B,A 发起一个连接(第一次握手),B 收到请求,并发送应答给 A(第二次握手),说明 B 可以建立连接,但是 B 的应答包,B 不知道 A 是否收到,可能丢失了,所以 A 需要应答 B 的应答包(第三次握手),B 收到这个消息,下能确认连接建立。</p>
<p>这也就是说,其实四次握手甚至更多也是可以的,但是只要双方的消息有去有回,就基本可以了。</p>
<p><strong>三次握手除了建立连接,还要沟通 TCP 包的序号</strong>。A 告诉 B 我发起的请求从哪个序号开始,B 告诉 A,B 发起的包的序号从哪个开始。<br>
为什么序号不都从 1 开始?为了防止冲突。</p>
<p>例如,A 连上 B,发送了 1,2,3 三个包,但是 3 丢失了或者绕路了,重新发送,但是后来 A 又掉线了,重新连上 B 后,又从 1 开始发,但是只发了 1,2,但是上次绕路的那个 3 又回来了,发给了 B,B 自然认为,这就是下一个包,于是发生了错误。</p>
<p>起始序号是随时间变化的,32 位,每 4S 加一。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/handshake.jpg"><img src="https://app.yinxiang.com/shard/s51/res/7c9842f8-2d8d-4d44-8be9-dbefed30a47c.png" alt=""></a></p>
<h3 id="四次挥手">四次挥手</h3>
<p>A:B 啊,我不想玩了。<br>
B:哦,你不想玩了啊,我知道了。</p>
<p>这个时候,还只是 A 不想玩了,也即 A 不会再发送数据,但是 B 能不能在 ACK 的时候,直接关闭呢?当然不可以了,很有可能 A 是发完了最后的数据就准备不玩了,但是 B 还没做完自己的事情,还是可以发送数据的,所以称为<strong>半关闭</strong>的状态。</p>
<p>B:A 啊,好吧,我也不玩了,拜拜。<br>
A:好的,拜拜</p>
<p>这样整个连接就关闭了。</p>
<p>但是这个过程有没有异常情况呢?</p>
<p>一种情况是,A 说完 “不玩了” 之后,直接跑路,是会有问题的,因为 B 还没有发起结束,而如果 A 跑路,B 就算发起结束,也得不到回答,B 就不知道该怎么办了。另一种情况是,A 说完 “不玩了”,B 直接跑路,也是有问题的,因为 A 不知道 B 是还有事情要处理,还是过一会儿会发送结束。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/handshake.jpg"><img src="https://app.yinxiang.com/shard/s51/res/7c9842f8-2d8d-4d44-8be9-dbefed30a47c.png" alt=""></a></p>
<p>A 收到 “B 说知道了”,就进入<code>FIN_WAIT_2</code>的状态,如果这个时候 B 直接跑路,则 A 将永远在这个状态。TCP 协议里面并没有对这个状态的处理,但是 Linux 有,可以调整<code>tcp_fin_timeout</code>这个参数,设置一个超时时间。</p>
<p>如果 B 没有跑路,发送了 “B 也不玩了” 的请求到达 A 时,A 发送 “知道 B 也不玩了” 的 ACK 后,从 FIN_WAIT_2 状态结束,按说 A 可以跑路了,但是最后的这个 ACK 万一 B 收不到呢?则 B 会重新发一个 “B 不玩了”,这个时候 A 已经跑路了的话,B 就再也收不到 ACK 了,因而 TCP 协议要求 A 最后等待一段时间 TIME_WAIT,这个时间要足够长,长到如果 B 没收到 ACK 的话,“B 说不玩了” 会重发的,A 会重新发一个 ACK 并且足够时间到达 B。</p>
<p>A 直接跑路还有一个问题是,A 的端口就直接空出来了,但是 B 不知道,B 原来发过的很多包很可能还在路上,如果 A 的端口被一个新的应用占用了,这个新的应用会收到上个连接中 B 发过来的包,虽然序列号是重新生成的,但是这里要上一个双保险,防止产生混乱,因而也需要等足够长的时间,等到原来 B 发送的所有的包都死翘翘,再空出端口来。</p>
<p>等待的时间设为 2MSL,<strong>MSL 是 Maximum Segment Lifetime,报文最大生存时间</strong>,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 域,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。协议规定 MSL 为 2 分钟,实际应用中常用的是 30 秒,1 分钟和 2 分钟等。</p>
<p>还有一个异常情况就是,B 超过了 2MSL 的时间,依然没有收到它发的 FIN 的 ACK,怎么办呢?按照 TCP 的原理,B 当然还会重发 FIN,这个时候 A 再收到这个包之后,A 就表示,我已经在这里等了这么长时间了,已经仁至义尽了,之后的我就都不认了,于是就直接发送 RST,B 就知道 A 早就跑了。</p>
<h2 id="TCP协议(下):西行必定多妖孽,恒心智慧消磨难">TCP 协议(下):西行必定多妖孽,恒心智慧消磨难</h2>
<h3 id="如何实现一个靠谱的协议?">如何实现一个靠谱的协议?</h3>
<p>为了保证顺序性,每一个包都有一个 ID。在建立连接的时候,会商定起始的 ID 是什么,然后按照 ID 一个个发送。为了保证不丢包,对于发送的包都要进行应答,但是这个应答也不是一个一个来的,而是会应答某个之前的 ID,表示都收到了,这种模式称为<strong>累计确认或者累计应答(cumulative acknowledgment)</strong>。</p>
<p>为了记录所有发送的包和接收的包,TCP 也需要发送端和接收端分别都有缓存来保存这些记录(为了效率,不能每发送一个包,要等到收到了应答,再发送下一个,所以现将事情几下来,办完一件回复一件)。发送端的缓存里是按照包的 ID 一个个排列,根据处理的情况分成四个部分。</p>
<ul>
<li>第一部分:发送了并且已经确认的。这部分就是你交代下属的,并且也做完了的,应该划掉的。</li>
<li>第二部分:发送了并且尚未确认的。这部分是你交代下属的,但是还没做完的,需要等待做完的回复之后,才能划掉。</li>
<li>第三部分:没有发送,但是已经等待发送的。这部分是你还没有交代给下属,但是马上就要交代的。</li>
<li>第四部分:没有发送,并且暂时还不会发送的。这部分是你还没有交代给下属,而且暂时还不会交代给下属的。</li>
</ul>
<p>为什么要区分第三部分和第四部分?<br>
因为流量控制,要考虑接收端的处理能力。</p>
<p>在 TCP 里,接收端会给发送端报一个窗口的大小,叫<strong>Advertised window</strong>。这个窗口的大小应该等于上面的第二部分加上第三部分,就是已经交代了没做完的加上马上要交代的。超过这个窗口的,接收端做不过来,就不能发送了。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/aw.jpg"><img src="https://app.yinxiang.com/shard/s51/res/57f5ea66-ab07-4bd2-8ace-f02121a5d244.png" alt=""></a></p>
<p>对于接收端来讲,它的缓存里记录的内容要简单一些。</p>
<ul>
<li>第一部分:接受并且确认过的。也就是我领导交代给我,并且我做完的。</li>
<li>第二部分:还没接收,但是马上就能接收的。也即是我自己的能够接受的最大工作量。</li>
<li>还没接收,也没法接收的。也即超过工作量的部分,实在做不完。</li>
</ul>
<p><a href="https://blog.shipengqi.top/images/network-protocol/recvbuffer.jpg"><img src="https://app.yinxiang.com/shard/s51/res/583cde98-810e-4920-aad2-17936a7c78c1.png" alt=""></a></p>
<ul>
<li>MaxRcvBuffer:最大缓存的量;</li>
<li>LastByteRead 之后是已经接收了,但是 <strong> 还没被应用层读取的 </strong>;</li>
<li>NextByteExpected 是第一部分和第二部分的分界线。</li>
</ul>
<p>NextByteExpected 和 LastByteRead 的差其实是还没被应用层读取的部分占用掉的 MaxRcvBuffer 的量,我们定义为 A。AdvertisedWindow 其实是 MaxRcvBuffer 减去 A。也就是:<br>
<code>AdvertisedWindow=MaxRcvBuffer-((NextByteExpected-1)-LastByteRead)</code>。</p>
<p>其中第二部分里面,由于受到的包可能不是顺序的,会出现空挡,<strong>只有和第一部分连续的,可以马上进行回复,中间空着的部分需要等待,哪怕后面的已经来了</strong>。</p>
<h3 id="顺序问题与丢包问题">顺序问题与丢包问题</h3>
<p>刚才的图,在发送端来看,1、2、3 已经发送并确认;4、5、6、7、8、9 都是发送了还没确认;10、11、12 是还没发出的;13、14、15 是接收方没有空间,不准备发的。</p>
<p>在接收端来看,1、2、3、4、5 是已经完成 ACK,但是没读取的;6、7 是等待接收的;8、9 是已经接收,但是没有 ACK 的。</p>
<p>当前的状态如下:</p>
<ul>
<li>1、2、3 没有问题,双方达成了一致。</li>
<li>4、5 接收方说 ACK 了,但是发送方还没收到,有可能丢了,有可能在路上。</li>
<li>6、7、8、9 肯定都发了,但是 8、9 已经到了,但是 6、7 没到,出现了乱序,缓存着但是没办法 ACK。</li>
</ul>
<p>顺序问题和丢包问题都有可能发生。</p>
<h4 id="确认与重发的机制">确认与重发的机制</h4>
<p>假设 4 的确认到了,不幸的是,5 的 ACK 丢了,6、7 的数据包丢了,这该怎么办?</p>
<p><strong>超时重试</strong>,也即对每一个发送了,但是没有 ACK 的包,都有设一个定时器,超过了一定的时间,就重新尝试。但是这个超时的时间如何评估呢?这个时间不宜过短,时间必须大于往返时间 RTT,否则会引起不必要的重传。也不宜过长,这样超时时间变长,访问就变慢了。</p>
<p>估计往返时间,需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个值,而且这个值还是要不断变化的,因为网络状况不断的变化。除了采样 RTT,还要采样 RTT 的波动范围,计算出一个估计的超时时间。由于重传时间是不断变化的,我们称为<strong>自适应重传算法(Adaptive RetransmissionAlgorithm)</strong>。</p>
<p>如果 7 丢了,重传之后又丢了,<strong>TCP 的策略是超时间隔加倍。每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送</strong>。</p>
<h4 id="快速重传">快速重传</h4>
<p>超时触发重传存在的问题是,超时周期可能相对较长。有一个可以快速重传的机制,当接收方收到一个序号大于下一个所期望的报文段时,就检测到了数据流中的一个间格,于是发送三个冗余的 ACK,客户端收到后,就在定时器过期之前,重传丢失的报文段。</p>
<h4 id="SelectiveAcknowledgment(SACK)">Selective Acknowledgment (SACK)</h4>
<p>这种方式需要在 TCP 头里加一个 SACK 的东西,可以将缓存的地图发送给发送方。例如可以发送 ACK6、SACK8、SACK9,有了地图,发送方一下子就能看出来是 7 丢了。</p>
<h3 id="流量控制问题">流量控制问题</h3>
<p>流量控制机制,<strong>在对于包的确认中,同时会携带一个窗口的大小</strong>。</p>
<p>先假设窗口不变的情况,窗口始终为 9。4 的确认来的时候,会右移一个,这个时候第 13 个包也可以发送了。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/rwnd1.jpg"><img src="https://app.yinxiang.com/shard/s51/res/a46ecac1-9827-4ed0-9d9d-5d53b69c6e39.png" alt=""></a></p>
<p>这个时候,假设发送端发送过猛,会将第三部分的 10、11、12、13 全部发送完毕,之后就停止发送了,未发送可发送部分为 0。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/rwnd2.jpg"><img src="https://app.yinxiang.com/shard/s51/res/3c6376b0-5e3c-4e0b-b727-274f516bad74.png" alt=""></a></p>
<p>当对于包 5 的确认到达的时候,在客户端相当于窗口再滑动了一格,这个时候,才可以有更多的包可以发送了,例如第 14 个包才可以发送。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/rwnd3.jpg"><img src="https://app.yinxiang.com/shard/s51/res/ecccc176-762b-4846-8df0-a2aeb4bab136.png" alt=""></a></p>
<p>如果接收方实在处理的太慢,导致缓存中没有空间了,可以通过确认信息修改窗口的大小,甚至可以设置为 0,则发送方将暂时停止发送。</p>
<p>我们假设一个极端情况,接收端的应用一直不读取缓存中的数据,当数据包 6 确认后,窗口大小就不能再是 9 了,就要缩小一个变为 8。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/rwnd4.jpg"><img src="https://app.yinxiang.com/shard/s51/res/49f991bc-c7cb-4ffc-a803-5ba1cc3ab3ac.png" alt=""></a></p>
<p>这个新的窗口 8 通过 6 的确认消息到达发送端的时候,你会发现窗口没有平行右移,而是仅仅左面的边右移了,窗口的大小从 9 改成了 8。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/rwnd5.jpg"><img src="https://app.yinxiang.com/shard/s51/res/5b8be30e-355a-456f-be4d-d640e6b61d84.png" alt=""></a></p>
<p>如果接收端还是一直不处理数据,则随着确认的包越来越多,窗口越来越小,直到为 0。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/rwnd6.jpg"><img src="https://app.yinxiang.com/shard/s51/res/abb64827-94b1-4a1b-a4f1-c0c0656ada2b.png" alt=""></a></p>
<p>当这个窗口通过包 14 的确认到达发送端的时候,发送端的窗口也调整为 0,停止发送。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/rwnd7.jpg"><img src="https://app.yinxiang.com/shard/s51/res/c1f44fd3-00dc-4dd2-bd06-44d49f8f56c4.png" alt=""></a></p>
<p>如果这样的话,发送方会定时发送窗口探测数据包,看是否有机会调整窗口的大小。当接收方比较慢的时候,<strong>要防止低能窗口综合征,别空出一个字节来就赶快告诉发送方,<br>
然后马上又填满了,可以当窗口太小的时候,不更新窗口,直到达到一定大小,或者缓冲区一半为空,才更新窗口</strong>。</p>
<h3 id="拥塞控制问题">拥塞控制问题</h3>
<p>拥塞控制的问题,也是通过窗口的大小来控制的,前面的<strong>滑动窗口 rwnd 是怕发送方把接收方缓存塞满,而拥塞窗口 cwnd,是怕把网络塞满</strong>。</p>
<p><code>LastByteSent - LastByteAcked <= min {cwnd, rwnd}</code>,是拥塞窗口和滑动窗口共同控制发送的速度。</p>
<p>发送方怎么判断网络是不是满了?TCP 发送包常被比喻为往一个水管里面灌水,而<strong>TCP 的拥塞控制就是在不堵塞,不丢包的情况下,尽量发挥带宽</strong>。</p>
<p>水管有粗细,网络有带宽,也即每秒钟能够发送多少数据;水管有长度,端到端有时延。在理想状态下,水管里面水的量 = 水管粗细 x 水管长度。对于到网络上,<strong>通道的容量 = 带宽 × 往返延迟</strong>。</p>
<p>如果我们设置发送窗口,使得发送但未确认的包为为通道的容量,就能够撑满整个管道。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/cwnd1.jpg"><img src="https://app.yinxiang.com/shard/s51/res/d94bdee9-a766-4ba7-930d-7a048dd7865d.png" alt=""></a></p>
<p>如图所示,假设往返时间为 8s,去 4s,回 4s,每秒发送一个包,每个包 1024byte。已经过去了 8s,则 8 个包都发出去了,其中前 4 个包已经到达接收端,但是 ACK 还没有返回,不能算发送成功。5-8 后四个包还在路上,还没被接收。这个时候,整个管道正好撑满,在发送端,已发送未确认的为 8 个包,正好等于带宽,也即每秒发送 1 个包,乘以来回时间 8s。</p>
<p>TCP 的拥塞控制主要来避免两种现象,包丢失和超时重传。一旦出现了这些现象就说明,发送速度太快了,要慢一点。但是一开始我怎么知道速度多快呢,我怎么知道应该把窗口调整到多大呢?<br>
如果我们通过漏斗往瓶子里灌水,我们就知道,不能一桶水一下子倒进去,肯定会溅出来,要一开始慢慢的倒,然后发现总能够倒进去,就可以越倒越快。这叫作<strong>慢启动</strong>。</p>
<p>一条 TCP 连接开始,cwnd 设置为一个报文段,一次只能发送一个;当收到这一个确认的时候,cwnd 加一,于是一次能够发送两个;当这两个的确认到来的时候,每个确认 cwnd 加一,两个确认 cwnd 加二,于是一次能够发送四个;当这四个的确认到来的时候,每个确认 cwnd 加一,四个确认 cwnd 加四,于是一次能够发送八个。可以看出这是<strong>指数性的增长</strong>。</p>
<p><strong>有一个值 ssthresh 为 65535 个字节,当超过这个值的时候,就要小心一点了,不能倒这么快了,可能快满了,再慢下来。</strong></p>
<p>每收到一个确认后,cwnd 增加 1/cwnd,我们接着上面的过程来,一次发送八个,当八个确认到来的时候,每个确认增加 1/8,八个确认一共 cwnd 增加 1,于是一次能够发送九个,变成了线性增长。但是线性增长还是增长,还是越来越多,直到有一天,水满则溢,出现了拥塞,这时候一般就会一下子降低倒水的速度,等待溢出的水慢慢渗下去。拥塞的一种表现形式是丢包,需要超时重传,这个时候,将 sshresh 设为 cwnd/2,将 cwnd 设为 1,重新开始慢启动。这真是一旦超时重传,马上回到解放前。但是这种方式太激进了,将一个高速的传输速度一下子停了下来,会造成网络卡顿。</p>
<p>当接收端发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速的重传,不必等待超时再重传。TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,cwnd 减半为 cwnd/2,然后 sshthresh = cwnd,当三个包返回的时候,cwnd = sshthresh +3,也就是没有一夜回到解放前,而是还在比较高的值,呈线性增长。就像前面说的一样,正是这种知进退,使得时延很重要的情况下,反而降低了速度。但是如果你仔细想一下,TCP 的拥塞控制主要来避免的两个现象都是有问题的。</p>
<ul>
<li>第一个问题是丢包并不代表着通道满了,也可能是管子本来就漏水。例如公网上带宽不满也会丢包,这个时候就认为拥塞了,退缩了,其实是不对的。</li>
<li>第二个问题是 TCP 的拥塞控制要等到将中间设备都填充满了,才发生丢包,从而降低速度,这时候已经晚了。其实 TCP 只要填满管道就可以了,不应该接着填,直到连缓存也填满。</li>
</ul>
<p>为了优化这两个问题,后来有了 TCP BBR 拥塞算法。它企图找到一个平衡点,就是通过不断的加快发送速度,将管道填满,但是不要填满中间设备的缓存,因为这样时延会增加,在这个平衡点可以很好的达到高带宽和低时延的平衡。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/cwnd2.jpg"><img src="https://app.yinxiang.com/shard/s51/res/9822d4f5-c40e-4953-9f24-b05d9bcb83a6.png" alt=""></a></p>
<h2 id="HTTP协议:看个新闻原来这么麻烦">HTTP 协议:看个新闻原来这么麻烦</h2>
<p><code>http://www.163.com</code>是个 URL,叫作<strong>统一资源定位符</strong>。之所以叫统一,是因为它是有格式的。<code>HTTP</code>称为协议,<code>www.163.com</code>是一个域名,表示互联网上的一个位置。有的 URL 会有更详细的位置标识,例如<code>http://www.163.com/index.html</code>。正是因为这个东西是统一的,所以当你把这样一个字符串输入到浏览器的框里的时候,浏览器才知道如何进行统一处理。</p>
<h3 id="HTTP请求的准备">HTTP 请求的准备</h3>
<ul>
<li>浏览器会将 <code>www.163.com</code> 这个域名发送给 DNS 服务器,让它解析为 IP 地址。</li>
<li>HTTP 是基于 TCP 协议的,要先建立 TCP 连接,目前使用的 HTTP 协议大部分都是 1.1。在 1.1 的协议里面,默认是开启了 <code>Keep-Alive</code> 的,这样建立的 TCP 连接,就可以在多次请求中复用。</li>
</ul>
<h3 id="HTTP请求的构建">HTTP 请求的构建</h3>
<p>建立了连接以后,浏览器就要发送 HTTP 的请求。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/httpformat.jpg"><img src="https://app.yinxiang.com/shard/s51/res/f42367f8-c246-474f-9026-b350018d5820.png" alt=""></a></p>
<p>HTTP 的报文的三大部分:</p>
<ul>
<li>请求行,</li>
<li>请求的首部,</li>
<li>请求的正文实体。</li>
</ul>
<h4 id="请求行">请求行</h4>
<p>URL 就是<code>http://www.163.com</code>,版本为 HTTP 1.1。方法有几种类型,<code>get</code>,<code>post</code>,<code>put</code>,<code>delete</code>。</p>
<h4 id="首部字段">首部字段</h4>
<p><strong>首部是 key value,通过冒号分隔</strong>。重点<strong>缓存</strong>,为啥要使用缓存?因为一个非常大的页面有很多东西。</p>
<p>例如,我浏览一个商品的详情,里面有这个商品的价格、库存、展示图片、使用手册等等。展示图片会保持较长时间不变,而库存会根据用户购买的情况经常改变。如果图片非常大,而库存数非常小,如果我们每次要更新数据的时候都要刷新整个页面,对于服务器的压力就会很大。</p>
<p>对于这种高并发场景下的系统,在真正的业务逻辑之前,都需要有个接入层,将这些静态资源的请求拦在最外面。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/arch.jpg"><img src="https://app.yinxiang.com/shard/s51/res/e96abe21-a052-4a62-9beb-ccb746d05a63.png" alt=""></a></p>
<p>和这一节关系比较大的就是 Nginx 这一层,它如何处理 HTTP 协议呢?对于静态资源,有 Vanish 缓存层。当缓存过期的时候,才会访问真正的 Tomcat 应用集群。</p>
<p>在 HTTP 头里面,<code>Cache-control</code>是用来<strong>控制缓存</strong>的。</p>
<h3 id="HTTP请求的发送">HTTP 请求的发送</h3>
<p>HTTP 协议是基于 TCP 协议的,所以它使用面向连接的方式发送请求,通过 stream 二进制流的方式传给对方。当然,到了 TCP 层,它会把二进制流变成一个的报文段发送给服务器。IP 层 -> ARP 获取 MAC -> 路由器 -> 找到机器 -> 解析 MAC IP TCP 根据端口号 -> 找到 HTTP 服务,</p>
<h3 id="HTTP返回的构建">HTTP 返回的构建</h3>
<p><a href="https://blog.shipengqi.top/images/network-protocol/httpresformat.jpg"><img src="https://app.yinxiang.com/shard/s51/res/39c9cca1-10b6-4957-9b8a-1d5b88b1a64b.png" alt=""></a></p>
<p>返回报文的三大部分:</p>
<ul>
<li>状态行,</li>
<li>首部</li>
<li>实体。</li>
</ul>
<p>状态码会反应 HTTP 请求的结果。“200” 意味着大吉大利;而我们最不想见的,就是 “404”,也就是 “服务端无法响应这个请求”。</p>
<p><strong>首部是 key value,通过冒号分隔</strong>。</p>
<h3 id="HTTP2.0">HTTP 2.0</h3>
<p>HTTP 1.1 在应用层以纯文本的形式进行通信。每次通信都要带完整的 HTTP 的头,而且不考虑 pipeline 模式的话,每次的过程总是像上面描述的那样一去一回。这样在实时性、并发性上都存在问题。</p>
<p>为了解决这些问题,HTTP 2.0 会对 HTTP 的头进行一定的压缩,将原来每次都要携带的大量 key value 在两端建立一个索引表,对相同的头只发送索引表中的索引。</p>
<p>HTTP 2.0 协议将一个 TCP 的连接中,切分成多个流,每个流都有自己的 ID,而且流可以是客户端发往服务端,也可以是服务端发往客户端。它其实只是一个虚拟的通道。流是有优先级的。</p>
<p>HTTP 2.0 还将所有的传输信息分割为更小的消息和帧,并对它们采用二进制格式编码。常见的帧有 Header 帧,用于传输 Header 内容,并且会开启一个新的流。再就是 Data 帧,用来传输正文实体。多个 Data 帧属于同一个流。</p>
<p>通过这两种机制,HTTP 2.0 的客户端可以将多个请求分到不同的流中,然后将请求内容拆成帧,进行二进制传输。这些帧可以打散乱序发送,然后根据每个帧首部的流标识符重新组装,并且可以根据优先级,决定优先处理哪个流的数据。</p>
<p>举一个例子:</p>
<p>假设我们的一个页面要发送三个独立的请求,一个获取 css,一个获取 js,一个获取图片 jpg。如果使用 HTTP 1.1 就是串行的,但是如果使用 HTTP 2.0,就可以在一个连接里,客户端和服务端都可以同时发送多个请求或回应,而且不用按照顺序一对一对应。</p>
<p>HTTP 2.0 成功解决了 HTTP 1.1 的队首阻塞问题,同时,也不需要通过 HTTP 1.x 的 pipeline 机制用多条 TCP 连接来实现并行请求与响应;减少了 TCP 连接数对服务器性能的影响,同时将页面的多个数据 css、js、jpg 等通过一个数据链接进行传输,能够加快页面组件的传输速度。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/http2stream1.jpg"><img src="https://app.yinxiang.com/shard/s51/res/fb61de5c-ba4b-40a0-90a8-302dcb69e0dc.png" alt=""></a></p>
<p>HTTP 2.0 其实是将三个请求变成三个流,将数据分成帧,乱序发送到一个 TCP 连接中。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/http2stream2.jpg"><img src="https://app.yinxiang.com/shard/s51/res/e16146d5-7cfd-4b2e-a142-8ab067600d21.png" alt=""></a></p>
<p>HTTP 2.0 成功解决了 HTTP 1.1 的队首阻塞问题,同时,也不需要通过 HTTP 1.x 的 pipeline 机制用多条 TCP 连接来实现并行请求与响应;减少了 TCP 连接数对服务器性能的影响,同时将页面的多个数据 css、js、jpg 等通过一个数据链接进行传输,能够加快页面组件的传输速度。</p>
<h3 id="QUIC">QUIC</h3>
<p>QUIC 协议通过基于 UDP 自定义的类似 TCP 的连接、重试、多路复用、流量控制技术,进一步提升性能。</p>
<h2 id="HTTPS协议:点外卖的过程原来这么复杂">HTTPS 协议:点外卖的过程原来这么复杂</h2>
<p>加密分为两种方式一种是<strong>对称加密</strong>(加密和解密使用的是同一个密钥),一种是<strong>非对称加密</strong>(加密使用的密钥和解密使用的密钥是不相同的。一把是作为公开的公钥,另一把是作为谁都不能给的私钥。<br>
公钥加密的信息,只有私钥才能解密。私钥加密的信息,只有公钥才能解密)。</p>
<h3 id="HTTPS的工作模式">HTTPS 的工作模式</h3>
<p>非对称加密在性能上不如对称加密,那是否能将两者结合起来呢?例如,公钥私钥主要用于传输对称加密的秘钥,而真正的双方大数据量的通信都是通过对称加密进行的。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/https.jpg"><img src="https://app.yinxiang.com/shard/s51/res/f5ff6063-6eab-4040-8796-1fc4b2f7217e/https.jpg" alt=""></a></p>
<p>证书校验这一步,一般浏览器的” 证书管理器” 会有” 受信任的根证书颁发机构” 列表。如果数字证书记载的网址,与你正在浏览的网址不一致,就说明这张证书可能被冒用,浏览器会发出警告。<br>
如果这张数字证书不是由受信任的机构颁发的,浏览器会发出另一种警告。</p>
<p>客户端拿到服务端证书之后,从自己信任的 CA 仓库中,拿 CA 的证书里面的公钥去解密外卖网站的证书。如果能够成功,则说明外卖网站是可信的。这个过程中,你可能会不断往上追溯 CA、CA 的 CA、CA 的 CA 的 CA,反正直到一个授信的 CA,就可以了。</p>
<h2 id="流媒体协议:如何在直播里看到美女帅哥?">流媒体协议:如何在直播里看到美女帅哥?</h2>
<p>无论是直播还是点播,其实都是对于视频数据的传输。</p>
<p>视频是什么?其实就是快速播放一连串连续的图片。</p>
<p>每一张图片,我们称为一<strong>帧</strong>。只要每秒钟帧的数据足够多,也即播放得足够快。比如每秒 30 帧,以人的眼睛的敏感程度,是看不出这是一张张独立的图片的,这就是我们常说的<strong>帧率(FPS)</strong>。</p>
<p>每一张图片,都是由<strong>像素</strong>组成的,假设为 1024*768(这个像素数不算多)。每个像素由 RGB 组成,每个 8 位,共 24 位。</p>
<p>我们来算一下,每秒钟的视频有多大?</p>
<p><code>30 帧× 1024 × 768 × 24 = 566,231,040Bits = 70,778,880Bytes</code></p>
<p>如果一分钟呢?<code>4,246,732,800Bytes</code>,已经是 4 个 G 了。</p>
<p>这个数据量实在是太大,根本没办法存储和传输。如果这样存储,你的硬盘很快就满了;如果这样传输,那多少带宽也不够用啊!</p>
<h3 id="编码">编码</h3>
<p>于是出现了<strong>编码</strong>,就是看如何用尽量少的 Bit 数保存视频,使播放的时候画面看起来仍然很精美。<strong>编码是一个压缩的过程</strong>。</p>
<p>之所以能够对视频流中的图片进行压缩,因为视频和图片有这样一些特点。</p>
<ul>
<li><strong> 空间冗余 </strong>:图像的相邻像素之间有较强的相关性,一张图片相邻像素往往是渐变的,不是突变的,没必要每个像素都完整地保存,可以隔几个保存一个,中间的用算法计算出来。</li>
<li><strong> 时间冗余 </strong>:视频序列的相邻图像之间内容相似。一个视频中连续出现的图片也不是突变的,可以根据已有的图片进行预测和推断。</li>
<li><strong> 视觉冗余 </strong>:人的视觉系统对某些细节不敏感,因此不会每一个细节都注意到,可以允许丢失一些数据。</li>
<li><strong> 编码冗余 </strong>:不同像素值出现的概率不同,概率高的用的字节少,概率低的用的字节多,类似霍夫曼编码(Huffman Coding)的思路。</li>
</ul>
<p>编码过程:</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/encode.jpg"><img src="https://app.yinxiang.com/shard/s51/res/d18a184e-55c3-4216-8f8c-e32400e0fc41.png" alt=""></a></p>
<h4 id="视频编码的标准">视频编码的标准</h4>
<p>ITU-T(国际电信联盟电信标准化部门,ITU Telecommunication Standardization Sector)与 MPEG 联合制定了<strong>H.264/MPEG-4 AVC</strong>。</p>
<p>经过编码之后,一帧一帧的图像,就变成了二进制,这个二进制可以放在一个文件里面,按照一定的格式保存起来,例如 RMVB 和 MP4。</p>
<h3 id="如何在直播里看到帅哥美女?">如何在直播里看到帅哥美女?</h3>
<p>这个二进制也可以通过某种网络协议进行封装,放在互联网上传输,这个时候就可以进行网络直播了。</p>
<p>网络协议将编码好的视频流,从主播端推送到服务器,在服务器上有个运行了同样协议的服务端来接收这些网络包,从而得到里面的视频流,这个过程称为<strong>接流</strong>。</p>
<p>服务端接到视频流之后,可以对视频流进行一定的处理,例如<strong>转码</strong>,也即从一个编码格式,转成另一种格式。因为观众使用的客户端千差万别,要保证他们都能看到直播。</p>
<p>流处理完毕之后,就可以等待观众的客户端来请求这些视频流。观众的客户端请求的过程称为<strong>拉流</strong>。</p>
<p>如果有非常多的观众,同时看一个视频直播,那都从一个服务器上拉流,压力太大了,因而需要一个视频的<strong>分发网络</strong>,将视频预先加载到就近的边缘节点,这样大部分观众看的视频,是从边缘节点拉取的,就能降低服务器的压力。当观众的客户端将视频流拉下来之后,就需要进行<strong>解码</strong>,也即通过上述过程的逆过程,将一串串看不懂的二进制,再转变成一帧帧生动的图片,在客户端播放出来,这样你就能看到美女帅哥啦。</p>
<p>直播过程:</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/livestreming.jpg"><img src="https://app.yinxiang.com/shard/s51/res/b95cfc0a-6b4c-469d-a54a-706f4b83d845.png" alt=""></a></p>
<h4 id="编码:如何将丰富多彩的图片变成二进制流?">编码:如何将丰富多彩的图片变成二进制流?</h4>
<p>虽然我们说视频是一张张图片的序列,但是如果每张图片都完整,就太大了,因而会将视频序列分成三种帧:</p>
<ul>
<li><strong>I 帧 </strong>,也称关键帧。里面是完整的图片,只需要本帧数据,就可以完成解码。</li>
<li><strong>P 帧 </strong>,前向预测编码帧。P 帧表示的是这一帧跟之前的一个关键帧(或 P 帧)的差别,解码时需要用之前缓存的画面,叠加上和本帧定义的差别,生成最终画面。</li>
<li><strong>B 帧 </strong>,双向预测内插编码帧。B 帧记录的是本帧与前后帧的差别。要解码 B 帧,不仅要取得之前的缓存画面,还要解码之后的画面,通过前后画面的数据与本帧数据的叠加,取得最终的画面。</li>
</ul>
<p><strong>I 帧最完整,B 帧压缩率最高,而压缩后帧的序列,应该是在 IBBP 的间隔出现的。这就是通过时序进行编码。</strong></p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/framing.jpg"><img src="https://app.yinxiang.com/shard/s51/res/16f08fc6-a01a-44d1-93bc-b66a3f5237d1.png" alt=""></a></p>
<p>在一帧中,分成多个片,每个片中分成多个宏块,每个宏块分成多个子块,这样将一张大的图分解成一个个小块,可以方便进行<strong>空间上的编码</strong>。</p>
<p>尽管时空非常立体的组成了一个序列,但是总归还是要压缩成一个二进制流。这个流是有结构的,是一个个的<strong>网络提取层单元(NALU,Network Abstraction Layer Unit)</strong>。变成这种格式就是为了传输,因为网络上的传输,默认的是一个个的包,因而这里也就分成了一个个的单元。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/nalu.jpg"><img src="https://app.yinxiang.com/shard/s51/res/1c3c0ad1-ab4a-4afc-a192-45ea26253afd.png" alt=""></a></p>
<p><strong>一个视频,可以拆分成一系列的帧,每一帧拆分成一系列的片,每一片都放在一个 NALU 里面,NALU 之间都是通过特殊的起始标识符分隔,在每一个 I 帧的第一片前面,要插入单独保存 SPS 和 PPS 的 NALU,<br>
最终形成一个长长的 NALU 序列</strong>。</p>
<p>每一个 NALU 首先是一个起始标识符,用于标识 NALU 之间的间隔</p>
<p>NALU 头里面,主要的内容是类型 NAL Type:</p>
<ul>
<li>0x07 表示 SPS,是序列参数集,包括一个图像序列的所有信息,如图像尺寸、视频格式等。</li>
<li>0x08 表示 PPS,是图像参数集,包括一个图像的所有分片的所有相关信息,包括图像类型、序列号等。</li>
</ul>
<p>Payload 里面是 NALU 承载的数据。</p>
<h4 id="推流:如何把数据流打包传输到对端">推流:如何把数据流打包传输到对端</h4>
<p>使用<strong>RTMP 协议</strong>将这个二进制的流打包成网络包进行发送。这就进入了第二个过程,<strong>推流</strong>。</p>
<p><strong>RTMP 是基于 TCP 的,因而肯定需要双方建立一个 TCP 的连接。在有 TCP 的连接的基础上,还需要建立一个 RTMP 的连接</strong>。</p>
<p>RTMP 为什么需要建立一个单独的连接?</p>
<p>因为它们需要商量一些事情,保证以后的传输能正常进行。主要就是两个事情,一个是<strong>版本号</strong>,如果客户端、服务器的版本号不一致,则不能工作。另一个就是<strong>时间戳</strong>,视频播放中,时间是很重要的,后面的数据流互通的时候,经常要带上时间戳的差值,因而一开始双方就要知道对方的时间戳。</p>
<p>握手之后,双方需要互相传递一些控制信息,例如 Chunk 块的大小、窗口大小等。</p>
<p>真正传输数据的时候,还是需要创建一个流 Stream,然后通过这个 Stream 来推流 publish。</p>
<p>推流的过程,就是将 NALU 放在 Message 里面发送,这个也称为 RTMP Packet 包。Message 的格式就像这样。</p>
<p>RTMP 在收发数据的时候并不是以 Message 为单位的,而是把 Message 拆分成 Chunk 发送,而且必须在一个 Chunk 发送完成之后,才能开始发送下一个 Chunk。每个 Chunk 中都带有 Message ID,表示属于哪个 Message,接收端也会按照这个 ID 将 Chunk 组装成 Message。</p>
<p>数据推送到流媒体服务器过程:</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/pushstream.jpg"><img src="https://app.yinxiang.com/shard/s51/res/db74bd4f-8569-40f3-9af3-1aca37e46279/pushstream.jpg" alt=""></a></p>
<p>然后直播的观众就可以通过 RTMP 协议从流媒体服务器上拉取,但是这么多的用户量,都去同一个地方拉取,服务器压力会很大,而且用户分布在全国甚至全球,如果都去统一的一个地方下载,也会时延比较长,需要有<strong>分发网络</strong>。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/deliverynetwork.jpg"><img src="https://app.yinxiang.com/shard/s51/res/aca01270-5956-422b-9d0a-2599d992aa10.png" alt=""></a></p>
<h4 id="拉流:观众的客户端如何看到视频">拉流:观众的客户端如何看到视频</h4>
<p><a href="https://blog.shipengqi.top/images/network-protocol/pullstream.jpg"><img src="https://app.yinxiang.com/shard/s51/res/83bf27e4-d9b2-4ac0-8965-0d6c7cb64366.png" alt=""></a></p>
<h2 id="P2P协议:我下小电影,99%急死你">P2P 协议:我下小电影,99% 急死你</h2>
<p>下载最简单的方式 HTTP,但是下载很慢,可以使用 FTP(文件传输协议)。</p>
<p>FTP 采用两个 TCP 连接来传输一个文件:</p>
<ul>
<li><strong> 控制连接 </strong>:服务器以被动的方式,打开众所周知用于 FTP 的端口 21,客户端则主动发起连接。该连接将命令从客户端传给服务器,并传回服务器的应答。<br>
常用的命令有:list—— 获取文件目录;reter—— 取一个文件;store—— 存一个文件。</li>
<li><strong> 数据连接 </strong>:每当一个文件在客户端与服务器之间传输时,就创建一个数据连接。</li>
</ul>
<h3 id="P2P是什么">P2P 是什么</h3>
<p>无论是 HTTP 的方式,还是 FTP 的方式,都有一个比较大的缺点,就是难以解决单一服务器的带宽压力,因为它们使用的都是传统的客户端服务器的方式。</p>
<p><strong>P2P 就是 peer-to-peer</strong>。资源开始并不集中地存储在某些设备上,而是分散地存储在多台设备上。这些设备我们姑且称为 peer。</p>
<p>想要下载一个文件的时候,你只要得到那些已经存在了文件的 peer,并和这些 peer 之间,建立点对点的连接,而不需要到中心服务器上,就可以就近下载文件。一旦下载了文件,你也就成为 peer 中的一员,你旁边的那些机器,也可能会选择从你这里下载文件,所以当你使用 P2P 软件的时候,例如 BitTorrent,往往能够看到,既有下载流量,也有上传的流量,也即你自己也加入了这个 P2P 的网络,自己从别人那里下载,同时也提供给其他人下载。可以想象,这种方式,参与的人越多,下载速度越快,一切完美。</p>
<h3 id="种子(.torrent)文件">种子(.torrent)文件</h3>
<p>怎么知道哪些 peer 有你要下载的文件?</p>
<p>这就用到种子啦,也即咱们比较熟悉的<strong>.torrent</strong>文件。.torrent 文件由两部分组成,分别是:<strong>announce(tracker URL)和文件信息</strong>。</p>
<p>文件信息里面有这些内容:</p>
<ul>
<li><strong>info 区 </strong>:这里指定的是该种子有几个文件、文件有多长、目录结构,以及目录和文件的名字。</li>
<li><strong>Name 字段 </strong>:指定顶层目录名字。</li>
<li><strong> 每个段的大小 </strong>:BitTorrent(简称 BT)协议把一个文件分成很多个小段,然后分段下载。</li>
<li><strong> 段哈希值 </strong>:将整个种子中,每个段的 SHA-1 哈希值拼在一起。</li>
</ul>
<p>下载时,BT 客户端首先解析 .torrent 文件,得到 tracker 地址,然后连接 tracker 服务器。tracker 服务器回应下载者的请求,将其他下载者(包括发布者)的 IP 提供给下载者。下载者再连接其他下载者,根据.torrent 文件,两者分别对方告知自己已经有的块,然后交换对方没有的数据。此时不需要其他服务器参与,并分散了单个线路上的数据流量,因此减轻了服务器的负担。</p>
<p>下载者每得到一个块,需要算出下载块的 Hash 验证码,并与.torrent 文件中的对比。如果一样,则说明块正确,不一样则需要重新下载这个块。这种规定是为了解决下载内容的准确性问题。</p>
<p>这种方式特别依赖 tracker。tracker 需要收集下载者信息的服务器,并将此信息提供给其他下载者,使下载者们相互连接起来,传输数据。虽然下载的过程是非中心化的,但是加入这个 P2P 网络的时候,都需要借助 tracker 中心服务器,这个服务器是用来登记有哪些用户在请求哪些资源。</p>
<p>一旦 tracker 服务器出现故障或者线路遭到屏蔽,BT 工具就无法正常工作了。</p>
<h3 id="去中心化网络(DHT)">去中心化网络(DHT)</h3>
<p><strong>DHT(Distributed Hash Table)去中心化网络,每个加入这个 DHT 网络的人,都要负责存储这个网络里的资源信息和其他成员的联系信息,相当于所有人一起构成了一个庞大的分布式存储数据库</strong>。</p>
<p>有一种著名的 DHT 协议,叫 Kademlia 协议。这个和区块链的概念一样。</p>
<p>任何一个 BitTorrent 启动之后,它都有两个角色。一个是 peer,监听一个 TCP 端口,用来上传和下载文件,这个角色表明,我这里有某个文件。另一个角色 DHT node,监听一个 UDP 的端口,通过这个角色,这个节点加入了一个 DHT 的网络。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/dht.jpg"><img src="https://app.yinxiang.com/shard/s51/res/652c8f12-15f6-4ffb-9f52-0f211a409a60.png" alt=""></a></p>
<p>在 DHT 网络里面,每一个 DHT node 都有一个 ID。这个 ID 是一个很长的串。每个 DHT node 都有责任掌握一些知识,也就是文件索引,也即它应该知道某些文件是保存在哪些节点上。<br>
它只需要有这些知识就可以了,而它自己本身不一定就是保存这个文件的节点。</p>
<h4 id="哈希值">哈希值</h4>
<p>每个 DHT node 不会有全局的知识,也即不知道所有的文件保存在哪里,它只需要知道一部分。那应该知道哪一部分呢?这就需要用哈希算法计算出来。</p>
<p>每个文件可以计算出一个哈希值,而<strong>DHT node 的 ID 是和哈希值相同长度的串</strong>。</p>
<p>DHT 算法是这样规定的:<strong>如果一个文件计算出一个哈希值,则和这个哈希值一样的那个 DHT node,就有责任知道从哪里下载这个文件,即便它自己没保存这个文件</strong>。</p>
<p>当然不一定这么巧,总能找到和哈希值一模一样的,有可能一模一样的 DHT node 也下线了,所以 DHT 算法还规定:<strong>除了一模一样的那个 DHT node 应该知道,ID 和这个哈希值非常接近的 N 个 DHT node 也应该知道</strong>。</p>
<p>什么叫和哈希值接近呢?例如只修改了最后一位,就很接近;修改了倒数 2 位,也不远;修改了倒数 3 位,也可以接受。总之,凑齐了规定的 N 这个数就行。</p>
<p>刚才那个图里,文件 1 通过哈希运算,得到匹配 ID 的 DHT node 为 node C,当然还会有其他的,我这里没有画出来。所以,node C 有责任知道文件 1 的存放地址,虽然 node C 本身没有存放文件 1。</p>
<p>接下来一个新的节点 node new 上线了。如果想下载文件 1,它首先要加入 DHT 网络,如何加入呢?</p>
<p>在这种模式下,种子.torrent 文件里面就不再是 tracker 的地址了,而是一个 list 的 node 的地址,而所有这些 node 都是已经在 DHT 网络里面的。当然随着时间的推移,很可能有退出的,有下线的,但是我们假设,不会所有的都联系不上,总有一个能联系上。</p>
<p>node new 只要在种子里面找到一个 DHT node,就加入了网络。</p>
<p>node new 会计算文件 1 的哈希值,并根据这个哈希值了解到,和这个哈希值匹配,或者很接近的 node 上知道如何下载这个文件,例如计算出来的哈希值就是 node C。</p>
<p>但是 node new 不知道怎么联系上 node C,因为种子里面的 node 列表里面很可能没有 node C,但是它可以问,DHT 网络特别像一个社交网络,node new 只有去它能联系上的 node 问, 你们知道不知道 node C 的联系方式呀?</p>
<p>在 DHT 网络中,每个 node 都保存了一定的联系方式,但是肯定没有 node 的所有联系方式。DHT 网络中,节点之间通过互相通信,也会交流联系方式,也会删除联系方式。和人们的方式一样,你有你的朋友圈,你的朋友有它的朋友圈,你们互相加微信,就互相认识了,过一段时间不联系,就删除朋友关系。</p>
<p>所以,node new 想联系 node C,就去万能的朋友圈去问,并且求转发,朋友再问朋友,很快就能找到。如果找不到 C,也能找到和 C 的 ID 很像的节点,它们也知道如何下载文件 1。</p>
<p>在 node C 上,告诉 node new,下载文件 1,要去 B、D、F,于是 node new 选择和 node B 进行 peer 连接,开始下载,它一旦开始下载,自己本地也有文件 1 了,于是 node new 告诉 node C 以及和 node C 的 ID 很像的那些节点,我也有文件 1 了,可以加入那个文件拥有者列表了。</p>
<p>但是你会发现 node new 上没有文件索引,但是根据哈希算法,一定会有某些文件的哈希值是和 node new 的 ID 匹配上的。在 DHT 网络中,会有节点告诉它,你既然加入了咱们这个网络,你也有责任知道某些文件的下载地址。</p>
<h2 id="DNS协议:网络世界的地址簿">DNS 协议:网络世界的地址簿</h2>
<h3 id="DNS服务器">DNS 服务器</h3>
<p>网络世界是很难记住网站的 IP 地址,于是,就需要一个地址簿,根据名称,就可以查看具体的地址,就是<strong>DNS 服务器</strong>。</p>
<p>DNS 在日常生活中非常重要。每个人上网,都需要访问它。一旦它出了故障,整个互联网都将瘫痪。另外,上网的人分布在全世界各地,如果大家都去同一个地方访问某一台服务器,时延将会非常大。因而,<strong>DNS 服务器,<br>
一定要设置成高可用、高并发和分布式的</strong>。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/dnsarch.jpg"><img src="https://app.yinxiang.com/shard/s51/res/8309d463-4f9e-4e8b-bbf4-913d7d9a2cd3/dnsarch.jpg" alt=""></a></p>
<ul>
<li>根 DNS 服务器:返回顶级域 DNS 服务器的 IP 地址</li>
<li>顶级域 DNS 服务器:返回权威 DNS 服务器的 IP 地址</li>
<li>权威 DNS 服务器:返回相应主机的 IP 地址</li>
</ul>
<h3 id="DNS解析流程">DNS 解析流程</h3>
<p>为了提高 DNS 的解析性能,很多网络都会就近部署 DNS 缓存服务器。于是,就有了以下的 DNS 解析流程。</p>
<ol>
<li>电脑客户端会发出一个 DNS 请求,问 <code>www.163.com</code> 的 IP 是啥啊,并发给本地域名服务器 (本地 DNS)。那本地域名服务器 (本地 DNS) 是什么呢?如果是通过 DHCP 配置,本地 DNS 由你的网络服务商(ISP),如电信、移动等自动分配,它通常就在你网络服务商的某个机房。</li>
<li>本地 DNS 收到来自客户端的请求。你可以想象这台服务器上缓存了一张域名与之对应 IP 地址的大表格。如果能找到 <code>www.163.com</code>,它直接就返回 IP 地址。如果没有,本地 DNS 会去问它的根域名服务器:“老大,能告诉我 < code>www.163.com</code> 的 IP 地址吗?” 根域名服务器是最高层次的,全球共有 13 套。它不直接用于域名解析,但能指明一条道路。</li>
<li>根 DNS 收到来自本地 DNS 的请求,发现后缀是 <code>.com</code>,说:“哦,<code>www.163.com</code> 啊,这个域名是由 <code>.com</code> 区域管理,我给你它的顶级域名服务器的地址,你去问问它吧。</li>
<li>本地 DNS 转向问顶级域名服务器:“老二,你能告诉我 < code>www.163.com</code> 的 IP 地址吗?” 顶级域名服务器就是大名鼎鼎的比如 <code>.com</code>、<code>.net</code>、<code>.org</code> 这些一级域名,它负责管理二级域名,比如 <code>163.com</code>,所以它能提供一条更清晰的方向。</li>
<li>顶级域名服务器说:“我给你负责 < code>www.163.com</code> 区域的权威 DNS 服务器的地址,你去问它应该能问到。”</li>
<li>本地 DNS 转向问权威 DNS 服务器:“您好,<code>www.163.com</code> 对应的 IP 是啥呀?”<code>163.com</code> 的权威 DNS 服务器,它是域名解析结果的原出处。为啥叫权威呢?就是我的域名我做主。</li>
<li>权威 DNS 服务器查询后将对应的 IP 地址 <code>X.X.X.X</code> 告诉本地 DNS。</li>
<li>本地 DNS 再将 IP 地址返回客户端,客户端和目标建立连接。</li>
</ol>
<p><a href="https://blog.shipengqi.top/images/network-protocol/dnsparse.jpg"><img src="https://app.yinxiang.com/shard/s51/res/b6ba403f-10dd-49a6-a5ce-075ed745a108/dnsparse.jpg" alt=""></a></p>
<h3 id="负载均衡">负载均衡</h3>
<p>站在客户端角度,这是一次<strong>DNS 递归查询过程</strong>。因为本地 DNS 全权为它效劳,它只要坐等结果即可。在这个过程中,DNS 除了可以通过名称映射为 IP 地址,它还可以做另外一件事,就是<strong>负载均衡</strong>。</p>
<p>以访问 “外婆家” 为例,它可能有很多地址,因为它在杭州可以有很多家。所以,如果一个人想去吃 “外婆家”,他可以就近找一家店,而不用大家都去同一家,这就是负载均衡。</p>
<p>DNS 首先可以做<strong>内部负载均衡</strong>。</p>
<p>例如,应用要访问数据库,配置这个数据库的域名,这样数据库如果 IP 地址修改,就不需要一个个修改应用的配置,直接修改 DNS 配置。<br>
例如,当某个被访问的应用撑不住的时候,可以部署多个,只要配置域名,在域名解析的时候,我们只要配置策略,这次返回第一个 IP,下次返回第二个 IP,就可以实现负载均衡了。</p>
<p>DNS 还可以做<strong>全局负载均衡</strong>。</p>
<p>例如,我们肯定希望北京的用户访问北京的数据中心,上海的用户访问上海的数据中心,这样,客户体验就会非常好,访问速度就会超快。这就是全局负载均衡的概念。</p>
<h3 id="DNS访问数据中心中对象存储上的静态资源">DNS 访问数据中心中对象存储上的静态资源</h3>
<p>假设全国有多个数据中心,托管在多个运营商,每个数据中心三个可用区(Available Zone)。对象存储通过跨可用区部署,实现高可用性。在每个数据中心中,都至少部署两个内部负载均衡器,内<br>
部负载均衡器后面对接多个对象存储的前置服务器(Proxy-server)。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/dnslb.jpg"><img src="https://app.yinxiang.com/shard/s51/res/2ab2a2f2-6932-4120-9763-0f2dae6cdc6c/dnslb.jpg" alt=""></a></p>
<ol>
<li>当一个客户端要访问 <code>object.yourcompany.com</code> 的时候,需要将域名转换为 IP 地址进行访问,所以它要请求本地 DNS 解析器。</li>
<li>本地 DNS 解析器先查看看本地的缓存是否有这个记录。如果有则直接使用,因为上面的过程太复杂了,如果每次都要递归解析,就太麻烦了。</li>
<li>如果本地无缓存,则需要请求本地的 DNS 服务器。</li>
<li>本地的 DNS 服务器一般部署在你的数据中心或者你所在的运营商的网络中,本地 DNS 服务器也需要看本地是否有缓存,如果有则返回,因为它也不想把上面的递归过程再走一遍。</li>
<li>如果本地没有,本地 DNS 才需要递归地从根 DNS 服务器,查到 <code>.com</code> 的顶级域名服务器,最终查到 <code>yourcompany.com</code> 的权威 DNS 服务器,给本地 DNS 服务器,权威 DNS 服务器按说会返回真实要访问的 IP 地址。</li>
</ol>
<p>对于不需要做全局负载均衡的简单应用来讲,<code>yourcompany.com</code>的权威 DNS 服务器可以直接将<code>object.yourcompany.com</code>这个域名解析为一个或者多个 IP 地址,然后客户端可以通过多个 IP 地址,进行简单的轮询,实现简单的负载均衡。</p>
<p>但是对于复杂的应用,尤其是跨地域跨运营商的大型应用,则需要更加复杂的全局负载均衡机制,因而需要专门的设备或者服务器来做这件事情,这就是<strong>全局负载均衡器(GSLB,Global Server LoadBalance)</strong>。</p>
<p>在 yourcompany.com 的 DNS 服务器中,一般是通过<strong>配置 CNAME 的方式</strong>,给<code>object.yourcompany.com</code>起一个别名,例如 object.vip.yourcomany.com,然后告诉本地 DNS 服务器,让它请求 GSLB 解析这个域名,GSLB 就可以在解析这个域名的过程中,通过自己的策略实现负载均衡。</p>
<p>图中画了两层的 GSLB,是因为分运营商和地域。我们希望不同运营商的客户,可以访问相同运营商机房中的资源,这样不跨运营商访问,有利于提高吞吐量,减少时延.</p>
<ol>
<li>第一层 GSLB,通过查看请求它的本地 DNS 服务器所在的运营商,就知道用户所在的运营商。假设是移动,通过 CNAME 的方式,通过另一个别名 <code>object.yd.yourcompany.com</code>,告诉本地 DNS 服务器去请求第二层的 GSLB。</li>
<li>第二层 GSLB,通过查看请求它的本地 DNS 服务器所在的地址,就知道用户所在的地理位置,然后将距离用户位置比较近的 Region 里面,六个 <strong> 内部负载均衡(SLB,Server Load Balancer)</strong> 的地址,返回给本地 DNS 服务器。</li>
<li>本地 DNS 服务器将结果返回给本地 DNS 解析器。</li>
<li>本地 DNS 解析器将结果缓存后,返回给客户端。</li>
<li>客户端开始访问属于相同运营商的距离较近的 Region 1 中的对象存储,当然客户端得到了六个 IP 地址,它可以通过负载均衡的方式,随机或者轮询选择一个可用区进行访问。对象存储一般会有三个备份,从而可以实现对存储读写的负载均衡。</li>
</ol>
<h2 id="HTTPDNS:网络世界的地址簿也会指错路">HTTPDNS:网络世界的地址簿也会指错路</h2>
<p>有时候这个地址簿也经常给你指错路,明明距离你 500 米就有个吃饭的地方,非要把你推荐到 5 公里外。为什么会出现这样的情况呢?</p>
<p>当我们发出请求解析 DNS 的时候,首先,会先连接到运营商本地的 DNS 服务器,由这个服务器帮我们去整棵 DNS 树上进行解析,然后将解析的结果返回给客户端。但是本地的 DNS 服务器,作为一个本地导游,往往有自己的 “小心思”。</p>
<h3 id="传统DNS存在哪些问题?">传统 DNS 存在哪些问题?</h3>
<h4 id="域名缓存问题">域名缓存问题</h4>
<p>它可以在本地做一个缓存,也就是说,不是每一个请求,它都会去访问权威 DNS 服务器,而是访问过一次就把结果缓存到自己本地,当其他人来问的时候,直接就返回这个缓存数据。</p>
<p>这就相当于导游去过一个饭店,自己脑子记住了地址,当有一个游客问的时候,他就凭记忆回答了,不用再去查地址簿。这样经常存在的一个问题是,人家那个饭店明明都已经搬了,结果作为导游,他并没有刷新这个缓存,结果你辛辛苦苦到了这个地点,发现饭店已经变成了服装店,你是不是会非常失望?</p>
<p>另外,有的运营商会把一些静态页面,缓存到本运营商的服务器内,这样用户请求的时候,就不用跨运营商进行访问,这样既加快了速度,也减少了运营商之间流量计算的成本。在域名解析的时候,不会将用户导向真正的网站,而是指向这个缓存的服务器。</p>
<p>很多情况下是看不出问题的,但是当页面更新,用户会访问到老的页面,问题就出来了。例如,你听说一个餐馆推出了一个新菜,你想去尝一下。结果导游告诉你,在这里吃也是一样的。有的游客会觉得没问题,但是对于想尝试新菜的人来说,如果导游说带你去,但其实并没有吃到新菜,你是不是也会非常失望呢?</p>
<p>再就是本地的缓存,往往使得全局负载均衡失败,因为上次进行缓存的时候,缓存中的地址不一定是这次访问离客户最近的地方,如果把这个地址返回给客户,那肯定就会绕远路。</p>
<p>就像客户要吃西湖醋鱼,导游知道西湖边有一家,因为当时游客就在西湖边,可是,下一次客户在灵隐寺,想吃西湖醋鱼的时候,导游还指向西湖边的那一家,那这就绕的太远了。</p>
<h4 id="域名转发问题">域名转发问题</h4>
<p>这样的问题是,如果是 A 运营商的客户,访问自己运营商的 DNS 服务器,如果 A 运营商去权威 DNS 服务器查询的话,权威 DNS 服务器知道你是 A 运营商的,就返回给一个部署在 A 运营商的网站地址,这样针对相同运营商的访问,速度就会快很多。</p>
<p>但是 A 运营商偷懒,将解析的请求转发给 B 运营商,B 运营商去权威 DNS 服务器查询的话,权威服务器会误认为,你是 B 运营商的,那就返回给你一个在 B 运营商的网站地址吧,结果客户的每次访问都要跨运营商,速度就会很慢。</p>
<h4 id="出口NAT问题">出口 NAT 问题</h4>
<p>很多机房都会配置 NAT,也即网络地址转换,使得从这个网关出去的包,都换成新的 IP 地址,当然请求返回的时候,在这个网关,再将 IP 地址转换回去,所以对于访问来说是没有任何问题。</p>
<p>但是一旦做了网络地址的转换,权威的 DNS 服务器,就没办法通过这个地址,来判断客户到底是来自哪个运营商,而且极有可能因为转换过后的地址,误判运营商,导致跨运营商的访问。</p>
<h4 id="域名更新问题">域名更新问题</h4>
<p>本地 DNS 服务器是由不同地区、不同运营商独立部署的。对域名解析缓存的处理上,实现策略也有区别,有的会偷懒,忽略域名解析结果的 TTL 时间限制,在权威 DNS 服务器解析变更的时候,解析结果在全网生效的周期非常漫长。但是有的时候,在 DNS 的切换中,场景对生效时间要求比较高。</p>
<p>例如双机房部署的时候,跨机房的负载均衡和容灾多使用 DNS 来做。当一个机房出问题之后,需要修改权威 DNS,将域名指向新的 IP 地址,但是如果更新太慢,那很多用户都会出现访问异常。</p>
<h4 id="解析延迟问题">解析延迟问题</h4>
<p>DNS 的查询过程需要递归遍历多个 DNS 服务器,才能获得最终的解析结果,这会带来一定的时延,甚至会解析超时。</p>
<h3 id="HTTPDNS的工作模式">HTTPDNS 的工作模式</h3>
<p><strong>HTTPNDS 其实就是,不走传统的 DNS 解析,而是自己搭建基于 HTTP 协议的 DNS 服务器集群,分布在多个地点和多个运营商。当客户端需要 DNS 解析的时候,直接通过 HTTP 协议进行请求这个服务器集群,得到就近的地址</strong>。</p>
<p>这就相当于每家基于 HTTP 协议,自己实现自己的域名解析,自己做一个自己的地址簿,而不使用统一的地址簿。但是默认的域名解析都是走 DNS 的,因而使用 HTTPDNS 需要绕过默认的 DNS 路径,就不能使用默认的客户端。使用 HTTPDNS 的,往往是手机应用,需要在手机端嵌入支持 HTTPDNS 的客户端 SDK。</p>
<h4 id="解析HTTPDNS的工作模式。">解析 HTTPDNS 的工作模式。</h4>
<p>在客户端的 SDK 里动态请求服务端,获取 HTTPDNS 服务器的 IP 列表,缓存到本地。随着不断地解析域名,SDK 也会在本地缓存 DNS 域名解析的结果。</p>
<p>当手机应用要访问一个地址的时候,首先看是否有本地的缓存,如果有就直接返回。这个缓存和本地 DNS 的缓存不一样的是,这个是手机应用自己做的,而非整个运营商统一做的。如何更新、何时更新,手机应用的客户端可以和服务器协调来做这件事情。</p>
<p>如果本地没有,就需要请求 HTTPDNS 的服务器,在本地 HTTPDNS 服务器的 IP 列表中,选择一个发出 HTTP 的请求,会返回一个要访问的网站的 IP 列表。</p>
<p>当所有这些都不工作的时候,可以切换到传统的 LocalDNS 来解析。</p>
<h4 id="HTTPDNS的缓存设计">HTTPDNS 的缓存设计</h4>
<p>解析 DNS 过程复杂,通信次数多,对解析速度造成很大影响。为了加快解析,因而有了缓存,但是这又会产生缓存更新速度不及时的问题。最要命的是,这两个方面都掌握在别人手中,也即本地 DNS 服务器手中,它不会为你定制,你作为客户端干着急没办法。</p>
<p>而 HTTPDNS 就是将解析速度和更新速度全部掌控在自己手中。一方面,解析的过程,不需要本地 DNS 服务递归的调用一大圈,一个 HTTP 的请求直接搞定,要实时更新的时候,马上就能起作用;另一方面为了提高解析速度,本地也有缓存,缓存是在客户端 SDK 维护的,过期时间、更新时间,都可以自己控制。</p>
<p>HTTPDNS 的缓存设计策略也是咱们做应用架构中常用的缓存设计模式,也即分为客户端、缓存、数据源三层。</p>
<p>只要是缓存模式,就存在缓存的过期、更新、不一致的问题,解决思路也是很像的。</p>
<p>例如 DNS 缓存在内存中,也可以持久化到存储上,从而 APP 重启之后,能够尽快从存储中加载上次累积的经常访问的网站的解析结果,就不需要每次都全部解析一遍,再变成缓存。这有点像 Redis 是基于内存的缓存,但是同样提供持久化的能力,使得重启或者主备切换的时候,数据不会完全丢失。</p>
<p>SDK 中的缓存会严格按照缓存过期时间,如果缓存没有命中,或者已经过期,而且客户端不允许使用过期的记录,则会发起一次解析,保障记录是更新的。</p>
<p>解析可以<strong>同步进行</strong>,也就是直接调用 HTTPDNS 的接口,返回最新的记录,更新缓存;也可以<strong>异步进行</strong>,添加一个解析任务到后台,由后台任务调用 HTTPDNS 的接口。</p>
<h4 id="HTTPDNS的调度设计">HTTPDNS 的调度设计</h4>
<p>由于客户端嵌入了 SDK,因而就不会因为本地 DNS 的各种缓存、转发、NAT,让权威 DNS 服务器误会客户端所在的位置和运营商,而可以拿到第一手资料。</p>
<p>在客户端,可以知道手机是哪个国家、哪个运营商、哪个省,甚至哪个市,HTTPDNS 服务端可以根据这些信息,选择最佳的服务节点返回。</p>
<p>在服务端,应用可以通过调用 HTTPDNS 的管理接口,配置不同服务质量的优先级、权重。HTTPDNS 会根据这些策略综合地理位置和线路状况算出一个排序,优先访问当前那些优质的、时延低的 IP 地址。</p>
<h2 id="CDN:你去小卖部取过快递么">CDN:你去小卖部取过快递么</h2>
<p>你去电商网站下单买个东西,这个东西一定要从电商总部的中心仓库送过来吗?原来基本是这样的,每一单都是单独配送,所以你可能要很久才能收到你的宝贝。但是后来电商网站的物流系统学聪明了,他们在全国各地建立了很多仓库,而不是只有总部的中心仓库才可以发货。</p>
<p>电商网站根据统计大概知道,北京、上海、广州、深圳、杭州等地,每天能够卖出去多少书籍、卫生纸、包、电器等存放期比较长的物品。这些物品用不着从中心仓库发出,所以平时就可以将它们分布在各地仓库里,客户一下单,就近的仓库发出,第二天就可以收到了。</p>
<p>全球有这么多的数据中心,无论在哪里上网,临近不远的地方基本上都有数据中心。是不是可以在这些数据中心里部署几台机器,形成一个缓存的集群来缓存部分数据,那么用户访问数据的时候,就可以就近访问了呢?</p>
<p>当然是可以的。这些分布在各个地方的各个数据中心的节点,就称为<strong>边缘节点</strong>。</p>
<p>由于边缘节点数目比较多,但是每个集群规模比较小,不可能缓存下来所有东西,因而可能无法命中,这样就会在边缘节点之上。有区域节点,规模就要更大,缓存的数据会更多,命中的概率也就更大。在区域节点之上是中心节点,规模更大,缓存数据更多。如果还不命中,就只好回源网站访问了。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/cdn.jpg"><img src="https://app.yinxiang.com/shard/s51/res/781f27f0-b73c-4620-be72-6805cdee3e0f/cdn.jpg" alt=""></a></p>
<p>这就是<strong>CDN 的分发系统的架构</strong>。CDN 系统的缓存,也是一层一层的,能不访问后端真正的源,就不打扰它。这也是电商网站物流系统的思路,北京局找不到,找华北局,华北局找不到,再找北方局。</p>
<h3 id="客户端如何找到相应的边缘节点进行访问">客户端如何找到相应的边缘节点进行访问</h3>
<p>如何访问边缘节点?</p>
<p>CDN 分发网络也是一个分布在多个区域、多个运营商的分布式系统,用基于 DNS 的全局负载均衡的思路。</p>
<p><a href="https://blog.shipengqi.top/images/network-protocol/cdnparse.jpg"><img src="https://app.yinxiang.com/shard/s51/res/f3b4c1d3-77bb-4960-85d6-b4e212e559f0/cdnparse.jpg" alt=""></a></p>
<p>图中实线是在有 CDN 的情况下,在<code>web.com</code>这个权威 DNS 服务器上,会设置一个 CNAME 别名,指向另外一个域名<code>www.web.cdn.com</code>,返回给本地 DNS 服务器。</p>
<p>当本地 DNS 服务器拿到这个新的域名时,需要继续解析这个新的域名。这个时候,再访问的就不是<code>web.com</code>的权威 DNS 服务器了,而是 web.cdn.com 的权威 DNS 服务器,这是 CDN 自己的权威 DNS 服务器。<br>
在这个服务器上,还是会设置一个 CNAME,指向另外一个域名,也即 CDN 网络的全局负载均衡器。</p>
<p>接下来,本地 DNS 服务器去请求 CDN 的全局负载均衡器解析域名,全局负载均衡器会为用户选择一台合适的缓存服务器提供服务,选择的依据包括:</p>
<ul>
<li>根据用户 IP 地址,判断哪一台服务器距用户最近</li>
<li>用户所处的运营商</li>
<li>根据用户所请求的 URL 中携带的内容名称,判断哪一台服务器上有用户所需的内容</li>
<li>查询各个服务器当前的负载情况,判断哪一台服务器尚有服务能力</li>
</ul>
<p>进行综合分析之后,全局负载均衡器会返回一台缓存服务器的 IP 地址。</p>
<p>缓存服务器响应用户请求,将用户所需内容传送到用户终端。如果这台缓存服务器上并没有用户想要的内容,那么这台服务器就要向它的上一级缓存服务器请求内容,直至追溯到网站的源服务器将内容拉到本地。</p>
<h3 id="CDN可以进行缓存的内容有很多种">CDN 可以进行缓存的内容有很多种</h3>
<p>保质期长的日用品比较容易缓存,因为不容易过期,对应到就像电商仓库系统里,就是静态页面、图片等,因为这些东西也不怎么变,所以适合缓存。</p>
<p>但是静态内容中,有一种特殊的内容,也大量使用了 CDN,这个就是前面讲过的流媒体。</p>
<p>CDN 支持流媒体协议,例如前面讲过的 RTMP 协议。在很多情况下,这相当于一个代理,从上一级缓存读取内容,转发给用户。由于流媒体往往是连续的,因而可以进行预先缓存的策略,也可以预先推送到用户的客户端。</p>
<p>对于静态页面来讲,内容的分发往往采取拉取的方式,也即当发现未命中的时候,再去上一级进行<strong>拉取</strong>。但是,流媒体数据量大,如果出现回源,压力会比较大,所以往往采取<strong>主动推送</strong>的模式,将热点数据主动推送到边缘节点。</p>
<p>对于流媒体来讲,很多 CDN 还提供<strong>预处理服务</strong>,也即文件在分发之前,经过一定的处理。例如将视频转换为不同的码流,以适应不同的网络带宽的用户需求;再如对视频进行分片,降低存储压力,也使得客户端可以选择使用不同的码率加载不同的分片。这就是我们常见的,“我要看超清、标清、流畅等”。</p>
<h4 id="防盗链问题">防盗链问题</h4>
<p>视频是要花大价钱买版权的,为了挣点钱,收点广告费,如果流媒体被其他的网站盗走,在人家的网站播放,那损失可就大了。</p>
<p>最常用也最简单的方法就是<strong>HTTP 头的 refer 字段</strong>,当浏览器发送请求的时候,一般会带上 referer,告诉服务器是从哪个页面链接过来的,服务器基于此可以获得一些信息用于处理。如果 refer 信息不是来自本站,就阻止访问或者跳到其它链接。</p>
<p><strong>refer 的机制相对比较容易破解,所以还需要配合其他的机制</strong>。</p>
<p>一种常用的机制是<strong>时间戳防盗链</strong>。使用 CDN 的管理员可以在配置界面上,和 CDN 厂商约定一个加密字符串。</p>
<p>客户端取出当前的时间戳,要访问的资源及其路径,连同加密字符串进行签名算法得到一个字符串,然后生成一个下载链接,带上这个签名字符串和截止时间戳去访问 CDN。</p>
<p>在 CDN 服务端,根据取出过期时间,和当前 CDN 节点时间进行比较,确认请求是否过期。然后 CDN 服务端有了资源及路径,时间戳,以及约定的加密字符串,根据相同的签名算法计算签名,如果匹配则一致,访问合法,才会将资源返回给客户。</p>
<h3 id="动态CDN">动态 CDN</h3>
<p>有两种模式:</p>
<ul>
<li>一种为 <strong> 生鲜超市模式 </strong>,也即 <strong> 边缘计算的模式 </strong>。既然数据是动态生成的,所以数据的逻辑计算和存储,也相应的放在边缘的节点。其中定时从源数据那里同步存储的数据,然后在边缘进行计算得到结果。就像对生鲜的烹饪是动态的,没办法事先做好缓存,因而将生鲜超市放在你家旁边,既能够送货上门,也能够现场烹饪,也是边缘计算的一种体现。</li>
<li>另一种是 <strong> 冷链运输模式 </strong>,也即 <strong> 路径优化的模式 </strong>。数据不是在边缘计算生成的,而是在源站生成的,但是数据的下发则可以通过 CDN 的网络,对路径进行优化。因为 CDN 节点较多,能够找到离源站很近的边缘节点,也能找到离用户很近的边缘节点。中间的链路完全由 CDN 来规划,选择一个更加可靠的路径,使用类似专线的方式进行访问。</li>
</ul>
<p>趣谈网络协议第二部分 <a href="/linux/network-protocol-2">在这里</a> 。</p>
<p>原文地址:<a href="https://blog.shipengqi.top/2018/11/23/network-protocol/">https://blog.shipengqi.top/2018/11/23/network-protocol/</a></p>
Linux GDB 调试器工作原理(3):调试信息
23
2019-06-12T04:09:52.592446Z
2019-06-12T04:09:52.592446Z
allen
本文将解释调试器是如何在机器码中查找它将 C 语言源代码转换成机器语言代码时所需要的 C 语言函数、变量、与数据。
<blockquote><p><strong> 摘要 </strong>:本文将解释调试器是如何在机器码中查找它将 C 语言源代码转换成机器语言代码时所需要的 C 语言函数、变量、与数据。</p>
</blockquote>
<p>这是调试器的工作原理系列文章的第三篇。阅读这篇文章之前应当先阅读 <a href="/post/linux-gdb-principle-ptrace">第一篇</a> 与 <a href="/post/linux-gdb-breakpoints">第二篇</a> 。</p>
<h2 id="关于本文">关于本文</h2>
<p>本文将解释调试器是如何在机器码中查找它将 C 语言源代码转换成机器语言代码时所需要的 C 语言函数、变量、与数据。</p>
<h2 id="调试信息">调试信息</h2>
<p>现代编译器能够将有着各种缩进或嵌套的程序流程、各种数据类型的变量的高级语言代码转换为一大堆称之为机器码的 0/1 数据,
这么做的唯一目的是尽可能快的在目标 CPU 上运行程序。通常来说一行 C 语言代码能够转换为若干条机器码。
变量被分散在机器码中的各个部分,有的在堆栈中,有的在寄存器中,或者直接被优化掉了。
数据结构与对象在机器码中甚至不 “存在”,它们只是用于将数据按一定的结构编码存储进缓存。</p>
<p>那么调试器怎么知道,当你需要在某个函数入口处暂停时,程序要在哪停下来呢?它怎么知道当你查看某个变量值时,它怎么找到这个值?答案是,调试信息。</p>
<p>编译器在生成机器码时同时会生成相应的调试信息。调试信息代表了可执行程序与源代码之间的关系,并以一种提前定义好的格式,同机器码存放在一起。
过去的数年里,人们针对不同的平台与可执行文件发明了很多种用于存储这些信息的格式。不过我们这篇文章不会讲这些格式的历史,
而是将阐述这些调试信息是如何工作的,所以我们将专注于一些事情,比如<code>DWARF</code>。<code>DWARF</code>如今十分广泛的用作 Linux 和类 Unix 平台上的可执行文件的调试格式。</p>
<h2 id="ELF中的DWARF">ELF 中的 DWARF</h2>
<p><img src="https://eli.thegreenplace.net/images/2011/02/dwarf_logo.gif" alt="dwarf_logo.gif"></p>
<p>根据它的 <a href="http://en.wikipedia.org/wiki/DWARF">维基百科</a> 所描述,虽然<code>DWARF</code>是同<code>ELF</code>一同设计的(<code>DWARF</code>是由<code>DWARF</code>标准委员会推出的开放标准。上文中展示的图标就来自这个网站。),但<code>DWARF</code>在理论上来说也可以嵌入到其他的可执行文件格式中。</p>
<p>DWARF 是一种复杂的格式,它吸收了过去许多年各种不同的架构与操作系统的格式的经验。正是因为它解决了一个在任何平台与 ABI (应用二进制接口)上为任意高级语言产生调试信息这样棘手的难题,它也必须很复杂。想要透彻的讲解 DWARF 仅仅是通过这单薄的一篇文章是远远不够的,说实话我也并没有充分地了解 DWARF 到每一个微小的细节,所以我也不能十分透彻的讲解 (如果你感兴趣的话,文末有一些能够帮助你的资源。建议从 DWARF 教程开始上手)。这篇文章中我将以浅显易懂的方式展示 DWARF,以说明调试信息是如何实际工作的。</p>
<h2 id="ELF文件中的调试部分">ELF 文件中的调试部分</h2>
<p>首先让我们看看 DWARF 处在 ELF 文件中的什么位置。ELF 定义了每一个生成的目标文件中的每一节。 节头表 section header table 声明并定义了每一节及其名字。不同的工具以不同的方式处理不同的节,例如连接器会寻找连接器需要的部分,调试器会查找调试器需要的部分。</p>
<p>我们本文的实验会使用从这个 C 语言源文件构建的可执行文件,编译成<code>tracedprog2</code>:</p>
<figure class="highlight c" data-lang="c"><table><tbody><tr><td class="code"><pre class="hljs c"><span></span><span class="cp">#include</span> <span class="cpf"><stdio.h></span><span class="cp"></span>
<span class="kt">void</span> <span class="nf">do_stuff</span><span class="p">(</span><span class="kt">int</span> <span class="n">my_arg</span><span class="p">)</span><span class="err">、</span>
<span class="p">{</span>
<span class="kt">int</span> <span class="n">my_local</span> <span class="o">=</span> <span class="n">my_arg</span> <span class="o">+</span> <span class="mi">2</span><span class="p">;</span>
<span class="kt">int</span> <span class="n">i</span><span class="p">;</span>
<span class="k">for</span> <span class="p">(</span><span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o"><</span> <span class="n">my_local</span><span class="p">;</span> <span class="o">++</span><span class="n">i</span><span class="p">)</span>
<span class="n">printf</span><span class="p">(</span><span class="s">"i = %d</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">i</span><span class="p">);</span>
<span class="p">}</span>
<span class="kt">int</span> <span class="nf">main</span><span class="p">()</span>
<span class="p">{</span>
<span class="n">do_stuff</span><span class="p">(</span><span class="mi">2</span><span class="p">);</span>
<span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</pre></table></figure>
<p>使用<code>objdump -h</code>命令检查 ELF 可执行文件中的节头 section header,我们会看到几个以<code>.debug_</code>开头的节,这些就是<code>DWARF</code>的调试部分。</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span><span class="m">26</span> .debug_aranges <span class="m">00000020</span> <span class="m">00000000</span> <span class="m">00000000</span> <span class="m">00001037</span>
CONTENTS, READONLY, DEBUGGING
<span class="m">27</span> .debug_pubnames <span class="m">00000028</span> <span class="m">00000000</span> <span class="m">00000000</span> <span class="m">00001057</span>
CONTENTS, READONLY, DEBUGGING
<span class="m">28</span> .debug_info 000000cc <span class="m">00000000</span> <span class="m">00000000</span> 0000107f
CONTENTS, READONLY, DEBUGGING
<span class="m">29</span> .debug_abbrev 0000008a <span class="m">00000000</span> <span class="m">00000000</span> 0000114b
CONTENTS, READONLY, DEBUGGING
<span class="m">30</span> .debug_line 0000006b <span class="m">00000000</span> <span class="m">00000000</span> 000011d5
CONTENTS, READONLY, DEBUGGING
<span class="m">31</span> .debug_frame <span class="m">00000044</span> <span class="m">00000000</span> <span class="m">00000000</span> <span class="m">00001240</span>
CONTENTS, READONLY, DEBUGGING
<span class="m">32</span> .debug_str 000000ae <span class="m">00000000</span> <span class="m">00000000</span> <span class="m">00001284</span>
CONTENTS, READONLY, DEBUGGING
<span class="m">33</span> .debug_loc <span class="m">00000058</span> <span class="m">00000000</span> <span class="m">00000000</span> <span class="m">00001332</span>
CONTENTS, READONLY, DEBUGGING
</pre></table></figure>
<p>每个节的第一个数字代表了该节的大小,最后一个数字代表了这个节开始位置距离 ELF 的偏移量。调试器利用这些信息从可执行文件中读取节。</p>
<p>现在让我们看看一些在 DWARF 中查找有用的调试信息的实际例子。</p>
<h2 id="查找函数">查找函数</h2>
<p>调试器的最基础的任务之一,就是当我们在某个函数处设置断点时,调试器需要能够在入口处暂停。为此,必须为高级代码中的函数名称与函数在机器码中指令开始的地址这两者之间建立起某种映射关系。</p>
<p>为了获取这种映射关系,我们可以查找<code>DWARF</code>中的<code>.debug_info</code>节。在我们深入之前,需要一点基础知识。<code>DWARF</code>中每一个描述类型被称之为调试信息入口(<code>DIE</code>)。
每个<code>DIE</code>都有关于它的类型、属性之类的标签。<code>DIE</code>之间通过兄弟节点或子节点相互连接,属性的值也可以指向其它的<code>DIE</code>。</p>
<p>运行以下命令:</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>objdump --dwarf<span class="o">=</span>info tracedprog2
</pre></table></figure>
<p>输出文件相当的长,为了方便举例我们只关注这些行(从这里开始,无用的冗长信息我会以 (...)代替,方便排版):</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span><<span class="m">1</span>><<span class="m">71</span>>: Abbrev Number: <span class="m">5</span> <span class="o">(</span>DW_TAG_subprogram<span class="o">)</span>
<<span class="m">72</span>> DW_AT_external : <span class="m">1</span>
<<span class="m">73</span>> DW_AT_name : <span class="o">(</span>...<span class="o">)</span>: do_stuff
<<span class="m">77</span>> DW_AT_decl_file : <span class="m">1</span>
<<span class="m">78</span>> DW_AT_decl_line : <span class="m">4</span>
<<span class="m">79</span>> DW_AT_prototyped : <span class="m">1</span>
<7a> DW_AT_low_pc : 0x8048604
<7e> DW_AT_high_pc : 0x804863e
<<span class="m">82</span>> DW_AT_frame_base : 0x0 <span class="o">(</span>location list<span class="o">)</span>
<<span class="m">86</span>> DW_AT_sibling : <0xb3>
<<span class="m">1</span>><b3>: Abbrev Number: <span class="m">9</span> <span class="o">(</span>DW_TAG_subprogram<span class="o">)</span>
<b4> DW_AT_external : <span class="m">1</span>
<b5> DW_AT_name : <span class="o">(</span>...<span class="o">)</span>: main
<b9> DW_AT_decl_file : <span class="m">1</span>
<ba> DW_AT_decl_line : <span class="m">14</span>
<bb> DW_AT_type : <0x4b>
<bf> DW_AT_low_pc : 0x804863e
<c3> DW_AT_high_pc : 0x804865a
<c7> DW_AT_frame_base : 0x2c <span class="o">(</span>location list<span class="o">)</span>
</pre></table></figure>
<p>上面的代码中有两个带有<code>DW_TAG_subprogram</code>标签的入口,在<code>DWARF</code>中这是对函数的指代。
注意,这是两个节的入口,其中一个是<code>do_stuff</code>函数的入口,另一个是主(<code>main</code>)函数的入口。
这些信息中有很多值得关注的属性,但其中最值得注意的是<code>DW_AT_low_pc</code>。它代表了函数开始处程序指针的值(在 x86 平台上是<code>EIP</code>)。
此处<code>0x8048604</code>代表了<code>do_stuff</code>函数开始处的程序指针。下面我们将利用<code>objdump -d</code>命令对可执行文件进行反汇编。</p>
<p>来看看这块地址中都有什么:</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span><span class="m">08048604</span> <do_stuff>:
<span class="m">8048604</span>: <span class="m">55</span> push ebp
<span class="m">8048605</span>: <span class="m">89</span> e5 mov ebp,esp
<span class="m">8048607</span>: <span class="m">83</span> ec <span class="m">28</span> sub esp,0x28
804860a: 8b <span class="m">45</span> <span class="m">08</span> mov eax,DWORD PTR <span class="o">[</span>ebp+0x8<span class="o">]</span>
804860d: <span class="m">83</span> c0 <span class="m">02</span> add eax,0x2
<span class="m">8048610</span>: <span class="m">89</span> <span class="m">45</span> f4 mov DWORD PTR <span class="o">[</span>ebp-0xc<span class="o">]</span>,eax
<span class="m">8048613</span>: c7 <span class="m">45</span> <span class="o">(</span>...<span class="o">)</span> mov DWORD PTR <span class="o">[</span>ebp-0x10<span class="o">]</span>,0x0
804861a: eb <span class="m">18</span> jmp <span class="m">8048634</span> <do_stuff+0x30>
804861c: b8 <span class="m">20</span> <span class="o">(</span>...<span class="o">)</span> mov eax,0x8048720
<span class="m">8048621</span>: 8b <span class="m">55</span> f0 mov edx,DWORD PTR <span class="o">[</span>ebp-0x10<span class="o">]</span>
<span class="m">8048624</span>: <span class="m">89</span> <span class="m">54</span> <span class="m">24</span> <span class="m">04</span> mov DWORD PTR <span class="o">[</span>esp+0x4<span class="o">]</span>,edx
<span class="m">8048628</span>: <span class="m">89</span> <span class="m">04</span> <span class="m">24</span> mov DWORD PTR <span class="o">[</span>esp<span class="o">]</span>,eax
804862b: e8 <span class="m">04</span> <span class="o">(</span>...<span class="o">)</span> call <span class="m">8048534</span> <printf@plt>
<span class="m">8048630</span>: <span class="m">83</span> <span class="m">45</span> f0 <span class="m">01</span> add DWORD PTR <span class="o">[</span>ebp-0x10<span class="o">]</span>,0x1
<span class="m">8048634</span>: 8b <span class="m">45</span> f0 mov eax,DWORD PTR <span class="o">[</span>ebp-0x10<span class="o">]</span>
<span class="m">8048637</span>: 3b <span class="m">45</span> f4 cmp eax,DWORD PTR <span class="o">[</span>ebp-0xc<span class="o">]</span>
804863a: 7c e0 jl 804861c <do_stuff+0x18>
804863c: c9 leave
804863d: c3 ret
</pre></table></figure>
<p>显然,<code>0x8048604</code>是<code>do_stuff</code>的开始地址,这样一来,调试器就可以建立函数与其在可执行文件中的位置间的映射关系。</p>
<h2 id="查找变量">查找变量</h2>
<p>假设我们当前在<code>do_staff</code>函数中某个位置上设置断点停了下来。我们想通过调试器取得<code>my_local</code>这个变量的值。
调试器怎么知道在哪里去找这个值呢?很显然这要比查找函数更为困难。变量可能存储在全局存储区、堆栈、甚至是寄存器中。
此外,同名变量在不同的作用域中可能有着不同的值。调试信息必须能够反映所有的这些变化,当然,<code>DWARF</code>就能做到。</p>
<p>我不会逐一去将每一种可能的状况,但我会以调试器在<code>do_stuff</code>函数中查找<code>my_local</code>变量的过程来举个例子。
下面我们再看一遍<code>.debug_info</code>中<code>do_stuff</code>的每一个入口,这次连它的子入口也要一起看。</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span><<span class="m">1</span>><<span class="m">71</span>>: Abbrev Number: <span class="m">5</span> <span class="o">(</span>DW_TAG_subprogram<span class="o">)</span>
<<span class="m">72</span>> DW_AT_external : <span class="m">1</span>
<<span class="m">73</span>> DW_AT_name : <span class="o">(</span>...<span class="o">)</span>: do_stuff
<<span class="m">77</span>> DW_AT_decl_file : <span class="m">1</span>
<<span class="m">78</span>> DW_AT_decl_line : <span class="m">4</span>
<<span class="m">79</span>> DW_AT_prototyped : <span class="m">1</span>
<7a> DW_AT_low_pc : 0x8048604
<7e> DW_AT_high_pc : 0x804863e
<<span class="m">82</span>> DW_AT_frame_base : 0x0 <span class="o">(</span>location list<span class="o">)</span>
<<span class="m">86</span>> DW_AT_sibling : <0xb3>
<<span class="m">2</span>><8a>: Abbrev Number: <span class="m">6</span> <span class="o">(</span>DW_TAG_formal_parameter<span class="o">)</span>
<8b> DW_AT_name : <span class="o">(</span>...<span class="o">)</span>: my_arg
<8f> DW_AT_decl_file : <span class="m">1</span>
<<span class="m">90</span>> DW_AT_decl_line : <span class="m">4</span>
<<span class="m">91</span>> DW_AT_type : <0x4b>
<<span class="m">95</span>> DW_AT_location : <span class="o">(</span>...<span class="o">)</span> <span class="o">(</span>DW_OP_fbreg: <span class="m">0</span><span class="o">)</span>
<<span class="m">2</span>><<span class="m">98</span>>: Abbrev Number: <span class="m">7</span> <span class="o">(</span>DW_TAG_variable<span class="o">)</span>
<<span class="m">99</span>> DW_AT_name : <span class="o">(</span>...<span class="o">)</span>: my_local
<9d> DW_AT_decl_file : <span class="m">1</span>
<9e> DW_AT_decl_line : <span class="m">6</span>
<9f> DW_AT_type : <0x4b>
<a3> DW_AT_location : <span class="o">(</span>...<span class="o">)</span> <span class="o">(</span>DW_OP_fbreg: -20<span class="o">)</span>
<<span class="m">2</span>><a6>: Abbrev Number: <span class="m">8</span> <span class="o">(</span>DW_TAG_variable<span class="o">)</span>
<a7> DW_AT_name : i
<a9> DW_AT_decl_file : <span class="m">1</span>
<aa> DW_AT_decl_line : <span class="m">7</span>
<ab> DW_AT_type : <0x4b>
<af> DW_AT_location : <span class="o">(</span>...<span class="o">)</span> <span class="o">(</span>DW_OP_fbreg: -24<span class="o">)</span>
</pre></table></figure>
<p>看到每个入口处第一对尖括号中的数字了吗?这些是嵌套的等级,在上面的例子中,以<code><2></code>开头的入口是以<code><1></code>开头的子入口。
因此我们得知<code>my_local</code>变量(以<code>DW_TAG_variable</code>标签标记)是<code>do_stuff</code>函数的局部变量。
除此之外,调试器也需要知道变量的数据类型,这样才能正确的使用与显示变量。上面的例子中<code>my_local</code>的变量类型指向另一个<code>DIE <0x4b></code>。
如果使用<code>objdump</code>命令查看这个 DIE 的话,我们会发现它是一个有符号 4 字节整型数据。</p>
<p>而为了在实际运行的程序内存中查找变量的值,调试器需要使用到<code>DW_AT_location</code>属性。对于<code>my_local</code>而言,是<code>DW_OP_fbreg: -20</code>。
这个代码段的意思是说<code>my_local</code>存储在距离它所在函数起始地址偏移量为 -20 的地方。</p>
<p><code>do_stuff</code>函数的<code>DW_AT_frame_base</code>属性值为<code>0x0 (location list)</code>。这意味着这个属性的值需要在<code>location list</code>中查找。下面我们来一起看看。</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>$ objdump --dwarf<span class="o">=</span>loc tracedprog2
tracedprog2: file format elf32-i386
Contents of the .debug_loc section:
Offset Begin End Expression
<span class="m">00000000</span> <span class="m">08048604</span> <span class="m">08048605</span> <span class="o">(</span>DW_OP_breg4: <span class="m">4</span> <span class="o">)</span>
<span class="m">00000000</span> <span class="m">08048605</span> <span class="m">08048607</span> <span class="o">(</span>DW_OP_breg4: <span class="m">8</span> <span class="o">)</span>
<span class="m">00000000</span> <span class="m">08048607</span> 0804863e <span class="o">(</span>DW_OP_breg5: <span class="m">8</span> <span class="o">)</span>
<span class="m">00000000</span> <End of list>
0000002c 0804863e 0804863f <span class="o">(</span>DW_OP_breg4: <span class="m">4</span> <span class="o">)</span>
0000002c 0804863f <span class="m">08048641</span> <span class="o">(</span>DW_OP_breg4: <span class="m">8</span> <span class="o">)</span>
0000002c <span class="m">08048641</span> 0804865a <span class="o">(</span>DW_OP_breg5: <span class="m">8</span> <span class="o">)</span>
0000002c <End of list>
</pre></table></figure>
<p>我们需要关注的是第一列(<code>do_stuff</code>函数的<code>DW_AT_frame_base</code>属性包含<code>location list</code>中<code>0x0</code>的偏移量。而<code>main</code>函数的相同属性包含<code>0x2c</code>的偏移量,这个偏移量是第二套地址列表的偏移量)。
对于调试器可能定位到的每一个地址,它都会指定当前栈帧到变量间的偏移量,而这个偏移就是通过寄存器来计算的。对于 x86 平台而言,<code>bpreg4</code>指向<code>esp</code>,而<code>bpreg5</code>指向<code>ebp</code>。</p>
<p>让我们再看看<code>do_stuff</code>函数的头几条指令。</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span><span class="m">08048604</span> <do_stuff>:
<span class="m">8048604</span>: <span class="m">55</span> push ebp
<span class="m">8048605</span>: <span class="m">89</span> e5 mov ebp,esp
<span class="m">8048607</span>: <span class="m">83</span> ec <span class="m">28</span> sub esp,0x28
804860a: 8b <span class="m">45</span> <span class="m">08</span> mov eax,DWORD PTR <span class="o">[</span>ebp+0x8<span class="o">]</span>
804860d: <span class="m">83</span> c0 <span class="m">02</span> add eax,0x2
<span class="m">8048610</span>: <span class="m">89</span> <span class="m">45</span> f4 mov DWORD PTR <span class="o">[</span>ebp-0xc<span class="o">]</span>,eax
</pre></table></figure>
<p>只有当第二条指令执行后,<code>ebp</code>寄存器才真正存储了有用的值。当然,前两条指令的基址是由上面所列出来的地址信息表计算出来的。
一但<code>ebp</code>确定了,计算偏移量就十分方便了,因为尽管<code>esp</code>在操作堆栈的时候需要移动,但<code>ebp</code>作为栈底并不需要移动。</p>
<p>究竟我们应该去哪里找<code>my_local</code>的值呢?在<code>0x8048610</code>这块地址后,<code>my_local</code>的值经过在<code>eax</code>中的计算后被存在了内存中,从这里开始我们才需要关注<code>my_local</code>的值。
调试器会利用<code>DW_OP_breg5: 8</code>这个栈帧来查找。我们回想下,<code>my_local</code>的<code>DW_AT_location</code>属性值为<code>DW_OP_fbreg: -20</code>。
所以应当从基址中 -20 ,同时由于<code>ebp</code>寄存器需要 +8,所以最终结果为<code>ebp - 12</code>。现在再次查看反汇编代码,来看看数据从<code>eax</code>中被移动到哪里了。当然,这里<code>my_local</code>应当被存储在了<code>ebp - 12</code>的地址中。</p>
<h2 id="查看行号">查看行号</h2>
<p>当我们谈到在调试信息寻找函数的时候,我们利用了些技巧。当调试 C 语言源代码并在某个函数出放置断点的时候,我们并不关注第一条 “机器码” 指令(函数的调用准备工作已经完成而局部变量还没有初始化)。我们真正关注的是函数的第一行 “C 代码”。</p>
<p>这就是<code>DWARF</code>完全覆盖映射 C 源代码中的行与可执行文件中机器码地址的原因。下面是<code>.debug_line</code>节中所包含的内容,我们将其转换为可读的格式展示如下。</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>$ objdump --dwarf<span class="o">=</span>decodedline tracedprog2
tracedprog2: file format elf32-i386
Decoded dump of debug contents of section .debug_line:
CU: /home/eliben/eli/eliben-code/debugger/tracedprog2.c:
File name Line number Starting address
tracedprog2.c <span class="m">5</span> 0x8048604
tracedprog2.c <span class="m">6</span> 0x804860a
tracedprog2.c <span class="m">9</span> 0x8048613
tracedprog2.c <span class="m">10</span> 0x804861c
tracedprog2.c <span class="m">9</span> 0x8048630
tracedprog2.c <span class="m">11</span> 0x804863c
tracedprog2.c <span class="m">15</span> 0x804863e
tracedprog2.c <span class="m">16</span> 0x8048647
tracedprog2.c <span class="m">17</span> 0x8048653
tracedprog2.c <span class="m">18</span> 0x8048658
</pre></table></figure>
<p>很容易就可以看出其中 C 源代码与反汇编代码之间的对应关系。第 5 行指向<code>do_stuff</code>函数的入口,<code>0x8040604</code>。
第 6 行,指向<code>0x804860a</code>,正是调试器在调试<code>do_stuff</code>函数时需要停下来的地方。这里已经完成了函数调用的准备工作。
上面的这些信息形成了行号与地址间的双向映射关系。</p>
<ul>
<li>当在某一行设置断点的时候,调试器会利用这些信息去查找相应的地址来做断点工作(还记得 <a href="/linux/linux-gdb-breakpoints"> 上篇文章中 </a> 的 <code>int 3</code> 指令吗?)</li>
<li>当指令造成段错误时,调试器会利用这些信息来查看源代码中发生问题的行。</li>
</ul>
<h2 id="libdwarf-用DWARF编程">libdwarf - 用 DWARF 编程</h2>
<p>尽管使用命令行工具来获得<code>DWARF</code>很有用,但这仍然不够易用。作为程序员,我们希望知道当我们需要这些调试信息时应当怎么编程来获取这些信息。</p>
<p>自然我们想到的第一种方法就是阅读<code>DWARF</code>规范并按规范操作阅读使用。有句话说的好,分析 HTML 应当使用库函数,永远不要手工分析。对于<code>DWARF</code>来说正是如此。<code>DWARF</code>比 HTML 要复杂得多。上面所展示出来的只是冰山一角。更糟糕的是,在实际的目标文件中,大部分信息是以非常紧凑的压缩格式存储的,
分析起来更加复杂(信息中的某些部分,例如位置信息与行号信息,在某些虚拟机下是以指令的方式编码的)。</p>
<p>所以我们要使用库来处理 DWARF。下面是两种我熟悉的主要的库(还有些不完整的库这里没有写)</p>
<ol>
<li>BFD<code>(</code>libbfd<code>),包含了 </code>objdump<code>(对,就是这篇文章中我们一直在用的这货),</code>ld<code>(GNU 连接器)与 </code>as<code>(GNU 编译器)。</code>BFD`主要用于 <a href="http://www.gnu.org/software/binutils/">GNU binutils</a> 。</li>
<li><code>libdwarf</code>,同它的哥哥 <code>libelf</code> 一同用于 Solaris 与 FreeBSD 中的调试信息分析。</li>
</ol>
<p>相比较而言我更倾向于使用<code>libdwarf</code>,因为我对它了解的更多,并且<code>libdwarf</code>的开源协议更开放(LGPL 对比 GPL)。</p>
<p>因为<code>libdwarf</code>本身相当复杂,操作起来需要相当多的代码,所以我在这不会展示所有代码。你可以在 <a href="https://github.com/eliben/code-for-blog/blob/master/2011/dwarf_get_func_addr.c">这里</a> 下载代码并运行试试。
运行这些代码需要提前安装<code>libelfand</code>与<code>libdwarf</code>,同时在使用连接器的时候要使用参数<code>-lelf</code>与<code>-ldwarf</code>。</p>
<p>这个示例程序可以接受可执行文件并打印其中的函数名称与函数入口地址。下面是我们整篇文章中使用的 C 程序经过示例程序处理后的输出。</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>$ dwarf_get_func_addr tracedprog2
DW_TAG_subprogram: <span class="s1">'do_stuff'</span>
low pc : 0x08048604
high pc : 0x0804863e
DW_TAG_subprogram: <span class="s1">'main'</span>
low pc : 0x0804863e
high pc : 0x0804865a
</pre></table></figure>
<p><code>libdwarf</code>的文档很棒,如果你花些功夫,利用<code>libdwarf</code>获得这篇文章中所涉及到的<code>DWARF</code>信息应该并不困难。</p>
<h2 id="结论与计划">结论与计划</h2>
<p>原理上讲,调试信息是个很简单的概念。尽管实现细节可能比较复杂,但经过了上面的学习我想你应该了解了调试器是如何从可执行文件中获取它需要的源代码信息的了。
对于程序员而言,程序只是代码段与数据结构;对可执行文件而言,程序只是一系列存储在内存或寄存器中的指令或数据。但利用调试信息,调试器就可以将这两者连接起来,从而完成调试工作。</p>
<p>此文与这系列的前两篇,一同介绍了调试器的内部工作过程。利用这里所讲到的知识,再敲些代码,应该可以完成一个 Linux 中最简单、基础但也有一定功能的调试器。</p>
<p>下一步我并不确定要做什么,这个系列文章可能就此结束,也有可能我要讲些堆栈调用的事情,又或者讲 Windows 下的调试。你们有什么好的点子或者相关材料,可以直接评论或者发邮件给我。</p>
<h2 id="引文">引文</h2>
<ul>
<li><code>objdump</code> 参考手册</li>
<li><a href="http://en.wikipedia.org/wiki/Executable_and_Linkable_Format">ELF</a> 与 <a href="http://en.wikipedia.org/wiki/DWARF">DWARF</a> 的维基百科</li>
<li><a href="http://dwarfstd.org/">Dwarf Debugging Standard 主页 </a> ,这里有很棒的 <code>DWARF</code> 教程与 <code>DWARF</code> 标准,作者是 Michael Eager。第二版基于 <code>GCC</code> 也许更能吸引你。</li>
<li><a href="http://reality.sgiweb.org/davea/dwarf.html">libdwarf 主页 </a> ,这里可以下载到 <code>libwarf</code> 的完整库与参考手册</li>
<li><a href="http://sourceware.org/binutils/docs-2.21/bfd/index.html">BFD 文档 </a></li>
</ul>
<p>原文地址:<a href="https://eli.thegreenplace.net/2011/02/07/how-debuggers-work-part-3-debugging-information">https://eli.thegreenplace.net/2011/02/07/how-debuggers-work-part-3-debugging-information</a></p>
Linux GDB 调试器工作原理(2):断点
22
2019-06-12T03:15:47.948700Z
2019-06-12T03:15:47.948700Z
allen
断点和检查变量值是调试的两大利器,本文将会演示如何在调试器中实现断点功能。
<blockquote><p><strong> 摘要 </strong>:断点和检查变量值是调试的两大利器,本文将会演示如何在调试器中实现断点功能。</p>
</blockquote>
<p>这是调试器工作原理系列文章的第二部分,阅读本文前,请确保你已经读过 <a href="/post/linux-gdb-principle-ptrace">第一部分</a> 。</p>
<h2 id="关于本文">关于本文</h2>
<p>我将会演示如何在调试器中实现断点(Breakpoints)。断点是调试的两大利器之一,另一个是可以在被调试进程的内存中检查变量值。
我们在系列的第一部分已经了解过值检查,但是断点对我们来说依然神秘。不过本文过后,它们就不再如此了。</p>
<h2 id="软件中断">软件中断</h2>
<p>为了在 x86 架构机器上实现断点,软件中断(也被称作 “陷阱”)被会派上用场。在我们深入细节之前,我想先大致解释一下中断(Interrupts)和陷阱(Traps)的概念。</p>
<p>CPU 有一条单独的执行流,一条指令接一条的执行(在更高的层面看是这样的,但是在底层的细节上来说,现在的许多 CPU 都会并行执行多个指令,这其中的一些指令就不是按照原本的顺序执行的)。
为了能够处理异步的事件,如 IO 和 硬件定时器,CPU 使用了中断。硬件中断通常是一个特定的电子信号,并附加了一个特别的” 响应电路”。
该电路通知中断激活,并让 CPU 停止当前执行,保存状态,然后跳转到一个预定义的地址,也就是中断处理程序的位置。当处理程序完成其工作后,CPU 又从之前停止的地方重新恢复运行。</p>
<p>软件中断在规则上与硬件相似,但实际操作中有些不同。CPU 支持一些特殊的指令,来允许软件模拟出一个中断。
当这样的一个指令被执行时,CPU 像对待一个硬件中断那样 —— 停止正常的执行流,保存状态,然后跳转到一个处理程序。
这种 “中断” 使得许多现代 OS 的惊叹设计得以高效地实现(如任务调度,虚拟内存,内存保护,调试)。</p>
<p>许多编程错误(如被 0 除)也被 CPU 当做中断对待,常常也叫做 “异常”, 这时候硬件和软件中断之间的界限就模糊了,
很难说这种异常到底是硬件中断还是软件中断。但我已经偏离今天主题太远了,所以现在让我们回到断点上来。</p>
<h2 id="int3理论">int 3 理论</h2>
<p>前面说了很多,现在简单来说断点就是一个部署在 CPU 上的特殊中断,叫<code>int 3</code>。<code>int</code>是一个 “中断指令” 的 x86 术语,该指令是对一个预定义中断处理的调用。
x86 支持 8 位的<code>int</code>指令操作数,这决定了中断的数量,所以理论上可以支持 256 个中断。前 32 个中断为 CPU 自己保留,而<code>int 3</code>就是本文关注的 —— 它被叫做 “调试器专用中断”。</p>
<p>避免更深的解释,我将引用 “圣经” 里一段话(这里说的 “圣经”,当然指的是英特尔的体系结构软件开发者手册,卷 2A)。</p>
<blockquote><p><code>INT 3</code> 指令生成一个以字节操作码(CC),用于调用该调试异常处理程序。(这个一字节格式是非常有用的,因为它可以用于使用断点来替换任意指令的第一个字节 ,包括哪些一字节指令,而不会覆写其它代码)</p>
</blockquote>
<p>上述引用非常重要,但是目前去解释它还是为时过早。本文后面我们会回过头再看。</p>
<h2 id="int3实践">int 3 实践</h2>
<p>没错,知道事物背后的理论非常不错,不过,这些理论到底意思是啥?我们怎样使用 int 3 部署断点?或者怎么翻译成通用的编程术语 —— 请给我看代码!</p>
<p>实际上,实现非常简单。一旦你的程序执行了<code>int 3</code>指令, OS 就会停止程序( OS 是怎么做到像这样停止进程的? OS 注册其 int 3 的控制程序到 CPU 即可,就这么简单)。
在 Linux(这也是本文比较关心的地方) 上, OS 会发送给进程一个信号 ——<code>SIGTRAP</code>。</p>
<p>就是这样,真的。现在回想一下本系列的第一部分,追踪进程(调试程序) 会得到其子进程(或它所连接的被调试进程)所得到的所有信号的通知,接下来你就知道了。</p>
<p>就这样,没有更多的电脑架构基础术语了。该是例子和代码的时候了。</p>
<h2 id="手动设置断点">手动设置断点</h2>
<p>现在我要演示在程序里设置断点的代码。我要使用的程序如下:</p>
<figure class="highlight asm" data-lang="asm"><table><tbody><tr><td class="code"><pre class="hljs asm"><span></span><span class="nf">section</span> <span class="no">.text</span>
<span class="c">; The _start symbol must be declared for the linker (ld)</span>
<span class="nf">global</span> <span class="no">_start</span>
<span class="nl">_start:</span>
<span class="c">; Prepare arguments for the sys_write system call:</span>
<span class="c">; - eax: system call number (sys_write)</span>
<span class="c">; - ebx: file descriptor (stdout)</span>
<span class="c">; - ecx: pointer to string</span>
<span class="c">; - edx: string length</span>
<span class="nf">mov</span> <span class="no">edx</span><span class="p">,</span> <span class="no">len1</span>
<span class="nf">mov</span> <span class="no">ecx</span><span class="p">,</span> <span class="no">msg1</span>
<span class="nf">mov</span> <span class="no">ebx</span><span class="p">,</span> <span class="mi">1</span>
<span class="nf">mov</span> <span class="no">eax</span><span class="p">,</span> <span class="mi">4</span>
<span class="c">; Execute the sys_write system call</span>
<span class="nf">int</span> <span class="mi">0x80</span>
<span class="c">; Now print the other message</span>
<span class="nf">mov</span> <span class="no">edx</span><span class="p">,</span> <span class="no">len2</span>
<span class="nf">mov</span> <span class="no">ecx</span><span class="p">,</span> <span class="no">msg2</span>
<span class="nf">mov</span> <span class="no">ebx</span><span class="p">,</span> <span class="mi">1</span>
<span class="nf">mov</span> <span class="no">eax</span><span class="p">,</span> <span class="mi">4</span>
<span class="nf">int</span> <span class="mi">0x80</span>
<span class="c">; Execute sys_exit</span>
<span class="nf">mov</span> <span class="no">eax</span><span class="p">,</span> <span class="mi">1</span>
<span class="nf">int</span> <span class="mi">0x80</span>
<span class="nf">section</span> <span class="no">.data</span>
<span class="nf">msg1</span> <span class="no">db</span> <span class="err">'</span><span class="no">Hello</span><span class="p">,</span><span class="err">'</span><span class="p">,</span> <span class="mi">0xa</span>
<span class="nf">len1</span> <span class="no">equ</span> <span class="no">$</span> <span class="p">-</span> <span class="no">msg1</span>
<span class="nf">msg2</span> <span class="no">db</span> <span class="err">'</span><span class="no">world</span><span class="p">!</span><span class="err">'</span><span class="p">,</span> <span class="mi">0xa</span>
<span class="nf">len2</span> <span class="no">equ</span> <span class="no">$</span> <span class="p">-</span> <span class="no">msg2</span>
</pre></table></figure>
<p>我现在在使用汇编语言,是为了当我们面对 C 代码的时候,能清楚一些编译细节。上面代码做的事情非常简单,就是在一行打印出 “hello,”,然后在下一行打印出 “world!”。这与之前文章中的程序非常类似。</p>
<p>现在我想在第一次打印和第二次打印之间设置一个断点。我们看到在第一条<code>int 0x80</code>,其后指令是<code>mov edx, len2</code>。
(等等,再次 int?是的,Linux 使用<code>int 0x80</code>来实现用户进程到系统内核的系统调用。用户将系统调用的号码及其参数放到寄存器,并执行<code>int 0x80</code>。
然后 CPU 会跳到相应的中断处理程序,其中, OS 注册了一个过程,该过程查看寄存器并决定要执行的系统调用。)</p>
<p>首先,我们需要知道该指令所映射的地址。运行<code>objdump -d</code>:</p>
<pre><code>traced_printer2: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000033 08048080 08048080 00000080 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .data 0000000e 080490b4 080490b4 000000b4 2**2
CONTENTS, ALLOC, LOAD, DATA
Disassembly of section .text:
08048080 <.text>:
8048080: ba 07 00 00 00 mov $0x7,%edx
8048085: b9 b4 90 04 08 mov $0x80490b4,%ecx
804808a: bb 01 00 00 00 mov $0x1,%ebx
804808f: b8 04 00 00 00 mov $0x4,%eax
8048094: cd 80 int $0x80
8048096: ba 07 00 00 00 mov $0x7,%edx
804809b: b9 bb 90 04 08 mov $0x80490bb,%ecx
80480a0: bb 01 00 00 00 mov $0x1,%ebx
80480a5: b8 04 00 00 00 mov $0x4,%eax
80480aa: cd 80 int $0x80
80480ac: b8 01 00 00 00 mov $0x1,%eax
80480b1: cd 80 int $0x80</code></pre>
<p>所以,我们要设置断点的地址是<code>0x8048096</code>。等等,这不是调试器工作的真实姿势,对吧?真正的调试器是在代码行和函数上设置断点,而不是赤裸裸的内存地址?
完全正确,但是目前我们仍然还没到那一步,为了更像真正的调试器一样设置断点,我们仍不得不首先理解一些符号和调试信息。所以现在,我们就得面对内存地址。</p>
<p>在这点上,我真想又偏离一下主题。所以现在你有两个选择,如果你真的感兴趣想知道为什么那个地址应该是<code>0x8048096</code>,它代表着什么,那就看下面的部分。否则你只是想了解断点,你可以跳过这部分。</p>
<h2 id="题外话——程序地址和入口">题外话 —— 程序地址和入口</h2>
<p>坦白说,<code>0x8048096</code>本身没多大意义,仅仅是可执行程序的<code>text</code>部分开端偏移的一些字节。如果你看上面导出来的列表,你会看到 text 部分从地址<code>0x08048080</code>开始。
这告诉 OS 在分配给进程的虚拟地址空间里,将该地址映射到<code>text</code>部分开始的地方。在 Linux 上面,这些地址可以是绝对地址(例如,当可执行程序加载到内存中时它不做重定位),
因为通过虚拟地址系统,每个进程获得自己的一块内存,并且将整个 32 位地址空间看做自己的(称为 “线性” 地址)。</p>
<p>如果我们使用<code>readelf</code>命令检查 ELF 文件头部(ELF,可执行和可链接格式,是 Linux 上用于对象文件、共享库和可执行程序的文件格式),我们会看到:</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>$ readelf -h traced_printer2
ELF Header:
Magic: 7f <span class="m">45</span> 4c <span class="m">46</span> <span class="m">01</span> <span class="m">01</span> <span class="m">01</span> <span class="m">00</span> <span class="m">00</span> <span class="m">00</span> <span class="m">00</span> <span class="m">00</span> <span class="m">00</span> <span class="m">00</span> <span class="m">00</span> <span class="m">00</span>
Class: ELF32
Data: <span class="m">2</span><span class="err">'</span>s complement, little endian
Version: <span class="m">1</span> <span class="o">(</span>current<span class="o">)</span>
OS/ABI: UNIX - System V
ABI Version: <span class="m">0</span>
Type: EXEC <span class="o">(</span>Executable file<span class="o">)</span>
Machine: Intel <span class="m">80386</span>
Version: 0x1
Entry point address: 0x8048080
Start of program headers: <span class="m">52</span> <span class="o">(</span>bytes into file<span class="o">)</span>
Start of section headers: <span class="m">220</span> <span class="o">(</span>bytes into file<span class="o">)</span>
Flags: 0x0
Size of this header: <span class="m">52</span> <span class="o">(</span>bytes<span class="o">)</span>
Size of program headers: <span class="m">32</span> <span class="o">(</span>bytes<span class="o">)</span>
Number of program headers: <span class="m">2</span>
Size of section headers: <span class="m">40</span> <span class="o">(</span>bytes<span class="o">)</span>
Number of section headers: <span class="m">4</span>
Section header string table index: <span class="m">3</span>
</pre></table></figure>
<p>注意头部里的<code>Entry point address</code>,它同样指向<code>0x8048080</code>。所以我们在系统层面解释该<code>elf</code>文件的编码信息,它意思是:</p>
<ol>
<li>映射 text 部分(包含所给的内容)到地址 <code>0x8048080</code></li>
<li>从入口 —— 地址 <code>0x8048080</code> 处开始执行</li>
</ol>
<p>但是,为什么是<code>0x8048080</code>呢?事实证明是一些历史原因。一些 Google 的结果把我引向源头,宣传每个进程的地址空间的前 128M 是保留在栈里的。
128M 对应为<code>0x8000000</code>,该地址是可执行程序其他部分可以开始的地方。而<code>0x8048080</code>,比较特别,是 Linux ld 链接器使用的默认入口地址。该入口可以通过给 ld 传递<code>-Ttext</code>参数改变。</p>
<p>总结一下,这地址没啥特别的,我们可以随意修改它。只要 ELF 可执行文件被合理的组织,并且头部里的入口地址与真正的程序代码(text 部分)开始的地址匹配,一切都没问题。</p>
<h2 id="用int3在调试器中设置断点">用 int 3 在调试器中设置断点</h2>
<p>为了在被追踪进程的某些目标地址设置一个断点,调试器会做如下工作:</p>
<ol>
<li>记住存储在目标地址的数据</li>
<li>用 int 指令替换掉目标地址的第一个字节</li>
</ol>
<p>然后,当调试器要求 OS 运行该进程的时候(通过上一篇文章中提过的<code>PTRACE_CONT</code>),进程就会运行起来直到遇到<code>int 3</code>,此处进程会停止运行,并且 OS 会发送一个信号给调试器。
调试器会收到一个信号表明其子进程(或者说被追踪进程)停止了。调试器可以做以下工作:</p>
<ol>
<li>在目标地址,用原来的正常执行指令替换掉 <code>int 3</code> 指令</li>
<li>将被追踪进程的指令指针回退一步。这是因为现在指令指针位于刚刚执行过的 <code>int 3</code> 之后。</li>
<li>允许用户以某些方式与进程交互,因为该进程仍然停止在特定的目标地址。这里你的调试器可以让你取得变量值,调用栈等等。</li>
<li>当用户想继续运行,调试器会小心地把断点放回目标地址去(因为它在第 1 步时被移走了),除非用户要求取消该断点。</li>
</ol>
<p>让我们来看看,这些步骤是如何翻译成具体代码的。我们会用到第一篇里的调试器 “模板”(<code>fork</code>一个子进程并追踪它)。</p>
<p>无论如何,文末会有一个完整样例源代码的链接</p>
<figure class="highlight c" data-lang="c"><table><tbody><tr><td class="code"><pre class="hljs c"><span></span><span class="cm">/* Obtain and show child's instruction pointer */</span>
<span class="n">ptrace</span><span class="p">(</span><span class="n">PTRACE_GETREGS</span><span class="p">,</span> <span class="n">child_pid</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="o">&</span><span class="n">regs</span><span class="p">);</span>
<span class="n">procmsg</span><span class="p">(</span><span class="s">"Child started. EIP = 0x%08x</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">regs</span><span class="p">.</span><span class="n">eip</span><span class="p">);</span>
<span class="cm">/* Look at the word at the address we're interested in */</span>
<span class="kt">unsigned</span> <span class="n">addr</span> <span class="o">=</span> <span class="mh">0x8048096</span><span class="p">;</span>
<span class="kt">unsigned</span> <span class="n">data</span> <span class="o">=</span> <span class="n">ptrace</span><span class="p">(</span><span class="n">PTRACE_PEEKTEXT</span><span class="p">,</span> <span class="n">child_pid</span><span class="p">,</span> <span class="p">(</span><span class="kt">void</span><span class="o">*</span><span class="p">)</span><span class="n">addr</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span>
<span class="n">procmsg</span><span class="p">(</span><span class="s">"Original data at 0x%08x: 0x%08x</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">addr</span><span class="p">,</span> <span class="n">data</span><span class="p">);</span>
</pre></table></figure>
<p>这里调试器从被追踪的进程中取回了指令指针,也检查了在 0x8048096 的字。当开始追踪运行文章开头的汇编代码,将会打印出:</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span><span class="o">[</span><span class="m">13028</span><span class="o">]</span> Child started. <span class="nv">EIP</span> <span class="o">=</span> 0x08048080
<span class="o">[</span><span class="m">13028</span><span class="o">]</span> Original data at 0x08048096: 0x000007ba
</pre></table></figure>
<p>目前为止都看起来不错。接下来:</p>
<figure class="highlight c" data-lang="c"><table><tbody><tr><td class="code"><pre class="hljs c"><span></span><span class="cm">/* Write the trap instruction 'int 3' into the address */</span>
<span class="kt">unsigned</span> <span class="n">data_with_trap</span> <span class="o">=</span> <span class="p">(</span><span class="n">data</span> <span class="o">&</span> <span class="mh">0xFFFFFF00</span><span class="p">)</span> <span class="o">|</span> <span class="mh">0xCC</span><span class="p">;</span>
<span class="n">ptrace</span><span class="p">(</span><span class="n">PTRACE_POKETEXT</span><span class="p">,</span> <span class="n">child_pid</span><span class="p">,</span> <span class="p">(</span><span class="kt">void</span><span class="o">*</span><span class="p">)</span><span class="n">addr</span><span class="p">,</span> <span class="p">(</span><span class="kt">void</span><span class="o">*</span><span class="p">)</span><span class="n">data_with_trap</span><span class="p">);</span>
<span class="cm">/* See what's there again... */</span>
<span class="kt">unsigned</span> <span class="n">readback_data</span> <span class="o">=</span> <span class="n">ptrace</span><span class="p">(</span><span class="n">PTRACE_PEEKTEXT</span><span class="p">,</span> <span class="n">child_pid</span><span class="p">,</span> <span class="p">(</span><span class="kt">void</span><span class="o">*</span><span class="p">)</span><span class="n">addr</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span>
<span class="n">procmsg</span><span class="p">(</span><span class="s">"After trap, data at 0x%08x: 0x%08x</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">addr</span><span class="p">,</span> <span class="n">readback_data</span><span class="p">);</span>
<span class="err">注意到</span> <span class="kt">int</span> <span class="mi">3</span> <span class="err">是如何被插入到目标地址的。此处打印:</span>
<span class="p">[</span><span class="mi">13028</span><span class="p">]</span> <span class="n">After</span> <span class="n">trap</span><span class="p">,</span> <span class="n">data</span> <span class="n">at</span> <span class="mh">0x08048096</span><span class="o">:</span> <span class="mh">0x000007cc</span>
</pre></table></figure>
<p>正如预料的那样 ——<code>0xba</code>被<code>0xcc</code>替换掉了。</p>
<p>现在调试器运行子进程并等待它在断点处停止:</p>
<figure class="highlight c" data-lang="c"><table><tbody><tr><td class="code"><pre class="hljs c"><span></span><span class="cm">/* Let the child run to the breakpoint and wait for it to</span>
<span class="cm">** reach it</span>
<span class="cm">*/</span>
<span class="n">ptrace</span><span class="p">(</span><span class="n">PTRACE_CONT</span><span class="p">,</span> <span class="n">child_pid</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span>
<span class="n">wait</span><span class="p">(</span><span class="o">&</span><span class="n">wait_status</span><span class="p">);</span>
<span class="k">if</span> <span class="p">(</span><span class="n">WIFSTOPPED</span><span class="p">(</span><span class="n">wait_status</span><span class="p">))</span> <span class="p">{</span>
<span class="n">procmsg</span><span class="p">(</span><span class="s">"Child got a signal: %s</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">strsignal</span><span class="p">(</span><span class="n">WSTOPSIG</span><span class="p">(</span><span class="n">wait_status</span><span class="p">)));</span>
<span class="p">}</span>
<span class="k">else</span> <span class="p">{</span>
<span class="n">perror</span><span class="p">(</span><span class="s">"wait"</span><span class="p">);</span>
<span class="k">return</span><span class="p">;</span>
<span class="p">}</span>
<span class="cm">/* See where the child is now */</span>
<span class="n">ptrace</span><span class="p">(</span><span class="n">PTRACE_GETREGS</span><span class="p">,</span> <span class="n">child_pid</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="o">&</span><span class="n">regs</span><span class="p">);</span>
<span class="n">procmsg</span><span class="p">(</span><span class="s">"Child stopped at EIP = 0x%08x</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">regs</span><span class="p">.</span><span class="n">eip</span><span class="p">);</span>
<span class="err">这里打印出:</span>
<span class="n">Hello</span><span class="p">,</span>
<span class="p">[</span><span class="mi">13028</span><span class="p">]</span> <span class="n">Child</span> <span class="n">got</span> <span class="n">a</span> <span class="nl">signal</span><span class="p">:</span> <span class="n">Trace</span><span class="o">/</span><span class="n">breakpoint</span> <span class="n">trap</span>
<span class="p">[</span><span class="mi">13028</span><span class="p">]</span> <span class="n">Child</span> <span class="n">stopped</span> <span class="n">at</span> <span class="n">EIP</span> <span class="o">=</span> <span class="mh">0x08048097</span>
</pre></table></figure>
<p>注意到 “Hello,” 在断点前打印出来了 —— 完全如我们计划的那样。同时注意到子进程停止的地方 —— 刚好就是单字节中断指令后面。</p>
<p>最后,如早先诠释的那样,为了让子进程继续运行,我们得做一些工作。我们用原来的指令替换掉中断指令,并且让进程从这里继续之前的运行。</p>
<figure class="highlight c" data-lang="c"><table><tbody><tr><td class="code"><pre class="hljs c"><span></span><span class="cm">/* Remove the breakpoint by restoring the previous data</span>
<span class="cm">** at the target address, and unwind the EIP back by 1 to</span>
<span class="cm">** let the CPU execute the original instruction that was</span>
<span class="cm">** there.</span>
<span class="cm">*/</span>
<span class="n">ptrace</span><span class="p">(</span><span class="n">PTRACE_POKETEXT</span><span class="p">,</span> <span class="n">child_pid</span><span class="p">,</span> <span class="p">(</span><span class="kt">void</span><span class="o">*</span><span class="p">)</span><span class="n">addr</span><span class="p">,</span> <span class="p">(</span><span class="kt">void</span><span class="o">*</span><span class="p">)</span><span class="n">data</span><span class="p">);</span>
<span class="n">regs</span><span class="p">.</span><span class="n">eip</span> <span class="o">-=</span> <span class="mi">1</span><span class="p">;</span>
<span class="n">ptrace</span><span class="p">(</span><span class="n">PTRACE_SETREGS</span><span class="p">,</span> <span class="n">child_pid</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="o">&</span><span class="n">regs</span><span class="p">);</span>
<span class="cm">/* The child can continue running now */</span>
<span class="n">ptrace</span><span class="p">(</span><span class="n">PTRACE_CONT</span><span class="p">,</span> <span class="n">child_pid</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span>
</pre></table></figure>
<p>这会使子进程继续打印出 “world!”,然后退出。</p>
<p>注意,我们在这里没有恢复断点。通过在单步调试模式下,运行原来的指令,然后将中断放回去,并且只在运行<code>PTRACE_CONT</code>时做到恢复断点。文章稍后会展示<code>debuglib</code>如何做到这点。</p>
<h2 id="更多关于int3">更多关于 int 3</h2>
<p>现在可以回过头去看看<code>int 3</code>和因特尔手册里那个神秘的说明,原文如下:</p>
<blockquote><p> 这个一字节格式是非常有用的,因为它可以用于使用断点来替换任意指令的第一个字节 ,包括哪些一字节指令,而不会覆写其它代码。</p>
</blockquote>
<p><code>int</code>指令在 x86 机器上占两个字节 ——<code>0xcd</code>紧跟着中断数(细心的读者可以在上面列出的转储中发现<code>int 0x80</code>翻译成了<code>cd 80</code>)。<code>int 3</code>被编码为<code>cd 03</code>,但是为其还保留了一个单字节指令 ——<code>0xcc</code>。</p>
<p>为什么这样呢?因为这可以允许我们插入一个断点,而不需要重写多余的指令。这非常重要,考虑下面的代码:</p>
<figure class="highlight asm" data-lang="asm"><table><tbody><tr><td class="code"><pre class="hljs asm"><span></span><span class="nf">..</span> <span class="no">some</span> <span class="no">code</span> <span class="no">..</span>
<span class="nf">jz</span> <span class="no">foo</span>
<span class="nf">dec</span> <span class="no">eax</span>
<span class="nl">foo:</span>
<span class="nf">call</span> <span class="no">bar</span>
<span class="nf">..</span> <span class="no">some</span> <span class="no">code</span> <span class="no">..</span>
</pre></table></figure>
<p>假设你想在<code>dec eax</code>这里放置一个断点。这对应一个单字节指令(操作码为<code>0x48</code>)。由于替换断点的指令长于一个字节,我们不得不强制覆盖掉下个指令(<code>call</code>)的一部分,
这就会篡改<code>call</code>指令,并很可能导致一些完全不合理的事情发生。这样一来跳转到<code>foo</code>分支的<code>jz foo</code>指令会导致什么?就会不在<code>dec eax</code>这里停止,CPU 径直去执行后面一些无效的指令了。</p>
<p>而有了单字节的<code>int 3</code>指令,这个问题就解决了。 1 字节是在 x86 上面所能找到的最短指令,这样我们可以保证仅改变我们想中断的指令。</p>
<h2 id="封装一些晦涩的细节">封装一些晦涩的细节</h2>
<p>很多上述章节样例代码的底层细节,都可以很容易封装在方便使用的 API 里。我已经做了很多封装的工作,将它们都放在一个叫做<code>debuglib</code>的通用库里 —— 文末可以去下载。
这里我仅仅是想展示它的用法示例,但是绕了一圈。下面我们将追踪一个用 C 写的程序。</p>
<h2 id="追踪一个C程序地址和入口">追踪一个 C 程序地址和入口</h2>
<p>目前为止,为了简单,我把注意力放在了目标汇编代码。现在是时候往上一个层次,去看看我们如何追踪一个 C 程序。</p>
<p>事实证明并不是非常难 —— 找到放置断点位置有一点难罢了。考虑下面样例程序:</p>
<figure class="highlight c" data-lang="c"><table><tbody><tr><td class="code"><pre class="hljs c"><span></span><span class="cp">#include</span> <span class="cpf"><stdio.h></span><span class="cp"></span>
<span class="kt">void</span> <span class="nf">do_stuff</span><span class="p">()</span>
<span class="p">{</span>
<span class="n">printf</span><span class="p">(</span><span class="s">"Hello, "</span><span class="p">);</span>
<span class="p">}</span>
<span class="kt">int</span> <span class="nf">main</span><span class="p">()</span>
<span class="p">{</span>
<span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o"><</span> <span class="mi">4</span><span class="p">;</span> <span class="o">++</span><span class="n">i</span><span class="p">)</span>
<span class="n">do_stuff</span><span class="p">();</span>
<span class="n">printf</span><span class="p">(</span><span class="s">"world!</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>
<span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</pre></table></figure>
<p>假设我想在<code>do_stuff</code>入口处放置一个断点。我会先使用<code>objdump</code>反汇编一下可执行文件,但是打印出的东西太多。
尤其看到很多无用,也不感兴趣的 C 程序运行时的初始化代码。所以我们仅看一下<code>do_stuff</code>部分:</p>
<figure class="highlight asm" data-lang="asm"><table><tbody><tr><td class="code"><pre class="hljs asm"><span></span><span class="err">080483</span><span class="nf">e4</span> <span class="err"><</span><span class="no">do_stuff</span><span class="err">></span><span class="p">:</span>
<span class="err">80483</span><span class="nl">e4:</span> <span class="err">55</span> <span class="nf">push</span> <span class="nv">%ebp</span>
<span class="err">80483</span><span class="nl">e5:</span> <span class="err">89</span> <span class="nf">e5</span> <span class="no">mov</span> <span class="nv">%esp</span><span class="p">,</span><span class="nv">%ebp</span>
<span class="err">80483</span><span class="nl">e7:</span> <span class="err">83</span> <span class="nf">ec</span> <span class="mi">18</span> <span class="no">sub</span> <span class="no">$0x18</span><span class="p">,</span><span class="nv">%esp</span>
<span class="err">80483</span><span class="nl">ea:</span> <span class="nf">c7</span> <span class="mi">04</span> <span class="mi">24</span> <span class="no">f0</span> <span class="mi">84</span> <span class="mi">04</span> <span class="mi">08</span> <span class="no">movl</span> <span class="no">$0x80484f0</span><span class="p">,(</span><span class="nv">%esp</span><span class="p">)</span>
<span class="err">80483</span><span class="nl">f1:</span> <span class="nf">e8</span> <span class="mi">22</span> <span class="no">ff</span> <span class="no">ff</span> <span class="no">ff</span> <span class="no">call</span> <span class="mh">8048318</span> <span class="p"><</span><span class="no">puts@plt</span><span class="p">></span>
<span class="err">80483</span><span class="nl">f6:</span> <span class="nf">c9</span> <span class="no">leave</span>
<span class="err">80483</span><span class="nl">f7:</span> <span class="nf">c3</span> <span class="no">ret</span>
</pre></table></figure>
<p>那么,我们将会把断点放在<code>0x080483e4</code>,这是<code>do_stuff</code>第一条指令执行的地方。
而且,该函数是在循环里面调用的,我们想要在断点处一直停止执行直到循环结束。
我们将会使用 debuglib 来简化该流程,下面是完整的调试函数:</p>
<figure class="highlight c" data-lang="c"><table><tbody><tr><td class="code"><pre class="hljs c"><span></span><span class="kt">void</span> <span class="nf">run_debugger</span><span class="p">(</span><span class="kt">pid_t</span> <span class="n">child_pid</span><span class="p">)</span>
<span class="p">{</span>
<span class="n">procmsg</span><span class="p">(</span><span class="s">"debugger started</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>
<span class="cm">/* Wait for child to stop on its first instruction */</span>
<span class="n">wait</span><span class="p">(</span><span class="mi">0</span><span class="p">);</span>
<span class="n">procmsg</span><span class="p">(</span><span class="s">"child now at EIP = 0x%08x</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">get_child_eip</span><span class="p">(</span><span class="n">child_pid</span><span class="p">));</span>
<span class="cm">/* Create breakpoint and run to it*/</span>
<span class="n">debug_breakpoint</span><span class="o">*</span> <span class="n">bp</span> <span class="o">=</span> <span class="n">create_breakpoint</span><span class="p">(</span><span class="n">child_pid</span><span class="p">,</span> <span class="p">(</span><span class="kt">void</span><span class="o">*</span><span class="p">)</span><span class="mh">0x080483e4</span><span class="p">);</span>
<span class="n">procmsg</span><span class="p">(</span><span class="s">"breakpoint created</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>
<span class="n">ptrace</span><span class="p">(</span><span class="n">PTRACE_CONT</span><span class="p">,</span> <span class="n">child_pid</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span>
<span class="n">wait</span><span class="p">(</span><span class="mi">0</span><span class="p">);</span>
<span class="cm">/* Loop as long as the child didn't exit */</span>
<span class="k">while</span> <span class="p">(</span><span class="mi">1</span><span class="p">)</span> <span class="p">{</span>
<span class="cm">/* The child is stopped at a breakpoint here. Resume its</span>
<span class="cm"> ** execution until it either exits or hits the</span>
<span class="cm"> ** breakpoint again.</span>
<span class="cm"> */</span>
<span class="n">procmsg</span><span class="p">(</span><span class="s">"child stopped at breakpoint. EIP = 0x%08X</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">get_child_eip</span><span class="p">(</span><span class="n">child_pid</span><span class="p">));</span>
<span class="n">procmsg</span><span class="p">(</span><span class="s">"resuming</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>
<span class="kt">int</span> <span class="n">rc</span> <span class="o">=</span> <span class="n">resume_from_breakpoint</span><span class="p">(</span><span class="n">child_pid</span><span class="p">,</span> <span class="n">bp</span><span class="p">);</span>
<span class="k">if</span> <span class="p">(</span><span class="n">rc</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
<span class="n">procmsg</span><span class="p">(</span><span class="s">"child exited</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>
<span class="k">break</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">rc</span> <span class="o">==</span> <span class="mi">1</span><span class="p">)</span> <span class="p">{</span>
<span class="k">continue</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">else</span> <span class="p">{</span>
<span class="n">procmsg</span><span class="p">(</span><span class="s">"unexpected: %d</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">rc</span><span class="p">);</span>
<span class="k">break</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="n">cleanup_breakpoint</span><span class="p">(</span><span class="n">bp</span><span class="p">);</span>
<span class="p">}</span>
</pre></table></figure>
<p>为了避免修改 EIP 标志位和目的进程的内存空间的麻烦,我们仅需要调用<code>create_breakpoint,resume_from_breakpoint</code>和<code>cleanup_breakpoint</code>。
让我们来看看追踪上面的 C 代码样例会输出什么:</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>$ bp_use_lib traced_c_loop
<span class="o">[</span><span class="m">13363</span><span class="o">]</span> debugger started
<span class="o">[</span><span class="m">13364</span><span class="o">]</span> target started. will run <span class="s1">'traced_c_loop'</span>
<span class="o">[</span><span class="m">13363</span><span class="o">]</span> child now at <span class="nv">EIP</span> <span class="o">=</span> 0x00a37850
<span class="o">[</span><span class="m">13363</span><span class="o">]</span> breakpoint created
<span class="o">[</span><span class="m">13363</span><span class="o">]</span> child stopped at breakpoint. <span class="nv">EIP</span> <span class="o">=</span> 0x080483E5
<span class="o">[</span><span class="m">13363</span><span class="o">]</span> resuming
Hello,
<span class="o">[</span><span class="m">13363</span><span class="o">]</span> child stopped at breakpoint. <span class="nv">EIP</span> <span class="o">=</span> 0x080483E5
<span class="o">[</span><span class="m">13363</span><span class="o">]</span> resuming
Hello,
<span class="o">[</span><span class="m">13363</span><span class="o">]</span> child stopped at breakpoint. <span class="nv">EIP</span> <span class="o">=</span> 0x080483E5
<span class="o">[</span><span class="m">13363</span><span class="o">]</span> resuming
Hello,
<span class="o">[</span><span class="m">13363</span><span class="o">]</span> child stopped at breakpoint. <span class="nv">EIP</span> <span class="o">=</span> 0x080483E5
<span class="o">[</span><span class="m">13363</span><span class="o">]</span> resuming
Hello,
world!
<span class="o">[</span><span class="m">13363</span><span class="o">]</span> child exited
</pre></table></figure>
<p>如预期一样!</p>
<h2 id="样例代码">样例代码</h2>
<p><a href="https://github.com/eliben/code-for-blog/tree/master/2011/debuggers_part2_code">这里是</a> 本文用到的完整源代码文件。在归档中你可以找到:</p>
<ul>
<li>debuglib.h 和 debuglib.c - 封装了调试器的一些内部工作的示例库</li>
<li>bp_manual.c - 这篇文章开始部分介绍的 “手动” 设置断点的方法。一些样板代码使用了 <code>debuglib</code> 库。</li>
<li>bpuselib.c - 大部分代码使用了 <code>debuglib</code> 库,用于在第二个代码范例中演示在 C 程序的循环中追踪。</li>
</ul>
<p><a href="/linux/linux-gdb-debugging-information">下一篇文章</a> 中我将向您展示如何获取调试信息。</p>
<h2 id="引文">引文</h2>
<p>在准备本文的时候,我搜集了如下的资源和文章:</p>
<ul>
<li> <a href="http://www.alexonlinux.com/how-debugger-works">How debugger works</a> </li>
<li> <a href="http://www.linuxforums.org/articles/understanding-elf-using-readelf-and-objdump_125.html">Understanding ELF using readelf and objdump</a> </li>
<li> <a href="http://mainisusuallyafunction.blogspot.com/2011/01/implementing-breakpoints-on-x86-linux.html">Implementing breakpoints on x86 Linux</a> </li>
<li> <a href="http://www.nasm.us/xdoc/2.09.04/html/nasmdoc0.html">NASM manual</a> </li>
<li> <a href="http://stackoverflow.com/questions/2187484/elf-binary-entry-point">SO discussion of the ELF entry point</a> </li>
<li> <a href="http://news.ycombinator.net/item?id=2131894">This Hacker News discussion of the first part of the series</a> </li>
<li> <a href="http://www.deansys.com/doc/gdbInternals/gdbint_toc.html">GDB Internals</a> </li>
</ul>
<p>原文地址:<a href="http://eli.thegreenplace.net/2011/01/27/how-debuggers-work-part-2-breakpoints">http://eli.thegreenplace.net/2011/01/27/how-debuggers-work-part-2-breakpoints</a></p>
Linux GDB 调试器工作原理(1):ptrace 基础篇
21
2019-06-12T02:31:50.283413Z
2019-06-12T02:31:50.283413Z
allen
本文介绍关于 Linux 下的 gdb 调试器实现的主要组成部分 —— ptrace 系统调用。
<blockquote><p><strong> 摘要 </strong>:本文介绍关于 Linux 下的 gdb 调试器实现的主要组成部分 —— ptrace 系统调用。</p>
</blockquote>
<p>本文是一系列探究调试器工作原理的文章的第一篇。我还不确定这个系列需要包括多少篇文章以及它们所涵盖的主题,但我打算从基础知识开始说起。</p>
<h2 id="关于本文">关于本文</h2>
<p>文中出现的代码都在 32 位的 Ubuntu 系统上开发。请注意,这里出现的代码是同平台紧密相关的,但移植到别的平台上应该不会太难。</p>
<h2 id="本文动机">本文动机</h2>
<p>要想理解我们究竟要做什么,试着想象一下调试器是如何工作的。调试器可以启动某些进程,然后对其进行调试,或者将自己本身关联到一个已存在的进程之上。
它可以单步运行代码,设置断点然后运行程序,检查变量的值以及跟踪调用栈。
许多调试器已经拥有了一些高级特性,比如执行表达式并在被调试进程的地址空间中调用函数,甚至可以直接修改进程的代码并观察修改后的程序行为。</p>
<p>尽管现代的调试器都是复杂的大型程序,但令人惊讶的是构建调试器的基础确是如此的简单。
调试器只用到了几个由操作系统以及编译器 / 链接器提供的基础服务,剩下的仅仅就是 <a href="http://en.wikipedia.org/wiki/Small_matter_of_programming">简单的编程问题了</a> 。(译注:可查阅维基百科中关于这个词条的解释,作者是在反讽)</p>
<h2 id="Linux下的调试——ptrace">Linux 下的调试 —— ptrace</h2>
<p>Linux 下调试器拥有一个瑞士军刀般的工具,这就是<code>ptrace</code>系统调用。这是一个功能众多且相当复杂的工具,能允许一个进程控制另一个进程的运行,而且可以监视和渗入到进程内部。<code>ptrace</code>本身需要一本中等篇幅的书才能对其进行完整的解释,这就是为什么我只打算通过例子把重点放在它的实际用途上。让我们继续深入探寻。</p>
<h3 id="遍历进程的代码">遍历进程的代码</h3>
<p>我现在要写一个在 “跟踪” 模式下运行的进程的例子,这里我们要单步遍历这个进程的代码 —— 由 CPU 所执行的机器码(汇编指令)。
我会在这里给出例子代码,解释每个部分,本文结尾处你可以通过链接下载一份完整的 C 程序文件,可以自行编译执行并研究。
从高层设计来说,我们要写一个程序,它产生一个子进程用来执行一个用户指定的命令,而父进程跟踪这个子进程。首先,<code>main</code>函数是这样的:</p>
<figure class="highlight c" data-lang="c"><table><tbody><tr><td class="code"><pre class="hljs c"><span></span><span class="kt">int</span> <span class="nf">main</span><span class="p">(</span><span class="kt">int</span> <span class="n">argc</span><span class="p">,</span> <span class="kt">char</span><span class="o">**</span> <span class="n">argv</span><span class="p">)</span>
<span class="p">{</span>
<span class="kt">pid_t</span> <span class="n">child_pid</span><span class="p">;</span>
<span class="k">if</span> <span class="p">(</span><span class="n">argc</span> <span class="o"><</span> <span class="mi">2</span><span class="p">)</span> <span class="p">{</span>
<span class="n">fprintf</span><span class="p">(</span><span class="n">stderr</span><span class="p">,</span> <span class="s">"Expected a program name as argument</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>
<span class="k">return</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>
<span class="p">}</span>
<span class="n">child_pid</span> <span class="o">=</span> <span class="n">fork</span><span class="p">();</span>
<span class="k">if</span> <span class="p">(</span><span class="n">child_pid</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span>
<span class="n">run_target</span><span class="p">(</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">]);</span>
<span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">child_pid</span> <span class="o">></span> <span class="mi">0</span><span class="p">)</span>
<span class="n">run_debugger</span><span class="p">(</span><span class="n">child_pid</span><span class="p">);</span>
<span class="k">else</span> <span class="p">{</span>
<span class="n">perror</span><span class="p">(</span><span class="s">"fork"</span><span class="p">);</span>
<span class="k">return</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</pre></table></figure>
<p>代码相当简单,我们通过<code>fork</code>产生一个新的子进程。随后的<code>if</code>语句块处理子进程(这里称为 “目标进程”),而<code>else if</code>语句块处理父进程(这里称为 “调试器”)。
下面是目标进程:</p>
<figure class="highlight c" data-lang="c"><table><tbody><tr><td class="code"><pre class="hljs c"><span></span><span class="kt">void</span> <span class="nf">run_target</span><span class="p">(</span><span class="k">const</span> <span class="kt">char</span><span class="o">*</span> <span class="n">programname</span><span class="p">)</span>
<span class="p">{</span>
<span class="n">procmsg</span><span class="p">(</span><span class="s">"target started. will run '%s'</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">programname</span><span class="p">);</span>
<span class="cm">/* Allow tracing of this process */</span>
<span class="k">if</span> <span class="p">(</span><span class="n">ptrace</span><span class="p">(</span><span class="n">PTRACE_TRACEME</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span> <span class="o"><</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
<span class="n">perror</span><span class="p">(</span><span class="s">"ptrace"</span><span class="p">);</span>
<span class="k">return</span><span class="p">;</span>
<span class="p">}</span>
<span class="cm">/* Replace this process's image with the given program */</span>
<span class="n">execl</span><span class="p">(</span><span class="n">programname</span><span class="p">,</span> <span class="n">programname</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span>
<span class="p">}</span>
</pre></table></figure>
<p>这部分最有意思的地方在<code>ptrace</code>调用。<code>ptrace</code>的原型是(在<code>sys/ptrace.h</code>):</p>
<figure class="highlight c" data-lang="c"><table><tbody><tr><td class="code"><pre class="hljs c"><span></span><span class="kt">long</span> <span class="nf">ptrace</span><span class="p">(</span><span class="k">enum</span> <span class="n">__ptrace_request</span> <span class="n">request</span><span class="p">,</span> <span class="kt">pid_t</span> <span class="n">pid</span><span class="p">,</span> <span class="kt">void</span> <span class="o">*</span><span class="n">addr</span><span class="p">,</span> <span class="kt">void</span> <span class="o">*</span><span class="n">data</span><span class="p">);</span>
</pre></table></figure>
<p>第一个参数是<code>request</code>,可以是预定义的以<code>PTRACE_</code>打头的常量值。第二个参数指定了进程 id,第三以及第四个参数是地址和指向数据的指针,用来对内存做操作。
上面代码段中的<code>ptrace</code>调用使用了<code>PTRACE_TRACEME</code>请求,这表示这个子进程要求操作系统内核允许它的父进程对其跟踪。这个请求在<code>man</code>手册中解释的非常清楚:</p>
<blockquote><p> 表明这个进程由它的父进程来跟踪。任何发给这个进程的信号(除了 SIGKILL)将导致该进程停止运行,而它的父进程会通过 wait () 获得通知。<strong> 另外,该进程之后所有对 exec () 的调用都将使操作系统产生一个 SIGTRAP 信号发送给它,这让父进程有机会在新程序开始执行之前获得对子进程的控制权。</strong> 如果不希望由父进程来跟踪的话,那就不应该使用这个请求。(pid、addr、data 被忽略)</p>
</blockquote>
<p>我已经把这个例子中我们感兴趣的地方高亮显示了。注意,<code>run_target</code>在 ptrace 调用之后紧接着做的是通过<code>execl</code>来调用我们指定的程序。
这里就会像我们高亮显示的部分所解释的那样,操作系统内核会在子进程开始执行<code>execl</code>中指定的程序之前停止该进程,并发送一个信号给父进程。</p>
<p>因此,是时候看看父进程需要做些什么了:</p>
<figure class="highlight c" data-lang="c"><table><tbody><tr><td class="code"><pre class="hljs c"><span></span><span class="kt">void</span> <span class="nf">run_debugger</span><span class="p">(</span><span class="kt">pid_t</span> <span class="n">child_pid</span><span class="p">)</span>
<span class="p">{</span>
<span class="kt">int</span> <span class="n">wait_status</span><span class="p">;</span>
<span class="kt">unsigned</span> <span class="n">icounter</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
<span class="n">procmsg</span><span class="p">(</span><span class="s">"debugger started</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>
<span class="cm">/* Wait for child to stop on its first instruction */</span>
<span class="n">wait</span><span class="p">(</span><span class="o">&</span><span class="n">wait_status</span><span class="p">);</span>
<span class="k">while</span> <span class="p">(</span><span class="n">WIFSTOPPED</span><span class="p">(</span><span class="n">wait_status</span><span class="p">))</span> <span class="p">{</span>
<span class="n">icounter</span><span class="o">++</span><span class="p">;</span>
<span class="cm">/* Make the child execute another instruction */</span>
<span class="k">if</span> <span class="p">(</span><span class="n">ptrace</span><span class="p">(</span><span class="n">PTRACE_SINGLESTEP</span><span class="p">,</span> <span class="n">child_pid</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span> <span class="o"><</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
<span class="n">perror</span><span class="p">(</span><span class="s">"ptrace"</span><span class="p">);</span>
<span class="k">return</span><span class="p">;</span>
<span class="p">}</span>
<span class="cm">/* Wait for child to stop on its next instruction */</span>
<span class="n">wait</span><span class="p">(</span><span class="o">&</span><span class="n">wait_status</span><span class="p">);</span>
<span class="p">}</span>
<span class="n">procmsg</span><span class="p">(</span><span class="s">"the child executed %u instructions</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">icounter</span><span class="p">);</span>
<span class="p">}</span>
</pre></table></figure>
<p>通过上面的代码我们可以回顾一下,一旦子进程开始执行<code>exec</code>调用,它就会停止然后接收到一个<code>SIGTRAP</code>信号。
父进程通过第一个<code>wait</code>调用正在等待这个事件发生。一旦子进程停止(如果子进程由于发送的信号而停止运行,<code>WIFSTOPPED</code>就返回 true),父进程就去检查这个事件。</p>
<p>父进程接下来要做的是本文中最有意思的地方。父进程通过<code>PTRACE_SINGLESTEP</code>以及子进程的 id 号来调用<code>ptrace</code>。
这么做是告诉操作系统 —— 请重新启动子进程,但当子进程执行了下一条指令后再将其停止。然后父进程再次等待子进程的停止,整个循环继续得以执行。
当从<code>wait</code>中得到的不是关于子进程停止的信号时,循环结束。在正常运行这个跟踪程序时,会得到子进程正常退出(<code>WIFEXITED</code>会返回 true)的信号。</p>
<p><code>icounter</code>会统计子进程执行的指令数量。因此我们这个简单的例子实际上还是做了点有用的事情 —— 通过在命令行上指定一个程序名,
我们的例子会执行这个指定的程序,然后统计出从开始到结束该程序执行过的 CPU 指令总数。让我们看看实际运行的情况。</p>
<h3 id="实际测试">实际测试</h3>
<p>我编译了下面这个简单的程序,然后在我们的跟踪程序下执行:</p>
<figure class="highlight c" data-lang="c"><table><tbody><tr><td class="code"><pre class="hljs c"><span></span><span class="cp">#include</span> <span class="cpf"><stdio.h></span><span class="cp"></span>
<span class="kt">int</span> <span class="nf">main</span><span class="p">()</span>
<span class="p">{</span>
<span class="n">printf</span><span class="p">(</span><span class="err">“</span><span class="n">Hello</span><span class="p">,</span> <span class="n">world</span><span class="o">!</span><span class="err">\</span><span class="n">n</span><span class="err">”</span><span class="p">);</span>
<span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</pre></table></figure>
<p>令我惊讶的是,我们的跟踪程序运行了很长的时间然后报告显示一共有超过 100000 条指令得到了执行。仅仅只是一个简单的<code>printf</code>调用,为什么会这样?答案非常有意思。
默认情况下,Linux 中的<code>gcc</code>编译器会动态链接到 C 运行时库。这意味着任何程序在运行时首先要做的事情是加载动态库。
这需要很多代码实现 —— 记住,我们这个简单的跟踪程序会针对每一条被执行的指令计数,不仅仅是<code>main</code>函数,而是整个进程。</p>
<p>因此,当我采用<code>-static</code>标志静态链接这个测试程序时(注意到可执行文件因此增加了 500KB 的大小,因为它静态链接了 C 运行时库),
我们的跟踪程序报告显示只有 7000 条左右的指令被执行了。这还是非常多,但如果你了解到<code>libc</code>的初始化工作仍然先于<code>main</code>的执行,
而清理工作会在<code>main</code>之后执行,那么这就完全说得通了。而且,<code>printf</code>也是一个复杂的函数。</p>
<p>我们还是不满足于此,我希望能看到一些可检测的东西,例如我可以从整体上看到每一条需要被执行的指令是什么。
这一点我们可以通过汇编代码来得到。因此我把这个 “Hello,world” 程序汇编(<code>gcc -S</code>)为如下的汇编码:</p>
<figure class="highlight asm" data-lang="asm"><table><tbody><tr><td class="code"><pre class="hljs asm"><span></span><span class="nf">section</span> <span class="no">.text</span>
<span class="c">; The _start symbol must be declared for the linker (ld)</span>
<span class="nf">global</span> <span class="no">_start</span>
<span class="nl">_start:</span>
<span class="c">; Prepare arguments for the sys_write system call:</span>
<span class="c">; - eax: system call number (sys_write)</span>
<span class="c">; - ebx: file descriptor (stdout)</span>
<span class="c">; - ecx: pointer to string</span>
<span class="c">; - edx: string length</span>
<span class="nf">mov</span> <span class="no">edx</span><span class="p">,</span> <span class="no">len</span>
<span class="nf">mov</span> <span class="no">ecx</span><span class="p">,</span> <span class="no">msg</span>
<span class="nf">mov</span> <span class="no">ebx</span><span class="p">,</span> <span class="mi">1</span>
<span class="nf">mov</span> <span class="no">eax</span><span class="p">,</span> <span class="mi">4</span>
<span class="c">; Execute the sys_write system call</span>
<span class="nf">int</span> <span class="mi">0x80</span>
<span class="c">; Execute sys_exit</span>
<span class="nf">mov</span> <span class="no">eax</span><span class="p">,</span> <span class="mi">1</span>
<span class="nf">int</span> <span class="mi">0x80</span>
<span class="nf">section</span> <span class="no">.data</span>
<span class="nf">msg</span> <span class="no">db</span> <span class="err">'</span><span class="no">Hello</span><span class="p">,</span> <span class="no">world</span><span class="p">!</span><span class="err">'</span><span class="p">,</span> <span class="mi">0xa</span>
<span class="nf">len</span> <span class="no">equ</span> <span class="no">$</span> <span class="p">-</span> <span class="no">msg</span>
</pre></table></figure>
<p>这就足够了。现在跟踪程序会报告有 7 条指令得到了执行,我可以很容易地从汇编代码来验证这一点。</p>
<h3 id="深入指令流">深入指令流</h3>
<p>汇编码程序得以让我为大家介绍<code>ptrace</code>的另一个强大的功能 —— 详细检查被跟踪进程的状态。</p>
<p>下面是<code>run_debugger</code>函数的另一个版本:</p>
<figure class="highlight c" data-lang="c"><table><tbody><tr><td class="code"><pre class="hljs c"><span></span><span class="kt">void</span> <span class="nf">run_debugger</span><span class="p">(</span><span class="kt">pid_t</span> <span class="n">child_pid</span><span class="p">)</span>
<span class="p">{</span>
<span class="kt">int</span> <span class="n">wait_status</span><span class="p">;</span>
<span class="kt">unsigned</span> <span class="n">icounter</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
<span class="n">procmsg</span><span class="p">(</span><span class="s">"debugger started</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>
<span class="cm">/* Wait for child to stop on its first instruction */</span>
<span class="n">wait</span><span class="p">(</span><span class="o">&</span><span class="n">wait_status</span><span class="p">);</span>
<span class="k">while</span> <span class="p">(</span><span class="n">WIFSTOPPED</span><span class="p">(</span><span class="n">wait_status</span><span class="p">))</span> <span class="p">{</span>
<span class="n">icounter</span><span class="o">++</span><span class="p">;</span>
<span class="k">struct</span> <span class="n">user_regs_struct</span> <span class="n">regs</span><span class="p">;</span>
<span class="n">ptrace</span><span class="p">(</span><span class="n">PTRACE_GETREGS</span><span class="p">,</span> <span class="n">child_pid</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="err">®</span><span class="n">s</span><span class="p">);</span>
<span class="kt">unsigned</span> <span class="n">instr</span> <span class="o">=</span> <span class="n">ptrace</span><span class="p">(</span><span class="n">PTRACE_PEEKTEXT</span><span class="p">,</span> <span class="n">child_pid</span><span class="p">,</span> <span class="n">regs</span><span class="p">.</span><span class="n">eip</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span>
<span class="n">procmsg</span><span class="p">(</span><span class="s">"icounter = %u. EIP = 0x%08x. instr = 0x%08x</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span>
<span class="n">icounter</span><span class="p">,</span> <span class="n">regs</span><span class="p">.</span><span class="n">eip</span><span class="p">,</span> <span class="n">instr</span><span class="p">);</span>
<span class="cm">/* Make the child execute another instruction */</span>
<span class="k">if</span> <span class="p">(</span><span class="n">ptrace</span><span class="p">(</span><span class="n">PTRACE_SINGLESTEP</span><span class="p">,</span> <span class="n">child_pid</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span> <span class="o"><</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
<span class="n">perror</span><span class="p">(</span><span class="s">"ptrace"</span><span class="p">);</span>
<span class="k">return</span><span class="p">;</span>
<span class="p">}</span>
<span class="cm">/* Wait for child to stop on its next instruction */</span>
<span class="n">wait</span><span class="p">(</span><span class="o">&</span><span class="n">wait_status</span><span class="p">);</span>
<span class="p">}</span>
<span class="n">procmsg</span><span class="p">(</span><span class="s">"the child executed %u instructions</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">icounter</span><span class="p">);</span>
<span class="p">}</span>
</pre></table></figure>
<p>同前个版本相比,唯一的不同之处在于<code>while</code>循环的开始几行。这里有两个新的<code>ptrace</code>调用。第一个读取进程的寄存器值到一个结构体中。结构体<code>user_regs_struct</code>定义在<code>sys/user.h</code>中。
这儿有个有趣的地方 —— 如果你打开这个头文件看看,靠近文件顶端的地方有一条这样的注释:</p>
<blockquote><p>/* 本文件的唯一目的是为 GDB,且只为 GDB 所用。对于这个文件,不要看的太多。除了 GDB 以外不要用于任何其他目的,除非你知道你正在做什么。*/</p>
</blockquote>
<p>现在,我不知道你是怎么想的,但我感觉我们正处于正确的跑道上。无论如何,回到我们的例子上来。
一旦我们将所有的寄存器值获取到<code>regs</code>中,我们就可以通过<code>PTRACE_PEEKTEXT</code>标志以及将<code>regs.eip</code>(x86 架构上的扩展指令指针)做参数传入<code>ptrace</code>来调用。
我们所得到的就是指令。</p>
<p>让我们在汇编代码上运行这个新版的跟踪程序。</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>$ simple_tracer traced_helloworld
<span class="o">[</span><span class="m">5700</span><span class="o">]</span> debugger started
<span class="o">[</span><span class="m">5701</span><span class="o">]</span> target started. will run <span class="s1">'traced_helloworld'</span>
<span class="o">[</span><span class="m">5700</span><span class="o">]</span> <span class="nv">icounter</span> <span class="o">=</span> <span class="m">1</span>. <span class="nv">EIP</span> <span class="o">=</span> 0x08048080. <span class="nv">instr</span> <span class="o">=</span> 0x00000eba
<span class="o">[</span><span class="m">5700</span><span class="o">]</span> <span class="nv">icounter</span> <span class="o">=</span> <span class="m">2</span>. <span class="nv">EIP</span> <span class="o">=</span> 0x08048085. <span class="nv">instr</span> <span class="o">=</span> 0x0490a0b9
<span class="o">[</span><span class="m">5700</span><span class="o">]</span> <span class="nv">icounter</span> <span class="o">=</span> <span class="m">3</span>. <span class="nv">EIP</span> <span class="o">=</span> 0x0804808a. <span class="nv">instr</span> <span class="o">=</span> 0x000001bb
<span class="o">[</span><span class="m">5700</span><span class="o">]</span> <span class="nv">icounter</span> <span class="o">=</span> <span class="m">4</span>. <span class="nv">EIP</span> <span class="o">=</span> 0x0804808f. <span class="nv">instr</span> <span class="o">=</span> 0x000004b8
<span class="o">[</span><span class="m">5700</span><span class="o">]</span> <span class="nv">icounter</span> <span class="o">=</span> <span class="m">5</span>. <span class="nv">EIP</span> <span class="o">=</span> 0x08048094. <span class="nv">instr</span> <span class="o">=</span> 0x01b880cd
Hello, world!
<span class="o">[</span><span class="m">5700</span><span class="o">]</span> <span class="nv">icounter</span> <span class="o">=</span> <span class="m">6</span>. <span class="nv">EIP</span> <span class="o">=</span> 0x08048096. <span class="nv">instr</span> <span class="o">=</span> 0x000001b8
<span class="o">[</span><span class="m">5700</span><span class="o">]</span> <span class="nv">icounter</span> <span class="o">=</span> <span class="m">7</span>. <span class="nv">EIP</span> <span class="o">=</span> 0x0804809b. <span class="nv">instr</span> <span class="o">=</span> 0x000080cd
<span class="o">[</span><span class="m">5700</span><span class="o">]</span> the child executed <span class="m">7</span> instructions
</pre></table></figure>
<p>OK,所以现在除了<code>icounter</code>以外,我们还能看到指令指针以及每一步的指令。如何验证这是否正确呢?可以通过在可执行文件上执行<code>objdump –d</code>来实现:</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>$ objdump -d traced_helloworld
traced_helloworld: file format elf32-i386
Disassembly of section .text:
<span class="m">08048080</span> <.text>:
<span class="m">8048080</span>: ba 0e <span class="m">00</span> <span class="m">00</span> <span class="m">00</span> mov <span class="nv">$0</span>xe,%edx
<span class="m">8048085</span>: b9 a0 <span class="m">90</span> <span class="m">04</span> <span class="m">08</span> mov <span class="nv">$0</span>x80490a0,%ecx
804808a: bb <span class="m">01</span> <span class="m">00</span> <span class="m">00</span> <span class="m">00</span> mov <span class="nv">$0</span>x1,%ebx
804808f: b8 <span class="m">04</span> <span class="m">00</span> <span class="m">00</span> <span class="m">00</span> mov <span class="nv">$0</span>x4,%eax
<span class="m">8048094</span>: <span class="nb">cd</span> <span class="m">80</span> int <span class="nv">$0</span>x80
<span class="m">8048096</span>: b8 <span class="m">01</span> <span class="m">00</span> <span class="m">00</span> <span class="m">00</span> mov <span class="nv">$0</span>x1,%eax
804809b: <span class="nb">cd</span> <span class="m">80</span> int <span class="nv">$0</span>x80
</pre></table></figure>
<p>用这份输出对比我们的跟踪程序输出,应该很容易观察到相同的地方。</p>
<h3 id="关联到运行中的进程上">关联到运行中的进程上</h3>
<p>你已经知道了调试器也可以关联到已经处于运行状态的进程上。看到这里,你应该不会感到惊讶,这也是通过<code>ptrace</code>来实现的。这需要通过<code>PTRACE_ATTACH</code>请求。
这里我不会给出一段样例代码,因为通过我们已经看到的代码,这应该很容易实现。基于教学的目的,这里采用的方法更为便捷(因为我们可以在子进程刚启动时立刻将它停止)。</p>
<h2 id="代码">代码</h2>
<p>本文给出的这个简单的跟踪程序的完整代码(更高级一点,可以将具体指令打印出来)可以在 <a href="https://github.com/eliben/code-for-blog/blob/master/2011/simple_tracer.c">这里</a> 找到。
程序通过<code>-Wall –pedantic –std=c99</code>编译选项在 4.4 版的<code>gcc</code>上编译。</p>
<h2 id="结论及下一步要做的">结论及下一步要做的</h2>
<p>诚然,本文并没有涵盖太多的内容 —— 我们离一个真正可用的调试器还差的很远。但是,我希望这篇文章至少已经揭开了调试过程的神秘面纱。<code>ptrace</code>是一个拥有许多功能的系统调用,目前我们只展示了其中少数几种功能。</p>
<p>能够单步执行代码是很有用处的,但作用有限。以 “Hello, world” 为例,要到达<code>main</code>函数,需要先遍历好几千条初始化 C 运行时库的指令。
这就不太方便了。我们所希望的理想方案是可以在<code>main</code>函数入口处设置一个断点,从断点处开始单步执行。</p>
<p><a href="/linux/linux-gdb-breakpoints">下一篇文章</a> 中我将向您展示该如何实现断点机制。</p>
<h2 id="引文">引文</h2>
<p>在准备本文的时候,我搜集了如下的资源和文章:</p>
<ul>
<li> <a href="http://www.linuxjournal.com/article/6100?page=0,1">Playing with ptrace, Part I</a> </li>
<li> <a href="http://www.alexonlinux.com/how-debugger-works">How debugger works</a> </li>
</ul>
<p>原文地址:<a href="https://eli.thegreenplace.net/2011/01/23/how-debuggers-work-part-1">https://eli.thegreenplace.net/2011/01/23/how-debuggers-work-part-1</a></p>
防 HTTP 劫持解决方案-回源下载
20
2019-06-11T14:02:25.156526Z
2019-06-11T14:02:25.156526Z
allen
本分整理自《2018腾讯移动游戏技术评审标准与实践案例》,介绍了两种常见的 HTTP 劫持(HTTP-Hijacking)的案例以及对应的解决方法。
<blockquote><p><strong> 摘要 </strong>:本分整理自《2018 腾讯移动游戏技术评审标准与实践案例》,介绍了两种常见的 HTTP 劫持(HTTP-Hijacking)的案例以及对应的解决方法。</p>
</blockquote>
<h2 id="案例1:HTTP资源劫持">案例 1:HTTP 资源劫持</h2>
<p>2017 年 11 月, 某游戏发布客户端更新包后,收到部分玩家投诉客户端更新失败。
经过排查发现,玩家网络正常,但是下载到的文件客户端检测异常。
怀疑是下载资源文件时遇到了 http 劫持,联系该玩家配合进行测试,访问正常 url 和回源 url 下载到的文件对比发现,正常 url 下载的文件 md5 是错误的, 通过回源 url 请求下载的文件 md5 是正确的。
判定是遇到了 http 劫持, 客户端先暂时通过热更 dir 模块修改资源 url 为回源 url,最终该玩家客户端资源更新正常, 成功进入游戏。</p>
<h2 id="案例2:302跳转劫持">案例 2:302 跳转劫持</h2>
<p>2017 年 5 月, 某游戏发布客户端更新包后,收到福建某个玩家投诉客户端更新失败, 开发商根据玩家反馈的报错截图发现是某个资源 url 下载异常。
运维联系玩家,让玩家通过手机浏览器访问该 url,结果跳转到了错误的 url。
可以判定玩家访问 url, 302 重定向被运营商指向了错误的 url,客户端先暂时通过热更 dir 模块修改资源 url 为回源 url,最终该玩家客户端资源更新正常, 成功进入游戏。</p>
<p><strong>排查过程:</strong></p>
<p>当玩家投诉客户端升级报错后,我们可以按以下步骤进行排查:
玩家客户端资源升级报错 --> 联系开发定位是哪个 url 下载失败 --> 让玩家在(与游戏相同网络下) 手机浏览器访问该 url --> 若下载失败, 则让玩家用手机浏览器访问回源 url --> 由于回源 url 有多个,
某个不行可以切换到备用地址,提高玩家的下载成功率 --> 确认访问正常后, 让客户端临时使用回源 url 给玩家提供资源下载服务。</p>
<p><strong>解决方案:</strong></p>
<p>以上案例已在多个游戏发生过, 针对 http 资源劫持的现象,我们推出了防劫持的回源代理方案,失败的用户通过直连回源代理服务器来重新获取资源,绕开了 196 当前异常的 cdn 节点,大幅缓解资源劫持现象。</p>
<h2 id="方案介绍">方案介绍</h2>
<p>以下是解决方案的架构图</p>
<p>当用户通过正常路径访问 oc 节点失败后,我们提供了两种接入方式给用户</p>
<p><img src="/upload/http-back-to-upstream.png" alt="HTTP 回源图"></p>
<ol>
<li><p> 静态方式:</p>
<p> 在业务原 url 前面加上 download.mocmna.qq.com,支持 http 和 https,调用举例如下:</p>
<p> 若业务 url 为 <a href="http://image.xxx.qq.com/test.zip">http://image.xxx.qq.com/test.zip</a>,则 </p>
<p>http 接入方式为:<a href="http://download.mocmna.qq.com/image.xxx.qq.com/test.zip">http://download.mocmna.qq.com/image.xxx.qq.com/test.zip</a></p>
<p>https 接入方式为:<a href="https://download.mocmna.qq.com/image.xxx.qq.com/test.zip">https://download.mocmna.qq.com/image.xxx.qq.com/test.zip</a></p></li>
<li><p> 动态方式(推荐):</p>
<p> 客户端请求 download01.ino.qq.com/GetUrlHead?client=x.x.x.x ,获取域名或 IP, 获取得到域名或 IP 后,再按照【静态方式】操作。</p></li>
</ol>
<h2 id="方案优势">方案优势</h2>
<ol>
<li>优质链路切换:根据用户出口 IP,智能选择网络路径,避开下载失败路径</li>
<li>全网劫持监测:利用 Q 调和灯塔平台探测技术,监控回源域名或热点 url</li>
<li>数据分析:完善的数据分析报表能力提供单用户的日志定位分析辅助全网监测</li>
</ol>
Nginx (13: Permission denied) while reading upstream
16
2019-05-17T15:28:55.728528Z
2019-05-17T15:28:55.728528Z
allen
Nginx 做反向代理时,需要接收完 upstream 返回的内容才发回给客户端。 当 upstream 返回>的内容过长时,需要临时存到磁盘,存盘时可能出现权限异常。
<blockquote><p><strong> 摘要 </strong>:Nginx 做反向代理时,需要接收完 upstream 返回的内容才发回给客户端。 当 upstream 返回 > 的内容过长时,需要临时存到磁盘,存盘时可能出现权限异常。</p>
</blockquote>
<p>今天傍晚突然收到运维反馈说某 Web 后台突然访问非常慢,打不开页面。</p>
<p>登录服务器查看 Nginx 日志,发现在 Nginx error.log 中包含大量<code>(13: Permission denied) while reading upstream</code>错误:</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span><span class="m">2019</span>/05/17 <span class="m">19</span>:33:51 <span class="o">[</span>crit<span class="o">]</span> <span class="m">31237</span><span class="c1">#0: *12624419709 open() "/var/lib/nginx/uwsgi/2/09/0009076092" failed (13: Permission denied) while reading upstream, ...</span>
<span class="m">2019</span>/05/17 <span class="m">19</span>:33:52 <span class="o">[</span>crit<span class="o">]</span> <span class="m">31239</span><span class="c1">#0: *12624422108 open() "/var/lib/nginx/uwsgi/3/09/0009076093" failed (13: Permission denied) while reading upstream, ...</span>
</pre></table></figure>
<p>今天并没有做线上更新,而且后台其他页面正常,只有访问某个模块才出现卡顿。</p>
<p>Google <code>(13: Permission denied) while reading upstream</code>发现,原来 Nginx 做反向代理时,会先把 upstream 返回的内容全部都完后再发送给客户端。
Nginx 做反向代理时,需要接收完 upstream 返回的内容才发回给客户端。 当 upstream 返回的内容长度超过 <a href="http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_temp_file_write_size">proxy_temp_file_write_size</a> 时,会先写入本地缓存目录:</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>Syntax: proxy_temp_file_write_size size<span class="p">;</span>
Default:
proxy_temp_file_write_size 8k<span class="p">|</span>16k<span class="p">;</span>
Context: http, server, location
Limits the size of data written to a temporary file at a time, when buffering
of responses from the proxied server to temporary files is enabled.
</pre></table></figure>
<p>本地缓存地址与你使用的 proxy 配置有关,比如我们线上使用的是 Debian 8 系统,使用的 proxy 是 uwsgi,所以临时目录是 <a href="http://nginx.org/en/docs/http/ngx_http_uwsgi_module.html#uwsgi_temp_path">uwsgi_temp_path</a> ,默认路径就是<code>/var/lib/nginx/uwsgi</code>。如果 Nginx 进程对该目录没有写权限,
则会抛出异常。</p>
<p>查看该目录权限:</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>$ ls -ldsh /var/lib/nginx/uwsgi
<span class="m">4</span>.0K drwx------ <span class="m">12</span> www-data www-data <span class="m">4</span>.0K Jul <span class="m">15</span> <span class="m">2017</span> /var/lib/nginx/uwsgi
</pre></table></figure>
<p>发现目录所有者是<code>www-data</code>,且只有所有者有权限读写该目录。而我们的 Nginx 进程是使用业务账号(非 www-data)运行的,所以当需要写临时目录时就会报错。</p>
<p>通过分析请求的接口的 access 日志发现,该接口返回的 body 大小将近 2M,远超过<code>proxy_temp_file_write_size</code>默认的 16k,所以才触发了这个问题。</p>
<p>为什么今天才突然出现呢,原来接口里面有一个白名单字段,是根据后台导入的白名单文件内容生成的。今天正好有个运维人员上传了一个非常大的白名单列表,
引发了该问题。</p>
<p>扩展: <a href="https://wincent.com/wiki/Fixing_nginx_client_body_temp_permission_denied_errors">proxy_temp_path</a> 没有权限写入的话也可能触发一个类似的<code>(13: Permission denied)</code>异常:</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span><span class="m">2008</span>/03/22 <span class="m">09</span>:44:33 <span class="o">[</span>crit<span class="o">]</span> <span class="m">18223</span><span class="c1">#0: *642 open() "/usr/local/nginx/client_body_temp/0000000002" failed (13: Permission denied)</span>
</pre></table></figure>
<p>参考链接:<a href="https://github.com/dockerfile/nginx/issues/4">https://github.com/dockerfile/nginx/issues/4</a></p>
用 dis 模块分析为什么 Python 2 中 while 1 比 while True 好
15
2019-05-11T13:48:13.130057Z
2019-05-11T13:48:13.130057Z
allen
使用 Python 2 编程的时候,经常会看到有人说要用 while 1 来替代 while True,这是为什么呢?本文将带你使用 dis 内置模块来分析为什么是这样。
<blockquote><p><strong> 摘要 </strong>:使用 Python 2 编程的时候,经常会看到有人说要用 while 1 来替代 while True,这是为什么呢?本文将带你使用 dis 内置模块来分析为什么是这样。</p>
</blockquote>
<p>Python 代码是先被编译为 Python 字节码(Byte Code,文件后缀为 .pyc)后,再由 Python 虚拟机来执行 Python 字节码。</p>
<p>一般来说一个 Python 语句会对应若干字节码指令,Python 的字节码是一种类似汇编指令的中间语言,
但是一个字节码指令并不是对应一个机器指令(二进制指令),而是对应一段 C 代码(可以查看 <a href="https://github.com/python/cpython/blob/master/Python/ceval.c">cevel.c</a> ),
而不同的指令的性能不同,所以一般不能单单通过指令数量来判断代码的性能。</p>
<p>Python 提供了 <a href="https://docs.python.org/3/library/dis.html">dis</a> (Disassembler for Python bytecode,反汇编 Python 代码为字节码) 内置模块来分析和评估 Python 的性能和质量。</p>
<h2 id="while1真的比whileTrue好吗">while 1 真的比 while True 好吗</h2>
<p>我们通过以下代码进行演示:</p>
<figure class="highlight python3" data-lang="python3"><table><tbody><tr><td class="code"><pre class="hljs python3"><span></span><span class="err">$</span> <span class="n">python2</span>
<span class="o">>>></span> <span class="k">def</span> <span class="nf">f1</span><span class="p">():</span>
<span class="o">...</span> <span class="k">while</span> <span class="mi">1</span><span class="p">:</span>
<span class="o">...</span> <span class="k">pass</span>
<span class="o">...</span>
<span class="o">>>></span> <span class="k">def</span> <span class="nf">f2</span><span class="p">():</span>
<span class="o">...</span> <span class="k">while</span> <span class="kc">True</span><span class="p">:</span>
<span class="o">...</span> <span class="k">pass</span>
<span class="o">...</span>
<span class="o">>>></span> <span class="kn">import</span> <span class="nn">dis</span>
<span class="o">>>></span> <span class="n">dis</span><span class="o">.</span><span class="n">dis</span><span class="p">(</span><span class="n">f1</span><span class="p">)</span>
<span class="mi">2</span> <span class="mi">0</span> <span class="n">SETUP_LOOP</span> <span class="mi">4</span> <span class="p">(</span><span class="n">to</span> <span class="mi">7</span><span class="p">)</span>
<span class="mi">3</span> <span class="o">>></span> <span class="mi">3</span> <span class="n">JUMP_ABSOLUTE</span> <span class="mi">3</span>
<span class="mi">6</span> <span class="n">POP_BLOCK</span>
<span class="o">>></span> <span class="mi">7</span> <span class="n">LOAD_CONST</span> <span class="mi">0</span> <span class="p">(</span><span class="kc">None</span><span class="p">)</span>
<span class="mi">10</span> <span class="n">RETURN_VALUE</span>
<span class="o">>>></span> <span class="n">dis</span><span class="o">.</span><span class="n">dis</span><span class="p">(</span><span class="n">f2</span><span class="p">)</span>
<span class="mi">2</span> <span class="mi">0</span> <span class="n">SETUP_LOOP</span> <span class="mi">10</span> <span class="p">(</span><span class="n">to</span> <span class="mi">13</span><span class="p">)</span>
<span class="o">>></span> <span class="mi">3</span> <span class="n">LOAD_GLOBAL</span> <span class="mi">0</span> <span class="p">(</span><span class="kc">True</span><span class="p">)</span>
<span class="mi">6</span> <span class="n">POP_JUMP_IF_FALSE</span> <span class="mi">12</span>
<span class="mi">3</span> <span class="mi">9</span> <span class="n">JUMP_ABSOLUTE</span> <span class="mi">3</span>
<span class="o">>></span> <span class="mi">12</span> <span class="n">POP_BLOCK</span>
<span class="o">>></span> <span class="mi">13</span> <span class="n">LOAD_CONST</span> <span class="mi">0</span> <span class="p">(</span><span class="kc">None</span><span class="p">)</span>
<span class="mi">16</span> <span class="n">RETURN_VALUE</span>
</pre></table></figure>
<p><a href="https://docs.python.org/2/library/dis.html#dis.disassemble">参考官方文档说明</a> ,输出分为以下几列:</p>
<ul>
<li>第一列:行号,用于每一行的第一条指令</li>
<li>第二列:当前执行的指令,用 --> 表示;</li>
<li>第三列:一个带标签(Labelled)的指令,用 >> 表示;</li>
<li>第四列:指令的地址;</li>
<li>第五列:操作代码名;</li>
<li>第六列:操作参数;</li>
<li>第七列:用括号括起来的参数解释。</li>
</ul>
<p>对比发现,<code>while True</code>版本比<code>while 1</code>版本对应的字节码多了以下两条:</p>
<figure class="highlight python3" data-lang="python3"><table><tbody><tr><td class="code"><pre class="hljs python3"><span></span><span class="o">>></span> <span class="mi">3</span> <span class="n">LOAD_GLOBAL</span> <span class="mi">0</span> <span class="p">(</span><span class="kc">True</span><span class="p">)</span>
<span class="mi">6</span> <span class="n">POP_JUMP_IF_FALSE</span> <span class="mi">12</span>
</pre></table></figure>
<p>所以<code>while True</code>性能上也会有一定损失,<code>while 1</code>确实好一些。</p>
<h2 id="使用timeit进行验证">使用 timeit 进行验证</h2>
<p>我们使用 <a href="https://docs.python.org/2/library/timeit.html">timeit</a> 模块测试一下耗时情况:</p>
<figure class="highlight python3" data-lang="python3"><table><tbody><tr><td class="code"><pre class="hljs python3"><span></span><span class="o">>>></span> <span class="k">def</span> <span class="nf">f1</span><span class="p">():</span>
<span class="o">...</span> <span class="n">n</span> <span class="o">=</span> <span class="mi">1000</span>
<span class="o">...</span> <span class="k">while</span> <span class="mi">1</span><span class="p">:</span>
<span class="o">...</span> <span class="n">n</span> <span class="o">-=</span> <span class="mi">1</span>
<span class="o">...</span> <span class="k">if</span> <span class="n">n</span> <span class="o"><</span> <span class="mi">0</span><span class="p">:</span>
<span class="o">...</span> <span class="k">break</span>
<span class="o">...</span>
<span class="o">>>></span> <span class="k">def</span> <span class="nf">f2</span><span class="p">():</span>
<span class="o">...</span> <span class="n">n</span> <span class="o">=</span> <span class="mi">1000</span>
<span class="o">...</span> <span class="k">while</span> <span class="kc">True</span><span class="p">:</span>
<span class="o">...</span> <span class="n">n</span> <span class="o">-=</span> <span class="mi">1</span>
<span class="o">...</span> <span class="k">if</span> <span class="n">n</span> <span class="o"><</span> <span class="mi">0</span><span class="p">:</span>
<span class="o">...</span> <span class="k">break</span>
<span class="o">...</span>
<span class="o">>>></span> <span class="kn">import</span> <span class="nn">timeit</span>
<span class="o">>>></span> <span class="n">timeit</span><span class="o">.</span><span class="n">timeit</span><span class="p">(</span><span class="s1">'f1()'</span><span class="p">,</span> <span class="n">number</span><span class="o">=</span><span class="mi">100000</span><span class="p">,</span> <span class="n">setup</span><span class="o">=</span><span class="s1">'from __main__ import f1, f2'</span><span class="p">)</span>
<span class="mf">2.807568073272705</span>
<span class="o">>>></span> <span class="n">timeit</span><span class="o">.</span><span class="n">timeit</span><span class="p">(</span><span class="s1">'f2()'</span><span class="p">,</span> <span class="n">number</span><span class="o">=</span><span class="mi">100000</span><span class="p">,</span> <span class="n">setup</span><span class="o">=</span><span class="s1">'from __main__ import f1, f2'</span><span class="p">)</span>
<span class="mf">4.125874996185303</span>
</pre></table></figure>
<p>果然,对于上面简单的测试代码,<code>while 1</code>版本确实快不少。</p>
<h2 id="为什么whileTrue生成更多字节码">为什么 while True 生成更多字节码</h2>
<p>原来在 Python 2 中,由于历史原因,<code>True</code>和<code>False</code>被实现为内置变量,而不是 Python 关键字,所以在使用的时候需要执行<code>LOAD_GLOBAL</code>操作。</p>
<figure class="highlight python3" data-lang="python3"><table><tbody><tr><td class="code"><pre class="hljs python3"><span></span><span class="err">$</span> <span class="n">python2</span>
<span class="o">>>></span> <span class="kc">True</span> <span class="o">=</span> <span class="kc">False</span> <span class="c1"># 可以对 True 内置变量进行覆盖</span>
<span class="o">>>></span> <span class="kc">True</span> <span class="ow">is</span> <span class="kc">False</span>
<span class="kc">True</span>
</pre></table></figure>
<p>但是在 Python 3 中,它们都已经变成关键字:</p>
<figure class="highlight python3" data-lang="python3"><table><tbody><tr><td class="code"><pre class="hljs python3"><span></span><span class="err">$</span> <span class="n">python3</span>
<span class="o">>>></span> <span class="kc">True</span> <span class="o">=</span> <span class="kc">False</span>
<span class="n">File</span> <span class="s2">"<stdin>"</span><span class="p">,</span> <span class="n">line</span> <span class="mi">1</span>
<span class="ne">SyntaxError</span><span class="p">:</span> <span class="n">can</span><span class="s1">'t assign to keyword</span>
</pre></table></figure>
<p>所以 Python 3 以后,<code>while 1</code>和<code>while True</code>编成的字节码就是一样的了:</p>
<figure class="highlight python3" data-lang="python3"><table><tbody><tr><td class="code"><pre class="hljs python3"><span></span><span class="err">$</span> <span class="n">python3</span>
<span class="o">>>></span> <span class="k">def</span> <span class="nf">f1</span><span class="p">():</span>
<span class="o">...</span> <span class="k">while</span> <span class="mi">1</span><span class="p">:</span>
<span class="o">...</span> <span class="k">pass</span>
<span class="o">...</span>
<span class="o">>>></span> <span class="k">def</span> <span class="nf">f2</span><span class="p">():</span>
<span class="o">...</span> <span class="k">while</span> <span class="kc">True</span><span class="p">:</span>
<span class="o">...</span> <span class="k">pass</span>
<span class="o">...</span>
<span class="o">>>></span> <span class="kn">import</span> <span class="nn">dis</span>
<span class="o">>>></span> <span class="n">dis</span><span class="o">.</span><span class="n">dis</span><span class="p">(</span><span class="n">f1</span><span class="p">)</span>
<span class="mi">2</span> <span class="mi">0</span> <span class="n">SETUP_LOOP</span> <span class="mi">4</span> <span class="p">(</span><span class="n">to</span> <span class="mi">6</span><span class="p">)</span>
<span class="mi">3</span> <span class="o">>></span> <span class="mi">2</span> <span class="n">JUMP_ABSOLUTE</span> <span class="mi">2</span>
<span class="mi">4</span> <span class="n">POP_BLOCK</span>
<span class="o">>></span> <span class="mi">6</span> <span class="n">LOAD_CONST</span> <span class="mi">0</span> <span class="p">(</span><span class="kc">None</span><span class="p">)</span>
<span class="mi">8</span> <span class="n">RETURN_VALUE</span>
<span class="o">>>></span> <span class="n">dis</span><span class="o">.</span><span class="n">dis</span><span class="p">(</span><span class="n">f2</span><span class="p">)</span>
<span class="mi">2</span> <span class="mi">0</span> <span class="n">SETUP_LOOP</span> <span class="mi">4</span> <span class="p">(</span><span class="n">to</span> <span class="mi">6</span><span class="p">)</span>
<span class="mi">3</span> <span class="o">>></span> <span class="mi">2</span> <span class="n">JUMP_ABSOLUTE</span> <span class="mi">2</span>
<span class="mi">4</span> <span class="n">POP_BLOCK</span>
<span class="o">>></span> <span class="mi">6</span> <span class="n">LOAD_CONST</span> <span class="mi">0</span> <span class="p">(</span><span class="kc">None</span><span class="p">)</span>
<span class="mi">8</span> <span class="n">RETURN_VALUE</span>
</pre></table></figure>
<h2 id="结论">结论</h2>
<p>我们通过使用 dis 模块分析了在 Python 2 中为什么<code>while 1</code>比<code>while True</code>快,也发现在 Python 3 中,由于<code>True</code>变成关键字,所以两者性能都一样。</p>
<p>其实不管是 Python 2 还是 Python 3,除非是性能要求非常高的关键代码,否则都用<code>while True</code>就好了,毕竟使用 Python 的目的之一,就是要让代码有更好的可读性。</p>
<h2 id="扩展">扩展</h2>
<ol>
<li>读者可以使用本分介绍的 dis 模块,分析一下为什么 <code>if not x</code> 比 <code>if x is None</code> 快。</li>
<li>Python <code>boolean</code> 类型继承自 <code>int</code>,所以 <code>isinstance (True, int)</code> 将返回 <code>True</code>, <code>True == 1</code> 也返回 <code>True</code>。</li>
</ol>
使用 fonttools 自定义字体实现 WebFont 反爬虫
14
2019-05-09T09:23:39.689515Z
2019-05-09T09:23:39.689515Z
allen
字体反爬虫通过 CSS3 font-face 功能调用自定义的字体文件来渲染网页中的文字,网页中的文字是被修改过的任意>编码,无法复制或简单爬取文字内容ï¼本文使用 fonttools 来生成反爬字体。
<blockquote><p><strong> 摘要 </strong>:字体反爬虫通过 CSS3 font-face 功能调用自定义的字体文件来渲染网页中的文字,网页中的文字是被修改过的任意 > 编码,无法复制或简单爬取文字内容 ï¼ 本文使用 fonttools 来生成反爬字体。</p>
</blockquote>
<p>CSS3 引入了 <a href="https://developer.mozilla.org/zh-CN/docs/Web/CSS/@font-face">font-face 功能</a> ,可以让网页自动下载自定义字体,
而不需要用户系统安装。目前浏览器对 WebFont 的支持非常好,在 <a href="http://caniuse.com/#feat=fontface">caniuse</a> 上可以看到基本是全平台兼容,
网页上的特殊字体再也不用通过切图来解决了。</p>
<h2 id="WebFont的优势">WebFont 的优势</h2>
<p>相对图片,WebFont 有如下优势:</p>
<ul>
<li>支持选中、复制</li>
<li>支持 Ctrl+F 查找</li>
<li>对搜索引擎友好</li>
<li>支持工具翻译</li>
<li>支持无障碍访问,支持朗读</li>
<li>字体是矢量图形,支持矢量缩放,自动适配高清屏</li>
<li>文本修改方便</li>
<li>字形可以重复利用,节省网络资源</li>
</ul>
<p>关于字体的格式,常见的有 EOT, TTF/OTF, WOFF, WOFF2, SVG 这几种,早期为了兼容通常会见到这种写法:</p>
<figure class="highlight css" data-lang="css"><table><tbody><tr><td class="code"><pre class="hljs css"><span></span><span class="p">@</span><span class="k">font-face</span> <span class="p">{</span>
<span class="nt">font-family</span><span class="o">:</span> <span class="s1">'字体名'</span><span class="o">;</span>
<span class="nt">src</span><span class="o">:</span> <span class="nt">url</span><span class="o">(</span><span class="s1">'字体名.eot'</span><span class="o">);</span> <span class="c">/* IE9 + */</span>
<span class="nt">src</span><span class="o">:</span> <span class="nt">url</span><span class="o">(</span><span class="s1">'字体名.eot?#iefix'</span><span class="o">)</span> <span class="nt">format</span><span class="o">(</span><span class="s1">'embedded-opentype'</span><span class="o">),</span> <span class="c">/* IE6-IE8 */</span>
<span class="nt">url</span><span class="o">(</span><span class="s1">'字体名.woff'</span><span class="o">)</span> <span class="nt">format</span><span class="o">(</span><span class="s1">'woff'</span><span class="o">),</span> <span class="c">/* 现代浏览器 */</span>
<span class="nt">url</span><span class="o">(</span><span class="s1">'字体名.ttf'</span><span class="o">)</span> <span class="nt">format</span><span class="o">(</span><span class="s1">'truetype'</span><span class="o">),</span> <span class="c">/* Safari, Android, iOS */</span>
<span class="nt">url</span><span class="o">(</span><span class="s1">'字体名.svg#grablau'</span><span class="o">)</span> <span class="nt">format</span><span class="o">(</span><span class="s1">'svg'</span><span class="o">);</span> <span class="c">/* Legacy iOS */</span>
<span class="p">}</span>
</pre></table></figure>
<p>但是现在各平台尤其是移动端对 .ttf 和 woff 格式的支持已经很好,所以通常只需要加载这两种字体即可。</p>
<h2 id="中文WebFont的问题">中文 WebFont 的问题</h2>
<p>当你需要显示特殊中文字体时,你会发现直接用 font-face 引用中文字体是极其不现实的。
因为英文只有 26 个字母,加上数字和标点符号,一套字体不过几十 KB;
而汉字却有数万个,导致字体文件通常有好几 MB 甚至 几十 MB。
直接页面上引用的话,不论是对服务器带宽资源,还是用户加载速度,都是极大的挑战。</p>
<h2 id="解决方案">解决方案</h2>
<p>解决办法就是对字体进行子集化(subsetting),将页面中用到的少量特殊字体提取为一个定制的字体文件。</p>
<p>如果你使用 node.js,业界已经有比较成熟的软件可以使用:</p>
<ul>
<li>腾讯: <a href="http://font-spider.org/">font-spider</a> (核心使用的 font-min)</li>
<li>百度: <a href="http://ecomfe.github.io/fontmin">font-min</a></li>
<li>阿里巴巴: <a href="http://iconfont.cn/webfont/#!/webfont/index">icon-font</a> <a href="http://purplebamboo.github.io/font-carrier/">font-carrier</a></li>
</ul>
<p>以上三家各有优劣:</p>
<ul>
<li>腾讯的 font-spider 的优化核心是用的百度的 font-min,可以根据网页内的中文文本自动生成对应字体文件,操作简洁,但依赖 node.js 且只支持 ttf,不支持 otf;</li>
<li>百度的 font-min 有 windows 客户端,生成字体的文字需要自行指定;</li>
<li>阿里的 iconfont 可以在线生成字体,但是字体有限,开源的核心 font-carrier 支持的字体方式较多,但是操作较繁琐。</li>
</ul>
<h2 id="更细粒度的自定义">更细粒度的自定义</h2>
<p>如果使用过程中遇到问题,或者想要做其他定制化(比如使用 <a href="http://copyfuture.com/blogs-details/293a464d564a609b7c8803dc5491fcd2">WebFont 进行反爬</a> ),则可以使用 <a href="https://github.com/fonttools/fonttools">fonttools</a> Python 工具。</p>
<p>FontTools 是一套以<code>ttx</code>为核心的工具集,用于处理与字体编辑有关的各种问题,程序用 Python 编写完成,代码开源,具有良好的跨平台性。</p>
<p>FontTools 由以下 4 个程序组成:</p>
<ul>
<li><code>ttx</code> 可将字体文件与 xml 文件进行双向转换</li>
<li><code>pyftmerge</code> 可将数个字体文件合并成为一个字体文件</li>
<li><code>pyftsubset</code> 可产生一个由字体的指定字符组成的子集</li>
<li><code>pyftinspect</code> 可显示字体文件的二进制组成信息</li>
</ul>
<h3 id="安装pip包">安装 pip 包</h3>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>pip install fonttools
</pre></table></figure>
<h3 id="生成字体子集">生成字体子集</h3>
<p><code>pyftsubset</code>可提取一个字体的部分字符,产生一个只由它们组成的新字体。通过这一子集化技术,可有效缩小字体文件的体积,便于网络传输。</p>
<figure class="highlight bash" data-lang="bash"><table><tbody><tr><td class="code"><pre class="hljs bash"><span></span>pyftsubset <字体文件> --text<span class="o">=</span><需要的字形> --output-file<span class="o">=</span><输出>
</pre></table></figure>
<p>可以使用<code>--help</code>命令,选项非常非常多,非常健壮和实用。</p>
<h3 id="使用fonttools修改字体编码(反爬)">使用 fonttools 修改字体编码(反爬)</h3>
<p>使用 FontBuilder 来提取子集,并修改 glyphNames 和 Unicode 编码,达到反爬的目的:</p>
<figure class="highlight python3" data-lang="python3"><table><tbody><tr><td class="code"><pre class="hljs python3"><span></span><span class="kn">import</span> <span class="nn">random</span>
<span class="kn">import</span> <span class="nn">struct</span>
<span class="kn">from</span> <span class="nn">fontTools.ttLib</span> <span class="k">import</span> <span class="n">TTFont</span>
<span class="kn">from</span> <span class="nn">fontTools.fontBuilder</span> <span class="k">import</span> <span class="n">FontBuilder</span>
<span class="kn">from</span> <span class="nn">fontTools.pens.ttGlyphPen</span> <span class="k">import</span> <span class="n">TTGlyphPen</span>
<span class="k">def</span> <span class="nf">font_minify</span><span class="p">(</span><span class="n">name</span><span class="o">=</span><span class="s1">'Microsoft Yahei.ttf'</span><span class="p">):</span>
<span class="n">orig_font</span> <span class="o">=</span> <span class="n">TTFont</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
<span class="n">text</span> <span class="o">=</span> <span class="s1">'x0123456789'</span>
<span class="c1"># https://github.com/fonttools/fonttools/blob/3.41.2/Lib/fontTools/fontBuilder.py#L28</span>
<span class="n">familyName</span> <span class="o">=</span> <span class="s1">'myfont'</span>
<span class="n">styleName</span> <span class="o">=</span> <span class="s1">'Regular'</span>
<span class="n">nameStrings</span> <span class="o">=</span> <span class="p">{</span>
<span class="s1">'familyName'</span><span class="p">:</span> <span class="n">familyName</span><span class="p">,</span>
<span class="s1">'styleName'</span><span class="p">:</span> <span class="n">styleName</span><span class="p">,</span>
<span class="s1">'psName'</span><span class="p">:</span> <span class="n">familyName</span> <span class="o">+</span> <span class="s1">'-'</span> <span class="o">+</span> <span class="n">styleName</span><span class="p">,</span>
<span class="s1">'copyright'</span><span class="p">:</span> <span class="s1">'Created by Allen'</span><span class="p">,</span>
<span class="s1">'version'</span><span class="p">:</span> <span class="s1">'Version 1.0'</span><span class="p">,</span>
<span class="s1">'vendorURL'</span><span class="p">:</span> <span class="s1">'https://seealso.cn'</span><span class="p">,</span>
<span class="p">}</span>
<span class="n">glyph_names</span> <span class="o">=</span> <span class="p">[</span><span class="s1">'.notdef'</span><span class="p">,</span> <span class="s1">'null'</span><span class="p">,</span> <span class="s1">'x'</span><span class="p">,</span> <span class="s1">'zero'</span><span class="p">,</span> <span class="s1">'one'</span><span class="p">,</span> <span class="s1">'two'</span><span class="p">,</span> <span class="s1">'three'</span><span class="p">,</span>
<span class="s1">'four'</span><span class="p">,</span> <span class="s1">'five'</span><span class="p">,</span> <span class="s1">'six'</span><span class="p">,</span> <span class="s1">'seven'</span><span class="p">,</span> <span class="s1">'eight'</span><span class="p">,</span> <span class="s1">'nine'</span><span class="p">]</span>
<span class="n">rand_names</span> <span class="o">=</span> <span class="p">[</span><span class="s1">'.notdef'</span><span class="p">]</span> <span class="o">+</span> <span class="n">random</span><span class="o">.</span><span class="n">sample</span><span class="p">(</span><span class="n">glyph_names</span><span class="p">[</span><span class="mi">1</span><span class="p">:],</span> <span class="nb">len</span><span class="p">(</span><span class="n">glyph_names</span><span class="p">[</span><span class="mi">1</span><span class="p">:]))</span>
<span class="n">glyphs</span><span class="p">,</span> <span class="n">metrics</span><span class="p">,</span> <span class="n">cmap</span> <span class="o">=</span> <span class="p">{},</span> <span class="p">{},</span> <span class="p">{}</span>
<span class="c1"># https://zh.wikipedia.org/wiki/Unicode字符平面映射#E000-F8FF私用区</span>
<span class="n">codes</span> <span class="o">=</span> <span class="n">random</span><span class="o">.</span><span class="n">sample</span><span class="p">(</span><span class="nb">range</span><span class="p">(</span><span class="mh">0xE000</span><span class="p">,</span> <span class="mh">0xF8FF</span><span class="p">),</span> <span class="nb">len</span><span class="p">(</span><span class="n">rand_names</span><span class="p">)</span> <span class="o">*</span> <span class="mi">3</span><span class="p">)</span>
<span class="c1"># https://github.com/fonttools/fonttools/blob/3.41.2/Tests/pens/ttGlyphPen_test.py#L22</span>
<span class="n">glyph_set</span> <span class="o">=</span> <span class="n">orig_font</span><span class="o">.</span><span class="n">getGlyphSet</span><span class="p">()</span>
<span class="n">pen</span> <span class="o">=</span> <span class="n">TTGlyphPen</span><span class="p">(</span><span class="n">glyph_set</span><span class="p">)</span>
<span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="p">(</span><span class="n">gn</span><span class="p">,</span> <span class="n">rn</span><span class="p">)</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="nb">zip</span><span class="p">(</span><span class="n">glyph_names</span><span class="p">,</span> <span class="n">rand_names</span><span class="p">)):</span>
<span class="n">glyph_set</span><span class="p">[</span><span class="n">gn</span><span class="p">]</span><span class="o">.</span><span class="n">draw</span><span class="p">(</span><span class="n">pen</span><span class="p">)</span>
<span class="n">glyphs</span><span class="p">[</span><span class="n">rn</span><span class="p">]</span> <span class="o">=</span> <span class="n">pen</span><span class="o">.</span><span class="n">glyph</span><span class="p">()</span>
<span class="n">metrics</span><span class="p">[</span><span class="n">rn</span><span class="p">]</span> <span class="o">=</span> <span class="n">orig_font</span><span class="p">[</span><span class="s1">'hmtx'</span><span class="p">][</span><span class="n">gn</span><span class="p">]</span>
<span class="k">if</span> <span class="n">i</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>
<span class="k">continue</span>
<span class="n">cmap</span><span class="p">[</span><span class="n">codes</span><span class="p">[</span><span class="mi">3</span> <span class="o">*</span> <span class="n">i</span><span class="p">]]</span> <span class="o">=</span> <span class="n">rn</span>
<span class="n">cmap</span><span class="p">[</span><span class="n">codes</span><span class="p">[</span><span class="mi">3</span> <span class="o">*</span> <span class="n">i</span> <span class="o">+</span> <span class="mi">1</span><span class="p">]]</span> <span class="o">=</span> <span class="n">rn</span>
<span class="n">cmap</span><span class="p">[</span><span class="n">codes</span><span class="p">[</span><span class="mi">3</span> <span class="o">*</span> <span class="n">i</span> <span class="o">+</span> <span class="mi">2</span><span class="p">]]</span> <span class="o">=</span> <span class="n">rn</span>
<span class="n">hhea</span> <span class="o">=</span> <span class="p">{</span>
<span class="s1">'ascent'</span><span class="p">:</span> <span class="n">orig_font</span><span class="p">[</span><span class="s1">'hhea'</span><span class="p">]</span><span class="o">.</span><span class="n">ascent</span><span class="p">,</span>
<span class="s1">'descent'</span><span class="p">:</span> <span class="n">orig_font</span><span class="p">[</span><span class="s1">'hhea'</span><span class="p">]</span><span class="o">.</span><span class="n">descent</span><span class="p">,</span>
<span class="p">}</span>
<span class="n">fb</span> <span class="o">=</span> <span class="n">FontBuilder</span><span class="p">(</span><span class="n">orig_font</span><span class="p">[</span><span class="s1">'head'</span><span class="p">]</span><span class="o">.</span><span class="n">unitsPerEm</span><span class="p">,</span> <span class="n">isTTF</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
<span class="n">fb</span><span class="o">.</span><span class="n">setupGlyphOrder</span><span class="p">(</span><span class="n">rand_names</span><span class="p">)</span>
<span class="n">fb</span><span class="o">.</span><span class="n">setupCharacterMap</span><span class="p">(</span><span class="n">cmap</span><span class="p">)</span>
<span class="n">fb</span><span class="o">.</span><span class="n">setupGlyf</span><span class="p">(</span><span class="n">glyphs</span><span class="p">)</span>
<span class="n">fb</span><span class="o">.</span><span class="n">setupHorizontalMetrics</span><span class="p">(</span><span class="n">metrics</span><span class="p">)</span>
<span class="n">fb</span><span class="o">.</span><span class="n">setupHorizontalHeader</span><span class="p">(</span><span class="o">**</span><span class="n">hhea</span><span class="p">)</span>
<span class="n">fb</span><span class="o">.</span><span class="n">setupNameTable</span><span class="p">(</span><span class="n">nameStrings</span><span class="p">)</span>
<span class="n">fb</span><span class="o">.</span><span class="n">setupOS2</span><span class="p">()</span>
<span class="n">fb</span><span class="o">.</span><span class="n">setupPost</span><span class="p">()</span>
<span class="n">fb</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="n">f</span><span class="s1">'</span><span class="si">{familyName}</span><span class="s1">.ttf'</span><span class="p">)</span>
</pre></table></figure>