Appearance
12 Factor App in Action (12 要素应用实战)
参考自
karminski牙医
的文章12 Factor App in Action (12 要素应用实战)
按照云原生基础指南来对照当前的查询程序,评估还有哪些改造需要完成
规则介绍
大概会按照如下的方式进行介绍
节选的说明
点评一下查询程序的挂经四路
1. 基准代码 (一份基准代码, 多份部署)
基准代码讲的是, 需要有个核心的代码库来存储所有版本
集中化意味着方便管理, 试想一下你刚上线的业务突然发生了问题, 你得 BOSS 认为对着你吼就能给你加个 Buff 让你智力+10 从而能迅速修复 Bug. 不堪重压的你直接连接到了线上服务器手动修正了问题. 就在这一切平息之后, 度过周末的你完成了新的功能, 忘记了线上有个飞天补丁在运行, 直接上线了新的版本覆盖掉了这一修正, 就在你发布完毕的一刹那, 你听到了你的 BOSS 发出了如同被人踢到了蛋的吼声后朝你走来...
很明显,查询程序非常满足这个规则,甚至太过满足了。所有的代码都塞到了一个项目里,数据查询、web 服务器、定时任务和缓存模块没做区分。之后应该拆分成通用模块的多个合适的子模块
2. 依赖 (显式声明依赖关系)
显式声明依赖关系指的是通过一份 "依赖清单", 来声明需要的依赖
显式声明依赖关系的目的是方便进行再构建. 相信各位都有在 Linux 下手动安装某软件, 然后运行的时候提示缺少动态库 (xxx.so) 或运行 apt 或 yum 安装软件然后安装失败或者遇到了包冲突的经历. 本质上显式声明依赖关系正是为了避免类似问题.
理论上来说查询程序是有依赖文件和部署文档的,但是由于初期完全不懂什么是依赖文件以及数据库表和 web 服务器的配置复杂,因此基本上形同虚设
理论上依赖文件至少是可以手动指定的。但是由于网页部分魔改了 web 框架,而新版本的框架又不兼容原有魔改,因此准备直接写一个新的 web 框架,而不是适配新版本
3. 配置 (在环境中存储配置)
在环境中存储配置指的是, 删掉你的配置文件或硬编码在程序中的配置常量. 改用环境变量中存储配置来取代他们
一切为了解耦, 只有配置与实例松散耦合才适合更大规模的应用, 才能自动化, 程序化部署. 试想一下线上有 20 万个容器, 数据库连接是硬编码在程序中的. 现在数据库扩容, 要切换主从节点的连接配置信息. 20 万个, 手动修改后上线, 好嘛, 一周时间都不用干别的了. 而如果配置存在于环境变量中, 这一切将会变得很简单, 更新下 k8s 环境变量, 然后批量 reload 容器就完事了
这部分查询程序的部署就堪称悲剧了,查询程序目前是采用一个 python 配置文件的方式来完成的部署,而且这个巨大的配置文件还包含了秘钥和日志组件。每次更新一些短时的 cooikes 之类的内容还得手动给开发和生产环境手动同步
等待查询程序的新版本来完成依赖注入吧
4. 后端服务 (把后端服务当作附加资源)
把后端服务当作附加资源, 更准确地说, 是通过统一资源定位符去调用资源. 本质上讲其实还是强调与其他业务或资源进行解耦
得益于现在 web 组件的标准化, 现在资源与业务隔离基本都做得很好, 很少有切换个 MySQL 数据库还要修改代码去兼容的情况了. 所以这里更多情况指的是外部第三方服务或需要特殊逻辑 (比如需要注入一些存储过程) 的情况
目前查询程序还是比较松耦合的,没有特别强捆绑的。但是之后邮件和其他通知都得做改造
5. 构建, 发布, 运行 (严格分离构建和运行)
这里指的是严格区分, 构建, 发布, 运行这几个阶段
刚才也举例了直接修改线上代码会导致问题. 这里还要强调的是, 流程的标准化易于业务的规模化和自动化. 这意味着可以节省工程师的大量工时. 尤其是这种重复性的事务, 每次节生 1 分钟, 累积下来节省的时间就非常可观
这个问题其实比较复杂,因为查询程序目前的开发和运维都是由我一人完成。构建、发布和运行的流程实质上被打断了,基本上只有开发测试和部署。但是区分开发和生产环境依然是需要做的
6. 进程 (以一个或多个无状态进程运行应用)
12 Factor 应用是以一个或多个无状态进程运行应用, 这极大地方便了业务的伸缩. 简单的启动新的进程或关闭现有进程就可以了
符合这一点的业务天然具备了伸缩性, 比如 UNIX 下的组件, 每个都是无状态的, 通过管道等组件即可组建出强大的应用来解决问题. 而 "12 Factor" 业务则强调了其伸缩性, 从而方便构建适应现代互联网发展速度的宏大业务
这部分查询程序基本上是无状态的单个进程,但是由于查询程序依赖的账号和 IP 资源是共享的,因此本质上还是一个接近单体的程序
7. 端口绑定 (通过端口绑定提供服务)
端口绑定指的是业务通过暴露固定端口的方式来对外提供服务
相信现在大部分业务也都是通过端口来提供服务的了, 所以这一点几乎不用特殊强调. 其实这更多的是为了兼容现有系统, 现有系统跨节点通信最方便的方式仍然是通过 IP 协议进行通信. 而 unix socket 等本地协议不支持跨节点通信, CGI 相关的协议虽然可以跨机但支持度不够. 因此通过端口通信方式是最佳兼容方案
这个规则的要求实际上是表达程序应当通过通用可伸缩的端口服务来进行通讯,而不是依赖本地且平台绑定的 unix socket
8. 并发 (通过进程模型进行扩展)
其实这一点跟第 6 点是一样的, 通过无状态进程来组件业务, 自然就获得了方便扩展的特性
这里强调用进程, 即将业务伸缩归于平台管理, 而不是业务自己管理. 多线程模型更适合业务是个单体应用, 在这种情况业务只要关心自己的性能就可以了. 但是在微服务的情况下, 业务间是要协作的, 因此业务需要将本身的伸缩交给调度系统去管理. 而无状态进程模型更方便调度 (启动新的进程就可以了)。我的建议是, 如果你的业务需要跨机器伸缩, 那么直接使用 k8s. 否则不需要跨机器调度的话, 那就直接用 docker + systemd 来管理容器 (等同于进程) 的生命周期就可以了
目前查询程序还是一个单体的架构,因此没有考虑伸缩扩展之类的需求。后续查询程序做了改造之后并发量的限制也是对端限速,而不是查询程序的性能限制
但是对于一些性能依赖较高的程序,该如何完成扩展则比较麻烦。尤其是框架不一定支持扩容和缩容
9. 易处理 (快速启动和优雅终止可最大化健壮性)
易处理追求更小的启动和停止时间. 以及业务通信尽可能趋于原子性
更少的启动和停止时间意味着调度时间花费会更少, 可以提升调度性能. 这对于任何调度系统都是一样的. 比如地铁调度系统, 如果每辆车的进站和出站时间越短, 那么单条线路上能容纳的最大车辆数就会提升. 同样, 如果进程启动和退出的越迅速, k8s 调度也会越迅速, 调度能力(同时调度的容器数量)也会更高, 达到调度目标的耗时也会更短 (比如系统遇到了流量洪峰, 需要紧急扩容 1000 容器, 在 10 秒内启动 1000 容器和在 10 分钟内启动 1000 容器, 这种差距能直接决定现有业务会不会被流量打垮)
由于 python 不需要预热,查询程序也没有过多的预处理步骤,因此查询程序的启动速度还是很快的。但是相对的问题还是那个,查询程序的最大限制还是对端限速,而且请求流量比较稳定
10. 开发环境与线上环境等价 (尽可能的保持开发,预发布,线上环境相同)
即线下的环境需要有一个线上仿真环境, 这需要组件, 工具, 数据等全套工作流程的克隆
数据是最困难的, 维持一份与线上同步的数据来提供给开发或测试环境自然可以最大程度的复现真实生产场景. 但维持数据同步本身是非常复杂的. 较好的方式有从定期备份的数据副本中恢复到本地环境. 但如果定期备份的跨度太大, 本地与线上的数据差距还是很大的. 另一种方式是线上数据库本身的存储是网络的, 比如数据库的存储是 iSCSI, SAN, 又或者是 CEPH, 在这样的系统中, 可以通过 ByPass 或其他系统提供的复制方式进行大规模复制. 但这种方式的成本非常高. 又或者可以利用数据库软件本身的副本机制来进行同步, 但这需要投入一定的开发. 甚至还可以复制线上流量到线下进行实时副本写入, 这样的本地成本很可能无法接受
由于是单人开发且具有草台班子特性,查询程序的开发环境实际上就是只读的线上环境。这点比较无语,尤其是由于在线数据库非常巨大,本地做一份数据副本在成本上是完全行不通的
11. 日志 (把日志当作事件流)
即完全不考虑日志组件, 而是最简单的将日志信息写到 stdout 上
适合伸缩的平台是为了海量流量的大型业务而准备的, 而大型业务的日志自然也是海量的. 试想一下线上有几万实例的业务, 现在想要寻找特定的日志, 这无异于大海捞针. 而统一收集并处理业务, 就可以利用现有的大数据组件, 索引系统进行日志检索和处理. 而更先进系统与日志的融合, 让日志自动化分析, 阈值处理与自动化调度都变为了可能. 量变产生了质变. 传统的日志处理模式已经完全不适合云服务了. 只有这样组织的日志系统才能继续提供服务
查询程序目前对日志组件做了封装,如果想要切换到 std 模式,只需要把文件日志屏蔽掉即可。但是云原生的日志分析系统 1 反而没有部署到位,如何对数量众多日志进行分析还是一个问题
12. 管理进程 (后台管理任务当作一次性进程运行)
即一次性任务也应当当作业务去运行, 走正常的编写, 提交, 构建, 发布流程. 而不是随便写一些脚本扔到线上直接跑
我见过一个最有趣的例子是, 有一个同学需要清洗一批线上数据, 于是他编写了一个脚本, 然后将脚本扔到了线上在 terminal 中直接运行. 过了一会, 他下班了. 于是直接关闭了显示器, 让机器继续保持 session 继而脚本继续执行. 结果第二天上班. 发现昨晚物业停电电脑关机了. 数据非但没清洗完毕, 反而变成了只清洗了一半的更脏了的状态. 也许你会说他应该用 screen 命令. 但我想说的是, 如果这个一次性任务时长很长呢? 比如需要一周才能完成. 这期间的进程管理该怎么办? 这就是这条守则存在的意义
目前由于没有 cicd,因此查询程序确实是随便写的脚本。虽然说是有用 screen 命令托管,但是 screen 关闭后日志就会全都看不到。所以之后还是需要研究如何进行 cicd 化的改造
一些实践
对于一个尝试进行容器化, 微服务化的小型公司而言, 我建议的配置大概如下
- 代码托管采用 Gitlab, CI/CD 采用 Jenkins 搭配 kubernetes 组件. 容器调度系统采用 kubernetes. 容器运行时用 docker (runc). 镜像存储用 harbor. 分布式数据存储/对象存储用 CEPH
- Jenkins 用 webhook 与 Gitlab 连接. 然后 Gitlab 建立一个发布专用账号, 每当有新的提交, 就会通过 webhook 触发 Jenkins 的 CI/CD 流程. 通过 repo 内部维护的脚本进行镜像构建 (发布脚本, Dockerfile, k8s 配置全都是跟 repo 走的, 这些都是内部配置. 只有数据库等外部配置才是需要写到环境变量的). 如果你有配置管理系统, 就把配置提交到管理系统, 如果没有, 那就用一个脚本来将配置直接提交到 kubernetes
- 构建完毕的镜像存储到 harbor 中. 然后构建流程完毕后, 进入容器内运行过程, k8s 启动容器, 容器管理系统会自动从 harbor 将刚 build 好的镜像拖下来运行. 至此整个发布流程就结束了
- 另外, 还需要维护一个基础设施镜像库, 常用的基础组件, 比如数据库, web 服务器, 动态库等, 都需要维护一个镜像并提交到基础设施镜像库中. 方便开发的版本统一和后续升级
- 本地最好有 2 组至少 8 节点 kubernetes 集群作为测试和 beta 测试使用. 数据同步部分则像我上面提出的, 可以考虑用定期备份的数据来进行重新构建
预期这些业务的搭建和维护需要一个专业的工程师才能搞定. 计算资源的话至少需要 3 台 32 核心的服务器才能完成. 这大概就是最初期的投入了
目前的内网基础设施中,代码托管、CICD 和镜像存储用的是 Gitea,内网统一存储是通过 minio 和 smb,没有做到存储资源的高可用以及支持块存储。目前没有完成容器调度系统,这个还在考虑使用何种组件
实际上目前的最大问题还是个人简单的需求和硬件资源(主要是云服务器)无法支撑起 k8s 这种重型调度系统,以及个人的一些简单又有着复杂调度(爬虫和 QQ 机器人)需求的程序该如何适配
总结
总的来讲, "12 Factor" 业务的提出是革命性的. 这些共同的理念催生了 Docker, kubernetes 和 CloudNative. 我回想起我在 2010 年做后端开发的时候, 还没有这些概念. 但我开发的业务或多或少的都有了一些这样的特质
比如我经常用 Go 和 PHP 编写大型的数据处理业务. 这些业务完全无状态, 然后利用 systemd 来管理任务的生命周期而不是靠 daemon 进程. 虽然这些业务不需要自动伸缩. 但当我需要他们跑的快一点的时候, 我就会在多个机器上启动多个进程, 来提高从数据队列获取数据的速度. 而他们挂了也不必担心, 直接重启就可以了. 甚至我看到有些偷懒的工程师更是如此, 看到程序有内存泄漏, 懒得排查问题, 于是让进程定期重启从而避免了被 OOM Kill 掉
kubernetes 并不是突然出现的. 现实中我身边学习 Go 语言最多的恰恰是 PHP 工程师. PHP 甚至连 GC 都没有, 为什么? 恰恰是因为它本身在作为 fpm 的时候执行的生命周期非常短, 没等填满内存就已经退出了. 而 kubernetes 内的容器也借鉴了 "就让它崩溃" 的类似思想 (当然有很多系统都是这样的, 比如 Erlang). 容器挂了直接重启. 这样的思想成为了组建庞大系统的核心
而 "12 Factor" 这是这些思想中提炼出来的精髓. 每一条都值得仔细的思考. 大家看完我这篇狗尾续貂之作后, 能在日常工作的架构中看到类似的影子, 或者有一些感同身受的话, 就是我最大的荣幸了.
其实很多云原生的概念都是查询程序在开发和改进过程中有所借鉴的,但是真正在整体上做分析以及改造则是第一次。如何将查询程序和内网其他的服务改造成类似云原生的还是需要考虑的