SRE 的工作介绍

有很多人问过我想了解一下 SRE 这个岗位,这是个很大的话题,在这篇博客中把想到的一些介绍一下吧。

SRE 到底是什么?这是一个最早由 Google 提出的概念,我的理解是,用软件解决运维问题。标准化,自动化,可扩展,高可用是主要的工作内容。这个岗位被提出的时候,想解决的问题是打破开发人员想要快速迭代,与运维人员想要保持稳定,拒绝频繁更新之间的矛盾。

SRE 目前对于招聘来说还是比较困难。一方面,这个岗位需要一定的经验,而应届生一般来说不会有运维复杂软件的经历;另一方面就是很多人依然以为这就是“运维”工程师,认为做的是一些低级重复的工作,对这个工作有排斥。最根本的,其实这个岗位寻找的要么是具有运维经验的开发人员,要么是具有软件开发技能的运维工程师。所以比较难以找到合适的人。

在现实生活中,不同公司的 SRE 岗位大有不同,有一些甚至可能还是传统运维的名字换了一个岗位名称。

比如蚂蚁金服有两种 SRE,一种是负责稳定性的,就是大家所理解的 SRE;另一种叫做资金安全 SRE,并不负责服务正常运行,而是负责金钱数目正确,对账没有错误,工作内容以开发为主,主要是资金核对平台和核对规则(没有做过,只是个人理解)。某种意义上说,已经不算是 SRE 而是专业领域的开发了。

Netflix (2016年)的模式是谁开发,谁维护。SRE 负责提供技术支持,和咨询服务。Netflix 在全球 170 个国家有服务,Core SREs 只有 5 个人。

微软有专门的 Game Streaming SRE,负责 XBox 在线游戏的稳定性。

所以不同公司的 SRE 的内容各有偏重,取决于公司要提供什么样的服务。

我们可以学习网络分层的方式,将 SRE 大致的工作内容从下往上分成 3 个大类:

  1. Infrastructure:主要负责最基础的硬件设施,网络,类似于 IaaS,做的事情可参考 DigitalOcean
  2. Platform:提供中间件技术,开箱即用的一些服务,类似于 PaaS,做的事情可参考 Heroku, GCP, AWS 等
  3. 业务 SRE:维护服务,应用,维护业务的正常运行

Infrastructure

Infrastructure 和 Platform SRE 其实可有可无,这些年商业化的服务其实越来越多了,比如,如果公司选择全部在 AWS 部署自己的服务的话,那么就不需要自己建立 Datacenter,维护网络之类的工作了,只需要几个 AWS 专家即可。

如果有的话,工作内容也可大可小。可以从管理购买的 VPS 开始,也可以从采购硬件服务器开始。

我觉得 Infrastructure SRE 的工作内容可以这样定义:

  1. 负责服务器的采购,预算,CMDB 管理。要知道(能查询到)每一台的负责人是谁,在干什么。这个非常重要,如果做不好,会造成极大的资源浪费。
  2. 提供可靠软件的部署环境,一般是虚拟机,或者 bare mental。
  3. 操作系统的版本统一维护,Linux 发行版的版本,Kernel 的版本等。
  4. 维护机器上的基础软件,比如 NTP,监控代理,其他的一些代理。
  5. 提供机器的登录方式,权限管理,命令审计。
  6. 维护一套可观测性的基础设施,比如监控系统,log 系统,trace 系统。
  7. 维护网络,大公司可能都会自己设计机房内的网络。其中包括:
    1. 网络的连通,这个是必要的。对于上层用户(Platform SRE)来说,交付的服务应该是任意两个 IP 是可以 ping 通的,即管理好 3 层以下的网络。
    2. NAT 服务
    3. DNS 服务
    4. 防火墙
    5. 4 层负载均衡,7层负载均衡
    6. CDN
    7. 证书管理

每一项既可以是一个很大的团队,也可以只有一个人去对商业化的 Infra 服务。可以使用开源的产品,也可以自己研发。

Platform SRE

Infrastructure SRE 维护的是基础设施,Platform SRE 使用他们提供的基础设施建立软件服务,让公司内的开发者可以使用开箱即用的软件服务,比如 Queue,Cache,定时任务,RPC 服务等等。

主要的工作内容有:

  1. RPC 服务:让不同的服务可以互相发现并调用
  2. 私有云服务
  3. 队列服务,比如 Kafka 或者 RabbitMQ
  4. 分布式的 cronjob 服务
  5. Cache
  6. 网关服务:反向代理的配置
  7. 对象存储:s3
  8. 其他一些数据库:ES,mongo 等等。一般来说,关系型数据库会有 DBA 来运维,但是 NoSQL 或者图数据库一般由 SRE 维护。
  9. 内部的开发环境:
    1. SCM 系统,比如自建的 Gitlab
    2. CI/CD 系统
    3. 镜像系统,比如 Harbor
    4. 其他的一些开发工具,比如分布式编译,Sentry 错误管理等等
  10. 一些离线计算环境,大数据的服务

业务 SRE

有了 Platform SRE 的支持,开发人员写代码就基本上不需要关心部署的问题了。可以专注于开发,使用公司开箱即用的服务。这一层的 SRE 更加贴近于业务,知道业务是怎么运行的,请求是怎么处理的,依赖了哪些组件。如果 X 除了问题,可以有哪些降级策略。参与应用的架构设计,提供技术支持。

主要的工作内容有:

  1. 参与系统的设计。比如熔断、降级,扩容等策略。
  2. 做压测,了解系统的容量。
  3. 做容量规划。
  4. 业务侧的 Oncall。

对于一个专业的 SRE 来说,上述技能也不应该有明显的界限,比如说业务 SRE 也需要掌握一些网络技能,Infra SRE 也要写一些代码。很多工具每一个岗位的人都多少用的到,比如 Ansible/Puppet/SaltStack 这种 IT 自动化工具,或者 Grafana/Prometheus 这种监控工具,只有理解才能用的正确。换个角度讲,对于业务 SRE 来说,虽然基本上不会去管理四层以下的网络,但是如果遇到网络问题,能通过已有的工具和权限排查到交换机问题,去找 Infra SRE 帮忙:“请帮我看下 xx IP 到交换机是否有异常,因为 xxx 显示的结果是 xx”,总比 “我怀疑 xx 有网络问题,请帮忙排查下” 要好一些吧?

以上是工作职责的大体划分,这个分层其实没有什么意义,倒是可以让读者了解一下 SRE 都涉及哪一些工作。

下面是一些日常的工作内容。

部署服务

部署分成两种:

  1. Day 1:将服务部署上线的那一天
  2. Day 2+:服务部署之后,还会进行很多更新,升级,配置更改,服务迁移等等

Day2+ 的工作要做很多次,Day 1 做的很少,在不断的迭代升级之后,还能保证有一个可靠的 Day 1 操作是很难的。换句话说,我们在服务部署之后一直改来改去,还要保证这个服务在一个全新的环境能够可靠的部署起来。部署环境的硬编码,奇奇怪怪的 work around,都会破坏 Day 1 的可靠性。之前一家公司,扩容一个新机房的过程简直是噩梦,太多的奇怪配置,hardcode,导致踩过无数个坑才能在一个新的机房部署起来全部的服务。

Day2+ 的操作也不简单,主要要关注稳定性。对于重要的变更操作要设计好变更计划,如何做到灰度测试,如果出了问题应该如何回滚,如何保证回滚可以成功(如何测试回滚)等等。

部署的操作最好都是可以追踪的,因为并不是所有会引起问题的操作都会立即引起问题。比如一个操作当时做完没有什么问题,但是过了 1 个月,偶然的重启或者内存达到了某一个指标触发了问题。如果能记录操作的话,我们可以回溯之前做过的变更,方便定位问题。现在一般都用 git 来追踪部署过程的变更(gitops)。

Oncall

Oncall 简单来说就是要保证线上服务的正常运行。典型的工作流程是:收到告警,检查告警发出的原因,确认线上服务是否有问题,定位到问题,解决问题。

收到告警并不总意味着真正的问题,也有可能告警设置的不合理。告警和监控面板并不是一个静态的配置,它应该是每天都在变化的,时刻在调整的。如果发现没有标志真正线上问题的告警发了出来,就应该修改告警规则。如果发现当前的监控无法快速定位问题,应该调整监控面板,添加或者删除监控指标。业务在发展,请求量在变化,某些阈值也需要不断地调整。

定位问题没有一概而论的方法了,需要根据看到的实时,结合自己的经验,然后做推测,然后使用工具验证自己的推测,然后确定问题的根因。

但是解决问题是可以有方法论的,叫做 SOP,标准操作流程。即:如果出现了这种现象,那么执行那种操作,就可以恢复业务。SOP 文档应该提前制定,并且验证其有效性。

需要注意的是上述定位问题、解决问题并没有顺序关系。一个经常犯的错误是,在出现故障的时候,花了很长时间定位到故障的根因,然后再修复。这样花的时间一般会比较长。正确的做法是先根据现象看现有的 SOP 能否恢复业务。比如说当前错误只发生在某一个节点上,那么就直接下线这个节点,具体的原因后面再排查。恢复当前的故障永远是第一要务。但是恢复操作也要经过测试,比如猜测可以通过重启解决问题的话,可以先重启一台做测试,而不是一次性将所有服务重启。大部分情况是需要临场分析的,是一个紧张又刺激的过程。

故障到底多久恢复算好?出现多少故障是可以容忍的?怎么标志服务的稳定性到底如何?我们使用 SLI/SLO 来衡量这些问题。

制定和交付 SLI/SLO

维护服务等级协议,听起来像是一个非常简单的事情,只要“设定一个可用率”然后去实现它就好了。然而现实的情况并不是。

比如,制定可用率的时候,并不是说我们去“实现4个9”(99.99% 的时间可用)就够了,我们有以下问题要考虑:

  1. 如何定义这个可用率?比如我们以可用率 > 99.9% 为目标,有一个服务部署了 5 个 Zone, 那么有一个 Zone 挂了,其余的 Zone 是可用的,那么可用率被破坏了吗?这个可用率是每一个 Zone 的还是所有的 Zone 一起计算的?
  2. 可用率计算的最小单位是什么?如果 1min 内有 50s 没有达到可用率,那么这一分钟算是 down 还是 up?
  3. 可用率的周期是怎么计算的?按照一个月还是一个周?一个周是最近的 7 天还是计算一个自然周?
  4. 如何对 SLI 和 SLO 做监控?
  5. 如果错误预算即将用完,有什么措施?比如减少发布?如果 SLI 和 SLO 没有达到会怎么样?

等等,如果这些问题不考虑清楚的话,那么 SLI 和 SLO 很可能就是没有意义的。SLI/SLO 也适用于对公司内部用户的承诺,让用户对我们的服务有预期,而不能有盲目的信任。比如 Google 在 SLI/SLO 还有预算的时候,会在满足 SLI/SLO 的时候自行对服务做一些破坏,让用户不要对服务有 100% 可用的错误预期。SLI/SLO 也会让 SRE 自己对当前服务的稳定性有更好的认识,可以根据此调整运维、变更、发布计划。

故障复盘

故障复盘的唯一目的是减少故障的发生。有几个我目前认为不错的做法。

故障复盘需要有文档记录,包括故障发生的过程,时间线的记录,操作的记录,故障恢复的方法,故障根因的分析,为什么故障会发生的分析。文档应该隐去所有当事人的姓名对公司的所有人公开。很多公司对故障文档设置查看权限,我觉得没什么道理。有些公司的故障复盘甚至对外也是公开的

故障在复盘的时候应该将当事人的名字用代码替代,可以营造更好的讨论氛围。

不应该要求所有的故障复盘都产生 Action。之前一家的公司的故障复盘上,因为必须给领导一个“交待”,所以每次都会产生一些措施来预防相同的故障再次发生,比如增加审批流程之类的。这很扯,让级别很高的领导审批他自己也看不懂的操作,只能让领导更痛苦,也让操作流程变得又臭又长,最后所有人都会忘记这里为什么会有一个审批,但是又没有人敢删掉。你删掉,出了事情你负责。

Blame Free 文化?之前我认为是好的。但是后来发现,有些不按照流程操作导致的问题确实多少应该 Blame 一下,比如下线服务的时候没有检查还没有 tcp 连接就直接下线了,或者操作的时候没有做 canary 就全部操作了,这种不理智的行为导致的故障。但是条条框框又不应该太多,不然活都没法干了。

容量规划

容量规划是一个非常复杂的问题,甚至有一些悖论。容量要提前做好规划,但是容量的规划需要知道业务的扩张速度,扩张速度这种事情又不是提前能计划好的。所以我一直觉得这个事情很难做,也一直没有见过做的很好的例子。

但是至少可以对维护的系统建立一个模型,知道多少机器,多少资源,能容纳多少容量。这样遇到大促之类的活动也能及时估算需要的资源数量。

用户支持

用户支持也是日常的一部分。包括技术咨询,以及用户要求的线上问题排查。

这里就需要提到文档的重要性了。如果没有维护好文档,那么用户就会一遍又一遍问相同的问题。写文档也是一个技术活,优秀的需要很长时间的积累。文档也需要经常更新。我一般会这样,保持这样一种状态:用户可以不需要任何人就从文档中找到他需要的所有答案。如果我发现用户的问题无法从文档中找到,或者难以找到在文档中的什么地方,就会更新文档,或者重新组织文档。如果用户的问题已经从文档中找到,那么就直接发文档给他。如果用户的问题显然是文档看都没有看过(有很多人根本不看文档的,只看文档是谁写的然后径直去问这个人),就直接忽略。

优秀的文档应该尽量引入少的专有名词,少使用没有用处的专业词汇描述,只描述具有指导意义的事实,假定用户没有相关的背景知识,列举使用例子,举一些现实会用到的例子而不是强行举例子,明确 Bad Case。等等。这其实是一个很大的话题了,这里就不展开了。

暂时就想到这一些了。下面写一些我经常见到的误解,和经常被别人问的问题。

 

有关做项目没有专业团队得不到训练。

这方面是听到最多的抱怨。虽然说 SRE 在工作上应该是开发时间和运维时间各 50%,但是真实的情况是,即使 SRE 有一些开发工作,也大部分是面向内部用户,面向公司内部的开发者的。大部分项目是一些想法,需要去尝试一下行不行,基本上不会有专业的设计资源,PM 资源。这种项目就需要 SRE 有多方面的技能,包括对产品的理解,清楚地知道它有什么痛点,最好是自己经历过的痛点,然后需要懂设计,管理好开发进度。然而这种人非常少。其实能写中型项目代码的 SRE 就已经非常少了。所以大部分公司内部项目都会做的又难用又复杂。

即使是有专业配套 PM 和设计,甚至前端资源。基本上也是一个灾难。我也经历过这样的团队。这种内部项目对标的不是互联网项目,而更像是 toB 的项目。用户 UI 的设计,交互逻辑,操作流程,交付周期等需要的都是另一个领域的知识。否则的话人越多,也只会徒增沟通成本,拖慢项目进度。

回到经常听到的这个抱怨,说在 SRE 的团队没有像开发团队那样有“正规军”,有设计和 PM,大家各司其职,后端开发只要对齐 API 然后实现就好了。大部分的应届生会有这样的幻想,但实际上不是这样。被搞错的最重要的一点是,学习主要是靠自己的,和别人没有太大的关系。我觉得可能是在一个大团队里面,有很多人一起做一件事情,心里的怀疑和焦虑会少一点,人们会对这样的工作状态感到踏实,误以为是“成长”,自己做所有的工作焦虑更多。

事实是,在大团队工作可能学到更多的沟通技能,比如和不同的人对齐不同的阶段工作目标,要想要学到其他的东西还是要靠自己。比如拿到一个设计,如果照样子去实现了,其实不会学到什么东西。而要去理解为什么这么设计,为什么不那么设计。如果自己去做,思考的过程也基本是这样的,可以怎么设计,选择什么好。都是:思考,选择,尝试,经验,思考……

另一个需要澄清的误区是,模仿并不是学习。在团队中经历了一个设计,如果记住了这个设计,下次碰到类似的问题也用这个设计去解决。这也不能叫做是学习。我见过有在业务部门做过支付的 SRE 写的代码,在内部系统中去实现了订单业务的订单、交易等概念完成一个运维流程,甚至 Model 的名字都没改过。拿着锤子找钉子,会让系统变得更加糟糕和复杂。

总之,工作分的细并不代表工作就会更加专业。一个人身兼数职也可以在每一个方面做得很专业。重要的是不断学习,使用正确的做事方式,向优秀的项目和优秀的开发者学习。

 

有关脏活累活。

每一项工作都会有脏活累活:学不到什么东西,做起来没有意思。可能是整理系统的监控,可能是整理现有的文档,可能清理一些年久的运维脚本,可能是需要和不同的团队做一些沟通工作等。

这是不可避免的,如果可以的话,学会从每一项工作中找一些偷懒的方法吧,比如用脚本处理一些工作,用更聪明的方式工作等等。

但是如果这种工作的比例太高的话,就要思考工作方式的问题了。如果陷入恶性循环,看能不能从工具和工作流程上做一些改变。如果不能的话,考虑换一份工作吧。

 

有关背锅。

互相甩锅的工作环境无疑是非常糟糕的工作环境。如果相同的团队、或者不同的团队之间需要相互勾心斗角的话,如果工作环境不允许大方承认(SRE 无可避免地会犯一些错误)自己的错误,说明公司营造的氛围有问题。比如某些公司规定,发生 P1 级别的错误就必须开除一个 Px 级别的员工,发生 P0 级别的错误就必须开除一个 Py 级别的员工一样。如果是这种情况的话,公司实际上是在用一种懒惰地方法通过提高人的压力来提高系统的稳定性。有没有效果不知道,但是确定的是不会有人在这种情况下工作的开心。建议换一份工作。

 

如何转行?

其实难度没有想象的高,毕竟大学里面没有一个叫做 SRE 的专业。SRE 要求的知识也是编写代码、设计系统、了解操作系统和网络等。所以在大学里面将本科的课程好好学好,尝试做(并维护)一些自己的项目,毕业的时候基本上就满足要求了。非科班的人要转行的话,也可以参考大学的课程内容去补足这方面的知识。

需要注意的是,培训班出来的做开发完成业务可能够,但是做 SRE 远远不够。SRE 不仅需要 make things work,还要知道背后的原理。

 

面试会问什么?

我觉得和后端开发的面试内容基本上差不多。

如果是去应聘的这个岗位所需要的一些技能,比如 K8S,监控系统等,可能也会问一些领域内的知识。虽说这部分工具性的东西都可以学习,但是如果人家要一个经验丰富的、或者入职就能干活的,那么面试成功的机会就会小很多。当然,也不必沮丧,这是市场的供需关系决定的,如果对方执意要找符合特定要求的候选人,那么对方的选择的范围也会小很多,不必因为错失了这种机会而后悔没去学习什么工具。话又说回来,技能越多,选择也会越多。

排查错误可能是转行做 SRE 最大的一个门槛,这个需要一些经验。如果没有经验的话,就补足一些操作系统的知识,这样遇到未知的问题也可以通过已知的知识和工具去排查。

这个仓库是一个不错的面试题集锦:https://github.com/bregman-arie/devops-exercises

 

做 SRE 需要会写代码吗?

会,而且写代码的要求并不会比一个专业的后端开发低。

 

选择大公司还是小公司?

这属于两种截然不同的工作环境。小公司一般都有一个救火英雄式的人物,在公司的时间比较长,知道所有组件的部署结构,什么都懂。跟着这种人学习会成长很快。

大公司细分领域很多。本文前面列出的内容可能每一项在大公司中都是一个团队,对某个领域可以深入研究。

所以还是看想要做什么了。我个人比较喜欢靠谱的小公司,或者大公司中靠谱的小团队。

 

如何判断一家公司是否靠谱?

对于 SRE 这个职位,我总结了一些判断的技巧。比如可以判断一下对方目前的业务和 SRE 员工的数量是否处于一个“正常”的状态,人数是否在随着业务(机器的数量)现象增长?这是一个不好的迹象。是否 SRE 的数量过多?如果 SRE 人太多,有两个可能的原因:1)某个领导为了扩大自己的影响力在为一些“不必要的”岗位招人,这样会导致人多事少,大家开始做一些奇奇怪怪的事情,发明奇奇怪怪的需求,以各种各样的方式浪费自己的时间来领公司的工资;2)这个公司的基础太差,大部分工作都是需要人力运维,导致基本上有多少机器就需要多少人。总之,都不是什么好事情。

一些技术比较好的公司,都没有庞大的 SRE 队伍,比如 Instagram, Netflix(现在可能人数不少了),以及一些创业公司,甚至都可以没有专门的 SRE,优秀的 SRE 首先要是开发者,优秀的开发者也离 SRE 不远了。一些耳熟能详的服务,比如 webarchive 这样的数据量,其实背后也只有几个人在维护。前几年面试了国内的一家公司,在机房遍布全球,业务已经发展的比较庞大(上市了)的时候,SRE 团队也只有 10 个人。

另外我比较喜欢问的一个问题是对方关于 AIOps 怎么看。因为我之前搞了两年这个东西,最后得到的结论是,这基本上是个浪费时间、欺骗上层领导的东西。AI 这东西的不可解释性本质上就和运维操作将就因果相违背的。所以经常喜欢问面试官怎么看这个技术,基本上就可以判断靠不靠谱。当然了,这是我个人的职业阴影导致的后遗症,只能代表个人意见。

 

就说这么多吧,都是一些个人理解,不一定对。写这篇文章感觉自己好像指点江山一样,其实我自己也干了才几年而已,所以本文内容仅供参考。如果有什么问题可以在评论提出,我能回答的话就尽量回答。

 

多租户环境中的 TCP 限速(基于 iptables)

我们有个服务以类似 SideCar 的方式和应用一起运行,SideCar 和应用通过 Unix Domain Socket 进行通讯。为了方便用户,在开发的时候不必在自己的开发环境中跑一个 SideCar,我用 socat 在一台开发环境的机器上 map UDS 到一个端口。这样用户在开发的时候就可以直接通过这个 TCP 端口测试服务,而不用自己开一个 SideCar 使用 UDS 了。

因为所有人都要用这一个地址做开发,所以就有互相影响的问题。虽然性能还可以,几十万 QPS 不成问题,但是总有憨憨拿来搞压测,把资源跑满,影响别人。我在使用说明文档里用红色大字写了这是开发测试用的,不能压测,还是有一些视力不好的同事会强行压测。隔三差五我就得去解释一番,礼貌地请同事不要再这样做了。

最近实在累了。研究了一下直接给这个端口加上 per IP 的 rate limit,效果还不错。方法是在 Per-IP rate limiting with iptables 学习到的,这个公司是提供一个多租户的 SaaS 服务,也有类似的问题:有一些非正常用户 abuse 他们的服务,由于 abuse 发生在连接建立阶段,还没有进入到业务代码,所以无法从应用的层面进行限速,解决发现就是通过 iptables 实现的。详细的实现方法可以参考这篇文章。

iptables 本身是无状态的,每一个进入的 packet 都单独判断规则。rate limit 显然是一个有状态的规则,所以要用到 module: hashlimit。(原文中还用到了 conntrack,他是想只针对新建连接做限制,已经建立的连接不限制速度了。因为这个应用内部就可以控制了,但是我这里是想对所有的 packet 进行限速,所以就不需要用到这个 module)

完整的命令如下:

第一行是新建一个 iptables Chain,做 rate limit;

第二行处理如果在 rate limit 限额内,就接受包;否则跳到第三行,直接将包 DROP;

最后将新的 Chain 加入到 INPUT 中,对此端口的流量进行限制。

有关 rate limit 的算法,主要是两个参数:

  1. --hashlimit-upto 其实本质上是 1s 内可以进入多少 packet,50/sec 就是 20ms 一个 packet;
  2. 那如何在 10ms 发来 10个packet,后面一直没发送,怎么办?这个在测试情景下也比较常见,不能要求用户一直匀速地发送。所以就要用到 --hashlimit-burst。字面意思是瞬间可以发送多少 packet,但实际上,可以理解这个参数就是可用的 credit。

两个指标配合起来理解,就是每个 ip 刚开始都会有 burst 个 credit,每个 ip 发送来的 packet 都会占用 burst 里面的 credit,用完了之后再发来的包就会被直接 DROP。这个 credit 会以 upto 的速度一直增加,但是最多增加到 burst(初始值),之后就 use it or lost it.

举个例子,假如 --hashlimit-upto 50/sec --hashlimit-burst 20 的话,某个 IP 以匀速每 ms 一个 packet 的速度发送,最终会有多少 packets 被接受?答案是 70. 最初的 20ms,所有的 packet 都会被接受,因为 --hashlimit-burst 是 20,所以最初的 credit 是 20. 这个用完之后就要依赖 --hashlimit--upto 50/sec 来每 20ms 获得一个 packet credit 了。所以每 20ms 可以接受一个。

这是限速之后的效果,非常明显:

 

介绍 Lobbyboy 项目

上周末写了一个小工具,叫做 Lobbyboy(源代码:https://github.com/laixintao/lobbyboy )简单的来说,就是一个 ssh server,启动之后监听一个端口。你用通用的 ssh 客户端连接上以后,它会为你开一台 VPS,然后帮你打开一个 shell。等你用完 logout 之后它会再帮你销毁 VPS。

很多人会有疑问为啥需要这么个东西,其实这个只是解决一小部分人(指我自己)的需求,它并不是将不可能的事情变成可能,而是将可能、但是有些复杂的事情变得简单。这篇文章就来讲一讲为什么我将它实现成现在的样子,抓手是什么,底层逻辑是什么,形成了怎样的闭环,我的思考在哪里。

它是解决什么问题的?

它是解决运维人士经常需要使用一些服务器执行临时任务,比如编译乱七八糟的东西,跑一个数据清理,跑爬虫等,要经常开服务器,用完之后关掉这种场景;

还有一种场景是需要新的服务器进行验证(称之为验证场景),比如需要高规格的 CPU,需要 GPU,需要公网 IP,需要 IPv6,需要特定版本的 Ubuntu 发行版,甚至 Mac Mini(aws 提供的服务),或者 Windows(Azure提供的服务)等等。因为 Lobbyboy 提供了一种接口“协议”,三句话,我让男人三个接口,可以对接任意的 VPS 提供商,几乎可以做任何事情,构建出来任何的机器;测试完之后,想要拍屁股走人,不想要恢复原状(我是一个 Chaos Engineer!)。

实现一个新的 Provider(即,接入一个新的云厂商)只需要实现 Provider 的三个接口:

  1. 告诉lobbyboy 如何创建虚拟机 new_server
  2. 如何登录虚拟机 ssh_server_command
  3. 以及如何销毁虚拟机 destroy_server

所以任何能够提供 Linux 的地方都可以成为 Provider,比如说 Vagrant,VirtualBox,甚至感觉 Docker 也可以。lobbyboy 会内置一些 Provider,比如说 Vagrant(也作为一个内置的实现参考)。

用户实现的 Provider ,也可以考虑 patch 给 Lobbyboy,或者作为一个独立可安装的 pypi package 存在。

想到的还有一种场景(称之为吃火锅场景),就是你和你的好兄弟在吃火锅,这时候你正在上大学的表弟问你这行 Python 代码的输出结果是啥:”foo” == “bar” == False,虽然你 90% 确定,但是又不敢贸然回复,万一说错了,你在表弟心中“计算机大神”的人设就毁了。所以这时候你想找一台 Linux 跑跑试试。

 

上述需求能否用虚拟机实现呢?

运维人士需要经常背着电脑到处跑,所以选择笔记本通常会选择最轻薄的,方便 24 小时 OnCall. M1现在很火,但是 M1 上的虚拟机还不是很好用。Virtualbox 不支持 M1,UTM 貌似是支持的,但是这玩意不支持 vagrant。所以对 M1 用户来说,虚拟机就挺麻烦的。

另外值得一提的是,Lobbyboy 是支持 vagrant 的(vagrant provider,可以作为其他 provider 的一个实现参考),如果你在本地开一个 Lobbyboy,每次 ssh 到这个端口的时候,就会自动帮你开一台出来,省去了你 vagrant init; vagrant up; vagrant ssh; vagrant destroy 这些命令。

 

为什么不写一个命令行工具帮你开启/销毁机器?

有三个原因:

  1. 这样的话用起来还是需要安装,每次用的时候都要执行一堆命令,比较繁琐;
  2. Lobbyboy 有一个功能是,自动在 VPS 即将进入下一个计费周期的时候销毁。比如说 DigitalOcean 是按小时收费的,如果你一台机器用了 10 分钟,然后第 40 分钟的时候又想用,那么你还可以继续用。等到第 55 分钟了,Lobbyboy 发现机器没有在用了(没有 active session),就会给你销毁,这样你就付了一个小时的钱。假如第 59 分钟还有人在用的话,那么就等到第 119 分钟再尝试销毁。我们吃了一碗粉,就付一碗的钱;付了两碗的钱,就吃两碗粉。这种 Feature 必须有一个常驻的 daemon 来实现(crond 也是 d);
  3. cli 无法实现上述的“火锅场景”。而如果作为一个 ssh server 的话,无论你用 ipad,iPhone,Chromebook 等等等等,只要你有一个 ssh 客户端,你就可以用。

其实 Lobbyboy 还支持不同的 ssh key 策略,比如每次创建都加上你的 key,或者每次创建都自动生成一个新的 key(毕竟,你登录的是 Lobbyboy,Lobbyboy 登录新的 VPS,所以你的 key 其实不需要在 VPS 上面)。

 

为什么不一直使用一台 VPS?

是否可以 ssh 到一台你已经有的 VPS 上去做事情呢?无法满足“验证场景”。比如做一些具有破坏性的实验而不影响其他的服务。当然你也可以说,可以开一台备用的 VPS 一直放在那里,但其实也有一些不方便:1)费钱。2)如果做完一些事情不想清理,那么还是要再重新开一台。那又要用到 Lobbyboy 了。

 

为什么不使用 Homelab

有人在家里搭了一些服务器,是否可以直接使用 Homelab 完成上述需求呢?也是一样的问题。不过抛开电费和维护成本到底会不会比 VPS 更便宜不说, Homelab 可能可以实现每次帮你开新的虚拟机。不过这样 Homelab 就成了 Lobbyboy 的一个 Provider(所有能提供 Linux 的地方都可以成为 Provider)。所以要解决的问题也不是一样的。

可能上述方案都可以满足大部分人,即并不需要一个这样的反向代理。但是如果你有和我一样的使用习惯的话,那么可以试试 Lobbyboy。

 

为什么不直接用 openssh 的 server?

其实 openssh + 一个脚本几乎可以实现相同的功能。openssh 有一个功能,就是每次连接上就自动给你执行 /etc/sshrc 脚本。这样如果不从头实现一个 ssh server,只写一个 ssh 连接之后的脚本,也是可以的。但是我想了想,缺点有以下几点:

  1. 配置复杂,毕竟机器本身还有一个 ssh server,你要保证有另外一个端口能登录机器,而不是永远执行脚本,两个 sshd 进程要分别配置;
  2. 安装麻烦,要安装脚本,配置脚本。如果要实现机器回收的功能,还要去配置 crontab;
  3. Lobbyboy 因为只有一个 server 进程,所以它知道当前有哪些 active 的 session(全局变量),如果用 sshd 的话就要想别的办法查询活跃的 session,不同进程之间的交互也要通过 IPC 来解决;

但是 Lobbyboy 这种实现方式也有一些缺点:

  1. 多写很多代码,要处理 ssh 协议的部分,好在 paramiko 都已经做好了大部分。
  2. 有些功能是需要询问用户的,比如创建什么样的机器,去哪一个 provider 创建等等。因为这时候是在使用 ssh 协议简单地通讯,还没有 TTY,所以很多字符输入处理不好,比如你输入 1 再输入删除,并不会删除1,而是会得到 1\x7f

就写到这里吧,有关这个项目还有很多有意思的想法。如果你有什么好想法,欢迎在 issue 里面和我讨论。如果想试一下的话,可以按照 readme 来安装启动,只需要一个配置文件就可以启动了。项目就写了一个周末,如果启动不了或者遇到 bug 可以和我反馈~

 

Debug 一个在 uWSGI 下使用 subprocess 卡住的问题

今天花了很长的时间在排查一个诡异的问题,值得记录一下。

本来是想写一个 HTTP 的服务,你告诉这个 HTTP 服务一个 IP 地址和端口,这个 HTTP 服务就可以返回通过 TCP 访问这个 IP 端口的延迟。因为我们每次做 chaos 注入的时候都要测试一下注入延迟成功了没有,有了这个服务,这个测试就可以自动化。

其实很简单,收到请求之后用 TCP ping 几次拿到延迟,然后返回就可以了。

之前测量 TCP 的延迟使用的都是 hping3,Redis 的作者 antirez 写的。然后就想到用这个工具来做测试好了。

搜索了一下发现没有 Python 的 binding,所以打算粗暴一些,直接在 HTTP 服务里面 fork 出来一个进程做测试,然后去处理 stdout,grep 出来延迟的数据。很快就写好了,代码大体如下:

很简单的一段代码,收到请求,调用命令,返回结果。框架使用的是 Django,在本地测试一切正常,然后发布到 staging, 噩梦开始了……

在 staging 环境中,测试的时候发现,HTTP 请求发过去永远收不到回应,最后会得到一个 504 Gateway Timeout 的结果。去容器(应用运行在一个容器里面)看,发现 hping3 进程一直没有结束,像是卡住了。

一开始有很多错误的怀疑,比如怀疑 hping3 需要 TTY 才能执行,以为 hping3 需要使用绝对路径等…… 但是想想同样的代码在本地可以运行正常,就应该不是这些原因。一个验证就是,我去应用运行的环境中开一个 Python 的 REPL 执行这段代码,是能正常得到结果的。在应用运行的环境直接运行 hping3 命令,也是没有问题的。

然后怀疑 hping3 没有足够的权限来运行,因为 hping3 发送的是 raw TCP/IP 包,需要 CAP_NET_RAW 才能执行。去容器里面检查了一下,发现这个 capability 也是有的。

到这里,其实已经花费了很多时间了,得到的事实有:

  1. 容器里面执行 hping3 是完全没有问题的,权限是足够的
  2. 直接使用 Python3 的 REPL 执行这段代码也是没有问题,代码逻辑是对的

到这里你能猜到问题出在哪里了吗?

其实还有一个运行环境没有提到,就是 uWSGI. 这个 Python 写的服务是作为 WSGI 应用跑在 uWSGI 里面的。不知道和 uWSGI 有没有关系(直觉告诉我是有的,比直觉更厉害的同事也告诉是有关系的)。于是我打算直接使用 python manage.py runserver 在容器里面跑起来试试……

一切正常了。

所以 python 直接跑应用没问题,用 uWSGI 运行就有问题。现在问题锁定在 uWSGI 上面了。为了复现这个问题,我写了一个最小的测试用例。

首先需要一个文件,叫做 pingapp.py

然后使用 uWSGI 运行这个程序:uwsgi --http-socket :9090 --wsgi-file pingapp.py --threads=4.

uWSGI 的版本是 2.0.20,Python 的版本是 3.7.5, 但其实这不重要。

最后,访问这个程序,即 curl localhost:9090.

如果是 uWSGI 的问题的话,我们期望这里已经可以复现问题了,即 curl 命令会卡在这里,然后进程( ps -ef )里面出现一个 hping3 的进程,结束不了。

如同……下面这样:

图1 – 卡住的 hping3

但现实是……这个程序一点问题没有,运行地丝般顺滑。

这就见鬼了,直接没了思路。我的应用和这个最小的复现代码根本没什么(太大的)区别啊!我又没有用一些奇奇怪怪的 lib。

后面我实在解决不了了,找了(大佬)同事帮忙,花了很多时间,找到以下事实:

  1. hping3 卡住了,发现 SIGTERM 结束不了它,只能 kill -9
  2. 然后发现 uwsgi 进程也有一样的行为了,只能 kill -9 去杀它
  3. hping3 程序被 uwsgi 正常起起来是没问题的,起来之后运行不了
  4. …… 以及中间奇奇怪怪的现象,就不细说了,其实都不重要

最后虽然也找到了根因,但是走得路太弯了。本文从这里开始,就以事后诸葛亮的视角,看看有了上面的信息,我们怎么从正确的思路一步一步找到问题。

首先,同样的环境,在 shell 里面可以正常执行 hping3 但是 uWSGI 里面却不可以,既然 uWSGI 能正常开 hping3 进程,我们就可以看看这个进程到底卡在了哪里?

通过 strace 可以发现它一直在 poll 4 这个 fd,然后查看这个 fd,发现它是一个正常的 socket,应该就是 ping tcp 端口使用的那个 socket.

然后,我们应该去看一下,正常的 hping3 的 trace 是什么样子的。

strace hping3 104.244.42.1 -p 80 --syn -c 1

可以看到 poll 附近很神奇,下面突然就开始 write 到 stdout 内容了。不知道怎么结束的。

但是往上看,有一段代码获取了当前的 pid,然后给自己发送了 SIGALRM。再往前,发现注册了 SIGALRM 的 handler.

SIGALRM 是什么呢?

SIGALRM is an asynchronous signal. The SIGALRM signal is raised when a time interval specified in a call to the alarm or alarmd function expires.

看起来是用来做异步的。

搜索了一下 hping3代码。发现代码里确实是用这个信号的。

 

那么我们的 uWSGI 下的异常 hping3 是否是因为没有收到这个 SIGALRM 而一直在傻 poll 呢?

在上面的 图1 中,卡住的 hping3 pid 是 4285,我们看下这个进程能否处理信号:

这里可以发现 SigBlk 不是全 0 的,说明有 signal 被 block 了。SigBlk 是个 64 个 bit 组成的 bitmask,每一位代表一个 signal 是否被 block,1 是 block,0 是没有 block。从右到左分别表示 1-64 号信号。

举例:

可以使用下面这段脚本去 parse 到底哪些信号被 block 了:

可以看到,hping3 需要的 14 号信号正好被 block 了。

manual 中得知:

A child created via fork(2) inherits a copy of its parent’s signal mask; the signal mask is preserved across execve(2).

所以说,我们正常的 hping3 可以收到 signal,但是 uWSGI 里面 fork 出来的进程就不可以,可能是 hping3 从 uWSGI 里面继承了 signal mask,导致它也去 block 14 这个 signal 了。

我们可以去看下 uWSGI 里面是否也是 block 了这个 signal 的:

果然是的。

代码中发现,里面只有 core_id = 0 的 thread 是处理信号的,其余的 thread block 了所有的信号。

这段代码是 core_id 如果大于0,那么 block 所有的信号,如果不大于 0,就处理信号。

所以到现在也就明白我写的那个最小的 case 为什么不能复现了:我使用了默认配置,只有一个 thread,core_id =0,它永远可以处理信号。而生产环境开了 64 个 thread,只有 1/64 的几率能够处理信号从而让应用正确返回。

我们可以重新验证一下,开 2 个 thread,预期是它会有 50% 的几率卡住:

uwsgi --http-socket :9090 --wsgi-file pingapp.py --threads=2

果然是,50% 能得到结果,50% 会卡住。

 

到这里就真相大白了。至于修改呢,我打算直接用 socket.connect 来测量 tcp 的连接时间。因为 TCP 是 3 次握手的,但是对于客户端来说基本上只花费了一个 RTT 的时间。测试下来,这样得到的时间和 hping3 也是一致的。

 

另外这个故事告诉我们,uWSGI 下 fork 出来的子进程最好都默认信号不工作,虽然 core_id =0 是可以处理信号的,但是这个作为 uWSGI 本身回应信号的设计就可以。发现 uWSGI 的代码中还有很多别的地方调用了 sigprocmask(2),可能还有其他屏蔽信号的地方。

 

有一位智者同事曾经跟我说过这么一句话:

Learn some eBPF, xintao!

看来是时候认认真真学一学 eBPF 啦。

 

 

TTY 到底是什么?

先来回答一道面试题:我们知道在终端中有一些常用的快捷键,Ctrl+E 可以移动到行尾,Ctrl+W 可以删除一个单词,Ctrl+B 可以向后移动一个字母,按上键可以出现上一个使用过的 shell 命令。在这 4 种快捷键中,有一个是和其他的实现不一样的,请问是哪一个?

答案是 Ctrl+W。因为 Ctrl+W 是一个叫 TTY 的东西提供的,其余的三个是 shell 提供的。好吧,我承认问别人这样的题目会被打死,这里只是为了吸引读者的兴趣而已。

再看另外一个比较有意思的问题:假如你现在在 host1 上面使用 ssh 命令登录了 host2,然后执行了 sleep 9999 命令。这个时候按下 Ctrl+C,请问会发生什么情况?

  1. host1 上面的 ssh 会被停止
  2. host2 上面的 sleep 命令会被停止,ssh 会话将继续保持

用过 ssh 命令的人都应该知道现象是(2),我们可以在 ssh 提供的 shell 里面随便 Ctrl+C 而不会对 ssh 造成任何影响。

那么这是怎么实现的呢?

我们知道 Ctrl+C 是发送一个 signal,int值是2,名字叫做 SIGINT. 所以我们可以猜想:是否是 ssh 进程收到了 SIGINT,然后将其转发到了 ssh 远程那边的程序,而自己不会处理这个信号呢?

我们可以使用 killsnoop 程序验证这个猜想,这个程序可以将进程间的信号打印出来。

首先我们启动 killsnoop 程序:

然后新开一个 shell,按下 Ctrl+C,会发现所在的 shell (pid=1549)收到了 signal=2 的信号,即 SIGINT.

然后我们 ssh 到本机,在 ssh 内部按下 Ctrl+C:

如果我们猜想正确的话,现在应该是 shell (pid=1549) 依然收到 SIGINT,然后将其转发到 ssh 进程。

但是 killsnoop 显示只有 ssh 打开的那个 shell 收到了 SIGINT,ssh 进程本身和原来的 pid=1549 的 shell 并没有收到任何的信号。

显然,我们的猜想是不成立的。那么,是如何实现 Ctrl+C 不影响 ssh 本身而是会影响 ssh 内部的程序的呢?相信看完本文你就会有一个答案了。

希望已经吸引到了你足够的兴趣,这些问题都要从 TTY 开始讲起,我们现在开始考古。

TTY 是一个历史产物

首先要明确一点的是,TTY 是一个历史产物。就像现在的 Unix 系统有那么多的 /bin。是因为很多程序都默认这种存在了,老的程序需要它们才能运行,新的程序也会默认去兼容它们。如果不考虑历史原因和兼容,完全写一个从头设计的 Terminal 或者目录组织的话,是可以不需要那么多 /bin,不需要 TTY 的。

下面就简单地介绍一下需要 TTY 的那段历史,以及为什么在当时的情况下,TTY 和各个子组件是不可缺少的。

TTY 的全程是 Teletype,什么是 Teletype 呢?

By ArnoldReinhold – Own work, CC BY-SA 3.0, Link

这,就是 Teletype——远程(tele),打字机(type)。

这个视频展示了它是怎么工作的。

还有一个叫做 Teletype Model 33 的 Twitter 账号会发布一些相关的内容,比如这个 git pushTeletype 上的视频

简单的来说,在很久之前,很多人一起使用一台计算机(你一定听说过 Unix 是多用户多任务的操作系统吧?)。每个人都有这么一个“终端”(Terminal, TTY, 在这种语境下可以认为是一个意思啦)。在这里敲下自己要运行的命令,然后发送给系统执行,从系统拿到结果,在纸上打印出结果。

所以,在当时,TTY 是一个硬件,作为一个硬件,是怎么连接到计算机的呢?

首先要有线,但是这根线连到的其实并不直接是计算机,而是一个叫做 Universal Asynchronous Receiver and Transmitter (UART) 的硬件。UART Driver 可以从硬件中读出信息,然后将其发送到 TTY Driver. TTY 从中读出来发送给程序。(事实上,UART 到今天也还在使用,如果你玩过 Arduino 或者树莓派的话,可能接触过。)

类似于这样:

图片来自 The TTY demystified

到这里,其实对于我们“现代人”来说,也都比较直接。来自硬件的输入通过 Driver 层层复制最终到了应用程序而已。

等等,上面还有一个叫做 “Line discipline” 的东西。这是什么鬼?

如它的名字所说,用来“管教” line 的。命令在输入之后,在按下 Enter 键之前,其实是存储在 TTY 里面的。存在 TTY 的 line 就可以被 Line discipline 所“管教”。比如它提供的功能有:通过 Ctrl+U 删除,也就是说,你按下 Ctrl+U 之后,TTY 并不会发送字符给后面的程序,而是会将当前缓存的 line 整个删掉。同理,Ctrl+W 删除一个字符也是 Line discipline 所提供的功能。(哇!你现在能通过我的面试了!)我会在后面证明这是 TTY 提供的功能。

这个功能在我们“现代人”看来简直太无聊了!不能直接交给 bash 来处理吗?有必要作为一个 Kernel 的子系统处理这种事情吗?

每当你想要批评别人时,你要记住,这个世界上所有的人,并不是个个都有过你拥有的那些优越条件。

是的,当年的 Unix 就没有这样的条件。

在很久之前,将每一个字符读进来然后立即发送给后面的程序的话,对计算机来说太累了。因为 Unix 的 RAM 很小,只有 64K words. 如果 20 个人用每分钟 60 个单词的速度打字的话,大概每秒会需要 100 次 context switches 和 disk swap,那么计算机将会花费 100% 的时间来处理这些人的按键上,根本没有时间干别的了。(PS 这段内容其实是我从 dev.to 一个评论能看到的,实在太精彩了,看到这个评论之前我看了很多文章都没想明白到底为什么需要 Line discipline.)

Line discipline 最大的用处,其实是一个可编程的中间人。它可以 buffer 20 个 TTYs 的内容,直到有一个人按下 Enter 的时候,才会真正将内容发送给后端的程序。一个 Line discipline 模块可以 cache 20 个 TTYs,假设我们需要 30s 输入一个命令的话,那么每一个用户差不多有 1.5s 的执行时间。几乎快了 100 倍。

Line discipline 的工作方式有点像 Emacs,有一个 size=127 的 function table,每一个 key 都有一个绑定的功能。比如说:进入 buffer; 将命令发送出去等等。

你可以将 TTY 设置为 raw mode,这样 Line discipline 将不会对收到的字符作任何解释,会直接发送给后面的程序(准确说,应该是前台的进程组,session,会收到)(实际上,这就是 ssh 不会收到 SIGINT 而是 ssh 内部的程序收到 SIGINT 的原因,我会在后文给你证明)。现在很多程序使用的 TTY 都是 raw mode 了,比如 ssh 和 Vim. 但是在很久之前,Vim 是运行在 cooked mode(即 Line discipline 会起作用)。当你在一行的中间输入一些文字,比如 asdffwefs,屏幕会乱掉,这些文字会覆盖后面的内容,直到你按下 Esc 退出编辑才会正常。

今天的电脑已经比当时的硬件性能搞了千万倍,所以 Line discipline 没有什么意义了。但是在当时,如果人们想要对当前输入的命令进行删除在编辑,这个功能在哪里实现最合适呢?显然是 buffer 的地方了!

这里的性能问题已经成为历史,但是 TTY 和 Line discipline 却存在了下来(不然我们现在怎么能用 Ctrl+W 呢?),因为(我猜的)很多程序在写的时候,比如 bash,会默认有 TTY 的存在;TTY 也继续保留着 Line discipline 的功能,而用户对此并没有任何体感(之前我们不知道 TTY 这个玩意,终端和 ssh 不也用的好好的吗?)所以我看来,这是一个向后兼容的“文物”。

那么在今天,TTY 到底是什么呢?本质上,它不再是一个硬件,而只是一个软件(内核子系统)。从系统的用户层面来说,他是——一个文件。当然了,Unix 里面什么不是文件呢?

通过 tty 命令可以查看当前的 shell 使用的哪一个 TTY。(启动的 shell 在没有重定向的情况下,stdin, stdout, 和 stderr 都是一个 TTY 设备)

作为一个“文件”,你可以直接往里面写。内容写进 TTY 之后将会被输出设备读出去。(下图表现为在下面的 shell 写入,出现在上面的 shell 中)

当然,也可以读。但当你从 TTY 读的时候,你就和输出设备形成了竞争关系,因为你们都在从这个 TTY 中尝试读,原来这个 TTY 只有一个读者,现在有了两个。我在上面的 shell 中按下了 1-9 这几个数字,每一次输入不一定会被哪边读到:

一旦被 cat 读到了,那么你按下的键将不会显示在当前的 shell 中。

是不是有了坏坏的想法?是的,我们可以通过 w 命令看看有哪些人登录在机器上,然后去 cat 他们的 TTY,他们一定会以为自己的键盘坏了!(小提示,当用户登录的时候,使用的 TTY 文件权限将设置为仅自己读写,owner 设置为自己,所以这个恶作剧必须要 root 才行!)

了解了 TTY 是什么,那么它在今天有什么用呢?

我们可以反向思考这个问题,没有 TTY 行不行?

答案是可以的。

我可以演示一下没有 TTY 一样可以使用终端。

设想一种场景,假如你攻破了别人的一台机器,比如 kawabangga.com 所在的服务器,你发现了一种可以在里面执行 python 代码的方法,但是,你只能将代码注入进去执行,看不到输出,这怎么办呢?

有一种叫做 reverse shell 的东西。通俗来讲,我们 ssh 一般是我们跑去远程的电脑上做控制,reverse,顾名思义就是反向的 shell。其实就是我在远程的机器上打开一个 shell,然后将它拱手送给你,交给你控制。

下面演示,我在下面的终端使用 nc 打开了一个 tcp 端口(模拟入侵者掌握的一个服务器),然后在上面的终端(被入侵的机器)执行了如下命令:

可以看到这段 python 代码实际上打开了一个sh 程序,然后将 stdin/stdout/stderr 全部和 tcp 的 socket 连接了起来。对于 nc 的这一端来说,nc 的 stdin/stdout/stderr 就发送进入了 socket,所以,我的 nc 变成了能控制对方的一个shell!

这样,我就可以在对方的主机上随意执行命令了,非常方便!

使用其他语言也可以打开 reverse shell

通过上面的图片也可以看出,这是一个没有 TTY 的 shell。它有什么问题呢?我们来跑一下 TUI 程序,比如 htop

注意看左上角的问题,其实是按下 q 之后尝试敲下 hostname 这几个字,而 sh 已经丧失理智了,连我敲下的字符都不能正常显示出来。除此之外,这个没有 TTY 的 shell 还有以下缺点:

  1. 无法正常使用 TUI 的程序,比如 Vim,htop
  2. 无法使用 tab 补全
  3. 无法使用上箭头看 history 命令
  4. 没有 job control
  5. ……

(其实 reverse shell 也是可以有 TTY 的

所以说,在今天,没有 TTY,我们也能跑一个不完整的 shell,毕竟,我们今天的硬件已经和远程打字机没什么关系了。

但是,TTY 依然作为一个内核的模块承担着重要的功能。有了 TTY,可以给我们完成一些 Terminal 上的功能。Terminal 可以告诉 TTY,移动指针,清除屏幕,重置大小等功能。

诶?等一下,为什么我们在上面的图片中见到的 tty 命令,都是以 /dev/pts/ 开头的,而不是以 /dev/tty 开头的呢?有什么区别?

这其实是“假装的” TTY,叫做 Pseudo terminal。

不知道你有没有意识到,我们上面讨论的 TTY 有一个很重要的点是,TTY 是作为内核的一个模块(子系统,Drive)。TTY 在内核空间而不是用户空间,我们现代的 Terminal 程序,ssh 程序等,如何能和 TTY 交互呢?

答案就是 PTY。

这里会将解释进行简化,方便理解。当像 iTerm2 这样的程序需要 TTY 的时候,它会要求 Kernel 创建一个 PTY pair 给它。注意这里是 pair,也就是 PTY 总是成对出现的。一个是 master,一个是 slave。slave 那边交给程序(刚才说过了,bash 这种程序默认会认为有 TTY 的存在,在交互状态下会和 TTY 一起工作),程序并不知道这是一个 PTY slave 还是一个真正的 TTY,它只管读写。PTY master 会被返回给要求创建这个 PTY pair 的程序(一般是 ssh,终端模拟器图形软件,tmux 这种),程序拿到它(其实是一个 fd),就可以读写 master PTY 了。内核会负责将 master PTY 的内容 copy 到 slave PTY,将 slave PTY 的内容 copy 到 master PTY。上面我们看到的 /dev/pts/* 等,pts 的意思是 pseudo-terminal slave. 意思是这些交互式 shell 的 login device 是 pseudo-terminal slave.

terminal emulator - pty master <-- TTY driver( copies stuff from/to) --> pty slave - shell

所以说,我们在 GUI 下看到的程序,比如 Xterm/iTerm2(其实用的是 ttyS,这里就不细说了),比如 tmux 中打开的 shell,比如 ssh 打开的 shell,全部都是 PTY。所以,GUI 下面的这些终端,类似 konsole, Xterm,都叫做 “终端模拟器”,它们不是真正的终端,是模拟出来的。

怎么进入到一个真正的 TTY 呢?很简单,在  Ubuntu 桌面系统中,Ctrl+Alt+F1 按下去,是图形界面,但是 Ctrl+Alt+F2(其实 F2-F6都是),就是一个终端了,这个终端,就是 TTY,你在那里登录然后按下 tty 命令,它就会告诉你这是 tty device 了。

我正好有一个 virtualbox 虚拟机,只有命令行,没有 GUI,登录进去的话,可以看到这就是一个 TTY。

最后我们回到本文开头的第二个问题:为什么在 ssh 里面按下 Ctrl+C 并不会停止 ssh 而是会停止 ssh 内的程序呢?

我们回顾一下,当我们在本机按下 Ctrl+C 的时候,都发生了什么?

  1. kernel 的 driver 收到了 Ctrl+C 的输入,中间经过的不相关的模块我们忽略不计
  2. 然后到达 TTY,TTY 收到了这个输入之后向当前在 TTY 前台的进程组(其实是当前 TTY 被分配给了哪一个 session,就给哪里发)发送 SIGINT 信号,如果当前是 bash 在前台,bash 会收到这个信号,如果是 sleep,那么 sleep 就会收到。

由于 SIGTERM 是可以被程序自己处理的信号,所以 bash 收到之后决定忽略,sleep 收到之后会退出。

stty 程序可以让我们修改 tty 的 function table,Ctrl+C 这里涉及的是一个叫 isig 的功能:

[-] isig

enable interrupt, quit, and suspend special characters

–from man isig

这个其实是说,如果 TTY 收到的 Ctrl+C 这种输入(原始符号是 ^C ,对应的,可以使用 stty -a 命令查看,默认的 quit 是 ^\,默认的 suspend 是 ^Z),不要将它原文发给后面的程序,而是将其转换成 SIGINT 发送给当前 TTY 后面的进程组。所以我们可以用 stty -isig 来关闭这个行为。

现在,如果在 sleep 程序中按下 Ctrl+C,TTY 将会把 ^C 字符原封不动地发送给 sleep 程序,sleep 将不会收到任何的信号。我们无法使用 Ctrl+C 结束 sleep 程序了。

回到 ssh 的那个问题,我们现在合理的猜测是:ssh 在获取远程的 shell 的时候,会先将当前自己所在的 shell disable isig,这样子,Ctrl+C 这个行为将会以字符发送给 ssh,ssh 的客户端将这个字符发送给远程的 ssh server,ssh server 发送给自己的 TTY(其实是一个 PTY master 啦),最后远程的 TTY 发送给当前远程的前台进程一个 SIGINT 信号。

如何验证我们的猜想呢?

验证1

我们可以使用 stty 查看 shell 的 TTY 设置,然后使用这个 shell 通过 ssh 登录之后,再次查看 TTY 的设置。

这个图中,我们用上面的 shell 来查看下面的 shell TTY 配置。可以看到第一次查看是 ssh 登录之前 isig 是开启状态。第二次查看是在执行 ssh 登录之后,isig 变成关闭状态了。如果 ssh 退出,isig 又会变成开启的状态。

验证2

从反面证明一下,假如说我们在 ssh 登录之前,强行将 ssh 所在的 TTY 开启 isig,那么按下 Ctrl-C ,将会结束 ssh 进程本身,而不是 ssh 内部运行的程序。

因为我这里使用的 ssh 登录本机,所以为了区分是在当前的本地 shell 还是在 ssh 中,我修改了本地 shell 的命令行提示符。

这个图片是在 ssh 登录之后,在另一个 shell 中运行 stty --file /dev/pts/0 isig 对 ssh 所在的 shell 开启 isig。然后在 ssh (当前的前台程序是 sleep 9999)按下 Ctrl+C。这时候 ssh 直接退出了,我们回到了 local shell,而不是结束 ssh 中的 sleep。

验证3

我们可以直接使用 strace 程序去跟踪 ssh 的系统调用。

strace -o strace.log ssh [email protected]

可以看到在 ssh 启动的时候,会有一行:

ioctl(0, SNDCTL_TMR_STOP or TCSETSW, {B9600 -opost -isig -icanon -echo ...}) = 0

是将 TTY 的设置改成了 -isig,以及一些其他的设置。

然后在 ssh 退出的时候,会有一行:

ioctl(0, SNDCTL_TMR_STOP or TCSETSW, {B9600 opost isig icanon echo ...}) = 0

将设置修改回去。

其实如果你用 Terminal 用的足够多的话,你一定遇到过这种情况:运行了某些 TUI 程序之后,非正常退出(比如它卡了,崩溃了,或者被 SIGKILL 了),然后你会到 Terminal 发现 Terminal 都乱了,回车无法正常换行,Ctrl+W 等无法工作等情况。有可能就是因为程序在退出的时候没有执行本应该去执行的 reset tty 代码。使用 reset 命令可以重置当前的 Terminal,让它恢复理智。

那么回到第一个问题,怎么证明哪些快捷键是 TTY 提供的,哪些是 shell 提供的呢?

这就更简单了,其实 stty -a 已经将所有 stty 的配置打印出来了:

raw mode 下,甚至回车键就是 newline,不会给你将光标移动到行首。

如果取消 Ctril+W, 这个功能自然就没了。打一个 Ctrl+W 就真的是 ^W

那么 shell 的那些快捷方式呢(比如 Ctrl+E)?我们可以用 sh 程序来验证它们是 shell 提供的功能,而不是 TTY 提供的功能。sh 是一个非常傻的程序,并不会解释 Ctrl+A 或者 上键这些功能。按下左箭头会出现 ^[[D,按下 Ctrl+A 就会出现 ^A(感觉这些字符之前很多人都会见过,当 shell 卡了的时候,按下箭头就会把这些 raw 字符打在屏幕上)。但是,在正常的 TTY 下(cooked TTY, 可以使用 reset 命令复原之前被我们玩坏的 TTY),Ctrl+W 这个功能在 sh 下依然是可以使用的。

参考链接的汇总:

  1. The TTY demystified TTY 还和 sessions, jobs, flow control, 拥塞控制,signal 有关,本文在介绍这些的时候多少有些省略,如果想了解详细的内容可以阅读这个链接
  2. Linux terminals, tty, pty and shell 这篇文章是一个对 shell,terminal,TTY 大体的介绍。其中,这个评论非常精彩。我几乎将其完全翻译到本文中了
  3. Run interactive Bash with popen and a dedicated TTY Python 这是在 Python 中如何使用 PTY 的一个例子
  4. Reverse Shell Cheat Sheet 各个语言打开 reverse shell 的方法
  5. The Linux Programming Interface 书中,第 64 章 PSEUDOTERMINALS,第 62 章 TERMINALS.
  6. Terminal emulator
  7. A history of the TTY