Repository: liheyuan/hands-on-microservices Branch: master Commit: 9c7da66a030d Files: 128 Total size: 556.3 KB Directory structure: gitextract_6y7hg2hl/ ├── .gitignore ├── README.md ├── book.toml ├── legacy/ │ ├── SUMMARY.md │ ├── architecture/ │ │ ├── README.md │ │ ├── devops.md │ │ ├── microservics.md │ │ ├── ms-arch.xml │ │ ├── overview.md │ │ └── toolchain.md │ ├── devops/ │ │ ├── README.md │ │ ├── discovery.md │ │ ├── docker-repo.md │ │ ├── jump-server.md │ │ └── openvpn-k8s.md │ ├── k8s/ │ │ ├── README.md │ │ ├── docker-k8s.md │ │ ├── helm.md │ │ ├── k8s-cluster.md │ │ ├── k8s-ha.md │ │ ├── k8s-intro.md │ │ ├── k8s-ipvs.md │ │ └── k8s-office.md │ ├── ms-circuit-breaker-and-limit/ │ │ ├── README.md │ │ ├── sb-hystrix.md │ │ └── sb-limit.md │ ├── ms-config/ │ │ ├── README.md │ │ ├── cfg4j.md │ │ ├── consul-devops.md │ │ └── sb-config.md │ ├── ms-delivery/ │ │ ├── README.md │ │ ├── jenkins-devops.md │ │ ├── ms-cd.md │ │ └── ms-ci.md │ ├── ms-discovery/ │ │ ├── README.md │ │ ├── msd.md │ │ └── service-discovery.xml │ ├── ms-log/ │ │ ├── README.md │ │ ├── elk-devops.md │ │ ├── sb-eblk.md │ │ ├── sb-logback.md │ │ └── sb-trace.md │ ├── ms-monitor/ │ │ ├── README.md │ │ ├── k8s-prometheus-grafana.md │ │ ├── sb-prometheus.md │ │ ├── sb-sentry.md │ │ ├── sentry-devops.md │ │ └── sentry.txt │ ├── ms-msgq/ │ │ ├── README.md │ │ ├── dev-kafka.md │ │ ├── kafka-devops.md │ │ ├── rabbitmq-devops.md │ │ ├── rocketmq-devops.md │ │ ├── sb-kafka.md │ │ ├── sb-rabitmq.md │ │ └── sb-rocketmq.md │ ├── ms-storage/ │ │ ├── README.md │ │ ├── memcached-devops.md │ │ ├── mysql-devops.md │ │ ├── redis-devops.md │ │ ├── sb-memcached.md │ │ ├── sb-mysql.md │ │ └── sb-redis.md │ ├── spring-boot/ │ │ ├── README.md │ │ ├── discovery.md │ │ ├── gerrit.md │ │ ├── graceful-shutdown.xml │ │ ├── mockito.md │ │ ├── rest-nginx.xml │ │ ├── sb-gradle-structure.md │ │ ├── sb-mockito.md │ │ ├── sb-rest.md │ │ └── sb-thrift.md │ └── toolchain/ │ ├── README.md │ ├── bom.md │ ├── gerrit.md │ ├── kanboard.md │ ├── ldap.md │ ├── nexus.md │ ├── spring-boot-scripts.md │ ├── spring-boot-template.md │ └── stress-test.md └── src/ ├── README.md ├── SUMMARY.md ├── ch01-architecture/ │ ├── README.md │ ├── continuous-x.md │ ├── micro-service-intro.md │ ├── ms-arch.plantuml │ ├── ms-architecture.md │ ├── ms-tech-stack.md │ └── rd-ops-toolchain.md ├── ch02-ms-dev1/ │ ├── README.md │ ├── database1.md │ ├── database2.md │ ├── gradle.md │ ├── redis.md │ ├── rpc.md │ └── spring-boot.md ├── ch03-ms-dev2/ │ ├── README.md │ ├── circuit-breaker-and-limiter.md │ ├── config.md │ ├── mq.md │ ├── registry1.md │ └── registry2.md ├── ch04-ms-dev3/ │ ├── README.md │ ├── elkfk.md │ ├── micrometer.md │ ├── skywalking.md │ └── victorialmetrics.md ├── ch05-k8s/ │ ├── README.md │ ├── container.md │ ├── k8s-101.md │ ├── k8s-cluster.md │ ├── k8s-ha-cluster.md │ └── k8s-ingress.md ├── ch06-cd/ │ ├── README.md │ ├── jenkins-custom.md │ ├── jenkins-k8s-optimize.md │ ├── jenkins-k8s.md │ └── jenkins.md └── ch07-tools/ ├── .md ├── README.md ├── gitlab.md ├── jfrog-artifactory.md ├── ldap.md ├── microservice-template.md ├── registry2.md └── seafile.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ book .DS_Store ================================================ FILE: README.md ================================================ # 从0到1实战微服务架构(开源电子书) ## 前言 微服务是继SOA后,最流行的服务架构风格之一。 按照微服务对系统进行拆分后,每个服务的业务逻辑都更加简单、清晰。服务之间是松耦合的,模块之间的边界也更加清晰。 微服务有效降低了软件项目的业务复杂程度,为小团队独立开发、持续交付和部署打下了良好的基础。 遗憾的是,微服务并不是银弹。与传统的单一架构相比,微服务架构对团队的组织架构、技术水平、运维能力等方面,都提出了更高的要求。如果没有掌握得当的方法而生搬硬套,微服务架构只会会适得其反--降低项目的开发效率,这是本书的创作初衷之一。 在国内外的技术社区中,比较推崇现有开源方案,如"Spring Cloud全家桶"或者阿里开源的"Dubbo"。 上述框架通常已经实现了服务发现、配置、负载均衡、限流熔断,等微服务架构所必须的的核心功能。 使用开源框架省却了造轮子的过程,但也降低了我们学习、思考的动力。 为什么需要服务发现,又如何实现它呢?配置中心呢....思考和设计的过程充满了挑战,也是提升自身架构能力的一种手段。这是本书的创作初衷之二。 已有的微服务资料过于重视微服务的开发,忽略了微服务赖以生存的生态系统:工具链、自动化运维。可以说,离开了这两点的支持,微服务架构将难以落地。完善这两方面的思考和实战,是本书的创作初衷之三。 为此,我撰写了这本《从0到1实战微服务架构》。让我们"暂时忘掉"已有的、成熟的开源解决方案。尝试亲自动手,实现微服务架构的各个模块。 我们会从微服务开发、工具链、运维这三个角度,阐述微服务架构的实战方案。 如果本书帮助了你,欢迎在在[github](https://github.com/liheyuan/hands-on-microservices)加Star,但严禁用于商业用途!(参见本页底部版权声明) 由于能力水平所限,本书难免存在各种错误,恳请各位进行指正(Issue or PR),谢谢! ## 2.0前言 自从本书发布了1.0版本后,已过去2年多了。 技术圈又发生了很多变化,与本书密切相关的有: * Spring Boot 2.0 稳定版发布 * Kubernetes下的包管理项目“Helm”,正式加入CNCF基金会 为此,我开启了本书2.0版的写作计划。 由于gitbook项目已不在维护,我们改用改用[mdbook](https://github.com/rust-lang/mdBook)做为图书渲染工具。 本书的写作工具为[MarkText](https://github.com/marktext/marktext)。 写作水平有限,还请各位多提宝贵意见。 ## 读者基础 由于篇幅、精力所限,本书无法写成一本”零起点”教程。我假设读者具有至少2年的服务端工作经验,并且了解以下技术或原理: * Git * Maven & Gradle * Docker & Kubernetes * Java * Spring / Spring Boot * 数据库: 如MySQL * 消息队列: 如RabbitMQ * 缓存系统: 如Memcached * 内存数据库: 如Redis 本书可以供架构师、项目经理、高级服务端程序员参考、学习。 动手实战是本书的核心内容,因此本书所涉及的全部代码,都托管到了我的[Github上](https://github.com/liheyuan)(以lmsia-开头的项目)。 这些代码以研讨为主要目的,也可以直接应用于生产,但本人不对其稳定性负责。 ## 版权 本书虽然在github上公开写作,但版权归本人[Coder4](https://coder4.com)所有。 依照 [署名-非商业性使用-相同方式共享](https://creativecommons.org/licenses/by-nc-sa/2.5/cn/) ,任何人可以在保留署名的情况下转载。但严禁用于商业用途。 This is a book powered by [mdBook](https://github.com/rust-lang/mdBook). ================================================ FILE: book.toml ================================================ [book] authors = ["lihy"] language = "en" multilingual = false src = "src" title = "从0到1实战微服务架构(第2版)" ================================================ FILE: legacy/SUMMARY.md ================================================ # Summary * [从0到1实战微服务架构](README.md) * [架构概览](architecture/README.md) * [微服务架构概览](architecture/overview.md) * [运维技术链概览](architecture/devops.md) * [微服务技术栈概览](architecture/microservics.md) * [研发工具链概览](architecture/toolchain.md) * [Kubernetes快速入门](k8s/README.md) * [集装箱、容器化、容器编排](k8s/docker-k8s.md) * [Kubernetes 快速入门](k8s/k8s-intro.md) * [搭建Kubernetes集群](k8s/k8s-cluster.md) * [为Kubernetes集群开启ipvs](k8s/k8s-ipvs.md) * [使用Helm进行包管理](k8s/helm.md) * [办公网与Kubernetes集群的打通](k8s/k8s-office.md) * [Kubernetes集群的高可用方案](k8s/k8s-ha.md) * [微服务的自动发现与负载均衡](ms-discovery/README.md) * [微服务的自动发现与负载均衡](ms-discovery/msd.md) * 使用边车模式于微服务部署 * [微服务的开发框架](spring-boot/README.md) * [Gradle子项目划分与微服务的代码结构](spring-boot/sb-gradle-structure.md) * [Spring Boot整合Thrift RPC](spring-boot/sb-thrift.md) * [Spring Boot整合REST服务](spring-boot/sb-rest.md) * [Mockito 单元测试打桩神器](spring-boot/sb-mockito.md) * [微服务的存储与缓存](ms-storage/README.md) * [MySQL 数据库的运维](ms-storage/mysql-devops.md) * [Spring Boot整合MySQL](ms-storage/sb-mysql.md) * [Memcached 缓存服务的运维](ms-storage/memcached-devops.md) * [Spring Boot整合Memcached](ms-storage/sb-memcached.md) * [Redis 内存数据库的运维](ms-storage/redis-devops.md) * [Spring Boot整合Redis](ms-storage/sb-redis.md) * [微服务的消息队列](ms-msgq/README.md) * [RabbitMQ 消息队列的运维](ms-msgq/rabbitmq-devops.md) * [Spring Boot整合RabbitMQ](ms-msgq/sb-rabitmq.md) * [RocketMQ 消息队列的运维](ms-msgq/rocketmq-devops.md) * [Spring Boot整合RocketMQ](ms-msgq/sb-rocketmq.md) * [Kafka 流处理平台的运维](ms-msgq/kafka-devops.md) * [Kafka 流处理开发简介](ms-msgq/dev-kafka.md) * [微服务日志监控](ms-log/README.md) * [Spring Boot配置Logback日志](ms-log/sb-logback.md) * [Spring Boot整合分布式追踪](ms-log/sb-trace.md) * [ELK日志分析平台的运维](ms-log/elk-devops.md) * [Spring Boot整合EBLK日志分析平台](ms-log/sb-eblk.md) * [微服务平台监控](ms-monitor/README.md) * [Sentry 错误预警系统的运维](ms-monitor/sentry-devops.md) * [Spring Boot整合Sentry](ms-monitor/sb-sentry.md) * [Kubernetes + Prometheus + Grafana平台监控](ms-monitor/k8s-prometheus-grafana.md) * [微服务配置中心](ms-config/README.md) * [cfg4j及方案简介](ms-config/cfg4j.md) * [Spring Boot整合配置中心](ms-config/sb-config.md) * [微服务熔断与限流](ms-circuit-breaker-and-limit/README.md) * [熔断与Hystrix](ms-circuit-breaker-and-limit/sb-hystrix.md) * [限流的实现](ms-circuit-breaker-and-limit/sb-limit.md) * [微服务持续交付](ms-delivery/README.md) * [Jenkins平台的运维](ms-delivery/jenkins-devops.md) * [Jenkins持续集成](ms-delivery/ms-ci.md) * [Jenkins持续部署](ms-delivery/ms-cd.md) * [研发工具链](toolchain/README.md) * [LDAP 内部账号管理系统](toolchain/ldap.md) * [gerrit 代码的版本管理与评审](toolchain/gerrit.md) * [Nexus 私有maven仓库](toolchain/nexus.md) * [BOM 减少版本冲突](toolchain/bom.md) * [Spring Boot 项目模板](toolchain/spring-boot-template.md) * [开发效率脚本](toolchain/spring-boot-scripts.md) * [打压工具](toolchain/stress-test.md) * [运维工具链](devops/README.md) * [Docker 私有仓库](devops/docker-repo.md) * [Nginx REST 网关自动配置](devops/discovery.md) * * [OpenVPN访问Kubernetes集群内网](devops/openvpn-k8s.md) * [线上跳板机](devops/jump-server.md) ================================================ FILE: legacy/architecture/README.md ================================================ # 架构概览 当我们谈微服务架构时,我们需要关注哪些点,本章将从这里说起。在了解了微服务的整体架构后,我们会从“研发工具链”、“微服务技术栈”、“运维技术链条”三个角度展开。我们会讨论微服务架构中,如何对这三类问题进行技术选型以及做出这些选型决策的原因。同时,我们将探讨如何将这三部分有机地融入到微服务架构中。 ================================================ FILE: legacy/architecture/devops.md ================================================ # 运维工具链概览 在看过微服务整体架构后,我们来讨论下架构的各个层次中,本书所选用的技术栈。 与前面类似,我们依然自底向上讨论。 运维工具链选型 * 基础设施层:对于绝大多数的中小公司,且无强烈的数据保密需求,我强烈建议使用云主机。 * 运维成本更低。想对机器加64GB的内存,如果自运维的话跑去机房、断电、拆机器、插内存,半天过去了。如果采用云主机,就是点点鼠标,重启一下的事情。 * 弹性更大。老板一拍脑袋,明天要上线大促,预估要扩容10倍。只有一天时间,是不可能完成100台机器的采购、上架、配置的。大促结束后,流量回归到平常情况,这100台机器还要继续空转烧钱么?如果采用云主机,这些就是点点鼠标,甚至是几个API调用就可以搞定的事情。 * 平均稳定性更高。内存ECC故障、硬盘坏道、RAID卡故障,这些都是自运维服务器时代的家常便饭。若采用云主机,就可以很好的避免这些烦恼。常见的大厂云主机,都提供了至少3个9的在线时间保障,与运维服务器相比,平均稳定性更高。 * 运维平台层之容器管理:前面已经提到,微服务需要借助容器技术才能“顺利启航”。目前主流的方案有:docker加脚本、swarm集群、Kubernetes集群、Marathon集群。本书选用Kubernetes集群,它内置的功能完全可以满足”容器的监控、调度、管理“。在这里我们采用排除法,说说为什么不选用其他几个方案。 * docker加脚本:2014年Docker刚刚兴起时,还没有集群管理的解决方案,多数公司采用了这类架构,在不同物理机器上,通过自动化脚本来启动不同的容器。这种方式简单直接,但是当集群规模扩大后,运维非常困难。此外,这种方式很难解决自动网络配置、自动调度、容器数据迁移等很现实的问题。 * swarm集群:swarm集群方案是Docker公司于2014年末推出的容器集群技术方案。尽管swarm是Docker公司的“亲儿子”又是市场的先发产品,但swarm很快被后起之秀Kubernetes超越。时至今日,从的新功能跟进、社区活跃、文档完善程度等方面,都弱于Kubernetes。更重要的是,借助CNCF基金会的影响力,Kubernetes已经成为了事实上的容器集群解决方案,GCE、AWS等云平台,都原生支持Kubernetes集群。在2017年,Docker公司在自己的商业化产品中直接集成了Kubernetes,标志着swarm项目的全面落败。所以在本书中,我们也不会选用swarm集群。 * Marathon集群:Marathon是构建在Mesos集群上的一套容器集群管理软件。想使用Marathon,先要部署一套底层的Mesos。对于大型公司,特别是已经部署了Mesos的Hadoop集群的公司来说,这种搞法可以提升机器复用程度。但对于中小规模的企业而言,Mesos + Marathon多少有一些“”高射炮打蚊子“的感觉。此外,虽然Marathon在大规模机器管理上有比较多的经验,但并未在容器编排上投入太多新功能,更像是借助容器的概念为自己"造势"。综上所述,我们也不会选用Marathon集群。 * 运维平台之持续部署系统:部署前需要先构建,本书的微服务开发选用Spring Boot框架,在构建方面,我们使用Gradle(之后会阐述原因)。在持续部署方面,我们选用老牌工具Jenkins,通过一些插件和配置完成全套的持续部署。 * 运维平台之部署版本管理系统:前面已经提到,我们将采用容器技术。本书采用自建私有Docker仓库的方式,完成容器的镜像工作,并使用它作为部署版本的管理系统。 本书的主线是微服务的架构及开发。为了保证这一主题的的稳定和连惯性,我们将上述运维工具链的使用单独抽提出来,在[《运维工具链》](../devops/README.md)一章中介绍上述内容。 ================================================ FILE: legacy/architecture/microservics.md ================================================ # 微服务技术栈概览 下面来看一下微服务相关的技术选型 * 服务开发框架:在微服务开发方面,我们选用Java作为开发语言。市面上的语言众多,特别近几年来,Go、Rust语言作为服务端语言快速崛起。既然如此,我们为什么还要选用基于Java语言呢?尽管Go等语言崛起的很快,但相对依然小众,能够熟练运用这些语言进行生产开发的人才更是少之又少,从人才供给量上就无法满足业务需求。此外,新兴语言的社区相对活跃,版本变化较为频繁,经常出现各种不兼容升级,各种坑,这些都不利于业务的开发。反观Java,虽然大家都批评多年来一直是”八股文“,但一直保持向下兼容,且依然稳重求进。最终要的是,Java拥有”海量“的开发人员基础,人才供给不会成为瓶颈。因此,我们选用Java作为开发语言,并使用Spring Boot作为开发框架。Spring Boot作为Spring框架的一次"革命",可以说是为了微服务而生的,在本书的后续章节,大家会逐渐体会到选用Spring Boot的原因。 * 服务注册与发现:为了简化实现难度,我们将借助Kubernetes内置的服务和虚拟端口功能,来实现服务的注册与发现。换句话说,我们将服务注册与发现的能力,下推一层到运维平台层。对于微服务这一层,服务的注册与发现是完全透明的。 * 熔断与限流:微服务之间的调用链更加复杂,为了降低&隔离服务故障造成的影响,一般选用和熔断或限流策略。我们选用Hystrix作为熔断功能的基础库并进行了封装,Guava的RateLimit作为限流功能的基础库并进行了封装。 * 配置中心:这里主要关注配置的自动下发、自动更新和易维护性。我们采用git + cfg4j的模式实现配置中心。 * 后端组件 * RPC:目前主流的开源RPC框架是gRPC和Thrift。gRPC在设计理念上更为先进,且原生支持HTTP2,可以直接集成到客户端上。Thrift作为老牌RPC框架,历经多家公司的考验,已经非常成熟和稳定,且性能比gRPC更为出色。因此,我们选用Thrift作为本书的RPC框架。 * 关系型数据库:我们选用市场占有率最高的开源数据库MySQL。Spring Boot内置了多种数据库接入方式,我们会采用JPA和“裸写”DAO两种接入方法,以满足不同应用场景。 * 内存数据库:”让系统更快“是我们不断追求的目标。当基于磁盘的MySQL数据库不能满足性能需求时,我们选用Redis内存数据库。Redis是一款高性能的内存数据库,在官方的性能评测中,其QPS可达到十万级别[^1]。在接入组件方面,我们选用Redisson。与老牌的jedis等开源库相比相比,Redisson的优势在于将Redis操作与数据结构进行了有机的结合,可以用类似Java内置数据类型的操作方式轻松地使用Redis。 * 缓存:构建高性能的分布式系统,缓存是必不可少的。Memcached是经典的高性能分布式内存缓存系统,我们选用它作为后端缓存组件。只有后端组件是不够的,还需要与Spring Boot集成。常见的Java客户端有Spymemcached和xMemcached。由于xMemcached采用了NIO模型,我们选用它作为接入库。 * 消息队列:当系统的同步阻塞处理频繁出现性能瓶颈,甚至拖垮整个系统时,我们可以引入消息队列,将同步处理转为异步处理。消息队列就是为这种场景而设计的。目前比较主流的开源消息队列有Kafka、RabbitMQ等。从设计理念来看,Kafka是一个成熟的分布式流处理平台,更专注于海量消息和分布式拓展性。RabbitMQ则更加专注于消息队列,且兼容AMQP协议。结合我们的需求,选用RabbitMQ更为合适。虽然Spring内置了AMQP的集成方案,但使用起来略为繁琐。我们会以官方客户端为基础,自行构建一套工具类库。 * 日志:我们选用Spring Boot默认的logback作为日志记录系统;使用"EBLK"组合作为日志收集、分析系统。 * 监控: * 微服务监控:在微服务部署之后,我们需要对微服务和其所在的容器进行的健康状况进行监控。包括容器的内存、CPU、网络状况,以及微服务的GC等信息。我们选用Prometheus作为数据的收集和查询系统,Grafana作为监控可视化平台。我们会探讨如何向Prometheus发送自定义的监控数据。 * 业务异常报警:微服务监控可以帮助我们了解服务的健康情况,定位性能瓶颈。但对于系统的业务异常却无能为力。Sentry是一款错误追踪系统,可以帮助我们发现并定位逻辑异常。类似的,我们也会探讨如何集成Spring Boot与Sentry。 [^1]: [How fast is Redis?] (https://redis.io/topics/benchmarks) ================================================ FILE: legacy/architecture/ms-arch.xml ================================================ 7VtLd6M2FP41WnYOICTEEhzcLtpzek4WbZfYyDYz2HIJnjj99dULg41I7AnCDU1mMXAlQNzvu09hAGfb489lut/8xjJaAM/JjgA+AM/zYBDw/4TkRUlcj0AlWZd5pmWN4DH/h2qho6WHPKNPZxMrxooq358Ll2y3o8vqTJaWJXs+n7ZixflT9+madgSPy7ToSv/Is2qjpAQ5jfwXmq839ZNdR48s0uW3dckOO/084MGV/FPD27S+l57/tEkz9twSwQTAWclYpY62xxkthHJrtanr5j2jp3WXdFddc4GnLvieFgdar1iuq3qpdSHfhor5LoDx8yav6OM+XYrRZw4/l22qbaGHV3lRzFjBSnktXCHxj8ufqpJ9o60RLP/4iF4ALSt67H0J96QazjnKtrQqX/iU+oKQfEHqIk04jLV+nxv0sBMq2aaFXD0v1YRZn27eKI0faL2ZdQinqUMUjKhDf5o69MmIOkQGHeKCPyBe8IN1JV9SCVaMv1Fbu/jvA6sHfnqS/jjiEzx/f2wGT3dJEAjnII5AEoDIBfwlEwKiBMQJSDAIMYgf6ifxRauHnS+Ai1uLukCaI1Cdw3kO247t6AXGWpQW+XrHT5ccMcrlscAz53490gPbPMvEY4z8aRjmDEMI37v0SwY+nGJHmw/eAHwgr/Hhh7EfiEEExHMQOoJBcQxiX3AqDkEMxQHh5HJOiy0N5PnkVOOnwxE5FY7pY7g7iZRHmQHyIIkRCX/z6WxMxPCCS2I4BmJAS8RwTVnQbURwcZ+r4GgL2ANxEAbSeXAu+AJ/HoRCpxf/AVOILKVktTSmEEtCF6uBQHQvUcSGNIwYQPSHANGUhg0DIgLRAwiJNGIPRGTKIMIasntA2J8FvhfCQHja0NUhm/vk173u+81+qvxAtZ3dgx/YGj9knBZFAQZEpneD3Zffjsf5xJdEcA2pYpNFYpkj+G9nBB+cQ9h/28e4PrFEov7SYgCwebJXOxkeM5IQECgDhvQ/sScPoBjVjmg+HVS74R+ZKkbPlnPoT+8H9+ZC4ghohTVzk+X2DUUngdSJnsr4+EyCpwy5j/E1kGPXDuQ1lSxAbjJcEgpnrkJFNPt/IY3hVcaNLBl33VK14rRjhTQSEHIHLkI1ASTREVoljfw0HieLQ5RkvglO4i3gUN3erq/2r+3EDAKnvYKb2yiSDRdpuFHS35v7+Cj6sNOzN6BoKxs37nsMVnAnAjWOZuiDMGzZZChORQjGMii700ETepfBdFybtFd8Y9H4ipA0xblsgrWypTCWLhYLdKP2kKqGkOyhPYgcazJIo06704i071tC2l4ZjWQmlMjciFdAc50JidzZl1XuhFDEwVUourbsNTCgOKASx9l29hznQonQoETsBF0lDrHtbNxnnIISDdsn9pQ44saazAPCqLWf1t1h41HGFY36z401w8aa7xp8lK2NNdhfoA/ODJVAyCSDVwDi4NTzlxJVAXwyo5cZ0BuTGW5HszRb00d9yspqw9ZslxZJI73QQQsCesyrP4X4C9Jnf9UjO76w1pA4rce+0qp60d9vpoeKcVHz3F8Z2+t5aqViea+rmr8NO5TLepbOsqq0XNOqLetCUtIirfLv5/d/l3q7YWwA9bot5Sp92lWh/05t6Ut/Z7l0LZr4xOm0stwLRivM9HUXWj8t5Dog7BXJ3VDY+9XJKFXyarXylsZGZIYXGNnrXJk+FbG1ywDtVck34NkfwT4cnhDfF097tfANeMLp4InubJ+mqnh0PPu38D8cnvjO9ml1t/7NguFOgZTiHmCDcOEMlf93DBWaSkNrwFrbrj+7y3vA7sF6pOA7Bge6wXdUDvj97YH/OAdGCthjcKAbsMflgPtROTBSkB+DA90gPy4HPGtBvvvpVmfHcTLf6EB0zTc6IbIEYn+n44d7vci5+ec1XILkx7S+tGtlzonYeFQH5IrPa2/p/g6RiaHwAjeXBB3cIDJ8EItuB46fNj/RVh2t5ofwMPkX ================================================ FILE: legacy/architecture/overview.md ================================================ # 微服务架构概览 在正式讨论微服务架构前,有必要用简短的篇幅,讨论下微服务以及这种架构风格的优点和缺点。 在微服务出现之前,我们的架构多数是单体应用架构。会有一个或者少数几个”巨无霸“进程,里面可能包含了”用户管理“、”订单管理“、”支付确认“、”物流“等等各种复杂的业务逻辑和功能。这种传统的单块应用架构风格是很直观、自然的,然而在现代软件开发领域,特别是互联网开发领域中,单块架构遇到了一些问题。 单块架构的缺点 * 耦合严重:单块服务内的各个逻辑之间,往往缺乏清晰的边界。导致内部耦合严重,正所谓“牵一发而动全身”。 * 维护困难:单快服务包含了过多的业务,代码量严重膨胀。开发人员难免”失焦“,不知道如何下手。 * 团队协作困难:如果多人同时开发同一个单块应用,势必导致代码冲突成为常态,团队协作成本急剧上升。 * 测试困难:单块服务是作为一个整体进行开发、上线的。尽管你只对A功能进行了修改,但难免会影响B功能。随着单块应用的愈发膨胀,测试工作量会提升数倍。 上述单块应用的缺点,在传统软件开发中,尚可通过“小心规划”、“人海战术”等方法解决。但到了互联网时代,就很难实现了。为什么这么讲呢?互联网软件开发,讲究的是“快糙猛”,对迭代速度的要求非常高。对于成熟的互联网企业,一个项目在一天内上线好几次,都是稀松平常的事情。试想一下我们的单块“巨无霸”服务,稍微改动一点,就要经过复杂的代码评审、测试验证,如何才能跟上快节奏的迭代和上线呢? 尽管微服务不是银弹,但微服务的出现,确实从一定程度上改善了这种情况。微服务是⼀种架构⻛格,将单块应⽤划分成⼀组⼩的服务,服务之间相互协作,以组合的形式,实现复杂的业务功能。 微服务架构的优点 * 低耦合:在单块服务中,不同业务的逻辑耦合在一起。做微服务拆分后,微服务内只包含有限的业务逻辑,耦合也随之大大降低。 * 易维护:微服务内部只包含单一业务逻辑,功能更为集中,更容易开发人员聚焦问题和修改。 * 适合团队协作:拆分为微服务后,每个服务中涉及的代码和功能更少,可以将不同微服务划分给不同团队甚至个人负责。各司其职后,有效降低了开发和代码冲突,使得其适合团队协作。 * 测试成本低:在单块服务中,哪怕只改动了一点点代码,也需要对整个巨无霸服务进行测试。微服务拆分后,功能的修改,只需要涉及改动的个别微服务进行测试,有效降低了测试工作量。 * 易横向拓展:在单块服务时代,巨无霸服务已经占用了大量资源,机器的配置已经很高,若要再单独部署一个结点做负载均衡,成本会非常很高,所以多数情况下,都是通过[纵向拓展](http://blog.51cto.com/linuxgp/764622)的方式提升系统性能(如加内存,换个更好的cpu)。采用微服务拆分后,各个微服务占用的资源更少,可以轻松的通过增加节点的横向拓展方式,提升系统性能。更进一步的说,根据[阿姆达尔定律](https://zh.wikipedia.org/zh-hans/阿姆达尔定律),微服务拆分后,各个微服务对性能的要求并不一致,可以优先拓展那些具有性能短板的微服务,有效降低了拓展成本。 * 技术选择更多样:由于微服务是各自独立的进程。各个团队可以根据自己的需要选择不同的技术方案。然而在实践中,我并不推荐这么搞,这会在后面技术选型时展开。 * 加快迭代速度:上面已经提到,微服务低耦合、易维护、适合团队协作、测试起来成本更低,也更易于横向拓展。采用微服务架构后,可以显著的提升迭代速度。 尽管微服务具有这些优点,我想再次强调:“”微服务不是银弹“”,他解决了单一服务的软件复杂度,提升了迭代速度,但也带来了一些缺点。 微服务架构的缺点 * 运维难度加大:在单块架构中,不管改动多少需求,只需要上线一个服务。而采用微服务后,为了一个需求,可能要上线一堆服务。 * 开发能力要求高:在单块架构中,巨无霸服务的逻辑都在一个项目中。采用微服务后,逻辑分散在不同的项目中,在加上微服务架构本身引入的新技术,对开发能力提出了更高挑战。 * 调试难度更大:在单块架构中,我们只需要关注一个或少数几个服务。应用微服务架构后,后端系统演变为分布式系统,可能会出现A调用B,B调用C,甚至C还要调用D的长调用链,势必增加了调试和问题排查的难度。 幸运的是,通过容器等的新技术的引入,以及合理的架构设计,这些缺点都可以得到一定程度的缓解。本书会在后续章节对这些问题进行展开。 在讨论了微服务的优缺点之后,我们可以看一下微服务的整体架构了。 ![微服务整体架构](./ms-arch.png "微服务整体架构") 如上图所示,微服务架构可以大致分为五个层次,我们自底向上,逐层做一下解释。 * 基础设施层 * 微服务是后端服务,最终一定要部署在基础设施的某台机器上。基础设施层可以是自运维的服务器或机架,也可以选用云计算的虚拟机。 * 在这一层,我们重点关注底层“物理资源”[^1]的可用性及调整:计算资源(CPU 和 GPU)、存储资源(内存、硬盘)、网络资源(交换机、路由)。对这些基础设施的维护,或者是直接在机房维护,或者是操作云平台的API。 * 举几个例子:大促前预估系统容量不足,我们要加半个机架的机器;直播平台今晚要来大V,预计带宽不足,要增加带宽。这些都是在基础设施层要关注的内容。 * 运维平台层 * 对于后端服务的运维工作,持续交付是最重要的能力。由于微服务数量众多,一般需要构建一个持续交付系统,来完成微服务的自动运维,如微服务的初始化、发布、回滚、扩容。 * 采用微服务架构后,上线的次数、频率都会显著提升,这就需要一个上线的镜像版本管理系统,记录上线的镜像版本。在微服务架构中,一般采用容器技术来实现微服务的自动运维。 * 容器是轻量级资源,隔离能力相对较弱,我们需要对容器资源进行监控,调度和管理。举个例子:我们有三台物理机,现在物理机A和C各运行了20个容器,物理机B运行了22个容器,假设每个容器的资源占用完全一致,那么资源调度系统会自动地,将B的两个的容器调整到物理机A和C上。 * 微服务设施层 * 假设服务A需要调用服务B,那么A服务如何获取服务B的地址(例如IP和端口)呢?在传统的单块架构中,服务数量很少,尚可采用同在配置中写死IP、端口的方法来解决这个问题。但在微服务时代,面对动辄成百上千的微服务,这种做法将不再可行。因此,如何自动注册、发现微服务的多个实例,是架构必须解决的核心问题。 * 微服务拆分后,我们的系统从单机演化为分布式系统,为了防止分布式系统的雪崩效应[^2]、微服务设施需要有的熔断和限流的能力。 * 面对众多的微服务,逐一修改配置的方式显得更加笨拙,我们需要有一个中央配置平台,快速完成微服务的配置修改。 * 前面已经提到,微服务的分布式架构,会加大调试难度,所以日志的收集和预警显得更加重要,同时,我们可以根据业务日志来进行一些监控预警,这和平台层的容器监控预警是不一样的。 * 微服务需要集成一些后端组件,如数据库、消息队列、缓存等。 * 业务服务层:在提供了微服务的基础设施后,我们可以放手开发各个微服务了。业务服务层是一些“基础微服务”或“业务微服务”,他们“各司其职”,服务之间的耦合应当做到最低。 * 接入网关层:微服务面向的“用户”,一般是Web、移动端、PC端等。出于用户体验的考量,服务端提供给客户端的的接口应当尽量实现结果聚合,减少请求次数。因此,一般要设置一层接入网关,他负责调用业务微服务A B C等,完成结果聚合后,一并返回给客户端。 在绝大多数的公司中,业务规模不会很大,所需要的机器也非常有限,基础设施层往往会使用云平台或机房托管的外包方式完成。因此,本书将不对基础设施层做过多讨论。 [^1]: 由于云主机的虚拟化技术对我们的架构透明,我们也将其看作一种物理资源。 [^2]: [分布式应用雪崩效用](http://youyu4.iteye.com/blog/2405976) ================================================ FILE: legacy/architecture/toolchain.md ================================================ # 研发工具链概览 子曰:“工欲善其事,必先利其器”。前面已经提到,微服务架构的对开发水平提出了更高的=要求,我们更应该注重研发工具链的建设,以提高开发效率。 * 内部帐号管理:不论大小企业,无论企业性质,都需要一个集中式的帐号管理系统,员工只需要设置一次帐号密码,就可以方便地使用各个不同的系统。我们选用了经典的OpenLDAP 作为帐号管理服务器。OA、代码服务器、Jenkins等系统,都很方便地接入LDAP,实现“一套帐号,各系统共享”。 * 代码版本管理:从架构和团队协作模式考虑,在微服务架构下,git比svn更合适作为版本管理系统[^1]。GitLab和Gerrit都是经典的Git代码托管系统。GitLab类似于GitHub适合GitFlow的分支独立开发,Gerrit侧重于代码评审。考虑到代码评审的需求较为强烈,我们选用Gerrit。 * 依赖管理:无论什么开发语言,只要引入了开源库,就需要面对依赖管理的的问题。正如Python的Pip、Ruby的Gem、Node的npm,Java中使用Maven来管理库依赖。对于企业级开发,一般采用自搭建Maven私有仓库的方式,方便内部包的部署和依赖。我们选用Nexus搭建私有仓库,它被官方Maven仓库所采用,是Maven仓库的事实标准。 * 自动构建工具:既然使用了Maven的依赖管理,那么配套工具按理也应但选用Maven。然而在微服务的开发中,版本依赖比传统系统更为复杂,Maven的xml文件会变得非常难以维护。Gradle在兼容Maven依赖管理的基础上,使用了更为简洁的DSL描述语言,且构建速度更快,插件更为丰富。因此,我们选用Gradle 4.X作为微服务的构建工具。 * 效率脚本与工具:开篇介绍微服务优缺点时已经提到。微服务架构下,经常需要新增微服务。为了降低新增成本,我们一套代码层面的脚本或工具来提升效率。我们会介绍“微服务初始化模板”、“更新Thrift RPC接口”等工具,以提升微服务的开发效率。 本书的主线是微服务的架构及开发。为了保证这一主题的的稳定和连惯性,我们将上述研发工具链的使用单独抽提出来,在[《研发工具链》](../toolchain/README.md)一章中一并介绍。 [^1]: [Why is Git better than Subversion?](https://stackoverflow.com/questions/871/why-is-git-better-than-subversion) ================================================ FILE: legacy/devops/README.md ================================================ # 运维工具链 如果你一直从事研发职位,或很少接触运维岗位,可能会觉得将"运维"放到架构设计中,有一些多余。 事实上,上述想法大错特错,我们来看几个案例: 1. "刚才的上线,研发人员是从test分支打的jar包,引入了新的bug,赶快从主分支打个jar包,我要重新上线..." 1. "在我的本地跑的很好的啊,一上到线上就NPE,这线上环境有毒!" 1. "从一大早就开始上线,每次都要重新打jar包,拷贝、重启...郁闷的是有个顽固的bug,研发改了3次,我也上了3次,现在已经晚上9点了,还没上完..." 1. "这个服务周日就挂掉了,直到周一早上才被发现,客服电话都被打爆了!" 如果你曾在一些不重视运维技术的公司呆过,一定对上述场景颇有感触。 如果你恰好没有经历过上述场景,我推荐你读一下《凤凰项目:一个IT运维的传奇故事》。 相信读完后,你会对"运维部门"有更加深刻的理解。 运维并不是简单的"将代码推上线",他至少应当包含两个职能: 1. 尝试通过"自动化"、"可重用"、"稳定"的方式解决部署问题。即打造所谓的"持续部署"平台。 1. 维护公司内的基础设施,例如后端服务所需要的数据库、消息队列等组建。 1. 监控系统运行状况,尝试自动恢复故障,对潜在的故障作出预警。 看了上述介绍后,你还会觉得在"运维"是"架构设计"中可有可无的一部分么? 当然,本书并不是以运维作为核心主题的。因此,在本章中,我们将介绍运维工作与微服务架构关系最紧密的几个部分: 1. 后端组建的运维,包括Docker仓库、数据库、缓存、消息队列 1. 日志与监控,日志的收集、异常报警系统,平台监控系统。 1. 生产、测试环境的构建,如跳板机、机房打通等。 好了,让我们开启微服务架构下,运维工作的新篇章吧。 ================================================ FILE: legacy/devops/discovery.md ================================================ # Nginx REST网关自动配置 ================================================ FILE: legacy/devops/docker-repo.md ================================================ # Docker 私有仓库 在前面的章节中,我们使用了Kubernetes和容器技术实现了微服务的发现、负载均衡、持续部署等需求。 然而,我们并未提到Docker镜像的配置。默认的,我们使用了Docker官方默认的Docker镜像。 然而在实际工作中,我们最好使用Docker私有仓库。 想象一下,持续部署流程中,我们会将微服务的jar包自动构建,并打成Docker镜像,推送到Docker镜像服务器,然后部署到Kubernetes集群上。 想象下,如果我们使用默认的公共镜像,等于将自己的产品完全"开源"地暴露给了互联网。这里"开源"打了引号,虽然打成jar包后都是class文件,但是可以通过反编译工具轻松的解析到源代码,和开源是差不多的。 因此,与之前的[私有maven仓库](toolchain/nexus.md)类似,我们也需要一个私有的Docker仓库。 ## 启动私有仓库的Kubernetes服务 有意思的是,Docker私有仓库(Docker registry)本身也是一个Docker镜像。有没有鸡生蛋,蛋生鸡的感觉:-) 与之前所有的服务类似,我们也将Docker私有仓库部署在Kubernetes上。 首先,还是先创建物理机上的挂载点: ```shell sudo mkdir /data/registry/ sudo chmod -R 777 /data/registry/ ``` 然后创建部署, lmsia-docker-registry.yaml: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: lmsia-docker-registry-deployment spec: selector: matchLabels: app: lmsia-docker-registry replicas: 1 template: metadata: labels: app: lmsia-docker-registry spec: restartPolicy: Always nodeSelector: kubernetes.io/hostname: minikube containers: - name: lmsia-docker-registry-ct image: registry:2.6.2 ports: - containerPort: 5000 hostPort: 5000 volumeMounts: - mountPath: "/auth" name: volume subPath: auth - mountPath: "/var/lib/registry" name: volume subPath: registry env: - name: "REGISTRY_STORAGE_DELETE_ENABLED" value: "true" volumes: - name: volume hostPath: path: /data/registry/ ``` 在上面的描述文件中,进行了如下配置: * 创建了registry容器2.6.2版本,暴露端口5000 * 强制绑定到物理机"minikube"上,挂掉自动重启 * 支持删除镜像 应用一下,成功: ```shell kubectl apply -f ./lmsia-docker-registry.yaml ``` ## 向私有仓库发布镜像 在本书架构的应用场景下,私有仓库的使用场景是: * jenkins完成自动构建,并向私有仓库发布镜像 * 其他Kubernetes节点,从私有仓库拉取镜像,启动Pod 我们这里先完成第一步,我们登录minikube来模拟发布镜像: 首先登录私有仓库 ```shell minikube ssh $docker login localhost:5000 test pass ``` 需要说明的是,我们创建的私有仓库,默认有一个用户test/pass,如果你认为安全性不够的话,可以参考[官方文档](https://docs.docker.com/registry/deploying)自行修改,这里不再赘述。 还需要登录共有仓库 ```shell $docker login coder4 xxxxxx ``` 注意:这里共有仓库的登录步骤不可少,因为我们接下来需要在本地读取共有仓库的镜像。 然后我们编辑一个镜像Dockerfile: ```shell FROM alpine CMD sleep 3600 ``` 编译并发布到私有仓库上 ```shell $docker build -t alpine_test . $docker tag alpine_test $DR_DOMAIN/alpine_test:test_1.0 $docker push $DR_DOMAIN/alpine_test ``` 至此,我们已经发布到了私有仓库上,查询后,发现成功了: ```shell $ curl http://localhost:5000/v2/_catalog {"repositories":["alpine_test"]} ``` ## Kubernetes从私有仓库拉取镜像 对于Kubernetes集群而言,我们不太可能登录到每台机器上手工执行docker login。 幸运的是,Kubernetes为我们提供了解决方案。 创建一个regcred,相当于一个在集群内部通用的凭证: ```shell kubectl create secret docker-registry regcred --docker-server=192.168.99.100:5000 --docker-username=user --docker-password=pass --docker-email=lihy@coder4.com secret "regcred" created ``` 查看一下: ```shell kubectl get secret regcred --output="jsonpath={.data.\.dockerconfigjson}" | base64 -d {"auths":{"192.168.99.100:5000":{"username":"user","password":"pass","email":"lihy@coder4.com","auth":"dXNlcjpwYXNz"}}} ``` 下面,我们来创建一个使用这个私有仓库的Pod,看一下yaml ```yaml apiVersion: v1 kind: Pod metadata: name: lmsia-private-test spec: containers: - name: lmsia-private-test image: 192.168.99.100:5000/alpine_test:test_1.0 imagePullSecrets: - name: regcred ``` 如上,特殊的配置有: * 我们在image定义之前,增加了私服的前缀 * 最后增加了刚才配置好的imagePullSecrets apply之后,可以发现启动成功了。 ```shell kubectl get pods NAME READY STATUS RESTARTS AGE lmsia-docker-registry-deployment-569fd8b594-ldch2 1/1 Running 0 2m lmsia-private-test 1/1 Running 0 57s ``` 提醒一下,如果启动失败,并且错误原因是: ```shell Warning Failed 4s (x2 over 20s) kubelet, minikube Failed to pull image "192.168.99.100:5000/alpine_test": rpc error: code = Unknown desc = Error response from daemon: Get https://192.168.99.100:5000/v2/: http: server gave HTTP response to HTTPS client ``` 那么,请参考这篇文章进行解决[insecure repository in minikube](https://github.com/kubernetes/minikube/blob/master/docs/insecure_registry.md) 至此,我们完成了Docker私有仓库的搭建和访问。 ## 思考与拓展 * 在Docker Registry 2后,默认强制采用加密认证方式,请结合[这篇文章](http://tech.paulcz.net/2016/01/deploying-a-secure-docker-registry/),将私有仓库的部署改为加密方式。 ================================================ FILE: legacy/devops/jump-server.md ================================================ # 线上跳板机 ================================================ FILE: legacy/devops/openvpn-k8s.md ================================================ # OpenVPN访问Kubernetes集群内网 搭建好的Kubernetes集群中,默认是存在网络隔离的,即集群内部使用一套独立的网络,与物理网络相互隔离。 为了将内部服务暴露给外部调用,Kubernetes提供了ClusterIP等多种方式。 但在有的时候,我们能够从本地网络直接访问Kubernetes集群内网的所有结点: * 本地调试微服务时,可能要调用许多微服务,而这些微服务所部署的Pod或Service并没有配置对外暴露的Cluster IP * 我们需要登录Pod查看内存、日志等信息 实现这类功能,可以有两种方式: * 在本地网络和集群内网之间,配置路由协议,从而实现互联互通。 * 通过VPN的方式,打通物理网络和集群内网。 路由配置的方式看起来最直观,但有时难以实施: * 配置路由协议需要专业级的路由器、也需要学习路由的配置,设备采购成本、学习成本较大 * 如果本地和机房之间需要通过互联网而不是局域网访问,那么路由协议的配置需要运营商配合,基本无法实现 在本小节,我们主要介绍第二种方式,即通过VPN穿透的方式,从本地网络直接访问Kuberntes集群内网。 在深入技术细节前,我们先来了解下VPN的原理,如下图所示: ![VPN原理](./vpn.gif "VPN原理") 如上图所示,VPN通过互联网链接,建立其一条本地网络到远程内网之间的隧道。远程内网的含义是:有一台具有公网IP的网关,但内网本身没有暴露给公网。当VPN连接建立后,就可以直接从本地网络访问远程内网中的所有资源。 在本小节,我们选用OpenVPN,这是一个开源的VPN实现,选用理由有: * 客户端支持广泛:Windows, Linux, Mac, Android,iOS等几乎所有主流平台 * 传输层协议同时支持TCP和UDP * 协议流行,在一些软路由(如DD-WRT)上都有直接实现,方便了日后拓展 ## OpenVPN路由服务的配置 配置OpenVPN路由主要分为两大步骤: * 配置服务端 * 客户端连接 在配置OpenVPN服务前,我们再回顾一下这节的标题"OpenVPN访问Kubernetes集群内网",我们要做的是访问Kubernetes集群内网。 我们假设你已经架设了Kubernetes集群,并使用了calico网络模型。 我们先来看一下服务端的配置,在启动服务前,先要生成Open VPN所需要的密钥: ```shell #!/bin/bash VOLUME="$HOME/openvpn" vpn_ip="vpn.coder4.com" # init for first time only rm -rf $VOLUME mkdir -p $VOLUME docker run -v $VOLUME:/etc/openvpn --rm kylemanna/openvpn ovpn_genconfig -u udp://$vpn_ip -s 10.4.0.0/24 docker run -v $VOLUME:/etc/openvpn --rm -it kylemanna/openvpn ovpn_initpki ``` 如上所示,我们直接使用了封装好的[kylemanna/openvpn](https://github.com/kylemanna/docker-openvpn)这个Docker镜像。 * 上述请在集群的任意一台物理机上执行,这台物理机之后会作为OpenVPN的接入点,所以一定要有公网IP * 生成配置到本地HOME目录的openvpn文件夹 * vpn内网范围是10.4.0.0/24,这只得是VPN隧道所使用的网段,一定要注意,不要和已有物理网络、Kubernetes网络冲突。 * 通信协议是UDP,我们假设这台物理机有DNS指向vpn.coder4.com 生成配置后,就可以启动VPN服务器了: ```shell #!/bin/bash # submit to tool node NAME="openvpn" VOLUME="$HOME/openvpn" dns_ip="10.96.0.10" # stop & run server (should call init_open_vpn_test.sh before) docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --name $NAME \ --network bridge \ --dns $dns_ip \ -d \ -v $VOLUME:/etc/openvpn \ -p 1194:1194/udp \ --cap-add=NET_ADMIN \ --restart always \ kylemanna/openvpn \ ovpn_run --cipher AES-128-CBC ``` 如上所述,我们启动了VPN服务器: * 注意我们这里是直接用的docker启动,而非放到Kubernetes集群中 * 通过bridge即NAT的方式连接物理服务器,实际上在calico网络模型下,所有的Kubernetes集群内网IP都可以通过物理机访问,所以这里我们用了桥接的方式即可实现访问。 * 绑定到物理机1194的UDP端口上 * 加密算法用的AES-128-CBC 经过上述配置后,我们可以在本地通过netcat访问,如果能成功连接而没有"Connection Refused",就说明服务启动成功了。 ```shell nc -u vpn.coder4.com 1194 ``` ## 配置客户端 在服务端启动后,我们需要能够从客户端真正的建立OpenVPN隧道。 OpenVPN默认是支持多用户的(即可以有不同用户连接VPN),我们首先要为用户在OpenVPN系统中创建帐号, create_vpn_user.sh: ```shell #!/bin/bash if [ x"$#" != x"1" ];then echo "Usage: $0 " exit -1 fi USERNAME="$1" OVPN_FILE="$USERNAME.ovpn" CIPHER="AES-128-CBC" DNS_IP="10.96.0.10" ROUTE_CMD="route 192.168.0.0 255.255.0.0" VOLUME="$HOME/openvpn" # generate client cert for username docker run -v $VOLUME:/etc/openvpn --rm -it kylemanna/openvpn easyrsa build-client-full $USERNAME nopass docker run -v $VOLUME:/etc/openvpn --rm kylemanna/openvpn ovpn_getclient $USERNAME > $OVPN_FILE # post process sed -i 's/redirect-gateway.*$//' $OVPN_FILE cat >> $OVPN_FILE < -----BEGIN PRIVATE KEY----- xxxx -----END PRIVATE KEY----- -----BEGIN CERTIFICATE----- xxxx -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- xxxx -----END CERTIFICATE----- key-direction 1 # # 2048 bit OpenVPN static key # -----BEGIN OpenVPN Static key V1----- xxxx -----END OpenVPN Static key V1----- # add this line, the k8s network route route 192.168.0.0 255.255.0.0 # dns update #dhcp-option DNS 10.96.0.10 script-security 2 up /etc/openvpn/update-resolv-conf down /etc/openvpn/update-resolv-conf # security cipher AES-128-CBC ``` 如上所述: * 证书部分已经被省略 * 禁用lzo压缩 * 远程服务器地址是vpn.code4.com,协议是udp * vpn建立连接后,自动设置路由192.168.0.0/16,这个是Kubernets集群内网的路由 * 应用dns服务器10.96.0.10,这个也是Kubernetes集群默认的 生成文件后,我们在本地网络建立vpn连接: ```shell openvpn ./coder4.ovpn ``` vpn链接建立成功后,我们尝试ping一个Kubernetes集群内的地址(假设集群内已经启动了若干容器): ```shell ping 192.168.1.2 PING (192.168.1.2) 56(84) bytes of data. 64 bytes from 192.168.1.2: icmp_seq=1 ttl=48 time=8.39 ms 64 bytes from 192.168.1.2: icmp_seq=2 ttl=48 time=8.34 ms 64 bytes from 192.168.1.2: icmp_seq=3 ttl=48 time=8.41 ms ``` 访问成功!至此,我们通过OpenVPN的方式,成功打通了本地网络和远程Kubernetes集群的内网。 最后,还需要再说明两点: 1. Kubernetes网络模型很多,实现差别很大,本文所述的k8s + calico + docker bridge(nat)的方式 配合才能生效,其他网络模型和组合不保证能成功 1. 由于一些你懂的原因,如果openvpn的server和client之间的互联网跨国了,会被断开或者无法访问,UDP也不行,不过对于微服务的应用场景,影响不大,一般都是服务器部署在国内,开发人员也在国内。 ## 拓展与思考 1. 在k8s中,POD与Service IP一般不会分配在同一网段中。如果我们的客户端也想访问Service IP,应当在哪一步、增加哪些配置? 1. 本节通过OpenvVPN实现了从本地(单机)访问Kubernetes集群内网。如果想让本地局域网内,所有机器都可以访问远程Kubernetes集群的内网,应当如何配置呢? ================================================ FILE: legacy/k8s/README.md ================================================ ================================================ FILE: legacy/k8s/docker-k8s.md ================================================ # 集装箱、容器化、容器编排 ## 集装箱革命、容器化革命 前面已经提到,微服务架构离不开容器技术。为什么需要容器呢? 我们先来看一个海运的例子。 * 传统海运:尽管货海运已经出现了几千年,但直到20世纪中叶,货物运输依然是一种劳动力密集型工作。码头雇佣了数以万计的工人,将货物从岸上搬运到船舱中。由于货物的种类繁多,体积不一、传送带、铲车都不能根本的解决问题,货物装卸依然大量依赖人工,而且装卸时间大量占用了港口时间,装卸价格居高不下。 * 集装箱革命:20世纪50年代开始,集装箱逐渐走向商用的舞台。货物在岸上按照整齐的规格码放整齐,从而可以封装进集装箱。而装卸货物只需要机械来搬运集装箱即可,极大提高了港口的装卸效率。 集装箱革命使得货物的装卸成本从5美元/吨骤降到16美分/吨,节约了97%... 容器技术的兴起,也成为了运维领域的另一场"集装箱式革命": - 容器就是集装箱 - 货物则是运行与容器中的、各式应用程序。 容器为运维工作带来了如下革命性进展: - 容器的标准化:为了让应用程序在生产机上跑起来,经常需要做各种不同操作系统的安装,依赖软件库的安装、环境配置......这些过程非常繁琐,还经常会由于版本的细微差异,和开发环境不一致,出现“这个程序本地好好的,放到服务器上就崩溃”这类情况。容器可以使用统一的描述语言,快速构建出完全相同的、标准化环境,从而解决运行环境的问题。 - 容器的隔离化:此外,不同的应用程序需要不同的应用环境,如果都部署在一台物理机上,很可能会发生包、依赖冲突,导致无法运维,而容器可以为运行在同一台物理机上的应用程序,创建不同的隔离环境。 不仅在运维领域,容器技术在开发环境的搭建、软件教学等领域,也产生了深远的影响。如果你使用过Docker,相信一定对容器带来的变革深有感触,这里就不再一一列举:-) ## 容器、容器编排 如果你应用过容器技术,那么一定听说过该领域内最迟手可热的两个技术:Docker和Kubernetes。 同样都是容器化技术的代表,这两者有什么不同呢? 我们来看一下基本定义: - Docker:一种容器化引擎 - Kubernetes:一种容器服务的编排系统 Docker的定义比较好理解,它是一种容器的运行时环境。此外,Docker还提供了Dockerfile,这是一种运行环境描述语言。我们可以借助它来快速构建出应用程序所需的、可移植的环境。 Kubernetes的“容器编排”定位,就不是很好理解了,我们先来看一些应用容器技术时遇到的实际问题: - 如何部署一个应用的多个结点,并进行自动的负载均衡? - 应用不是孤立的,容器的运行也会有依赖关系,如何进行管理? - 如何在不影响业务运行的前提下,升级容器(内的应用)? - 如何在容器应用挂掉的时候,自动恢复故障? 上述问题,已经超越了容器引擎的管理范畴,这些就是“容器编排”系统所要考虑的事情了。 从上述例子我们不难看出,将Docker直接与Kubernetes来进行比较,并不科学,因为他们不是同一个范畴的技术。 实际上,容器引擎有很多种,除了最流行的Docker外,还有[rkt](https://coreos.com/rkt/docs/latest/)、[LXC](https://linuxcontainers.org/)等。从容器编排引擎的角度来看,无论是Docker、rkt还是LXC,都只是运行时引擎,都是可以相互替换的。 类似地,容器编排引擎也有很多种,除了最流行的Kubernetes外,还有[Swarm](https://docs.docker.com/engine/swarm/)、[MESOS](http://mesos.apache.org/)。只不过后两者的发展并不顺利,Kubernetes已经获得了容器编排领域的霸主地位。 前面谈了Docker与Kubernetes的许多不同,但他们也有一些共同点: - 都是基于容器技术 - 都是采用GO语言编写的 - 都是用yaml作为配置文件格式 - 都是开源社区项目 以Docker为代表的容器技术、以Kubernetes为代表的容器编排技术,也对微服务的发展也起到了推动作用。 试想一下如果你将一个单体服务拆分为10个微服务,部署、运维成本将陡然上升。如何解决这些问题呢?这恰好是容器、容器编排的用武之地。 从容器一下子跳到微服务,你可能还看的有些懵,不要紧,我会在本章接下来的章节为你逐步展开。 ## 思考与拓展 1. 如果你想了解更多关于容器、容器编排的事情,可以参考容器周刊上的一篇文章[《Kubernetes vs. Docker: A Primer》](https://containerjournal.com/topics/container-ecosystems/kubernetes-vs-docker-a-primer/) 2. 不同的容器引擎Docker、rkt等,各有什么优劣么? 3. Swarm与Docker出自同一公司。为什么Docker引擎成功了,Swarm编排引擎却被Kubernetes打败了呢? ================================================ FILE: legacy/k8s/helm.md ================================================ # 使用Helm进行包管理 通过前面的章节,我们已经学会了如何在Kubernetes上启动Pod、Deployment及Service。 我们再来简单回顾一下流程过程(以Service为例): 编写yaml文件,其中要包含如下信息: 1. Pod信息 2. Deployment信息 3. Service(ClusterIP)信息 然后通过kubectl应用。 如果你多部署几个Service,就会发现编写yaml是一个非常繁琐的过程,有没有方法可以简化这一操作呢? 答案当然是肯定的,我们可以引入包管理工具Helm。 Helm与Kubernetes的关系,类似于apt与Ubuntu的关系:你当然可以通过编译的方法,手动安装软件;但通过apt install安装软件更加简单方便。 我们先了解下Helm中的几个基本概念: - Chart:Helm中的软件包,类似于Ubuntu中的deb - Release:Chart的部署实例,在同一个Kubernetes集群中,同一个Chart可以部署多份。 - Repository:Chart的仓库,可以支持镜像。 下面,跟着我,一起体验Helm的强大之处吧: ## 安装Helm、设置镜像 Helm 3已经正式发布了,最显著的改进是:移除了被人诟病的Tiller,我们以3为例进行讲解。 如无特殊说明,本文的所有操作都在Kubernets的master节点上执行。 首先下载二进制包: ``` wget https://get.helm.sh/helm-v3.0.1-linux-amd64.tar.gz tar -xzvf helm-v3.0.1-linux-amd64.tar.gz ``` 解压缩后,得到helm文件,版本是3.0.1 ```bash ./helm version version.BuildInfo{Version:"v3.0.1", GitCommit:"7c22ef9ce89e0ebeb7125ba2ebf7d421f3e82ffa", GitTreeState:"clean", GoVersion:"go1.13.4"} ``` 由于众所周知的原因,我们切换到国内源: ```bash helm repo add stable http://mirror.azk8s.cn/kubernetes/charts/ ``` 添加后验证一下: ```bash ./helm repo list NAME URL stable http://mirror.azk8s.cn/kubernetes/charts/ ``` 至此,我们已经完成了helm的配置。 ## 使用Helm部署Memcached集群 由于本书还没有涉及Kubernetes的存储部分,我们无需存储的Memcached为例,讲解如何使用helm完成快速部署。 首先搜一下Charts: ```bash ./helm search repo memcached NAME CHART VERSION APP VERSION DESCRIPTION stable/memcached 3.2.1 1.5.20 Free & open source, high-performance, distribut... stable/mcrouter 1.0.2 0.36.0 Mcrouter is a memcached protocol router for sca... ``` 我们选择stable的memcached进行部署: ```bash ./helm install test-memcached stable/memcached ``` 其中test-memcached是service的名字,默认会部署一个3节点的,有状态的memcached集群 部署成功后输出如下: ```bash NAME: test-memcached LAST DEPLOYED: Thu Dec 12 17:46:36 2019 NAMESPACE: default STATUS: deployed REVISION: 1 TEST SUITE: None NOTES: Memcached can be accessed via port 11211 on the following DNS name from within your cluster: test-memcached.default.svc.cluster.local If you'd like to test your instance, forward the port locally: export POD_NAME=$(kubectl get pods --namespace default -l "app=test-memcached" -o jsonpath="{.items[0].metadata.name}") kubectl port-forward $POD_NAME 11211 In another tab, attempt to set a key: $ echo -e 'set mykey 0 60 5\r\nhello\r' | nc localhost 11211 You should see: STORED ``` 稍等一会,可以看一下Pod状态: ```bash kubectl get pods test-memcached-0 1/1 Running 0 25m test-memcached-1 1/1 Running 0 25m test-memcached-2 0/1 Pending 0 24m ``` 从上图可以发现,2个节点已经启动成功,第3个在Pending,这是因为我的集群中只有2台机器,并且memcached的Charts中规定了每台node上只能启动一台memcached。 如果你直接ping域名“test-memcached.default.svc.k8s.coder4.com”的话,会发现不通,这是因为在master节点上,并没有使用k8s集群内的dns服务器。 我们启动一个临时pod(tiny-tools)来验证一下域名: ```bash kubectl run -i --tty tiny-tools --image=giantswarm/tiny-tools --restart=Never -- sh $ ping test-memcached.default.svc.k8s.coder4.com PING test-memcached.default.svc.k8s.coder4.com (10.36.0.1): 56 data bytes 64 bytes from 10.36.0.1: seq=0 ttl=64 time=0.081 ms 64 bytes from 10.36.0.1: seq=1 ttl=64 time=0.061 ms 64 bytes from 10.36.0.1: seq=2 ttl=64 time=0.061 ms ..... ``` 在tiny-tools中,可以ping成功,我们再深入验证下域名解析: ```bash $ dig test-memcached.default.svc.k8s.coder4.com ; <<>> DiG 9.14.3 <<>> test-memcached.default.svc.k8s.coder4.com ;; global options: +cmd ;; Got answer: ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 18530 ;; flags: qr aa rd; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1 ;; WARNING: recursion requested but not available ;; OPT PSEUDOSECTION: ; EDNS: version: 0, flags:; udp: 4096 ; COOKIE: 02680bbf7152f858 (echoed) ;; QUESTION SECTION: ;test-memcached.default.svc.k8s.coder4.com. IN A ;; ANSWER SECTION: test-memcached.default.svc.k8s.coder4.com. 30 IN A 10.36.0.1 test-memcached.default.svc.k8s.coder4.com. 30 IN A 10.44.0.1 ;; Query time: 1 msec ;; SERVER: 10.100.0.10#53(10.100.0.10) ;; WHEN: Thu Dec 12 10:26:21 UTC 2019 ;; MSG SIZE rcvd: 196 ``` 这个域名解析到了两个POD上:10.36.0.1、10.44.0.1,与前面的启动符合。 如果我们在集群内,想通过域名访问某一台memcached,也是可以的: ```bash $ ping test-memcached-0.test-memcached PING test-memcached-0.test-memcached (10.44.0.1): 56 data bytes 64 bytes from 10.44.0.1: seq=0 ttl=64 time=0.053 ms 64 bytes from 10.44.0.1: seq=1 ttl=64 time=0.063 ms 64 bytes from 10.44.0.1: seq=2 ttl=64 time=0.063 ms ..... ``` 在Kubernetes集群外(比如master节点上),我们暂时只能通过IP来进行访问: ``` ping 10.44.0.1 PING 10.44.0.1: 56 data bytes 64 bytes from 10.44.0.1: seq=0 ttl=64 time=0.053 ms 64 bytes from 10.44.0.1: seq=1 ttl=64 time=0.063 ms 64 bytes from 10.44.0.1: seq=2 ttl=64 time=0.063 ms ``` 至此,借助Helm,我们只需一行命令,就完成了memcached集群的部署。 ## 拓展与思考 1. 如何在Kubernetes集群外,通过域名进行访问呢? 1. Helm可以部署有复杂依赖关系的多组服务么? ================================================ FILE: legacy/k8s/k8s-cluster.md ================================================ # 搭建Kubernetes集群 minikube是入门Kubernetes的优秀工具。使用minikube,可以轻松地在本地运行Kuberntes的主要功能。 为了演示方便,本书如有涉及到Kubernetes的章节,多数都是在minikube上运行的。 然而,minikube并不是为生产环境而设计的,当产品真正上线后,是需要部署到真正的Kubernetes集群中的。 在本节中,我们将探讨如何部署真正的Kuberntes集群。 ## 环境准备 正所谓“三人成群”,一般来说"集群"需要至少有3台机器。为了方便演示,我们也搭建一台具有3个物理机的Kuberntes集群,1台master,2台slave。 在搭建之前,需要做如下准备: * 3台物理机安装了Ubuntu 18.04系统[^1] * 3台物理机具有内网IP,假设为192.168.8.165 ~ 192.168.8.167 * 3台物理设置主机名为k1 ~ k3,通过hostname可以内网互通 * 3台物理机需要联网,但不需要有公网IP ### Docker环境安装 准备好上述条件后,我们在3台机器上分别安装Docker: ```shell sudo apt-get update && apt-get install -y apt-transport-https sudo apt install -y docker.io sudo systemctl start docker sudo systemctl enable docker ``` 如上所述,安装成功后,我们手动启动了docker,并将其加入开机自启动中。 接下来,我们将docker镜像切换到国内源 ```bash sudo vim /etc/docker/daemon.json ``` 添加如下内容 ```bash { "registry-mirrors": ["https://registry.docker-cn.com"] } ``` 并记得重启 ```bash systemctl restart docker ``` 如果你既不是使用root、也没有使用docker用户执行安装,默认是缺少一些权限的,我们添加一下: ``` # 将自己添加到docker组中 sudo groupadd docker sudo gpasswd -a ${USER} docker # 重启后重新load下权限 sudo service docker restart newgrp - docker ``` 再强调一次,3台机器上都要执行上述同样的操作。 至此,我们已经准备好了Docker环境。 ### Kubernetes的二进制安装 有了Docker后,我们来安装Kubernetes。 由于众所周知的原因,直接使用谷歌的镜像,是没法进行安装的,我们该用aliyun的镜像。 ``` sudo /etc/apt/source/apt.list ``` 尾部添加如下内容 ``` deb http://mirrors.aliyun.com/kubernetes/apt kubernetes-xenial main ``` 然后更新源 ``` sudo apt-get update ``` 报错了 ``` W: GPG error: http://mirrors.aliyun.com/kubernetes/apt kubernetes-xenial InRelease: The following signatures couldn't be verified because the public key is not available: NO_PUBKEY 6A030B21BA07F4FB ``` 不要慌张,我们只需要修复下PGP,先复制下出错PGP的后6位,然后执行: ``` gpg --keyserver keyserver.ubuntu.com --recv-keys BA07F4FB ``` 然后导入并添加,这次是完整PGP了: ``` gpg -a --export 6A030B21BA07F4FB | sudo apt-key add - ``` 之后再更新&安装,应该就不会有问题了 ``` sudo apt-get install -y kubelet kubeadm kubectl kubernetes-cni ``` 安装后,我们看一下当前版本,目前是1.16.3,这个版本号后面还会用到。 ``` kubelet --version Kubernetes v1.16.3 ``` 安装完毕后,我们需要关闭一下swap ``` sudo swapoff -a ``` 注意,上述关闭swap的方法,只针对本次启动有效。 如果想永久关闭swap,需要进行2步操作: ``` sudo vim /etc/fstab 禁用swapfile开头的那一行 ``` 接着 ``` sudo vim /etc/sysctl.conf # 添加如下行,保存重启 vm.swappiness = 0 ``` 保存、重启后,就可以了。 记得在3台机器上,都执行上述所有的操作。 ### Kubernetes的依赖镜像的准备 有了二进制文件后,还需要依赖的镜像才可以部署集群,这些镜像都在gcr.io上。 由于众所周知的原因,kubernetes启动集群时执行镜像下载会失败,我们需要将镜像提前安装好。 首先将镜像下载到本地,这里我们使用Azure提供的国内镜像(给世纪互联点赞!) 我们首先输入这条awk命令,会输出如下的docker开头的命令,然后执行这些命令: ``` kubeadm config images list --kubernetes-version v1.16.3 | awk -F "/" '{print "docker pull gcr.azk8s.cn/google_containers/"$2""}' docker pull gcr.azk8s.cn/google_containers/kube-apiserver:v1.16.3 docker pull gcr.azk8s.cn/google_containers/kube-controller-manager:v1.16.3 docker pull gcr.azk8s.cn/google_containers/kube-scheduler:v1.16.3 docker pull gcr.azk8s.cn/google_containers/kube-proxy:v1.16.3 docker pull gcr.azk8s.cn/google_containers/pause:3.1 docker pull gcr.azk8s.cn/google_containers/etcd:3.3.15-0 docker pull gcr.azk8s.cn/google_containers/coredns:1.6.2 ``` 镜像到本地了,但都是tag的模式,我们需要进行重命名,才能使用,同样的,执行docker开头的输出命令: ``` kubeadm config images list --kubernetes-version v1.16.3 | awk -F "/" '{print "docker tag gcr.azk8s.cn/google_containers/"$2" k8s.gcr.io/"$2""}' docker tag gcr.azk8s.cn/google_containers/kube-apiserver:v1.16.3 k8s.gcr.io/kube-apiserver:v1.16.3 docker tag gcr.azk8s.cn/google_containers/kube-controller-manager:v1.16.3 k8s.gcr.io/kube-controller-manager:v1.16.3 docker tag gcr.azk8s.cn/google_containers/kube-scheduler:v1.16.3 k8s.gcr.io/kube-scheduler:v1.16.3 docker tag gcr.azk8s.cn/google_containers/kube-proxy:v1.16.3 k8s.gcr.io/kube-proxy:v1.16.3 docker tag gcr.azk8s.cn/google_containers/pause:3.1 k8s.gcr.io/pause:3.1 docker tag gcr.azk8s.cn/google_containers/etcd:3.3.15-0 k8s.gcr.io/etcd:3.3.15-0 docker tag gcr.azk8s.cn/google_containers/coredns:1.6.2 k8s.gcr.io/coredns:1.6.2 ``` 至此,镜像已经可以直接使用了,但我们可以删除被tag的镜像,节省一些空间: ``` kubeadm config images list --kubernetes-version v1.16.3 | awk -F "/" '{print "docker rmi gcr.azk8s.cn/google_containers/"$2""}' docker rmi gcr.azk8s.cn/google_containers/kube-apiserver:v1.16.3 docker rmi gcr.azk8s.cn/google_containers/kube-controller-manager:v1.16.3 docker rmi gcr.azk8s.cn/google_containers/kube-scheduler:v1.16.3 docker rmi gcr.azk8s.cn/google_containers/kube-proxy:v1.16.3 docker rmi gcr.azk8s.cn/google_containers/pause:3.1 docker rmi gcr.azk8s.cn/google_containers/etcd:3.3.15-0 docker rmi gcr.azk8s.cn/google_containers/coredns:1.6.2 ``` 同样地,三台机器都要执行上述操作。 至此,我们已经安装好了部署Kubernetes所需的软件、镜像。 ## 集群初始化 在初始化Kuberntes集群前,我们先来解释一些重要参数: * API Advertise Address:这是Kubernetes Master对外提供交互和操作的接口,一般用内网IP地址 * Pod Network CIDR: 在Kuberntes上运行的所有Pod都需要分配IP地址,会从这个CIDR池中分配。 * Service CIDR: 类似Pod Network,Service分配的IP地址,会从这个CIDR池中分配 * Service DNS Domain: 集群内网的后缀,默认是cluster.local 了解了参数后,我们来初始化集群,在k1上执行: ```shell sudo kubeadm init --kubernetes-version v1.16.3 --apiserver-advertise-address=192.168.8.165 --service-cidr=10.96.0.0/12 --pod-network-cidr=10.200.0.0/16 --service-dns-domain=cluter.coder4 ``` 如上所述: * 我们选用k1即192.168.8.165这台机器作为master * pod的cidr是10.200.0.0/16 * service的cidr是10.96.0.0/12 (其实这也是k8s的默认值) * k8s内网的域名后缀是cluster.coder4 执行过程可能稍长,最后结果大致如下: ```shell ... Your Kubernetes control-plane has initialized successfully! To start using your cluster, you need to run the following as a regular user: mkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config You should now deploy a pod network to the cluster. Run "kubectl apply -f [podnetwork].yaml" with one of the options listed at: https://kubernetes.io/docs/concepts/cluster-administration/addons/ Then you can join any number of worker nodes by running the following on each as root: kubeadm join 192.168.8.165:6443 --token lcyhu1.ie6owlcotmrwiydv \ --discovery-token-ca-cert-hash sha256:4c345192970992063a0e704ef03ef831a48a368c686047bc32871334292ce091 ``` 前面打了...的地方,表示还有很长的输出,我们可以不用关心过程,只看最后这些结果。 首先我们按照提示执行配置命令: ```bash mkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config ``` 接着,我们部署网络组件,这里我们选用calico的最新版3.9 ```bash sysctl net.bridge.bridge-nf-call-iptables=1 -w wget https://docs.projectcalico.org/v3.9/manifests/calico.yaml sed -i -e "s?192.168.0.0/16?10.200.0.0/16?g" calico.yaml kubectl apply -f ./calico.yaml ``` 注意在上面,我们将calico的cird替换成了我们要使用的10.200.0.0/16,这一步不要忘记。 都执行完毕后,我们查看下集群状态: ```bash kubectl get nodes ``` 应该能输出当前master的状态: ```bash NAME STATUS ROLES AGE VERSION k1 NotReady master 17s v1.16.3 ``` 这里的NotReady是因为还没有Slave加入。 你可能还注意到了最后有一行"join ... token",先复制下来,后面会用到。 ## 加入Slave机器 初始化好集群后,加入Slave机器的工作就非常简单了,还记得前面初始化时生成的token命令行么,我们直接拷贝过来,在k2和k3上执行: ```shell sudo kubeadm join 192.168.8.165:6443 --token lcyhu1.ie6owlcotmrwiydv \ --discovery-token-ca-cert-hash sha256:4c345192970992063a0e704ef03ef831a48a368c686047bc32871334292ce091 ``` 加入成功后,我们回到Master机器即k8s1上查看集群状态: ```shell kubectl get nodes ``` ```bash NAME STATUS ROLES AGE VERSION k1 Ready master 3m37s v1.16.3 k2 Ready 105s v1.16.3 k3 Ready 91s v1.16.3 ``` 可以看到,集群具有3台机器,1个master、2个slave,部署成功! ## 集群重置 有的时候,我们可能执行了错误的命令,或者想直接重建集群。 此时,可以按照如下命令重置集群: 在集群的每台机器上分别执行: ```bash sudo ipvsadm --clear sudo kubeadm reset rm -rf /home/coder4/.kube/ ``` [^1]: 这里使用的发行版为Ubuntu最新的LTS版本18.04。其他Linux发型版也是可以,不过后续的安装、配置命令会有略微不同,这里不再赘述。 ## 拓展与思考 1. 前面提到了Kubernetes 提供了多种网络模型,他们的区别是什么,各自适用什么业务场景呢?请自行查找资料并回答。 1. 在使用minikube部署Pod时,有时需要创建Volume,我们都是直接创建的本地PV。在正式的Kubernetes集群下,创建Volume有什么不同么?请自行查找资料并回答。 ================================================ FILE: legacy/k8s/k8s-ha.md ================================================ # Kubernetes集群的高可用方案 高可用(High Availability)是指系统可以“无中断”地提供服务的能力。 Kubernetes作为容器调度和编排的“操作系统”,高可用显得尤为重要。 在之前的章节,我们搭建的都是“单主集群”,假设master节点挂掉,Kubernetes集群内容器的调度会受到影响 针对“单主集群”、“master节点挂掉”这种情况,Kubernetes已经做了一些处理:已启动的Pod还将继续运行,但挂掉的、新增的Pod将无法被调度。 可见,对于一个要求高可用的线上环境而言,上述的策略是远远不够的。 本节,我们将讨论两种Kubernetes集群的高可用(HA)方案。 ## Kubernetes集群的HA方案 构建Kubernetes集群的HA方案有很多种,我们首先介绍如何通过KeepAlived + HAProxy完成HA方案。 我们假设在独立部署的网络环境(非共有云)中有如下的6台主机: - k1 192.168.8.191 (master) - k2 192.168.8.187 (master) - k3 192.168.8.186 (master) - k4 192.168.8.188 - k5 192.168.8.190 - k6 192.168.8.189 - vip 192.168.8.10 如上所述,k1~k3是主节点,k4~k6是普通节点,而vip(virtual ip)使用ip: 192.168.8.10。 温馨提示,不要在共有云(阿里云、AWS)上尝试本小节的方案。 ### 高可用和负载均衡配置 我们首先在主节点安装keepalived: ```bash sudo apt-get install -y keepalived ``` 然后对k1进行配置: ```bash #sudo nano /etc/keepalived/keepalived.conf ! Configuration File for keepalived global_defs { router_id LVS_DEVEL } vrrp_script check_haproxy { script "killall -0 haproxy" # check process alive interval 3 weight -2 fall 10 rise 2 } vrrp_instance VI_1 { state MASTER # backup -> BACKUP interface eth0 # eth virtual_router_id 51 priority 250 # < 250 for backup server advert_int 1 authentication { auth_type PASS auth_pass 123456 # please change it for online } virtual_ipaddress { 192.168.8.10 # virtual ip not conflict with other } track_script { check_haproxy } } ``` 如上所述,我们将k1的keepalived配置了默认master,并将vip绑定到eth0端口上。 对于k2和k3,也要进行类似的配置,我们只需要将"state MASTER"修改为"state slave"即可。 三台机器配置完成后,记得启动服务并设为开机自启动: ```bash sudo service keepalived start sudo systemctl enable keepalived ``` 我们在k1上查看效果: ```bash ip address show eth0 2: eth0: mtu 1500 qdisc mq state UP group default qlen 1000 link/ether 00:16:3e:0a:b3:b4 brd ff:ff:ff:ff:ff:ff inet 192.168.8.191/24 brd 192.168.8.255 scope global dynamic eth0 valid_lft 315359417sec preferred_lft 315359417sec inet 192.168.8.10/32 scope global eth0 valid_lft forever preferred_lft forever ``` 如上所述,k1上的vip已经自动绑定到了eth0上,当k1挂掉后,k2和k3会竞争master,胜者获得vip。 关于为什么要配置vip,我们稍后会详细讲述。 接着我们安装haproxy: ```bash sudo apt-get install -y haproxy ``` k1上进行配置: ```bash sudo nano /etc/haproxy/haproxy.cfg #--------------------------------------------------------------------- # Global settings #--------------------------------------------------------------------- global # to have these messages end up in /var/log/haproxy.log you will # need to: # # 1) configure syslog to accept network log events. This is done # by adding the '-r' option to the SYSLOGD_OPTIONS in # /etc/sysconfig/syslog # # 2) configure local2 events to go to the /var/log/haproxy.log # file. A line like the following can be added to # /etc/sysconfig/syslog # # local2.* /var/log/haproxy.log # log 127.0.0.1 local2 chroot /var/lib/haproxy pidfile /var/run/haproxy.pid maxconn 4000 user haproxy group haproxy daemon # turn on stats unix socket stats socket /var/lib/haproxy/stats #--------------------------------------------------------------------- # common defaults that all the 'listen' and 'backend' sections will # use if not designated in their block #--------------------------------------------------------------------- defaults mode http log global option httplog option dontlognull option http-server-close option forwardfor except 127.0.0.0/8 option redispatch retries 3 timeout http-request 10s timeout queue 1m timeout connect 10s timeout client 1m timeout server 1m timeout http-keep-alive 10s timeout check 10s maxconn 3000 #--------------------------------------------------------------------- # kubernetes apiserver frontend which proxys to the backends #--------------------------------------------------------------------- frontend kubernetes mode tcp bind *:16443 option tcplog default_backend kubernetes-apiserver #--------------------------------------------------------------------- # round robin balancing between the various backends #--------------------------------------------------------------------- backend kubernetes-apiserver mode tcp balance roundrobin server k1 192.168.8.191:6443 check server k2 192.168.8.187:6443 check server k3 192.168.8.186:6443 check #--------------------------------------------------------------------- # collection haproxy statistics message #--------------------------------------------------------------------- listen stats bind *:1080 stats auth admin:awesomePassword stats refresh 5s stats realm HAProxy\ Statistics stats uri /admin?stats ``` 上述配置比较长,但关键点是将后台的6443端口映射到了haproxy的16443端口上。 经过映射,我们访问haproxy的16443,将会自动负载均衡到k1~k3的某一台的6443端口上。 我们将k2~k3执行同样的配置。 配置完成后,记得启用并设置为开机启动。 ```bash sudo service haproxy restart sudo systemctl enable haproxy ``` 你可能已经发现了,在haproxy的配置中,我们并没有绑定到vip的ip上,而是选择了bind *(绑定全部网络接口)。 虽然k1~k3机器都有16443端口的负载均衡,但如果你想在集群中通过一个固定ip来访问api server,只能使用vip这个固定ip,这也就是为什么要选用vip的原因。 接下来,我们将vip以"host域名"的方式,配置到k1~k6机器的host上: ```bash cat >> /etc/hosts << EOF 192.168.8.10 k8s-apiserver-vip EOF ``` 至此,所以准备工作已经就绪。 ### 多主Kubernetes集群配置 我们在k1上开始Kubernetes集群的配置 ```bash # nano cluster-config.yaml apiVersion: kubeadm.k8s.io/v1beta1 kind: ClusterConfiguration kubernetesVersion: v1.16.3 apiServer: certSANs: - "k8s-apiserver-vip" controlPlaneEndpoint: "k8s-apiserver-vip:16443" networking: podSubnet: "10.200.0.0/16" ``` 上述配置中,我们将api server设置为了api的域名。 启动集群 ```bash sudo kubeadm init --config=./cluster-config.yaml --upload-certs ``` 启动成功后,输出 ```bash Your Kubernetes control-plane has initialized successfully! To start using your cluster, you need to run the following as a regular user: mkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config You should now deploy a pod network to the cluster. Run "kubectl apply -f [podnetwork].yaml" with one of the options listed at: https://kubernetes.io/docs/concepts/cluster-administration/addons/ You can now join any number of the control-plane node running the following command on each as root: kubeadm join cluster.coder4:16443 --token vrvld4.vmo52532y6129ktg \ --discovery-token-ca-cert-hash sha256:3c0268be210a9bd736eebaf80cc6d052b7b317251b15b76b88ddaa223a5836c2 \ --control-plane --certificate-key 3c1890e1c1ceb1c64c7d1cd002e943e25c89f48c5b3b0e7d84d0879759e54545 Please note that the certificate-key gives access to cluster sensitive data, keep it secret! As a safeguard, uploaded-certs will be deleted in two hours; If necessary, you can use "kubeadm init phase upload-certs --upload-certs" to reload certs afterward. Then you can join any number of worker nodes by running the following on each as root: kubeadm join cluster.coder4:16443 --token vrvld4.vmo52532y6129ktg \ --discovery-token-ca-cert-hash sha256:3c0268be210a9bd736eebaf80cc6d052b7b317251b15b76b88ddaa223a5836c2 ``` 与之前的单master集群相比,这里的输出有两个join命令,其中前者是用于加入其他master节点的,而后者是用于普通节点加入的。 我们先继续k1上的配置 ```bash mkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config ``` 配置网络: ```bash wget https://docs.projectcalico.org/v3.9/manifests/calico.yaml sed -i -e "s?192.168.0.0/16?10.200.0.0/16?g" calico.yaml kubectl apply -f ./calico.yaml ``` 如果你对上述网络配置不熟悉,请参考[《搭建Kubernetes集群》](k8s-cluster.md)一节中的介绍。 接着在k2和k3执行master节点的加入: ```bash sudo kubeadm join cluster.coder4:16443 --token vrvld4.vmo52532y6129ktg \ --discovery-token-ca-cert-hash sha256:3c0268be210a9bd736eebaf80cc6d052b7b317251b15b76b88ddaa223a5836c2 \ --control-plane --certificate-key 3c1890e1c1ceb1c64c7d1cd002e943e25c89f48c5b3b0e7d84d0879759e54545 ``` 和配置 ```bash mkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config ``` 经过上述配置后,我们检查一下,发现3台master已经成功启动了: ```bash kubectl get nodes NAME STATUS ROLES AGE VERSION k1 Ready master 2m47s v1.16.3 k2 Ready master 90s v1.16.3 k3 Ready master 86s v1.16.3 ``` 上述kubectl命令可以在k1~k3任意一台机器执行。 对于k4~k6,我们执行普通节点的加入就好: ```bash sudo kubeadm join cluster.coder4:16443 --token vrvld4.vmo52532y6129ktg \ --discovery-token-ca-cert-hash sha256:3c0268be210a9bd736eebaf80cc6d052b7b317251b15b76b88ddaa223a5836c2 \ --control-plane --certificate-key 3c1890e1c1ceb1c64c7d1cd002e943e25c89f48c5b3b0e7d84d0879759e54545 ``` 经过上述操作,我们的集群启动完毕: ```bash kubectl get nodes NAME STATUS ROLES AGE VERSION k1 Ready master 42m v1.16.3 k2 Ready master 37m v1.16.3 k3 Ready master 9m39s v1.16.3 k4 Ready 3m25s v1.16.3 k5 Ready 3m18s v1.16.3 k6 Ready 3m15s v1.16.3 ``` 为了验证高可用,我们可以试着关闭一下k1,发现虽然节点离线了,但是集群依然可以正常工作,如下所示: ```bash kubectl get nodes NAME STATUS ROLES AGE VERSION k1 Ready master 3h37m v1.16.3 k2 NotReady master 3h33m v1.16.3 k3 Ready master 3m18s v1.16.3 k4 Ready 178m v1.16.3 k5 Ready 178m v1.16.3 k6 Ready 178m v1.16.3 ``` 至此,高可用Kubernetes集群已经搭建完毕。 ## 共有云下Kubernetes集群的HA方案 在上一小节的开始,我们提到了“不要在共有云尝试上述方案”,为什么呢? 如果你动手尝试过的话,就会发现keepalived在共有云上是行不通的,这是因为: - 禁用了ssrp,导致ip切换无法广播 - 网卡和ip是存在映射绑定关系的,无法动态添加virtual ip 上述限制主要是出于网络隔离性的安全性考量而产生的。 为了在共有云上部署HA的Kubernetes集群,我们只能另辟蹊径。 事实上,共有云多数提供了负载均衡服务(来替代keepalived),我们将直接使用它来搭建HA集群。 我们假设在阿里云上有6台主机: - 192.168.8.208 k1(master) - 192.168.8.207 k2(master) - 192.168.8.209 k3(master) - 192.168.8.211 k4 - 192.168.8.212 k5 - 192.168.8.213 k6 其中k1~k3依然作为master节点,其他节点作为普通节点,我们预留了192.168.8.210作为负载均衡的ip。 ### 启动HA集群 首先,我们将k1和k3的api-server-lb都指向k1: ```bash cat >> /etc/hosts << EOF 192.168.8.208 k8s-apiserver-lb EOF ``` 接着在k1上启动k8s集群: ```bash # nano cluster-config.yaml apiVersion: kubeadm.k8s.io/v1beta1 kind: ClusterConfiguration kubernetesVersion: v1.16.3 apiServer: certSANs: - "k8s-apiserver-lb" controlPlaneEndpoint: "k8s-apiserver-lb" networking: podSubnet: "10.200.0.0/16" ``` 初始化: ```bash sudo kubeadm init --config=./cluster-config.yaml --upload-certs ``` 启动成功过后,也是同样返回了两组节点加入命令: ```bash Your Kubernetes control-plane has initialized successfully! To start using your cluster, you need to run the following as a regular user: mkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config You should now deploy a pod network to the cluster. Run "kubectl apply -f [podnetwork].yaml" with one of the options listed at: https://kubernetes.io/docs/concepts/cluster-administration/addons/ You can now join any number of the control-plane node running the following command on each as root: kubeadm join k8s-ctl:6443 --token 3kmc18.dhw625244cp3fbc7 \ --discovery-token-ca-cert-hash sha256:d01e9b50221c690bad534e2ec7b0d6054052f4a016390268730d0b32f387650f \ --control-plane --certificate-key 849fff7f3df059ed87d91b82f00bdba5988268f9be08f6630eb6d3185438bd7c Please note that the certificate-key gives access to cluster sensitive data, keep it secret! As a safeguard, uploaded-certs will be deleted in two hours; If necessary, you can use "kubeadm init phase upload-certs --upload-certs" to reload certs afterward. Then you can join any number of worker nodes by running the following on each as root: kubeadm join k8s-ctl:6443 --token 3kmc18.dhw625244cp3fbc7 \ --discovery-token-ca-cert-hash sha256:d01e9b50221c690bad534e2ec7b0d6054052f4a016390268730d0b32f387650f ``` 我们先完成k1上的配置: ```bash mkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config ``` 初始化网络组件: ```bash wget https://docs.projectcalico.org/v3.9/manifests/calico.yaml sed -i -e "s?192.168.0.0/16?10.200.0.0/16?g" calico.yaml kubectl apply -f ./calico.yaml ``` k2、k3执行master加入操作: ```bash sudo kubeadm join k8s-ctl:6443 --token 3kmc18.dhw625244cp3fbc7 \ --discovery-token-ca-cert-hash sha256:d01e9b50221c690bad534e2ec7b0d6054052f4a016390268730d0b32f387650f \ --control-plane --certificate-key 849fff7f3df059ed87d91b82f00bdba5988268f9be08f6630eb6d3185438bd7c ``` 和配置: ```bash mkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config ``` 接着,我们在阿里云上配置一个私网的负载均衡器,设置如下: - IP: 192.168.8.210 - 对外暴露TCP 6443端口 - 默认服务器组k1、k2、k3,端口均为6443 - 健康检查间隔调整至最低 配置完成后,稍等几秒钟,执行telnet 负载均衡ip,发现成功连接: ```bash telnet 192.168.8.210 6443 Trying 192.168.8.210... Connected to 192.168.8.210. Escape character is '^]'. ``` 接下来,我们在k4~k6配置host为负载均衡的ip: ```bash cat >> /etc/hosts << EOF 192.168.8.210 k8s-apiserver-lb EOF ``` 然后作为普通节点加入: ```bash sudo kubeadm join k8s-ctl:6443 --token 3kmc18.dhw625244cp3fbc7 \ --discovery-token-ca-cert-hash sha256:d01e9b50221c690bad534e2ec7b0d6054052f4a016390268730d0b32f387650f ``` 目前在阿里云的负载均衡上有一些限制,当后端服务(k1~k3)自己连接负载均衡ip时,会出现无法连接的情况,而对于其他工作节点(k4~k6)是没有问题的。 对于上述问题,我建议在k1~k3上将上述apiserver修改为自己的ip,即 ```bash # vim /etc/hosts 192.168.8.207~209 k8s-apiserver-lb ``` 我们尝试关闭k2节点,然后验证一下高可用,成功: ```bash kubectl get nodes NAME STATUS ROLES AGE VERSION k1 Ready master 3h37m v1.16.3 k2 NotReady master 3h33m v1.16.3 k3 Ready master 3m18s v1.16.3 k4 Ready 178m v1.16.3 k5 Ready 178m v1.16.3 k6 Ready 178m v1.16.3 ``` 至此,我们完成了共有云环境下的Kubernetes集群的HA方案。 ## 拓展与思考 1. 出于安全性的考虑,Kubernetes集群的节点加入默认只有1个小时的时间窗口,超过这一时间后,如何才能加入集群呢? 2. 除了keepalived、负载均衡,你还知道其他Kubernetes集群的HA方案么? ================================================ FILE: legacy/k8s/k8s-intro.md ================================================ # Kubernetes 快速入门 ## Kubernetes中的基本操作单元 为了适应复杂的业务需求,Kubernetes中内置了不同层级的操作单元: * Pod: Pod是Kubernetes的基本操作单元,也是应用运行的载体。如果你了解Docker的话,可以理解为Pod = 若干紧密相连的Docker + 数据卷。Pod中可能包含若干容器,它们是无法进行更细粒度的分割的,例如:微服务和它的日志收集进程。Pod内部的这些容器共享相同的资源(网络、进程通信、数据卷) * Replica Set:高可用、高性能是分布式系统中常见的问题。一般都可以采用增加冗余节点的方式解决,Replica Set通过标签关联Pod,并可以设置一个副本数,以实现微服务的冗余。 * Deployment: Deployment描述了一个部署。在Kubernetes中,并不推荐直接启动Pod,也不推荐使用Replica Set,而建议直接使用Deployment。通过在Deployment中描述所期望的Pod、版本和副本数量,就可以实现管理、滚动升级、回滚、扩容、缩容等复杂的操作。Deployment与Pod并非是包含关系,而是相互独立的。Deployment通过“标签匹配”,可以关联若干Pod。 * Service: 从字面意义理解,Service就是服务组。类似的,Service也是独立于Pod、Deployment概念。它也是通过“标签匹配”的方式关联若干Pod,并对外提供了统一的服务代理。通过访问统一服务代理,流量被自动分发到所有关联的Pod上,服务代理可以根据不同策略,进行负载均衡。如果你仔细阅读了[微服务架构概览](architecture/overview.md),就会明白,Kubernetes的Service就是服务发现的一种实现方式。 ## minikube Kubernetes提供了强大的集群管理功能,当然,它的集群环境的配置较为复杂,并非简短篇幅可以说清楚。 本书的核心是微服务架构,而非Kubernetes的使用,因此,我们不会详细讲解k8s集群的配置。 幸运的是,k8s为我们提供了minikube,它一个用于快速开发的单机k8s环境,拥有与k8s集群完全相同的功能。本章的剩余章节,我们将使用minikube来进行讲解。 关于minikube的安装,可以参考官方的这篇[minikube安装教程](https://kubernetes.io/docs/tasks/tools/install-minikube/),这里不做详细展开。 需要特别指出:minikube只限于开发和学习使用。对于生产环境,请务必配置Kubernetes的分布式集群,大家可以参考[官方文档](https://kubernetes.io/docs/home)。 ## Hello Deployment minikube安装妥当后,让我们来部署第一个Deployment。 首先,启动minikube。第一次启动需要下载ISO镜像,时间较长,请耐心等待一下。 ```shell minikube start --disk-size 50g --memory 4096 --insecure-registry "192.168.99.0/24" ``` 上述第一次start实际是配置了minikube虚拟机的参数,我们简单解释一下: * disk-size 磁盘空间我们给了50g。我们之后会配置私有Maven仓库,需要建立主仓库索引,默认的20g不太够用。 * memory 内存,给了4G,可以根据你的需求自己设定。 * insecure-registry 我们之后会搭建不带证书的私有仓库,所以这里预先设置好仓库的IP范围。 提醒一下,上述minikube参数只对第一次启动(实际是创建)生效,一旦虚拟机生成完毕,这些参数的修改都不会生效了。 Kubernetes支持两种操作方式:命令行参数、yaml文件定义。鉴于维护性等角度,我们更推荐推荐后者,即用yaml文件的方式。 Deployment描述文件,lmsia-abc-server-deployment.yaml ```yaml apiVersion: apps/v1 // Deployment kind: Deployment metadata: name: lmsia-abc-server-deployment spec: selector: matchLabels: app: lmsia-abc-server replicas: 2 // Pod define template: metadata: labels: app: lmsia-abc-server spec: containers: - name: lmsia-abc-server-ct image: coder4/lmsia-abc-server:1.0 ports: - containerPort: 8080 - containerPort: 3000 ``` 我们来解读一下这个yaml文件,采用自底向上的步骤: * Pod定义:如注释标记,文件的下半部分定义了Pod信息。  * metadata.labels.app是Pod的标签名,用于与Deployment、Replica Set和Service做关联。 * spec.containers定义了Pod的名字(name)、镜像(image)和开放端口(ports)。这里使用了我预先编译好的一个微服务镜像,它集成了REST服务和RPC服务,分别监听8080端口和3000端口。 * Deployment定义:文件的上半部分,是部署的定义。 * kind类型是Deployment * metadata.name是Deployment的名字,用于后续的进一步操作 * replica是副本数定义,这里我们的副本数(replicas)设定为2。 * selector.matchLabels定义了与Pod的关联,请注意selector.matchLabels与Pod中的metadata.labels需要保持一致,才能成功关联。 理解了文件内容后,让我们来新建这个部署: ```shell kubectl apply -f ./lmsia-abc-server-deployment.yaml ``` 我们来看看启动了哪些Pod。这里我们以标签为查询参数。 ```shell kubectl get pods -l app=lmsia-abc-server NAME READY STATUS RESTARTS AGE lmsia-abc-server-deployment-bd4949ff9-jcczg 1/1 Running 0 10m lmsia-abc-server-deployment-bd4949ff9-zqsvc 1/1 Running 0 10m ``` 不难发现,启动了两个Pod,和我们在yaml文件中设定的副本数一致。 注意:上图展示的是最终结果,Pod的启动前需要先拉取镜像,因此会存在“非Running”的中间状态。 截至目前,我们已经通过Deployment的方式,成功的启动了两个Pod。前面已经介绍,镜像中的微服务对外暴露了两个端口:REST(HTTP)服务的8080端口,和RPC服务的3000端口。接下来,我们尝试访问Pod内的HTTP服务。 先来获取一下IP地址,以第一个Pod为例: ```shell kubectl describe pod lmsia-abc-server-deployment-bd4949ff9-jcczg Name: lmsia-abc-server-deployment-bd4949ff9-jcczg .... Status: Running IP: 172.17.0.5 Controlled By: ReplicaSet/lmsia-abc-server-deployment-bd4949ff9 .... ``` 由于结果较多,这里只截取了关键的几行,可以从结果中看到,名字为“lmsia-abc-server-deployment-bd4949ff9-jcczg”的Pod,它的IP是“"172.17.0.5"。 尝试访问一下,会报"No route to host"错误: ```shell curl http://172.17.0.5:8080/lmsia-abc/api/ curl: (7) Failed to connect to 172.17.0.5 port 8080: No route to host ``` 为什么会这样呢?因为我们启动的minikube集群实际是一个虚拟机,172.17.0.5是虚拟机的内网地址。我们执行命令的命令行,是在虚拟机外部。相当与我们从外网要访问内网地址,这自然是无法成功访问的。 要说明的是,我们这里并没有访问跟路径,而是访问了“lmsia-abc/api/”,大家可以暂且认为这是一个合法的url pattern,我们将在微服务开发中对此进行讲解。 如何解决呢,有两种方案: * 登录到虚拟机上,再访问 * 打通内网和外网 其中,第二种方案是日常工作中常见的需求,我们在OpenVPN + NAT 打通办公网与IDC](devops/openvpn-nat.md)一节中,详细介绍了一种方案。在此处,我们先采用第一种方案。 登录到minikube虚拟机很简单: ```shell minikube ssh $ ``` 注意:若需要登录到minikube虚拟机后再执行的操作,我们会增加一个$符号,以便区分。 登录到minikube虚拟机后,再次尝试Pod上的HTTP服务,可以成功访问了: ```shell $curl http://172.17.0.5:8080/lmsia-abc/api/ Hello, REST ``` 至此,我们成功的创建了Deployment、查看了Pod的信息、访问了Pod上的Rest服务。 最后,我们学习下如何删除Deployment。在虚拟机环境下,是没有kubectl可以使用的,所以首先要退出虚拟机。 ```shell $exit ``` 然后再来删除Deployment,命令可以成功执行: ```shell kubectl delete deployment lmsia-abc-server-deployment deployment.extensions "lmsia-abc-server-deployment" deleted ``` 再来看一下相关的Pod信息,发现已经找不到对应Pod了: ```shell kubectl get pods -l app=lmsia-abc-server No resources found. ``` ================================================ FILE: legacy/k8s/k8s-ipvs.md ================================================ # 为Kubernetes开启ipvs 通过前面的章节,我们已经学会了如何部署一个真正的Kubernetes集群。 此外,你可能也听说过,Kubernetes内置了Service、Deployment等机制,原生支持了负载均衡。 Kubernetes的负载均衡支持iptables、ipvs两种方案。 两种负载均衡方案相比:ipvs不仅提供了更好的可拓展性,更高的性能,也支持服务器健康检查和重连功能。 然而,Kubernetes默认采用iptables方案,我们在本节探讨如何在k8s中启用ipvs负载均衡。 ## 内核模块加载 ipvs依赖一些内核模块,我们先要进行加载 ```bash sudo modprobe ip_vs sudo modprobe ip_vs_rr sudo modprobe ip_vs_wrr sudo modprobe ip_vs_sh sudo modprobe nf_conntrack_ipv4 ``` 如果你不想每次都执行这些操作,也可以加入到/etc/modules文件中 ## 创建一个Kubernetes集群 我们先使用常规方法创建一个集群,它依然是应用iptables来做负载均衡,稍后我们会修改这一点。 如果你还不知道如何创建集群,可以参考[搭建Kubernetes集群](ms-discovery/k8s-cluster.md) 搭建后,你可以验证一下当前的负载均衡技术,在主节点上执行: ```bash docker ps | grep proxy ``` 获得id后,查看日志,例如我本地的是72a851c1f395 ```bash docker logs -f 72a851c1f395 W1126 11:49:10.647711 1 server_others.go:329] Flag proxy-mode="" unknown, assuming iptables proxy ``` 可以看到目前采用的是iptables ## 修改为ipvs负载均衡 我们依然在主节点上执行: ```bash kubectl edit cm kube-proxy -n kube-system ``` 找到"mode"一项,默认应该是空的,修改为"ipvs"。 修改完毕后,不会自动生效,我们手动重启下kube-proxy节点: ```bash kubectl get pod -n kube-system | grep kube-proxy |awk '{system("kubectl delete pod "$1" -n kube-system")}' ``` 稍等几秒钟,所有pod会自动重启,我们验证一下: ``` kubectl get pod -n kube-system |grep kube-proxy kube-proxy-8zh86 1/1 Running 0 60s kube-proxy-dp9lj 1/1 Running 0 58s kube-proxy-zd8xn 1/1 Running 0 52s ``` 启动成功后,我们再次找到本地的kube-proxy的docker ```bash docker logs -f be8714395739 .... I1206 11:03:38.105777 1 node.go:135] Successfully retrieved node IP: 192.168.8.168 I1206 11:03:38.105820 1 server_others.go:176] Using ipvs Proxier. W1206 11:03:38.106134 1 proxier.go:420] IPVS scheduler not specified, use rr by default .... ``` 可以看到,已经成功切换到ipvs负载均衡。 我们也可以通过ipvsadm验证一下: ```bash sudo ipvsadm IP Virtual Server version 1.2.1 (size=4096) Prot LocalAddress:Port Scheduler Flags -> RemoteAddress:Port Forward Weight ActiveConn InActConn TCP k1:https rr -> k1:6443 Masq 1 5 0 TCP k1:domain rr -> 10.32.0.2:domain Masq 1 0 0 -> 10.32.0.3:domain Masq 1 0 0 TCP k1:9153 rr -> 10.32.0.2:9153 Masq 1 0 0 -> 10.32.0.3:9153 Masq 1 0 0 UDP k1:domain rr -> 10.32.0.2:domain Masq 1 0 0 -> 10.32.0.3:domain Masq 1 0 0 ``` ## 拓展与思考 1. ipvs支持多种负载均衡策略,在Kubernetes集群中,如何调整这些策略呢? 2. 想较于iptable,为什么ipvs可以提供更好的性能呢? ================================================ FILE: legacy/k8s/k8s-office.md ================================================ # 办公网与Kubernetes集群的打通 通过前面的章节,我们已经学会了Kubernetes集群的搭建、优化、部署应用。 在本节中,我们将讨论另一个常见的场景:打通办公网与Kubernetes集群。 在测试或者开发环境,经常会有这种需求: - 从开发机直接调用k8s集群中的微服务的rpc接口 - 从开发机直接访问k8s集群中的数据库 然而在默认情况下,办公网络和Kubernetes的pod(service)网络时不互通的。 如果只有少量的HTTP服务,我们可以通过ingress来解决问题。但对于mysql、redis、rpc接口等,就无法通过ingress了,只能用NodePort,当微服务数量不断扩大时,端口将变得难以维护。 对于这种需求,可行的方案并不多,我们将通过vpn来打通办公网和Kubernets集群的网络。 此外,由于绝大多数场景中,只需要从办公网访问Kubernetes集群,所以我们只讨论这种单向打通。 ## 从办公网访问Kubernetes集群的Pod IP 首先,你需要有一个Kubernetes集群(calico网络组件),我们假设集群有4个节点: - k1: 192.168.8.174,主节点 - k2: 192.168.8.175,普通节点 - k3: 192.168.8.176,普通节点 - k4: 192.168.8.177,普通节点(不调度pod) 另外,我们的Kubernetes集群部署在共有云上,至少要保证k4节点有公网IP。 我们查看下状态: ```bash kubectl get nodes NAME STATUS ROLES AGE VERSION k1 Ready master 7m35s v1.16.3 k2 Ready 72s v1.16.3 k3 Ready 48s v1.16.3 k4 Ready 37s v1.16.3 ``` 我们看一下pod的网段: ```bash kubectl cluster-info dump | grep -m 1 cluster-cidr "--cluster-cidr=10.200.0.0/16", ``` 再看一下service的网段: ``` kubectl cluster-info dump | grep -m 1 service-cluster-ip-range "--service-cluster-ip-range=10.96.0.0/12", ``` 上述两个网段,下面会用到。 我们会将vpn部署在k4上,所以先要打一个标记,不调度Pod到k4上: ```bash kubectl taint nodes k4 forward=k4:NoSchedule ``` 接下来,我们将在k4上部署openvpn。 由于众所周知的原因,我这里不详述openvpn配置了,但请注意一下几点: - 假设vpn的网络段是192.168.6.0/24 - openvpn可以直接用docker启动,并需要绑定到k4机器的公网ip的端口上 此外,客户端的ovpn配置中,要包含以下几行: ```bash # For Inner Network (Can Be Done at openwrt also) route 192.168.6.0 255.255.255.0 route 192.168.8.0 255.255.255.0 route 10.96.0.0 255.240.0.0 route 10.200.0.0 255.255.0.0 ``` 这4个IP段的配置,分别表示了vpn、k8s物理机、pod ip、service ip。 vpn隧道建立连接后,我们在办公网机器ping一下k8s集群中的pod: ```bash # ping ping 10.200.0.2 PING 10.200.0.2 (10.200.0.2): 56 data bytes 64 bytes from 10.200.0.2: icmp_seq=0 ttl=61 time=10.682 ms 64 bytes from 10.200.0.2: icmp_seq=1 ttl=61 time=10.746 ms 64 bytes from 10.200.0.2: icmp_seq=2 ttl=61 time=10.970 ms ``` 至此,办公网 -> k8s的IP访问已经打通。 温馨提示:如果你觉得让开发每次挂openvpn不方便: - 可以将openvpn直接部署到软路由上。 - 如果你的k8s部署在共有云上,也可以使用云厂提供的vp专线(接到出口路由上)替换openvpn。 ## 打通DNS Kubernetes内置了DNS服务,可以通过域名来访问Service。 我们假设通过helm在k8s中部署了一套memcached服务。 首先获取一下dns的地址: ```bash kubectl get svc -n kube-system |grep kube-dns kube-dns ClusterIP 10.96.0.10 53/UDP,53/TCP,9153/TCP 39m ``` 在vpn隧道建立连接后,从办公网尝试直接dig: ```bash dig @10.96.0.10 test-memcached.default.svc.cluter.coder4 ; <<>> DiG 9.10.6 <<>> @10.96.0.10 test-memcached.default.svc.cluter.coder4 ; (1 server found) ;; global options: +cmd ;; Got answer: ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 56004 ;; flags: qr aa rd; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1 ;; WARNING: recursion requested but not available ;; OPT PSEUDOSECTION: ; EDNS: version: 0, flags:; udp: 4096 ;; QUESTION SECTION: ;test-memcached.default.svc.cluter.coder4. IN A ;; ANSWER SECTION: test-memcached.default.svc.cluter.coder4. 30 IN A 10.42.0.1 test-memcached.default.svc.cluter.coder4. 30 IN A 10.36.0.1 test-memcached.default.svc.cluter.coder4. 30 IN A 10.44.0.1 ;; Query time: 12 msec ;; SERVER: 10.96.0.10#53(10.96.0.10) ;; WHEN: Tue Dec 17 20:24:21 CST 2019 ;; MSG SIZE rcvd: 237 ``` 发现可以找到对应的3个后台POD,说明这套DNS基本是可以使用的。 但在实际应用开发中,我们不太可能反复切换本地开发机的DNS服务器地址。 因为,我的建议是: - 办公网内用dnsmasq搭建一套内网DNS,并打通隧道 - dnsmasq配置,默认走公网dns ip - dnsmasq配置,对于default.svc.cluster.coder4等k8s集群内的域名,直接走k8s的dns ip 由于这些配置比较常规,这里就不再赘述了。 至此,我们完成了办公网 -> kubernetes集群的网络打通。 ## 拓展与思考 1. 如果想做双向打通,如何修改配置呢? 2. 如何用云厂商提供的虚拟专线隧道(VPC),替换openvpn呢? 3. 如果Kubernetes集群部署在办公网内,但属于不同的网段。能否利用双网卡+iptables来替换openvpn呢? ================================================ FILE: legacy/ms-circuit-breaker-and-limit/README.md ================================================ # 微服务熔断与限流 "熔断"、"限流"这两个词看起来是和性能相关的。你可能会有疑问:微服务架构支持横向拓展,性能不够加机器就可以,为什么还需要熔断呢? 是的,横向拓展确实可以提升性能,但同时也降低了可用性。假设一个服务的可用性是99.9%,假设有100个服务实例依赖这个微服务,那么整体的可用性就0.999^100=90%,可用性下降了接近10!考虑到微服务架构下的性能横向拓展,微服务有多个副本的情况下,100个实例的依赖是很正常的情况。 微服务的实际应用中,调用链条可能更长,A调用B、B调用C...链条上的任何一个服务发生故障,都会导致调用链条上后续服务发生故障,从而将故障的影响逐级放大,最终导致整个系统崩溃,这称为雪崩效应。 为了避免雪崩的发生,除了提高服务的稳定性外,还可以采取"熔断"、"限流"等防御性手段。 本章将就这两种手段进行讨论,并引入Hytrix和Guava两款开源解决方案,探讨如何在微服务架构下快速地实现服务的熔断和限流。 ================================================ FILE: legacy/ms-circuit-breaker-and-limit/sb-hystrix.md ================================================ # 熔断与Hystrix 在本节,我们将讨论"熔断"方案的思路及其在微服务架构下的落地。 "熔断"这个词来源于电路保护。如果一条线路上的的电压过高,就会将保险丝烧断,从而切断该条线路上的电流,防止其影响其他线路。 我们将上述场景对应到微服务上,当调用某个微服务频繁发生故障(相当于电压过高),会触发熔断(相当于保险丝烧断),微服务将直接返回一个降级的结果,防止影响其他业务。 故障的类型可能有很多种,最常见的是抛出了异常或者调用超时。 你可能会有疑问:返回一个降级的结果,不就是错误了么? 是的,降级结果是错误的。但你可以降低错误的影响范围,例如,返回上一次成功执行的结果。 ## Hystrix的基本用法 Hystrix是由Netflex开源的一款开源组件,提供了基础的熔断功能。 Hystrix将降级策略封装在Commend中,不同的Commend根据group分割开。Commend内置了run和fallback两个方法,内置方法。 正常情况下,会先执行run方法(正常执行逻辑),若发生了故障,再执行fallback方法并返回其结果。若发生多次故障会在一定时间范围内触发短路,即跳过run方法,直接执行fallback方法。 关于Hystrix的更详细的原理,可以参考[Hystrix工作原理(官方文档翻译)](https://segmentfault.com/a/1190000012439580),这里不再赘述。 有几个涉及Hystrix的关键参数,这里做一些简要介绍: 首先是几个key * groupKey: 区分不同降级环境,相同的groupKey下处在相同的降级环境。 * commendKey: 区分不同命令。 * threadPoolKey: 相同的key将运行在相同的线程池下。 然后是几个通用配置 * executionTimeoutInMilliseconds: 执行超时时间,单位毫秒 * circuitBreakerEnabled: 发生多次故障后,是否会触发短路。默认是false,即总是先执行run方法,不会主动跳过。 最后是线程池 * coreSize: 线程池常驻线程数量 * maximumSize: 线程池最大线程数量 * allowMaximumSizeToDivergeFromCoreSize: 仅当设置为true时,上述maxiumSize才生效。 * maxQueueSize: 最多允许多少个任务堆积 * queueSizeRejectionThreshold: 多少个任务堆积会处罚降级。堆积的任务太多,说明处理速度跟不上需求了,也会被认为是故障并处罚降级。 ## Hystrix的基本封装 上述概念固然重要,但每次做熔断时如果都要仔细考虑,未免太过繁琐,为此,我们做了一个抽象的基类,实现了上述的默认的配置: ```java package com.coder4.lmsia.hystrix; import com.coder4.sbmvt.trace.TraceIdContext; import com.coder4.sbmvt.trace.TraceIdUtils; import com.netflix.hystrix.HystrixCommand; import com.netflix.hystrix.HystrixCommandGroupKey; import com.netflix.hystrix.HystrixCommandKey; import com.netflix.hystrix.HystrixCommandProperties; import com.netflix.hystrix.HystrixThreadPoolKey; import com.netflix.hystrix.HystrixThreadPoolProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.util.StringUtils; import java.util.function.Supplier; /** * @author coder4 */ public class BaseHystrixCommend extends HystrixCommand { private Logger LOG = LoggerFactory.getLogger(getClass()); private final Supplier realSupplier; private final Supplier fallbackSupplier; public BaseHystrixCommend(String key, Supplier realSupplier, Supplier fallbackSupplier) { this(key, new BaseHytrixConfig(), realSupplier, fallbackSupplier); } public BaseHystrixCommend(String key, BaseHytrixConfig config, Supplier realSupplier, Supplier fallbackSupplier) { super(Setter // 3个key合一 .withGroupKey(HystrixCommandGroupKey.Factory.asKey(key)) .andCommandKey(HystrixCommandKey.Factory.asKey(key)) .andThreadPoolKey(HystrixThreadPoolKey.Factory.asKey(key)) .andCommandPropertiesDefaults( HystrixCommandProperties.Setter() .withExecutionTimeoutInMilliseconds(config.getExecutionTimeoutInMilliseconds()) .withCircuitBreakerEnabled(config.isCircuitBreakerEnabled()) .withFallbackIsolationSemaphoreMaxConcurrentRequests(config.getFallbackIsolationSemaphoreMaxConcurrentRequests()) ) .andThreadPoolPropertiesDefaults( HystrixThreadPoolProperties.defaultSetter() .withAllowMaximumSizeToDivergeFromCoreSize(config.isAllowMaximumSizeToDivergeFromCoreSize()) .withCoreSize(config.getCorePoolSize()) .withMaximumSize(config.getMaxPoolSize()) .withMaxQueueSize(config.getMaxQueueSize()) .withQueueSizeRejectionThreshold(config.getMaxQueueSize()) )); this.realSupplier = realSupplier; this.fallbackSupplier = fallbackSupplier; } @Override protected R run() throws Exception { if (StringUtils.isEmpty(TraceIdContext.getTraceId())) { TraceIdContext.setTraceId(TraceIdUtils.getTraceId()); } R r = this.realSupplier.get(); TraceIdContext.removeTraceId(); return r; } @Override protected R getFallback() { try { LOG.error("enter fallback because ", getExecutionException()); return this.fallbackSupplier.get(); } finally { TraceIdContext.removeTraceId(); } } } ``` 如上所示,在构造函数中,对上述参数进行了配置,此外还结合了[分布式追踪](../ms-log/sb-trace.md)中介绍的TraceIdContext,注入并销毁traceId。 Hystrix的默认值在另一个文件中进行了配置: ```java package com.coder4.lmsia.hystrix; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; /** * @author coder4 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class BaseHytrixConfig { private static int DEFAULT_EXECUTION_TIMEOUT_IN_MILLISECONDS = 1000; private static int DEFAULT_FALL_BACK_ISOLATION_SEMAPHORE_MAX_CON_CURRENT_REQUESTS = 512; private static int DEFAULT_CORE_POOL_SIZE = 64; private static int DEFAULT_MAX_POOL_SIZE = 512; private static int DEFAULT_MAX_QUEUE_SIZE = 32; // 执行时限(毫秒) private int executionTimeoutInMilliseconds = DEFAULT_EXECUTION_TIMEOUT_IN_MILLISECONDS; // 启动断路器 private boolean circuitBreakerEnabled = true; // (信号量隔离时)降级调用最大并发数 private int fallbackIsolationSemaphoreMaxConcurrentRequests = DEFAULT_FALL_BACK_ISOLATION_SEMAPHORE_MAX_CON_CURRENT_REQUESTS; // 允许线程数峰值超过coreSize private boolean allowMaximumSizeToDivergeFromCoreSize = true; // 核心线程数 private int corePoolSize = DEFAULT_CORE_POOL_SIZE; // 最大线程数量 private int maxPoolSize = DEFAULT_MAX_POOL_SIZE; // 最大队列等待数量 private int maxQueueSize = DEFAULT_MAX_QUEUE_SIZE; } ``` 有了上述默认设置后,我们将上述两个类封装成独立的项目lmsia-hystrix中,方便其他微服务的调用。 ## Hystrix在微服务中的用法 最后,我们来看一下如何在微服务中使用hystrix。 首先在依赖中引入lmsia-hystrix: ``` compile 'com.github.liheyuan:lmsia-hystrix:0.0.4' ``` 然后定义一个Commend,并execute ``` @GetMapping(value = "/") public String hello() { return new BaseHystrixCommend("abc", this::helloReal, this::helloFallback).execute(); } private String helloReal() { LOG.info("hello real"); if (true) { throw new RuntimeException("haha"); } return abcLogic.getHello(); } private String helloFallback() { LOG.info("hello fb"); return "Hello, fallback"; } ``` 如上所示,我们定义了两个函数helloReal是正常逻辑,但这个方法会抛异常并触发降级。helloFallback是降级逻辑。 我们构造的Commend会根据情况执行上述两个方法。 在未启用Commend前,rest请求会直接500错误,因为抛出了异常。 启用Commend后,返回总是200。前几次,会发现日志先打印"hello real",再打印"hello fb",这说明只是出发降级未触发短路。当多执行几次,就会发现不再输出"hello real",这时就是真正触发了短路。 至此,我们已经借助Hystrix实现了微服务的降级功能。 ## 拓展与思考 1. 返回一个固定的降级结果,可能会影响产品体验。如果想返回上一次执行成功的结果,该如何进行修改呢,Hystrix中有没有内置这个功能呢? 2. Hystrix中默认触发短路的阈值是多少,默认短路时间又是多少呢, 如果想进行修改,需要如何进行配置呢? ================================================ FILE: legacy/ms-circuit-breaker-and-limit/sb-limit.md ================================================ # 限流的实现 与"熔断"类似,"限流"也是一种降级手段,但限流的思路更简单、直观: 它直接拒绝部分请求。 在微服务架构下,若大量请求超过微服务的处理能力时,可能会将服务打跨,甚至产生雪崩效应、影响系统的整体稳定性。 孙子兵法有一计"李代桃僵",说的是当局势发展到必然有所损失时,应当舍得局部弱小兵力,以保全大局优势。 我们可以将这种战略应用到微服务中,在流量超出承受阈值时,直接进行"限流"、拒绝部分请求,从而保证系统的整体稳定性。 有的业务场景中,系统压力并不大,但也需要限制用户每秒的操作次数,例如:验证码的发送接口。 进行限流的方案有很多种,本节这里讨论两个层面上的限流:负载均衡器和微服务。 ## 负载均衡层的限流 Nginx是一款高性能的反向代理服务器,是用户请求进入系统的第一道关卡。 在Nginx上配置限流策略,不仅可以保护系统稳定性,也能防范一部分恶意攻击。 我们来看一组最常见的策略。 (1) 按照IP地址, 限制每秒请求数量: ``` limit_req_zone $binary_remote_addr zone=limit1:1m rate=20r/s; ``` 如上所述,配置的是一个限流区域: * 区域名字是limit1,分配1MB的内存,大致可以追踪1.5万个IP地址 * 每个IP地址,每秒钟的访问上限是20次。 (2) 支持突发缓存队列 ``` limit_req zone=limit1 burst=10 nodelay; ``` 如上所述,细化了limit1这个区域上的具体策略: * burst建立了一个长度为10的缓冲区,若突发流量导致限流会先放到缓冲区中 * nodelay当缓冲区已满了,丢弃请求,返回503 上面提到的缓存策略可以应用于全局,也可以应用于不同的url路径下。 此外,Nginx还提供了多种高级的限流配置手段,可以参考这篇博客[Nginx Rate Limiting](https://dzone.com/articles/nginx-rate-limiting)。 ## 微服务层的基础限流 由于Nginx无法解析业务逻辑,只能在IP层面进行"较为粗犷的限流"。 如果想结合业务逻辑或更复杂的策略,可以在微服务层面进行限流。 Guava是谷歌开源的Java库,其中提供了基于令牌桶算法的RateLimiter。我们将以此为基础,实现微服务层面的限流。 首先,来定义一个注解类 ```java package com.coder4.lmsia.ratelimit; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * 对方法限流,超限会抛出HTTP 429异常 * @author coder4 */ @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD}) @Documented public @interface MethodRateLimit { // 每秒允许多少次请求 double permitsPerSecond(); } ``` 如上所述,注解定义了一个参数permitsPerSecond,即每秒允许几次请求,支持非整数配置。 为了让注解生效,我们需要配合AOP使用: ```java package com.coder4.lmsia.ratelimit.aspect; import com.coder4.lmsia.commons.http.exception.Http429TooManyRequestsException; import com.coder4.lmsia.ratelimit.MethodRateLimit; import com.coder4.lmsia.ratelimit.RateLimiterProvider; import com.google.common.util.concurrent.RateLimiter; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import java.util.Optional; /** * @author coder4 */ @Component @Aspect public class MethodRateLimitAspect { protected Logger LOG = LoggerFactory.getLogger(getClass()); @Around(value = "(execution(* com.coder4..*(..))) && @annotation(methodLimit)", argNames = "joinPoint, methodLimit") public Object methodAround(ProceedingJoinPoint joinPoint, MethodRateLimit methodLimit) throws Throwable { // Get RateLimiter Optional rateLimiterOp = RateLimiterProvider.getInstance() .getRateLimiter( joinPoint.getSignature().toLongString(), methodLimit.permitsPerSecond()); if (!rateLimiterOp.isPresent() || rateLimiterOp.get().tryAcquire()) { // allow return joinPoint.proceed(); } else { // deny throw new Http429TooManyRequestsException(); } } } ``` 如上所述,我们对所有添加了MethodRateLimit注解的方法进行AOP注入: * 根据方法名获取一个RateLimiter,RateLimiterProvider稍后会介绍 * 若可以获得令牌,则执行方法,否则抛出HTTP429(Too Mangy Requests)异常 再来看一下RateLimiterProvider: ```java package com.coder4.lmsia.ratelimit; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import com.google.common.util.concurrent.RateLimiter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Optional; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; /** * @author coder4 */ public class RateLimiterProvider { private Logger LOG = LoggerFactory.getLogger(getClass()); private static final RateLimiterProvider instance = new RateLimiterProvider(); private static final int CAPACITY = 2000; private static final int TTL_SECS = 60; private Cache rateLimiterCache; private RateLimiterProvider() { rateLimiterCache = CacheBuilder.newBuilder() .maximumSize(CAPACITY) .expireAfterAccess(TTL_SECS, TimeUnit.SECONDS) .build(); } public static RateLimiterProvider getInstance() { return instance; } public Optional getRateLimiter(String key, double permitsPerSecond) { // 未测试线程安全,但影响不大 try { return Optional.ofNullable( rateLimiterCache.get(key, () -> RateLimiter.create(permitsPerSecond))); } catch (ExecutionException e) { LOG.error("getRateLimiter exception", e); return Optional.empty(); } } } ``` 如上所述,Provider的内部使用Guava的Cache机制: * 根据字符串key从Cache中尝试获取RateLimiter,获取不到则新建一个 * Cache最高存储2000个、过期时间为60秒,以防不断膨胀导致过高的内存开销。 有了上述注解,在微服务中进行限流将异常简单: ```java @MethodRateLimit(permitsPerSecond = 2.0) @GetMapping(value = "/") public String hello() { return new BaseHystrixCommend("abc", this::helloReal, this::helloFallback).execute(); } ``` 如上,只需要一行代码即可搞定。 ## 微服务层的高级限流 在一些复杂的业务场景下,我们可能希望根据不同用户或其他字段进行限流。 我们提供了另一款MethodParamRateLimit来满足这类需求: ```java package com.coder4.lmsia.ratelimit; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * 根据方法+参数限流,超限会抛出HTTP 429异常 * * @author coder4 */ @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD}) @Documented public @interface MethodParamRateLimit { // 每秒允许多少词请求 double permitsPerSecond(); // 参数下标(0开始) int paramIndex(); } ``` 新增的参数paramIndex稍后会作出解释,我们看一下AOP的Aspect: ```java package com.coder4.lmsia.ratelimit.aspect; import com.coder4.lmsia.commons.http.exception.Http429TooManyRequestsException; import com.coder4.lmsia.ratelimit.MethodParamRateLimit; import com.coder4.lmsia.ratelimit.RateLimiterProvider; import com.google.common.util.concurrent.RateLimiter; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import java.util.Optional; /** * @author coder4 */ @Component @Aspect public class MethodParamRateLimitAspect { protected Logger LOG = LoggerFactory.getLogger(getClass()); @Around(value = "(execution(* com.coder4..*(..))) && @annotation(methodParamLimit)", argNames = "joinPoint, methodParamLimit") public Object methodAround(ProceedingJoinPoint joinPoint, MethodParamRateLimit methodParamLimit) throws Throwable { // Get RateLimiter Optional rateLimiterOp = RateLimiterProvider.getInstance() .getRateLimiter(getRateLimiterKey(joinPoint, methodParamLimit), methodParamLimit.permitsPerSecond()); if (!rateLimiterOp.isPresent() || rateLimiterOp.get().tryAcquire()) { // allow return joinPoint.proceed(); } else { // deny throw new Http429TooManyRequestsException(); } } private String getRateLimiterKey(ProceedingJoinPoint joinPoint, MethodParamRateLimit methodParamLimit) { // Get Param Value String paramValue = getParamLimit(joinPoint, methodParamLimit.paramIndex()); return String.format("%s-%s", joinPoint.getSignature().toString(), paramValue); } private String getParamLimit(ProceedingJoinPoint joinPoint, int paramIndex) { Object[] args = joinPoint.getArgs(); if (paramIndex < 0 || paramIndex >= args.length) { LOG.warn("paramIndex exceed length, use default"); return "default_param"; } return args[paramIndex].toString(); } } ``` 如上所述,进行切面处理时: * 从用方法和第paramIndex参数的值拼接为key来获取RateLimit。这有些抽象,我们稍后会举个例子。 * 其他处理策略同MethodLimitAspect 看一下用法: ```java @MethodParamRateLimit(permitsPerSecond = 1, paramIndex = 0) @GetMapping(value = "/ids/{id}") public String helloWithId(@PathVariable int id) { return helloFallback(id); } ``` 如上所述,MethodParamRateLimit应用在此处,实现了根据不同的id进行限流,每个id每秒只能访问1次,不同id之间不会相互影响。 ## 阅读与思考 1. Nginx进行限流时,容易发生误伤,例如来自内网或者监控系统的IP。请自行查找资料,实现白名单配置,避免这种情况。 2. 除了负载均衡、微服务层面的限流,你还能想到其他层面的限流么? ================================================ FILE: legacy/ms-config/README.md ================================================ # 微服务配置中心 "配置中心"是对配置进行集中管理的系统,是微服务架构中的一个基础环节。 服务端存在多种类型的配置: * 环境变量:如操作系统环境变量 * 内部配量:如阈值、关键字 * 应用配量:如功能、特性开关 环境变量等配置很少发生变动,但内部、应用配置可能会频繁发生变动。 传统的配置都是写在文件中,并伴随服务端一起发布,这样做有一些弊端: * 配置文件格式多样,易产生错误。例如,yaml和ini的格式就极容易混淆,导致配置出错。 * 不同环境下配置容易混淆。例如,误将测试环境配置发布到生产环境。 * 每次配置的修改都要伴随服务端的上线。在微服务架构下,服务种类、实例数量增多,为了改动配置而单独上线会产生较大的运维成本。 针对这些场景,配置中心应运而生,它的核心功能有: * 提供配置的存、取服务 * 支持配置的动态更新,即无需启动服务即可完成配置更新。 * 支持配置的版本管理或追溯功能,方便进行审计。 本章将围绕配置中心展开讨论,在第一节讨论了一种基于cfg4j的配置中心方案。第二节讨论了如何在Spring Boot中整合上述配置中心。 ================================================ FILE: legacy/ms-config/cfg4j.md ================================================ # cfg4j及方案简介 实现微服务的配置中心有多种选择方案,常见的方案有: * 使用Spring Cloud全家桶中的Spring Cloud Config。 * 使用Consul或者Zookeeper作为分布式一致性存储,自己实现配置中心。 但上述方案都有一些不足: * Sping Cloud Config不支持配置的实时更新,需要额外实现。此外,Spring Cloud的依赖较多,不太干净。 * Consul或者Zookeeper只提供了存取接口,对于配置下发、更新(特别是配置的管理界面)都需要自己开发实现,成本非常高。 我们在此选用了一种成本较低的方案: 使用cfg4j库,存储源选择git。 cfg4j是一款"分布式系统"的配置类库,它不包含服务端存储部分,但实现了从多种数据源读取,更新配置,以及缓存策略。 我们选用git作为数据源,原因有: * 本书架构本身采用git作为代码管理工具,不需要额外部署成本 * 本书已经采用了gerrit作为git服务器和代码审核工具,它的diff、review功能非常强大,可以不用额外开发配置管理的web界面 ## gerrit中的权限配置 截至目前的最新版,cfg4j默认只支持从匿名git仓库拉取配置,我们需要对gerrit进行一些配置以满足这一条件。 使用管理员帐号登录gerrit,然后选择"Projects" -> "All-Projects" -> "Access",进行如下修改: * 给"Reference: refs/*" 添加匿名组 "Anonymous Users" 的 "DENY"权限。注意,这只是一个全局的默认配置,可以被项目级别的权限覆盖。 * 新建项目"lmsia-config",修改项目权限,给"Reference: refs/*"添加匿名组 "Anonymous Users" 的 "ALLOW"权限。 经过上述修改后,我们需要做一下简单验证: ```shell git clone http://127.0.0.1:9002/lmsia-config ``` 如果上述命令可以直接clone项目到本地,且无需输入用户名、密码,说明gerrit的权限配置成功。 ## 支持多个微服务的配置 在前面,我们新建了项目lmsia-config,并且给它配置了匿名访问权限。 我们的微服务可能有很多,如何让lmsia-config项目,支持多个微服务的配置呢? 我们通过目录的方式来实现: ```shell . ├── lmsia-abc │   └── config.properties └── lmsia-xyz └── config.properties ``` 如上所示,对于每一个微服务项目,我们都在lmsia-config下创建一个目录,并在目录中放置config.properties作为配置文件。 在后面的章节,我们将介绍如何让微服务自动地解析上述配置文件路径。 ## 拓展与思考 1. 如果测试环境、线上环境需要使用不同的配置,如何支持这种特性?(提示:分支) 2. 如果希望匿名用户只读而不能写,如何修改gerrit权限? ================================================ FILE: legacy/ms-config/consul-devops.md ================================================ # Consul服务的运维 Consul是一款支持高可用的服务发现、配置管理服务。我们使用Consul作为配置中心的基础服务。即由Consul提供配置的管理、获取等基础功能。 在探讨配置中心之前,我们首先来看一下Consul的运维工作。 ## 生成Consul所需要的证书 本文配置生成的部分参考了[consul-on-kubernetes](https://github.com/kelseyhightower/consul-on-kubernetes)项目。 首先生成中证书 ```shell ~/go/bin/cfssl gencert -initca ca/ca-csr.json | ~/go/bin/cfssljson -bare ca ~/go/bin/cfssl gencert -ca=ca.pem -ca-key=ca-key.pem -config=ca/ca-config.json -profile=default ca/consul-csr.json | ~/go/bin/cfssljson -bare consul ``` 生成的文件为: * ca-key.pem * ca.pem * consul-key.pem * consul.pem 然后根据证书生成kubernetes所需要的configmap ```shell kubectl create secret generic consul --from-literal="gossip-encryption-key=X9u61NBsxoQt6edwxpStLg==" --from-file=ca.pem --from-file=consul.pem --from-file=consul-key.pem kubectl create configmap consul --from-file=configs/server.json ``` 其中上述密码部分是由“consul keygen”生成的,可以改成自己的密码。 ================================================ FILE: legacy/ms-config/sb-config.md ================================================ # Spring Boot整合配置中心 上一小节中,我们探讨了如何利用gerrit搭建配置中心的版本仓库。 现在,我们探讨如何在Spring Boot的框架中整合配置中心。 ## 开发lmsia-cfg4j库,实现配置项的自动注入 与之前的Cache等功能类似,我们在多个微服务中,轻松地使用配置中心,所以将相关功能提取但独立的项目中。你可以在这里查看[lmsia-cfg4j](https://github.com/liheyuan/lmsia-cfg4j)的源代码。 如前文描述,我们使用cfg4j来辅助实现配置中心的功能。 cfg4j提供了默认的"Binding"方式,来绑定配置项到类中,但使用起来较为繁琐。 许多Spring的功能都是通过注解来实现的,非常方便。我们的配置项也可以用注解来实现,首先定义一个注解接口: ```java package com.coder4.lmsia.cfg4j; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * @author coder4 */ @Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Cfg4jValue { String value() default ""; } ``` 如上所示,之后希望可以使用类似"@Cfg4jValue"的方式,将配置项注解到对应字段中。 有了注解接口,如何实现自动注解呢,传统的方式需要使用动态代理来完成,在这里我们采用Spring提供的BeanPostProcessor来完成: ```java package com.coder4.lmsia.cfg4j; import org.cfg4j.provider.ConfigurationProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.aop.support.AopUtils; import org.springframework.beans.BeansException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.core.Ordered; import org.springframework.util.ReflectionUtils; import java.lang.reflect.Field; import java.util.NoSuchElementException; /** * @author coder4 */ public class Cfg4jValueProcessor implements BeanPostProcessor, Ordered { private Logger LOG = LoggerFactory.getLogger(getClass()); @Autowired private ConfigurationProvider configurationProvider; // 初始化前注入 @Override public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { final Class targetClass = AopUtils.getTargetClass(bean); ReflectionUtils.doWithFields(targetClass, field -> process(bean, targetClass, field), field -> { return field.isAnnotationPresent(Cfg4jValue.class); }); return bean; } private void process(final Object bean, Class targetClass, final Field field) { // Get injected field name Cfg4jValue valueAnnotation = field.getDeclaredAnnotation(Cfg4jValue.class); String fieldName = getPropName(valueAnnotation, field.getName()); // inject for some support type fieldSetWithSupport(bean, field, fieldName); } private void fieldSetWithSupport(Object bean, Field field, String key) { Class type = field.getType(); field.setAccessible(true); try { if (int.class == type || Integer.class == type) { field.set(bean, configurationProvider.getProperty(key, Integer.class)); } else if (boolean.class == type || Boolean.class == type) { field.set(bean, configurationProvider.getProperty(key, Boolean.class)); } else if (String.class == type) { field.set(bean, configurationProvider.getProperty(key, String.class)); } else if (long.class == type || Long.class == type) { field.set(bean, configurationProvider.getProperty(key, Long.class)); } else { LOG.error("not support cfj4j value inject type"); throw new RuntimeException("not supported cfg4jValue type"); } } catch (IllegalAccessException e) { LOG.error("exception during field set", e); throw new RuntimeException(e); } catch (NoSuchElementException e) { LOG.error("config missing key, please check"); throw new RuntimeException(e); } } public static String getPropName(Cfg4jValue annotation, String defaultName) { String key = annotation.value(); if (key == null || key.isEmpty()) { key = defaultName; } return key; } @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { return bean; } @Override public int getOrder() { return HIGHEST_PRECEDENCE; } } ``` 如上所示,Cfg4jValueProcessor完成了以下功能: * 自动查找所有@Cfg4jValue的注解 * 对有上述注解的字段,根据字段名从Cfg4j的数据源(ConfigurationProvider)中读取配置项 * 若有配置项,完成类型转换并注入到对应字段中。这里目前只支持int, long, string这三种类型。 ConfigurationProvider是cfg4j的数据源,如前文所述,我们希望它自动从gerrit来读取。 为此,实现一个自动配置如下: ```java package com.coder4.lmsia.cfg4j.configuration; import com.coder4.lmsia.cfg4j.Cfg4jValueProcessor; import org.cfg4j.provider.ConfigurationProvider; import org.cfg4j.provider.ConfigurationProviderBuilder; import org.cfg4j.source.ConfigurationSource; import org.cfg4j.source.context.environment.Environment; import org.cfg4j.source.context.environment.ImmutableEnvironment; import org.cfg4j.source.context.filesprovider.ConfigFilesProvider; import org.cfg4j.source.git.GitConfigurationSourceBuilder; import org.cfg4j.source.reload.ReloadStrategy; import org.cfg4j.source.reload.strategy.PeriodicalReloadStrategy; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.stereotype.Service; import java.nio.file.Paths; import java.util.Arrays; import java.util.concurrent.TimeUnit; /** * @author coder4 */ @Configuration @ConditionalOnProperty("msName") public class Cfg4jGitConfiguration { @Value("${msName}") private String msName; // May Change this private static String CONFIG_GIT_HOST = "10.1.64.72"; // May Change this private static String CONFIG_GIT_REPO = "http://" + CONFIG_GIT_HOST + ":9002/lmsia-config.git"; // May Change this private static String branch = "master"; private static int RELOAD_SECS = 60; @Bean public ConfigurationProvider configurationProvider() { ConfigFilesProvider configFilesProvider = () -> Arrays.asList(Paths.get(msName + "/config.properties")); ConfigurationSource source = new GitConfigurationSourceBuilder() .withRepositoryURI(CONFIG_GIT_REPO) .withConfigFilesProvider(configFilesProvider) .build(); Environment environment = new ImmutableEnvironment(branch); ReloadStrategy reloadStrategy = new PeriodicalReloadStrategy(RELOAD_SECS, TimeUnit.SECONDS); return new ConfigurationProviderBuilder() .withConfigurationSource(source) .withEnvironment(environment) .withReloadStrategy(reloadStrategy) .build(); } @Bean public Cfg4jValueProcessor createCfg4jValueProcessor() { return new Cfg4jValueProcessor(); } } ``` 上述完成了如下功能: * 从Git仓库拉去lmsia-config项目(即前文用于微服务配置的仓库) * 定义缓存时间为60秒 * 配置文件具体路径为/项目名/config.properties(与前一节的目录结构相对应) * 顺便配置刚才编写的Cfg4jValueProcessor,让配置可以自动注入到对应的地方上。 当然,为了让上述自动注解生效,不要忘记配置spring.factories ``` org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.coder4.lmsia.cfg4j.configuration.Cfg4jGitConfiguration ``` 至于项目名,可以通过微服务自身的application.yaml指定,若不制定将不会启动这个自动配置 ## 使用 有了lmsia-cfg4j后,如何在微服务中自动注入配置项目呢? 首先我们需要准备好配置,例如lmsia-config/lmsia-abc的config.properties中定义 ``` key=value enable=false ``` 接着,在微服务的application.yaml中定义项目名称 ```yaml msName: lmsia-abc ``` 最后一步,在代码中,使用注解: ```java package com.coder4.lmsia.abc.server.configuration; import com.coder4.lmsia.cfg4j.Cfg4jValue; import lombok.Data; import org.springframework.stereotype.Service; /** * @author coder4 */ @Service @Data public class TestConfig { @Cfg4jValue private String key; @Cfg4jValue private boolean enable; } ``` 如上所示,是不是非常简单! 你可以启动自己的微服务项目,测试上述配置项是否被如期的注入进来。 在lmsia-cfg4j中,有默认60秒的缓存,你也可以修改lmsia-abc的配置,等待60秒,再观察新的配置是否生效。 ## 拓展与思考 1. 我们介绍的的配置中心架构中,实际采用的是拉默认来获取最新配置。当微服务及其副本数量众多的时候,可能会对gerrit服务器造成巨大压力。有什么好的方法可以改进这一点么? 2. 如果需要结合profile实现测试、线上环境使用不同的配置,lmsia-cfg4j项目要如何进行修改呢? ================================================ FILE: legacy/ms-delivery/README.md ================================================ # 微服务持续交付 "持续集成"(Continuous integration):频繁地将开发代码合并到主干,并保证可以编译通过,并通过基本额单元测试。 坚持持续集成的优点有: * 快速失败(Fast Fail): 尽早发现错误,"早发现、早治疗",将错误的成本降到最低。 * 减少代码冲突风险:使用频繁、多次、小的分支合并,来很久不合并代码导致的大量的代码冲突。 * 提升迭代速度和质量:小步快跑,让进度更可控,避免"Deadline前赶工期"的现象。 "持续交付"(Continuous delivery):频繁地将代码的最新版本,交付给用户(或线上环境),它的优势不言而喻: * 更快的交付速度:借助自动化的持续交付系统,可以做到1天上线多次 * 产品功能可并行上线:持续交付降低了上线的人力成本,多个功能可并行开发、上线 在互联网软件开发领域,持续集成和持续交付已经成为基本的共识,极大地提升了项目的迭代、交付速度。 与之形成鲜明对比的是,在传统软件开发领域,持续集成和持续交付的理念还没有得到贯彻,软件的开发、上线以周、月为单位,并且功能之间往往难以进行拆分。 本章将围绕上述两个问题,探讨探讨微服务架构下,借助Jenkins实现持续集成、持续部署。 ================================================ FILE: legacy/ms-delivery/jenkins-devops.md ================================================ # Jenkins构建平台的运维 Jenkins是一款开源的持续构建工具,除了基础功能外,还有各种功能丰富的插件,可以实现各种高级功能。 Jenkins常见的应用场景是: * 项目的自动构建(编译),即持续集成 * 自动执行项目的单元/集成测试,即持续测试 * 实现项目的自动部署、上线,即持续部署 在本小节,我们首先探讨Jenkins系统的运维工作,并尝试将Jenkins与LDAP系统集成起来。 ## Jenkins系统的搭建 对于Jenkins系统,我们将直接用Docker部署在物理机而不是k8s集群上,主要原因有: * Jenkins的定位是持续集成、持续部署,需要作用在k8s集群上,故耦合不宜太紧密。 * 由于资源有限,我们的k8s集群主要运行微服务及相关后台组件。 我们先来看一下创建脚本 ```shell #!/bin/bash NAME="jenkins" VOLUME="$HOME/docker_data/jenkins" # ensure volume ready sudo mkdir -p $VOLUME sudo chmod -R 777 $VOLUME # submit to local docker node docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --name $NAME \ -v $VOLUME:/var/jenkins_home \ -p 9001:8080 \ -p 50000:50000 \ --detach \ --restart always \ jenkins/jenkins:2.60.3-alpine ``` 如上所述: * 我们使用了jenkins的官方镜像 * 映射默认端口到本地的9001,这个即web管理界面的端口 * 映射端口50000,这个是用于管理通信的端口 * volume映射了/var/jenkins_home文件夹 启动完毕后,需要进行Jenkins系统的初始化,如下图所示: ![Jenkins初始化](./jenkins-init.png) 初始化需要输入初始化密码,存放在docker的/var/jenkins_home/secrets/initialAdminPassword目录下,可以通过docker exec -i -t 登录容器,然后cat查看。 ```shell docker exec -i -t ad74be122fcd /bin/sh cat /var/jenkins_home/secrets/initialAdminPassword 0b7aee7513774800ac6cf1fdd41d0366 ``` 输入密码后,稍等一会儿,提示选择安装插件的模式,选择自定义插件: ![Jenkins插件选项初始化](./jenkins-init-plugin.png) 针对本书架构,建议选择的插件为: * Organization and Administration * Folder * Build Features * Build Timeout * SSH Agent * Timestamper * Workspace Cleanup * Active Choice * Build Tools * Gradle * Pipelines and Continuous Delivery * Pipeline * Pipeline: Stage View * Source Code Management * Git * Distributed Builds * SSH Slaves * User Management and Security * LDAP * Role-based Authorization Strategy * Notifications and Publishing * Publish Over SSH * SSH 选择好后进行安装,这一步会耗费的久一些,随后需要创建管理员帐号: ![Jenkins创建管理员帐号](./jenkins-init-admin.png) 最后点击完成 经过上述配置后,我们已经构建了基本的Jenkins环境,并有了一个可以登录的系统管理员帐号。 ## Jenkins系统接入LDAP系统 作为持续集成的核心,Jenkins系统的重要性毋庸置疑,有必要进行细粒度的权限控制与管理,例如: * 开发用户可以修改、执行Job,但不能新建项目 * 测试用户只能运行Job * 管理员可以进行任何操作 因此,我们需要给每个团队成员配置独立的帐号。面对这种需求,接入LDAP验证是一个很好的选择。 在[LDAP 内部账号管理系统](toolchain/ldap.md)一节中,我们介绍了LDAP的运维方案,本节假设你已经部署好了LDAP服务。 登录Jenkins后,在左侧菜单选择"Manage Jenkins",随后选择"Configure Global Security" 在"Access Control"中选中"LDAP",填写如下信息: * Server: LDAP服务和端口号,例如 ldap.coder4.com:389 * Root DN: LDAP的根目录,例如 dc=coder4,dc=com * User search base: 用户所在的子目录,例如 ou=users * User search filter: 用户名的字段,例如cn={0} * Manager DN: 可查询LDAP的额外帐号,例如一个只读帐号 cn=guest,dc=coder4,dc=com * Manager Password: 上述帐号对应的密码 * Display Name LDAP attribute: 帐号名字段名,例如cn * Email Address LDAP attribute: 邮箱字段名,例如mailk 上述都填写完毕后,可以点击底部的Test LDAP Settings 测试通过后,不要忘记点击页面最底部的保存按钮。 随后,我们尝试用LDAP帐号登录,可以发现登录成功。 ## 设置基于角色的权限 团队成员有多种不同的角色,如前面提到的开发、测试、管理员。 在Jeknins中,可以设置不同的角色,并针对角色配置不同的权限。 点击"Manage Jenkins",然后选择"Configure Global Security",在"Authorization"中更改为"Role Based Strategy"。 不要退出Jenkins,选择"Manage and Assign Roles",首先管理角色"Manage and Assign Roles" ![Jenkins分配系统角色](./jenkins-role.png) 如上图所示,新增2个角色:开发rd和测试qa,并设置对应的权限。 随后进入"Assign Roles",将开发帐号赋给对应的角色,这里给lihy赋予admin角色、给zhangsan赋予rd角色,如图所示: ![Jenkins赋予系统角色](./jenkins-assign-role.png) 我们可以登录zhangsan,没有看到"Manage Jenkins"的菜单,角色配置成功。 ================================================ FILE: legacy/ms-delivery/ms-cd.md ================================================ # Jenkins持续部署 在上一小节,我们完成了Jenkins的持续集成工作,经过持续集成,我们的代码已经编译成Docker镜像,并被Push到私有仓库中。 在本节,我们接着前一小节的成功,讨论部署问题。 这里的部署指的是将微服务真正地运行在k8s集群系统中,主要涉及如下步骤: * 获取可部署的Docker镜像版本 * 获取k8s集群操作权限 * 服务操作: 部署服务、重启服务等 其中获取k8s集群的权限有多种方式: * 通过REST API * 远程登录k8s的master节点 其中通过REST API的方式更加可靠、易于编程,但在k8s 1.7版本后,新增了权限控制,使用起来较为复杂。 在这里,我们采用直接登录k8s节点的方式。 ## 获取Docker镜像的版本列表 前面已经提到,在持续集成时,会打包好最新的镜像到仓库中,并且制定版本为Jenkins的版本。 对于部署环节,却不一定总是需要上线最新版本的镜像。例如,我们上线了一个新功能,半小时后发现有个Bug,需要回滚,此时就需要上线前一个版本的镜像。 如何获取镜像对应的版本呢?我们可以通过"Active Choices Plugin"插件来实现。 首先用管理员登录,Manage Jenkins -> Manage Plugins,安装"Active Choices Plugin"插件。 随后新建一个项目,例如"lmsia-xyz-deploy",勾选"This project is parameterized",然后新增一个"Active Choices Parameter",进行如下配置: ![配置动态参数](./jenkins-docker-img-version.png) 其中的主要代码如下: ```groovy // Variable def proj_name="lmsia-xyz" def curl_prefix="https://10.1.64.72/v2/$proj_name" // Get From Docker Registry Using curl import groovy.json.JsonSlurper def cmd= "curl --insecure $curl_prefix/tags/list" def object = new JsonSlurper().parseText(cmd.execute().text) // Sort and return return object.tags.sort { a, b -> b.compareToIgnoreCase a } ``` 上述代码从Docker私有仓库获取"lmsia-xyz"这个镜像的所有版本,然后倒着排序后,返回给Jenkins插件。 点击底部保存,点击"lmsia-xyz-deploy"项目的"Build with Parameters",可以发现正常显示了版本列表: ![正常展示了版本列表](./jenkins-deploy-version.png) ## 新增远程主机 前面已经提到,我们将直接登录到k8s的主节点来执行部署命令。 为了实现这一点,首先要将该机器添加到Jenkins的SSH Site中。 用管理员帐号登录 -> Manage Jenkins -> Configure System, 找到SSH Site添加如下: ![配置远程执行主机](./jenkins-ssh-succ.png) 除了配置主机名、端口外,记得选择一个已经配置好的SSH私钥,这需要提前到Jenkins的Credentials中配置好。 接着我们回到lmsia-xyz-deploy项目,在Build环节新建一个"Execute script on remote host using ssh" * SSH Site选择刚才配置好的主机 * Command里设置为"/home/coder4/deploy2k8s.sh $JOB_NAME $IMG_VERSION" 其中deploy2k8s.sh需要部署在这台远程的/home/coder4目录下(根据你的情况可自行更改),内容为: ```bash #!/bin/bash set -e if [ x"$#" != x"2" ];then echo "Usage $0 proj_name img_version" exit -1 fi # Const DOCKER_REGISTRY="10.1.64.72" PROJECT_NAME=$1 IMAGE_NAME=$(echo $PROJECT_NAME | sed -r 's/-deploy$//g') IMAGE_VERSION=$2 # Generate yaml cat > ./deployment.yaml < "Manage Nodes" -> "New Node",然后如下图所示配置: ![Jenkins配置Slave机器](./jenkins-slave.png) 主要的配置为: * Remote root directory 家目录/home/build * Usage 尽可能多使用(尽量不占用master进程) * Launch method 使用ssh模式 * Host slave即上面启动的容器 * Credentials 可以新增一个密码方式的验证build/build123 * Availability 尽量让slave在线 配置成功默认是离线的,稍等一会,会提示"slave已经上线"。 ## 持续集成第一步:迁出代码、编译 本节开篇已经提到,持续集成的第一步即从代码仓库中迁出代码,我们来完成这项工作。 首先在gerrit上准备一个项目,假设为lmsia-xyz,这是一个最简单的Spring Boot项目。 为了能够提交、迁出代码,需要将公钥配置到gerrit上,点击右上角的名字 -> Setting -> SSH Public Keys,填入即可完成。 准备好项目后,我们在Jenkins上新建一个"Freestyle"项目,命名为lmsia-xyz-build。 首先配置代码仓库,如下图所示: ![Jenkins配置Gerrit权限](./jenkins-gerrit.png) * 在"Source Code Management"中,选择"Git",并填写gerrit的repo地址 * Credentials中新增一个用户,为gerrit中的用户,要填写私钥 此外,还要限制只能在slave上执行: Restrict where this project can be run中设置"slave"。 完成后点击底部的Save。 配置好后,我们执行第一次Build,在项目左侧菜单选择"Build Now",可以在Log中查看输出如下: ``` Building remotely on slave in workspace /home/build/workspace/lmsia-xyz-build Cloning the remote Git repository Cloning repository ssh://lihy@10.1.64.72:29418/lmsia-xyz > git init /home/build/workspace/lmsia-xyz-build # timeout=10 Fetching upstream changes from ssh://lihy@10.1.64.72:29418/lmsia-xyz > git --version # timeout=10 using GIT_SSH to set credentials > git fetch --tags --progress ssh://lihy@10.1.64.72:29418/lmsia-xyz +refs/heads/*:refs/remotes/origin/* > git config remote.origin.url ssh://lihy@10.1.64.72:29418/lmsia-xyz # timeout=10 > git config --add remote.origin.fetch +refs/heads/*:refs/remotes/origin/* # timeout=10 > git config remote.origin.url ssh://lihy@10.1.64.72:29418/lmsia-xyz # timeout=10 Fetching upstream changes from ssh://lihy@10.1.64.72:29418/lmsia-xyz using GIT_SSH to set credentials > git fetch --tags --progress ssh://lihy@10.1.64.72:29418/lmsia-xyz +refs/heads/*:refs/remotes/origin/* > git rev-parse refs/remotes/origin/master^{commit} # timeout=10 > git rev-parse refs/remotes/origin/origin/master^{commit} # timeout=10 Checking out Revision eab8a79ff6cde375c017b6f9eec29dff02a0bb85 (refs/remotes/origin/master) > git config core.sparsecheckout # timeout=10 > git checkout -f eab8a79ff6cde375c017b6f9eec29dff02a0bb85 Commit message: "MOD: init commit" First time build. Skipping changelog. Finished: SUCCESS ``` 如上所示,我们成功地从代码仓库迁出了代码,第一步顺利完成! 在迁出代码后,我们需要进行编译,回到lmsia-xyz-build项目的配置中,找到Build选项,新增一个"Execute shell"步骤,命令输入"gradle build",点击底部"Save"。 再次执行"Build Now",发现项目依然执行成功,查看日志,可以发现编译也成功地执行了! 至此,我们已经完成了代码的迁出和编译。 ## 打包镜像并上出到私有仓库 在lmsia-xyz-build项目中新建一个Shell步骤,内容如下: ``` $HOME/ms2docker.sh ``` 上述脚本需要添加到slave的镜像中,直接COPY即可,这里不再赘述。 脚本内容如下: ```shell #!/bin/bash set -e # Const DOCKER_REGISTRY="10.1.64.72" PROJECT_VERSION=${BUILD_NUMBER:-1} PROJECT_NAME=$(basename `pwd`| sed -r 's/-build$//g') SERVER_NAME="$PROJECT_NAME-server" JAR_NAME="$SERVER_NAME.jar" DOCKER_FULLNAME="$PROJECT_NAME:$PROJECT_VERSION" # Copy Jar find . -name "$SERVER_NAME*.jar" -exec cp {} ./$JAR_NAME \; # Generate Dockerfile cat > ./Dockerfile < Annotations: kubectl.kubernetes.io/last-applied-configuration={"apiVersion":"v1","kind":"Service","metadata":{"annotations":{},"name":"lmsia-abc-server-service","namespace":"default"},"spec":{"ports":[{"name":"htt... Selector: app=lmsia-abc-server Type: ClusterIP IP: 10.109.20.138 Port: http 8080/TCP TargetPort: 8080/TCP Endpoints: 172.17.0.4:8080,172.17.0.5:8080 Session Affinity: None Events: ``` 上面返回的结果中,有一些关键信息: * Type: 指的是ServiceType,ClusterIP是仅供集群内访问的负载均衡IP。类似的,如果想将虚拟IP暴露给集群外,可以使用NodePort等,具体可以参考官方文档[Publising Service Types](https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types)。 * IP: 服务提同的虚拟IP地址。 * Port: 微服务进程上的端口,即HTTP的8080和RPC的3000 * TargetPort: 虚拟IP对外提供负载均衡的端口,由于我们未单独制定,默认是与上述Port保持一致的。 * Endpoints:我们在Deployment中定义的两个Pod。Service通过虚拟IP将流量分发到这两个后端Pod上。 让我们来验证下负载均衡的配置是否生效,由于rpc接口的数据格式较为复杂,在此我们仅验证http端口。 首先登录到minikube ```shell minikube ssh curl http://10.109.20.138:8080/lmsia-abc/api/ Hello, REST curl http://10.109.20.138:8080/lmsia-abc/api/ Hello, REST ``` 我们执行了两次,都成功了,那么这个请求真的被均匀地分发到后端的进程上了么?我们需要验证一下。 首先获取两个容器的ID ```shell # list pod kubectl get pods -l app=lmsia-abc-server NAME READY STATUS RESTARTS AGE lmsia-abc-server-deployment-bd4949ff9-7bgvq 1/1 Running 0 16m lmsia-abc-server-deployment-bd4949ff9-mlmlq 1/1 Running 0 16m # get container id for pod1 kubectl describe pod lmsia-abc-server-deployment-bd4949ff9-7bgvq ... Name: lmsia-abc-server-deployment-bd4949ff9-7bgvq ... Containers: lmsia-abc-server-ct: Container ID: docker://a146ee545d11638a331d1696e7e6e3c88cc3231b97f3eb50c63cb9f50724cf2c ... # get container id for pod 2 kubectl describe pod ... Name: lmsia-abc-server-deployment-bd4949ff9-mlmlq ... Containers: lmsia-abc-server-ct: Container ID: docker://608decbb198dcbdce5442a4401eeeec1cb316e483ddba2d5c993ea10081a5e6a ... ``` 登录minikube集群,分别查看两个Container的日志 ```shell minikube ssh # check pod 1 access log $ docker exec -i -t a146ee545d11638a331d1696e7e6e3c88cc3231b97f3eb50c63cb9f50724cf2c cat /app/logs/access_log.2018-05-14.log 10.0.2.15 - - [14/May/2018:07:27:57 +0000] "GET /lmsia-abc/api/ HTTP/1.1" 200 11 # check pod 2 access log $ docker exec -i -t 608decbb198dcbdce5442a4401eeeec1cb316e483ddba2d5c993ea10081a5e6a cat /app/logs/access_log.2018-05-14.log 10.0.2.15 - - [14/May/2018:07:27:56 +0000] "GET /lmsia-abc/api/ HTTP/1.1" 200 11 ``` 这里需要说明下'docker exec -i -t',是针对Docker容器执行命令,要执行的命令即后面的cat /app/logs.... 查看了两个Pod对应的Container的日志,可以发现:虽然我们的curl是访问的虚拟IP,但是流量被均衡地分发到了2个后端容器上。至此,我们已经通过Service实现了多节点的自动负载均衡。 需要指出的是:Kubernetes的虚拟IP内置了多种实现,目前以ipvs性能最好,具体可以查看[Virtual IPs and service proxies](https://kubernetes.io/docs/concepts/services-networking/service/#virtual-ips-and-service-proxies) 现在让我们来回顾下这一节的标题:"微服务的自动发现"。对于服务发现这个需求,我们目前的效果似乎并不这么完美,为什么这样说呢?我们目前是通过虚拟IP直接访问的服务,但在实际生产环境中,每个Service创建的虚拟IP并不固定,我们不可能将这些虚拟IP分别配置在依赖的众多微服务中。 幸运的是,Kubernetes早就为我们解决了这个问题。在创建Service的同时,Kubernetes还为我们创建了一条DNS记录,我们可以通过域名直接访问虚拟IP: ```shell docker exec -i -t 608decbb198dcbdce5442a4401eeeec1cb316e483ddba2d5c993ea10081a5e6a busybox wget -q -O - http://lmsia-abc-server-service:8080/lmsia-abc/api/ Hello, REST ``` 如上所示,通过lmsia-abc-server-service这个域名,就可以成功地访问虚拟IP了。对于ClusterIP的Service,域名的默认组成是'服务名.服务所在命名空间.svc.cluster.集群域名',或者简单使用`服务名`[^1],上面例子中我们采用的就是后者。 让我们用一张图来回顾下服务发现、负载均衡流程: ![基于Kubernetes的服务发现与负载均衡](./service-discovery.png "基于Kubernetes的服务发现与负载均衡") 如上图所示: 1. 约定好微服务Service的命名方式 1. 通过DNS服务获取微服务Service对应的虚拟IP(VIP) 1. 访问VIP和端口(3000) 1. Kubernetes的VIP自动完成了负载均衡,转发到后端Service B的3个节点(Pod/Docker)上 至此,我们借助Kubernetes的Service功能,"近似完美"地实现了服务的注册与发现。 为什么讲"近似完美"呢?这里还会有一个小坑。熟悉DNS协议的朋友知道,为了提升查询效率,DNS被设计成可以多级缓存的。在Java的JVM虚拟机上,也会进行DNS缓存,但这个缓存有效期默认是-1即永久。这也就意味着,如果我们删除这个Service重新创建,那么虚拟IP的变更将不会自动反馈到相应微服务的JVM中。 为了解决这个小坑,一般建议修改JVM的安全设置,修改缓存TTL时间,具体可以参考[亚马逊AWS的这篇介绍](https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/java-dg-jvm-ttl.html)。 我们为本章构建的Docker镜像也自动解决了这个问题: ```shell FROM anapsix/alpine-java:8_server-jre WORKDIR /app RUN mkdir -p /app/logs ADD lmsia-abc-server.jar /app CMD ["java", "-jar", "lmsia-abc-server.jar"] ``` 其中`anapsix/alpine-java:8_server-jre`是我们依赖的基础镜像,它将DNS Cache设置为了10秒钟,读者也可以直接使用这个基础镜像。 需要特别说明的时:若想使用上述的自动发现机制,必须使用Kubernetes的DNS服务,它默认是开启的: ```shell kubectl -n kube-system get svc kube-dns NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE kube-dns ClusterIP 10.96.0.10 53/UDP,53/TCP 3d ``` 通过Kubernetes创建的Pod(Docker),已经自动配置了上述DNS。若想在在集群外使用这个DNS,有两种方案: * 将DNS通过NodePort的方式暴露出去,可以参考[这篇讨论](https://stackoverflow.com/questions/37449121/how-to-expose-kube-dns-service-for-queries-outside-cluster) * 打通办公内网和集群内网,本书后续章节[OpenVPN + NAT 打通办公网与IDC](devops/openvpn-nat.md)将对此做出介绍。 [^1]: 这一特性并未记录在官方文档中,本书假设该特性持续有效。 ================================================ FILE: legacy/ms-discovery/service-discovery.xml ================================================ 5Vlbk5owFP41PraDBBEf1b11uu3srDPt9qkT4QjpBkJj8NJf30QSAcFdO0WdUV9MziUk5/vOyWHooHG8uuc4jb6wAGjHtoJVB910bLvr2K78U5J1Lun3tCDkJNBGhWBC/oAWWlqakQDmFUPBGBUkrQp9liTgi4oMc86WVbMZo9WnpjiEmmDiY1qXfieBiHKp17MK+QOQMDJP7lpaM8X+a8hZlujndWw02/xydYzNWtp+HuGALUsidNtBY86YyEfxagxUxdaELfe726Pd7ptDIg5y0B5zsTZnh0CGQk8ZFxELWYLpbSEdbc4HagVLziIRUznsyiGsiHgpjX8ok489NUsEX79oj82k0P0CIdaaAzgTTIqK5z4yluoV62czm2cZ9/XukWYL5iFoq34uUucquel43AOLQW5HGnCgWJBFlQJYMync2hXRlAMd0D3BtS8/uN7ZgosuP7iDcwVX72WBaQamkLlU7mo0Y/II5ai7vzNmFB/mm1gMpUHXTVeFUo5C9f85mwJPQMjargpTvuSUG/Xz0/jnt09PUnfHlBDHMoKjZDpP8zV2zHctJsAXRIbTtrbG8qD5jo3HDmWqhFhGRMAkxRtIlvJqq5JkRigdMyq3pnxRgMGb+VI+F5y9Qknj+h5MZ28BvwAuYPUmqFqLBk7usjaXq746lsXF1DXXSVS6lDzr/3lgt3s9nCqVnHoqmQidPpecI+VSwfbhoWyX3UaqhllMh75QbB0pIhLZ+DziKdAnNieCsESaTJkQLJYGVClG27amxHLd2BRrDCkJla9gO4nDMkFJIjPEdGtWO7nhoWpq9OqpgRoyw20hM/rHRbWh2j1DSmWUledevOsVcm+96/57veuBFzhN9c6zp8h128G0b3UroPYPLHdtgOqdEVT7mkBtytSjoTo4I6romlC1kX06VO1Le7kygSp3LehcTYvZzKnfAG6+Thr7/Atp5rsDt1oHT9rMt/BW5zRh+sDmIsEx7EfuEMl76MoQiyqEVagSlsAOrlqEdV/qS+Cg3PSahjUmQbCpD02cqbKqDRKYMmlI4A5qJHAaOGC3wYEW3kYaOaDe2t/HGllWQ9JfHQMQ2mmHXO/joPxzTkcI90iEeGQ4aCzxdYmEByc+XB0Nesg9Ew3ktPjGsNGVPuSg278= ================================================ FILE: legacy/ms-log/README.md ================================================ # 微服务日志监控 与前端或移动端产品不同,微服务运行于后台,我们不能直观的观察到服务端的运行状况。因此,合理地记录日志是检查服务端运行状态,查找问题的有效手段。 服务端日志的常见用途有: * 业务日志:对一些分支条件进行简单记录,方便日后排查。 * 异常记录:记录运行时异常或业务逻辑异常,方便事后排查。 * 性能定位:日志系统一般自带时间戳,可以通过此方法排查函数调用的时间消耗。 对于微服务架构,记录下来日志只是第一步,如何使用日志是更大的难点。 试想在微服务的分布式系统下,有多种微服务,每个微服务又存在多个副本,日志文件可能散落在几百个不同的路径下。 这种情况下,面临着如下挑战: * 如何快速找到某个微服务的所有(或某个)副本的日志? * 微服务之间存在调用,如何从一次完整的调用角度,来分析相关服务的日志? * 如何对异常日志进行预警? 上述挑战实际对应了日志监控的三个问题,即 * 微服务日志的收集、管理与查询 * 微服务的调用链跟踪 * 微服务异常日志的预警 本章将从微服务的日志系统展开讨论,探讨了使用Logback记录微服务日志的相关问题。 接着,我们讨论如何实现调用链的跟踪,并引入了TraceId类库。 最后,讨论如何使用"EBLK架构"对微服务的日志进行收集、管理、查询。 ================================================ FILE: legacy/ms-log/elk-devops.md ================================================ # ELK日志分析平台的运维 在上一节中,我们在日志文件中增加了调用链信息,方便我们追踪每一次调用的完整关系链条。 尽管有了追踪信息,可以更好地排查信息。但在微服务架构下,微服务众多,每个微服务又会启动若干个副本,日志文件的数量会随着文件系统迅速增加。 为了排查一个问题,我们可能要分别到十几个服务上打开几十个不同的文件,效率非常低下。 ELK就是在这种场景下营运而生的,ELK是一套数据分析套件,由Elasticsearch, Logstach, Kibana组成。在微服务架构的应用场景下,一般用来分析日志。 在ELK套件中: * Logstash负责从不同的微服务、不同的副本上收集日志文件,进行格式化。 * Elasticsearch负责日志数据的存储、索引 * Kibana提供了友好的数据可视化、分析界面 ![ELK套件流程图](./elk-stack.jpg "ELK套件流程图") 在本节中,我们暂不接入微服务的日志,单纯探讨ELK套件的运维工作。 与之前类似,我们的ELK套件将运行在Kubernetes集群上。 ## Elasticsearch的运维 Elasticsearch是ELK套件的核心与中枢。我们首先来看一下它的运维工作。 Elasticsearch的索引需要持久化存储,我们首先声明Pv: ```yaml apiVersion: v1 kind: PersistentVolume metadata: name: pv031 spec: storageClassName: standard accessModes: - ReadWriteOnce capacity: storage: 20Gi hostPath: path: /data/pv031/ ``` 然后创建一下这个pv: ```shell kubectl apply -f ./pvs.yaml ``` 下面看一下elasticserch的定义: ```yaml apiVersion: v1 kind: Service metadata: name: es spec: ports: - name: p2 port: 9200 - name: p3 port: 9300 selector: app: elasticsearch clusterIP: None --- apiVersion: apps/v1 kind: StatefulSet metadata: name: elasticsearch spec: selector: matchLabels: app: elasticsearch serviceName: "es" replicas: 1 template: metadata: labels: app: elasticsearch spec: hostname: elasticsearch containers: - name: elasticsearch-ct image: docker.elastic.co/elasticsearch/elasticsearch:6.3.1 ports: - containerPort: 9200 - containerPort: 9300 env: - name: "ES_JAVA_OPTS" value: "-Xms384m -Xmx384m" volumeMounts: - mountPath: /usr/share/elasticsearch/data name: elasticsearch-pvc volumeClaimTemplates: - metadata: name: elasticsearch-pvc spec: storageClassName: standard accessModes: - ReadWriteOnce resources: requests: storage: 20Gi ``` 如上所述: * 考虑到日志数据量大了之后,可能需要分片,我们这里采用了StatefulSet,但目前只有一台服务器。 * 暴露两个端口9200和9300,前者是Restful接口,后者是集群同步接口 * 采用IP直发,service伪组名是"es"。这样配置后,所有Pod都可以通过elasticsearch-0.es来直接访问这台服务器 启动一下: ```yaml kubctl apply -f ./elasticsearch.yaml ``` 如果启动失败,可以查看日志,可能是如下原因: ``` kubectl logs elasticsearch-0 ... vm.max_map_count < 262144 ... ``` 这种情况,可以使用具有sudo权限的帐号,更改宿主机(物理机)的配置: ```yaml sudo sysctl -w vm.max_map_count=262144 ``` 再次启动一下,可以发现启动成功: ```yaml NAME READY STATUS RESTARTS AGE elasticsearch-0 1/1 Running 4 6h ``` ## Logstash运维 在启动了Elasticsearch后,我们来看一下Logstash的运维。 前面已经提到了,我们本节先不会接入Spring Boot的日志,为了方便演示,我们先Mock一个定时任务,每间隔5秒生成日志: ```yaml apiVersion: v1 data: logstash.yml: | http.host: "0.0.0.0" xpack.monitoring.elasticsearch.url: http://elasticsearch:9200 input { heartbeat { interval => 5 message => 'Hello from Logstash 💓' } } output { elasticsearch { hosts => [ 'elasticsearch-0.es' ] user => 'elastic' password => '' } } kind: ConfigMap metadata: name: logstash-configmap ``` 上述是一个ConfigMap,我们在本书中是第一次使用它。它相当于一个可以加载的Volume,可以方便的直接追加到Pod上。 来创建这个ConfigMap: ```yaml kubectl apply -f logstash-configmap.yaml ``` 下面看一下Logstash的部署: ```yaml apiVersion: v1 kind: Service metadata: name: ls spec: ports: - name: p port: 5000 selector: app: logstash clusterIP: None --- apiVersion: apps/v1 kind: StatefulSet metadata: name: logstash spec: selector: matchLabels: app: logstash serviceName: "ls" replicas: 1 template: metadata: labels: app: logstash spec: hostname: logstash containers: - name: logstash-ct image: docker.elastic.co/logstash/logstash:6.3.1 ports: - containerPort: 5000 env: - name: "ES_JAVA_OPTS" value: "-Xms384m -Xmx384m" - name: "XPACK_MONITORING_ENABLED" value: "false" - name: "XPACK_MONITORING_ELASTICSEARCH_URL" value: "http://elasticsearch-0.es:9200" volumeMounts: - name: logstash-configmap mountPath: /usr/share/logstash/pipeline/logstash.conf subPath: logstash.conf volumes: - name: logstash-configmap configMap: name: logstash-configmap ``` 如上所述: * 我们使用了刚才配置的logstash-configmap,并覆盖到Pod的/usr/share/logstash/pipeline/logstash.conf,这个文件中 * 监控地址是elasticsearch-0.es:9200,即前面启动的es服务地址 启动一下: ```shell kubectl apply -f ./logstash.yaml ``` 稍等一会,启动成功: ```shell NAME READY STATUS RESTARTS AGE logstash-0 1/1 Running 0 7h ``` ## Kibana的运维 最后,我们来看一下Kibana的运维: ```yaml apiVersion: v1 kind: Service metadata: name: kb spec: selector: app: kibana clusterIP: None --- apiVersion: apps/v1 kind: Deployment metadata: name: kibana spec: selector: matchLabels: app: kibana replicas: 1 template: metadata: labels: app: kibana spec: hostname: kibana containers: - name: kibana-ct image: docker.elastic.co/kibana/kibana:6.3.1 ports: - containerPort: 5601 hostPort: 5601 env: - name: "ES_JAVA_OPTS" value: "-Xms384m -Xmx384m" - name: "ELASTICSEARCH_URL" value: "http://elasticsearch-0.es:9200" - name: "XPACK_MONITORING_ENABLED" value: "false" ``` 一般来说,Kibana作为前端展示组件,只需要一台就够了,我们直接用了Deployment。 尝试打开浏览器访问一下: ![Kibana界面图](./kibana-chrome.png "Kibana界面图") 如果一切顺利,可以发现,访问成功。 对于新一些的ElasticSearch/Kibana版本,可能需要先配置一下索引,比较简单,跟着向导就可以完成。 Kibana的功能非常强大,拿来做日志分析实际有点大材小用。感兴趣的话,可以参考[官方使用教程](https://www.elastic.co/guide/en/kibana/current/getting-started.html)。 我们对ELK的运维就介绍到这里。 ================================================ FILE: legacy/ms-log/sb-eblk.md ================================================ # Spring Boot整合EBLK日志分析平台 不知道你有没有注意到,这一节的标题是"EBLK日志分析平台",而上一节的标题中是"ELK日志分析平台"。是的,你没有看错,这也不是笔误。 在ELK平台中,Logstash负责收集微服务的各种Log文件,发送给ElasticSearch。 当微服务数量少、副本数也不多的时候,Logstash是可以胜任的。随着微服务数量不断增多,副本数不断增长,Logstash的负载会越来越高,极易造成单点故障。 此外,在我们的微服务架构下,各个微服务进程是运行在Kubernetes集群上的,它们的日志文件可能分散在各个物理机上。如何让单一的Logstash收集这些遍布各处的日志文件,也是一个难题。 一个简单的想法就是启用边车模式,即每个微服务启动时,同时伴随部署一个Logstash,这样就可以解决单点故障和收集的问题。 想法是好的,但Logstash本身的结构较为复杂,同时具有监听文件、网络、批处理等各种复杂功能,此外Logstash需要JVM运行环境,内存占用较大。 为了更加轻量级级的收集日志,ElasticSearch推出了Beat,我们以边车模式伴随微服务进行部署。关于Beat与Logstash的对比,可以参考[这篇文章](https://logz.io/blog/filebeat-vs-logstash/)。 Beat负责收集日志,并将日志发送给Logstash。这样看起来还是没有解决Logstash的单点故障? 是的,但经过Beat转发后,我们实际上可以配置多个Logstah结点从而解决掉单点故障。 此外,Beat可以缓存日志,当Logstash挂掉后,会自动重试。Logstash恢复后,可以继续处理日志的发送。加上B这一层后,整EBLK的架构如下所示: ![EBLK架构图](./eblk.png "EBLK架构图") 在本小节的前半部分,我们将在一个受限环境中,使用Beat收集日志,并发送给Logstash。后半部分,将讨论如何在Kubernetes中应用便车模式,让Beat伴随微服务一同启动。 ## 使用Beat收集日志 Beats是一系列日志收集工具的统称,官方推出了多种Beat,如:Filebeat, Metricbeat, Packetbeat, Winlogbeat等等,详细可以参见[官方介绍](https://www.elastic.co/products/beats)。 在我们的场景下,需要解析微服务输出的日志文件,直接用Filebeat即可。 首先来看一下FileBeat的配置: ```yaml apiVersion: v1 data: filebeat.yml: | filebeat.inputs: - type: log enabled: true multiline.pattern: '^2' multiline.negate: true multiline.match: after name: filebeat-test paths: - /usr/share/filebeat/*.log output.logstash: hosts: ["logstash-0.ls:5555"] kind: ConfigMap metadata: name: filebeat-configmap ``` 上述配置包含2个部分: * 输入监听/user/share/filebeat/下后缀为log的文件,这里只是限定环境下的测试,并非线上微服务的日志,支持多行自动合并为同一个事件(主要是异常时调用堆栈信息)。 * 输出到logstash, logstash-0.ls:5555 然后我们看一下FileBeat的服务定义: ```yaml apiVersion: v1 kind: Service metadata: name: ls spec: ports: - name: p port: 5000 selector: app: filebeat clusterIP: None --- apiVersion: apps/v1 kind: StatefulSet metadata: name: filebeat spec: selector: matchLabels: app: filebeat serviceName: "ls" replicas: 1 template: metadata: labels: app: filebeat spec: hostname: filebeat containers: - name: filebeat-ct image: docker.elastic.co/beats/filebeat:6.3.2 env: - name: "ES_JAVA_OPTS" value: "-Xms384m -Xmx384m" - name: "XPACK_MONITORING_ENABLED" value: "false" - name: "XPACK_MONITORING_ELASTICSEARCH_URL" value: "http://elasticsearch-0.es:9200" volumeMounts: - name: filebeat-configmap mountPath: /usr/share/filebeat/filebeat.yml subPath: filebeat.yml volumes: - name: filebeat-configmap configMap: name: filebeat-configmap ``` 如上所示,配置基本与之前的Logstash相同,并且加载了刚配置好的filebeat.yml。 ## Logstash汇总日志 对应地,logstash也需要做对应的调整: ```yaml apiVersion: v1 data: logstash.conf: | input { beats { port => 5555 } } filter { grok { match => {"message" => "(?m)^(?%{YEAR}-%{MONTHNUM}-%{MONTHDAY} %{TIME}) \[%{LOGLEVEL:LEVEL}\] \[(?.*?)\] \[(?.*?)\] \[tr=(?.*?)\]\s+(?.*)" } } } output { elasticsearch { hosts => [ 'elasticsearch-0.es' ] user => 'elastic' password => '' } } kind: ConfigMap metadata: name: logstash-configmap ``` 如上所示,我们修改了logstash的配置: * input是beat格式,端口5555,与上面filebeat的配置对应 * filter对输入的beat事件进行解析。这里使用了grok插件,具体的语法可以参考[官方grok插件介绍](https://www.elastic.co/guide/en/logstash/current/plugins-filters-grok.html) * output输出到elasticsearch,这里没有变化 我们重启Logstash和FileBeat后,尝试向FileBeat的Docker中写入几行日志,稍等几秒,打开Kibana,可以发现,日志已经可以检索到了。 ![接入了FileBeat后的Kibana](./kibana-filebeat.png "接入了FileBeat后的Kibana") ## 将FileBeat与Spring Boot进行整合 前面已经提到,微服务数量、副本数众多、遍布在集群的各个物理机上,日志收集、汇总起来非常麻烦,所以一般来说,需要使用边车模式,即一个微服务伴随一个日志收集器(FileBeat)。 上述模式的实现,有两个技术选择: * 使用Kubernetes的Pod多容器模式 * 手动将FileBeat打包进微服务的镜像内。 方案二比较传统,也易于理解,可以参考[这篇文章](https://stackoverflow.com/questions/47811121/dockerfile-springboot-app-with-filebeat) 而方案一,则是利用了Kubernetes的原生支持特性。 Kubernets中的最小操作单位是Pod,Pod中可以启动多个Docker容器,且同他们之间共享同样的磁盘、端口。 关于微服务的服务定义、FileBeat定义,我们前面已经分别介绍过了,所需要做的,就是将他们“粘贴”到同一个Pod里面。 这里,我不再赘述具体描述,而是作为一个思考题留给你来实现。如果实现起来有困难,可以参考[Multi-Container Pods in Kubernetes](https://linchpiner.github.io/k8s-multi-container-pods.html)。 ================================================ FILE: legacy/ms-log/sb-logback.md ================================================ # Spring Boot配置Logback及HTTP日志 系统上先后,需要进行一系列的运维、监控工作,可能还需要排查业务故障和系统问题。 服务已经上线了,不能像本地开发的一样“”打断点调试“,此时,日志的作用就非常重要了。 与其他服务框架类似,Spring Boot也默认集成了日志系统,默认采用Logback日志类库。 Logback是Log4j的作者开发的另一款日志类库,与其他同类竞品相比,它的优势有: * 更高的性能,官方说比Log4j快10倍以上 * 原生兼容slf4j * 支持多环境配置、自动切换、压缩等高级功能 在本节的前半部分,我们将讨论如何在Spring Boot中如何使用Logback。本节的后半部分,我们看一下如何在Spring Boot中启用HTTP访问日志(内嵌的Tomcat日志)。 ## Spring Boot中配置Logback 在Spring Boot中配置Logback只需要两步: * 确认类路中含有logback,这一般是通过其他starter自动带上的,例如spring-boot-starter-web * 定义配置文件:logback-spring.xml 我们来看一下配置好的文件: ```xml UTF-8 %d{yyyy-MM-dd HH:mm:ss.SSS} [%-5level] [%thread] [%logger] [tr=%mdc{TRACE_ID:-0}] %msg %n UTF-8 %d{yyyy-MM-dd HH:mm:ss.SSS} [%-5level] [%thread] [%logger] [tr=%X{TRACE_ID:-0}] %msg %n /app/logs/lmsia-abc.log /app/logs/lmsia-abc.%d{yyyy_MM_dd}.log.gz 30 ``` 如上所示,我们的配置中包含了2个Appender(可理解为两种日志输出方法): * ConsoleAppender: 直接输出到命令行 * ServerFileAppender: 输出到/app/logs/lmsia-abc.log文件中,并且:按天自动切换文件、并做gz压缩、最多保留30天。 上述切换、压缩、30天仅通过几行就搞定了,可见logback的强大之处! 在下面,我们通过对不同profile的判断,可以让不同的Appender生效。 当我们在本地执行时,默认是local的profile,此时我们只运行ConsoleAppender,即直接输出到命令行,方便调试。 当在服务器执行时,如测试环境test和生产环境online,我们只启用ServerFileAppender。因为此时没有人会看stdout的输出,都是通过看文件的方式来看日志的。 最后,我们简单看一下日志格式,即Pattern: ``` %d{yyyy-MM-dd HH:mm:ss.SSS} [%-5level] [%thread] [%logger] [tr=%X{TRACE_ID:-0}] %msg %n ``` 几个部分分别表示: * 日期,如2018-06-07 18:30:23.124 * 日志级别,INFO, ERROR等 * 日志线程,如果有名字会优先用名字,没有用线程ID * Logger名字,一般是类名 * TraceId,追踪信息,下一节将介绍它 * 消息体及换行,如果有异常及异常栈,会自动输出在后面 在代码中使用,也是非常简单: ``` import org.slf4j.Logger; import org.slf4j.LoggerFactory; private Logger LOG = LoggerFactory.getLogger(getClass()); LOG.info("Test"); ``` ## 配置Tomcat日志 Spring Boot默认内置了Tomcat服务器,从而实现了真正的"开箱即用",如何开启Tomcat的HTTP访问日志呢? 一般有两种方法: * 在yaml中配置 * 通过代码实现 其中yaml中配置的方案最简单,但每个项目都要配置一次,非常麻烦,网上资料很多,这里不做介绍了。 我们重点看一下第二种方案,我们可以将它抽成一个包,别的项目引用这个包时候,自动启用HTTP访问日志。 ```java import org.apache.catalina.valves.AccessLogValve; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.context.embedded.ConfigurableEmbeddedServletContainer; import org.springframework.boot.context.embedded.EmbeddedServletContainerCustomizer; import org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter; @Configuration @ConditionalOnWebApplication public class TomcatAccessLogConfiguration extends WebMvcConfigurerAdapter implements EmbeddedServletContainerCustomizer { private Logger LOG = LoggerFactory.getLogger(getClass()); @Override public void customize(ConfigurableEmbeddedServletContainer container) { if (container instanceof TomcatEmbeddedServletContainerFactory) { TomcatEmbeddedServletContainerFactory factory = (TomcatEmbeddedServletContainerFactory) container; AccessLogValve accessLogValve = new AccessLogValve(); accessLogValve.setEnabled(true); accessLogValve.setDirectory("/app/logs/"); accessLogValve.setPattern("common"); accessLogValve.setSuffix(".log"); factory.addContextValves(accessLogValve); } else { LOG.error("This customizer does not support your configured container!"); } } } ``` 如上所示: * 当启用Web时,自动激活这个自动配置 * 使用默认的日志格式common * 路径在/app/logs下,后缀是.log 当然不要忘记加一个spring.factories ``` org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.coder4.lmsia.commons.http.configuration.TomcatAccessLogConfiguration ``` 这样,当别的Spring Boot项目引用这个库时,就会自动启用HTTP日志了。 这个HTTP日志也是支持按天滚动,只不过不支持压缩,如果你想对其进行更多定制,推荐直接阅读Tomcat的相关源代码。 ================================================ FILE: legacy/ms-log/sb-trace.md ================================================ # Spring Boot整合分布式调用链追踪 在上一节,我们讨论了如何在Spring Boot项目中配置LogBack日志系统。 如果是传统的巨服务架构,有日志就能够满足基本的需求了。 但面对微服务,事情变得有一些复杂: * 微服务之间存在复杂的调用链路,例如A -> B -> C * 为了高可用,每个微服务可能存在多个实例 设想我们有A, B, C三个微服务,每个微服务有2个实例,在调用链A -> B -> C的过程中,发生了异常,导致某个请求挂掉了。 此时,我们已经有日志系统了,该如何检查呢?我们需要一次检查2个A服务,如果运气不好的话,可能没有异常,我们接下来检查B服务,也可能没有异常,最后检查C服务,发现了异常。 在上述任务排查过程中不难看出,在微服务架构下,各个服务的相互调用非常复杂。 实际上,我们可以引入调用链的追踪机制,来查明这种关系。 调用链追踪是这样一直机制:对于每一次调用,例如从A开始,就生成一条"调用链路"并赋一个追踪信息(后简称TraceId),调用到B时,会继承这个TraceId,如果它又调用了C服务,这个TraceId也会传递下去,直到调用链的末端。若是另一次调用链条,则会使用另一个随机生成的TraceId。 针对这种追踪机制,业界已经存在了一些较为成熟的方案,例如[Zipkin](https://zipkin.io/)能够很好的完成链路调用的追踪工作。 如果你使用的是Spring Boot全家桶,那么Zipkin可以较为方便地集成进来,可以参考[这篇教程](https://spring.io/blog/2016/02/15/distributed-tracing-with-spring-cloud-sleuth-and-spring-cloud-zipkin)。 本书将选择一种更为直接的方式:手写代码实现调用追踪,并将它整合进日志系统中。 这样做的好处有: * 如果你用过Zipkin,就能发现,它并不能覆盖全部的代码。通过手写代码的方式,我们能够更细粒度的控制追踪的实现。 * ZipKin默认是需要独立存储的,对于常年运行的系统来说,无论是运维还是机器,都会造成一定的浪费。在我们的架构下,会把追踪与日志进行融合,节省Zipkin带来的额外成本。 * 打日志时会自动带上TraceId,让调试和定位问题更加方便。 ## 利用Logback的MDC机制存储TraceId 前面已经提到,我们想要将TraceId追加到日志系统中。 幸运的是,Logback中提供了[Mapped Diagnostic Context](https://logback.qos.ch/manual/mdc.html)的功能,我们可以将一些变量存储到MDC中,在打日志中,将它打印出来。 要说明的是,MDC是线程独立、线程安全的,而在我们的架构中,无论是HTTP还是RPC请求,都是在各自独立的线程中完成的,与MDC的机制可以很好地契合。 我们来看一下TraceId的存取: ```java import org.slf4j.MDC; public class TraceIdContext { public static final String TRACE_ID_KEY = "TRACE_ID"; public static void setTraceId(String traceId) { if (traceId != null && !traceId.isEmpty()) { MDC.put(TRACE_ID_KEY, traceId); } } public static String getTraceId() { String traceId = MDC.get(TRACE_ID_KEY); if (traceId == null) { return ""; } return traceId; } public static void removeTraceId() { MDC.remove(TRACE_ID_KEY); } } ``` 如上所示: 我们直接调用MDC的put, get , remove方法完成了traceId(TraceId)的存取 traceId可以根据需求随机生成: ```java import java.util.Random; /** * @author coder4 */ public class TraceIdUtils { private static final Random random = new Random(System.currentTimeMillis()); public static String getTraceId() { // 随机正整数的16进制化 return Long.toString(Math.abs(random.nextLong()), 16); } } ``` 如上所属,我们随机生成正整数,并将其格式化为16进制字符串,方便查看。 至于TraceId的生成时机,我们稍后进行讨论。 ## 调用TraceId的全新生成 根据前面的描述,应该可以想到,当TraceId为空的情况下,我们需要生成一个新的TraceId。 换句话说,当访问是"源头"的情况下,标志着一次追踪的开始,例如: * HTTP请求开始之前 * 消息队列监听器接收新消息时 下面来看一下实现。首先,我们可以通过Filter机制,实现HTTP请求中的TraceId分配: ```java import org.springframework.web.filter.AbstractRequestLoggingFilter; import javax.servlet.http.HttpServletRequest; public class TraceIdRequestLoggingFilter extends AbstractRequestLoggingFilter { @Override protected void beforeRequest(HttpServletRequest request, String message) { TraceIdContext.setTraceId(TraceIdUtils.getTraceId()); } @Override protected void afterRequest(HttpServletRequest request, String message) { TraceIdContext.removeTraceId(); } } ``` 如上所示,我们通过Spring MVC的AbstractRequestLoggingFilter接口,在发起请求之前生成一个全新的TraceId,并在请求结束后清理这个TraceId。 当然,上述Filter需要配合一个自动配置才能生效: ```java nfiguration @ConditionalOnWebApplication public class TraceIdRequestLoggingFilterConfiguration { @Bean public TraceIdRequestLoggingFilter createTraceIdMDCFilter() { return new TraceIdRequestLoggingFilter(); } } ``` 代码比较简单,不再详细讨论了。 在消息队列的事件监听器中,也可以采取类似的方法新建TraceId: ```java public void onMessage(Message msg) { TraceIdContext.setTraceId(TraceIdUtils.getTraceId()); // do message process TraceIdContext.removeTraceId(); } ``` 当然,如果对每个事件监听器都做上述处理,未免有些麻烦,可以使用抽象基类或者AOP的方式统一,这里不再详细展开。 ## TraceId的传递 前面说了TraceId的全新生成,在另外一些情况中,只需要继承环境中已有的TraceId,不需要重新生成,例如: * RPC调用,一般情况是在HTTP请求中、或者消息队列中发起,此时系统中已有了一个TraceId * 服务内各类之间的相互调用,由于并不是与外界隔离的入口,一般都已经存在了一个TraceId,所以也不需要生成。 前面已经提到,每次完整请求都是在各自独立的线程中完成的,因此"服务内各类之间"的相互调用,不需要额外处理,直接从MDC获取TraceId即可。 我们重点看一下RPC中,如何传递TraceId。 我们的技术架构使用了Thrife RPC,可以通过自定义协议的方式,将TraceId自动传递过去: ```java import com.coder4.sbmvt.trace.TraceIdContext; import com.coder4.sbmvt.trace.TraceIdUtils; import org.apache.thrift.TException; import org.apache.thrift.protocol.TBinaryProtocol; import org.apache.thrift.protocol.TField; import org.apache.thrift.protocol.TProtocol; import org.apache.thrift.protocol.TProtocolFactory; import org.apache.thrift.protocol.TProtocolUtil; import org.apache.thrift.protocol.TType; import org.apache.thrift.transport.TTransport; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * @author coder4 */ public class TraceBinaryProtocol extends TBinaryProtocol { public static final short TRACE_ID_FIELD = Short.MAX_VALUE; private Logger LOG = LoggerFactory.getLogger(getClass()); public TraceBinaryProtocol(TTransport trans) { super(trans); } public TraceBinaryProtocol(TTransport trans, boolean strictRead, boolean strictWrite) { super(trans, strictRead, strictWrite); } public TraceBinaryProtocol(TTransport trans, long stringLengthLimit, long containerLengthLimit, boolean strictRead, boolean strictWrite) { super(trans, stringLengthLimit, containerLengthLimit, strictRead, strictWrite); } @Override public void writeFieldStop() throws TException { // get traceId from context String traceId = TraceIdContext.getTraceId(); if (traceId == null || traceId.isEmpty()) { // generate new one if not avaliable traceId = TraceIdUtils.getTraceId(); TraceIdContext.setTraceId(traceId); } // parse traceId TField field = new TField("", TType.STRING, TRACE_ID_FIELD); writeFieldBegin(field); writeString(traceId); writeFieldEnd(); // super super.writeFieldStop(); } @Override public TField readFieldBegin() throws TException { // super TField field = super.readFieldBegin(); // read traceId while (true) { switch (field.id) { case TRACE_ID_FIELD: if (field.type == TType.STRING) { // set traceId to context String traceId = readString(); TraceIdContext.setTraceId(traceId); readFieldEnd(); } else { TProtocolUtil.skip(this, field.type); LOG.error("traceId field type is not string"); } break; default: return field; } field = super.readFieldBegin(); } } public static class Factory extends TBinaryProtocol.Factory implements TProtocolFactory { public Factory() { super(); } public Factory(boolean strictRead, boolean strictWrite) { super(strictRead, strictWrite); } public Factory(boolean strictRead, boolean strictWrite, long stringLengthLimit, long containerLengthLimit) { super(strictRead, strictWrite, stringLengthLimit, containerLengthLimit); } @Override public TProtocol getProtocol(TTransport trans) { TraceBinaryProtocol protocol = new TraceBinaryProtocol(trans, stringLengthLimit_, containerLengthLimit_, strictRead_, strictWrite_); return protocol; } } } ``` 如上所示,我们继承了TBinaryProtocol,实现了TraceBinaryProtocol。 * 它在writeFieldStop即写完其他字段后,追加了一个特殊字段TRACE_ID。字段TRACE_ID对应的值,首先会从MDC中获取,若取不到则需要重新生成。 * 类似地在服务读取阶段,会检查有无TRACE_ID字段,若有将它写入到当前MDC环境中。 ## TraceID的展示 经过上面的努力,在我们的架构下,所有请求相关的处理,都会自动带上一个TRACE_ID,我们再来看一下如何将其展示在日志中: 我们在logback的Pattern中添加"[tr=%mdc{TRACE_ID:-0}]"一项,表示从MDC中获取key为TRACE_ID的数据,若取不到则打印0。 完整的Pattern如下: ```xml %d{yyyy-MM-dd HH:mm:ss.SSS} [%-5level] [%thread] [%logger] [tr=%mdc{TRACE_ID:-0}] %msg %n ``` 经过上述修改后,你可以重启一下服务,访问REST或者RPC接口。 你会发现,不同的请求中,[tc=xxx]中的TraceId会发生变化。但在同一次请求中调用了多个类,则TraceId会保持、传递下去。 ================================================ FILE: legacy/ms-monitor/README.md ================================================ # 微服务监控 ================================================ FILE: legacy/ms-monitor/k8s-prometheus-grafana.md ================================================ # Kubernetes + Prometheus + Grafana监控平台 平台监控是微服务架构中的重要一环。 例如,一个很常见的场景,某个微服务突然响应变慢,之前都是100毫米内返回,现在需要2秒才能返回结果,导致大量下游服务超时,究竟出了什么问题呢? 可能的原因有很多,举几个常见的例子: * 物理机出现了问题,被别的任务影响,占用了CPU。 * 微服务的某一个副本出现了Bug,导致死循环,响应变慢 * 微服务出现Full GC,导致响应变慢 可能的原因还有很多,那么,究竟是哪种原因导致的呢? 我们可以通过日志去查找,但查找起来很费时。而且,对于收到物理机影响等部分请款,是无法在日志中体现出来的。 此时,就是监控平台大显身手的时刻了。监控系统会收集系统中的各项性能指标,按照类型及时间进行聚合,并通过图形化界面的方式呈现出来,让我们对系统的基本运行状况一目了然,便于快速发现当前问题、查找历史问题。 对于监控平台,已经有很多优秀的开源解决方案,例如传统的Zabbix、Nagios,但这些系统比较复杂,一般需要较为专业的运维人员才能上手。 本书选用较为轻量级的Prometheus + Grafana实现监控平台。 Prometheus是一款开源的性能监控、预警系统,他的特点有: * 多维数据源、时间聚合 * 支持高级查询语句 * 支持单击和多级存储,不依赖其他分布式系统 * 数据源、服务器均支持多种配置、自动发现方式 上述特点没有提到可视化部分,是的,你已经猜到了,Prometheus只负责收集、存储、查询数据,并不包含数据可视化的部分。 一般可以通过Grafana实现监控数据的可视化,效果还是非常炫酷的,如下图所示: ![Grafana可视化](./grafana-node.png) 上图通过曲线图和仪表盘的方式,展示了k8s集群中,某台物理机的性能状况,CPU、系统负载、内存、网络等状况一目了然。 本节将主要探讨如何将Kuberntes、Prometheus、Grafana整合在一起。 ## 前期准备 在整合监控平台前,你首先需要有一个真正的Kubernetes集群,我们假设你已经搭建了一个有两个结点的集群,一台是master,另一台是slave。 如果你不了解如何搭建k8s集群,可以参考[《搭建Kubernets集群》](../devops/k8s-cluster.md)一节。 在本文提供的方案中,Grafana和Prometheus默认都是架设在Kubernetes集群内的,因此,你需要打通本地和集群网络,如果你没有头绪,可以参考[《OpenVPN访问Kubernetes集群内网》](../devops/openvpn-k8s.md)一节。 上述技术准备妥当后,我们来开始搭建监控平台。 ## 搭建监控平台 首先,我们要安装helm,这是Kubernetes的包管理系统,类似于Ubuntu中apt的地位。 ```shell wget https://storage.googleapis.com/kubernetes-helm/helm-v2.9.1-linux-amd64.tar.gz tar -xvf ./helm-v2.9.1-linux-amd64.tar.gz cd linux-amd64/ ``` helm需要初始化才能工作,但在初始化前,先需要在rbac中进行授权, tiller-rbac.yaml: ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: tiller namespace: kube-system --- apiVersion: rbac.authorization.k8s.io/v1beta1 kind: ClusterRoleBinding metadata: name: tiller roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: cluster-admin subjects: - kind: ServiceAccount name: tiller namespace: kube-system ``` 授权比较简单 ```shell kubectl apply -f ./tiller-rbac.yaml ``` 有了权限后,我们进行初始化,并添加一下第三方源 ```shell helm init --service-account tiller helm repo add coreos https://s3-eu-west-1.amazonaws.com/coreos-charts/stable/ ``` 上述执行成功后,我们开始创建prometheus: ```shell helm install coreos/prometheus-operator --name prometheus-operator --namespace monitoring ``` 创建成功后,再创建grafana,并配置其和prometheus关联: ```shell helm install coreos/kube-prometheus --name kube-prometheus --set global.rbacEnable=true --namespace monitoring ``` 由于涉及到的Pod比较多,下载镜像的时间比较长,全部下载完成后,状态应该如下所示: ```shell kubectl get pod -n monitoring NAME READY STATUS RESTARTS AGE alertmanager-kube-prometheus-0 2/2 Running 0 10m kube-prometheus-exporter-kube-state-66bccfc84-x4ngb 2/2 Running 0 10m kube-prometheus-exporter-node-62phq 1/1 Running 0 10m kube-prometheus-exporter-node-lt954 1/1 Running 0 10m kube-prometheus-grafana-f869c754-44f9k 2/2 Running 0 10m prometheus-kube-prometheus-0 3/3 Running 1 10m prometheus-operator-858c485-fkcjz 1/1 Running 0 1h ``` 对外暴露的Service如下: ```shell kubectl get service -n monitoring NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE alertmanager-operated ClusterIP None 9093/TCP,6783/TCP 11m kube-prometheus ClusterIP 10.111.94.74 9090/TCP 11m kube-prometheus-alertmanager ClusterIP 10.109.44.85 9093/TCP 11m kube-prometheus-exporter-kube-state ClusterIP 10.105.121.198 80/TCP 11m kube-prometheus-exporter-node ClusterIP 10.96.155.209 9100/TCP 11m kube-prometheus-grafana ClusterIP 10.109.181.200 80/TCP 11m prometheus-operated ClusterIP None 9090/TCP 11m ``` 其中,kube-prometheus-grafana的10.109.181.200就是Grafana的Service IP地址。 ## 监控系统展示 在打通k8s集群内网后,我们直接打开浏览器访问"10.109.181.200",即可进入Grafana图形化监控系统。 ![Grafana可视化](./grafana-pod.png) ![Grafana可视化](./grafana-statefulset.png) 如上所示,默认会从物理机(node)、容器(pod)、容器合集(statefulset)、k8s集群四个层次展示,点击左上角的按钮可以切换展示级别。点击顶部筛选条可以切换具体的机器、容器等实体。 ## 拓展与思考 1. 在本文中,我们实现了对集群中资源、实体的监控,在实际应用中,还想对微服务进行监控,例如REST接口的.99响应、错误码4xx、5xx数量。如何完成这项工作呢?请自行查找资料,并实现这类功能。 1. Prometheus除了收集监控指标外,还支持报警。如果我们想实现物理机内存占用大于90%,自动发邮件(或短信)报警,如何实现呢?请自行查找资料,实现这类功能。 ================================================ FILE: legacy/ms-monitor/sb-prometheus.md ================================================ # 整合Prometheus ================================================ FILE: legacy/ms-monitor/sb-sentry.md ================================================ # Spring Boot整合Sentry 在上一小节中,我们探讨了如何运维Senty系统。 搭建好的Sentry系统,需要接入错误事件的数据源,才能发挥功效。 在本节中,我们探讨如何将Spring Boot的微服务项目与Sentry整合起来。 ## Sentry中新建项目 Sentry中可以新建若干个项目,对应于若干微服务。 而同一微服务的若干副本应该向同一个Sentry项目发送错误信息的数据,这些副本可以通过来源IP区分。 我们来新建一个Sentry项目,如下图所示: ![Sentry中新建项目](./sentry-create-proj.png) 首先选择一个项目类型,这里选择Java 接着,选择项目名称,我们这里和微服务的名字保持一致lmsia-abc。 新建好项目后,首页会出现项目的列表,及24小时内,该项目的错误事件数量,如下图所示: ![Sentry首页展示项目](./sentry-proj2.png) ## 在Spring Boot中配置日志输出 Sentry提供了丰富多样的接入方式。 在本书中我们采用了Spring Boot默认的logback作为日志系统,Sentry也支持这种方式。 首先添加依赖: ```grovvy compile 'io.sentry:sentry-logback:1.7.5' ``` 然后将在logback的配置修改如下: ```xml UTF-8 %d{yyyy-MM-dd HH:mm:ss.SSS} [%level] [%thread] [%logger] [tr=%mdc{TRACE_ID:-0}] %msg %n UTF-8 %d{yyyy-MM-dd HH:mm:ss.SSS} [%level] [%thread] [%logger] [tr=%X{TRACE_ID:-0}] %msg %n /app/logs/lmsia-abc.log /app/logs/lmsia-abc.%d{yyyy_MM_dd}.log.gz 30 ERROR ``` 与之前的配置文件相比,主要改动为: * 新增SentryAppender。它会自动沿用之前最近的一个Pattern,及这里的ServerFileAppender。另外,只有ERROR级别的LOG,才会加到这个Appender中。 * 当test或者online的profile激活时,自动上报。你可能希望test和online上报到不同的项目中,别着急,我们后面解决这种情况。 经过上述配置后,每当发生ERROR级别的LOG,就会追加到SentryAppender中。 ## Spring Boot中配置DSN 在刚才的配置中,我们并没有制定Sentry服务的IP和端口,如何让Spring Boot知道事件要发送到哪里呢? 此外前面提到,Sentry中支持配置多个项目,如何告诉Spring Boot要发送到Sentry的哪个项目中呢? 这就需要DSN出场了,DSN是Sentry创建项目时生成的一个“KEY”,用于标识和区分不同的项目。 可以在项目的Setting中找到它,如下图所示: ![Sentry项目的DSN](./sentry-dsn.png) 然后,我们在Spring Boot项目的resources目录下,新建sentry.properties文件 ``` dsn = http://9445296bd1a5441c8988af84044890a3@sentry-host:sentry-port/2 ``` 如上配置后,就可以识别出sentry服务的位置已经对应的项目名了。 要说明的是,sentry-host和sentry-port要根据你的需要自行修改,可以是IP也可以是可DNS解析的域名。 如果你想要区分test和online环境,可以如下操作: * 建立不同的Sentry服务,或者同一个Senty服务下建立不同的项目 * 根据不同的profile分别创建sentry.properties文件,如sentry.properties.test, sentry.properties.online,里面配置不同的dsn key * 打Docker镜像时加载不同的文件,并重命名为sentry.properties ## 实验异常发生的效果 为了实验效果,我们在微服务代码中,主动抛出一个异常,然后看一下Sentry的lmsia-abc项目: ![Sentry项目的异常预警](./sentry-err.png) 可以看到,我们的异常被Sentry捕获,并显示了出来。如果同样的ERROR级别LOG发生了多次,还会自动聚合。 至此,我们完成了Spring Boot与Sentry的整合工作。 ================================================ FILE: legacy/ms-monitor/sentry-devops.md ================================================ # Sentry 错误预警系统的运维 在上一章中,我们介绍了EBLK的日志分析平台。 在日志分析平台上,我们可以很方便的查找系统的日志。然而,EBLK并总是能满足需求: * 日志绝大多数是INFO等级的,即信息日志。如果系统运行出现问题,我们想从中查找,实际希望的是找到ERROR类型的或者异常信息。 * 我们经常需要排查一些历史故障,即需要增加时间维度的信息 * 我们希望经常出现的类似错误,能够聚合在一起,方便我们排查 Sentry是一个实时的事件日志和聚合平台,我们可以通过配置,把所有ERROR类型的日志发送给Sentry保存下来,并通过其聚合结果,迅速的定位线上问题。 在本节中,我们将探讨Sentry预警系统的运维工作。 由于Sentry服务本身比较复杂,涉及多个步骤初始化步骤及多个Volume,因此我们不再采用Kubernetes集群,而是通过Docker直接部署在某台物理机上。 ## Sentry存储依赖的部署 Sentry服务需要使用两个存储:Redis、Postgres,我们首先启动这两个依赖服务。 首先创建redis, sentry-redis.sh: ```shell #!/bin/bash NAME="sentry-redis" VOLUME="/home/coder4/docker_data/sentry-redis" # make sure volume valid mkdir -p $VOLUME && sudo chmod -R 777 $VOLUME # kill old and run new docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ --volume "$VOLUME":/data \ --detach \ --restart always \ redis:4 ``` 如上所述: * 创建了基于redis 4的容器 * 设置volume到/data,这样,重启redis并不会导致数据丢失 接着,我们创建Postegres数据库, sentry_postgres.sh: ```shell #!/bin/bash NAME="sentry-postgres" VOLUME="/home/coder4/docker_data/sentry-postgres" POSTGRES_DB_USER="sentry" POSTGRES_DB_PASS="sentry_pass" # make sure volume valid sudo mkdir -p $VOLUME && sudo chmod -R 777 $VOLUME # kill old and run new docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ --volume "$VOLUME":/var/lib/postgresql/data \ --env POSTGRES_USER=$POSTGRES_DB_USER \ --env POSTGRES_PASSWORD=$POSTGRES_DB_PASS \ --detach \ --restart always \ postgres:10 ``` ## Sentry 服务的部署 在正式部署Sentry之前,首先要生成key: ```shell docker run --rm sentry config generate-secret-key ``` 输出结果是 ``` q5%_k4t#w#43rlnd(mr1ms%p8(mjofh9z&4al8d1q&a3f#19_d ``` 记住这个key,我们马上会用到。 下面,我们对Sentry进行初始化: ```shell #!/bin/bash NAME="sentry-main" REDIS_LINK="sentry-redis:redis" POSTGRES_LINK="sentry-postgres:postgres" SENTRY_SECRET="q5%_k4t#w#43rlnd(mr1ms%p8(mjofh9z&4al8d1q&a3f#19_d" # kill old and run new docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ --env SENTRY_SECRET_KEY=$SENTRY_SECRET \ -it \ --restart always \ --link $REDIS_LINK \ --link $POSTGRES_LINK \ sentry:9 upgrade ``` 在执行db操作一段时间后,会有一个交互,如下: ``` Email: lihy@coder4.com Password: Repeat for confirmation: Should this user be a superuser? [y/N]: y User created: lihy@coder4.com ... ``` 创建好的用户,就是之后默认的管理员用户 初始化完毕后,我们正式创建Sentry: ```shell #!/bin/bash NAME="sentry-main" REDIS_LINK="sentry-redis:redis" POSTGRES_LINK="sentry-postgres:postgres" SENTRY_SECRET="q5%_k4t#w#43rlnd(mr1ms%p8(mjofh9z&4al8d1q&a3f#19_d" # kill old and run new docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ -p 8080:9000 \ --env SENTRY_SECRET_KEY=$SENTRY_SECRET \ --detach \ --restart always \ --link $REDIS_LINK \ --link $POSTGRES_LINK \ sentry:9 ``` 与上面的初始化相比,脚本基本是类似的,差别在于: * 使用的是后台运行detach而非-it交互模式 * 没有执行upgrade命令 * 开放了8080端口 执行成功后,我们访问http://localhost:8080,即可成功进入登录界面,如下图所示: ![Sentry登录界面](./sentry-login.png) 如果你现在登录的话,会提示"Background workers havn't checked in recently...",即后台收集进程&定时任务没有启动。 我们首先来启动收集进程: ```shell #!/bin/bash NAME="sentry-worker" REDIS_LINK="sentry-redis:redis" POSTGRES_LINK="sentry-postgres:postgres" SENTRY_SECRET="q5%_k4t#w#43rlnd(mr1ms%p8(mjofh9z&4al8d1q&a3f#19_d" # kill old and run new docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ --env SENTRY_SECRET_KEY=$SENTRY_SECRET \ --detach \ --restart always \ --link $REDIS_LINK \ --link $POSTGRES_LINK \ sentry:9 run worker ``` 然后启动定时任务: ```shell #!/bin/bash NAME="sentry-cron" REDIS_LINK="sentry-redis:redis" POSTGRES_LINK="sentry-postgres:postgres" SENTRY_SECRET="q5%_k4t#w#43rlnd(mr1ms%p8(mjofh9z&4al8d1q&a3f#19_d" # kill old and run new docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ --env SENTRY_SECRET_KEY=$SENTRY_SECRET \ --detach \ --restart always \ --link $REDIS_LINK \ --link $POSTGRES_LINK \ sentry:9 run cron ``` 启动后稍等一会,我们用初始化时设置的用户名、密码登录系统,进入初始化界面: ![Sentry初始配置界面](./sentry-config.png) 配置如下: * Root URL写入一个可以DNS解析的域名,例如sentry.coder4.com * Admin Email写入任意一个邮件地址,例如sentry@coder4.com 配置好后,进入如下的登台添加新项目界面,至此,Sentry的部署工作配置完成。 ![Sentry完成界面](./sentry-ready.png) ## 思考与拓展 * 在本节的部署中,我们使用了默认的账户配置模式。前面介绍过,在团队内部,应该使用统一的帐号系统以提升效率。如何让Sentry系统接入LDAP帐号服务呢,请自行查找资料,实现这个功能。 ================================================ FILE: legacy/ms-monitor/sentry.txt ================================================ https://laravel-china.org/articles/4285/build-your-own-sentry-service ================================================ FILE: legacy/ms-msgq/README.md ================================================ # 微服务的消息队列 ================================================ FILE: legacy/ms-msgq/dev-kafka.md ================================================ # Kafka 流处理开发简介 在上一节,我们介绍了分布式流处理平台Kafka的运维工作,在这一节,我们将讨论Kafka的应用开发。 你可能已经注意到,这一节的标题并不是"在微服务中的集成",而是"开发简介"。 使得,如在前文所述,Kafka的吞吐性能出色,但延迟性能一般,因此多用于离线处理的场景,典型应用有: * 异源数据[^1]的同步、转换、备份。即"数据搬运工" * 处理从多处收集的日志 * Event Sourcing模式的事件回放 对于数据搬运工类的需求,建议优先考虑[Kafka Connect](http://kafka.apache.org/documentation/#connect_overview)。这是Kafka官方提供的一组工具集,可以通过简单的配置,就完成数据的同步和一些转换的工作。 对于Event Sourcing的模式开发,和日志处理收集的开发模式基本一致。 在此,我们用较短的篇幅,对Kafka的开发做一个简要的介绍。 在研究代码之前,有一些必须明确的基本概念: * Topics: 可以理解为队列,同种消息应放入相同的Topic内。 * Partition: Topic下可划分为多个分区, 便于并行处理。 * Replicas: Topic的Partition可以划分为多个副本,从而实现高可用保证。 * Producer: 消息的生产者。 * Consumer: 消息的消费者。 * Consumer Group: 每个消费者可以设定一个Consumer Group。Kafka保证: 同一个消息,只投递给相同Consumer Group中的一台Consumer。换句话说,注册在同一个Consumer Group下的Consumer,可以并行的处理消息。 下面,我们看一下生产者 ``` //import util.properties packages import java.util.Properties; //import simple producer packages import org.apache.kafka.clients.producer.Producer; //import KafkaProducer packages import org.apache.kafka.clients.producer.KafkaProducer; //import ProducerRecord packages import org.apache.kafka.clients.producer.ProducerRecord; //Create java class named “SimpleProducer" public class SimpleProducer { public static void main(String[] args) throws Exception{ // Check arguments length value if(args.length == 0){ System.out.println("Enter topic name"); return; } //Assign topicName to string variable String topicName = args[0].toString(); // create instance for properties to access producer configs Properties props = new Properties(); //Assign localhost id props.put("bootstrap.servers", "localhost:9092"); //Set acknowledgements for producer requests. props.put("acks", "all"); //If the request fails, the producer can automatically retry, props.put("retries", 0); //Specify buffer size in config props.put("batch.size", 16384); //Reduce the no of requests less than 0 props.put("linger.ms", 1); //The buffer.memory controls the total amount of memory available to the producer for buffering. props.put("buffer.memory", 33554432); props.put("key.serializer", "org.apache.kafka.common.serializa-tion.StringSerializer"); props.put("value.serializer", "org.apache.kafka.common.serializa-tion.StringSerializer"); Producer producer = new KafkaProducer (props); for(int i = 0; i < 10; i++) producer.send(new ProducerRecord(topicName, Integer.toString(i), Integer.toString(i))); System.out.println("Message sent successfully"); producer.close(); } } ``` 简单解释下代码: 1. 连接localhost:9092 1. 收到消息后自动ack 1. 设置缓存大小等配置 1. 消息的key和value都是string类型,配置对应的序列化类 1. 发送10个消息 下面来看一下Consumer代码 ```java import java.util.Properties; import java.util.Arrays; import org.apache.kafka.clients.consumer.KafkaConsumer; import org.apache.kafka.clients.consumer.ConsumerRecords; import org.apache.kafka.clients.consumer.ConsumerRecord; public class ConsumerGroup { public static void main(String[] args) throws Exception { if(args.length < 2){ System.out.println("Usage: consumer "); return; } String topic = args[0].toString(); String group = args[1].toString(); Properties props = new Properties(); props.put("bootstrap.servers", "localhost:9092"); props.put("group.id", group); props.put("enable.auto.commit", "true"); props.put("auto.commit.interval.ms", "1000"); props.put("session.timeout.ms", "30000"); props.put("key.deserializer", "org.apache.kafka.common.serializa-tion.StringDeserializer"); props.put("value.deserializer", "org.apache.kafka.common.serializa-tion.StringDeserializer"); KafkaConsumer consumer = new KafkaConsumer(props); consumer.subscribe(Arrays.asList(topic)); System.out.println("Subscribed to topic " + topic); int i = 0; while (true) { ConsumerRecords records = con-sumer.poll(100); for (ConsumerRecord record : records) System.out.printf("offset = %d, key = %s, value = %s\n", record.offset(), record.key(), record.value()); } } } ``` 基本的配置与Producer类似,这里i不再重复了,能够并行处理的关键是"group.id"这个参数。 如果同时启动2个Consumer进程,会发现消息是在两个Consumer进程中,交替输出的。 上述代码参考自[Tutorial Spoint的Kafka教程](https://www.tutorialspoint.com/apache_kafka/),这是一部非常好的Kafka教程,如果你没有时间完整阅读官方文档,强烈推荐你读一下这部教程。 [^1]: 异源指的是不同数据源,例如MySQL和Kafka之间、HDFS和Kafka之间。 ================================================ FILE: legacy/ms-msgq/kafka-devops.md ================================================ # Kafka流处理平台的运维 Kafka是高性能的分布式流处理平台,它的特点有: * 类似于传统的消息队列,为海量流式数据提供了消息发布/订阅模型。 * 支持容错的流式数据存储。 * 流式数据的实时处理。 Kafka是一款吞吐性能非常优秀的分布式流处理系。虽然吞吐性能优秀,但Kafka的处理延迟较高,一般多用于日志等离线处理,不会用于实时的消息队列系统。 本节将讨论Kafka集群的部署。 与我们之前讨论的MySQL、Memcached等组件稍有不同: * Kafka对I/O资源消耗较大,使用Volume挂载的方式,存在一定性能损耗。 * 并且Kafka本身内置了高可用、集群的功能。 * Kafka依赖Zookeeper,后者对资源波动较为敏感,一般需要独立部署。 综上所述,对于Kafka和其依赖的ZooKeeper,我们将在服务器上独立部署,而不会将其部署在Kubernetes集群中。 ## 准备Java环境 我们假设你手里的是仅安装了操作系统的"裸机"服务器,在这里,我们以以Ubuntu 18.04为例进行讲解。 首先准备Java的apt源 ```shell sudo add-apt-repository -y ppa:webupd8team/java sudo apt update ``` 然后,自动同意许可、自动安装 ```shelll echo debconf shared/accepted-oracle-license-v1-1 select true | sudo debconf-set-selections echo debconf shared/accepted-oracle-license-v1-1 seen true | sudo debconf-set-selections sudo apt install -y oracle-java8-set-default ``` 安装好后,我们验证一下 ```shell java -version java version "1.8.0_171" Java(TM) SE Runtime Environment (build 1.8.0_171-b11) Java HotSpot(TM) 64-Bit Server VM (build 25.171-b11, mixed mode) ``` 部署Kafka集群至少需要6台机器,3台给Zookeeper,另外3台给Kafka的Broker。 为了说明方便,我们假设6台机器的主机名分别为zk1~zk3,kafka1~kafka3 请在6台机器上都进行上述Java 8的安装。 ## 准备主机环境 zk和kafka集群的部署,都依赖2个先决条件: * 主机之间必须支持内网访问 * 内网可以通过hostname直接访问 由于是在同一个机房内部署,所以我们假设上述条件1是满足的。 对于条件2,有多种实现方案,我们这里采用最传统的hostname修改方式。 对于上述6台主机,内网IP分别为: * z1~zk3:192.168.0.11 ~ 192.168.0.13 * kafka1~kafka3:192.168.0.21 ~ 192.168.0.23 则我们修改6台主机的hosts文件如下: ```shell sudo vim /etc/hosts 127.0.0.1 localhost #127.0.1.1 zk2 # The following lines are desirable for IPv6 capable hosts ::1 localhost ip6-localhost ip6-loopback ff02::1 ip6-allnodes ff02::2 ip6-allrouters 192.168.0.11 zk1 192.168.0.12 zk2 192.168.0.13 zk3 192.168.0.21 kafka1 192.168.0.22 kafka2 192.168.0.23 kafka3 ``` 修改后,任意一台机器应该都可以通过hostname来ping通其他主机,例如: ```shell zk1$ ping kafka2 PING baidu.com (192.168.0.22) 56(84) bytes of data. 64 bytes from 192.168.0.22: icmp_seq=1 ttl=47 time=10.0 ms ... ``` 注意,在上面的配置中,我们还去掉127.0.1.1的映射,最终效果是ping也会返回内网ip,而不是127.0.1.1: ```shell ping zk2 PING zk2 (192.168.0.12) 56(84) bytes of data. 64 bytes from zk2 (192.168.0.12): icmp_seq=1 ttl=64 time=0.046 ms ``` ## 部署Zookeeper 接下来,我们将在zk1~zk3上部署zookeeper,请确认这三台机器已经安装了Java 8。 首先为zookeeper创建本地用户,在zk1~zk3上分别执行: ```shell useradd -m zookeeper ``` 下载并解压到本地,同样在zk1~zk3上分别执行: ```shell su zookeeper cd $HOME wget https://archive.apache.org/dist/zookeeper/zookeeper-3.4.12/zookeeper-3.4.12.tar.gz tar -xzvf ./zookeeper-3.4.12.tar.gz ln -s zookeeper-3.4.12 zookeeper mkdir /home/zookeeper/zookeeper_data ``` 注意最后创建了一个文件夹,用于储存zk的数据文件 为zk1和zk3添加不同的id ```shell zookeeper@zk1:~$ echo "1" > /home/zookeeper/zookeeper_data/myid zookeeper@zk2:~$ echo "2" > /home/zookeeper/zookeeper_data/myid zookeeper@zk3:~$ echo "3" > /home/zookeeper/zookeeper_data/myid ``` 在zk1~zk3上分别添加配置 ```shell vim /home/zookeeper/zookeeper/conf/zoo.cfg tickTime=2000 dataDir=/home/zookeeper/zookeeper_data clientPort=2181 initLimit=5 syncLimit=2 server.1=zk1:2888:3888 server.2=zk2:2888:3888 server.3=zk3:2888:3888 ``` 在zk1~zk3上启动zookeeper ```shell cd /home/zookeeper/zookeeper/bin ./zkServer.sh start ``` 启动后,可以在zookeeper.out中查看错误输出日志。 如果一切正常,我们用客户端尝试连接。 ```shell ./zookeeper/bin/zkCli.sh -server zk1:2181,zk2:2181,zk3:2181 .... [zk: zk1:2181,zk2:2181,zk3:2181(CONNECTED) 0] ... ``` 如上如果能显示"CONNECTED",就是连接成功了。 我们尝试创建结点,也能成功: ```shell [zk: zk1:2181,zk2:2181,zk3:2181(CONNECTED) 5] create /hello world Created /hello [zk: zk1:2181,zk2:2181,zk3:2181(CONNECTED) 6] get /hello world cZxid = 0x100000002 ctime = Tue Jun 12 11:36:22 UTC 2018 mZxid = 0x100000002 mtime = Tue Jun 12 11:36:22 UTC 2018 pZxid = 0x100000002 cversion = 0 dataVersion = 0 aclVersion = 0 ephemeralOwner = 0x0 dataLength = 5 numChildren = 0 ``` 至此,我们已经完成了zookeeper集群的配置。 ## Kafka集群配置 首先为kafka创建本地用户,在kafka1~kafka3上分别执行: ```shell useradd -m kafka ``` 下载kafka并解压缩 ```shell su kafka cd $HOME wget http://www-eu.apache.org/dist/kafka/1.1.0/kafka_2.11-1.1.0.tgz tar -xzvf kafka_2.11-1.1.0.tgz ln -s kafka_2.11-1.1.0 kafka ``` 创建数据目录 ```shell mkdir /home/kafka/kafka_logs ``` 配置文件(kafka1) ```shell vim kafka/config/server.properties broker.id=1 zookeeper.connect=zk1:2181,zk2:2181,zk3:2181 listeners=PLAINTEXT://kafka1:9092 log.dirs=/home/kafka/kafka_logs ``` 分别在kafka1~kafka3上启动 ```shell cd $HOME kafka/bin/kafka-server-start.sh -daemon ./kafka/config/server.properties ``` 创建队列(topic) ```shell kafka/bin/kafka-topics.sh --create --zookeeper zk1:2181,zk2:2181,zk3:2181 --replication-factor 2 --partitions 3 --topic topic1 Created topic "topic1". ``` 查看队列(topic) ```shell kafka/bin/kafka-topics.sh --describe --zookeeper zk1:2181,zk2:2181,zk3:2181 --topic topic1 Topic:topic1 PartitionCount:3 ReplicationFactor:2 Configs: Topic: topic1 Partition: 0 Leader: 2 Replicas: 2,1 Isr: 2,1 Topic: topic1 Partition: 1 Leader: 1 Replicas: 1,2 Isr: 1,2 Topic: topic1 Partition: 2 Leader: 2 Replicas: 2,1 Isr: 2,1 ``` 列出所有队列(topic) ```shell kafka/bin/kafka-topics.sh --list --zookeeper zk1:2181,zk2:2181,zk3:2181 topic1 ``` 生产消息 ```shell kafka/bin/kafka-console-producer.sh --broker-list kafka1:9092,kafka2:9092,kafka3:9092 --topic topic1 >a >b ``` 消费消息 ```shell kafka/bin/kafka-console-consumer.sh --zookeeper zk1:2181,zk2:2181,zk3:2181 --topic topic1 --from-beginning a b ``` 删除队列 ```shell kafka/bin/kafka-topics.sh --delete --zookeeper zk1:2181,zk2:2181,zk3:2181 --topic topic1 Topic topic1 is marked for deletion. ``` 至此,我们完成了Kafka的集群配置,更多内容可以参考[Kafka 官方文档](https://kafka.apache.org/documentation/)。 ================================================ FILE: legacy/ms-msgq/rabbitmq-devops.md ================================================ # RabbitMQ 消息队列 RabbitMQ支持单机部署,也提供了"高可用"的集群部署方式,以提升性能和(或)可用性。 RabbitMQ支持两种形式的集群模式: * 普通模式: 默认的模式。消息队列的数据结构存在于每个节点上,但实际消息只存储于某一个节点上。这种模式的优点是性能较高。缺点是,一旦存储数据的节点挂掉,消息就暂时不可用了,需要节点启动后才能恢复。 * 镜像模式: 在普通模式的基础上可配置需要镜像的队列。配置队列后,消息数据会自动同步到集群中的每个节点上。该模式的优点是可用性高,缺点是性能相对较低。 在本节,我们将首先配置普通模式的集群,然后配置镜像队列。 ## Kubernetes下配置RabbitMQ集群 与Memcached类似,RabbitMQ集群中的节点,是相互独立的,而不是可替代的,因此我们也使用StatefulSet。 然而,RabbitMQ比Memcached更为复杂,他需要磁盘存储,即在StatefulSet上的Volume。这种情况下,是无法直接创建Volume挂载点的,而是需要先手动创建Persistent Volume(简称PV),然后再通过Persistent Volume Claim(简称PVC)去关联创建可用的PV。上述过程可以参考Kubernetes的[Persistent Volume文档](https://kubernetes.io/docs/concepts/storage/persistent-volumes/)。 我们首先创建3个PV, pvs.yaml: ```yaml apiVersion: v1 kind: PersistentVolume metadata: name: pv001 spec: storageClassName: standard accessModes: - ReadWriteOnce capacity: storage: 20Gi hostPath: path: /data/pv001/ --- apiVersion: v1 kind: PersistentVolume metadata: name: pv002 spec: storageClassName: standard accessModes: - ReadWriteOnce capacity: storage: 20Gi hostPath: path: /data/pv002/ --- apiVersion: v1 kind: PersistentVolume metadata: name: pv003 spec: storageClassName: standard accessModes: - ReadWriteOnce capacity: storage: 20Gi hostPath: path: /data/pv003/ ``` 在这里,我们使用的是minikube做演示,因而直接创建基于路径的Volume,在实际生产中,你可能需要创建支持自动迁移的Volume,具体可以参考[Storage Classes](https://kubernetes.io/docs/concepts/storage/storage-classes/)。 我们应用一下: ```yaml kubectl apply -f pvs.yaml persistentvolume "pv001" created persistentvolume "pv002" created persistentvolume "pv003" created ``` 在创建RabbitMQ集群之前,我们先要针对rabnbitmq这个metadata修改rbac。rbac是Kubernetes的安全性访问限制,具体原因可以参考这个[Issue](https://github.com/rabbitmq/rabbitmq-peer-discovery-k8s/issues/15) rabbitmq-rbac.yaml: ```yaml --- apiVersion: v1 kind: ServiceAccount metadata: name: rabbitmq --- kind: Role apiVersion: rbac.authorization.k8s.io/v1beta1 metadata: name: rabbitmq rules: - apiGroups: [""] resources: ["endpoints"] verbs: ["get"] --- kind: RoleBinding apiVersion: rbac.authorization.k8s.io/v1beta1 metadata: name: rabbitmq roleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: rabbitmq subjects: - kind: ServiceAccount name: rabbitmq ``` 上述修改了rabbitmq的ServiceSccount、Role、RoleBinding默认安全设置,简要来说是允许其访问get和endpoints这两个Kubernetes提供的API。 我们应用上述修改,修改成功: ```yaml kubectl apply -f ./rabbitmq-rbac.yaml serviceaccount "rabbitmq" configured role.rbac.authorization.k8s.io "rabbitmq" configured rolebinding.rbac.authorization.k8s.io "rabbitmq" configured ``` 在创建了pv,修改了rbac后,可以创建rabbitmq集群了,我们来看一下描述文件,rabbitmq-service.yaml: ```yaml kind: Service apiVersion: v1 metadata: name: rabbitmq labels: app: rabbitmq spec: type: NodePort ports: - name: http protocol: TCP port: 15672 targetPort: 15672 nodePort: 31672 selector: app: rabbitmq --- apiVersion: apps/v1 kind: StatefulSet metadata: name: rabbitmq spec: selector: matchLabels: app: rabbitmq serviceName: rabbitmq replicas: 3 template: metadata: labels: app: rabbitmq spec: serviceAccountName: rabbitmq terminationGracePeriodSeconds: 10 containers: - name: rabbitmq-autocluster image: pivotalrabbitmq/rabbitmq-autocluster ports: - name: http protocol: TCP containerPort: 15672 - name: amqp protocol: TCP containerPort: 5672 livenessProbe: exec: command: ["rabbitmqctl", "status"] initialDelaySeconds: 30 timeoutSeconds: 5 readinessProbe: exec: command: ["rabbitmqctl", "status"] initialDelaySeconds: 10 timeoutSeconds: 5 volumeMounts: - mountPath: /var/lib/rabbitmq/mnesia name: rabbitmq-pvc env: - name: MY_POD_IP valueFrom: fieldRef: fieldPath: status.podIP - name: RABBITMQ_USE_LONGNAME value: "true" - name: RABBITMQ_NODENAME value: "rabbit@$(MY_POD_IP)" - name: AUTOCLUSTER_TYPE value: "k8s" - name: AUTOCLUSTER_DELAY value: "10" - name: K8S_ADDRESS_TYPE value: "ip" - name: AUTOCLUSTER_CLEANUP value: "true" - name: CLEANUP_WARN_ONLY value: "false" volumeClaimTemplates: - metadata: name: rabbitmq-pvc spec: storageClassName: standard accessModes: - ReadWriteOnce resources: requests: storage: 20Gi ``` 解释一下上面的描述文件: * 创建了Headless Service用于暴露管理界面的Web端口31672。这里主要是为了演示,在实际应用中,这个可能是没必要的。 * 创建了rabbitmq的StatefulSet,含有3个节点,其中每个节点通过livenessProbe和readinessProbe检查可用性。 * 使用volumeClaimTemplates自动生成volumeClaim,这里的template即模板,会自动给StatefulSet中的每一个节点创建一个Volume Claim,命名为rabbitmq-pvc-0/1/2 下面我们来应用下上面的描述: ``` kubectl apply -f ./rabbitmq-service.yaml service "rabbitmq" created statefulset.apps "rabbitmq" created ``` 稍过一会后,我们来看一下Web服务器的管理界面,http://192.168.99.100,用户名密码都是guest: ![查看RabbitMQ集群](./rabbitmq-cluster-status.png "查看RabbitMQ集群") 登录成功后,如上图所示。不难发现,已经成功启动了3个节点,并组成了集群,至此,我们的RabbitMQ基本集群配置成功。 ## 配置镜像集群 下面,我们来配置镜像策略,在Web管理工具上点击"Admin" -> "Policies" -> "Add / update a policy",如下填写: * Name: ha_mirror_queue * Pattern: ^ * Apply to: All exchange and queues * Defination: * ha-mode: all 添加好后点击"Add" 配置好后,我们尝试创建一个Queue,在Web管理工具点击"Queues" -> "Add queue",name写test,其他保持默认,最后点击"Add queue"。 添加完成后,可以发现队列有一个"+2"的标志,如下图所示。这个+2意思是队列有额外的2个备份(主1+镜2一共3个节点),镜像配置成功。 ![查看RabbitMQ镜像队列](./rabbitmq-mirror-queue.png "查看RabbitMQ镜像队列") 至此,我们已经完成了RabbitMQ的集群配置、镜像队列配置。 关于Rabbit MQ的高可用、集群的更相信信息,可以查看官方文档:[RabbitMQ 集群](http://www.rabbitmq.com/clustering.html)。 ================================================ FILE: legacy/ms-msgq/rocketmq-devops.md ================================================ # RocketMQ 消息队列的运维 在前两节,我们讨论了RabbitMQ。 然而根据实际的生产经验来看,当系统瞬时流量达到一定规模时,上述两款产品都不再适合作为消息系统的首选。 RabbitMQ在企业级应用是没有问题的,但它的抗消息堆积能力非常差,一旦突发流量提高,发生事件堆积,整个RabbitMQ集群就会挂起拒绝接受新消息,在极端情况下,甚至会发生RabbitMQ集群的宕机和消息丢失。 RocketMQ是一款高性能的分布式消息队列,它借鉴了Kafka的设计思路并继承了其高吞吐的特点,并重点改进了延迟,使得其更加适用于消息的实时处理。根据官方评测,在主流服务器上,RocketMQ的处理性能可达7万/秒,且随着Topic数量(队列数目)的增长而基本保持稳定,比Kafka更为稳定[^1]。 在本小节,我们将讨论RocketMQ的运维,一般有两种方案。 * RocketMQ部署在多台物理机上,优点是性能可靠。可以参考[官方集群部署文档](https://rocketmq.apache.org/docs/rmq-deployment/)。 * RocketMQ部署在容器集群上,优点是运维方便。本小节将主要介绍这种方案。 RocketMQ服务器有两种角色: * NameServer: 管理元数据和Broker服务器、客户端的连接入口 * Broker: 处理消息队列的服务器 在本小节,我们将构建4台服务器的RocketMQ集群,2台NameServer、2台Broker。 首先构建4个PersistentVolume,给上述4台机器使用: ```yaml apiVersion: v1 kind: PersistentVolume metadata: name: pv011 spec: storageClassName: standard accessModes: - ReadWriteOnce capacity: storage: 20Gi hostPath: path: /data/pv011/ --- apiVersion: v1 kind: PersistentVolume metadata: name: pv012 spec: storageClassName: standard accessModes: - ReadWriteOnce capacity: storage: 20Gi hostPath: path: /data/pv012/ --- apiVersion: v1 kind: PersistentVolume metadata: name: pv021 spec: storageClassName: standard accessModes: - ReadWriteOnce capacity: storage: 20Gi hostPath: path: /data/pv021/ --- apiVersion: v1 kind: PersistentVolume metadata: name: pv022 spec: storageClassName: standard accessModes: - ReadWriteOnce capacity: storage: 20Gi hostPath: path: /data/pv022/ ``` 应用4个持久化目录: ``` kubectl -f ./pvs.yaml ``` 接着,看一下NameServer的部署: ```yaml apiVersion: v1 kind: Service metadata: name: rn spec: ports: - port: 9876 selector: app: rocketmq-nameserver clusterIP: None --- apiVersion: apps/v1 kind: StatefulSet metadata: name: rocketmq-nameserver spec: selector: matchLabels: app: rocketmq-nameserver serviceName: "rn" replicas: 2 template: metadata: labels: app: rocketmq-nameserver spec: restartPolicy: Always hostname: rocketmq-nameserver containers: - name: rocketmq-nameserver-ct imagePullPolicy: Never image: coder4/rocketmq:4.2.0 ports: - containerPort: 9876 volumeMounts: - mountPath: /opt/rocketmq_home name: rocketmq-nameserver-pvc env: - name: NAME_SERVER value: "true" volumeClaimTemplates: - metadata: name: rocketmq-nameserver-pvc spec: storageClassName: standard accessModes: - ReadWriteOnce resources: requests: storage: 20Gi ``` 如上所示: * 我们使用了定制镜像coder4/rocketmq,它集成了NameServer/Broker并支持集群部署 * 使用StatefulSet部署两台相互独立的NameServer,主机名分别为rocketmq-nameserver-0和rocketmq-nameserver-1 * Volume的挂在点是/opt/rocketmq_home,其中会包含data和log两个子目录。 启动一下,稍后成功: ```yaml kubectl apply -f ./nameserver-service.yaml kubectl get pods NAME READY STATUS RESTARTS AGE rocketmq-nameserver-0 1/1 Running 0 2m rocketmq-nameserver-1 1/1 Running 0 2m ``` 接下来,我们看一下Broker。 针对Broker,官方提供了几种高可用方案,我们这里采用"two master no slave"的模式,更多模式可参考官方文档。 ```yaml piVersion: v1 kind: Service metadata: name: rb spec: ports: - name: p9 port: 10909 - name: p11 port: 10911 selector: app: rocketmq-brokerserver clusterIP: None --- apiVersion: apps/v1 kind: StatefulSet metadata: name: rocketmq-brokerserver spec: selector: matchLabels: app: rocketmq-brokerserver serviceName: "rb" replicas: 2 template: metadata: labels: app: rocketmq-brokerserver spec: restartPolicy: Always hostname: rocketmq-brokerserver containers: - name: rocketmq-brokerserver-ct imagePullPolicy: Never image: rocketmq:latest ports: - containerPort: 10909 - containerPort: 10911 volumeMounts: - mountPath: /opt/rocketmq_home name: rocketmq-brokerserver-pvc env: - name: NAME_SERVER_LIST value: "rocketmq-nameserver-0.rn:9876;rocketmq-nameserver-1.rn:9876" - name: BROKER_SERVER value: "true" - name: BROKER_CLUSTER_CONF value: "2m-noslave" volumeClaimTemplates: - metadata: name: rocketmq-brokerserver-pvc spec: storageClassName: standard accessModes: - ReadWriteOnce resources: requests: storage: 20Gi ``` 上述brokerserver的配置与nameserver存在如下区别: * 通过环境变量NAME_SERVER_LIST设定了nameServer的集群列表,即之前启动的两台机器。 * BROKER_SERVER表示启用的是broker server模式 * BROKER_CLUSTER_CONF表示集群配置模式,即我们提到的"双主零从模式" 我们也启动一下broker server,稍等一会后,会成功: ```yaml kubectl apply -f ./broker-service.yaml kubectl get pods NAME READY STATUS RESTARTS AGE rocketmq-brokerserver-0 1/1 Running 0 59s rocketmq-brokerserver-1 0/1 Running 0 49s ``` 我们尝试用RocketMQ的自带工具n查看一下broker集群状态,能发现两台机器,说明集群部署成功: ```shell ./mqadmin clusterList -n "rocketmq-nameserver-0.rn:9876;rocketmq-nameserver-1.rn:9876" #Cluster Name #Broker Name #BID #Addr #Version #InTPS(LOAD) #OutTPS(LOAD) #PCWait(ms) #Hour #SPACE DefaultCluster broker-a 0 172.17.0.15:10911 V4_2_0_SNAPSHOT 0.00(0,0ms) 0.00(0,0ms) 0 425363.14 -1.0000 DefaultCluster broker-b 0 172.17.0.16:10911 V4_2_0_SNAPSHOT 0.00(0,0ms) 0.00(0,0ms) 0 425363.14 -1.0000 ``` 至此,我们完成了RocketMQ的集群部署工作。 [^1]: [Kafka vs. RocketMQ- Multiple Topic Stress Test Results](https://medium.com/@Alibaba_Cloud/kafka-vs-rocketmq-multiple-topic-stress-test-results-d27b8cbb360f) ================================================ FILE: legacy/ms-msgq/sb-kafka.md ================================================ # Spring Boot整合Kafka ================================================ FILE: legacy/ms-msgq/sb-rabitmq.md ================================================ # Spring Boot整合RabbitMQ 在上一节,我们已经掌握了RabbitMQ集群的运维方法。 在本章中,我们来看一下如何在Spring Boot中集成RabbitMQ ## 依赖配置 RabbitMQ实现了AMQP协议,因此,在Spring Boot中,我们直接引入ampq的starter ```groovy compile("org.springframework.boot:spring-boot-starter-amqp") ``` ## RabbitMQ服务配置 在yaml中,我们需要配置RabbitMQ服务的地址: ```yaml spring.rabbitmq: addresses: rmq1:5672,rmq2:5672 username: guest password: guest ``` ## 发送消息 在Spring Boot中发送消息需要4个步骤: 1. 声明Exchange 1. 声明Queue 1. 声明Queue到Exchange的绑定 1. 调用RabbitTemplate发送消息 前三步声明都可以通过Spring Boot注入: ```java #Bean public TopicExchange createExchange() { return new TopicExchange("exchange"); } @Bean public Queue createQueue() { return new Queue("queue"); } @Bean public Binding declareBindingGeneric() { return BindingBuilder.bind(createQueue()).to(createExchange()).with("#"); } ``` 如上所示,分别声明了Exchange、Queue和Binding 发送消息相对比较简单,拿到注入的RabbitTemplate后,直接发送即可。 ```java public class Tut1Sender { @Autowired private RabbitTemplate template; @Autowired private Queue queue; @Scheduled(fixedDelay = 1000, initialDelay = 500) public void send() { String message = "Hello World!"; this.template.convertAndSend(queue.getName(), message); System.out.println(" [x] Sent '" + message + "'"); } } ``` 如上,我们直接调用convertAndSend即可发送字符串类型的消息。如果想发送更复杂类型的,可以让类型实现Converter。 ## 接受消息 接受消息,直接绑定Queue的名字即可: ```java @RabbitListener(queues = "queue") public class Tut1Receiver { @RabbitHandler public void receive(String in) { System.out.println(" [x] Received '" + in + "'"); } } ``` 至此,我们可以在Sping Boot中集成RabbitMQ了。 ================================================ FILE: legacy/ms-msgq/sb-rocketmq.md ================================================ # Spring Boot整合RocketMQ 在本小节中,我们将讨论在Spring Boot中整合RocketMQ。 我们选用官方推荐的Spring Boot拓展[rocketmq-spring-boot-starter](https://github.com/apache/rocketmq-externals/tree/master/rocketmq-spring-boot-starter) ## 依赖接入 首先,需要在Spring Boot中添加依赖: ```groovy compile 'com.qianmi:spring-boot-starter-rocketmq:1.1.0-RELEASE' ``` ## 配置RocketMQ的服务器 在配置中,我们需要设定RocketMQ的服务器集群 ```yaml spring.rocketmq: nameServer: 127.0.0.1:9876 producer.group: lmsia-abc ``` 如上所示: * spring.rocketmq.nameServer 配置元服务器的地址 * spring.rocketmq.producer.group 是[Producer Group](http://rocketmq.apache.org/docs/core-concept/)。我们这里设定为微服务的名字,即相同的微服务都认为是同一个Producer Group。 ## 在Spring Boot中首发消息 按照上述进行配置后,自动配置会被激活,并自动注入RocketMQTemplate用于生成发消息、ListenerContainer用于收消息。 上述对使用方都是透明的,在Spring Boot中收发消息非常简单,如下: ```java @Service @RocketMQMessageListener(topic = TOCPIC, consumerGroup = LmsiaAbcConstant.PROJECT_NAME) public class MyEventHandlerImpl implements MyEventHandler, RocketMQListener { private Logger LOG = LoggerFactory.getLogger(getClass()); @Resource private RocketMQTemplate rocketMQTemplate; @Override public void send(MyEvent event) { rocketMQTemplate.convertAndSend(TOCPIC, event); } @Override public void onMessage(MyEvent message) { LOG.info("receive message, data = {}", message.getData()); } } ``` 如上所述: * topic需要配置成一致的,即TOPIC常量,类似的,consumerGroup定义为微服务名称,即认为同一个微服务的所有节点属于同一个组。 * 我们自动注入了RocketMQTemplate用于发消息,消息默认继承自Serializable * RocketMQListener会在收到消息后回调onMessage方法。 如果你使用过RocketMQ的官方客户端的话,会发现其易用性要远低于[spring-boot-starter-rocketmq](https://github.com/apache/rocketmq-externals/tree/master/rocketmq-spring-boot-starter)。 RocketMQ还支持顺序消息、广播消息等更多功能,spring-boot-starter-rocketmq中都支持了具体的实现,可以直接参考上述项目主页的说明。 ================================================ FILE: legacy/ms-storage/README.md ================================================ # 微服务的存储与缓存 在计算机领域,有一句人尽皆知的名言"算法=程序+数据结构",这是图灵奖获得者尼古拉斯·沃斯提出的[^1]。 从大师的这句名言中,我们不难感受到,数据的存储方式与算法同等重要。 在微服务架构中,我们虽然不会研究特定的算法,但数据的存储依然是必不可少的环节。 例如:用户的注册信息、订单信息、生成的UGC内容等,都需要以合适的方式存储下来。 存下来只是第一步,更为关键的是,我们需要在需要时,以合理的速度、合理的成本开销将数据读取出来。特别是在互联网软件开发中,数据经常是"读多写少",数据的读取往往比存储更加关键。 请注意我的用词"合理的速度"、"合理的成本"。为了理解这两点,我们先了解一下常见的存储方式: 请注意我的用词,是"合理的速度"、"合理的成本",并非"最快的速度"、"最低的成本"。 [^1](https://www.jianshu.com/p/19f68c72effc) ================================================ FILE: legacy/ms-storage/memcached-devops.md ================================================ # Memcached 缓存服务的运维 如果业务进一步发展,通过"读写分离"、"分库分表"后,数据库的性能依然无法满足高并发读请求,此时就需要缓存出马了。 缓存的原理其实非常简单: 用"存取速度更快的空间"换取"存取速度更慢的时间"。 当然,天下没有免费的午餐,缓存自然也是有代价的。单位容量的内存比磁盘要昂的多。幸运的是,根据数据的局部性原理[^2],我们可以有如下策略: * 只选择少量的"热数据"放入缓存 * 当缓存空间不够放置"热数据"时,根据策略替换掉缓存中的已有数据。 本节的主角是Memcached,一款高性能的内存缓存,性能可达每秒5万次[^1]。 Memcached本身是不支持集群的,但可以通常可以部署多台服务。在访问时,可以根据key的哈希值取模进行分片,然后访问不同的Memcached结点。 ## 集群搭建 由于Memcached是全内存的,所以无需创建Volume挂载点。 在这里,我们没有使用Deployment,而是使用了[StatefulSet](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/)。 StatefulSet与Deployment基本相同,唯一的的区别是,前者认为所有副本是相互独立的,而后者认为所有副本是互为冗余的。 对于微服务的应用场景,每个节点都是完全相同的逻辑、连接完全相同的数据库、执行等同的操作,所以我们用的一直是Deployment。 而对于Memcached,我们会将不同数据分片到不同Memcached结点上,他们是相互独立而不是可替代的,所以我们采用了StatefulSet。 memcached-service.yaml: ```yaml apiVersion: v1 kind: Service metadata: name: memcached spec: ports: - port: 11211 selector: app: memcached clusterIP: None --- apiVersion: apps/v1 kind: StatefulSet metadata: name: memcached spec: selector: matchLabels: app: memcached serviceName: "memcached" replicas: 2 template: metadata: labels: app: memcached spec: restartPolicy: Always hostname: memcached containers: - name: memcached-ct image: memcached:1.5-alpine ports: - containerPort: 11211 args: ["memcached", "-m", "256"] ``` 简单说明下: * 我们声明了StatefulSet为memcached,2个独立节点 * 限定了内存使用为256m 启动下: ```shell kubectl apply -f memcached-service.yaml ``` 然后我们登录一个额外的docker上,尝试ping一下,是可以的,说明启动成功: ```shell ping memcached-0.memcached PING memcached1 (172.17.0.8): 56 data bytes 64 bytes from 172.17.0.8: seq=0 ttl=64 time=1.014 ms 64 bytes from 172.17.0.8: seq=1 ttl=64 time=0.138 ms 64 bytes from 172.17.0.8: seq=2 ttl=64 time=0.134 ms ping memcached-1.memcached PING memcached2 (172.17.0.9): 56 data bytes 64 bytes from 172.17.0.9: seq=0 ttl=64 time=0.076 ms 64 bytes from 172.17.0.9: seq=1 ttl=64 time=0.123 ms 64 bytes from 172.17.0.9: seq=2 ttl=64 time=0.119 ms ``` 注意上面对不同节点的DNS域名为"statefulName-x"."serviceName" Memcached的配置看起来很简单,但是分片策略还需要进一步思考。 例如,前面提到了利用哈希值取模,可以实现Memcahced在客户端的分片。按照此策略,如果现在要增加一台机器到3台,计算取模的值将发生变化,缓存上的所有的数据都需要清空重新分片。 这种"推倒重来"的策略看起来简单,但可能会导致缓存击穿甚至造成线上故障。 想解决这类问题,可以采用[一致性哈系](http://www.cnblogs.com/RockLi/p/3530176.html),它可以尽可能地减少机器变动后,造成的数据重分布。 Memcached的日常运维比较简单,常见的操作就是清空全部缓存,可以通过nc指令来完成: ```shell echo 'flush_all' | nc memcached1 11211 OK ``` 提醒一下,线上执行清空操作要非常谨慎,若系统性能严重依赖缓存,那么清空操作往往会导致缓存击穿并造成系统故障。 ## 小结 本节,我们使用StatefulSet,完成了Memcached集群的运维,并介绍了Memcahced集群运维中常见的问题。 [^1]: [Memcached性能评测数据](https://github.com/scylladb/seastar/wiki/Memcached-Benchmark) [^2]: 分为空间局部性和时间局部性,可参考[局部性原理浅析](https://www.cnblogs.com/yanlingyin/archive/2012/02/11/2347116.html) ================================================ FILE: legacy/ms-storage/mysql-devops.md ================================================ # MySQL数据库的运维 近几年,以"Redis"、"MongoDB"为代表的"NoSQL"数据库迅速崛起。"NoSQL"并不是"没有SQL"而是"Not Only SQL"。在特定场景下,NoSQL数据库确实解决了一些问题,例如: * 海量数据的列存储: 以HBase、Cassandra。 * 速度更快的内存数据库:Redis。 * 文档存储:Mongo DB。 * 支持全文检索特性: ElasticSearch。 若将视角放到更普适的场景,关系型数据库凭借着"ACID"特性,依然牢牢占据着数据存储的核心地位。 根据市场调研显示,在数据库领域,排名前3的分别是: 1. Oracle 1. MySQL 1. Microsoft SQL Server 其中,MySQL是最流行的开源关系数据库,其性能较为优秀、服务稳定、社区活跃,是众多中小型公司的首选。 在本节中,我们首先将讨论MySQL数据库的基础运维,随后讨论常见的优化手段-MySQL主从复制。 ## MySQL数据库的部署 我们的MySQL依然部署在Kubernetes上,首先创建挂载点: ```shell sudo mkdir /data/mysql sudo chmod -R 777 /data/mysql ``` 如果是线上环境,可以将挂载点设定在SSD磁盘等IOPS较高的存储介质上。 看一下部署脚本: ```yaml apiVersion: v1 kind: Service metadata: name: mysql spec: ports: - port: 3306 selector: app: mysql clusterIP: None --- apiVersion: apps/v1 kind: Deployment metadata: name: mysql spec: selector: matchLabels: app: mysql replicas: 1 template: metadata: labels: app: mysql spec: nodeSelector: kubernetes.io/hostname: minikube restartPolicy: Always hostname: mysql containers: - name: mysql-ct image: mysql:8 ports: - containerPort: 3306 volumeMounts: - mountPath: "/var/lib/mysql" name: volume env: - name: "MYSQL_ROOT_PASSWORD" value: "root123" volumes: - name: volume hostPath: path: /data/mysql/ ``` 上面的部署yaml与之前的类似,只解释几点: * 上述实际启动了一个"Headless"Service,即将clusterIP设置为None。此时将不再启动单独的VIP,而是让dns直接解析到Pod的IP上 * MySQL的存储数据量较大,一般固定在某台物理机上,不会主动迁移。例如这里我们选择了minikube。 * MYSQL_ROOT_PASSWORD是root的管理员密码,但root默认只允许本机登录。 我们来尝试登录下,首先获取docker的容器id: ```shell kubectl get pods NAME READY STATUS RESTARTS AGE mysql-7bf88bcfd-gljv7 1/1 Running 1 4m kubectl describe pod mysql-7bf88bcfd-gljv7 ... Container ID: docker://479a3b1d9e7b9f445f2cb8133e156de480337c23c6f27aa541ca4df8b3cf944d ... ``` 然后尝试登录MySQL,是成功的: ```shell minikube ssh $docker exec -i -t 479a3b1d9e7b9f445f2cb8133e156de480337c23c6f27aa541ca4df8b3cf944d /bin/sh $#mysql -h localhost -u root -proot123 mysql -h localhost -uroot -proot123 mysql: [Warning] Using a password on the command line interface can be insecure. Welcome to the MySQL monitor. Commands end with ; or \g. Your MySQL connection id is 8 Server version: 8.0.11 MySQL Community Server - GPL Copyright (c) 2000, 2018, Oracle and/or its affiliates. All rights reserved. Oracle is a registered trademark of Oracle Corporation and/or its affiliates. Other names may be trademarks of their respective owners. Type 'help;' or '\h' for help. Type '\c' to clear the current input statement. mysql> ``` 至此,我们已经完成了MySQL数据库的基本搭建。 ## MySQL数据库的日常运维 出于性能、安全性、可拓展性等考量,一般会为MySQL服务器建立多个库,并分配不同的帐号。在微服务架构下,不同的微服务应使用不同的库,尽量避免数据库层的耦合。 因此,新建数据库、分配帐号是MySQL日常运维工作最常见的事情。我们给出一个脚本: ```shell if [ $# -ne 1 ]; then echo "Usage $0 " exit -1 fi DB_NAME=$1 DB_USER="lmsia" DB_PASS="pass" echo "CREATE DATABASE IF NOT EXISTS $DB_NAME DEFAULT CHARSET utf8mb4;" echo "CREATE USER $DB_USER IDENTIFIED by '$DB_PASS';" echo "GRANT ALL PRIVILEGES ON $DB_NAME.* to $DB_USER;" echo "FLUSH PRIVILEGES;" ``` 在上述脚本中,会自动生成如下语句: * 建库(编码utf8mb4) * 建用户,并分配到上述库 注意:脚本里的密码给的是固定的"pass",在实际生产环境,应当使用随机密码。 我们可以执行上述脚本,然后将生成的语句粘帖到root登录的mysql客户端,即可完成数据库的添加工作。 ## MySQL数据库的同步 MySQL较为优秀,但也不是万能的银弹。随着业务逐步发展壮大,MySQL的性能可能会成为瓶颈,例如: * CPU占用持续较高 * 网络带宽常常打满 * 查询慢 当性能成为瓶颈时,应首先从使用方面查找原因,举几个常见例子: * 查询语句是否合理利用了索引 * 事务锁表时间是否太长 * 是否经常性的超大批量数据导出占用了带宽 如果使用上都没有问题,依然出现性能问题,可以考虑从架构上优化,常见的手段有: * 读写分离: 如果读多写少,可以MySQL端进行主从复制,微服务中读写分离。 * 分库: 某个库占用大量性能,可考虑将其拆分到单独的MySQL服务器上 * 分表: 单表行数超过1000万后,可考虑将表进行水平、垂直划分,拆分到不同MySQL服务器上。 其中分库和分表一般要借助中间件实现,在本小节,我们先介绍第一种手段:读写分离。 读写分离也是一种基于特定场景的优化。在互联网软件开发中,经常会有"读多写少"的情景,举几个例子: * 微博上看的人多,发微博的人少。 * 新闻浏览的人数多,评论的人数少,发布的新闻更少。 针对"读多写少",我们可以将读和写分离开,使得读性能得到更高的保证,看一下读写分离的原理: ![MySQL读写分离](./mysql-replication.png "MySQL读写分离") 如图所示,我们开启两个MySQL服务器: * Master,MySQL数据库服务器,主要承担写请求 * Slave,MySQL数据库服务器,负责承担读请求 要说明的是:写SQL到达Master后,会通过住从复制机制(Replication)同步到Slave上,这个过程是异步的,会存在延迟。若微服务的读请求都发往Slave,那么势必也会受到住从复制的延迟影响,特别是对于"写后读"这个场景,可能会导致一些Bug,感兴趣的朋友自行思考如何解决。 MySQL的主从数据同步有几个要点: * master和slave机器配置了不同的server-id * slave机器通过配置或sql命令设定master的主机名 下面我们尝试在Kubernetes中配置一组主从复制的MySQL服务器。 首先创建master(写库)和slave(读库)的挂载点: ```shell sudo mkdir /data/mysql_writer sudo mkdir /data/mysql_reader sudo chmod -R 777 /data/mysql_writer sudo chmod -R 777 /data/mysql_reader ``` 然后看一下master(写服务)的定义, mysql-writer-service.yaml: ```yaml apiVersion: v1 kind: Service metadata: name: mysql-writer spec: ports: - port: 3306 selector: app: mysql-writer clusterIP: None --- apiVersion: apps/v1 kind: Deployment metadata: name: mysql-writer spec: selector: matchLabels: app: mysql-writer template: metadata: labels: app: mysql-writer spec: nodeSelector: kubernetes.io/hostname: minikube restartPolicy: Always hostname: mysql-writer containers: - name: mysql-writer-ct image: coder4/mysql-replication:8.0 ports: - containerPort: 3306 volumeMounts: - mountPath: "/var/lib/mysql" name: volume env: - name: "MYSQL_ROOT_PASSWORD" value: "root123" args: ["--server-id=1"] volumes: - name: volume hostPath: path: /data/mysql_writer/ ```` 如上所述,配置基本与之前的单机MySQL类似,区别是: * 使用了一个支持主从同步的镜像,coder4/mysql-replication * 主机名、dns配置为mysql-writer * 服务id是1 应用一下,过一会可以看到启动成功: ```shell kubectl apply -f ./mysql-writer-service.yaml ``` 下面看下读库(slave结点), mysql-reader-service.yaml: ```yaml apiVersion: v1 kind: Service metadata: name: mysql-reader spec: ports: - port: 3306 selector: app: mysql-reader clusterIP: None --- apiVersion: apps/v1 kind: Deployment metadata: name: mysql-reader spec: selector: matchLabels: app: mysql-reader template: metadata: labels: app: mysql-reader spec: nodeSelector: kubernetes.io/hostname: minikube restartPolicy: Always hostname: mysql-reader containers: - name: mysql-reader-ct image: coder4/mysql-replication:8.0 ports: - containerPort: 3306 volumeMounts: - mountPath: "/var/lib/mysql" name: volume env: - name: "MYSQL_ROOT_PASSWORD" value: "root123" - name: "MYSQL_MASTER_SERVER" value: "mysql-writer" args: ["--read-only=1", "--server-id=2"] volumes: - name: volume hostPath: path: /data/mysql_reader/ ``` 与独立MySQL服务器的配置相比,主要做了如下修改: * 设置主结点(写库)为mysql-writer * 用于主从同步的用户名密码同写库 * 只读,这主要是为了防止误删除数据,导致主从不一致 * 服务id是2 类似的,我们启动下从库: ```shell kubectl apply -f ./mysql-reader-service.yaml ``` 下面我们尝试在主库创建数据库,看看能否自动同步到从库: (这里省略登录mysql-writer的过程,具体参照本节第一部分) ```sql CREATE DATABASE IF NOT EXISTS lmsia_user DEFAULT CHARSET utf8mb4; CREATE USER lmsia IDENTIFIED by 'pass'; GRANT ALL PRIVILEGES ON lmsia_user.* to lmsia; FLUSH PRIVILEGES; ``` 然后登录mysql-reader看一下,发现可以成功登录,说明主从同步的配置是成功的: ```shell mysql -hlocalhost -ulmsia -ppass lmsia_user mysql: [Warning] Using a password on the command line interface can be insecure. Welcome to the MySQL monitor. Commands end with ; or \g. Your MySQL connection id is 6 Server version: 5.7.14-log MySQL Community Server (GPL) Copyright (c) 2000, 2016, Oracle and/or its affiliates. All rights reserved. Oracle is a registered trademark of Oracle Corporation and/or its affiliates. Other names may be trademarks of their respective owners. Type 'help;' or '\h' for help. Type '\c' to clear the current input statement. mysql> ``` 上述例子只展示了coder4/mysql-replication镜像的部分配置,如果你想启用更多高级配置,可以参考[docker-mysql-replication](https://github.com/liheyuan/docker-mysql-replication) MySQL主从同步是一个很有意思的话题,例如: * 上述例子中,mysql-reader只会同步启动后接受到的binlog,如何同步启动前mysql-writer的改动呢? * 本小节一开始提到的"写后读"问题,能不能通过"写到slave后才算写完成"的方式解决呢? * 能否配置多个slave节点呢? 由于篇幅所限,这里不会对上述问题展开讨论,如果你感兴趣,可以在[MySQL Replication官方文档](https://dev.mysql.com/doc/refman/8.0/en/replication.html)中找到答案。 ================================================ FILE: legacy/ms-storage/redis-devops.md ================================================ # Redis 数据库的运维 作为纯内存缓存,Memcached拥有非常出色的读写性能,但也存在一个较为严重的缺点:无法持久化。 这意味着,一旦Memcached服务重启(更常见的是掉电),之前所有的缓存就会丢失。若线上的流量很大,这种重启很容易诱发"缓存雪崩",从而导致系统故障。 Redis的出现很好的解决了这个问题,它是一款高性能的内存的数据库,既不仅数据的支持持久化、也内置了许多数据结构,方便实现各种需求。在一些场景下[^1],可以直接用Redis取代Memcached + MySQL的组合。 本节将讨论Redis运维相关的问题。 ## Redis单服务器的运维 Redis同时支持单服务器、高可用、集群等三种方案。 我们先来看一下单服务器方案。 顾名思义,单服务器模式下,只启动一个Redis服务进程,若服务挂掉则Redis不可用。可见,这种方案并不保证高可用。 与之前的部署类似,我们同样将Redis部署在Kubernetes集群上,首先是创建Volume挂载点 ```shell sudo mkdir /data/redis sudo chmod -R 777 /data/redis ``` 接着,我们看一下部署文件: ```yaml apiVersion: v1 kind: Service metadata: name: redis spec: ports: - port: 6379 selector: app: redis clusterIP: None --- apiVersion: apps/v1 kind: Deployment metadata: name: redis spec: selector: matchLabels: app: redis template: metadata: labels: app: redis spec: nodeSelector: kubernetes.io/hostname: minikube restartPolicy: Always hostname: redis containers: - name: redis-ct image: redis:3.2-alpine ports: - containerPort: 6379 hostPort: 6379 volumeMounts: - mountPath: "/data" name: volume volumes: - name: volume hostPath: path: /data/redis/ ``` 简要说明下: * 这里使用Redis官方的Docker镜像 * 与MySQL类似,考虑到持久化后的数据量可能较大,我们将Pod绑定到minikube机器上,以固定存储。 应用servie,稍等一会,成功: ```yaml kubectl apply -f kubectl describe pod redis-798659bc79-vdht7 service "redis" created deployment.apps "redis" created ``` 我们尝试连接一下,首先获取Pod的ContainerId: ```shell kubectl get pods NAME READY STATUS RESTARTS AGE redis-798659bc79-vdht7 1/1 Running 0 4m kubectl describe pod redis-798659bc79-vdht7 ... Container ID: docker://090c2a7a004200aa6f0c4f3779e3823c401f03ad4f23985fdc08c38f86d6c598 ... ``` 尝试登录,并登录redis服务器: ```shell minikube ssh $ docker exec -i -t 090c2a7a004200aa6f0c4f3779e3823c401f03ad4f23985fdc08c38f86d6c598 /bin/sh /data # echo "info" | redis-cli # Server redis_version:3.2.12 redis_git_sha1:00000000 .... ``` 通过上面的操作,可以成功登录Redis服务器,并获取了版本信息。 至此,Redis的单服务器模式配置完成。 ## Redis高可用(Sentinel)集群的运维 在上面的Redis单服务器模式下,存在单点故障,假如这个Redis进程挂掉了,则Redis就无法提供服务了。 为了解决可用性,Redis内置了两种高可用方案,较为经典的是Sentinel集群。 Sentinel集群采用主备模式: * 支持多个Redis服务组,不同服务组通过唯一的master_name标识。 * 组内一个主redis节点提供服务,若干从redis节点定期从主redis节点同步数据。但从节点只作为热备,不提供服务。 * 当某个组的主节点挂掉后,Sentinel服务会检测到主节点故障,并进行主备切换。 * 客户端先连接Sentinel,根据master_name获取组内主Redis节点的IP和端口信息,再连接。 ![Sentinel集群架构](./redis-sentinel.png "Sentinel架构") 如果你对Sentinel的架构细节感兴趣,可以阅读[官方文档](https://redis.io/topics/sentinel)。 首先,我们来部署一组Redis服务的主节点: ```yaml apiVersion: v1 kind: Service metadata: name: redis-lmsia-test1-master spec: ports: - port: 6379 selector: app: redis-lmsia-test1-master --- apiVersion: apps/v1 kind: Deployment metadata: name: redis-lmaia-test1-deployment spec: selector: matchLabels: app: redis-lmsia-test1-master template: metadata: labels: app: redis-lmsia-test1-master spec: nodeSelector: kubernetes.io/hostname: minikube restartPolicy: Always hostname: redis containers: - name: redis-sentinel-ct image: coder4/redis-sentinel-k8s:4.0.10 ports: - containerPort: 6379 env: - name: "MASTER" value: "true" - name: "MASTER_NAME" value: "lmsia_test1" ``` 如上所示: * 我们使用了自定制的镜像redis-sentinel-k8s,其原理可以查看项目主页[docker-redis-sentinel-k8s](https://github.com/liheyuan/docker-redis-sentinel-k8s) * MASTER=true,开启主节点模式 * MASTER_NAME=lmsia_test1,Redis服务的组名叫lmsia_test1 * 服务组名是redis-lmsia-test1-master,这个很重要,slave节点和sentinel会根据这个来定位master节点。 接着,我们启动lmsia_test1这个服务组一个从节点: ```yaml apiVersion: v1 kind: Service metadata: name: redis-lmsia-test1-slave spec: ports: - port: 6379 selector: app: redis-lmsia-test1-slave --- apiVersion: apps/v1 kind: Deployment metadata: name: redis-lmaia-test1-deployment spec: selector: matchLabels: app: redis-lmsia-test1-slave template: metadata: labels: app: redis-lmsia-test1-slave spec: nodeSelector: kubernetes.io/hostname: minikube restartPolicy: Always hostname: redis containers: - name: redis-sentinel-ct image: coder4/redis-sentinel-k8s:4.0.10 ports: - containerPort: 6379 env: - name: "SLAVE" value: "true" - name: "MASTER_NAME" value: "lmsia_test1" ``` 如上,组内从节点的启动方式和主节点基本一致,有几个需要特别注意的: * MASTER_NAME需要和主节点保持一致,即lmsia_test1 * SLAVE=true,开启从节点模式。 我们先来启动这一组主从服务: ```shell kubectl apply -f ./redis-lmsia-test1-master-service.yaml kubectl apply -f ./redis-lmsia-test1-slave-service.yaml ``` 我们分别登录redis,看看他们的组状态,首先是master,身份是主节点,并可以看到从节点的IP: ```shell redis-cli>info replication info replication # Replication role:master connected_slaves:1 slave0:ip=172.17.0.8,port=6379,state=online,offset=168,lag=1 master_replid:9b7dfe0b5f8d538d0f7b81d4095c239f1da72553 master_replid2:0000000000000000000000000000000000000000 master_repl_offset:168 second_repl_offset:-1 repl_backlog_active:1 repl_backlog_size:1048576 repl_backlog_first_byte_offset:1 repl_backlog_histlen:168 ``` 然后slave,状态是从节点,可以看到主节点的IP: ```shell # Replication role:slave master_host:10.105.12.178 master_port:6379 master_link_status:up master_last_io_seconds_ago:2 master_sync_in_progress:0 slave_repl_offset:266 slave_priority:100 slave_read_only:1 connected_slaves:0 master_replid:9b7dfe0b5f8d538d0f7b81d4095c239f1da72553 master_replid2:0000000000000000000000000000000000000000 master_repl_offset:266 second_repl_offset:-1 repl_backlog_active:1 repl_backlog_size:1048576 repl_backlog_first_byte_offset:1 repl_backlog_histlen:266 ``` 接着,我们来启动Sentinel服务: ```yaml apiVersion: v1 kind: Service metadata: name: redis-sentinel spec: ports: - port: 26379 selector: app: redis-sentinel --- apiVersion: apps/v1 kind: Deployment metadata: name: redis-sentinel-deployment spec: selector: matchLabels: app: redis-sentinel replicas: 3 template: metadata: labels: app: redis-sentinel spec: nodeSelector: kubernetes.io/hostname: minikube restartPolicy: Always hostname: redis containers: - name: redis-sentinel-ct image: coder4/redis-sentinel-k8s:4.0.10 ports: - containerPort: 26379 env: - name: "SENTINEL" value: "true" - name: "MASTER_NAME_LIST" value: "lmsia_test1" ``` 如上,我们部署了3个节点的Sentinel服务: * 使用我们定制的镜像redis-sentinel-k8s,其原理可以查看项目主页[docker-redis-sentinel-k8s](https://github.com/liheyuan/docker-redis-sentinel-k8s) * 打开26379端口,这是默认Sentinel的默认端口 * 环境变量SENTINEL表明以Sentinel模式启动 * 环境变量MASTER_NAME_LIST,列出了所有要监听的组名即master_name,用空格分割开。 我们尝试连接任意一台sentinel来获取主结点的信息: ``` redis-cli -h localhost -p 26379 > SENTINEL get-master-addr-by-name lmsia_test1 1) "10.105.12.178" 2) "6379" ``` 组内主redis服务获取成功。 至此,我们已经完成了Redis的Sentinel部署方式。 ## 小结 在本节中,我们从讨论了Redis的优点,以及单服务的运维方式。 接着,我们讨论了一种高可用Redis运维方案:Sentinel集群。这种方案可以保证Redis服务的高可用。但该方案也有明显的缺点:主备模式决定了资源的利用率只有50%,造成了一定的浪费。 ## 拓展阅读 1. 在小结中,我们提到了Sentinel模式会造成一定的资源浪费。可以采用[Redis Cluster](https://redis.io/topics/cluster-tutorial)的部署模式,在保证高可用的同时,资源利用率。 1. 为了保证高性能,Redis采用异步持久话的方式,分为rdb和aof两种,需要根据实际情况,选择适合的一种甚至混合方案。具体可参见文档(https://redis.io/topics/persistence) 1. 若采用aof方式,积累较多修改后,重启redis会非常慢,可以定期进行[aof rewrite](https://redis.io/commands/bgrewriteaof)压缩aof日志。 [^1]: 若要维持较高性能,建议保留足够的内存以存储全部数据。 ================================================ FILE: legacy/ms-storage/sb-memcached.md ================================================ # Spring Boot整合Memcached 前面已经提到,缓存是快速提升系统性能,缓解瓶颈的有效手段。 缓存的种类多种多样,小到CPU的缓存,大到静态生成的页面缓存。在本小节中,我们主要讨论在Spring Boot中整合如下两种缓存: * 本地缓存: 在内存中开辟一小块空间,用于缓存,速度很快,但容量受限。我们采用Gruva中的缓存实现。 * 网络缓存: 同一微服务的不同节点间,通过网络共享,例如Memcached。 ## 通用缓存接口 既然要在微服务中支持2种缓存,不妨设计一个较为通用的接口: ```java public interface ICache { @Nullable V get(K key); @Nullable default V cacheGet(K key, Function func, int ttlSecs) { V val = get(key); if (val != null) { return val; } else { val = func.apply(key); put(key, val, ttlSecs); return val; } } default V cacheGet(K key, Function func) { return cacheGet(key, func, 0); } Map batchGet(Collection keys); default Map batchCacheGet(Collection keys, Function, Map> func, int ttlSecs) { // hit map Map hitMap = batchGet(keys); // miss keys Collection missedKeys = null; if (hitMap == null || hitMap.isEmpty()) { missedKeys = keys; } else { missedKeys = keys.stream().filter(k -> !hitMap.containsKey(k)).collect(Collectors.toSet()); } // check if no miss keys if (missedKeys == null || missedKeys.isEmpty()) { return hitMap; } // fetch miss key Map missMap = func.apply(missedKeys); missMap.entrySet().forEach(e -> put(e.getKey(), e.getValue(), ttlSecs)); if (missMap == null || missMap.isEmpty()) { // no miss map again return hitMap; } else { // union & return hitMap.putAll(missMap); return hitMap; } } default Map batchCacheGet(Collection keys, Function, Map> func) { return batchCacheGet(keys, func, 0); } void put(K key, V value); void put(K key, V value, int ttlSecs); void del(K key); default void batchDel(Collection keys) { if (keys != null) { keys.stream().forEach(key -> del(key)); } } void clear(); } ``` 如上所示,我们定义了3大类Cache的基本操作: * get/batchGet: 从缓存中获取数据,支持单个或批量操作。 * put/del: 向缓存中写入或删除,支持设置超时时间。 * cacheGet: 若缓存中存在则直接返回,否则通过一个Function来生成结果,并写入缓存。类似的,支持单个和批量操作、支持设置超时时间。 ## LocalCache的实现 在设计Memcached缓存之前,先来看一下本地缓存实现: ```java public class LocalCache implements ICache { private Logger LOG = LoggerFactory.getLogger(getClass()); private Cache gCache; private long ttlSecs = 0; public LocalCache(long capacity, long ttlSecs) { CacheBuilder builder = CacheBuilder.newBuilder(); if (capacity > 0) { builder.maximumSize(capacity); } if (ttlSecs > 0) { this.ttlSecs = ttlSecs; builder.expireAfterWrite(ttlSecs, TimeUnit.SECONDS); } this.gCache = builder.build(); } @Nullable @Override public V get(K key) { return gCache.getIfPresent(key); } @Override public Map batchGet(Collection keys) { if (keys == null || keys.isEmpty()) { return new HashMap<>(); } else { Map result = new HashMap<>(); for (K key : keys) { V val = gCache.getIfPresent(key); if (val != null) { result.put(key, val); } } return result; } } @Override public void put(K key, V value) { gCache.put(key, value); } @Override public void put(K key, V value, int curTtlSecs) { if (curTtlSecs != this.ttlSecs) { LOG.error("not support per-put ttlSecs currently"); } put(key, value); } @Override public void del(K key) { gCache.invalidate(key); } @Override public void batchDel(Collection keys) { gCache.invalidateAll(keys); } @Override public void clear() { gCache.invalidateAll(); } } ``` 如上所示,我们调用了Grava的缓存,来实现了本地缓存。 要说明的是,由于Grava的设计限制,目前TTL需要在创建缓存之初就设定好,并不支持per-key的ttl设定。 ## MemcachedCache的实现 在Spring Boot中集成Memcached,首先要选择一款基于Java的客户端,比较成熟的开源项目有: * spymemcached * XMemcached 在本小节中,我们选择社区更为活跃的XMemcached,它支持线程池、一致性哈系等较为重要的特性。 首先来看一下客户端的构造: ```java public class MemcachedClientBuilder2 { public static MemcachedClient build(String serverList, int connPoolSize) throws IOException { MemcachedClientBuilder builder = new XMemcachedClientBuilder( AddrUtil.getAddresses(serverList)); // conn pool builder.setConnectionPoolSize(connPoolSize); // consistent hash builder.setSessionLocator(new KetamaMemcachedSessionLocator()); return builder.build(); } } ``` 如上所示,Builder主要设定了两个参数: * serverList: 服务器列表(形如ip:port,若有多个可通过空格分割开) * connPoolSize: 线程池大小 如果你仔细观察ICache接口,可以发现它是泛型的ICache,K和V可以是任意类型。 然而,Memcached的设计较为轻量,Key必须是字符串,而Value则是byte数组。 所以,需要设计一种通用的方式,以方便泛型数据类型到Memcached的Key/Value转换。 我们将这一转换逻辑抽提成Key/Value的Transformer, Key的: ```java public interface CacheKeyTransformer { String getKey(T t); } ``` 和Value的 ```java public interface CacheValueTransformer { byte[] serialize(T obj); T deserialize(byte[] bytes); } ``` 这两个接口看起来很抽象,我们首先来看一下DefaultCacheKeyTransformer,实现了任何类型到String(Memcached Key类型)的转换: ```java public class DefaultCacheKeyTransformer implements CacheKeyTransformer { private String cacheType; public DefaultCacheKeyTransformer(String cacheType) { this.cacheType = cacheType; } @Override public String getKey(T t) { return cacheType + "#" + t.toString(); } } ``` 一种很常见的场景,是将对象序列化为Json然后放到Memcached的Value中,JsonCacheValueTransformer完成了这一过程: ```java public class JsonCacheValueTransformer implements CacheValueTransformer { protected final Logger LOG = LoggerFactory.getLogger(getClass()); private ObjectMapper objectMapper; private Class cls; public JsonCacheValueTransformer(Class cls) { this.objectMapper = new ObjectMapper(); this.cls = cls; } @Override public byte[] serialize(T o) { byte[] defReturn = new byte[1]; try { if (o == null) { return defReturn; } return objectMapper.writeValueAsBytes(o); } catch (Exception e) { LOG.error("JsonCacheValueTransformer serialize exception", e); return defReturn; } } @Override public T deserialize(byte[] bytes) { try { if (bytes == null) { return null; } return objectMapper.readValue(bytes, cls); } catch (Exception e) { LOG.error("JsonCacheValueTransformer deserialize exception", e); return null; } } } ``` 如上所示,我们应用了Jackson来实现了Json的序列化(反序列化),并适配了byte数组到字符串的转换。 此外,Cache的Value中直接存储Integer/Long/String也较为常见,感兴趣的可以直接查看[lmsia-cache项目的源代码](https://github.com/liheyuan/lmsia-cache),这里不再赘述。 实现了Key/Value的序列化之后,我们看一下具体的MemcachedCache实现: ```java public abstract class AbstractMemcachedCache implements ICache { protected final Logger LOG = LoggerFactory.getLogger(getClass()); private static final int connPoolSize = 16; protected abstract MemcachedClient getMemcachedClient(); protected abstract CacheKeyTransformer getKeyTransformer(); protected abstract CacheValueTransformer getValueTransformer(); private Transcoder transcoder = new Transcoder() { @Override public void setPrimitiveAsString(boolean primitiveAsString) { } @Override public void setPackZeros(boolean packZeros) { } @Override public void setCompressionThreshold(int to) { } @Override public void setCompressionMode(CompressionMode compressMode) { } @Override public boolean isPrimitiveAsString() { return false; } @Override public boolean isPackZeros() { return false; } @Override public CachedData encode(byte[] o) { return new CachedData(0, o); } @Override public byte[] decode(CachedData d) { if (d != null) { return d.getData(); } else { return null; } } }; public void init() throws Exception { // check if (getKeyTransformer() == null) { throw new RuntimeException("keyTransformer can not be null"); } if (getValueTransformer() == null) { throw new RuntimeException("valueTransformer can not be null"); } } @Nullable @Override public V get(K key) { try { byte[] bytes = getMemcachedClient().get(getKeyTransformer().getKey(key), transcoder); if (bytes == null) { return null; } return getValueTransformer().deserialize(bytes); } catch (Exception e) { LOG.error("memcached get exception", e); return null; } } @Override public Map batchGet(Collection keys) { if (keys == null || keys.isEmpty()) { return new HashMap<>(); } Map key2idMap = new HashMap<>(); for (K key : keys) { key2idMap.put(key, getKeyTransformer().getKey(key)); } Collection ids = key2idMap.values(); try { Map map = getMemcachedClient().get(ids, transcoder); if (map == null || map.isEmpty()) { return new HashMap<>(); } Map result = new HashMap<>(); for (Entry entry : key2idMap.entrySet()) { K key = entry.getKey(); String id = entry.getValue(); byte[] bytes = map.get(id); if (bytes != null) { result.put(key, getValueTransformer().deserialize(bytes)); } } return result; } catch (Exception e) { LOG.error("batchGet exception", e); return new HashMap<>(); } } @Override public void put(K key, V value) { put(key, value, 0); } @Override public void put(K key, V value, int ttlSecs) { try { getMemcachedClient().add( getKeyTransformer().getKey(key), ttlSecs, getValueTransformer().serialize(value)); } catch (Exception e) { LOG.error("memcached put exception", e); } } @Override public void del(K key) { try { getMemcachedClient().delete(getKeyTransformer().getKey(key)); } catch (Exception e) { LOG.error("memcached del exception", e); } } @Override public void clear() { try { getMemcachedClient().flushAll(); } catch (Exception e) { LOG.error("memcached flushAll exception", e); } } } ``` 如上所示,AbstractMemcachedCache预留了3个抽象getter方法: * memcachedClient * keyTransfomer * valueTransfomer 实现者可以根据自己的需求来实现。 ## 自动配置 前面已经提到,MemcachedCache依赖MemcachedClient的实例。 如果每次都要手动构造MemcachedClient,实在是有些繁琐,我们可以通过Spring Boot的自动配置来自动注入: ```java @Configuration @ConfigurationProperties(prefix = "memcached") public class MemcachedClientAutoConfiguration { // Server list seperate by space private String serverList; // Connection Pool Size, default 64 private int connPoolSize = 64; public String getServerList() { return serverList; } public void setServerList(String serverList) { this.serverList = serverList; } public int getConnPoolSize() { return connPoolSize; } public void setConnPoolSize(int connPoolSize) { this.connPoolSize = connPoolSize; } @Bean @ConditionalOnMissingBean(MemcachedClient.class) public MemcachedClient createMemcachedClient() throws IOException { return MemcachedClientBuilder2.build(serverList, connPoolSize); } } ``` 如上所示,上述自动配置会扫描配置文件: * 若发现"memcached"开头的配置,会尝试解析其serverList和connPoolSize字段。 * 若解析成功,会调用之前介绍的Builder,自动生成一个MemcachedClient。 ## Memcached的应用案例 我们通过一个简单的案例来说明MemcachedCache的使用。 设计一个接口,返回10秒内每个用户的第一次访问的时间戳。 我们通过MemcachedCache来实现,首先定义Cache: ```java @Service public class TimestampMemcachedCache extends AbstractMemcachedCache { @Autowired private MemcachedClient client; private CacheKeyTransformer keyTransformer = new DefaultCacheKeyTransformer<>("timestamp"); private CacheValueTransformer valueTransformer = new LongValueTransformer(); @Override protected MemcachedClient getMemcachedClient() { return client; } @Override protected CacheKeyTransformer getKeyTransformer() { return keyTransformer; } @Override protected CacheValueTransformer getValueTransformer() { return valueTransformer; } } ``` 如上所示,我们定义了类型的Cache,其中Integer的Key表示用户Id,Long类型的Value表示时间戳。 上述的MemcachedClient是自动注入的,我们需要做一下配置: ```yaml memcached.serverList: "127.0.0.1:11211" ``` 在使用的Service中,如下使用: ```yaml @Autowired private TimestampMemcachedCache cache; @Override public String getCacheTimestampByUserId(int usrId) { return String.valueOf(cache.cacheGet(userId, key -> System.currentTimeMillis(), 10)); } ``` 如上所示,我们用了cacheGet来分别缓存最新时间戳,过期时间设为10秒钟。 通过这个例子,你一定体会到了:有了ICache等封装后,Memcached的使用变得非常简单。 ## 小结 在本节中,我们设计了通过了ICache接口,并实现了LocalCache、MemcachedCache两种不同的Cache。其中,我们重点探讨了MemcachedClient实现的细节,包括MemcachedClient的自动注入、Memcached数据类型的转换(Transfomer)。 ## 拓展阅读 1. 实际的应用中,缓存的更新是一个较为复杂的任务,建议阅读[缓存更新的套路](https://coolshell.cn/articles/17416.html)。 ================================================ FILE: legacy/ms-storage/sb-mysql.md ================================================ # Spring Boot整合MySQL 经过上一节的讨论,相信你已经有了一套可运维的MySQL服务器了,接下来的两节,我们来讨论如何在Spring Boot中整合MySQL。 在Spring Boot中整合MySQL有很多方式,常见的有: * Spring Jdbc Template直接集成 * Spring Data JPA集成 * Hibernate集成 * MyBatis集成 使用过Spring框架开发的同学,可能对后两种比较熟悉。但是这两种方法过于重量级,本书将专注于前两种,其中: * Jdbc Template需要直接编写SQL语句,更加接近数据库底层,开发效率低、性能高。 * Spring Data JPA可以自动生成部分参数、解析结果的语句,开发效率高,性能低一些。 上述两种方法各有优略,大家可以根据实际情况作出选择。 ## 数据源配置 无论是选用哪种集成方式,数据源的集成都是必不可少的。 为了提升性能,一般会使用数据库连接池,我们采用Spring Boot默认的tomcat连接池,只需要如下依赖配置即可生效: ``` compile 'org.springframework.boot:spring-boot-starter-jdbc' compile 'mysql:mysql-connector-java:5.1.9' ``` 接下来我们看一下数据源的配置,在application.yaml中添加: ```yaml spring.datasource: url: jdbc:mysql://mysql/lmsia_abc?rewriteBatchedStatements=true username: lmsia password: pass testOnBorrow: true validationQuery: SELECT 1 tomcat: max-active: 500 ``` 如上所示,除了基本的url和用户名、密码外,还设定了一系列额外参数,这些都是生产环境建议设置的,解释一下: * testOnBorrow / validationQuery: 从连接池取出连接后,先检查是否可用。这主要是解决长时间空闲情况下MySQL Server的[Gone Away问题(]https://dev.mysql.com/doc/refman/8.0/en/gone-away.html) * tomcat.max-active: 连接池最大连接数设定为500,默认的100在高并发场景下可能不够。 * rewriteBatchedStatements: 只有设置为true,才会默认启用batch模式,可提升批量写入的性能。 添加了上述配置后,Spring Boot会自动生成DataSource以及NamedParameterJdbcTemplate。我们可以通过后者直接操作数据库。 ## 通过JDBCTemplate操作数据库 在操作数据库前,我们先来看一下数据表结构: ```sql CREATE TABLE IF NOT EXISTS `user` ( `id` BIGINT(20) NOT NULL AUTO_INCREMENT, `name` VARCHAR(256) NOT NULL, `createdTime` BIGINT(20) NOT NULL, `updatedTime` BIGINT(20) NOT NULL, PRIMARY KEY (`id`) ); ``` 在微服务开发中,一般会将表的一行映射成一个实体: ```java import lombok.Data; @Data public class User { private int id; private String name; private long createdTime; private long updatedTime; } ``` 其中上面的@Data是lombok的注解,用于帮我们自动生成getter和setter,感兴趣的同学可以看这lombok官方文档](https://projectlombok.org/),这里不再详述。 来看一下数据库操作,我们将其封装在了Repository中: ```java @Repository public class UserRepositoryImpl implements UserRepository { protected Logger LOG = LoggerFactory.getLogger(getClass()); @Autowired protected NamedParameterJdbcTemplate db; private RowMapper ROW_MAPPER = new BeanPropertyRowMapper(User.class); @Override public void add(User user) { String sql = "INSERT INTO `user`(`name`, `createdTime`, `updatedTime`) VALUES " + "(:name, :createdTime, :updatedTime)"; SqlParameterSource param = new BeanPropertySqlParameterSource(user); KeyHolder keyHolder = new GeneratedKeyHolder(); db.update(sql, param, keyHolder); LOG.info("insert succ, id = {}", keyHolder.getKey().longValue()); } @Override public Optional getUserById(int id) { String sql = "SELECT * FROM `user` WHERE `id` = :id"; SqlParameterSource param = new MapSqlParameterSource("id", id); try { return Optional.ofNullable(db.queryForObject(sql, param, ROW_MAPPER)); } catch (EmptyResultDataAccessException e) { return Optional.empty(); } } } ``` 解读一下上面的代码: * 通过Autowired自动注入NamedParameterJdbcTemplate * JdbcTemplate上执行update和query来完成插入或查询 * 查询参数通过SqlParameterSource传入,返回值的对象映射通过RowMapper完成。 ## 两个数据源 在上面的数据源配置、数据库操作中,都存在一个假设:只有一个数据源。 如果一个微服务要同时依赖多个数据库,需要做如下事情: * 配置不同的数据源,建议不要采用默认的spring.datasource前缀,这主要是为了避免@Autowired时命名冲突。 * 为多个数据源手动声明Configuration,包含多组DataSource和JdbcTemplate 例如我们现在要添加2个数据库的数据源,那么配置文件要变成: ```yaml db1.datasource: url: jdbc:mysql://mysql/db1?rewriteBatchedStatements=true username: db1 password: pass testOnBorrow: true validationQuery: SELECT 1 tomcat: max-active: 500 db2.datasource: url: jdbc:mysql://mysql/db2?rewriteBatchedStatements=true``` username: db2 password: pass testOnBorrow: true validationQuery: SELECT 1 tomcat: max-active: 500 ``` 由于不采用默认的spring.datasource前缀了,Spring Boot默认不会激活自动配置,需要手动编写: ```java @Configuration @EnableTransactionManagement public class DataSourceConfiguration { @Bean(name = "db1JdbcTemplate") @Primary public NamedParameterJdbcTemplate initDb1JdbcTemplate( @Autowired @Qualifier("db1DataSource") DataSource dataSource) { return new NamedParameterJdbcTemplate(dataSource); } @Bean @Primary @ConfigurationProperties(prefix = "db1.datasource") public DataSource db1DataSource() { return DataSourceBuilder.create().build(); } @Bean(name = "db2JdbcTemplate") public NamedParameterJdbcTemplate initDb2JdbcTemplate( @Autowired @Qualifier("db2DataSource") DataSource dataSource) { return new NamedParameterJdbcTemplate(dataSource); } @Bean @ConfigurationProperties(prefix = "db2.datasource") public DataSource db2DataSource() { return DataSourceBuilder.create().build(); } @Bean public PlatformTransactionManager txManager() { return new DataSourceTransactionManager(tutorClockinWriterDataSource()); } } ``` 简单说明一下: * 根据自定义的前缀生成对应DataSource。 * 根据DataSource生成对应的NamedParameterJdbcTemplate。 * 因为要生成两组Datasource和NamedParameterJdbcTemplate,所以有一组要设置为@Primary,这是Spring Boot的要求。 在使用时,因为有两个NamedParameterJdbcTemplate了,所以要补充一下名字以做区分,如下: ```java @Autowired @Qualifier("db1") protected NamedParameterJdbcTemplate db1; @Autowired @Qualifier("db2") protected NamedParameterJdbcTemplate db2; ``` 区分了不同的NamedParameterJdbcTemplate后,其余的数据库操作和一个Datasource时是完全相同的,这里不再赘述。 # 通过JPA操纵数据库 前面提到了,除了JdbcTemplate外,还可以使用JPA来操作数据库。 由于spring-boot-starer-data-jpa显示以依赖了spring-boot-starter-jdbc,所以我们可以直接替换依赖: ```grovvy compile 'org.springframework.boot:spring-boot-starter-data-jpa' ``` 这一步替换,将不会影响DataSource、JdbcTemplate的自动注入,JPA也是需要Datasource和JdbcTemplate才能正常完成工作的。 Spring Boot JPA的默认实现是通过Hibernete完成的(JPA只是一套接口,Hibernete是接口的一种实现)。 jpa需要在yaml中添加一些特殊配置: ```yaml spring.jpa.properties.hibernate.dialect: org.hibernate.dialect.MySQL5InnoDBDialect spring.jpa.hibernate.ddl-auto: validate spring.jpa.hibernate.naming.physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl ``` 其中: * hibernate.dialect让Hibernete可以更高效的生成sql * ddl-auto设置为validate,不自动创建表但是会验证表与实体是否符合 * naming.physical-strategy表字段名映射为驼峰命名 针对要操作的实体,需要做一些特殊注解,以让JPA可以关联到对应的表上,为了对比说明,我们单独创建了一个对象UserForJpa: ```java import lombok.Data; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.Table; import javax.persistence.Temporal; import javax.persistence.TemporalType; /** * @author coder4 */ @Data @Entity @Table(name = "user") public class UserForJpa { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; @Column(nullable = false) private String name; @Column(name = "createdTime", nullable = false, updatable = false) private long createdTime; @Column(name = "updatedTime", nullable = false) private long updatedTime; } ``` 说明一下: * 实体通过@Entity标注 * @Table关联实体和表 * @Id和@GeneratedValue完成自增主键的声明 * @Column是普通列的声明,可设置是否nullable以及是否可更新 在数据库操作中,我们可以完全让JPA帮我们生成sql,如下: ```java @Repository public interface UserJpaRepository extends JpaRepository { } ``` 是的,你没有看错,我们不需要编写任何方法,就能自动获得save(), findOne(), findAll(), count(), delete()等接口,具体可以参见JpaRepository的源代码。 我们看一下调用方式: ```java userJpaRepository.findOne(userId); userJpaRepository.save(user); ``` JpaRepository提供的都是较为基础的操作,有事无法完全满足我们的需求。我们可以自行定义sql,如下: ```java @Query( value = "SELECT * FROM `user` ORDER BY `id` DESC LIMIT 1", nativeQuery = true) UserForJpa findLatestUser(); ``` 如上所示,我们通过@Query注解实现了通过指定SQL查找最新注册的用户。 ## 小结 在本小节中,我们首先介绍了Sping Boot中MySQL数据源的配置,随后,介绍了如何配置多个数据源并手动注入DataSource、JdbcTemplate。 接下来,我们介绍了两种数据库操作方法: * JdbcTemplate更接近数据库底层,需要编写较多代码,性能较好 * Spring JPA Data可以自动生成部分代码,开发效率高,性能稍差,且对POJO具有一定的侵入性 上述两种方法各有优劣,大家可以根据实际需求进行选择。 ## 拓展阅读 1. Tomcat数据库连接池的详细配置参数可以参考[官方参数文档](https://tomcat.apache.org/tomcat-8.0-doc/jdbc-pool.html#Common_Attributes) 1. Spring JPA Data更详细的用法可以参考[Spring JPA Data官方文档](https://docs.spring.io/spring-data/jpa/docs/current/reference/html/) 1. Spring JDBC更详细的用法可以参考[Spring JDBC官方文档](https://docs.spring.io/spring/docs/current/spring-framework-reference/data-access.html) ================================================ FILE: legacy/ms-storage/sb-redis.md ================================================ # Spring Boot整合Redis 在上一章中,我们讨论了Redis服务的运维,包括单机运行和Sentinel运行。 在本小节中,我们讨论如何在Spring Boot中集成Redis。 Spring Boot内置了Redis的接入方式,spring-data-redis,这种方案在Jedis客户端的基础上尽心过了简单的封装。若只使用Redis的KV存储特性,该方案可以满足要求。但对于Redis的高级特性(如SortedSet、SETNX等),则需要手动调用底层Jedis客户端的API,使用方式较为晦涩且容易出错。 为此,我们推荐使用Redisson作为接入客户端,它提供了简单易用的封装,可以用最小的编程代价来发挥Redis的最大功能。 ## 库依赖及自动配置 为了方便类库的复用,我们将Redisson的依赖及自动配置抽成一个单独的项目[lmsia-redis ](https://github.com/liheyuan/lmsia-redis)。 首先来看一下依赖: ```groovy compileOnly 'org.springframework.boot:spring-boot-autoconfigure:1.5.6.RELEASE' compileOnly 'com.fasterxml.jackson.core:jackson-databind:2.9.0' compileOnly 'ch.qos.logback:logback-classic:1.2.3' compile 'org.redisson:redisson:3.7.3' // Use JUnit test framework testCompile 'junit:junit:4.12' ``` 如上述的代码片段所示,编译依赖了Sping Boot的自动注解、jackson、以及logback,此外显式依赖了redisson库。 上一小节已经介绍,Redis有单机、Sentinel、Cluster三种部署方式,我们这里介绍前两种。 首先看一下单机Redis的自动配置: ```java package com.coder4.sbmvt.redis.configuration; import com.coder4.sbmvt.redis.utils.RedissonUtils; import org.redisson.Redisson; import org.redisson.api.RedissonClient; import org.redisson.config.Config; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.io.IOException; /** * @author coder4 */ @Configuration @ConfigurationProperties(prefix = "redis") public class RedissonAutoConfiguration { // server list private String server; // redis password private String password; // connection pool size, default 128 private int connPoolSize = 128; // retry interval in ms private int retryInterval = 100; @Bean(destroyMethod = "shutdown") @ConditionalOnMissingBean(RedissonClient.class) public RedissonClient createRedissonClient() throws IOException { if (getServer() == null || getServer().isEmpty()) { throw new IllegalArgumentException("server is empty"); } Config config = new Config(); config.useSingleServer() .setAddress(RedissonUtils.wrapSchema(server)) .setPassword(password) .setRetryInterval(retryInterval) .setConnectionPoolSize(connPoolSize); return Redisson.create(config); } public String getServer() { return server; } public void setServer(String server) { this.server = server; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public int getConnPoolSize() { return connPoolSize; } public void setConnPoolSize(int connPoolSize) { this.connPoolSize = connPoolSize; } public int getRetryInterval() { return retryInterval; } public void setRetryInterval(int retryInterval) { this.retryInterval = retryInterval; } } ``` 如上所示: * 若yaml配置中包含"redis"前缀的配置,则注解被激活。 * 尝试解析server、password、connPoolSize、retryInterval4个配置字段。 * server是redis服务器的IP:Port * password是redis服务器的密码 * connPoolSize是连接池默认大小,默认是128 * retryInterval是命令执行失败后的重试间隔,默认是100ms * 根据上述配置自动生成ResissonClient 再来看一下Sentinel方式的自动配置: ```java package com.coder4.sbmvt.redis.configuration; import com.coder4.sbmvt.redis.utils.RedissonUtils; import org.redisson.Redisson; import org.redisson.api.RedissonClient; import org.redisson.config.Config; import org.redisson.config.ReadMode; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.io.IOException; import java.util.List; @Configuration @ConfigurationProperties(prefix = "redis-sentinel") public class RedissonSentinelAutoConfiguration { // server list private String sentinelServerList; // sentinel master name private String masterName; // redis password private String password; // connection pool size, default 128 private int connPoolSize = 128; // retry interval in ms private int retryInterval = 100; @Bean(destroyMethod = "shutdown") @ConditionalOnMissingBean(RedissonClient.class) public RedissonClient createRedissonClient() throws IOException { List sentinelAddrs = RedissonUtils.splitStr(sentinelServerList); if (sentinelAddrs == null || sentinelAddrs.size() == 0) { throw new IllegalArgumentException("sentinel address is empty"); } Config config = new Config(); config.useSentinelServers() .setMasterName(masterName) .addSentinelAddress(sentinelAddrs.stream().map(RedissonUtils::wrapSchema).toArray(String[]::new)) .setPassword(password) .setMasterConnectionPoolSize(connPoolSize) .setSlaveConnectionPoolSize(connPoolSize) .setRetryInterval(retryInterval) .setReadMode(ReadMode.MASTER); return Redisson.create(config); } public String getSentinelServerList() { return sentinelServerList; } public void setSentinelServerList(String sentinelServerList) { this.sentinelServerList = sentinelServerList; } public String getMasterName() { return masterName; } public void setMasterName(String masterName) { this.masterName = masterName; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public int getConnPoolSize() { return connPoolSize; } public void setConnPoolSize(int connPoolSize) { this.connPoolSize = connPoolSize; } public int getRetryInterval() { return retryInterval; } public void setRetryInterval(int retryInterval) { this.retryInterval = retryInterval; } } ``` 上述自动配置和RedissonAutoConfiguration基本一致,唯一的差别是配置了Sentinal服务集群列表和masterName。 最后,在别的项目引用这个包时,我们要将上述两个自动配置暴露给Spring Boot扫描,添加到spring.factories中: ``` org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.coder4.sbmvt.redis.configuration.RedissonAutoConfiguration,\ com.coder4.sbmvt.redis.configuration.RedissonSentinelAutoConfiguration ``` 接下来,我们来看一下在Spring Boot中的具体集成方法。 ## Spring Boot中集成Redis 在Spring Boot中集成Redis,首先依赖刚才的lmsia-redis类库: ```groovy compile 'com.github.liheyuan:lmsia-redis:0.0.4' ``` 然后在yaml中添加配置: ```yaml # redis redis.server: "192.168.99.100:6379" ``` 经过上述配置后,Spring Boot在启动后,会自动注入RedissonClient,我们可以直接Autowired使用: ```java @Service public class MyListRedissonImpl implements MyListRepository { @Autowired private RedissonClient redissonClient; private static String getKey(int userId) { return String.format("list:%d", userId); } private RSet obtainSet(int userId) { return redissonClient.getSet(getKey(userId), new LongCodec()); } @Override public List get(int userId) { return new ArrayList(obtainSet(userId).readAll()); } @Override public void add(int userId, long data) { obtainSet(userId).add(data); } ``` 我们通过上述代码,简单看一下Redisson的用法。 * 通过getKey拼接一个key * 通过redissonClient.getSet获取key对应的RSet,编码是Long,这里的RSet和Java的Set完全兼容。 * add进行添加、get返回set中全部数据。 Redisson将较为繁琐的Redis命令进行了封装和组合,我们操作的是类Java的数据结构,但实际底层命令都是Redis的。 关于Sentinel的配置方法,是类似的,这里不再赘述。 至此,我们完成了Spring Boot中Redis服务的整合工作。 # 拓展阅读 1. Redisson还提供了锁、排序集合等许多高级数据结构,可以参考[Redisson官方文档](https://github.com/redisson/redisson/wiki/Table-of-Content) ================================================ FILE: legacy/spring-boot/README.md ================================================ # 微服务的开发框架 在[上一章](../ms-discovery/README.md),我们解决了微服务的第一个核心问题"服务发现"。 本章我们将回归开发本质,开始上手微服务的开发框架。 本书选用Java作为微服务的开发语言,选用Spring Boot作为开发框架。作为经典J2EE框架Spring的"继承者",Spring Boot具有快速上手、轻松集成后端组件、高效配置、代码依赖注入等诸多特性,能够很好地适应微服务"快速开发"、"快速迭代"的理念。 本章将从微服务代码的文件结构入手,首先看一下采用Gralde构建工具后,如何实现子项目的划分,以及这样划分的意义。接着我们会看一下各个子模块下,代码的层次结构。 在熟悉了微服务的代码结构后,我们来具体看一下微服务的"核心服务"功能:RPC服务和REST服务,在分析代码的基础上,我们将探讨如何将它们与服务发现机制进行融合。 章节的最后,我们将探索一些常用工具类,并介绍"单元测试神器"Mockito的使用方法。 ================================================ FILE: legacy/spring-boot/discovery.md ================================================ # Spring Boot + k8s 服务发现 ================================================ FILE: legacy/spring-boot/gerrit.md ================================================ # Spring Boot 多模块Gradle项目 ================================================ FILE: legacy/spring-boot/graceful-shutdown.xml ================================================ 7Vlbk5owFP41PraDBBEfV/fSTrednTrTy1MnwBHSBkJDWLW/vokkAgLd7Sxqu60vhnMJyfm+c3KiI7RINjccZ/FbFgId2Va4GaHLkW2PHduVX0qyLSVTTwsiTkJtVAmW5AdooaWlBQkhbxgKxqggWVMYsDSFQDRkmHO2bpqtGG2+NcMRtATLANO29CMJRVxKvYlVyV8BiWLz5rGlNT4OvkWcFal+38hGq92nVCfYzKXt8xiHbF0ToasRWnDGRDlKNgugKrYmbKXfdY92v24OqXiUg/bIxdbsHUIZCv3IuIhZxFJMryrpfLc/UDNY8ikWCZXDsRzChohPtfFnZfJyop5SwbeftMfuodJ9BSG2mgO4EEyKqvfeMpbpGdt7M4tnBQ/06pFmC+YRaKtpKVL7qrnpeNwAS0AuRxpwoFiQ+yYFsGZStLeroikHOqA9wbWff3C9swUXPf/gzs4VXL2We0wLMIXMpXJV8xWTW6hH3f1eMKN4ke9icSENxm62qZRyFKnvN4UPPAUha7sqTOWUPjfq93eLLx9e30ndNVNCnMgIzlM/z8o5DswPLZbA74kMp23tjeVGyxUbjwPKNAmxjomAZYZ3kKzl0dYkyYpQumBULk35ohCDtwqkPBecfYOaxg088Fe/Av4euIDNL0HVWjRzSpetOVz10bGuDqaxOU7i2qHkWU/ngT3s8XCqVHLaqWQidPpcco6USxXbLx7LdtltZGpYJPQiEIqtc0VEIhufW+wDvWM5EYSl0sRnQrBEGlClmO/bmhrLdWNTzXFBSaR8BTtIHFYISlKZIaZbs4bJDQ81U2PSTg3UkRnuAJkxPS6qHdXuPWRURll59uLdrpC99W78+/VuAl7odNU7z/aR6w6D6dQaN0CdPrLcDQGqd0ZQ7X8J1K5MPRqqsyOhambJM5wOxw25m3LCfviHWHxFvAd41yN+YkLEhbhk6/Q8nF95AQSdjZvvTZzJQIfTIedtZJ+O8/Zzu3qaQNV7OnSuls4s5tT3o8t3y/568PdfdcYzt3lKnPSqM8Cd1+nC9BXLRYoT6EfuMZKH0JUhFk0Im1ClLIUDXLUI6649kMBB/Upg2vmEhOGuPnRxpsmqIUhgyqQhgTtrkcDp4IA9BAcGuKt1ckD9pvEw1neyPP5ngI3QQbPoei9n9Y9zOkK4RyLELcNhZ4lvSyQ8OA3gn6PBBLl/Cg0MIQenwQhd/424tkDsgLoXV9f8JLPvi91jASkfq7/Sdrra/5Xo6ic= ================================================ FILE: legacy/spring-boot/mockito.md ================================================ # Mockito 单元测试打桩神器 ================================================ FILE: legacy/spring-boot/rest-nginx.xml ================================================ 7Vtbl9o2EP41PCbHtmxjHlmym/R0m3JK2qRPObItjBrZcoVYoL++Mpbwlaw3+LJA/II9us83nzQjiRGYhbv3DMar36iPyMjQ/N0IvBsZ4hnb4ieR7FPJ2JKCgGE/FemZYIH/Q1KoSekG+2hdyMgpJRzHRaFHowh5vCCDjNFtMduSkmKrMQxQRbDwIKlKP2Ofr1KpY2mZ/APCwUq1rGsyxYXet4DRTSTbGxlgeXjS5BCqumT+9Qr6dJsTgfsRmDFKefoW7maIJLpVakvLPZxIPfaboYg3KiBLPEGykWNnsmd8r7SBfKEc+UkZX9GARpDcZ9K7w4hRUqcmvlY8JOJVF69oh/mX3PvfSZa3VvIVcbb/IkscPrK0fxDne2kVcMOpEGXtPlIayxqro5XDWdMN82TvgbQfyAIkc41TUTKuXDGpofeIhkh0R2RgiECOn4pGAaVtBcd8mX7Fi1TxCXUb169cZzDlgutX7mQo5YLKPDEybCJ6dbekYgh5rdv/bqhKeLM+6GIqMuh2vMsSxVuQ/P66cRGLEBezfTJVpVW6TCV/+PRp/vWvX+Yi8YEmUhgKFd5F7jpOKynlL+dYIPaEhT4N7ZhZjDTtsipRspmiRWxXmKNFDA+YbMVqV7SSJSZkRonoWlIW+BA5S0/I15zRbyiXYnsOcpffQ/4JMY5230VVpoKJmRaRq6tuytVkm61VulphVrl1ytFaMIRx9ywDpUcmzhHDor+IyXpeRrYSZ55ln1llX7pw9MA2syO2TSOfUdGIoc0IRgcSVAnUiCXCcYmT101Iph5PrPwuMWAsfKhH6CIyp2vMMY1EFpdyTkORgSQJd0cPKccO6SNldUwJDpKynJYIRzec4EgwSzl+WjucemMo506SCmh6hVSghlN2C5wadwS3nP1q5sk/UEyEnpOSJxGvmsbJmVJ/+UxpIcc362ZKx3CBbbeD6liBqOKQhhNlG6A6A4Jq3BKoutUjqpMBUQW3hKoBjP5QNa4tLlOKynsvoNYPOsObORSdMgb3uQwxxRFf52qeJ4Kc8zourbOyhYeG+XVHKyGb9iDD+TiURtBbFxc1vtSRrbMF07lE7E3LaBV7pZm+o9p3Hxc/7nm//vhUnxjFqdyputKdxadtxE5mHabJJsQpzDKJozk1gDfFViiYFwEsAhXRCJVQlSIoAyZPwIby0ZiKpELs+4e5q85iijbVggkA3S4yXa/6aHaNBRhtWIDdkQU8UujX8rkqEXjAKNl3ujHcLVDEXbedt5P8Y1bMwOzIDFRPrnhhH1cX9twpUrdbVJ1tWnwMcLQbZnlcLpeGV7s8+rZrW1mkUyFEDVant5rMolfT6/Ko2n4F27cDEsepIU5fe7udbQzh34VXeY0bu+exrbyxa6pAuIeNXeD8ZNtxI2cQtqnGW2fbZ+T+ZNvzbNOd/thmtnBIXev4/xkvOEMwbOL5X0fwd5YR6KpIFvwNFQSY1sUHAYUz6RrHxZw0xqf9c+rLj7GeU292528A9fZw12Jo9TafXdpXb1eeeG+XmqavatP4rEUDTEDRTVd3JXuJijs+1p1WQb2cGxhnwTrWSkc+ZkNY23AI1dw9CKzGLcFqGn3CWr203h+sr/0SRquwWlrVVe8O1hZC9Rs+tzkLeAtYRT6bfYVs4jP7u0t6hp/9pwjc/w8= ================================================ FILE: legacy/spring-boot/sb-gradle-structure.md ================================================ # Gradle子项目划分与微服务的代码结构 ## Gradle简介 如前序章节[微服务技术栈概览](../architecture/microservics.md)所述,本书选用Java作为开发语言、Gradle作为构建工具。 与Maven相比,Gradle具有如下优势: * 灵活性:Gradle内置了脚本支持,可以实现更强大、更灵活的构建功能。 * 高性能:Gradle支持并行编译、多级缓存,最高可节省90%的编译时间[^1]。 * 易于维护:与xml相比,Gradle的依赖描述语言更简洁,更易于维护。 * 无缝兼容:Gradle无缝兼容Maven,已有的系统也可以轻松地迁移过来。 ## 微服务架构下Gradle的子项目划分 在[微服务的自动发现与负载均衡](ms-discovery/README.md)一章中,我们已经构建了一个微服务项目"lmsia-abc",让我们来看一下它的目录结构。为了清晰起见,只展示一层目录结构: ```shell . ├── build.gradle ├── gradle │   └── wrapper ├── gradlew ├── gradlew.bat ├── lmsia-abc-client │   ├── build │   ├── build.gradle │   ├── out │   └── src ├── lmsia-abc-common │   ├── build │   ├── build.gradle │   ├── out │   └── src ├── lmsia-abc.iml ├── lmsia-abc-job │   ├── build │   ├── build.gradle │   ├── out │   └── src ├── lmsia-abc-server │   ├── build │   ├── build.gradle │   ├── out │   └── src ├── settings.gradle └── tool ├── compileThrift.sh └── shutdown.sh ``` 我们来逐一进行讲解: * 主项目级别Gradle配置文件: build.gradle和settings.gradle,定义了子项目,以及子项目共用的依赖、仓库等,我们会在稍后展开讲解。 * gradle最小化构建工具: gradle构建工具初始化后,会在项目中生成gradle、gradlew、gradlew.bat,这些是最小化的构建工具,方便项目移植后的构建。 * lmsia-abc-common: 如前文所属,我们的项目采用Thrift RPC。我们将Thrift的dsl文件、自动生成的Java(客户端桩)代码放置在common子项目中。这样,如果有其他微服务需要依赖相关数据结构,只需要依赖'lmsia-abc-common'即可。 * lmsia-abc-client: 在引用common包后,可以自行构造Thrift客户端,从而完成RPC调用。然而,这一过程较为繁琐。试想有一个提供用户信息的微服务,因为较为基础,有20个微服务依赖它,那么就需要20次书写重复的代码。"重复代码乃万恶之源",为了解决Thrift客户端重复生成的问题,我们创建了client子项目,负责生成Thrift客户端,并添加自动配置(如果没有接触过Spring Boot,可能会不理解自动配置,没有关系,我们很快就会作出解释)。 * lmsia-abc-server: 微服务的核心,即提供"服务"。我们将Thrift、RPC服务的逻辑代码封装在server子项目中。 * lmsia-abc-job: 在微服务业务的升级、演进过程中,可能会需要对数据作出修正。这些代码可能只需要执行一次,因此不需要放入server子项目提供服务,我们将他们放入job子项目中。 * tool: 一些提升微服务开发的效率工具,我们将在[开发效率脚本](../toolchain/spring-boot-scripts.md)一节中进行介绍。 由于篇幅所限,我们不会对Thrift进行入门介绍,如果你无法理解上述Thrift的DSL、自动代码生成等内容,可以参考[官方教程](http://thrift.apache.org/tutorial/java)。 我们来看一下根路径下的build.gradle ```shell buildscript { ext { springBootVersion = '1.5.6.RELEASE' } repositories { maven { url 'http://maven.aliyun.com/nexus/content/groups/public' } maven { url 'https://jitpack.io' } } dependencies { classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") } } subprojects { apply plugin: 'java' apply plugin: 'idea' apply plugin: 'org.springframework.boot' sourceCompatibility = 1.8 targetCompatibility = 1.8 group = 'com.coder4.lmsia' version = '0.0.1' repositories { maven { url 'http://maven.aliyun.com/nexus/content/groups/public' } maven { url 'https://jitpack.io' } mavenLocal() } } repositories { maven { url 'http://maven.aliyun.com/nexus/content/groups/public' } maven { url 'https://jitpack.io' } } ``` 我们来顺序解释上述文件: * buildscript: 定义了gradle自身所需要使用的资源,包含Spring Boot插件和maven的仓库地址。 * subprojects: 定义了子项目(common, client, job, server)所需要使用的共用部分,Java、IDEA、Spring Boot插件、Javac版本、项目的group, version,以及仓库,这里的仓库是给子项目使用的,看似与buildscript的定义重复,但确实是必要的。 * repositories: 定义主项需要的仓库地址,与上面类似,这里也是必须的,并不是冗余定义。 在settings.gradle中,定义了各个子项目的路径: ```shell include 'lmsia-abc-common' include 'lmsia-abc-client' include 'lmsia-abc-job' include 'lmsia-abc-server' ``` 下面,我们来看一下子项目中的gradle文件,以'lmsia-abc-server/build.gradle'为例: ```shell dependencies { compile project(':lmsia-abc-common') compile 'org.springframework.boot:spring-boot-starter-web' compile 'com.github.liheyuan:lmsia-thrift-server:0.0.1' compile 'com.github.liheyuan:lmsia-commons-http:0.0.1' testCompile 'org.springframework.boot:spring-boot-starter-test' } ``` 由于我们将子项目共用的部分抽取到根目录的build.gradle中,所以上述子项目的gradle文件就十分简单了。 上述文件表明:server子项目依赖common子项目,同时依赖了'spring-boot-starter-web'、'lmsia-thrift-server'、'lmsia-commons-http'两个项目,测试依赖'spring-boot-starter-test'。细心的读者可能已经发现,'spring-boot-starter-web'和'spring-boot-starter-test'并没有定义版本号。这就是我们在根文件中定义的'Spring Boot插件'所完成的工作之一。 ## common子项目的代码结构 我们来看一下common子项目的结构: ```shell ├── build.gradle └── src └── main ├── java │   └── com │   └── coder4 │   └── lmsia │   └── abc │   ├── constant │   │   └── LmsiaAbcConstant.java │   └── thrift │   └── LmsiaAbcThrift.java └── thrift └── lmsiaAbc.thrift ``` 我们解释一下目录结构: * 除了build.gradle外,代码被放置在src/main/java下,这是gradle推荐的默认路径。 * thrift的DSL文件放置在'src/main/thrift'下 * 编译好的Thrift桩文件在'src/main/java`下 ## client子项目的代码结构 接下来,我们看一下client子项目的目录结构: ```shell ├── build.gradle └── src ├── main │   ├── java │   │   └── com │   │   └── coder4 │   │   └── lmsia │   │   └── abc │   │   └── client │   │   ├── configuration │   │   │   └── LmsiaAbcClientConfiguration.java │   │   ├── LmsiaAbcEasyClientBuilder.java │   │   └── LmsiaK8ServiceClientBuilder.java │   └── resources │   └── META-INF │   └── spring.factories └── test └── java └── com └── coder4 └── lmsia └── abc └── client ├── LmsiaAbcEasyClientTest.java └── LmsiaAbcK8ServiceClientTest.java ``` * 自动配置: 代码包的LmsiaAbcClientConfiguration和资源包的spring.factories,一起实现了自动配置。当别的项目通过maven引用这个client包时,配置会自动生效,生成可注入的客户端实例。 * Builder: 方便手动或自动配置的调用,用于生成客户端实例。 * 测试: 'src/test'里面内置了两个测试。 ## server子项目的代码结构 看一下server子项目的目录结构: ```shell . ├── build.gradle └── src ├── main │   ├── java │   │   └── com │   │   └── coder4 │   │   └── lmsia │   │   └── abc │   │   └── server │   │   ├── configuration │   │   │   └── ThriftProcessorConfiguration.java │   │   ├── LmsiaAbcApplication.java │   │   ├── rest │   │   │   ├── controller │   │   │   │   └── AbcController.java │   │   │   ├── logic │   │   │   │   ├── impl │   │   │   │   │   └── AbcLogicImpl.java │   │   │   │   └── intf │   │   │   │   └── AbcLogic.java │   │   │   └── wrapper │   │   ├── service │   │   │   ├── impl │   │   │   │   └── HelloServiceImpl.java │   │   │   └── intf │   │   │   └── HelloService.java │   │   └── thrift │   │   └── ThriftServerHandler.java │   └── resources │   ├── application.yaml │   └── logback-spring.xml └── test └── java └── com.coder4.lmsia.abc └── server └── LmsiaAbcTest.java ``` 解释一下文件: * RPC服务相关: * 自动配置: 'server.configuration.ThriftProcessorConfiguration'是RPC服务的自动配置,用于自动启动RPC服务,我们后面会对此详细讲解。 * RPC入口函数: server.thrift.thrift.ThriftServerHandler定义了RPC的入口函数 * REST服务:REST服务放在server.rest包下,并进行了进一步分层 * Spring MVC: Controller在rest.controller下 * REST逻辑: 为了防止Controller过于臃肿,我们将Controller的逻辑都放在了rest.logic中。该包又分为intf和impl,前者是Interface(接口),后者是Implementation(实现)。 * Wrapper: 如果Logic中需要对REST接口进行包装,可以放在wrapper里 * 业务逻辑: 我们将所有业务逻辑抽象出来,放到server.service下,与Logic类似,也分为intf和impl * 配置: * Spring Boot配置:resources/application.yaml是Spring Boot的配置文件,如服务名、数据库配置等 * 日志配置:我们使用了默认的logback作为日志系统,配置在resources/logback-spring.xml中 * 测试用例:test下,与client和common类似,不再赘述。 上述分层看起来有些复杂,但会让各个层次的职责划分的更为清楚,如果你的项目中有更好的方案,也可以采用已有分层结构。 ## job子项目的代码结构 最后,我们看一下job子项目的目录结构: ```shell ├── build.gradle └── src └── main ├── java │   └── com │   └── coder4 │   └── lmsia │   └── abc │   └── job │   ├── LmsiaAbcJob.java │   └── LmsiaAbcJobStarter.java └── resources ├── application.yaml └── logback-spring.xml ``` 简单解释下: * 命令行入口: 本节开篇部分已经提到,job是可执行程序,LmsiaAbcJobStarter即是命令行的入口。 * 具体job: 这里只有一个LmsiaAbcJob,会通过参数与入口关联,后续会详细讲解。 至此,我们已经对lmsia这个示例项目的Gradle、子项目划分、子项目结构做了较为详尽的讲解。 需要说明的是:由于篇幅先后关系的问题,server子项目我们并未包含数据库、事件处理的相关文件和目录结构,我们会在后续章节视进度逐渐添加。 [^1]:数据来源自官方性能评测[Gradle vs Maven: Performance Comparison](https://gradle.org/gradle-vs-maven-performance/) ================================================ FILE: legacy/spring-boot/sb-mockito.md ================================================ # Mockito 单元测试打桩神器 ## 单元测试 软件测试是软件质量保证的关健环节,代表了需求、设计和编码的最终检查。 ![软件测试金字塔](./test.jpg "软件测试金字塔") 如上图所示,测试金字塔将测试分为三类 * 单元测试: 由开发者自行编写,应当覆盖80%以上的场景。对微服务架构而言,主要是在单个微服务内部,对复杂业务逻辑,编写单元测试。 * 集成测试: 由测试人员编写,强调系统整体联动,多偏向业务可用性验证。如下单流程是否畅通,库存扣减是否成功。它的覆盖场景一般之占10%。 * 功能测试: 一些由单元测试、集成测试不好做的,通过功能测试完成,一般来说,这类不好自动化的,需要手动进行测试。由于测试成功很高,这类一般只覆盖5%的场景。 测试金字塔也向我们揭示了一个实时:单元测试是整个测试环节的根基,如果单元测试做不好,上层的集成测试、功能测试都会无从谈起。 遗憾的是,多数开发者都不具备编写单元测试的良好习惯,甚至缺乏编写单元的动力。 除了缺乏软件质量保障的意识外,"嫌麻烦"也是这类开发者面对单元测试的口头禅。 本节将介绍Mockito,这是一个单元测试的利器。Mockito的出现,让我们可以更加轻松地编写单元测试。 在介绍Mockito之前,先来解释下,我们为什么不推荐使用Spring Boot启动单元测试框架。 实际上,Spring Boot本身是提供了单元测试框架的,可以在JUnit中通过注解的配置,启动一个Spring上下文环境,并支持自动注入等功能,如果你感兴趣,可以参考[这篇文档](http://www.baeldung.com/spring-boot-testing)。 在实际工作中,我也尝试过上述方法,但效果却并不太好,主要原因是: * 启动Spring Boot环境速度很慢,至少要3秒,而一般的单元测试都是毫秒级别。 * 依赖管理需要手动声明,随着业务不断升级,经常忘记维护单元测试中的依赖,导致单元测试无法正常执行。 基于上述原因,我强烈不推荐在单元测试中启动Spring Boot环境。 对于服务之间存在依赖关系的场景,建议直接使用Mockito的打桩(Mock)进行。 希望在仔细的阅读本节后,你也会爱上单元测试:-) ## Mockito 在软件测试中,Mock指的是效仿、模仿。Mockito就是为了解决测试中的Mock问题而诞生的,它可以很好的解决单元测试中,由于不同类耦合而带来的难以测试的问题。 还是以上面Spring Boot环境为例子。假设我们要测试A类,而类A又调用了B类和C类。此时可能有两种选择: 1. 手动构造B和C。 1. 通过启动Spring环境,自动地注入B和C。 现在有了Mockito后,我们有了另外的思路:无需构造B和C,而是通过Mockito,"Mock"出B和C(构造符合接口但没有实现的类),由于我们要测试的是A类中的逻辑,只要检查A调用B和C的时机、次数、参数是否正确,就可以了。 我们通过一个例子,来说明mockito的用法。 首先是ServiceA和它的实现: ```java package com.coder4.lmsia.abc.server.service.intf; /** * @author coder4 */ public interface ServiceA { int methodA(int a, int b); } ``` ```java package com.coder4.lmsia.abc.server.service.impl; import com.coder4.lmsia.abc.server.service.intf.ServiceA; import com.coder4.lmsia.abc.server.service.intf.ServiceB; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; /** * @author coder4 */ @Service public class ServiceAImpl implements ServiceA { @Autowired private ServiceB serviceB; @Override public int methodA(int a, int b) { if (a <= 10 && b <= 10) { return a + b; } else { return serviceB.methodB(a, b); } } } ``` 然后是服务B和它的实现: ```java package com.coder4.lmsia.abc.server.service.intf; /** * @author coder4 */ public interface ServiceB { int methodB(int a, int b); } ``` ```java package com.coder4.lmsia.abc.server.service.impl; import com.coder4.lmsia.abc.server.service.intf.ServiceB; import org.springframework.stereotype.Service; /** * @author coder4 */ @Service public class ServiceBImpl implements ServiceB { @Override public int methodB(int a, int b) { return a * b; } } ``` 我们总结一下功能: * 在服务A中,若参数a和b都小于10,则返回求和结果,否则交给服务B处理。 * 在服务B中,直接返回参数a和b的乘积结果。 在编写单元测试前,先要引用对应的包,lmabc-server/build.gradle: ```grovvy dependencies { compile project(':lmsia-abc-common') ... testCompile 'junit:junit:4.12' testCompile 'org.mockito:mockito-all:1.9.5' } ``` 这里要指出的是,mockito本身还是需要单元测试框架才能运行的,我们这里用的是最常见的JUnit。 然后看一下单元测试 ```java package com.coder4.lmsia.abc.server; import com.coder4.lmsia.abc.server.service.impl.ServiceAImpl; import com.coder4.lmsia.abc.server.service.intf.ServiceA; import com.coder4.lmsia.abc.server.service.intf.ServiceB; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; import org.mockito.internal.util.reflection.Whitebox; import static org.hamcrest.CoreMatchers.is; /** * @author coder4 */ public class ServiceATest { private ServiceA serviceA; private ServiceB serviceB; @Before public void before() { serviceA = new ServiceAImpl(); serviceB = Mockito.mock(ServiceB.class); Whitebox.setInternalState(serviceA, "serviceB", serviceB); } @Test public void testBelow10() { Assert.assertThat(serviceA.methodA(1, 1), is(2)); Mockito.verifyZeroInteractions(serviceB); } @Test public void testAbove10() { serviceA.methodA(100, 1); Mockito.verify(serviceB).methodB(100, 1); } } ``` 我们分步解释一下: * before: 初始化ServiceA,因为我们要测试这个,所以必须手动初始化。而ServiceB我们在A的测试并不关注,直接Mock一个,并通过Whitebox注入到服务A中。 * testBelow10: 前面服务实现已经介绍过,当参数a和b都小于10的场景,是在ServiceA中直接求和。所以这里我们验证两个的和,然后验证下是不是没有"碰过"服务B(verifyZeroInteractions) * testAbove10: 当任何一个参数大于10时候,实际会走服务B。所以我们验证下是否调用了服务B,且参数恰好是传给A的就好。 怎么样,有了Mockito后,测试是不是变得有趣起来了:-) [官方文档](http://static.javadoc.io/org.mockito/mockito-core/2.18.3/org/mockito/Mockito.html)中提供了更多有趣的例子,等待你的发掘。 ================================================ FILE: legacy/spring-boot/sb-rest.md ================================================ # Spring Boot REST接口 在介绍服务发现和负载均衡时已经提到,我们的架构中,对每个微服务开放两个虚拟IP端口,一个是RPC,另外一个是REST(HTTP)。 在上一节中,我们探讨了Spring Boot中集成Thrift RPC的方案,主要是针对RPC端口。 在本节中,我们首先看一下优雅停机的问题,随后探讨REST服务的发现与负载均衡均衡的问题。 ## 优雅停机 如果你经历过生产环境架构设计的话,一定遇到过"优雅停机"的需求。 优雅停机指的是在服务重启过程中,每个服务节点在不影响任何线上请求的前提下,有计划而平滑的退出。 听起来有些抽象,我们看看下图中的例子: ![优雅停机例子](./graceful-shutdown.png "优雅停机例子") 1. Kubernetes VIP收到请求,假设分发到Service B的Replica 3(最下面的节点)上 1. Replica 3的节点因计划升级,恰好停机,变为红色。 1. 此时,之前分发到Replica 3上的请求没有被处理完,服务就被停机了,于是发生错误。 你可能会说,这种概率非常小。但随着系统越来越庞大、上线节奏越来越快、业务规模越来越大后,出现这种问题的概率也会逐步增大。 实际上,我们在[微服务的自动发现与负载均衡](../ms-discovery/msd.md)中所使用的例子就会有这个问题。 我们来试验一下。首先,我们为镜像新建两个不同的版本0.1和0.2(实际内容是一样的)。 然后登录minikube,执行如下命令: ```shell while true; do curl -s "http://10.97.42.195:8080/lmsia-abc/api/" > /dev/null || echo "curl fail" ;done ``` 上述命令,会curl不间断地通过虚拟IP访问REST服务,如果出错的时候会报警。如果一切正常的话,当你执行上述命令,不会出现"curl fail"的报警。 下面让我们修改一下yaml文件: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: lmsia-abc-server-deployment spec: selector: matchLabels: app: lmsia-abc-server replicas: 2 template: metadata: labels: app: lmsia-abc-server spec: containers: - name: lmsia-abc-server-ct # here change from latest to 0.1 image: coder4/lmsia-abc-server:0.1 ports: - containerPort: 8080 - containerPort: 3000 ``` 上面我们修改了镜像的版本到0.1,然后我们新打开一个shell,应用一下 ```shell kubectl apply -f ./lmsia-abc-server-deployment.yaml ``` 再切换回刚才执行循环curl的命令行,会发现大量的失败: ```shell ... curl fail curl fail curl fail curl fail .... ``` 这就是说明,在Kubernetes更新Pod的镜像版本的时候,但是还是有部分请求打到了被杀掉的Pod上,从而导致请求失败。或者说,发生了"不优雅"的停机。 为了解决这个问题,我们可以假设一种如下的方案: 1. Replica 3节点需要停机前,以某种方式通知Kubernetes的VIP 1. Kubernetes的VIP收到通知后,摘掉Replica 3 1. Replica 3停机。 上述方案比起"粗鲁"的直接杀掉Replica 3,要"优雅"地多。实际上,这也是大多数系统中,优雅停机方案的原型。 我们来看一下具体的原理,首先看看deployment.yaml: ```shell apiVersion: apps/v1 kind: Deployment metadata: name: lmsia-abc-server-deployment spec: selector: matchLabels: app: lmsia-abc-server replicas: 2 template: metadata: labels: app: lmsia-abc-server spec: containers: - name: lmsia-abc-server-ct image: coder4/lmsia-abc-server:0.2 ports: - containerPort: 8080 - containerPort: 3000 readinessProbe: httpGet: path: /health port: 8081 initialDelaySeconds: 5 periodSeconds: 5 ``` 与之前的deployment定义相比,这里新增了"readinessProbe"一项目。关于它的详细解释可以参考官方文档,这里简单解释下具体的这个例子。 * Kubernetes会每间隔5秒钟,通过HTTP协议请求8081端口下的/health路径。 * 若能成功打开,会认为服务可用,不做任何处理。 * 若不能打开(非200返回值或无法连接)则会将对应的Pod从VIP上摘除,直到恢复为可用。 对应地,在lmsia-abc服务中,我们也应该定义这个8081端口的服务。 在本书架构中,我们使用Spring Boot内置的health indicator,即健康监控机制。 首先是自动配置GracefulShutdownConfiguration: ```java package com.coder4.lmsia.gracefulshutdown.configuration; import com.coder4.lmsia.gracefulshutdown.GracefulStatusHealthIndicator; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * @author coder4 */ @Configuration public class GracefulShutdownConfiguration implements DisposableBean { private static final int GRACE_SHUTDOWN_MS = 6000; private GracefulStatusHealthIndicator gshIndicator = new GracefulStatusHealthIndicator(); @Bean @ConditionalOnMissingBean(GracefulStatusHealthIndicator.class) public GracefulStatusHealthIndicator gracefulStatusHealthIndicator() { return gshIndicator; } @Autowired @Qualifier("shutdownThriftServerRunnable") private Runnable shutdownThriftServerRunnable; @Override public void destroy() throws Exception { gshIndicator.setReady(false); Thread.sleep(GRACE_SHUTDOWN_MS); if (shutdownThriftServerRunnable != null) { shutdownThriftServerRunnable.run(); } } } ``` 如上所示 * 自动配置会无条件激活。 * 初始状态,新建一个GracefulStatusHealthIndicator,我们稍后会讲解它。 * 销毁时,设置indicator的状态为not ready,睡眠6秒,然后尝试关闭Thrift服务。 为什么这里要睡眠6秒钟呢?我们可以回顾下,刚才设置deployment的时候,Kubernetes是每间隔5秒钟扫描一次。所以这里设置的休眠6秒钟,刚好可以覆盖一个扫描周期。 然后是具体的健康监控GracefulStatusHealthIndicator: ```java package com.coder4.lmsia.gracefulshutdown; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.HealthIndicator; /** * @author coder4 */ public class GracefulStatusHealthIndicator implements HealthIndicator { private static final String GRACEFUL_STATUS_KEY = "graceful_status"; private Logger LOG = LoggerFactory.getLogger(getClass()); private Health health; public GracefulStatusHealthIndicator() { setReady(true); } public void setReady(boolean ready) { synchronized (this) { if (ready) { health = new Health.Builder().withDetail(GRACEFUL_STATUS_KEY, "graceful_status_up").up().build(); LOG.info("graceful_status up"); } else { health = new Health.Builder().withDetail(GRACEFUL_STATUS_KEY, "graceful_status_down").down().build(); LOG.info("graceful_status down"); } } } @Override public Health health() { return health; } } ``` 上述代码比较简单,就是利用了Spring Boot内置的HealIndicator,当ready = false时,indicator和health都会挂掉(down),导致对应健康监控服务不可用。 最后,我们如何把健康监控导到8081端口呢?答案是通过application.yaml里的配置: ``` ... other config ... management: port: 8081 security.enabled: false ... other config ... ``` 熟悉Spring Boot的朋友清楚,这里如果不做上述定义,那么默认会在8080端口打开上述监控。为什么还要多配置这一步呢?这主要是为了安全性的考量。在实际开发中,可能还会暴露CPU、内存、硬盘等信息,而这些对于安全渗透人员来讲,都是非常危险的内部信息。因此,我们将这些健康信息放在另外的端口8081,并且不配置Nginx的反向代理,让他与外网隔离。 配置好这些后,你可以再执行循环curl的测试,并且升级镜像"coder4/lmsia-abc-server"到0.2,可以发现curl的访问再没有中断过。这就说明,我们的"优雅停机"配置成功了! ## REST(HTTP)服务的发现与负载均衡 如果大家仔细阅读了[Spring Boot整合Thrift RPC](spring-boot-1/sb-thrift.md),并且认真思考一下后,不难想到,其实REST(HTTP)服务的发现与负载均衡,与RPC服务的负载均衡,并没有什么两样。 ![REST(HTTP)服务的发现与负载均衡](./rest-nginx.png "REST(HTTP)服务的发现与负载均衡") 如上图所示 * 在我们的架构中,REST(HTTP)端口直接面向Web、PC、移动客户端暴露,所以在最外层,部署了一层Nginx,作为接入网关的代理。在Nginx上,通过UpStream的方式,指向对应微服务的VIP的Host地址和端口8080。 * 我们的REST(HTTP)服务集成及负载均衡,也采用Kubenetes的Service和虚拟IP来实现,唯一的区别是端口为8080。 需要特别指出的是,之前在讨论RPC的负载据均衡和发现时,我们并没有列出外部用户。实际上,在我们的架构中,RPC服务只面向内部使用,即只能是其他内部微服务调用,而不对外暴露服务能力。这主要是基于安全性的考虑。 有了上述一层隔离后,我们可以采取如下策略: * 由于微服务的RPC只对内部可用,可以跳过基本的鉴权等安全性检查,从而提高系统性能、降低开发难度。 * 在对外部暴露的REST(HTTP)服务中,再进行鉴权等安全性检查。 由于已经内置了Spring MVC,在Spring Boot中集成REST服务非常简单,我们来看一个例子: ```java package com.coder4.lmsia.abc.server.rest.controller; import com.coder4.lmsia.abc.constant.LmsiaAbcConstant; import com.coder4.lmsia.abc.server.rest.logic.intf.AbcLogic; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** * @author coder4 */ @RestController @RequestMapping(LmsiaAbcConstant.REST_API) public class AbcController { @Autowired private AbcLogic abcLogic; @GetMapping(value = "/") public String hello() { return abcLogic.getHello(); } } ``` 再看下这个常量: ```java public class LmsiaAbcConstant { ... public static final String REST_API = "/lmsia-abc/{client:api|iphone|ipad|android|win}"; ... } ``` 如上所述,我们通过@RestController和RequestMapping,设定了REST服务的路径为:"http://host:8080/lmsia-abc/api/,为什么要加上lmsia-abc这一层路径呢? 这是为了配置Nginx的UpStream时方便。加上这一层后,我们可以直接将端口转发过来,而不需要再纠结路径层面的转发。只要微服务的命名规则约定好,就能保证微服务之间的REST服务的路径不会相互冲突。 至此,我们已经完成了RPC服务、REST服务在Spring Boot中的集成,并且都实现了服务发现、负载均衡的这微服务的核心功能。 ================================================ FILE: legacy/spring-boot/sb-thrift.md ================================================ # Spring Boot整合Thrift RPC ## Spring Boot自动配置简介 在介绍RPC之前,我们先来学习下Spring Boot的自动配置。 我们前面已经提到:Spring Boot来源于Spring,并且做了众多改进,其中最有用的设计理念是约定优于配置,它通过自动配置功能(大多数开发者平时习惯设置的配置作为默认配置)为开发者快速、准确地构建出标准化的应用。 以集成MySQL数据库为例,在Spring Boot出现之前,我们要 1. 配置JDBC驱动依赖 1. 配置XML文件中数据源 1. 配置XML中的DataSource Bean 1. 配置XML中的XXXTemplate Bean 1. 配置XML中的XXXTransactionManager Bean 有了Spring Boot的自动配置后,自动配置帮我们生成了各种DataSource、XXXTemplate、XXXTransactionManager,我们所需要做的只有一条,就是激活它 1. maven中依赖包含自动配置的包 1. 配置JDBC驱动依赖 1. yaml文件中定义数据源 自动配置进行智能检测,只要满足上述3个条件,其他的Bean都会被自动生成并注入到Spring环境中。我们需要使用时只需要@Autowired一下就可以了,是不是非常简单! 由于篇幅所限,本书不会对自动配置的书写做零起点教学,如果你想了解自动配置的原理,可以参考这篇文章[spring boot实战(第十三篇)自动配置原理分析](https://blog.csdn.net/liaokailin/article/details/49559951) 在本节的后续部分,我们会以Thrift RPC Server为例,看看自动配置是如何书写的。 ## RPC简介 远程过程调用(remote procedure call或简称RPC),指的是运行于本地(客户端)的程序像调用本地程序一样,直接调用另一台计算机(服务器端)的程序,而程序员无需额外为远程交互做额外的编程。 RPC极大地简化了分布似乎系统中节点之间网络通信的开发工作量,是微服务架构中的重要组件之一。 在本书中,我们选用Thrift作为RPC框架。由于篇幅所限,我们不会对Thrift RPC作出详尽的介绍,如果你还不熟悉,可以参考官方的[快速入门文档](https://thrift.apache.org/tutorial/java)。 ## Spring Boot整合Thrift RPC服务端 简要来说,启动一个Thrift RPC的服务端需要如下步骤: 1. 书写DSL(.thrift文件),定义函数、数据结构等。 1. 编译并生成桩代码。 1. 编写Handler(RPC的逻辑入口)。 1. 基于上述Handler,构造Processor。 1. 构造Server,Thrift提供了多种服务端供选择,常用的有TThreadPoolServer(多线程服务器)和TNonblockingServer(非阻塞服务器)。 1. 设置Server的Protocol,类似的,Thrift提供了多种传输协议,最常用的是TBinaryProtocol和TCompactProtocol。 1. 设置Server的Transport(Factory),用这种方式指定底层的传输协议,常用的有TFramedTransport、TNonBlockingTransport,不同的Transport可以类似Java的IOStreawm方式,相互叠加,以产生更强大的效果。 上述对Thrift服务器的架构做了简要介绍,如果想更深入了解,可以自行阅读[官方源码](https://github.com/apache/thrift/tree/master/lib/java/src/org/apache/thrift)。 首先,我们来看一下thrift定义(根据上一节的介绍,文件放在lmsia-abc-common包中) ```thrift namespace java com.coder4.lmsia.abc service lmsiaAbcThrift { string sayHi() } ``` 调用thrift进行编译后,我们也将对应的桩文件放置在lmsia-abc-client下,目录结构可以参见上一节。 为了更方便的在Spring Boot中集成Thrift服务器,我将相应代码抽取成了公用库[lmsia-thrift-server](https://github.com/liheyuan/lmsia-thrift-server) ```shell ├── build.gradle ├── gradle │   └── wrapper │   ├── gradle-wrapper.jar │   └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── README.md ├── settings.gradle └── src ├── main │   ├── java │   │   └── com │   │   └── coder4 │   │    └── lmsia │   │      └── thrift │   │      └── server │   │      ├── configuration │   │      │   └── ThriftServerConfiguration.java │   │      └── ThriftServerRunnable.java │   └── resources │   └── META-INF │   └── spring.factories └── test └── java ``` 简单解析下项目结构: gradle相关: 与前节介绍的类似,只不过这里是单项目功能。 ThriftServerConfiguration: 自动配置,当满足条件后会自动激活,激活后可自动启动Thrift RPC服务。 ThriftServerRunnable: Thrift RPC服务器的构造逻辑、运行线程。 spring.factories: 当我们以类库方式提供自动配置时,需要增加这个spring.factories,让别的项目能"定位到"要检查的自动配置。 首先,我们来看一下ThriftServerRunnable.java ```java package com.coder4.lmsia.thrift.server; import org.apache.thrift.TProcessor; import org.apache.thrift.protocol.TBinaryProtocol; import org.apache.thrift.protocol.TProtocolFactory; import org.apache.thrift.server.TServer; import org.apache.thrift.server.TThreadedSelectorServer; import org.apache.thrift.transport.TFramedTransport; import org.apache.thrift.transport.TNonblockingServerSocket; import org.apache.thrift.transport.TNonblockingServerTransport; import org.apache.thrift.transport.TTransportException; import org.apache.thrift.transport.TTransportFactory; import java.util.concurrent.ExecutorService; import java.util.concurrent.SynchronousQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; /** * @author coder4 */ public class ThriftServerRunnable implements Runnable { private static final int THRIFT_PORT = 3000; private static final int THRIFT_TIMEOUT = 5000; private static final int THRIFT_TCP_BACKLOG = 5000; private static final int THRIFT_CORE_THREADS = 128; private static final int THRIFT_MAX_THREADS = 256; private static final int THRIFT_SELECTOR_THREADS = 16; private static final TProtocolFactory THRIFT_PROTOCOL_FACTORY = new TBinaryProtocol.Factory(); // 16MB private static final int THRIFT_MAX_FRAME_SIZE = 16 * 1024 * 1024; // 4MB private static final int THRIFT_MAX_READ_BUF_SIZE = 4 * 1024 * 1024; protected ExecutorService threadPool; protected TServer server; protected Thread thread; private TProcessor processor; private boolean isDestroy = false; public ThriftServerRunnable(TProcessor processor) { this.processor = processor; } public TServer build() throws TTransportException { TNonblockingServerSocket.NonblockingAbstractServerSocketArgs socketArgs = new TNonblockingServerSocket.NonblockingAbstractServerSocketArgs(); socketArgs.port(THRIFT_PORT); socketArgs.clientTimeout(THRIFT_TIMEOUT); socketArgs.backlog(THRIFT_TCP_BACKLOG); TNonblockingServerTransport transport = new TNonblockingServerSocket(socketArgs); threadPool = new ThreadPoolExecutor(THRIFT_CORE_THREADS, THRIFT_MAX_THREADS, 60L, TimeUnit.SECONDS, new SynchronousQueue<>()); TTransportFactory transportFactory = new TFramedTransport.Factory(THRIFT_MAX_FRAME_SIZE); TThreadedSelectorServer.Args args = new TThreadedSelectorServer.Args(transport) .selectorThreads(THRIFT_SELECTOR_THREADS) .executorService(threadPool) .transportFactory(transportFactory) .inputProtocolFactory(THRIFT_PROTOCOL_FACTORY) .outputProtocolFactory(THRIFT_PROTOCOL_FACTORY) .processor(processor); args.maxReadBufferBytes = THRIFT_MAX_READ_BUF_SIZE; return new TThreadedSelectorServer(args); } @Override public void run() { try { server = build(); server.serve(); } catch (Exception e) { e.printStackTrace(); throw new RuntimeException("Start Thrift RPC Server Exception"); } } public void stop() throws Exception { threadPool.shutdown(); server.stop(); } } ``` 我们来解释一下: * build方法用于构造一个可供运行的Thrift RPC Server 1. 构造非阻塞Socket,并设置监听端口、超时 2. 构造非阻塞Transport 3. 构造线程池,在这里我们的服务器模型是非阻塞线程池RPC服务器。 4. 构造底层传输协议即TFramedTransport 5. 构造ThriftServer,并设置前面构造的非阻塞Transport、线程池、协议TBinaryProtocol * 整个ThriftServerRunnable类是一个线程Runnablerun,run函数中构造RPC服务,并启动服务(servee) * stop服务提供停止服务的方法 下面我们来看一下自动配置ThriftServerConfiguration.java: ```java package com.coder4.lmsia.thrift.server.configuration; import com.coder4.lmsia.thrift.server.ThriftServerRunnable; import org.apache.thrift.TProcessor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.context.annotation.Configuration; import java.util.concurrent.TimeUnit; /** * @author coder4 */ @Configuration @ConditionalOnBean(value = {TProcessor.class}) public class ThriftServerConfiguration implements InitializingBean, DisposableBean { private Logger LOG = LoggerFactory.getLogger(ThriftServerConfiguration.class); private static final int GRACEFUL_SHOWDOWN_SEC = 3; @Autowired private TProcessor processor; private ThriftServerRunnable thriftServer; private Thread thread; @Override public void destroy() throws Exception { LOG.info("Wait for graceful shutdown on destroy(), {} seconds", GRACEFUL_SHOWDOWN_SEC); Thread.sleep(TimeUnit.SECONDS.toMillis(GRACEFUL_SHOWDOWN_SEC)); LOG.info("Shutdown rpc server."); thriftServer.stop(); thread.join(); } @Override public void afterPropertiesSet() throws Exception { thriftServer = new ThriftServerRunnable(processor); thread = new Thread(thriftServer); thread.start(); } } ``` 这是我们编写的第一个自动配置,我们稍微详细的解释一下: * 启动条件: 仅当服务提供了TProcessor才启用,我们稍后会在lmsia-abc项目中看到,后者封装了RPC的桩入口,提供了TProcessor。 * InitializingBean: 自动配置实现了InitializingBean,为什么要实现这个接口呢?当这个自动配置被初始化时,所有Autowired的属性被自动注入(即Processor),而前面ThriftServerRunnable中我么已经看到,只有拿到了TProcessor,才能启动RPC服务。因此,我们使用了InitializingBean,它自带了afterPropertiesSet这个回调,会在所有属性被注入完成后,调用这个回调函数。 * 在这里,我们调用了ThriftServerRunnable实现了Thrift RPC服务器的启动。 * DisposableBean: 除了InitializingBean,我们还实现了DisposableBean。看名字就可以知道,这是Spring为了服务关闭时清理资源而设计的接口。事实也是如此,当服务关闭时,会依次调用每个自动配置,如果实现了DisposableBean,则回调destroy函数。 * 在这里,我们先让线程休眠3秒,然后才关闭Thrift RPC服务,这主要是为了Graceful Shutdown而设计的("优雅关闭"),关于这一点,我们会在下一节会做详细讲解。 最后,我们的自动配置默认是无法被发现的,需要一个配置文件spring.factories: ```shell org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.coder4.lmsia.thrift.server.configuration.ThriftServerConfiguration ``` 解读完lmsia-thrift-server后,我们看看如何将它整合进lmsia-abc项目中。 1. 在lmsia-abc-server子项目中的build.gradle中加入: ```grovvy compile 'com.github.liheyuan:lmsia-thrift-server:0.0.1' ``` 1. 提供一个TProcessor,如前文所述,这是启用自动配置的必要条件,ThriftProcessorConfiguration: ```java package com.coder4.lmsia.abc.server.configuration; import com.coder4.lmsia.abc.thrift.LmsiaAbcThrift; import com.coder4.lmsia.abc.server.thrift.ThriftServerHandler; import org.apache.thrift.TProcessor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * @author coder4 */ @Configuration @ConditionalOnProperty(name = "thriftServer.enabled", matchIfMissing = true) public class ThriftProcessorConfiguration { @Bean(name = "thriftProcessor") public TProcessor processor(ThriftServerHandler handler) { return new LmsiaAbcThrift.Processor(handler); } } ``` 我们简单解释下: * 这也是一个自动配置,仅当配置文件中thriftServer.enabled=true时才启用(不配置默认true) * 提供的TProcessor,需要依赖ThriftServerHandler,这个就是Thrift生成的桩函数,项目结构分析中已经提到过,这是RPC服务器的逻辑入口。 怎么样,使用了自动配置后,启动一个Thrift 服务器是不是非常简单? ## Spring Boot整合Thrift RPC客户端 只有服务端是不行的,还需要有客户端。 类似地,为了方便的生成客户端,我们把代码进行了整理和抽象,放到了[lmsia-thrift-client](https://github.com/liheyuan/lmsia-thrift-client)项目中。 首先看一下项目结构: ```shell ├── build.gradle ├── gradle │   └── wrapper │   ├── gradle-wrapper.jar │   └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── README.md ├── settings.gradle └── src ├── main │   ├── java │   │   └── com │   │   └── coder4 │   │   └── lmsia │   │   └── thrift │   │   └── client │   │   ├── ThriftClient.java │   │   ├── AbstractThriftClient.java │   │   ├── EasyThriftClient.java │   │   ├── K8ServiceThriftClient.java │   │   ├── K8ServiceKey.java │   │   ├── builder │   │   │   ├── EasyThriftClientBuilder.java │   │   │   └── K8ServiceThriftClientBuilder.java │   │   ├── func │   │   │   ├── ThriftCallFunc.java │   │   │   └── ThriftExecFunc.java │   │   ├── pool │   │   │   ├── TTransportPoolFactory.java │   │   │   └── TTransportPool.java │   │   └── utils │   │   └── ThriftUrlStr.java │   └── resources └── test └── java └── LibraryTest.java ``` 解释下项目结构: * gradle相关的与之前类似,不再赘述 * ThriftClient相关,定义了Thrift的客户端 1. ThriftClient 抽象了客户端的接口 1. AbstractThriftClient 实现了除连接外的Thrift Client操作 1. EasyThriftClient 使用IP和端口直连的Thrift Client 1. K8ServiceThriftClient 使用Kubernetes服务名字(根据[微服务自动发现](../ms-discovery/msd.md)一节中的介绍,服务名字实际也是Host)和端口的Thrift Client,并内置了连接池。 * func 函数编程工具类 * builder 方便快速构造上述两种Thrift Client * pool 客户端连接池 本小节主要对IP、端口直连的客户端即EasyThriftClient进行介绍。关于支持服务自动发现以及连接池功能的K8ServiceThriftClient,将在下一节进行介绍。 先看一下接口定义,ThriftClient: ```java package com.coder4.lmsia.thrift.client; import com.coder4.lmsia.thrift.client.func.ThriftCallFunc; import com.coder4.lmsia.thrift.client.func.ThriftExecFunc; import org.apache.thrift.TServiceClient; import java.util.concurrent.Future; /** * @author coder4 */ public interface ThriftClient { /** * sync call with return value * @param tcall thrift rpc client call * @param return type * @return */ TRET call(ThriftCallFunc tcall); /** * sync call without return value * @param texec thrift rpc client */ void exec(ThriftExecFunc texec); /** * async call with return value * @param tcall thrift rpc client call * @param * @return */ Future asyncCall(ThriftCallFunc tcall); /** * asnyc call without return value * @param texec thrift rpc client call */ Future asyncExec(ThriftExecFunc texec); } ``` 这里需要解释一下,上述实际分成了两大类: * exec 无返回值的rpc调用 * call 有返回值的调用 这里使用了Java 8的函数式编程进行抽象。如果不太熟悉的朋友,可以自行查阅相关资料。 在函数式编程的帮助下,我们可以将每一个rpc调用都分为同步和异步两种,异步的调用会返回一个Future。 再来看一下AbstractThriftClient: ```java /** * @(#)AbstractThriftClient.java, Aug 01, 2017. *

* Copyright 2017 fenbi.com. All rights reserved. * FENBI.COM PROPRIETARY/CONFIDENTIAL. Use is subject to license terms. */ package com.coder4.lmsia.thrift.client; import com.coder4.lmsia.thrift.client.func.ThriftCallFunc; import com.coder4.lmsia.thrift.client.func.ThriftExecFunc; import org.apache.thrift.TServiceClient; import org.apache.thrift.TServiceClientFactory; import org.apache.thrift.protocol.TBinaryProtocol; import org.apache.thrift.protocol.TProtocol; import org.apache.thrift.transport.TTransport; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; /** * @author coder4 */ public abstract class AbstractThriftClient implements ThriftClient { protected static final int THRIFT_CLIENT_DEFAULT_TIMEOUT = 5000; protected static final int THRIFT_CLIENT_DEFAULT_MAX_FRAME_SIZE = 1024 * 1024 * 16; private Class thriftClass; private static final TBinaryProtocol.Factory protocolFactory = new TBinaryProtocol.Factory(); private TServiceClientFactory clientFactory; // For async call private ExecutorService threadPool; public void init() { try { clientFactory = getThriftClientFactoryClass().newInstance(); } catch (Exception e) { throw new RuntimeException(); } if (!check()) { throw new RuntimeException("Client config failed check!"); } threadPool = new ThreadPoolExecutor( 10, 100, 0, TimeUnit.MICROSECONDS, new LinkedBlockingDeque<>()); } protected boolean check() { if (thriftClass == null) { return false; } return true; } @Override public Future asyncCall(ThriftCallFunc tcall) { return threadPool.submit(() -> this.call(tcall)); } @Override public Future asyncExec(ThriftExecFunc texec) { return threadPool.submit(() -> this.exec(texec)); } protected TCLIENT createClient(TTransport transport) throws Exception { // Step 1: get TProtocol TProtocol protocol = protocolFactory.getProtocol(transport); // Step 2: get client return clientFactory.getClient(protocol); } private Class> getThriftClientFactoryClass() { Class clientClazz = getThriftClientClass(); if (clientClazz == null) { return null; } for (Class clazz : clientClazz.getDeclaredClasses()) { if (TServiceClientFactory.class.isAssignableFrom(clazz)) { return (Class>) clazz; } } return null; } private Class getThriftClientClass() { for (Class clazz : thriftClass.getDeclaredClasses()) { if (TServiceClient.class.isAssignableFrom(clazz)) { return (Class) clazz; } } return null; } public void setThriftClass(Class thriftClass) { this.thriftClass = thriftClass; } } ``` 上述抽象的Thrift客户端实现了如下功能: 1. 客户端线程池,这里主要是为异步调用准备的,与之前构造的服务端的线程池是完全不同的。 * asyncCall和asyncExec使用了线程池来完成异步调用 1. thriftClass 存储了Thrift的桩代码了类,不同业务生成的ThriftClass不一样,所以这里存储了class。 1. createClient提供了共用函数,传入一个transport,即可构造生成一个Thrift Client,特别注意的是,这里设定的通信协议为TBinaryProtocol,必须与服务端保持一致,否则无法成功通信。 由于call和exec与连接实现较为相关,因此并未在这一层中实现,最后我们来看一下EasyThriftClient: ```java package com.coder4.lmsia.thrift.client; import com.coder4.lmsia.thrift.client.func.ThriftCallFunc; import com.coder4.lmsia.thrift.client.func.ThriftExecFunc; import org.apache.thrift.TServiceClient; import org.apache.thrift.transport.TFramedTransport; import org.apache.thrift.transport.TSocket; import org.apache.thrift.transport.TTransport; /** * @author coder4 */ public class EasyThriftClient extends AbstractThriftClient { private static final int EASY_THRIFT_BUFFER_SIZE = 1024 * 16; protected String thriftServerHost; protected int thriftServerPort; @Override protected boolean check() { if (thriftServerHost == null || thriftServerHost.isEmpty()) { return false; } if (thriftServerPort <= 0) { return false; } return super.check(); } private TTransport borrowTransport() throws Exception { TSocket socket = new TSocket(thriftServerHost, thriftServerPort, THRIFT_CLIENT_DEFAULT_TIMEOUT); TTransport transport = new TFramedTransport( socket, THRIFT_CLIENT_DEFAULT_MAX_FRAME_SIZE); transport.open(); return transport; } private void returnTransport(TTransport transport) { if (transport != null && transport.isOpen()) { transport.close(); } } private void returnBrokenTransport(TTransport transport) { if (transport != null && transport.isOpen()) { transport.close(); } } @Override public TRET call(ThriftCallFunc tcall) { // Step 1: get TTransport TTransport tpt = null; try { tpt = borrowTransport(); } catch (Exception e) { throw new RuntimeException(e); } // Step 2: get client & call try { TCLIENT tcli = createClient(tpt); TRET ret = tcall.call(tcli); returnTransport(tpt); return ret; } catch (Exception e) { returnBrokenTransport(tpt); throw new RuntimeException(e); } } @Override public void exec(ThriftExecFunc texec) { // Step 1: get TTransport TTransport tpt = null; try { tpt = borrowTransport(); } catch (Exception e) { throw new RuntimeException(e); } // Step 2: get client & exec try { TCLIENT tcli = createClient(tpt); texec.exec(tcli); returnTransport(tpt); } catch (Exception e) { returnBrokenTransport(tpt); throw new RuntimeException(e); } } public String getThriftServerHost() { return thriftServerHost; } public void setThriftServerHost(String thriftServerHost) { this.thriftServerHost = thriftServerHost; } public int getThriftServerPort() { return thriftServerPort; } public void setThriftServerPort(int thriftServerPort) { this.thriftServerPort = thriftServerPort; } ``` 简单解释下上述代码 1. 需要外部传入RPC服务器的主机名和端口 thriftServerHost和thriftServerPort 1. borrowTransport完成Transport(Thrift中类似Socket的抽象) 的构造,注意这里要使用TFramedTransport,与之前服务端的构造保持一致。 1. returnTransport关闭Transport 1. returnBrokenTransport关闭出异常的Transport 1. call和exec 在拿到Transport后,使用函数式编程的方式,完成rpc调用,如果有异常则关闭连接。 最后我们来看一下对应的Builder,EasyThriftClientBuilder: ```java package com.coder4.lmsia.thrift.client.builder; import com.coder4.lmsia.thrift.client.EasyThriftClient; import org.apache.thrift.TServiceClient; /** * @author coder4 */ public class EasyThriftClientBuilder { private final EasyThriftClient client = new EasyThriftClient<>(); protected EasyThriftClient build() { client.init(); return client; } protected EasyThriftClientBuilder setHost(String host) { client.setThriftServerHost(host); return this; } protected EasyThriftClientBuilder setPort(int port) { client.setThriftServerPort(port); return this; } protected EasyThriftClientBuilder setThriftClass(Class thriftClass) { client.setThriftClass(thriftClass); return this; } } ``` Builder的代码比较简单,就是以链式调用的方式,通过主机和端口,方便地构造一个EasyThriftClient。 看了EasyThriftClient后下面我们来看一下如何集成到项目中。 在[Gradle子项目划分与微服务的代码结构](sb-gradle-structure.md)一节中,我们已经提到,将每个微服务的RPC客户端放在xx-client子工程中,现在我们再来回顾下lmsia-abc-client的目录结构。 ```shell ├── build.gradle └── src ├── main │   ├── java │   │   └── com │   │   └── coder4 │   │   └── lmsia │   │   └── abc │   │   └── client │   │   ├── configuration │   │   │   └── LmsiaAbcThriftClientConfiguration.java │   │   ├── LmsiaAbcEasyThriftClientBuilder.java │   │   └── LmsiaK8ServiceThriftClientBuilder.java │   └── resources │   └── META-INF │   └── spring.factories └── test ``` 我们简单介绍一下: 1. LmsiaAbcThriftClientConfiguration: 客户端自动配置,当激活时,自动生成lmsia-abc对应的RPC服务的客户端。引用者直接@Autowired一下,就可以使用了。 1. LmsiaAbcEasyThriftClientBuilder: EasyThriftClient构造器,主要是自动配置需要。 1. spring.factories: 与服务端的自动配置类似,需要在这个文件中指定自动配置的类路径,才能让Spring Boot自动扫描到自动配置。 1. 其他K8ServiceThriftClient相关的部分,我们将在下一小节进行介绍。 LmsiaAbcEasyThriftClientBuilder文件: ```java package com.coder4.lmsia.abc.client; import com.coder4.lmsia.abc.thrift.LmsiaAbcThrift; import com.coder4.lmsia.abc.thrift.LmsiaAbcThrift.Client; import com.coder4.lmsia.thrift.client.ThriftClient; import com.coder4.lmsia.thrift.client.builder.EasyThriftClientBuilder; /** * @author coder4 */ public class LmsiaAbcEasyThriftClientBuilder extends EasyThriftClientBuilder { public LmsiaAbcEasyThriftClientBuilder(String host, int port) { setThriftClass(LmsiaAbcThrift.class); setHost(host); setPort(port); } public static ThriftClient buildClient(String host, int port) { return new LmsiaAbcEasyThriftClientBuilder(host, port).build(); } } ``` 上述Builder完成了实际的参数填充,主要有: 1. ThriftClient的桩代码类设置(LmsiaAbcThrift.class) 1. 设置主机名和端口 LmsiaAbcClientConfiguration文件: ```java package com.coder4.lmsia.abc.client.configuration; import com.coder4.lmsia.abc.client.LmsiaAbcEasyThriftClientBuilder; import com.coder4.lmsia.abc.client.LmsiaK8ServiceClientBuilder; import com.coder4.lmsia.abc.thrift.LmsiaAbcThrift; import com.coder4.lmsia.abc.thrift.LmsiaAbcThrift.Client; import com.coder4.lmsia.thrift.client.K8ServiceKey; import com.coder4.lmsia.thrift.client.ThriftClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; @Configuration public class LmsiaAbcThriftClientConfiguration { private Logger LOG = LoggerFactory.getLogger(getClass()); @Bean(name = "lmsiaAbcThriftClient") @ConditionalOnMissingBean(name = "lmsiaAbcThriftClient") @ConditionalOnProperty(name = {"lmsiaAbcThriftServer.host", "lmsiaAbcThriftServer.port"}) public ThriftClient easyThriftClient( @Value("${lmsiaAbcThriftServer.host}") String host, @Value("${lmsiaAbcThriftServer.port}") int port ) { LOG.info("######## LmsiaAbcClientConfiguration ########"); LOG.info("easyClient host = {}, port = {}", host, port); return LmsiaAbcEasyThriftClientBuilder.buildClient(host, port); } } ``` 如上所示,满足两个条件时,会自动构造LmsiaAbcEasyThriftClient: 1. 还没有生成其他的LmsiaAbcEasyThriftClient(ConditionalOnMissingBean) 2. 配置中指定了lmsiaAbcThriftServer.host和lmsiaAbcThriftServer.port 根据我们前面的介绍,大家应该能理解,虽然有自动配置,但上述配置是一种很糟糕的方式。试想一下,如果我们的服务依赖了5个其他RPC服务,那么岂不是要分别配置5组IP和端口?此外,这种方式也无法支持节点的负载均衡。 如何解决这个问题呢?我们将在K8ServiceThriftClient中解决。 本小节的最后,我们看一下spring.factories: ```shell org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.coder4.lmsia.abc.client.configuration.LmsiaAbcThriftClientConfiguration ``` 和之前lmsia-abc-server子工程中的文件类似,这里设置了自动配置的详细类路径,方便Spring Boot的自动扫描。 ## K8ServiceThriftClient 在对EasyThriftClient的介绍中,我们发现了一个问题,需要单独配置IP和端口,不支持服务自动发现。 此外,在这个客户端的实现中,默认每次都要建立新的连接。而对于后端服务而言,RPC的服务端和客户端多数都是在内网环境中,连接情况比较稳定,可以通过连接池的方式减少连接握手开销,从而提升RPC服务的性能。如果你对连接池的原理还不太熟悉,可以参考[百科连接池](https://baike.baidu.com/item/%E8%BF%9E%E6%8E%A5%E6%B1%A0) 为此,我们本将介绍K8ServiceThriftClient,它很好的解决了上述问题。 首先,我们使用commons-pool2来构建了TTransport层的连接池。 TTransportPoolFactory: ```java package com.coder4.lmsia.thrift.client.pool; import com.coder4.lmsia.thrift.client.K8ServiceKey; import org.apache.commons.pool2.BaseKeyedPooledObjectFactory; import org.apache.commons.pool2.PooledObject; import org.apache.commons.pool2.impl.DefaultPooledObject; import org.apache.thrift.transport.TFramedTransport; import org.apache.thrift.transport.TSocket; import org.apache.thrift.transport.TTransport; /** * @author coder4 */ public class TTransportPoolFactory extends BaseKeyedPooledObjectFactory { protected static final int THRIFT_CLIENT_DEFAULT_TIMEOUT = 5000; protected static final int THRIFT_CLIENT_DEFAULT_MAX_FRAME_SIZE = 1024 * 1024 * 16; @Override public TTransport create(K8ServiceKey key) throws Exception { if (key != null) { String host = key.getK8ServiceHost(); int port = key.getK8ServicePort(); TSocket socket = new TSocket(host, port, THRIFT_CLIENT_DEFAULT_TIMEOUT); TTransport transport = new TFramedTransport( socket, THRIFT_CLIENT_DEFAULT_MAX_FRAME_SIZE); transport.open(); return transport; } else { return null; } } @Override public PooledObject wrap(TTransport transport) { return new DefaultPooledObject<>(transport); } @Override public void destroyObject(K8ServiceKey key, PooledObject obj) throws Exception { obj.getObject().close(); } @Override public boolean validateObject(K8ServiceKey key, PooledObject obj) { return obj.getObject().isOpen(); } } ``` 上述代码主要完成以下功能: 1. 连接超时配置(5秒) 1. create, 生成新连接(TTransport),这里与之前的EasyThriftClient非常类似,不再赘述 1. 验证连接是否有效,通过TTransport的isOpen判断。 TTransportPool: ```java package com.coder4.lmsia.thrift.client.pool; import com.coder4.lmsia.thrift.client.K8ServiceKey; import org.apache.commons.pool2.impl.GenericKeyedObjectPool; import org.apache.thrift.transport.TTransport; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * @author coder4 */ public class TTransportPool extends GenericKeyedObjectPool { private Logger LOG = LoggerFactory.getLogger(getClass()); private static int MAX_CONN = 1024; private static int MIN_IDLE_CONN = 8; private static int MAX_IDLE_CONN = 32; public TTransportPool(TTransportPoolFactory factory) { super(factory); setTimeBetweenEvictionRunsMillis(45 * 1000); setNumTestsPerEvictionRun(5); setMaxWaitMillis(30 * 1000); setMaxTotal(MAX_CONN); setMaxTotalPerKey(MAX_CONN); setMinIdlePerKey(MIN_IDLE_CONN); setMaxTotalPerKey(MAX_IDLE_CONN); setTestOnCreate(true); setTestOnBorrow(true); setTestWhileIdle(true); } @Override public TTransportPoolFactory getFactory() { return (TTransportPoolFactory) super.getFactory(); } public void returnBrokenObject(K8ServiceKey key, TTransport transport) { try { invalidateObject(key, transport); } catch (Exception e) { LOG.warn("return broken key " + key); e.printStackTrace(); } } } ``` 上述代码主要是完成连接池的配置,比较直观: 1. 设置最大连接数1024 1. 设置最大空闲数32,最小空闲数8,每间隔45秒尝试更改维护连接池中的连接数量。 1. 当每次"创建"、从池子中"借用"、"空闲"时,检查连接是否有效。 下面我们来看一下如何在K8ServiceThriftClient中使用: ```java package com.coder4.lmsia.thrift.client; import com.coder4.lmsia.thrift.client.func.ThriftCallFunc; import com.coder4.lmsia.thrift.client.func.ThriftExecFunc; import com.coder4.lmsia.thrift.client.pool.TTransportPool; import com.coder4.lmsia.thrift.client.pool.TTransportPoolFactory; import org.apache.thrift.TServiceClient; import org.apache.thrift.transport.TTransport; public class K8ServiceThriftClient extends AbstractThriftClient { private K8ServiceKey k8ServiceKey; private TTransportPool connPool; @Override public void init() { super.init(); // check if (k8ServiceKey == null) { throw new RuntimeException("invalid k8ServiceName or k8Serviceport"); } // init pool connPool = new TTransportPool(new TTransportPoolFactory()); } @Override public TRET call(ThriftCallFunc tcall) { // Step 1: get TTransport TTransport tpt = null; K8ServiceKey key = getConnBorrowKey(); try { tpt = connPool.borrowObject(key); } catch (Exception e) { throw new RuntimeException(e); } // Step 2: get client & call try { TCLIENT tcli = createClient(tpt); TRET ret = tcall.call(tcli); returnTransport(key, tpt); return ret; } catch (Exception e) { returnBrokenTransport(key, tpt); throw new RuntimeException(e); } } @Override public void exec(ThriftExecFunc texec) { // Step 1: get TTransport TTransport tpt = null; K8ServiceKey key = getConnBorrowKey(); try { // borrow transport tpt = connPool.borrowObject(key); } catch (Exception e) { throw new RuntimeException(e); } // Step 2: get client & exec try { TCLIENT tcli = createClient(tpt); texec.exec(tcli); returnTransport(key, tpt); } catch (Exception e) { returnBrokenTransport(key, tpt); throw new RuntimeException(e); } } private K8ServiceKey getConnBorrowKey() { return k8ServiceKey; } private void returnTransport(K8ServiceKey key, TTransport transport) { connPool.returnObject(key, transport); } private void returnBrokenTransport(K8ServiceKey key, TTransport transport) { connPool.returnBrokenObject(key, transport); } public K8ServiceKey getK8ServiceKey() { return k8ServiceKey; } public void setK8ServiceKey(K8ServiceKey k8ServiceKey) { this.k8ServiceKey = k8ServiceKey; } } ``` 上述大部分代码和EasyThriftClient非常接近,有差异的部分主要是与连接的"借用"、"归还"相关的: 1. 在call和exec中,借用连接 * getConnBorrowKey先构造一个key,包含了主机名和端口。这里的主机名是[微服务的自动发现](./ms-discovery/msd.md)中提到的Kubernetes服务,如果你对相关原理不太熟悉,可以自行回顾对应章节。 * 从connPool中借用一个连接(TTransport) * 剩余发起rpc调用的步骤就和EasyThriftClient相同了,不再赘述。 1. 当rpc调用结束后 * 正常结束,调用connPool.returnObject将TTransport归还到连接池中。 * 非正常结束,调用connPool.returnBrokenTransport,让连接池销毁这个连接,以防后续借用到这个可能出错的TTransport。 类似的,我们也配套了对应的Builder: ```java package com.coder4.lmsia.thrift.client.builder; import com.coder4.lmsia.thrift.client.EasyThriftClient; import org.apache.thrift.TServiceClient; /** * @author coder4 */ public class EasyThriftClientBuilder { private final EasyThriftClient client = new EasyThriftClient<>(); protected EasyThriftClient build() { client.init(); return client; } protected EasyThriftClientBuilder setHost(String host) { client.setThriftServerHost(host); return this; } protected EasyThriftClientBuilder setPort(int port) { client.setThriftServerPort(port); return this; } protected EasyThriftClientBuilder setThriftClass(Class thriftClass) { client.setThriftClass(thriftClass); return this; } } ``` 上述Builder主要是设置所需的两个参数,Host和Port,看起来和EasyThriftClient并没有什么不同? 别着急,我们继续看一下lmsia-abc-client中的集成: ```java package com.coder4.lmsia.abc.client; import com.coder4.lmsia.abc.thrift.LmsiaAbcThrift; import com.coder4.lmsia.abc.thrift.LmsiaAbcThrift.Client; import com.coder4.lmsia.thrift.client.K8ServiceKey; import com.coder4.lmsia.thrift.client.ThriftClient; import com.coder4.lmsia.thrift.client.builder.K8ServiceThriftClientBuilder; /** * @author coder4 */ public class LmsiaK8ServiceThriftClientBuilder extends K8ServiceThriftClientBuilder { public LmsiaK8ServiceThriftClientBuilder(K8ServiceKey k8ServiceKey) { setThriftClass(LmsiaAbcThrift.class); setK8ServiceKey(k8ServiceKey); } public static ThriftClient buildClient(K8ServiceKey k8ServiceKey) { return new LmsiaK8ServiceThriftClientBuilder(k8ServiceKey).build(); } } ``` 在集成的时候,我们需要传入一个key,可以手动制定,也可以自动配置 我们看一下完整的自动配置代码,LmsiaAbcThriftClientConfiguration: ```java public class LmsiaAbcThriftClientConfiguration { @Bean(name = "lmsiaAbcThriftClient") @ConditionalOnMissingBean(name = "lmsiaAbcThriftClient") @ConditionalOnProperty(name = {"lmsiaAbcThriftServer.host", "lmsiaAbcThriftServer.port"}) public ThriftClient easyThriftClient( @Value("${lmsiaAbcThriftServer.host}") String host, @Value("${lmsiaAbcThriftServer.port}") int port ) { LOG.info("######## LmsiaAbcThriftClientConfiguration ########"); LOG.info("easyThriftClient host = {}, port = {}", host, port); return LmsiaAbcEasyThriftClientBuilder.buildClient(host, port); } @Bean(name = "lmsiaAbcThriftClient") @ConditionalOnMissingBean(name = "lmsiaAbcThriftClient") public ThriftClient k8ServiceThriftClient() { LOG.info("######## LmsiaAbcThriftClientConfiguration ########"); K8ServiceKey k8ServiceKey = new K8ServiceKey(K8_SERVICE_NAME, K8_SERVICE_PORT); LOG.info("k8ServiceThriftClient key:" + k8ServiceKey); return LmsiaK8ServiceThriftClientBuilder.buildClient(k8ServiceKey); } //... } ``` 对比easyThriftClient和k8ServiceThriftClient不难发现,K8ServiceThriftClient的参数,是通过常量直接写死的。也就是我们在[微服务的自动发现与负载均衡](../ms-discovery/msd.md)中提到的,约定好服务的命名规则。 看下常量定义: ```java public class LmsiaAbcConstant { // ...... public static final String PROJECT_NAME = "lmsia-abc"; public static final String K8_SERVICE_NAME = PROJECT_NAME + "-server"; public static final int K8_SERVICE_PORT = 3000; // ...... } ``` 这样以来,一旦确定了项目名,那么Kubernetes中的服务名字也确定了。因此,k8ServiceThriftClient自动配置会被自动激活,即只要引用了lmsia-abc-client这个包,就会自动配置好一个RPC客户端,是不是非常方便? 我们来看一下具体的使用例子: ```java import com.coder4.lmsia.thrift.client.ThriftClient; public class LmsiaAbctProxy { @Autowired private ThriftClient client; public String hello() { return client.call(cli -> cli.sayHi()); } ``` 至此,我们已经完成了在Spring Boo中集成Thrift RPC的服务端、客户端的工作。 * 服务端,我们通过ThriftServerConfiguration、ThriftProcessorConfiguration自动配置了Thrift RPC服务端。 * 客户端,通过Kubernetes的服务功能,自动配置了带服务发现功能的Thrift RPC客户端K8ServiceThriftClient。该客户端同时内置了连接池,用于节省连接开销。 ================================================ FILE: legacy/toolchain/README.md ================================================ # 研发工具链 子曰: "工欲善其事必先利其器"。 本书的开篇已经指出,微服务的架构对研发人员提出了更高的要求。 幸运的是,通过不断完善、改进研发工具链,可以为研发人员提供更高效、更便捷的开发环境。 本书反复强调"微服务"、"研发工具链"、"运维工具链"三者是一个整体,如果只重视微服务的开发技术,而不重视工具链的建设,微服务的架构便无从谈起。 本章将对微服务架构下,常见的研发工具进行介绍。 大致又可分为两部分: * 研发环境构建: 主要包括内部帐号管理、代码版本管理、Java依赖管理,这些基础研发环境。 * 高效研发构建: 主要通过小工具、代码模板、开源项目的引入,降低微服务开发难度,提升开发效率。 现在,让我们开始研发工具链的构建之旅吧! ================================================ FILE: legacy/toolchain/bom.md ================================================ # BOM 减少版本冲突 在应用了Gradle构建工具,以及Maven仓库来管理版本依赖后,程序的构建、依赖问题已经得到了基本的解决。 但随着项目的不断发展,一个微服务的依赖可能会越来越多,出现版本冲突的问题。 举个版本冲突的例子:项目依赖的A的0.9版本,同时依赖了项目B,项目B又依赖了项目A的1.0版本。此时,项目会选择A的0.9还是1.0版本呢? 事实上,按照Maven的依赖规则,会选用最小的版本0.9。如果0.9和1.0是API兼容的,那么问题不大。如果1.0的API发生了"break change",那么很遗憾,项目B中的代码会包错,更离谱的是,只有运行时才会发生问题。这类问题经常难以诊断,因此,我们应当尽量减少版本冲突的问题。 BOM(Bill Of Materials)就是为了解决这个问题而生的,它定义了一组依赖管理的项目并约定了对应的版本。其他项目可以直接引用BOM而不用设定对应版本,BOM会自动把缺失的版本补全。 在本书的微服务架构下,我们强烈建议定义公共库的BOM,以减少版本冲突的问题。 应用BOM需要两个步骤: 1. 新建一个BOM的Maven项目 1. 在项目中引用该BOM项目 新建一个BOM项目非常简单,只需要一个xml文件 ```xml 4.0.0 com.coder4.lmsia pom-parent 0.0.4 pom UTF-8 1.8 org.springframework.boot spring-boot-dependencies 1.5.7.RELEASE pom import com.google.guava guava 23.0 com.coder4.lmsia redis 0.0.4 com.coder4.lmsia cache 0.0.5 com.coder4.lmsia rabbitmq 0.0.2 com.coder4.lmsia thrift-server 0.0.5 com.coder4.lmsia database 0.0.1 com.h2database h2 1.4.196 nexus_coder4 http://192.168.99.100:8081/nexus/content/repositories/releases/ nexus_coder4 http://192.168.99.100:8081/nexus/content/repositories/snapshots/ ``` 解释下上面的代码: 1. 这是一个pom,应用了若干包,并指定了他们的版本 1. 底部指定了maven仓库的发布地址(如果你有多个不同的maven repo权限才需要设定) 然后看一下在gradle项目中如何引用bom: build.gradle: ``` plugins { id "io.spring.dependency-management" version "1.0.3.RELEASE" } apply plugin: 'java' apply plugin: 'application' repositories { maven { credentials { username "$mavenUser" password "$mavenPass" } url 'http://maven.coder4.com/nexus/content/groups/public' } } // import bom dependencyManagement { imports { mavenBom 'com.coder4.sbmvt:pom-parent:0.0.1' } } dependencies { // use bom version compile 'org.springframework.boot:spring-boot-starter-web' compile 'com.coder4.lmsia:redis' // Use JUnit test framework testCompile 'junit:junit:4.12' } // Define the main class for the application mainClassName = 'App' ``` 如上所示,我们通过dependencyManagement插件引入了bom项目,而指定项目时只有group和project、没有版本,版本会自动使用bom中统一定义的。 对于微服务架构,我们可以将使用的数据库、RPC、消息队列、工具类等共用库的版本都放入BOM,以统一依赖的版本。 ================================================ FILE: legacy/toolchain/gerrit.md ================================================ # gerrit 代码的版本管理与审查 ## 为什么选用git作为版本管理系统 在实际工作中,绝大多数的项目都使用了代码的版本管理系统。在应用版本管理系统后,可以代码许多好处,相信大家有有所体会: * 团队合作: 应用版本管理系统后,每个团队成员都可以对每个文件进行修改,而不用担心出现不一致、改动丢失、甚至冲突的情况,版本管理系统会负责这些事情。 * 改动可见: 项目开发往往不是一蹴而就,而是划分为许多个小步骤。我们可以将每个改动作为一次提交,版本管理系统可以展示出两个提交之间的差异,项目的开发进展一目了然。 * 轻松回滚: 如果我们不小心搞出了一个bug,或者某个设计思路出现了较大错误,可以轻松的回滚到某个之前的版本,这也是版本管理系统为我们提供的便利功能。 在版本系统的选型上,我们选用了git,相比于svn,它具有诸多优点: * 分布式、协作方便: git的设计就是分布式版本管理系统,更适用于多人协作。而svn设计理念就是中央式管理,中规中矩但不利于团队协作。 * 速度更快: 在文件模式上,git基于"指针式"设计,比svn更快。在微服务架构下,创建新服务新项目更加频繁,git的速度优势会更加明显。 * 分支切换: git的分支设计非常轻量级,完全可以在本地完成,而svn则需要完全拉取分支的所有文件,如果你使用svn管理过多分支的大项目,一定对此深有感触。 * 操作更丰富:git提供了丰富的操作手段,当你使用熟练后,会比使用svn的效率更高。 当然,git也有一个最大的缺点:学习曲线较为陡峭。对于新手而言,svn简单看看文档就能上手,git可能需要几天才能掌握基本操作。 但是,面对git带给我们的种种好处,还是值得仔细学习一下的,篇幅所限,我们不会讨论git的用法。 如果你想仔细学习,推荐阅读[廖雪锋的Git教程](https://www.liaoxuefeng.com/wiki/0013739516305929606dd18361248578c67b8067c8c017b000)。 ## 为什么代码需要代码审查 如果是一个人做的开源项目,有版本管理系统就足够了。 但对于团队开发,除了版本管理外,一般还应有代码审查(code review)。代码审查的优势如下: * 相互检查、提升质量: 在开发过程中,我们自己写出的bug,往往是看不出来的,换个人却很容易发现,就是所谓的"当局者迷,旁观者清"。通过相互检查代码,可以有效提升软件质量。 * 让新成员快速提高: 我们希望新加入的团队成员,可以快速学习、快速成长。阅读项目固然是一个很好的方式,但一个项目往往太大,难以下手,代码审查的粒度是一次提交,更小、更适合新手学习。 * 边开发边讨论: 在方案设计阶段,我们可能有了大致的方案,但在开发过程中,往往会暴露出更多的问题。代码审查为这些问题的讨论提供了一个合适的契机,大家可以在代码审核的同时进行讨论。 在系统选型方面,我们选用了较为成熟的gerrit作为代码审查系统。 需要指出的是gerrit同时内置了git服务器的功能,因此我们使用gerrit同时作为版本管理和代码审查系统。 ## gerrit系统的基本配置 与之前的LDAP类似,我们也将gerrit部署在Kubernetes上。 首先保证物理机上Volume挂载点的创建 ```shell minikube ssh $sudo mkdir /data/gerrit $sudo chown -R 999:999 /data/gerrit/ ``` 接着我们看一下deployment文件。 gerrit-deployment.yaml ```shell piVersion: apps/v1 kind: Deployment metadata: name: gerrit-deployment spec: selector: matchLabels: app: gerrit replicas: 1 template: metadata: labels: app: gerrit spec: restartPolicy: Always nodeSelector: kubernetes.io/hostname: minikube containers: - name: gerrit-ct image: openfrontier/gerrit:2.15.1 ports: - containerPort: 8080 hostPort: 80 - containerPort: 29418 hostPort: 29418 volumeMounts: - mountPath: "/var/gerrit/review_site" name: volume env: - name: GITWEB_TYPE value: gitiles - name: AUTH_TYPE value: LDAP - name: LDAP_SERVER value: ldap://192.168.99.100 - name: LDAP_ACCOUNTBASE value: "dc=coder4,dc=com" - name: LDAP_ACCOUNTPATTERN value: "(cn=${username})" - name: LDAP_ACCOUNTSSHUSERNAME value: "${cn}" - name: LDAP_ACCOUNTFULLNAME value: "${sn}" - name: LDAP_USERNAME value: "cn=guest,dc=coder4,dc=com" - name: LDAP_PASSWORD value: "guest123" - name: WEBURL value: "http://192.168.99.100" volumes: - name: volume hostPath: path: /data/gerrit/ ``` 虽然文件很长,但并不复杂,我们简单解读下: * Docker镜像为openfrontier/gerrit:2.15.1 * 端口映射8080到物理机的的80端口上 * 挂载点/var/gerrit/review_site * 使用LDAP作为帐号接入,具体配置在之前LDAP一节已经见识过了,这里不再赘述。 * WEB跳转URL定义为 http://物理机IP 下面启动一下: ```shell kubectl apply -f ./gerrit-deployment.yaml ``` 启动成功后,我们访问gerrit,然后点击右上角的"Sign In"即可登录。这里的帐号,填写之前创建的一个LDAP内部帐号。需要特别说明的是,第一个登录的用户,会被gerrit认为是超级管理员,所以请慎重选择。 ![gerrit登录成功](./gerrit-login-succ.png "gerrit登录成功j") 如果一切顺利的话,就会登录成功了。至此,我们已经完成了gerrit服务器的基本配置。 ## gerrit常用插件 gerrit系统的基本功能比较简单,需要配合插件才能发挥出更大优势 在此,我们先安装两个系统内置的插件: * commit msg长度检查 * 项目下载url生成器 安装插件是通过ssh命令完成的,一次,首先要将ssh密钥的公钥上传到gerrit上。 如果你还没有ssh密钥,可以使用sshkeygen生成,这里不做详细展开。 点击右上角的姓名 -> Settings -> SSH Public Keys,粘贴后点击"Add"。 然后添加插件: ```shell ssh -p 29418 lihy@192.168.99.100 gerrit plugin install 'jar:file:/var/gerrit/review_site/bin/gerrit.war!/WEB-INF/plugins/download-commands.jar' ssh -p 29418 lihy@192.168.99.100 gerrit plugin install 'jar:file:/var/gerrit/review_site/bin/gerrit.war!/WEB-INF/plugins/commit-message-length-validator.jar' ``` ## gerrit项目的权限控制 gerrit默认的权限配置是对所有人(包括注册用户和匿名用户)开放所有项目。 这样的设置可能过为宽松,可以自行更改。 使用管理员帐号登录,然后进入Projects -> All Projects,点击底部的顶部的"Access",点击Edit。然后找到 Reference: refs/* -> Read,修改为 -> Block Anonymous Users,修改完成后点击"Save for change"。 ![修改匿名用户读权限](./gerrit-block-anonymous-users.png "修改匿名用户读权限") 我们可以登出当前用户,再次访问gerrit主页,可以发现,在未登录状态,无法找到任何review和项目了。 ## 第一个gerrit代码review 下面我们尝试用gerrit完成一个完整的流程:从新建项目、提交、到审核代码。 我们尝试新建一个项目:Projects -> Create New Project: * 项目名为lmsia-xyz * 继承自All-Projects 然后点击"Create Project" 创建完成后,我们就可以将代码克隆到本地进行开发了。 选择:Projects -> List 找到lmsia-xyz并点击,在顶部,可以找到Clone工具栏,选择右侧的ssh,底下会出现一行命令: ```shell git clone ssh://lihy@192.168.99.100:29418/lmsia-xyz ``` 我们在本地执行这行命令,即可成功得克隆代码 ```shell git clone ssh://lihy@192.168.99.100:29418/lmsia-xyz Cloning into 'lmsia-xyz'... remote: Counting objects: 2, done remote: Finding sources: 100% (2/2) remote: Total 2 (delta 0), reused 0 (delta 0) Receiving objects: 100% (2/2), done. Checking connectivity... done. ``` 如果报权限错误,一般是ssh密钥配置的不对,请检查gerrit个人资料中的key是否为本地设置的公钥。 配置修改后,可以自行通过这条命令测试 ```shell ssh -p 29418 lihy@192.168.99.100 **** Welcome to Gerrit Code Review **** Hi 李赫元, you have successfully connected over SSH. Unfortunately, interactive shells are disabled. To clone a hosted Git repository, use: git clone ssh://lihy@192.168.99.100:29418/REPOSITORY_NAME.git Connection to 192.168.99.100 closed. ``` 下面我们新建一个文件: ```shell touch README.md ``` 添加并提交: ```shell git add . git commit -m "ADD: README.md" ``` 至此,我们已经完成了代码的提交,当然这只是提交到本地git仓库中。 我们还需要推送到gerrit仓库中供别人审核。 在可以推送到gerrit之前,还需要进行2个配置: 1. (每台机器配置一次)若你的操作系统用户名和gerrit用户名一致,需要配置ssh选项。 1. (每个项目配置一次)配置项目的gerrit远程仓库 1. (每个项目配置一次)配置项目推送到gerrit后默认的代码审核人 首先是ssh配置,以我的环境为例,我的操作系统用户名是coder4,而gerrit用户名是lihy,于是在~/.ssh/config中添加如下配置: ```shell Host 192.168.99.100 User lihy IdentityFile ~/.ssh/id_rsa Hostname 192.168.99.100 Port 29418 ``` 这个配置并不复杂,就是告诉操作系统,当连接192.168.99.100这个host时,默认用户改为lihy而不是系统默认的coder4 而上面每个项目需要执行一次的2和3稍微,这个操作稍微复杂一些,所以我将它合并成了一个脚本,方便大家调用。 ```shell #!/usr/bin/env bash GERRIT_HOST="192.168.99.100" EMAIL_POSTFIX="coder4.com" set -e function join { local IFS="$1"; shift; echo "$*"; } if [ -z "$1" ]; then echo "Usage: $0 reviewer[,reviewer ...]" exit 1 fi set -u if [ -z `git remote | grep origin` ]; then echo "Remote origin not found, please clone this repository correctly or add origin remote by 'git remote add'." exit 1 fi scp -p -P 29418 $GERRIT_HOST:hooks/commit-msg .git/hooks/ cat > .git/hooks/pre-commit << EOF ##!/bin/sh if git-rev-parse --verify HEAD >/dev/null 2>&1 ; then against=HEAD else # Initial commit: diff against an empty tree object against=4b825dc642cb6eb9a060e54bf8d69288fbee4904 fi # Find files with trailing whitespace for FILE in \`exec git diff-index --check --cached \$against -- | sed '/^[+-]/d' | sed -E 's/:[0-9]+:.*//' | uniq\` ; do # Fix them! sed -i '' -E 's/[[:space:]]*$//' "\$FILE" git add "\$FILE" done EOF chmod a+x .git/hooks/pre-commit originURL=`git remote -v | grep fetch | perl -nle'print $& if m{(?<=origin\t)\S*}'` (git remote remove review >& /dev/null || exit 0) git remote add review $originURL IFS=',' read -a reviewers <<< "$1" sed -i '/\+refs\/heads\/\*:refs\/remotes\/review\/\*/d' .git/config for i in "${!reviewers[@]}"; do reviewers[$i]="--reviewer=${reviewers[$i]}@$EMAIL_POSTFIX" done echo -e "\tpush = HEAD:refs/for/master" >> .git/config echo -e "\treceivepack = git receive-pack `join " " ${reviewers[@]}`" >> .git/config ``` 如上的脚本做了3件事情: * 从gerrit上下载commit-msg的钩子,这是gerrit生成Change-ID所必须的。 * 配置远程review仓库 * 配置推送后默认的代码审核人 执行一下,默认自己和张三审核: ```shell initGerrit.sh lihy,zhangsan ``` 上述配置完成后,就可以推送你的第一个code review了: ```shell git push review Counting objects: 3, done. Writing objects: 100% (3/3), 244 bytes | 0 bytes/s, done. Total 3 (delta 0), reused 0 (delta 0) remote: Processing changes: refs: 1, done remote: ERROR: [127a929] missing Change-Id in commit message footer remote: remote: Hint: To automatically insert Change-Id, install the hook: remote: gitdir=$(git rev-parse --git-dir); scp -p -P 29418 lihy@192.168.99.100:hooks/commit-msg ${gitdir}/hooks/ remote: And then amend the commit: remote: git commit --amend remote: To ssh://lihy@192.168.99.100:29418/lmsia-xyz ! [remote rejected] HEAD -> refs/for/master ([127a929] missing Change-Id in commit message footer) error: failed to push some refs to 'ssh://lihy@192.168.99.100:29418/lmsia-xyz' ``` 然而我们发现还是执行失败,这是因为,我们先执行了commit后执行了initGerrit,导致commit时候没有Change-ID。 我们可以按照提示补救一下: ```shell git commit --amend ``` 再次执行推送,成功: ```shell git push review Counting objects: 3, done. Writing objects: 100% (3/3), 285 bytes | 0 bytes/s, done. Total 3 (delta 0), reused 0 (delta 0) remote: Processing changes: new: 1, done remote: remote: New Changes: remote: http://192.168.99.100/#/c/lmsia-xyz/+/21 ADD: README.md remote: To ssh://lihy@192.168.99.100:29418/lmsia-xyz * [new branch] HEAD -> refs/for/master ``` 我们到gerrit上看一眼,发现已经有了这个推送: ![第一个review](./gerrit-first-review.png "第一个review") 我们点击进去,自行+2,然后点击Submit,如下两图所示。 ![+2](./gerrit-p2.png "代码审核+2") ![代码合并](./gerrit-submit.png "代码合并") 此时,代码就被成功合并进master分支了。 我们的gerrit默认配置了gitweb,即可以通过网页的方式查看项目的完整源码: Plugsin -> gitiles,界面如下图所示: ![代码浏览](./gerrit-gittiles.png "代码浏览") 通过选择不同项目,可以查看不同分支的完整代码。 至此,我们完成了Gerrit服务的搭建,并通过完整的例子演示了项目的创建、克隆、开发、提交、审核流程。 Gerrit还有很多强大的功能,例如Web上可以创建分支、Rebase代码等等,如果你想探索这些高级用法,可以参考[官方文档](https://gerrit-documentation.storage.googleapis.com/Documentation/2.15.2/index.html)。 ================================================ FILE: legacy/toolchain/kanboard.md ================================================ # Kanboard Scrum看板 ================================================ FILE: legacy/toolchain/ldap.md ================================================ # LDAP 内部账号管理系统 ## LDAP及其必要性 对于任何一个研发团队,一套内部通用的帐号管理系统都是必不可少的。请注意我的用词:"内部通用"。 公司内部可能有各种系统: * 行政层面的OA系统、邮件系统、会议室预订系统。 * 研发团队内部又可能有代码管理、项目进度管理、Bug追踪、依赖管理、Wiki等等。 如果没有内部通用帐号,那么每来一个新员工,就需要到上述所有系统中,分别注册一次。想象一下,这是多么让人头疼的事情! 因此,我们建议团队一定要拥有一套"内部通用"的帐号管理系统。 在这里,我们选用了LDAP(Lightweight Directory Access Protocol)。是一个开放的,中立的,工业标准的应用协议,通过IP协议提供访问控制和维护分布式信息的目录信息。 在技术型团队中,LDAP可以当作内部帐号管理系统来使用。此外,LDAP可以很轻松地与其他系统对接,我们后面即将构建的代码管理、版本管理,都将通过LDAP帐号接入。 ## OpenLDAP服务的初步配置 能提供LDAP服务的开源项目有很多,我们选用了较为成熟的开源服务器OpenLDAP。 虽然OpenLDAP并不是微服务,但我们依然放到Kubernetes集群部署,主要原因是: * 方便运维: 如果不用Docker,就需要手动的安装、配置。一旦物理服务器发生故障,需要迁移服务时,就需要重新执行这些操作。运维起来非常麻烦。 * 方便备份与恢复: 对于这类帐号系统,可用性倒要求并不高(偶尔挂掉1个小时,能接受),但是对数据安全性,特别是备份有较高要求。使用Docker后,我们只需要将产生的数据挂载到Volume上,然后定期备份Volume即可。 来看一下部署文件openldap-deployment.yaml: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: openldap-deployment spec: selector: matchLabels: app: openldap replicas: 1 template: metadata: labels: app: openldap spec: restartPolicy: OnFailure nodeSelector: kubernetes.io/hostname: minikube containers: - name: openldap-ct image: osixia/openldap:1.1.9 ports: - containerPort: 389 hostPort: 389 - containerPort: 636 hostPort: 636 volumeMounts: - mountPath: "/etc/ldap/slapd.d" name: volume subPath: conf - mountPath: "/var/lib/ldap" name: volume subPath: data env: - name: LDAP_TLS value: "false" - name: LDAP_DOMAIN value: "coder4.com" - name: LDAP_ADMIN_PASSWORD value: "admin123" - name: LDAP_READONLY_USER value: "true" - name: LDAP_READONLY_USER_USERNAME value: "guest" - name: LDAP_READONLY_USER_PASSWORD value: "guest123" volumes: - name: volume hostPath: path: /data/openldap/ ``` 这是一个很长的文件,我们来逐条解释下: * restartPolicy: 虽然这是一个内部服务,但我们还是希望它能稳定提供服务。如果万一服务挂掉,希望能自动重启。因此我们设置自动重启策略为OnFailure。 * nodeSelector: 我们强制选择了主机名。即这个Pod只能启动在minikube这台hostname的主机上,为什么呢?因为我们的OpenLDAP服务使用了本地Volume(hostVolume),如果不固定机器,允许Pod在任意物理机启动的话,对应Volume并不会自动迁移,导致之前的账户信息"丢失"。因此,对于需要使用Volume的服务,要么选择一种可自动迁移的Volume,要么就需要绑定到一台物理机上。如果你想选用自动迁移的Volume,可以参考[官方Volumes文档](https://kubernetes.io/docs/concepts/storage/volumes/)。 * ports: 我们直接对集群外暴露了389和636两个端口。在实际生产中,我建议选择一台独立的物理机部署所有的内部服务(ldap、maven、git等)。为什么这样搞呢?如果物理机是固定的,我们可以给它分配一个固定的办公网IP,甚至固定的办公网DNS域名,然后简单地通过暴露端口的方式,就可以对全部办公网提供服务了。 * volumeMounts & volumes: 定义了两个volume挂载点,分别挂载到容器的/etc/ldap/slapd.d(配置)和/var/lib/ldap(数据)目录上。对应的物理机挂载目录在/data/openldap/conf和/data/openldap/data上。 * env: 通过环境变量完成了一些初始化的设定,具体如下。 * 不用加密协议[^1] * 设置域为coder4.com,可以根据你的需求自行更改。 * 创建系统管理员帐号,密码是admin123,这是一个超级管理员,对应用户名是admin(无法更改) * 创建系统只读帐号,用户名和密码是guest/guest123。这主要是用于其他服务与OpenLDAP服务的通信,只能读取、验证信息,不能做任何更改。 在部署前,我们先要保证物理机上的挂载点存在。 ```shell minikube ssh $ cd /data $ sudo mkdir openldap ``` 然后部署OpenLDAP服务: ```shell kubectl apply -f ./openldap-deployment.yaml ``` 查看下状态,启动成功了: ```shell kubectl get pods NAME READY STATUS RESTARTS AGE openldap-deployment-7d6b7875f-hxqxf 1/1 Running 0 14m ``` 获取集群的IP: ```shell minikube ip 192.168.99.100 ``` 验证下,端口已经成功暴露给了集群外: ```shell telnet 192.168.99.100 389 Trying 192.168.99.100... Connected to 192.168.99.100. Escape character is '^]'. ^] ``` 操作ldap集群,需要安装一些工具,以Ubuntu为例: ```shell sudo apt-get install ldap-utils ``` 有了工具后,两个系统帐号已经创建成功: ```shell ldapwhoami -h 192.168.99.100 -p 389 -D "cn=admin,dc=coder4,dc=com" -w admin123 dn:cn=admin,dc=coder4,dc=com ldapwhoami -h 192.168.99.100 -p 389 -D "cn=guest,dc=coder4,dc=com" -w guest123 dn:cn=guest,dc=coder4,dc=com ``` 至此,我们已经完成了OpenLDAP的基础配置,并且成功创建了两个系统帐号。 ## 创建内部用户 在刚才的配置中,我们创建了两个系统帐号,但在实际工作中,团队成员一般不会使用系统帐号。 对于一个团队成员,它的帐号至少需要有如下属性: * 用户名, 一般是纯英文、拼音缩写 * 中文姓名,这个不解释了 * 密码,最好不是明文,而是加密存储 * 邮箱,公司内部的电子邮箱地址 大公司的内部,会细分为多个团队,此时还应当将用户划分到相应的属组。由于篇幅所限,我们在此不讨论属组的问题。 在密码加密方面,我们采用ssha,它需要命令slappasswd,你可以在任何安装了openldap的机器上找到它: ```shell slappasswd -h {ssha} -s pass123 {SSHA}yG3DQj7iol10+fzWoeBAgoZ+D+h9uQre ``` 上述即生成了一个ssha加密过的密码pass123。 我们前面已经提到,LDAP是一个"目录式"的权限管理服务。其本身规则非常复杂到可以单独写一本书:-) 本书不会对其规则进行过多讲解,这里先提供了一个简单的模板,供大家学习。 ./users.ldif ```shell version: 1 # users org dn: ou=users,dc=coder4,dc=com objectClass: top objectClass: organizationalUnit ou: users # group org dn: ou=groups,dc=coder4,dc=com objectClass: top objectClass: organizationalUnit ou: groups # define users here dn: cn=lihy,ou=users,dc=coder4,dc=com objectClass: top objectClass: person objectClass: organizationalPerson objectClass: inetOrgPerson cn: lihy sn:: 5p2O6LWr5YWD mail: lihy@coder4.com userPassword: {SSHA}yG3DQj7iol10+fzWoeBAgoZ+D+h9uQre dn: cn=zhangsan,ou=users,dc=coder4,dc=com objectClass: top objectClass: person objectClass: organizationalPerson objectClass: inetOrgPerson cn: zhangsan sn:: 5byg5LiJ mail: zhangsan@coder4.com userPassword: {SSHA}yG3DQj7iol10+fzWoeBAgoZ+D+h9uQre # should also modify here if insert new user dn: cn=Users,ou=groups,dc=coder4,dc=com objectClass: top objectClass: groupOfUniqueNames cn: Users uniqueMember: cn=lihy,ou=users,dc=coder4,dc=com uniqueMember: cn=zhangsan,ou=users,dc=coder4,dc=com # define admin here dn: cn=Admin,ou=groups,dc=coder4,dc=com objectClass: top objectClass: groupOfUniqueNames cn: Admin uniqueMember: cn=lihy,ou=users,dc=coder4,dc=com ``` 简单解释下: * 我们创建了2个组users和groups,前者存放用户,后者表示用户的属组。 * 定义两个用户lihy和zhangsan,他们的密码用前面提到的SSLA加密 * 将两个用户加入Users组内 * 将lihy用户加入管理员组内 我们来应用这个模板: ```shell ldapadd -c -h 192.168.99.100 -p 389 -w admin123 -D "cn=admin,dc=coder4,dc=com" -f ./users.ldif ``` 如上,需要用admin帐号,-c选项是忽略所有错误,继续执行。 验证一下新增的内部用户: ```shell ldapwhoami -h 192.168.99.100 -p 389 -D "cn=lihy,ou=users,dc=coder4,dc=com" -w pass123 dn:cn=lihy,ou=users,dc=coder4,dc=com ``` 添加新用户,需要操作三个步骤: 1. 在user.idlf中增加用户的定义 1. 在user.idlf对应属组中添加 1. 执行ldapadd命令 ## LDAP系统管理脚本 不用我说大家也明白,上述步骤真的是非常繁琐,而且容易出错。 面对这种情况,大家可以选用第三方的工具来管理LDAP帐号,例如phpLDAPadmin,但是这需要额外维护一套系统,不免有些笨重。 为了降低维护成本,我提供了几个简单的小脚本,以满足日常的管理工作。 添加帐号,ldap_add.sh ```shell #!/bin/bash # const LDAP_SERVER_IP="192.168.99.100" LDAP_SERVER_PORT="389" LDAP_ADMIN_USER="cn=admin,dc=coder4,dc=com" LDAP_ADMIN_PASS="admin123" if [ x"$#" != x"3" ];then echo "Usage: $0 " exit -1 fi # param USERNAME="$1" PASSWORD="$2" ENCRYPT_PASSWORD=$(slappasswd -h {ssha} -s "$PASSWORD") REALNAME="$3" REALNAME_BASE64=$(echo -n $REALNAME | base64) # add count & group cat < " exit -1 fi # param USERNAME="$1" PASSWORD="$2" ENCRYPT_PASSWORD=$(slappasswd -h {ssha} -s "$PASSWORD") # modify cat <" exit -1 fi # param USERNAME="$1" # delete user ldapdelete -c -h $LDAP_SERVER_IP -p $LDAP_SERVER_PORT -w $LDAP_ADMIN_PASS -D $LDAP_ADMIN_USER "cn=$USERNAME,ou=users,dc=coder4,dc=com" ``` 尝试删除一下: ```shell ./ldap_delete.sh zhangsan ``` 然后验证下,确实无法登录了 ```shell ldapwhoami -h 192.168.99.100 -p 389 -D "cn=zhangsan,ou=users,dc=coder4,dc=com" -w pass123 ldap_bind: Invalid credentials (49) ``` 至此,我们完成了LDAP服务的构建,并可以通过简单的脚本完成帐号的添删改操作。 [^1]: 如果你十分看中帐号服务对外通信的安全性,建议还是开启,具体可以参考[docker-openldap](https://github.com/osixia/docker-openldap)) ================================================ FILE: legacy/toolchain/nexus.md ================================================ # Nexus 私有maven仓库 依赖管理是技术栈的重要一环,几乎所有的现代编程语言都拥有自己的依赖管理系统。 如果你在很久以前就从事了Java开发,或者参与过一些"不太正规"的项目,一定经历有过"jar包随便拷、jar包满天飞"的经历。在这种情况下,每次上线发布、升级jar包都是非常痛苦的事情。 大概从2003年开始,构建工具逐渐走入Java开发者的视野,Maven是构建工具中应用最为广泛的工具之一,它在提供构建功能的同时,也自带了强大的依赖管理功能。采用maven后,我们只需要定义xml就可以自动下载依赖的jar包,而不需要"手动将jar包拷来拷去"。 近几年来,作为一种更高效的构建工具 - Gradle - 逐渐崛起,在一些开发领域(如Android),Gradle已经完全取代了Maven成为事实上的构建标准。 尽管从构建工具的角度而言,Maven的地位有所下降,但它在Java依赖管理子领域的地位却不容撼动。Gradle默认也是直接采取Maven的依赖管理框架(只不过换为更简便的描述语言)。 在[架构概览](architecture/README.md)一章中,我们已经说明:在选型上,我们使用Gradle作为构建工具,但依然采用Maven来管理依赖。 在Maven的依赖管理方面,我们将使用Nexus搭建私有Maven服务器。为什么要搭建私服呢? 这和为什么搭建私有Git服务器却不用GitHub公开仓库是一个道理:没有公司愿意将自己的代码暴露给全世界:-) ## Nexus仓库的基本配置 与前两节类似,我们首先在Kubernetes上部署Nexus服务。 创建之前,先在物理机上创建Volume挂载点: ```shell minikube ssh $sudo mkdir /data/nexus $sudo chmod -R 777 /data/nexus/ ``` 这里因为nexus需要有一个文件锁,默认权限是不够的,我们给了777权限,如果你觉得过于宽松,可以自行更改Kubernetes的启动用户,并设定相应权限。 看一下部署文件, nexus-deployment.yaml: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: nexus-deployment spec: selector: matchLabels: app: nexus replicas: 1 template: metadata: labels: app: nexus spec: restartPolicy: Always nodeSelector: kubernetes.io/hostname: minikube containers: - name: nexus-ct image: sonatype/nexus:2.14.8 ports: - containerPort: 8081 hostPort: 8081 volumeMounts: - mountPath: "/sonatype-work" name: volume volumes: - name: volume hostPath: path: /data/nexus/ ``` 部署一下: ```shell kubectl apply -f ./nexus-deployment.yaml ``` 一切顺利的话,稍等一会访问"http://192.168.99.100:8081/nexus",部署成功,如下图所示: ![nexus部署成功](./nexus-welcome.png "nexus部署成功") ## Nexus接入LDAP帐号 服务虽然好了,但还没有接入LDAP帐号系统,Nexus中接入LDAP帐号较为繁琐,请耐心操作完。 首先,设置一下LDAP的连接配置。 1. 使用默认管理员帐号登录,用户名admin,密码admin123 1. 点击左侧菜单"Security" -> "LDAP Configuration" 1. 设置LDAP配置如下 1. Protocol: ldap 1. Hostname: 192.168.99.100 1. SearchBase: dc=coder4,dc=com 1. Authentication Method: Simple Authentication 1. Username: cn=guest,dc=coder4,dc=com 1. Password: guest123 1. Base DN: ou=users 1. Object Class: inetOrgPerson 1. User ID Attribute: cn 1. Real Name Attribute: sn 1. E-Mail Attribute: mail 1. Group Element Mapping: 不选中 上述配置稍显繁琐,请耐心完成。都配置完成后,点击"Save"。 此外,点击底部的"Check User Mapping",如果一切配置正确,可以展示所有的列表,如下图所示。 ![nexus配置ldap帐号接入](./nexus-ldap-config.png "nexus配置ldap帐号接入") 第二步,下面我们来更改默认认证方式为LDAP。 点击左侧菜单"Administration" -> "Server",进行如下配置: 1. 在Security Settings中,将右侧的"OSS LDAP Authentication Realm"加入到左边,并将其拖动到最顶部。 1. 取消勾选"Anoymous Access"。 ![配置认证方式为LDAP](./nexus-auth-config.png "配置认证方式为LDAP") 配置可以参考上图,设置好后,点击"Save"。 最后,我们需要对所有用户配置权限,注意,每次LDAP新接入用户后,都要执行下述操作。 点击左侧菜单"Security" -> "Users" ,执行下述操作: 1. 点击"All Configured Users"旁边的小箭头,选择"LDAP" 1. 点击"Refresh",此时就能拿到所有LDAP中的用户了。 1. 选中一个要操作的用户,例如"lihy",选择底部的"Config",然后"Role Management"。 1. 一般用户给"Nexus Deployment Role"就可以了,管理员可以给"Nexus Administrator Role"。 1. 设置好后点击"Save" ![nexus给用户权限](./nexus-role.png "nexus给用户权限") 一个配置好的结果如上图所示。 至此,我们已经成功接入了LDAP,试着用配置好的用户登录下,发现可以登录成功。 ## 配置Nexus中央仓库的缓存 Maven依赖仓库也是分布式,我们最长用的,是"Maven Central"这个中央仓库。 我们建议将中央仓库的索引缓存到Nexus私服上,这大约需要20GB的空间。 使用管理员帐号登录后,点击左侧菜单"View/Repositories": 1. 选择"Repositories" 1. 在右侧选择"Central"这个仓库 1. 底部"Configuration"配置 1. Remote Storage Location: http://maven.aliyun.com/nexus/content/repositories/central/ (这里我们使用了阿里云的国内镜像以加快速度) 1. Download Remote Indexes: True 1. 最后点击底部的"Save" ![nexus私服配置maven中央仓库缓存](./nexus-maven-central-cache.png "nexusa中央仓库缓存配置") 缓存的时间比较长,在我的虚拟机上,花费了20分钟。进度可以在这里查看,左侧菜单"Administration" -> "Logging" 选择Log, 可以看到目前还在缓存: ```shell 2018-05-28 08:40:19,792+0000 INFO [pxpool-1-thread-1] admin org.sonatype.nexus.index.DefaultIndexerManager - Trying to get remote index for repository "Central" [id=central] ``` 等缓存成功后,在本地仓库的"Browse Index中",应当能看到与中央仓库一样的目录结构,如下图所示: ![nexus私服中查看中央仓库结构](./nexus-maven-central-cache.png "nexus私服中查看仓库结构") 至此,我们成功架设了基于Nexus的Maven私有仓库,集成了LDAP登录,并缓存了Maven中央仓库。 ## 如何在Gradle中应用私有仓库 在配置了私有仓库后,我们还需要在微服务项目中启用这个私有仓库。 这大致需要2步 1. 配置maven私有仓库用户名和密码 1. build.gradle中配置 下面我们分别看一下 ## 配置Maven私有仓库用户名和米按摩 ```shell vim ~/.m2/settings.xml # 新增如下内容 nexus_coder4 lihy pass nexus_coder4 central http://192.168.99.100:8081/content/groups/public nexus_coder4 central http://192.168.99.100:8081/content/groups/public true true central http://192.168.99.100:8081/content/groups/public true true nexus_coder4 ``` 如上,我们新增了私有仓库的地址、用户配置,如果你觉得在文件中直接"裸写"密码不安全,可以参考[maven密码加密方法](https://maven.apache.org/guides/mini/guide-encryption.html#How_to_encrypt_server_passwords)。 下面,我们在build.gradle中配置: ```build.gradle buildscript { repositories { maven { url 'http://maven.aliyun.com/nexus/content/groups/public' } maven { url 'https://jitpack.io' } } dependencies { // version just for plugin, not important classpath("org.springframework.boot:spring-boot-gradle-plugin:1.5.6.RELEASE") } } subprojects { apply plugin: 'java' apply plugin: 'idea' apply plugin: 'maven' apply plugin: 'org.springframework.boot' sourceCompatibility = 1.8 targetCompatibility = 1.8 group = 'com.coder4.lmsia' version = '0.0.1' repositories { maven { credentials { username "$mavenUser" password "$mavenPass" } url 'http://192.168.99.100:8081/nexus/content/groups/public' } mavenLocal() } // maven deploy config start configurations { deployerJars } uploadArchives { repositories.mavenDeployer { configuration = configurations.deployerJars repository(url: "http://192.168.99.100:8081/nexus/content/repositories/releases/") { authentication(userName: "$mavenUser", password: "$mavenPass") } snapshotRepository(url: "http://192.168.99.100:8081/nexus/content/repositories/snapshots/") { authentication(userName: "$mavenUser", password: "$mavenPass") } } } // maven deploy config end } ``` 如上,build.gradle主要进行如下配置: * 子项目的仓库,采用私有仓库 * 子项目发布包时,也发布到私有仓库上 至此,我们成功地将maven私有仓库应用到了gradle的微服务上。 ================================================ FILE: legacy/toolchain/spring-boot-scripts.md ================================================ # 懒人脚本 ================================================ FILE: legacy/toolchain/spring-boot-template.md ================================================ # Spring Boot 项目模板 ================================================ FILE: legacy/toolchain/stress-test.md ================================================ # 打压工具 当业务刚刚起步的时候,微服务的稳定性是我们的首要保障目标,即服务能否稳定运行而不会挂掉。 随着业务逐渐发展,用户数据量不断增大,并发的请求数也会不断加大。慢慢地,性能问题也会逐渐暴露出来。 典型的性能问题有: 1. 服务响应变慢 1. 并发请求过多,导致数据库连接打满,无法访问数据库 1. 流量过大,带宽被打满 想要解决性能问题,先要能客观的评价性能,例如:在100个并发用户的前提下,我们的服务每秒能处理多少请求? 要评估这类性能问题,就需要做一些性能压测。 性能打压工具可以大致分为两类: 1. 在UI界面配置完成打压,如JMeter、Tsung等 1. 需要写代码完成打压,如Gatling、Locust等 对于JMeter等工具,虽然上手简单,但是可定制程度较低,一些复杂的规则和参数配置起来很繁琐,一般由测试人员做简单打压时使用。 而Locust等工具,虽然需要写代码,但了都提供了简化的API,编写起来非常简单,而且可以适应复杂的业务需求。 综上所述,我们选用代码类打压工具。 Gatling性能很好,但只支持Scala语言;Locust是Python语言开发的,可以支持多种编程语言。在本书中,我们将分别介绍这两款打压工具。 ## 暴露服务端口 在介绍打压工具前,我们先要对服务进行一些变更,让服务能够从集群外访问到。 在[Spring Boot整合REST服务](spring-boot-1/sb-rest.md)章节中,我们配置了基于Kubernetes的REST服务,并设置了虚拟IP、虚拟IP的8080端口负责多结点的负载均衡。但是,虚拟IP默认只在集群内部生效。 当我们需要将Kubernetes服务暴露给集群外时,一般有如下选择: * 为Service添加NodePort * 为Service添加ClusterIP * 增加Nginx反向代理,并为Nginx添加上述外部暴露的端口 在这里,我们采用第一种方式,如果你想了解其他方式,可以参考[Publising Service](https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types) 看一下更新的service描述文件 ```yaml apiVersion: v1 kind: Service metadata: name: lmsia-abc-server-service spec: selector: app: lmsia-abc-server type: NodePort ports: - name: http protocol: TCP port: 8080 nodePort: 30888 - name: rpc protocol: TCP port: 3000 nodePort: 30999 ``` 与之前的文件相比,上述yaml描述主要是增加了NodePort的定义和描述。 * http端口对外暴露的是30888 * rpc端口对外暴露的是30999 我们应用下配置变更: ```shell kubectl apply -f lmsia-abc-server-service-node-port.yaml ``` 然后尝试访问,可以成功访问: ```shell curl http://192.168.99.100:30888/lmsia-abc/api/ Hello, REST ``` ## Locust打压工具 在你的开发机上安装 ```shell pip install locustio ``` 下面我们来看打压脚本hello.py: ```python from locust import HttpLocust, TaskSet, task import resource resource.setrlimit(resource.RLIMIT_NOFILE, (999999, 999999)) print resource.getrlimit(resource.RLIMIT_NOFILE) class TestSet(TaskSet): @task(1) def hello(self): self.client.get("/lmsia-abc/api/") class WebsiteUser(HttpLocust): task_set = TestSet min_wait = 5000 max_wait = 9000 ``` 上面的代码非常简单,就是访问地址"/lmsia-abc/api/"。 下面来运行打压工具 ``` locust -f hello.py --host=http://192.168.99.100:30888 ``` 启动后,访问localhost:8089,会进入如下界面: ![Locust选择用户数](./locust-swarm.png "Locust选择用户数") 上面是设置最终多少并发,下面是设置用户增长的速度(每秒新增多少)。 我们这里分别设置1000和200,然后点击开始。 之后会进入打压进度页面,如下图所示: ![Locust打压进度](./locust-progress.png "Locust打压进度") 点击"Charts",可以看到随着用户数变化,响应时间、QPS的变化曲线,如下图: ![Locust打压变化](./locust-charts.png "Locust打压变化") 打压结束后,点击"STOP"即可。 除了单击打压外,Locust还支持分布式打压,即可以启动若干个节点共同完成打压作业,具体可以参考官方文档[Running Locust distributed](https://docs.locust.io/en/stable/running-locust-distributed.html)。 ## Gatling打压工具 首先,到[官网](https://gatling.io/download/)下载最新版的gatling: ```shell https://repo1.maven.org/maven2/io/gatling/highcharts/gatling-charts-highcharts-bundle/2.3.1/gatling-charts-highcharts-bundle-2.3.1-bundle.zip ``` 然后解压缩到本地路径: ```shell unzip gatling-charts-highcharts-bundle-2.3.1.zip mv gatling-charts-highcharts-bundle-2.3.1 gatling ``` 然后看一下打压脚本: ```scala import io.gatling.core.Predef._ import io.gatling.http.Predef._ import scala.concurrent.duration._ class HelloSimulation extends Simulation { val httpConf = http .baseURL("http://192.168.99.100:30888") val scn = scenario("HelloSimulation").during(30) { exec(http("hello_1") .get("/lmsia-abc/api/")) } setUp( scn.inject(atOnceUsers(2000)) ).protocols(httpConf) } ``` 如上,Gating的打压脚本稍微复杂一些: * 服务根地址192.168.99.100:30888 * 访问的get请求"/lmsia-abc/api/" * 并发2000个用户 执行一下打压: ```shell gatling.sh -sf . -s HelloSimulation ``` 结果中可以直接看到各项统计结果: ```shell ---- Global Information -------------------------------------------------------- > request count 353505 (OK=353505 KO=0 ) > min response time 0 (OK=0 KO=- ) > max response time 1644 (OK=1644 KO=- ) > mean response time 170 (OK=170 KO=- ) > std deviation 122 (OK=122 KO=- ) > response time 50th percentile 143 (OK=143 KO=- ) > response time 75th percentile 219 (OK=219 KO=- ) > response time 95th percentile 408 (OK=408 KO=- ) > response time 99th percentile 606 (OK=606 KO=- ) > mean requests/sec 11047.031 (OK=11047.031 KO=- ) ---- Response Time Distribution ------------------------------------------------ > t < 800 ms 353348 (100%) > 800 ms < t < 1200 ms 148 ( 0%) > t > 1200 ms 9 ( 0%) > failed 0 ( 0%) ================================================================================ Reports generated in 2s. ``` Gating的打压工具功能更为强大,具体可以参考官方教程[Gatling UserGuides](https://gatling.io/docs/2.3/) ================================================ FILE: src/README.md ================================================ # 从0到1实战微服务架构(第2版) ## 地址汇总 * [Github项目 求Star:-)](https://github.com/liheyuan/hands-on-microservices) * [在线阅读](https://coder4.com/homs_online/) ## 第2版前言 自从本书发布了后,技术圈发生了许多变化: * Spring Boot 2.X 稳定版发布 * Kubernetes下的包管理项目“Helm”,正式加入CNCF基金会 * 阿里巴巴开源了Nacos服务发现项目 * ...... 3年后的2021年,我正式开启了本书2.0版的写作计划。 由于技术更新迭代频繁,这是一次完全的重写,不是修订。 由于gitbook项目已不再维护,我改用[mdBook](https://github.com/rust-lang/mdBook)做为渲染工具,[MarkText](https://github.com/marktext/marktext)做为写作工具。 写作水平有限,还请各位多提宝贵意见。 ## 第1版前言 微服务是继SOA后,最流行的服务架构风格之一。 按照微服务对系统进行拆分后,每个服务的业务逻辑都更加简单、清晰。服务之间是松耦合的,模块之间的边界也更加清晰。 微服务有效降低了软件项目的业务复杂程度,为小团队独立开发、持续交付和部署打下了良好的基础。 遗憾的是,微服务并不是银弹。与传统的单一架构相比,微服务架构对团队的组织架构、技术水平、运维能力等方面,都提出了更高的要求。如果没有掌握得当的方法而生搬硬套,微服务架构只会会适得其反--降低项目的开发效率,这是本书的创作初衷之一。 在国内外的技术社区中,比较推崇现有开源方案,如"Spring Cloud全家桶"或者阿里开源的"Dubbo"。 上述框架通常已经实现了服务发现、配置、负载均衡、限流熔断,等微服务架构所必须的的核心功能。 使用开源框架省却了造轮子的过程,但也降低了我们学习、思考的动力。 为什么需要服务发现,又如何实现它呢?配置中心呢....思考和设计的过程充满了挑战,也是提升自身架构能力的一种手段。这是本书的创作初衷之二。 已有的微服务资料过于重视微服务的开发,忽略了微服务赖以生存的生态系统:工具链、自动化运维。可以说,离开了这两点的支持,微服务架构将难以落地。完善这两方面的思考和实战,是本书的创作初衷之三。 为此,我撰写了这本《从0到1实战微服务架构》。让我们"暂时忘掉"已有的、成熟的开源解决方案。尝试亲自动手,实现微服务架构的各个模块。 我们会从微服务开发、工具链、运维这三个角度,阐述微服务架构的实战方案。 如果本书帮助了你,欢迎在在[github](https://github.com/liheyuan/hands-on-microservices)加Star,但严禁用于商业用途!(参见本页底部版权声明) 由于能力水平所限,本书难免存在各种错误,恳请各位进行指正(Issue or PR),谢谢! ## 读者基础 由于篇幅、精力所限,本书无法写成一本”零起点”教程。我假设读者具有至少2年的服务端工作经验,并且了解以下技术或原理: * Git * Maven & Gradle * Docker & Kubernetes * Java * Spring / Spring Boot * 数据库: 如MySQL * 消息队列: 如RabbitMQ * 缓存系统: 如Memcached * 内存数据库: 如Redis 本书可以供架构师、项目经理、高级服务端程序员参考、学习。 动手实战是本书的核心内容,因此本书所涉及的全部代码,都托管到了我的[Github上](https://github.com/liheyuan)(以lmsia-开头的项目)。 这些代码以研讨为主要目的,也可以直接应用于生产,但本人不对其稳定性负责。 ## 版权 本书虽然在github上公开写作,但版权归本人[Coder4](https://coder4.com)所有。 依照 [署名-非商业性使用-相同方式共享](https://creativecommons.org/licenses/by-nc-sa/2.5/cn/) ,任何人可以在保留署名的情况下转载。但严禁用于商业用途。 This is a book powered by [mdBook](https://github.com/rust-lang/mdBook). ================================================ FILE: src/SUMMARY.md ================================================ # [从0到1实战微服务架构](./README.md) - [前言](./README.md) - [微服务概述](./ch01-architecture/micro-service-intro.md) - [微服务研发工具链](./ch01-architecture/rd-ops-toolchain.md) - [持续集成、持续部署、持续交付](./ch01-architecture/continuous-x.md) - [一种微服务的分层架构](./ch01-architecture/ms-architecture.md) - [一种微服务分层架构的技术栈选型](./ch01-architecture/ms-tech-stack.md) - [微服务开发上篇](./ch02-ms-dev1/README.md) - [Gradle构建工具配置](./ch02-ms-dev1/gradle.md) - [Sprint Boot项目与Gradle的集成](./ch02-ms-dev1/spring-boot.md) - [Spring Boot集成SQL数据库1](./ch02-ms-dev1/database1.md) - [Spring Boot集成SQL数据库2](./ch02-ms-dev1/database2.md) - [Spring Boot集成gRPC框架](./ch02-ms-dev1/rpc.md) - [Spring Boot集成Redis](./ch02-ms-dev1/redis.md) - [微服务开发中篇](./ch03-ms-dev2/README.md) - [Nacos注册中心:注册篇](./ch03-ms-dev2/registry1.md) - [Nacos注册中心:发现篇](./ch03-ms-dev2/registry2.md) - [Spring Boot集成配置中心](./ch03-ms-dev2/config.md) - [Spring Boot集成熔断、限流、降级](./ch03-ms-dev2/circuit-breaker-and-limiter.md) - [Spring Boot集成消息队列](./ch03-ms-dev2/mq.md) - [微服务开发下篇](./ch04-ms-dev3/README.md) - [基于ELKFK打造日志平台](./ch04-ms-dev3/elkfk.md) - [基于SkyWalking的链路追踪系统](./ch04-ms-dev3/skywalking.md) - [基于MicroMeter实现自定义应用监控指标](./ch04-ms-dev3/micrometer.md) - [基于VictoriaMetrics + Grafana的监控系统](./ch04-ms-dev3/victorialmetrics.md) - [容器与编排系统](./ch05-k8s/README.md) - [从集装箱到容器](./ch05-k8s/container.md) - [快速入门Kubernetes](./ch05-k8s/k8s-101.md) - [搭建Kubernetes集群](./ch05-k8s/k8s-cluster.md) - [搭建Kubernetes高可用集群](./ch05-k8s/k8s-ha-cluster.md) - [通过ingress暴露内部服务](./ch05-k8s/k8s-ingress.md) - [持续交付流水线](./ch06-cd/README.md) - [Jenkins搭建入门](./ch06-cd/jenkins.md) - [Jenkins定制Agent](./ch06-cd/jenkins-custom.md) - [Jenkins实现Kubernetes部署流水](./ch06-cd/jenkins-k8s.md) - [Jenkins优化Kubernetes部署流水线](./ch06-cd/jenkins-k8s-optimize.md) - [工具链](./ch07-tools/README.md) - [基于LDAP的内网统一认证](./ch06-cd/jenkins.md) - [JFrog Artifactory搭建Maven私有仓库](./ch07-tools/ldap.md) - [使用Registry2搭建Docker私有仓库](./ch07-tools/registry2.md) ================================================ FILE: src/ch01-architecture/README.md ================================================ # 第1章 微服务架构概述 当我们讨论服务端的架构时,“微服务”已经成为了最热门的关键字。 如果没有接触过"微服务",那么你的心里一定存在很多号? 不要急,我们将从三个基本问题谈起: - 什么是“微”服务? - 为什么需要微服务? - 微服务是“银弹”么? 接着,我们将沿着: - 研发工具链 - 微服务架构 两个线索展开,最后将讨论技术选型。 让我们开始“微”服务的探索之旅吧:-) ================================================ FILE: src/ch01-architecture/continuous-x.md ================================================ # 持续集成、持续部署、持续交付 标题里的三个“持续”在前几年特别火热,属于技术热词(BuzzWord)。 持续交付(Continuous Delivery)由马丁·福勒(Martin Fowler)于2006年提出。 是的,你没看错,又是马丁·福勒,那位提出微服务的大神。 歪个楼,介绍一些马丁·福勒的代表作: - 《重构:改善既有代码的设计》 - 《企业应用架构模式》 - 《敏捷软件开发宣言》(联合) - “微服务”、“持续部署” .... 以上任何一条单独拿出来,都足以封神。 言归正传,我们在一本“微服务”的书中讨论持续交付,仅仅因为它是由大神提出的么? 当然不是,我们将在本文的末尾再讨论这个问题。 [这篇](https://www.mindtheproduct.com/what-the-hell-are-ci-cd-and-devops-a-cheatsheet-for-the-rest-of-us/)文章很好的阐述了三个概念的联系与区别,我们展开讨论。 ## 持续集成 小王每次向gitlab提交一个代码,就会触发一次项目的自动构建、运行单元测试,这就是持续集成(Continuous Integration)。如下图所示: ![](./ci.png) 假设小王在提交中引入了一个Bug,借助CI流程(中的集成 or 单元测试),我们就能在第一时间发现,并尽早修复问题。 管理学大师戴明指出:“问题发现的越早,修复的成本越低”。通过持续集成,我们可以尽早发现问题,从而降低(修复问题带来的)返工成本。 ## 持续部署 持续部署(Continuous Deployment)指的是:在持续集成(成功)的基础上,自动将服务部署到"类似于线上"的环境中,如下图所示: ![](./cd.png) 为什么要部署到"类似于线上环境"呢?因为代码只在"集成阶段"通过了一部分"单元测试",假设单元测试覆盖不全,甚至还需要人工测试,那就可能将隐含的Bug发布到线上,造成生产事故。 图中画的"TEST"(测试环境)、"STAGING"(预发环境),都是这类"类似线上环境"。当新版本通过最终确认后,再手动(MANUAL)部署到线上。 ## 持续交付 持续交付(Continuous Delivery)在持续部署的基础上,更近了一步:成功发布到"类似生产环境"后,会继续自动发布到线上,如下图所示: ![](./cd2.png) 显然,这种"自动发布"需要极强的自信和勇气。这可能源于充分的单元测试,清晰的架构,以及对业务能力的自信。 实际上业界只有极少数公司"从容地"实现了上述意义上的"持续交付"。 其余宣称实现了"持续交付"的公司,或者混淆了持续部署的概念,或者对技术故障存在较大容忍度。 (先发布再灰度,难道不是一种容忍?) 这并不是高级黑,如果你认真做过一段时间软件开发,应该能明白“即使100%的单测覆盖率,也不能自动检查出尚未发现的Bug”,更何况绝大多数项目根本无法达到100%单元覆盖率。 我们回到本文开头的问题:为什么要在一本“微服务”的书中,讨论持续部署? 还记得微[服务概述](./micro-service-intro.md)一节中,微服务的缺点么?可靠性陷阱、运维复杂度升高。 - 借助持续集成,能够尽早发现缺陷,提升微服务架构下的可靠性。 - 应用持续部署,可以上线效率,降低运维难度。 由此可见,持续集成、持续部署,能够切实解决微服务中存在的问题。我们将在本书的后续章节,打造自己的持续集成系统,敬请期待。 ================================================ FILE: src/ch01-architecture/micro-service-intro.md ================================================ # 微服务概述 ## 什么是“微”服务? 如果你仔细观察,会发现我在上一行的标题中,将“微”打了个引号。 如果我们暂时去掉这个''微"字理解,微服务就是我们熟知的“服务端” 或者 “后端”。 现在让我们把微字加回来:-) "微服务"(Microservices)由马丁·福勒(Martin Fowler)提出的一种架构理念,[原文]([Microservices](https://martinfowler.com/articles/microservices.html))发表于2014年。 > 微服务是一种架构模式或者说是一种架构风格,它提倡将单一应用程序划分成一组小的服务,每个服务运行独立的自己的进程中,服务之间互相协调、互相配合,为用户提供最终价值。 我们抓三个关键点来理解: - 单一应用划分为一组更小的服务:将一个较大的、复杂的应用,拆分为多个小的服务。你可能会问:“这样不会增加复杂度么”?是的,会增加。但这种拆分也会带来明显的优点,我们后面会提到。 - 独立的进程:每个微服务独立运行在自己的进程中,互不干扰。虽然这里并没有限制进程的部署方式,但可以想见,经过"划分"后的微服务,势必会产生众多进程。微服务是拆分而来的,他们之间势必存在逻辑的耦合。由此,会产生新的问题"微服务间的通信"。 - 相互协调、配合:微服务的进程间需要通信、交互。从理论而言,所有IPC(Inter-Process Communacation,进程间通信)的方式都可以完成这个过程。但微服务的进程众多,很难完整地部署在同一台机器上,这势必产生跨主机的网络通信。所以,在微服务中,多采用RPC(Reomote Procedure Call,远程过程调用)的方式来完成通信。 ![](./sketch.png) 上图展示了单体服务 和 微服务的区别。 ## 为什么需要微服务? 在前文中,我们挖了一个坑:'微服务的划分会导致复杂度上升',为什么还要使用一项有缺陷的技术呢? 我们先讲第一个故事。 小张入职了一家互联网创业公司,一开始只有3个后端程序员,每天的工作是:和产品经理讨(si)论(bi)需求、写代(b)码(ug),改Bug,工作紧张但规律。服务端的上线窗口是周五下午:合并分支、代码Review、推送线上,一气呵成,不仅能准点下班,还能去吃个火锅。 过了两个月,行业赶上了风口,公司的业务快速发展,后端团队也快速膨胀到20人。然而,麻烦也接踵而至:大家修改的是同一个仓库下的服务端代码,"解冲突"成为了家常便饭,还发生了几次"一个小修改,破坏了其他业务主流程“的严重线上事故。 为了改善这种情况,老板招聘了2位QA(质量保证,测试)人员,由他们负责测试工作。然而,一个很小的改动都需要对整个后端服务的case进行全量回归测试。一个功能的开发需要1天,测试却耗费1周,迫于老板的压力,研发同学只能安慰自己:XX功能简单,不需要测试了,直接上线。 最终,周五成为了"噩梦日":周四晚上要提前开一个Excel表、统计好第二天要上线的需求,并按优先级排定顺序。周五全员提前1小时来公司,开始逐个逐个需求的"合并代码"”、"解冲突",“上线”、"观察" 、“回滚”、“修改代码”...... 上线结束的收工时间从6点变成了9点,又逐渐拖到了11点,最后索性全员加班、通宵上线。技术团队的每一位同学,都感到身心俱疲。 听完这个故事,你是否有"似曾相识"的感觉? 科普一下,上述故事中的服务一般称作“单体服务” 或者 “巨石服务”(Monoliths)。 接下来,是第二个故事。 由于工作强度大、线上故障频发、团队士气低落,老板请来了老刘担任技术经理。 第一周:老刘带领团队将复杂、臃肿的"巨型服务"拆分成了“用户”、“订单”、“服务”三个微服务。 第二周:老刘将团队进行了上述类似的拆分,也分成了三个小组。 第三周:事情有了微妙的变化。分组后,合并代码引发的冲突减少了。开发业务时,多数的改动都封闭在单独的微服务内,改动造成的影响范围减少了,测试周期缩短了。 ...... 三个月后的一个周五的下午,(得益于提高的交付质量,以及微服务的独立并行上线),团队提前2小时完成了上线,距离上一次故障通报已经过去了两个月。 研发讨论群里,小张发了一条消息:“今天居然可以正点下班了,老刘真厉害!” 老刘回复:这是大家的努力的结果,真正“厉害”的应该是“微服务”。 听完这两个故事,我们来总结下微服务架构的两个优点:) - 逻辑清晰:一个微服务只负责一项(或少数几项)很明确的业务,逻辑更加简介清晰,易于理解。 - 独立自治:每个微服务由一个小组负责。减少了跨团队的代码冲突,同时降低了改动的影响范围,提高了研发效率。 在故事之外,微服务架构还具有以下的优点: - 伸缩性强:相对于庞大的巨石服务,微服务更加独立,可以针对不同的性能需求,有选择的对不同微服务进行伸缩。举个栗子:明天有大促,产品预测:注册功能提升10倍,其他功能无波动。针对巨石服务,我们只能整体扩容10倍;微服务架构下,我们只需要10倍扩容用户微服务。 - 技术异构性:每个微服务内可以使用不同的技术栈,甚至不同的开发语言。只要微服务之间使用统一的通信方式即可。 微服务架构有很多优势,那团队抓紧上马微服务吧? ## 微服务是“银弹”么? 直接泼一盆冷水: > There is no Silver Bullet. -- 《人月神话》 微服务不是“银弹”,它存在以下缺点: - 复杂度升高:在巨石服务中,所有修改都集中在同一个项目内;在微服务架构下,复杂功能的开发,需要同步修改多个微服务,复杂度骤然升高。 - 性能损耗:原本在巨石服务中的方法调用,演变为微服务之间的跨进程、网络通信。性能会受到较大影响。 - 可靠性陷阱:假设每个服务的可靠性都是99%,一个巨石服务,可靠性是99%、三个微服务的可靠性会下降到99% x 99% x 99% = 97%。 - 运维难度加大:巨石服务被拆分成N个微服务,部署的数量翻倍的增长。此外,多组微服务的运行,也会增大运维、监控的难度。 有意思的是:"拆分"带来了优点,也引入了缺点。 > 夫尺有所短,寸有所长,物有所不足,智有所不明。 -- 《楚辞.屈原.卜居》 微服务架构也是如此,它的优缺点并存。 ## 微服务适用什么场景? 什么场景适用微服务,什么场景不适用呢? 这篇文章[《When to use and not use microservices》]([Best of 2020: When To Use - and Not To Use - Microservices - Container Journal](https://containerjournal.com/topics/container-ecosystems/when-to-use-and-not-to-use-microservices/))给出了一些建议: 适用微服务架构的场景: - 希望巨石服务能适应“可扩展性”、“敏捷性”、“可管理性”,提升交付速度时 - 需要为(使用陈旧技术开发的)的老系统,迭代新功能时 - 有一些相对独立的模块可以跨业务复用时:如登录、检索、身份验证等。 - 构建需要快速交付、创新度高、敏捷的应用 / 服务 不适用微服务架构的场景: - 业务简单,无需处理复杂问题 - 团队规模太小,尚无法负担微服务拆分带来的复杂度提升 - 为了微服务而微服务 最后,引用马丁·福勒(Martin Fowler)论文的结尾做结束本节的讨论。 > 我们怀着谨慎、乐观的态度写了这篇文章。到目前为止,我们已经看到:微服务风格是一条非常值得探索的路。我们不能肯定地说,我们将在哪里结束,但软件开发的挑战之一是,你只能基于目前能拿到手的、不完善的信息作出决定。 ================================================ FILE: src/ch01-architecture/ms-arch.plantuml ================================================ @startuml title "微服务架构实现" package "聚合接入层" as l5 { [聚合服务1] as n51 [聚合服务2] as n52 [聚合服务3] as n53 [PaddingPadding] as n54 hide n54 [n51] -[hidden]right-> [n52] [n52] -[hidden]right-> [n53] [n53] -[hidden]right-> [n54] } package "业务服务层" as l4 { [微服务1 ] as n41 [微服务2] as n42 [微服务3] as n43 [微服务4] as n44 [Pa] as n45 hide n45 [n41] -[hidden]right-> [n42] [n42] -[hidden]right-> [n43] [n43] -[hidden]right-> [n44] [n44] -[hidden]right-> [n45] } package "微服务设施层" as l3 { [开发框架] as n31 [RPC] as n32 [服务注册发现] as n33 [配置中心] as n34 [ ] as n35 hide n35 [熔断/限流] as n36 [数据库] as n37 [NoSQL] as n38 [消息队列] as n39 [链路追踪] as n3a [监控] as n3b [报警] as n3c [日志] as n3d [n31] -[hidden]right-> [n32] [n32] -[hidden]right-> [n33] [n33] -[hidden]right-> [n34] [n34] -[hidden]right-> [n35] [n31] -[hidden]down-> [n36] [n36] -[hidden]right-> [n37] [n37] -[hidden]right-> [n38] [n38] -[hidden]right-> [n39] [n36] -[hidden]down-> [n3a] [n3a] -[hidden]right-> [n3b] [n3b] -[hidden]right-> [n3c] [n3c] -[hidden]right-> [n3d] } package "运维平台层" as l2 { [CI/CD系统] as n21 [部署版本系统] as n22 [容器调度系统] as n23 [PaddingP] as n24 hide n24 n21 -[hidden]right-> n22 n22 -[hidden]right-> n23 n23 -[hidden]right-> n24 } package "基础设施" as l1 { [计 算 资 源] as n11 [存 储 资 源] as n12 [网 络 资 源] as n13 [PaddingPPddi] as n14 hide n14 n11 -[hidden]right-> n12 n12 -[hidden]right-> n13 n13 -[hidden]right-> n14 } n51 -[hidden]down-> n41 n41 -[hidden]down-> n31 n3a -[hidden]down-> n21 n21 -[hidden]down-> n11 @enduml ================================================ FILE: src/ch01-architecture/ms-architecture.md ================================================ # 一种微服务的分层架构 在上一小节,我们讨论了微服务架构“的的特征、优缺点等话题。 你可能对微服务有了一个模糊的概念,依然感觉不够清晰。 这种感受能够理解。因为,微服务的理论只是提供了一种“架构风格”的建议,并不包含具体的实施方案。 下图展示了一种微服务的分层架构: ![微服务整体架构](./ms-arch.png "微服务整体架构") 让我们自底向上、逐层分解: 1. 基础设施层 基础设施层涵盖了服务端运行时,所需要的物理资源。包括:计算资源、存储资源、网络资源等。 针对小型公司,可以直接选用云计算平台的资源(如阿里云、AWS等);中大型公司出于成本、审计等因素,会自建机房或混合云。 计算资源:CPU、GPU、内存等。除了CPU的核数、内存容量,配比等常见问题,还需要考虑计算资源的弹性伸缩能力,即如何应对“平台大促”等场景带来的流量提升。 存储资源:不仅要考虑磁盘容量,还要考虑磁盘性能([IOPS]([IOPS - 维基百科,自由的百科全书](https://zh.wikipedia.org/wiki/IOPS)))。举个例子:服务端日志主要是顺序写,异步处理 + 大容量机械磁盘就能满足要求;对MySQL等数据库场景,涉及大量随机读,使用SSD可以显著提升性能。 网络资源:外网带宽(峰值)、内网带宽、负载均衡、VPC等。内外网带宽问题较为常见,我们不再讨论。负载均衡:当业务流量规模升高后,接入层的传统软负载解决方案(Nginx、LVS)会显得力不从心。硬件负载均衡(F5)可以提供更高的性能,但做为专用计算的商业产品,其价格在百万以上。这几年,随着Kernel By Pass技术的兴起,基于X86通用硬件 + Linux的的软负载均衡也取得一定的性能突破,感兴趣的话,可以参考这篇[文章]([从Maglev到Vortex,揭秘100G+线速负载均衡的设计与实现-InfoQ](https://www.infoq.cn/article/Maglev-Vortex/))。 基础设施层的技能栈主要是:运维、网络建设,我们不在本书中做更多讨论。 2. 运维平台层 运维平台层是“[持续交付](continuous-x.md)”的重要载体,包括: 持续部署系统:构建从代码仓库、持续集成、持续部署的全链路系统、最终实现持续交付。 部署的版本管理系统:管理部署镜像粒度的版本,以支持滚动发布、回滚等部署功能。 容器、容器管理调度平台:容器是一种操作系统级的轻量虚拟化技术。在部署系统中,不仅需要容器技术、还需要容器调度管理系统。这两项技术我们会在后续章节展开讨论。 3. 微服务设施层 本层为微服务的开发和运行提供公用的设施基础。 在这里我们只做基本介绍,在后续章节会详细展开。 开发框架:微服务的开发需要一些基础的编程框架,可以自己从零搭建,也可以基于成熟开源框架完善。 RPC:微服务内部使用RPC(Remote Procedure Call)完成通信。 服务注册与发现:微服务A调用服务B且后者有3个实例,如何感知这3个实例的IP、端口,以及A要调用哪个实例呢?这就是服务的注册与发现问题,是微服务的核心问题之一。 配置中心:微服务的数量、实例众多,逐一修改配置文件的传统模式,既不经济又容易出错。配置中心是一个中央(但不一定是单机)配置系统,负责配置管理、修改等工作。 熔断:当微服务调链路上,服务不可用或响应时间太长时,触发熔断,快速提前返回。举个例子:家里有用电设备路时电流过大,空气开关会直接跳闸,防止造成进一步的破坏。 限流:为了保护服务不被流量击垮,而提前限制流量。举个例子:经过测算,故宫接待能力是每日1万人。那么当天超过1万后,就触发限流,不让更多游客入园。 数据库:传统的SQL数据库用于业务数据落盘,NoSQL数据库则用于缓存或高性能存取。 消息队列:将业务流量“削峰填谷”,对应对突发流量。 中间件:中间件是介于 服务端 与 数据库、消息队列等设施的中间。中间件帮助 业务服务更简单地使用这些基础设施。 近几年,“可观测性”成为了新的技术热词。这个舶来于控制理论的词,在软件系统中指的是:可以帮团队有效调试系统的工具或解决方案。以这个视角看,下述部分都是可观测性的一部分: 日志:如何在众多的微服务实例中,快速定位到某一种出错日志?日志平台实现了微服务实例中的日志收集、存储、检索、分析。 监控系统:通过采集多种指标,实时反馈系统运行状态,保证服务的平稳运行。举个生活中的例子:汽车驾驶位的仪表盘。 报警系统:当监控系统发现异常时,及时将报警发送出来。 链路追踪:当服务A->B->C调用链上发生超时,如何快速定位哪个环节发生了故障?链路追踪解决了分布式、复杂调用链路中的采集、追踪,分析工作。 4. 业务服务层 借助“基础设施“、”运维平台“、”微服务设施“的帮助,我们可以更高效、稳健的应用微服务,实现业务目标。关于微服务的拆分、建模理论,可以参考“领域驱动设计”的相关内容,本书不做讨论。 5. 聚合接入层 在“[微服务概述](micro-service-intro.md)”一节中,我们曾提到微服务的缺点之一:拆分导致的复杂度升高。在当前主流的前后端分离架构中,用户对这一拆分基本无感知。复杂度被转嫁到 前端 / 客户端 中:原本只需要调用一个接口,现在要分别调用N个微服务。还需要考虑时序关系、错误处理等。聚合接入层就是为解决这个问题而生的,他聚合多个微服务的调用,只保留必要字段,为前端 / 客户端提供了统一、清晰的服务接口。聚合接入层可以由服务端实现,有时还会加入部分熔断、限流等逻辑,组合成为微服务网关。聚合接入也可以由前端实现,有时也被称作BFF(Backend For Frontend)。 在剖析微服务的各层架构之后,不难发现:微服务的架构下,需要多个团队,多层系统、多纬度的支持。这也印证了在“[微服务概述](micro-service-intro.md)”一节中的观点:应用微服务架构,需要较高成本。 因此,尽量选用成熟、易维护的技术,从而尽可能降低成本,显得尤为重要。我们将在下一节展开讨论技术选型。 ================================================ FILE: src/ch01-architecture/ms-tech-stack.md ================================================ # 一种微服务分层架构的技术栈选型 我们在[工具链](./rd-ops-toolchain.md)、[一种微服务的分层架构](./ms-architecture.md) 两小节中讨论了技术栈的需求。 在本节中,我们将具体讨论技术栈的选型。 你可能注意到,上一节的标题是“一种微服务的分层架构”,而这一节的标题是“一种微服务分层架构的技术栈选型”。 加上“一种”这个词是有意而为之,请不要怀疑我的语文水平:-) "一种"强调的是: - 微服务只是一种架构风格,他可以有N种不同的实现,上一节只介绍了其中一种。 - 每一种微服务架构的实现,也可以对应N种不同的技术栈选型。 那么,在这N^2种架构 + 技术栈的组合种,哪一种才是最好的? 不急着回答,我们先来看下这个: > php is the best language for web programming. 这是PHP官方手册的原文,更多人更熟悉前5个单词,“PHP是全世界最好的语言”。 但加上后3个单词“for web programming”后,就变成了“PHP是web领域最好的语言”。 而我的观点(哪个架构更优) 与 PHP社区(关于语言优劣)的观点,是一致的:没有最好的语言(技术),只有最适合具体场景的。 因此,我们只会针对各项场景,列出技术选型,而不会打“为什么A比B好的”口水战。 ## 容器管理平台的技术选型 微服务架构下会对服务进行拆分,产生大量的服务实例。 容器化技术,可以实现环境隔离、快速部署,是微服务架构的基石。 Docker凭借“快速”、“可移植性”等特性""一战成名",是单机或小规模应用部署的最佳选择 然而,在复杂的分布式部署场景中,"扩容"、"编排"、"故障恢复"等成为了"刚需",“容器管理平台”应运而生。在这个赛道上,曾经出现过三个主流产品: - swarm: Docker公司于2014年末推出的容器集群技术方案。尽管swarm是Docker公司的“亲儿子”、手握大量社区资源,但很快被Kubernetes超越。 - Kubernetes: 简称k8s,支持自动部署,扩展和管理容器化应用程序的开源系统。k8s借鉴了Google的Borg管理系统,自问世以来发展迅猛,当前已经成为了容器管理的事实标准。 - Marathon: 构建在[Apache Mesos]([Apache Mesos](http://mesos.apache.org/))集群上的一套容器集群管理软件。由于Mesos的部署存在门槛,Marathon项目的关注度并不高,社区也并不活跃。其上一个发布版本依然停留在2019年,已经近2年没有更新。 因此,我们"毫无争议"地选择k8s作为微服务架构下的容器管理平台。 除了容器管理平台,我们还需要镜像仓库存储应用的容器镜像,我们将使用Docker搭建私有镜像仓库。 ## 微服务设施层的技术选型 设施层涉及较多的技术需求,技术选型如下: | 需求 | 选型 | 版本 | | ---------------- | ------------------------- | -------------- | | 开发语言 | Java | 8 | | 开发框架 | Spring Boot | 2.5.4 | | RPC | gRPC | 1.14.x | | 服务注册 / 发现 / 配置中心 | Nacos | 2.x | | 熔断 / 限流 | Resilience4j | 1.7.1 | | SQL数据库 | MySQL | 8.0.X | | 内存数据库 | Redis | 6.2 | | 消息队列 | RocketMQ | 4.9.1 | | 日志 | Kafka + ELK | 2.13 + 7.14.X | | 监控 / 告警 | VictoriaMetrics + Grafana | 1.64.1 + 8.1.X | | 链路追踪 | SkyWalking | 8.7.0 | 开发语言:我们选择了Java做为开发语言。与新近崛起的Go、Rust等语言相比,Java不是最完美的语言,但它依然拥有较高的开发、运行效率,最充足的人才供给。版本方面我们选择Java 8(最后一个免费的Java版本)。 开发框架:在Java开发领域,Spring生态的渗透率已超过60% ([出处]([Spring dominates the Java ecosystem with 60% using it for their main applications | Snyk](https://snyk.io/blog/spring-dominates-the-java-ecosystem-with-60-using-it-for-their-main-applications/)))。顺应这一趋势,我们选择Spring 生态内的Spring Boot做为主要开发框架。Spring Boot提供的注解配置、嵌入式容器、starter等特性,可以极大简化Java应用的开发。 RPC框架:我们选择开源的gRPC做为RPC框架,它使用Protocl Buffer序列化,HTTP 2传输协议,具有更灵活的通信模式和较高的传输效率。 服务注册、发现、配置中心:[Nacos]([什么是 Nacos](https://nacos.io/zh-cn/docs/what-is-nacos.html))是阿里巴巴开源的服务管理项目,同时具备服务注册、发现、配置中心。Nacos原生支持Spring Boot、k8s等融合方向。经过几年的发展,Nacos已经较为成熟,支撑了阿里巴巴、中国移动等数十家大型公司的线上系统。 熔断、限流:本书不会探讨Service Mesh等平台级别的流量控制方案。我们主要讨论服务进程级别的熔断、限流方案。老牌项目Hystrix停更后,我们选择开源的Resilience4j做为熔断、限流的Java库解决方案。 数据库:做为开源数据库的佼佼者,MySQL常年稳居市场份额的前三名。我们选择其较新的稳定版8.0.X。 内存数据库:做为SQL数据库的补充,内存数据库的应用场景是:吞吐量更大、延迟更低。高性能的Redis是最佳选择。根据[官方评测](https://redis.io/topics/benchmarks),Redis 6.x在开启pipeline模式的前提下,可以提供高达55万RPS。 消息队列:Apache RocketMQ是阿里巴巴的开源的分布式消息队列,具有极低的延迟和较高的吞吐量。相比于老牌的Kafka,Rocket MQ更适用于消息队列的场景。我们选用其最新稳定版4.9.1。 日志:ELK是经典的日志日志方案。在此基础上,我们前置增加了Kafka,利用其强大的写能力,构建起缓冲队列,以应对海量日志的突发写入。 监控 / 告警:纵观DevOps领域,Prometheus + Grafana已经成为了监控领域的事实标准。然而,Prometheus并不支持原生的集群部署,其在大规模应用下很容易出现瓶颈。[VictoriaMetrics](https://github.com/VictoriaMetrics/VictoriaMetrics)是一款可以嵌入Prometheus的分布式时序存储引擎。起初VictoriaMetrics只想做一个引擎,在近几个版本社区加大了对vmagent的开发投入。vmagent是一款轻量级的代理,兼容Prometheus协议,可以直接替代Prometheus完成大部分工作。在本书中,我们直接选择VictoriaMetrics + Grafana做为兼容告警的默认技术栈。 链路追踪:[SkyWalking](https://skywalking.apache.org/)是由国人主导的一款开源APM(application performance management)。在小米、滴滴等公司都有应用。我们选择其最新的稳定版本。 看了上面的文字,你可能有点困惑:“只是简单罗列选型结果,并没有具体分析过程“? 技术选型是一个非常大的话题,每一个点单独拎出来,都能洋洋洒洒的写一章出来,但是我觉得必要性不大,原因在于: - 技术演进的速度非常快,今天适合的明天就有可能被淘汰(看看Docker) - 每个公司面临的具体场景情况都是不同的,很难穷尽、更无法全部都满足 因此,我只是在自己可见的技术水平内,选择了相对靠谱的方案,解决了一部分“选择障碍的问题”,如果你有更优秀的选择,也欢迎提Issue交流、讨论。 ================================================ FILE: src/ch01-architecture/rd-ops-toolchain.md ================================================ ## 微服务研发工具链 > 子曰:“工欲善其事,必先利其器。居是邦也,事其大夫之贤者,友其士之仁者。” > >                                                                                                                     -- 《论语》 普通话版:工匠想要做好工作,先要把工具打磨锋利。 程序员版:软件工程师要想写好代码,需要一把机械键盘,并定期清洗轴以维持最佳手感。 对于程序员而言,除了键盘等硬件,还有一系列软件。我们这里将这些软件称为工具链。 ## 小王的一天 下面,让我们跟随小张 - 是的,就是在风口创业公司的那位 - 看看在微服务架构下,研发工具链包含了哪些内容。 | 时间 | 工作 | 工具需求 | 备注 | | ----- | ------------------------------ | ------------------------------ | ---------------------- | | 09:01 | 打开浏览器,登录公司内网 | 使用同一个账号,登录公司所有的内部系统。 | 暂不讨论“操作系统”、“浏览器”等通用软件。 | | 09:03 | 打开代码审核平台,查看Review | 代码版本控制、代码托管,代码审核 | | | 10:23 | 老张让我升级下xx的包,加了新接口 | 版本依赖管理系统 | 我们将开发语言暂时限定为Java | | 11:56 | 修改了一部分逻辑,午饭前抓紧提交上去,看能否跑通所有Case | 持续集成(Continuous integration)系统 | 暂不讨论“IDE”等通用软件。 | | 15:20 | 功能开发完毕,上线! | 持续交付(Continuous delivery)系统 | | | 16:03 | X功能重构,拆分到两个微服务中 | 微服务开发辅助工具 | | ## 研发工具链 小张的公司还处于创业阶段,出于节省成本的考虑,我们尽量选择开(mian)源(fei)的解决方案: 1. 内部帐号统一管理:在企业的内部,存在许多内部系统。出于安全性、管理性的考虑,需要统一的帐号管理系统。这里我们选用[OpenLDAP](https://www.openldap.org/):一款的开源的帐号管理服务,它实现了广泛使用的“轻量级目录管理协议”(LDAP v3),可以轻松对接各类系统的帐号管理功能。 2. 代码管理:团队协作的软件开发模式,需要版本控制系统。我们选用了[Git](https://git-scm.com/)做为代码的版本控制系统。在代码的托管、审核方面,[Gerrit](https://www.gerritcodereview.com/)和[GitLab](https://about.gitlab.com/install/?version=ce)都是成熟的开源解决方案。Gitlab上手容易,生态链更加成熟;Gerrit有一定上手门槛,在代码Review方面更加优秀。关于两者的讨论,可以参考这篇[帖子](https://www.reddit.com/r/git/comments/8ekeem/do_you_use_gerrit_software/)。经过多方面的综合考虑,我们选择了GitLab。 3. 版本依赖系统:在Java开发中,[Maven](https://maven.apache.org/)是依赖管理的事实标准。同时在企业开发中,不希望将私有包发布到公开仓库中,我们选用[Nexus Repository OSS](https://www.sonatype.com/products/repository-oss)搭建私有的Maven仓库。 4. 持续集成、持续交付,持续部署是三个既相近又重要的概念,我们将在下一小节展开讨论。 5. 微服务辅助开发工具:在微服务架构下,新增微服务、升级pom版本,接口变更等操作会频繁发生。需要开发一些辅助工具,提升研发效率。我们会在后面展开讨论。 针对上述选择的工具,我们会在后续章节详细介绍。 ## 微服务辅助开发工具 结合微服务的开发特点,我们需要这样一些辅助工具: - 自动创建新的微服务:包括从模板项目生成微服务代码、自动创建git项目、部署项目 - RPC桩文件生成:在RPC的(IDL)接口文件变更后,需要重新生成桩文件,这个步骤较为繁琐,需要工具辅助完成。 - pom版本自动升级:微服务之间的版本依赖,更新会更加频繁,我们需要一个工具,自动修改pom版本 这里我们只初步讨论一下需求,具体的实现会在后续章节展开。 ================================================ FILE: src/ch02-ms-dev1/README.md ================================================ # 微服务开发上篇:开发框架及其与RPC、数据库、Redis的集成 从这一章开始,我们正式进入微服务开发篇,共分上、中、下三篇。 本章我们将讨论开发框架,框架与RPC、数据库、Redis的集成。 2001年,我刚开始编程时,接触的第一个语言是"ASP"(没有.net),它通过脚本注解的方式,实现动态功能(存取数据库等),有点类似于PHP。在那个没有开发框架的年代,我们依然可以实现功能。但是这里只是“功能上的满足”,确无法做到“工程上的最优”,例如: - HTML与脚本混编,无论是页面样式修改,还是逻辑修改都很麻烦(视图、逻辑混合) - 有不少功能重复的代码,无法复用(如创建数据库连接) - 页面之间的内部依赖难以处理(往往只能通过url / session参数传递) 开发框架的出现,解决了上述部分问题,以Spring为例: - Spring MVC实现的分层架构,将页面、视图、逻辑层强制分离 - Spring JPA组件可以创建数据库模板,减少重复代码 - 通过IoC容器,可以清晰地分离逻辑、处理依赖 - .... 当然,引入开发框架会带来额外的学习成本。Spring Boot借鉴了ROR框架中“约定优于配置”的设计理念,进行了大量的改造,实现了框架的“开箱可用”,有效降低了学习成本。 本章会使用一个微服务为例,介绍Gradle + Spring Boot的基础集成。在此基础上,我们会介绍几个与框架紧密相关的内容:RPC框架、数据库、Redis的集成。 ================================================ FILE: src/ch02-ms-dev1/database1.md ================================================ # Spring Boot集成SQL数据库1 从银行的交易数据到打车订单,衣食住行,都离不开数据库的存储。 在接下来的两个小节中,我们将通过3种不同的技术,在Spring Boot中集成MySQL数据库。 - JDBC - MyBatis - JPA (Hibernate) 本节的前半部分,我们将通过Docker快速搭建MySQL的环境,随后介绍JDBC的集成方式。 ## 搭建MySQL实验环境 本书的重点是讨论微服务实战,我们直接使用Docker的方式,快速搭建实验环境。 如果你想部署在生产环境,请参考官方[部署文档](https://dev.mysql.com/doc/mysql-installation-excerpt/8.0/en/linux-installation.html)。 首先,请确认已经成功安装了Docker: ```shell docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES ``` 若尚未安装Docker,可以参考[官方文档]([Install Docker Engine | Docker Documentation](https://docs.docker.com/engine/install/))。 MySQL的Docker运行脚本如下: ```bash #!/bin/bash NAME="mysql" PUID="1000" PGID="1000" VOLUME="$HOME/docker_data/mysql" MYSQL_ROOT_PASS="123456" mkdir -p $VOLUME docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ --volume "$VOLUME":/var/lib/mysql \ --env MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASS \ --env PUID=$PUID \ --env PGID=$PGID \ -p 3306:3306 \ --detach \ --restart always \ mysql:8.0 ``` 如脚本所述: - 使用官方的8.0镜像启动Docker - 退出后自动重启 - 暴露3306端口到本机 - 设置Volume盘到~/docker_data/mysql路径下 - root密码123456(请务必更改为安全密码) 执行后的效果: ```bash docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES feb2838197a6 mysql:8.0 "docker-entrypoint.s…" 46 hours ago Up 7 hours 0.0.0.0:3306->3306/tcp, :::3306->3306/tcp, 33060/tcp mysql ``` 启动成功后,我们尝试连接数据库,新建库并授权给用户: ```bash mysql -h 127.0.0.1 -u root -p > CREATE DATABASE homs_demo; > CREATE USER 'HomsDemo'@'%' identified by '123456'; > GRANT ALL PRIVILEGES ON homs_demo.* TO 'HomsDemo'@'%'; ``` 尝试用新用户登录: ```bash mysql -h 127.0.0.1 -u HomsDemo -p homs_demo ``` 若能成功登录,我们创建本书实验所需的表: ```sql CREATE TABLE `users` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(64) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` 这里我们创建了表users,有两个列:id和name。 温馨提示:我们使用utf8mb4字符集,如果用utf8是会有坑,可以参考[这篇文章]([掘金](https://adamhooper.medium.com/in-mysql-never-use-utf8-use-utf8mb4-11761243e434))。强烈推荐你对所有的数据表,都设置为utf8mb4。 ## Spring Boot 集成 JDBC操作MySQL 我们先通过集成jdbc的方式操作MySQL数据库。 首先在server项目的build.gradle中添加依赖 ```groovy implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation 'mysql:mysql-connector-java:8.0.20' ``` 上述依赖中: - spring-boot-starter-jdbc是集成jdbc的starter依赖包 - mysql-connector-java是集成MySQL的驱动 接着,我们配置下数据源: ```yaml spring.datasource: url: jdbc:mysql://127.0.0.1:3306/homs_demo?useUnicode=true&characterEncoding=UTF-8&useSSL=false username: HomsDemo password: 123456 hikari: minimumIdle: 10 maximumPoolSize: 100 ``` 上述配置分为两部分: - spring.datasource.url / username / password定义了MySQL的访问链接 - hikari是数据库连接池的配置。 Hikari是Spring Boot 2默认的链接池,[官方性能评测优秀](https://github.com/brettwooldridge/HikariCP-benchmark)。这里我们配置了minimumIdle(最小连接数)和maximumPoolSize(最大连接数)两个选项。更多配置参数可以参考[官方文档]([GitHub - brettwooldridge/HikariCP: 光 HikariCP・A solid, high-performance, JDBC connection pool at last.](https://github.com/brettwooldridge/HikariCP#gear-configuration-knobs-baby))。 经过上述的组合配置后,对应DataSource对应的Configuration会自动激活,并注册一系列的关联Bean。 下面让我们使用它访问MySQL数据库: ```java @Repository public class UserRepository1Impl implements UserRepository { @Autowired protected NamedParameterJdbcTemplate db; private static RowMapper ROW_MAPPER = new BeanPropertyRowMapper<>(User.class); @Override public Optional create(User user) { String sql = "INSERT INTO `users`(`name`) VALUES(:name)"; SqlParameterSource param = new MapSqlParameterSource("name", user.getName()); KeyHolder holder = new GeneratedKeyHolder(); if (db.update(sql, param, holder) > 0) { return Optional.ofNullable(holder.getKey().longValue()); } else { return Optional.empty(); } } @Override public Optional getUser(long id) { String sql = "SELECT * FROM `users` WHERE `id` = :id"; SqlParameterSource param = new MapSqlParameterSource("id", id); try { return Optional.ofNullable(db.queryForObject(sql, param, ROW_MAPPER)); } catch (EmptyResultDataAccessException e) { return Optional.empty(); } } @Override public Optional getUserByName(String name) { String sql = "SELECT * FROM `users` WHERE `name` = :name"; SqlParameterSource param = new MapSqlParameterSource("name", name); try { return Optional.ofNullable(db.queryForObject(sql, param, ROW_MAPPER)); } catch (EmptyResultDataAccessException e) { return Optional.empty(); } } } ``` 在上面的代码中,我们自动装配了"NamedParameterJdbcTemplate",然后用它访问MySQL数据库: - 读请求使用db.query,配合RowMapper做类型转化 - 写请求使用db.update,配合KeyHolder获取自增主键 使用JDBC访问MySQL的方式,优点和缺点是完全一样的:使用显示的SQL语句操作数据库。 优点:直接、方便代码Review和性能检查 缺点:SQL编写过程繁琐、易错,特别是对于CRUD请求,效率较低 ================================================ FILE: src/ch02-ms-dev1/database2.md ================================================ # Spring Boot集成SQL数据库2 ## Spring Boot 集成 MyBatis操作MySQL MyBatis是一款半自动的ORM框架。由于某国内大厂的广泛使用,MyBatis在国内非常火热(在国外其热度不如Hibernate)。 首先还是集成依赖: ```groovy implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.0' implementation 'mysql:mysql-connector-java:8.0.20' ``` 套路与jdbc类似,但starter并不是官方的了,而是mybatis自己做的starter,感兴趣的可以来[这里](https://mvnrepository.com/artifact/org.mybatis.spring.boot/mybatis-spring-boot-starter/2.2.0)看下具体组成(会有惊喜)。 接下来是yaml配置环节: ```yaml spring.datasource: url: jdbc:mysql://127.0.0.1:3306/homs_demo?useUnicode=true&characterEncoding=UTF-8&useSSL=false username: HomsDemo password: 123456 hikari: minimumIdle: 10 maximumPoolSize: 100 # mybatis extra mybatis: configuration: map-underscore-to-camel-case: true type-aliases-package: com.coder4.homs.demo.server.mybatis.dataobject ``` 不难发现,数据库链接的定义复用了jdbc的那一套,MyBatis的定义分3行,如下: - configuration:开启驼峰规则转化 - type-aliases-package:mapper文件存放的包名 更多MyBatis的配置选项可以参考[这里]([mybatis-spring-boot-autoconfigure – Introduction](https://mybatis.org/spring-boot-starter/mybatis-spring-boot-autoconfigure/)) 接着,我们定义Mapper,在MyBatis中,Mapper相当于前面手写的Repository,定义如下: ```java package com.coder4.homs.demo.server.mybatis.mapper; import com.coder4.homs.demo.server.mybatis.dataobject.UserDO; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.springframework.stereotype.Repository; /** *

* Mapper 接口 *

* * @author author * @since 2021-09-09 */ @Repository @Mapper public interface UserMapper { @Insert("INSERT INTO users(name) VALUES(#{name})") @Options(useGeneratedKeys = true, keyProperty = "id") long create(UserDO user); @Select("SELECT * FROM users WHERE id = #{id}") UserDO getUser(@Param("id") Long id); @Select("SELECT * FROM users WHERE name = #{name}") UserDO getUserByName(@Param("name") String name); } ``` 你可能会奇怪:这不是接口(interface)么,并没有实现? 是的,通过定义@Repository和@Mapper,MyBatis会通过运行时的切面注入,帮我们自动实现,具体执行的SQL和映射,会读取@Select、@Options等注解中的配置。 经过上述介绍,你可以发现: MyBatis可以直接通过注解的方式快速访问数据库,(相对于JDBC的)精简了大量无用代码。 同时,MyBatis依然需要指定运行的SQL语句,这与JDBC的方式是一致的。虽然有些繁琐,但可以保证性能可控。 如果你在网上搜索"MyBatis Spring集成",会找到大量xml配置的用法。 在一些老项目中,xml是标准的集成方式。在这种配置方式下,配置繁琐、代码量大,即使借助"MyBatisX"等插件,也依然较为复杂。 因此,除非你要维护遗留的老项目代码,我都建议你使用(本文中)注解式集成MyBatis。 ## Spring Boot集成 JPA 操作MySQL JPA的全称是Java Persistence API,即持久化访问规范API。 Spring也提供了集成JPA的方案,称为 Spring Data JPA,其底层是通过Hibernate的JPA来实现的。 首先集成依赖: ```groovy implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'mysql:mysql-connector-java:8.0.20' ``` 与前面类似,不再重复介绍。 接着是配置: ```yaml # jdbc demo spring.datasource: url: jdbc:mysql://127.0.0.1:3306/homs_demo?useUnicode=true&characterEncoding=UTF-8&useSSL=false username: HomsDemo password: 123456 hikari: minimumIdle: 10 maximumPoolSize: 100 # jpa demo spring.jpa: database-platform: org.hibernate.dialect.MySQL8Dialect hibernate.ddl-auto: validate ``` 在MySQL连接上,我们依然复用了Spring DataSource的配置。 jpa侧的配置为: - database-platform:设置使用MySQL8语法 - hibernate.ddl-auto:只校验表,不回主动更新数据表的结构 接着,我们来定义实体(Entity): ```java @Entity @Data @Table(name = "users") public class UserEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; // @Column(name = "name") private String name; public User toUser() { User user = new User(); user.setId(id); user.setName(name); return user; } } ``` 这里我们将UserEntity与表"users"做了关联。 接下来是Repository: ```java @Repository public interface UserJPARepository extends CrudRepository { Collection findByName(String name); } ``` 我们继承了CrudRepository,他会自动生成针对UserEntity的CRUD操作。 此外,我们还定义了1个额外函数: - findByName,通过隐士语法规则,让JPA自动帮我们生成对应SQL 从直观感受上,JPA比MyBatis更加“高级” -- 一些简单的SQL都不用写了。 但天下真的有免费的馅饼么?我们先卖个关子。 ## JMJ应该选哪个 经过这两节的介绍,你已经掌握了JDBC、MyBatis、JPA三种操作数据库的方式。 在实战中,究竟要选哪个呢? 从易用性的角度来评估,我们可以得出结论:JPA > MyBatis > JDBC 那么从性能的角度来看呢? 我们使用wrk做了(get-by-id接口的)简单压测,结论如下: | | 读QPS | | ------- | ---- | | JDBC | 457 | | MyBatis | 445 | | JPA | 114 | 这里,你会惊讶的发现: - JDBC和MyBatis的性能差别不大,在5%以内 - JPA(Hibernate)的性能,居然只有其余两种方式的1/3 如此差的性能,真的让人百思不得其解,我尝试打印了SQL和执行耗时,并没有发现什么异常。 更进一步的,我们尝试用指定SQL的方式,替换了自动生成的接口,如下 ```java @Repository public interface UserJPARepository extends CrudRepository { @Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true) Optional findByIdFast(@Param("id") long id); } ``` 这次的压测结果是:447,性能基本和JDBC持平了。但是这种NativeSQL的用法并没有使用自动生成SQL的功能,没有发挥Hibernate本来的功效。 所以,我们认为,锅在于Hibernate自动生成SQL的逻辑耗时过大。 当然,Hibernate也不是一无是处,针对多层关联,建模复杂的场景,使用Entity做映射,会更加方便。 让我们回到前面的问题上:JMJ应该选哪个? - 如果对性能有极致要求,建议JDBC或者MyBatis。 - 如果建模场景复杂,嵌套密集,且对性能要求不高,可以选用Hibernate。 ================================================ FILE: src/ch02-ms-dev1/gradle.md ================================================ # Gradle构建工具配置 构建工具解决了依赖管理、打包流程、项目结构工程化等问题,是现代软件开发中的必备工具。 Gradle是一款Java开发语言的构建工具,兼容POM以来,使用Groovy作为描述语言,构建速度快、可拓展性强,是大量项目的首选。 在本节中,我们将介绍Gradle的基本用法与配置。 ## Gradle的下载与安装 我们使用稳定版7.2,你可以在[官网](https://gradle.org/releases/)下载二进制版本。 解压缩后,需要将二进制目录加入你的PATH路径: ```shell export PATH=$PATH:HOME/soft/gradle/bin/ ``` 然后执行gradle,查看是否安装成功 ```shell gradle -v ------------------------------------------------------------ Gradle 7.2 ------------------------------------------------------------ Build time: 2021-08-17 09:59:03 UTC Revision: a773786b58bb28710e3dc96c4d1a7063628952ad Kotlin: 1.5.21 Groovy: 3.0.8 Ant: Apache Ant(TM) version 1.10.9 compiled on September 27 2020 JVM: 1.8.0_291 (Oracle Corporation 25.291-b10) OS: Mac OS X 10.16 x86_64 ``` ## 修改Gradle的Maven仓库镜像 gradle的依赖使用了Maven的仓库。由于众所周知的原因,这些仓库在国内的速度并不稳定,我们需要将仓库切换成国内镜像。 修改~/.gradle/init.gradle文件如下: ``` // project allprojects{ repositories { mavenLocal() maven { url 'https://maven.aliyun.com/repository/public/' } maven { url 'https://maven.aliyun.com/repository/jcenter/' } maven { url 'https://maven.aliyun.com/repository/google/' } maven { url 'https://maven.aliyun.com/repository/gradle-plugin/' } maven { url 'https://jitpack.io/' } } } // plugin settingsEvaluated { settings -> settings.pluginManagement { // Clear repositories collection repositories.clear() // Add my Artifactory mirror repositories { mavenLocal() maven { url "https://maven.aliyun.com/repository/gradle-plugin/" } } } } ``` 解释下文件配置: - 上半部分:将maven中央仓库、jcenter仓库都修改为国内镜像(阿里云),并增加了jitpack仓库(后续章节会使用)。 - 下半部分:将gradle插件仓库修改为国内镜像,这部分是必须的,不要忘记。 我们可以通过一个简单的脚本,检查配置是否生效 验证脚本build.gradle ```groovy task listrepos { doLast { println "Repositories:" project.repositories.each { println "Name: " + it.name + "; url: " + it.url } } } ``` 执行验证: ``` gradle listrepos Repositories: Name: MavenLocal; url: file:/Users/coder4/.m2/repository/ Name: maven; url: https://maven.aliyun.com/repository/public/ Name: maven2; url: https://maven.aliyun.com/repository/jcenter/ Name: maven3; url: https://maven.aliyun.com/repository/google/ Name: maven4; url: https://maven.aliyun.com/repository/gradle-plugin/ Name: maven5; url: https://jitpack.io/ IntelliJ ``` ## gradle-wrapper生成 gradle-wrapper是用于执行gradle的脚本 + 精简版的gradle二进制文件。 既然已经有了gradle,为什么还要单独弄一个wrapper出来么? - 方便没有安装gradle的环境执行构建(例如打包机) - 支持多版本gradle的快速切换(实现nvm的效果) 初始化gradle项目时,执行如下命令: ```shell gradle init ``` gradle会生成如下wrapper相关文件: ```shell ├── gradle │   └── wrapper │   ├── gradle-wrapper.jar │   └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle ``` 建议将上述文件一并加入git仓库中,以防出现版本兼容问题。 ## IntelliJ IDEA中配置Gradle IntelliJ IDEA是一款功能强大的IDE,是许多Java程序员的首选。 IDEA默认支持Gradle,请确保配置正确: ![ ](./gradle-idea.png) 上方的Gradle配置文件默认路径,请维持默认配置,使用家目录下默认的。 下方的Gradle版本,推荐使用默认选项(gradle-wrapper.properties),即使用项目路径下gradle-wrapper.properties指定的版本。 经过上述配置,我们已经搭建了Gradle的构建环境。在下一节,我们会在此基础上集成Spring Boot框架。 ================================================ FILE: src/ch02-ms-dev1/redis.md ================================================ # Spring Boot集成Redis内存数据库 常规的业务数据,一般选择存储在SQL数据库中。 传统的SQL数据库基于磁盘存储,可以正常的流量需求。然而,在高并发应用场景中容易被拖垮,导致系统崩溃。 针对这种情况,我们可以通过增加缓存、使用NoSQL数据库等方式进行优化。 Redis是一款开源的内存NoSQL数据库,其稳定性高、[性能强悍]([How fast is Redis? – Redis](https://redis.io/topics/benchmarks)),是KV细分领域的[市场占有率冠军](https://db-engines.com/en/ranking/key-value+store)。 本节将介绍Redis与Spring Boot的集成方式。 ## Redis环境准备 与前文类似,我们使用Docker快速部署Redis服务器。 ```bash #!/bin/bash NAME="redis" PUID="1000" PGID="1000" VOLUME="$HOME/docker_data/redis" mkdir -p $VOLUME docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ --volume "$VOLUME":/data \ -p 6379:6379 \ --detach \ --restart always \ redis:6 \ redis-server --appendonly yes --requirepass redisdemo ``` 在上述脚本中: - 使用了最新的redis 6镜像 - 开启"appendonly"的持久化方式 - 启用密码"redisdemo" - 端口暴露为6379 我们尝试连接一下: ```bash redis-cli -h 127.0.0.1 -a redisdemo ``` 成功!(如果你没有redis-cli的可执行文件,可以到[官网下载](https://redis.io/download)) ## Redis的缓存使用 Spring提供了内置的Cache框架,可以通过@Cache注解,轻松实现redis Cache的功能。 首先引入依赖: ```groovy implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-json' implementation 'org.apache.commons:commons-pool2:2.11.0' ``` 上述依赖的作用分别为: - redis客户端:Spring Boot 2使用的是[lettuce](http://github.com/lettuce-io/lettuce-core) - json依赖:我们要使用jackson做json的序列化 / 反序列化 - commons-pool2线程池,这里其实是data-redis没处理好,需要额外加入,按理说应该集成在starter里的 接着我们在application.yaml中定义数据源: ```yaml # redis demo spring: redis: host: 127.0.0.1 port: 6379 password: "redisdemo" lettuce: pool: max-active: 50 min-idle: 5 ``` 接着我们需要设置自定义的Configuration: ```java package com.coder4.homs.demo.server.configuration; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.PropertyAccessor; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper.DefaultTyping; import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; import org.springframework.cache.annotation.CachingConfigurerSupport; import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.interceptor.KeyGenerator; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.cache.RedisCacheConfiguration; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext; import java.time.Duration; /** * @author coder4 */ @Configuration @EnableCaching public class RedisCacheCustomConfiguration extends CachingConfigurerSupport { @Bean public KeyGenerator keyGenerator() { return (target, method, params) -> { StringBuilder sb = new StringBuilder(); // sb.append(target.getClass().getName()); sb.append(target.getClass().getSimpleName()); sb.append(":"); sb.append(method.getName()); for (Object obj : params) { sb.append(obj.toString()); sb.append(":"); } sb.deleteCharAt(sb.length() - 1); return sb.toString(); }; } @Bean public RedisCacheConfiguration redisCacheConfiguration() { Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer(Object.class); ObjectMapper objectMapper = new ObjectMapper(); objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, DefaultTyping.NON_FINAL); // use json serde serializer.setObjectMapper(objectMapper); return RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(5)) // 5 mins ttl .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer)); } } ``` 上述主要包含两部分: - KeyGenerator可以根据Class + method + 参数 生成唯一的key名字,用于Redis中存储的key - RedisCacheConfiguration做了2处定制: - 更改了序列化方式,从默认的Java(Serilization更改为Jackson(json) - 缓存过期时间为5分钟 接着,我们在项目中使用Cache ```java public interface UserRepository { Optional create(User user); @Cacheable(value = "cache") Optional getUser(long id); Optional getUserByName(String name); } ``` 这里我们用了@Cache注解,"cache"是key的前缀 访问一下: ```bash curl http://127.0.0.1:8080/users/1 ``` 然后看一下redis ```bash redis-cli -a redisdemo > keys * > "cache::UserRepository1Impl:getUser1" > get "cache::UserRepository1Impl:getUser1" "[\"com.coder4.homs.demo.server.model.User\",{\"id\":1,\"name\":\"user1\"}]" > ttl "cache::UserRepository1Impl:getUser1" > 293 ``` 数据被成功缓存在了Redis中(序列化为json),并且会自动过期。 我们使用[Spring Boot集成SQL数据库2](./database2.md)一节中的压测脚本验证性能,QPS达到860,提升达80%。 在数据发生删除、更新时,你需要更新缓存,以确保一致性。推荐你阅读[缓存更新的套路]([缓存更新的套路 | 酷 壳 - CoolShell](https://coolshell.cn/articles/17416.html))。 在更新/删除方法上应用@CacheEvict(beforeInvocation=false),可以实现更新时删除的功能。 ## Redis的持久化使用 Redis不仅可以用作缓存,也可以用作持久化的存储。 首先请确认Redis已开启持久化: ``` 127.0.0.1:6379> config get save 1) "save" 2) "3600 1 300 100 60 10000" 127.0.0.1:6379> config get appendonly 1) "appendonly" 2) "yes" ``` 上述分别为rdb和aof的配置,有任意一个非空,即表示开启了持久化。 实际上,在我们集成Spring Data的时候,会自动配置RedisTemplte,使用它即可完成Redis的持久化读取。 不过默认配置的Template有一些缺点,我们需要做一些改造: ```java package com.coder4.homs.demo.server.configuration; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; /** * @author coder4 */ @Configuration public class RedisTemplateConfiguration { @Autowired public void decorateRedisTemplate(RedisTemplate redisTemplate) { RedisSerializer stringSerializer = new StringRedisSerializer(); redisTemplate.setKeySerializer(stringSerializer); redisTemplate.setKeySerializer(stringSerializer); redisTemplate.setValueSerializer(stringSerializer); redisTemplate.setHashKeySerializer(stringSerializer); redisTemplate.setHashValueSerializer(stringSerializer); } } ``` 如上所述,我们设置RedisTemplate的KV,分别采用String的序列化方式。 接着我们在代码中使用其存取Redis: ```java @Autowired private RedisTemplate redisTemplate; redisTemplate.boundValueOps("key").set("value"); ``` RedisTemplate的语法稍微有些奇怪,你也可以直接使用Conn来做操作,这样更加"Lettuce"。 ```java @Autowired private LettuceConnectionFactory leconnFactory; try (RedisConnection conn = leconnFactory.getConnection()) { conn.set("hehe".getBytes(), "haha".getBytes()); } ``` 至此,我们已经完成了Spring Boot 与 Redis的集成。 思考题:当一个微服务需要连接多组Redis,该如何集成呢? 请自己探索,并验证其正确性。 ================================================ FILE: src/ch02-ms-dev1/rpc.md ================================================ # Spring Boot集成gRPC框架 gRPC是谷歌开源的高性能、开源、通用RPC框架。由于gRPC基于HTTP2协议,所以其对移动端非常友好。 本节将介绍Spring Boot集成gRPC的服务端、客户端。 ### 安装protoc及gRPC gRPC默认使用[Protocol Buffers]([Protocol Buffers  |  Google Developers](https://developers.google.com/protocol-buffers))做为序列化协议,我们首先安装protoc编译器: 在这里下载最新版本的[protoc](https://github.com/protocolbuffers/protobuf/releases/tag/v3.17.3)编译器,请根据你的操作系统选择对应版本,这里我选用MacOSX的。 ```bash wget https://github.com/protocolbuffers/protobuf/releases/download/v3.17.3/protoc-3.17.3-osx-x86_64.zip unzip protoc-3.17.3-osx-x86_64.zip ``` 解压缩后,将其加入PATH路径下: ```bash export PATH=$PATH:$YOUR_PROTOC_PATH ``` 试一下是能否执行: ```bash protoc --version libprotoc 3.17.3 ``` 除此之外,我们还需要一个gRPC的Java插件,才能生成gRPC的桩代码,你可以在[这里]([Maven Central Repository Search](https://search.maven.org/search?q=a:protoc-gen-grpc-java))找到最新版本。这里我们依然选择OSX的64位版本: ```bash wget https://search.maven.org/remotecontent?filepath=io/grpc/protoc-gen-grpc-java/1.40.1/protoc-gen-grpc-java-1.40.1-osx-x86_64.exe ``` 下载后,将其加入PATH路径中。尝试定位一下: ```bash which protoc-gen-grpc-java Your_Path/protoc-gen-grpc-java ``` 至此,protoc和grpc的安装准备工作已经就绪。 ## Client侧集成 首先是集成依赖,我们放在client子项目的builld.gradle中: ```groovy implementation 'com.google.protobuf:protobuf-java:3.17.3' implementation "io.grpc:grpc-stub:1.39.0" implementation "io.grpc:grpc-protobuf:1.39.0" implementation 'io.grpc:grpc-netty-shaded:1.39.0' ``` 由于版本依赖较多,我建议使用platform统一管理,可以参考[前文](./spring-boot.md)。 接着,我们编写protoc文件,HomsDemo.proto: ```protobuf syntax = "proto3"; option java_package = "com.coder4.homs.demo"; option java_outer_classname = "HomsDemoProto"; ; message AddRequest { int32 val1 = 1; int32 val2 = 2; } message AddResponse { int32 val = 1; } message AddSingleRequest { int32 val = 1; } service HomsDemo { rpc Add(AddRequest) returns (AddResponse); rpc Add2(stream AddSingleRequest) returns (AddResponse); } ``` 我们添加了两个RPC方法: - Add是正常的调用 - Add2是单向Stream调用 接着,我们需要编译,生成桩文件: ```bash #!/bin/sh DIR=`cd \`dirname ${BASH_SOURCE[0]}\`/.. && pwd` protoc HomsDemo.proto --java_out=${DIR}/homs-demo-client/src/main/java --proto_path=${DIR}/homs-demo-client/src/main/java/com/coder4/homs/demo/ protoc HomsDemo.proto --plugin=protoc-gen-grpc-java=`which protoc-gen-grpc-java` --grpc-java_out=${DIR}/homs-demo-client/src/main/java --proto_path=${DIR}/homs-demo-client/src/main/java/com/coder4/homs/demo/ ``` 这里分为两个步骤: - 第一次protoc编译,生成protoc的桩文件 - 第二次protoc编译,使用了protoc-gen-grpc-java的插件,生成gRPC的服务端和客户端文件 编译成功后,路径如下: ```bash homs-demo-client ├── build.gradle └── src └── main └── java └── com └── coder4 └── homs └── demo ├── HomsDemo.proto ├── HomsDemoGrpc.java └── HomsDemoProto.java ``` 如上所示:HomsDemoProto是protoc的桩文件,HomsDemoGrpc是gRPC服务的桩文件。 下面我们来编写客户端代码,HomsDemoClient.java: ```java package com.coder4.homs.demo.client; import com.coder4.homs.demo.HomsDemoGrpc; import com.coder4.homs.demo.HomsDemoProto.AddRequest; import com.coder4.homs.demo.HomsDemoProto.AddResponse; import com.coder4.homs.demo.HomsDemoProto.AddSingleRequest; import io.grpc.Channel; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; import io.grpc.StatusRuntimeException; import io.grpc.stub.StreamObserver; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Arrays; import java.util.Collection; import java.util.Optional; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; /** * @author coder4 */ public class HomsDemoClient { private Logger LOG = LoggerFactory.getLogger(HomsDemoClient.class); private final HomsDemoGrpc.HomsDemoBlockingStub blockingStub; private final HomsDemoGrpc.HomsDemoStub stub; /** * Construct client for accessing HelloWorld server using the existing channel. */ public HomsDemoClient(Channel channel) { blockingStub = HomsDemoGrpc.newBlockingStub(channel); stub = HomsDemoGrpc.newStub(channel); } public Optional add(int val1, int val2) { AddRequest request = AddRequest.newBuilder().setVal1(val1).setVal2(val2).build(); AddResponse response; try { response = blockingStub.add(request); return Optional.ofNullable(response.getVal()); } catch (StatusRuntimeException e) { LOG.error("RPC failed: {0}", e.getStatus()); return Optional.empty(); } } public Optional add2(Collection vals) { try { CountDownLatch cdl = new CountDownLatch(1); AtomicLong respVal = new AtomicLong(); StreamObserver requestStreamObserver = stub.add2(new StreamObserver() { @Override public void onNext(AddResponse value) { respVal.set(value.getVal()); } @Override public void onError(Throwable t) { cdl.countDown(); } @Override public void onCompleted() { cdl.countDown(); } }); for (int val : vals) { requestStreamObserver.onNext(AddSingleRequest.newBuilder().setVal(val).build()); } requestStreamObserver.onCompleted(); try { cdl.await(1, TimeUnit.SECONDS); } catch (InterruptedException e) { e.printStackTrace(); } return Optional.ofNullable(respVal.intValue()); } catch (StatusRuntimeException e) { LOG.error("RPC failed: {0}", e.getStatus()); return Optional.empty(); } } } ``` 代码如上所示:Add还是相对简单的,但是使用了Stream的Add2就比较复杂了。 在上述代码中,需要传入Channel做为连接句柄,在假设知道IP和端口的情况下,可以如下构造: ```java String target = "127.0.0.1:5000"; ManagedChannel channel = null; try { channel = ManagedChannelBuilder .forTarget(target) .usePlaintext() .build(); } catch (Exception e) { LOG.error("open channel excepiton", e); return; } HomsDemoClient client = new HomsDemoClient(channel); ``` 在微服务架构下,实例众多,获取每个IP显得不太实际,我们会在后续章节介绍集成服务发现的Channel构造方案。 ## Server侧集成 老套路,首先是依赖集成: ```groovy implementation 'com.google.protobuf:protobuf-java:3.17.3' implementation "io.grpc:grpc-stub:1.39.0" implementation "io.grpc:grpc-protobuf:1.39.0" implementation 'io.grpc:grpc-netty-shaded:1.39.0' ``` 与上述客户端的集成完全一致。 接下来我们实现RPC的服务逻辑: ```java /** * @(#)HomsDemoImpl.java, 8月 12, 2021. *

* Copyright 2021 coder4.com. All rights reserved. * CODER4.COM PROPRIETARY/CONFIDENTIAL. Use is subject to license terms. */ package com.coder4.homs.demo.server.grpc; import com.coder4.homs.demo.HomsDemoGrpc.HomsDemoImplBase; import com.coder4.homs.demo.HomsDemoProto.AddRequest; import com.coder4.homs.demo.HomsDemoProto.AddResponse; import com.coder4.homs.demo.HomsDemoProto.AddSingleRequest; import io.grpc.stub.StreamObserver; /** * @author coder4 */ public final class HomsDemoGrpcImpl extends HomsDemoImplBase { @Override public void add(AddRequest request, StreamObserver responseObserver) { responseObserver.onNext(AddResponse.newBuilder() .setVal(request.getVal1() + request.getVal2()) .build()); responseObserver.onCompleted(); } @Override public StreamObserver add2(StreamObserver responseObserver) { return new StreamObserver() { int sum = 0; @Override public void onNext(AddSingleRequest value) { sum += value.getVal(); } @Override public void onError(Throwable t) { } @Override public void onCompleted() { responseObserver.onNext(AddResponse.newBuilder() .setVal(sum) .build()); sum = 0; responseObserver.onCompleted(); } }; } } ``` 这里要特别说明,因为gRPC都是异步回调的方式,所以其RPC在实现上有点反直觉: - 通过responseObserver.onNext返回调用结果 - 通过responseObserver.onCompleted结束调用 而add2方法,由于采用了Client-Streaming,所以实现会更加复杂一些。 实际上,gRPC支持[4种调用模式]([Generated-code reference | Java | gRPC](https://grpc.io/docs/languages/java/generated-code/)): - Unary: 客户端单输入,服务端单输出 - Client-Streaming: 客户端多输入,服务端单输出 - Server-Streaming: 客户端单输入,服务端多输出 - Bidirectional-Streaming: 客户端多输入,服务端多输出 由于篇幅所限,本文种只实现了前2种,推荐你手动实现另外的两种模式。 ================================================ FILE: src/ch02-ms-dev1/spring-boot.md ================================================ # Sprint Boot项目与Gradle的集成 本节我们将借助Spring Start快速搭建微服务项目。 在此基础上,我们会将工程改造成子项目的组织形式。 ## Spring Start快速生成项目 为了降低微服务的开发门槛,社区提供了[Spring initializr](https://start.spring.io/)工具。它可以一键生成微服务项目。如图所示: ![f](./spring-start.png) 我们需要注意几个配置: - Project(项目):选择Gradle - Language(开发语言):选择Java - Spring Boot(版本):选择2.5.4 - 下面的工程名、包名根据自己的需要填写 - Java(版本):选择8 完成后,点击下方的GENERATE(生成)按钮,即可下载项目的zip包。 解压缩后,目录结构如下: ```shell . ├── HELP.md ├── build.gradle ├── gradle │   └── wrapper │   ├── gradle-wrapper.jar │   └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src ├── main │   ├── java │   │   └── com │   │   └── coder4 │   │   └── homsdemo │   │   └── HomsDemoApplication.java │   └── resources │   ├── application.properties │   ├── static │   └── templates └── test └── java └── com └── coder4 └── homsdemo └── HomsDemoApplicationTests.java ``` 这是一个标准的gradle项目路径: - gradle*:gradle相关文件,可以参考[Gradle构建工具配置](./gradle.md)一节中的介绍 - src:项目源文件 - test:项目单元测试文件 我们来看一下src目录下唯一的Java源文件,HomsDemoApplication.java: ``` package com.coder4.homsdemo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class HomsDemoApplication { public static void main(String[] args) { SpringApplication.run(HomsDemoApplication.class, args); } } ``` 借助Spring Boot的精简设计,项目只需上述一个源文件即可服务端进程 编译项目: ```shell gradle build BUILD SUCCESSFUL in 19s 7 actionable tasks: 7 executed ``` 运行项目: ``` java -jar ./build/libs/homs-demo-0.0.1-SNAPSHOT.jar . ____ _ __ _ _ /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \ ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \ \\/ ___)| |_)| | | | | || (_| | ) ) ) ) ' |____| .__|_| |_|_| |_\__, | / / / / =========|_|==============|___/=/_/_/_/ :: Spring Boot :: (v2.5.4) 2021-09-08 12:47:51.906 INFO 2806 --- [ main] com.coder4.homsdemo.HomsDemoApplication : Starting HomsDemoApplication using Java 1.8.0_291 on coder4deMacBook-Pro.local with PID 2806 (/Users/coder4/Downloads/homs-demo/build/libs/homs-demo-0.0.1-SNAPSHOT.jar started by coder4 in /Users/coder4/Downloads/homs-demo) 2021-09-08 12:47:51.909 INFO 2806 --- [ main] com.coder4.homsdemo.HomsDemoApplication : No active profile set, falling back to default profiles: default 2021-09-08 12:47:52.960 INFO 2806 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http) 2021-09-08 12:47:52.975 INFO 2806 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat] 2021-09-08 12:47:52.975 INFO 2806 --- [ main] org.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/9.0.52] 2021-09-08 12:47:53.032 INFO 2806 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext 2021-09-08 12:47:53.032 INFO 2806 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 1067 ms 2021-09-08 12:47:53.413 INFO 2806 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path '' 2021-09-08 12:47:53.424 INFO 2806 --- [ main] com.coder4.homsdemo.HomsDemoApplication : Started HomsDemoApplication in 1.951 seconds (JVM running for 2.388) ``` 我们在浏览器打开 http:127.0.0.1:8080 已经可以成功打开了! 在微服务架构中,需要新建大量微服务。而Spring社区提供的Starter工具,降低了微服务的初始化门槛。在实际开发中,我们也可以结合实际情况,定制出适合自己团队的脚手架工具。 ## 子项目改造 上述脚手架生成的项目,是独立项目模式:一个目录下,只有一个独立项目。 在实际微服务开发中,一个目录下需要多组相互关联的子项目,例如: - protobuf和桩文件单独拆成子项目 - 常量提取到单独子项目 在本书的实战中,我们的微服务选用的是server / client 双子项目结构 - client:内置protobuf、桩文件,客户端代码、自动配置代码 - server:专注服务端逻辑开发 将Gradle项目拆分为子项目的功能,网上资料不多,自己摸索需要踩很多坑。 本文提供的也只是一种实现方式,你可以在此基础上,进行改造。 先看下整体目录结构: ```shell ./├── build.gradle ├── gradle │ └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── homs-demo-client │ ├── build.gradle │ └── src │ └── main │ └── java │ └── com │ └── coder4 │ └── homs │ └── demo │ ├── HomsDemo.proto │ ├── HomsDemoGrpc.java │ ├── HomsDemoProto.java │ └── client │ └── HomsDemoClient.java ├── homs-demo-server │ ├── build.gradle │ └── src │ ├── main │ │ ├── java │ │ │ └── com │ │ │ └── coder4 │ │ │ └── homs │ │ │ └── demo │ │ │ └── server │ │ │ ├── Application.java │ │ └── resources │ │ └── application.yaml │ └── test │ └── java │ └── com │ └── coder4 │ └── homs │ └── demo │ └── server │ └── Test.java └── settings.gradle ``` 如上图所述,我们在独立项目的基础上,改造如下: - 新增homs-demo-client / homs-demo-server 两个子项目 - 子项目内,额外添加了build.gradle文件 下面我们来看下gradle的相关配置 首先是根目录下的 settings.gradle ```groovy rootProject.name = 'homs-demo' include 'homs-demo-client' include 'homs-demo-server' ``` 如上所述,定义了项目名为"homs-demo",两个子项目"homs-demo-client" 和 "homs-demo-server"。 接着看一下根目录下的 build.gradle ```groovy plugins { id 'java' id 'idea' id 'org.springframework.boot' version '2.5.3' apply false id 'io.spring.dependency-management' version '1.0.11.RELEASE' apply false } subprojects { group = 'com.coder4' version = '0.0.1-SNAPSHOT' sourceCompatibility = '1.8' } ``` 这里的plugin部分,定义了4个插件: - java:java项目必选 - idea (Intellj IDEA):生成idea需要的文件 - org.springframework.boot:Spring Boot插件,支持构建可执行的server.jar - io.spring.dependency-management:Spring Boot相关版本的依赖管理 subprojects部分定义了所以子项目(server / client)的公用参数 - group / version 项目包名和版本 - sourceCompatibility:Java 8的语言版本 我们再来看一下client子项目 homs-demo-client/build.gradle ```groovy plugins { id 'java' id 'io.spring.dependency-management' } dependencies { implementation "org.slf4j:slf4j-api:1.7.32" } ``` 上述是client子项目的gradle配置,不难发现: - plugins:java、spring依赖 - dependencies:这里的配置等同于maven的pom.xml中的依赖配置,但gradle以冒号分割的语法更加简洁。这里只配置了一个slf4j。 再看下server子项目 ```groovy plugins { id 'java' id 'org.springframework.boot' id 'io.spring.dependency-management' } dependencies { implementation project(':homs-demo-client') implementation 'org.slf4j:slf4j-api:1.7.32' implementation 'org.springframework.boot:spring-boot-starter-web' } ``` server与client有所不同: - plugins:增加了spring boot插件 - dependencies:首先依赖了客户端子项目,接着依赖Spring Boot的web-starter。 你可能已经注意到了,在server的依赖中,并没有设定spring-boot-starter-web的版本。 Spring相关依赖的版本补全由'dependency-management'插件自动处理。当我们在项目根路径的build.gradle中,声明Spring Boot插件和Dependency Management时,就确定了所有子项目中,Spring依赖的版本。 经过上述改造,我们已经“基本”完成了子项目的改造。 ## 实现BOM功能 为什么我们说“基本”完成呢? 因为,子项目改造引入了新的问题: 若在client和server中,各自依赖slf4j但版本不同,会发生什么情况? 没错,这就是经典的“Maven依赖冲突”问题,关于背景和常见解法可以参考[这篇]([Solving Dependency Conflicts in Maven - DZone Java](https://dzone.com/articles/solving-dependency-conflicts-in-maven))文章。 依赖冲突问题的最根本解法是:让大家都依赖于相同的版本。在Maven中可以使用bom清单(bill of material):将所有公用包的版本都声明在bom文件中,然后其余项目都依赖bom。 Gradle并没有直接实现BOM,但在6.0+支持了platform机制。它可以实现与BOM类似的效果。 我们新建一个独立的项目,bom-homs settings.gradle ```groovy rootProject.name = 'bom-homs' ``` 这里声明了bom的名字 build.gradle ```groovy plugins { id 'java-platform' id 'maven-publish' } group 'com.coder4' version '1.0' dependencies { constraints { api 'org.slf4j:slf4j-api:1.7.32' } } publishing { publications { myPlatform(MavenPublication) { from components.javaPlatform } } } ``` 上述配置的解析如下: - plugins:platform和maven发布插件 - group、version:maven中同等概念,一会用到 - dependencies:公用包的版本声明,这里只又一个slf4j - publishing:这里借用了Maven的发布方式 下面我们执行发布(到本地): ```shell gradle publishToMavenLocal BUILD SUCCESSFUL in 704ms 3 actionable tasks: 3 executed ``` (这里我们暂时发布到本地,如何发布到远程、私有仓库,将在后续章节再介绍。) 成功发布后,我们回到homs-demo项目中,将server的子项目改造如下: ```groovy plugins { id 'java' id 'org.springframework.boot' id 'io.spring.dependency-management' } dependencies { implementation project(':homs-demo-client') implementation platform('com.coder4:bom-homs:1.0') implementation 'org.slf4j:slf4j-api' implementation 'org.springframework.boot:spring-boot-starter-web' } ``` 通过引入platform,我们就无需在项目中指明slf4j的版本了,从而在源头上解决了版本冲突的问题! 针对client子项目,也是类似的修改,这里不做赘述。 至此,我们完成Gradle与Spring Boot的集成、子项目拆分。 关于“Spring Boot + Gradle子项目”的资料,在网上并不多见,希望你能仔细阅读、反复揣摩、举一反三:-) 本文涉及的项目代码,我整理到了[这里](https://github.com/liheyuan/homs-demo),供大家参考。 ================================================ FILE: src/ch03-ms-dev2/README.md ================================================ # 微服务开发中篇:微服务的注册与发现、配置中心、消息队列、稳定性 你可能留意到,在"微服务上篇"的讨论中,我们介绍的RPC、数据库等内容,都局限于单机环境,并没有真正涉及“分布式”。 在本章,我们将"真正的"进入分布式的微服务实战开发。 在微服务的架构下,经过服务的拆分,会形成复杂的服务调用关系,例如A调用B,B调用C....调用Z。同时,出于性能考虑,每一个服务X可能由若干个实例组成。如此庞大的实例数量,如果依靠手工配置来管理,是一个不可能完成的任务。为此,我们需要引入微服务的注册中心。 我们将基于Nacos来实现服务的注册与发现:Nacos的基本用法、服务端的自动注册,客户端的自动发现、装配。 Nacos不仅是服务管理平台,也提供了配置管理的功能,我们将基于此实现微服务的配置中心。 消息队列是应用接耦、流量消峰的利器,我们将介绍Rocket MQ的基础概念,并将其集成进开发框架中。 保证微服务的稳定性有三大法宝:“熔断、限流、降级”。在本章的最后,我们将引入轻量但强大的resilience4j,为微服务保驾护航。 ================================================ FILE: src/ch03-ms-dev2/circuit-breaker-and-limiter.md ================================================ # Spring Boot集成熔断、限流、降级 在引入resilience4j之前,我们先来讨论下服务稳定性的三大法宝。 - 降级:在有限资源情况下,为了应对超负荷流量,适当放弃一些功能,以保证服务的整体稳定性。例如:双十一大促时,关闭个性化推荐。 - 限流:为了应对突发流量,只允许一部分请求通过,放弃其余请求。例如:当前服务忙,请稍后再试。 - 熔断:这个概念最早源于物理学。 - 在电路中,若电流过大,熔断器(保险丝 / 空气开关)会发生熔断,切断线路,以保证用电安全。 - 在微服务架构中,若服务调用发生大量错误(超时),可以直接将微服务降级,以保证服务的整体稳定性。 Resillence4j是一款轻量级、易用的"容错框架",提供了保证稳定性所需的几大基础组件: - Retry:重试 - Circuit Breaker:基于Ring Buffer的熔断器,根据失败率/次数,自动切换熔断器的开关状态。 - Rate Limiter:基于AtomicReference + 状态机 实现的限流器 - Time Limiter:基于限时Future / CompletationStage的时限器 - Bulk Head:基于信号量 / 线程池的壁仓隔离。 - Cache / Fallback:为上述组件提供降级时的包装函数 Resillence4j支持Java、注解等多种使用方法,我们这里选用最方便的Spring Boot注解方法。 ## Circuit Breaker 首先来看一下熔断器,它内置了如下三种状态: - CLOSE:初始状态,熔断器关闭,服务正常运行。 - OPEN:发生大量错误后,熔断器打开,直接返回降级结果,不再调用真实服务逻辑。 - HALF OPEN:OPEN一段时间后,小流量放开访问,看真实逻辑部分是否恢复正常。如果恢复,会切换到CLOSE状态。 老规矩,先添加依赖: ```groovy implementation 'io.github.resilience4j:resilience4j-all:1.7.1' implementation 'io.github.resilience4j:resilience4j-spring-boot2:1.7.1' ``` 说明如下: - 由于后续几个组件都会使用,我们这里直接使用了all,你可以根据实际情况,裁剪需要的组件。 - spring-boot:添加了对应的注解和自动配置。 熔断器的配置如下: ```yaml resilience4j: circuitbreaker: instances: getUserById: registerHealthIndicator: true slidingWindowSize: 100 failureRateThreshold: 50 ``` 说明如下: - 熔断器名称是getUserById - 滑动窗口大小100 - 失败(熔断)阀值是50% 代码用法如下: ```java @Override @CircuitBreaker(name = "getUser", fallbackMethod = "getUserByIdFallback") public Optional getUserById(long id) { // Mock a failure if (ThreadLocalRandom.current().nextInt(100) < 90) { throw new RuntimeException("mock failure"); } return userRepository.getUser(id); } public Optional getUserByIdFallback(long id, Throwable e) { LOG.error("enter fallback for getUserById", e); return Optional.empty(); } ``` 在上述代码中,我们以90%的概率模拟了随机异常。 当熔断发生时,会使用getUserByIdFallback中的降级结果。 执行几次后,会出现类似如下的错误日志,熔断器已成功开启。 ```shell 2021-10-09 01:34:32.156 ERROR 2214 --- [o-8080-exec-144] c.c.h.d.s.service.impl.UserServiceImpl : enter fallback for getUserById io.github.resilience4j.circuitbreaker.CallNotPermittedException: CircuitBreaker 'getUser' is OPEN and does not permit further calls at io.github.resilience4j.circuitbreaker.CallNotPermittedException.createCallNotPermittedException(CallNotPermittedException.java:48) ~[resilience4j-circuitbreaker-1.7.1.jar:1.7.1] at io.github.resilience4j.circuitbreaker.internal.CircuitBreakerStateMachine$OpenState.acquirePermission(CircuitBreakerStateMachine.java:696) ~[resilience4j-circuitbreaker-1.7.1.jar:1.7.1] at io.github.resilience4j.circuitbreaker.internal.CircuitBreakerStateMachine.acquirePermission(CircuitBreakerStateMachine.java:206) ~[resilience4j-circuitbreaker-1.7.1.jar:1.7.1] at io.github.resilience4j.circuitbreaker.CircuitBreaker.lambda$decorateCheckedSupplier$82a9021a$1(CircuitBreaker.java:70) ~[resilience4j-circuitbreaker-1.7.1.jar:1.7.1] at io.github.resilience4j.circuitbreaker.CircuitBreaker.executeCheckedSupplier(CircuitBreaker.java:834) ~[resilience4j-circuitbreaker-1.7.1.jar:1.7.1] at io.github.resilience4j.circuitbreaker.configure.CircuitBreakerAspect.defaultHandling(CircuitBreakerAspect.java:188) ~[resilience4j-spring-1.7.1.jar:1.7.1] at io.github.resilience4j.circuitbreaker.configure.CircuitBreakerAspect.proceed(CircuitBreakerAspect.java:135) ~[resilience4j-spring-1.7.1.jar:1.7.1] at io.github.resilience4j.circuitbreaker.configure.CircuitBreakerAspect.lambda$circuitBreakerAroundAdvice$6edadc33$1(CircuitBreakerAspect.java:118) ~[resilience4j-spring-1.7.1.jar:1.7.1] at io.github.resilience4j.fallback.DefaultFallbackDecorator.lambda$decorate$52452fd9$1(DefaultFallbackDecorator.java:36) ~[resilience4j-spring-1.7.1.jar:1.7.1] at io.github.resilience4j.circuitbreaker.configure.CircuitBreakerAspect.circuitBreakerAroundAdvice(CircuitBreakerAspect.java:118) ~[resilience4j-spring-1.7.1.jar:1.7.1] at sun.reflect.GeneratedMethodAccessor127.invoke(Unknown Source) ~[na:na] at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_291] at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_291] at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethodWithGivenArgs(AbstractAspectJAdvice.java:634) [spring-aop-5.3.9.jar:5.3.9] at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethod(AbstractAspectJAdvice.java:624) [spring-aop-5.3.9.jar:5.3.9] at org.springframework.aop.aspectj.AspectJAroundAdvice.invoke(AspectJAroundAdvice.java:72) [spring-aop-5.3.9.jar:5.3.9] at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:175) [spring-aop-5.3.9.jar:5.3.9] at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:750) [spring-aop-5.3.9.jar:5.3.9] at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97) [spring-aop-5.3.9.jar:5.3.9] at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) [spring-aop-5.3.9.jar:5.3.9] at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:750) [spring-aop-5.3.9.jar:5.3.9] at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:692) [spring-aop-5.3.9.jar:5.3.9] at com.coder4.homs.demo.server.service.impl.UserServiceImpl$$EnhancerBySpringCGLIB$$19b58f1b.getUserById() [main/:na] at com.coder4.homs.demo.server.web.logic.impl.UserLogicImpl.getUserById(UserLogicImpl.java:51) [main/:na] at com.coder4.homs.demo.server.web.ctrl.UserController.getById(UserController.java:36) [main/:na] at sun.reflect.GeneratedMethodAccessor113.invoke(Unknown Source) ~[na:na] at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_291] at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_291] at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:197) [spring-web-5.3.9.jar:5.3.9] at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:141) [spring-web-5.3.9.jar:5.3.9] at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:106) [spring-webmvc-5.3.9.jar:5.3.9] at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:895) [spring-webmvc-5.3.9.jar:5.3.9] at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:808) [spring-webmvc-5.3.9.jar:5.3.9] at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) [spring-webmvc-5.3.9.jar:5.3.9] at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1064) [spring-webmvc-5.3.9.jar:5.3.9] at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:963) [spring-webmvc-5.3.9.jar:5.3.9] at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006) [spring-webmvc-5.3.9.jar:5.3.9] at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898) [spring-webmvc-5.3.9.jar:5.3.9] at javax.servlet.http.HttpServlet.service(HttpServlet.java:655) [tomcat-embed-core-9.0.50.jar:4.0.FR] at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883) [spring-webmvc-5.3.9.jar:5.3.9] at javax.servlet.http.HttpServlet.service(HttpServlet.java:764) [tomcat-embed-core-9.0.50.jar:4.0.FR] at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:228) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53) [tomcat-embed-websocket-9.0.50.jar:9.0.50] at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) [spring-web-5.3.9.jar:5.3.9] at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) [spring-web-5.3.9.jar:5.3.9] at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) [spring-web-5.3.9.jar:5.3.9] at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) [spring-web-5.3.9.jar:5.3.9] at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.springframework.boot.actuate.metrics.web.servlet.WebMvcMetricsFilter.doFilterInternal(WebMvcMetricsFilter.java:96) [spring-boot-actuator-2.5.3.jar:2.5.3] at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) [spring-web-5.3.9.jar:5.3.9] at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) [spring-web-5.3.9.jar:5.3.9] at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) [spring-web-5.3.9.jar:5.3.9] at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:202) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:97) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:542) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:143) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:78) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:357) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:382) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:893) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1723) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49) [tomcat-embed-core-9.0.50.jar:9.0.50] at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149) [na:1.8.0_291] at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) [na:1.8.0_291] at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61) [tomcat-embed-core-9.0.50.jar:9.0.50] at java.lang.Thread.run(Thread.java:748) [na:1.8.0_291] ``` ## Bulkhead & TimeLimiter 下面我们来看一下实线器,即限定必须在X时间内执行完毕,否则抛出异常。 Resillence4j的TimeLimiter设计中,并没有内置线程池,而是要业务代码自行处理。我们可以结合Bulkhead的线程池模式一同使用,首先配置如下: ```yaml resilience4j: thread-pool-bulkhead: instances: getUserByName: maxThreadPoolSize: 100 coreThreadPoolSize: 50 queueCapacity: 200 timelimiter: instances: getUserByName: timeoutDuration: 1s cancelRunningFuture: true ``` 如上所述,我们配置了线程池,并设置时限为1秒。 接着看一下用法: ```java @Override @Bulkhead(name = "getUserByName", type = Type.THREADPOOL) @TimeLimiter(name = "getUserByName", fallbackMethod = "getUserByNameWithCompletableFutureFallback") public CompletableFuture> getUserByNameWithCompletableFuture(String name) { // Mock timeout Try.run(() -> Thread.sleep(ThreadLocalRandom.current().nextInt(2000))); return CompletableFuture.completedFuture(userRepository.getUserByName(name)); } public CompletableFuture> getUserByNameWithCompletableFutureFallback(String name, Throwable e) { LOG.error("enter fallback for getUserByNameFallback", e); return CompletableFuture.completedFuture(Optional.empty()); } ``` 我们模拟了随机超时时间,当超过1秒时,会自动抛出如下的降级异常,并走降级逻辑。 ```shell 2021-10-09 01:53:32.637 ERROR 4890 --- [pool-7-thread-1] c.c.h.d.s.service.impl.UserServiceImpl : enter fallback for getUserByNameFallback java.util.concurrent.TimeoutException: TimeLimiter 'getUserByName' recorded a timeout exception. at io.github.resilience4j.timelimiter.TimeLimiter.createdTimeoutExceptionWithName(TimeLimiter.java:221) ~[resilience4j-timelimiter-1.7.1.jar:1.7.1] at io.github.resilience4j.timelimiter.internal.TimeLimiterImpl$Timeout.lambda$of$0(TimeLimiterImpl.java:185) ~[resilience4j-timelimiter-1.7.1.jar:1.7.1] at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511) ~[na:1.8.0_291] at java.util.concurrent.FutureTask.run(FutureTask.java:266) [na:1.8.0_291] at java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.access$201(ScheduledThreadPoolExecutor.java:180) [na:1.8.0_291] at java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:293) [na:1.8.0_291] at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149) [na:1.8.0_291] at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) [na:1.8.0_291] at java.lang.Thread.run(Thread.java:748) [na:1.8.0_291] ``` ## RateLimiter 最后我们来看一下限流器,配置如下: ```yaml resilience4j: rateLimiter: instances: getUserByIdV2: limitForPeriod: 1 limitRefreshPeriod: 500ms timeoutDuration: 0 ``` 设置了每0.5秒限1个请求,用法如下: ```java @Override @RateLimiter(name = "getUserByIdV2", fallbackMethod = "getUserByIdV2Fallback") public Optional getUserByIdV2(long id) { return Optional.ofNullable(userMapper.getUser(id)).map(UserDO::toUser); } public Optional getUserByIdV2Fallback(long id, Throwable e) { LOG.error("getUserByIdV2 fallback exception", e); return Optional.empty(); } ``` 当快速访问两次接口后,会抛出如下的异常,并返回降级结果。 ```shell 2021-10-09 14:00:13.564 ERROR 5598 --- [nio-8080-exec-8] c.c.h.d.s.service.impl.UserServiceImpl : getUserByIdV2 fallback exception io.github.resilience4j.ratelimiter.RequestNotPermitted: RateLimiter 'getUserByIdV2' does not permit further calls at io.github.resilience4j.ratelimiter.RequestNotPermitted.createRequestNotPermitted(RequestNotPermitted.java:43) ~[resilience4j-ratelimiter-1.7.1.jar:1.7.1] at io.github.resilience4j.ratelimiter.RateLimiter.waitForPermission(RateLimiter.java:591) ~[resilience4j-ratelimiter-1.7.1.jar:1.7.1] at io.github.resilience4j.ratelimiter.RateLimiter.lambda$decorateCheckedSupplier$9076412b$1(RateLimiter.java:213) ~[resilience4j-ratelimiter-1.7.1.jar:1.7.1] at io.github.resilience4j.ratelimiter.RateLimiter.executeCheckedSupplier(RateLimiter.java:898) ~[resilience4j-ratelimiter-1.7.1.jar:1.7.1] at io.github.resilience4j.ratelimiter.RateLimiter.executeCheckedSupplier(RateLimiter.java:884) ~[resilience4j-ratelimiter-1.7.1.jar:1.7.1] at io.github.resilience4j.ratelimiter.configure.RateLimiterAspect.handleJoinPoint(RateLimiterAspect.java:179) ~[resilience4j-spring-1.7.1.jar:1.7.1] at io.github.resilience4j.ratelimiter.configure.RateLimiterAspect.proceed(RateLimiterAspect.java:142) ~[resilience4j-spring-1.7.1.jar:1.7.1] at io.github.resilience4j.ratelimiter.configure.RateLimiterAspect.lambda$rateLimiterAroundAdvice$749d37c4$1(RateLimiterAspect.java:125) ~[resilience4j-spring-1.7.1.jar:1.7.1] at io.github.resilience4j.fallback.DefaultFallbackDecorator.lambda$decorate$52452fd9$1(DefaultFallbackDecorator.java:36) ~[resilience4j-spring-1.7.1.jar:1.7.1] at io.github.resilience4j.ratelimiter.configure.RateLimiterAspect.rateLimiterAroundAdvice(RateLimiterAspect.java:125) ~[resilience4j-spring-1.7.1.jar:1.7.1] at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_291] at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_291] at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_291] at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_291] at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethodWithGivenArgs(AbstractAspectJAdvice.java:634) ~[spring-aop-5.3.9.jar:5.3.9] at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethod(AbstractAspectJAdvice.java:624) ~[spring-aop-5.3.9.jar:5.3.9] at org.springframework.aop.aspectj.AspectJAroundAdvice.invoke(AspectJAroundAdvice.java:72) [spring-aop-5.3.9.jar:5.3.9] at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:175) [spring-aop-5.3.9.jar:5.3.9] at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:750) [spring-aop-5.3.9.jar:5.3.9] at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97) [spring-aop-5.3.9.jar:5.3.9] at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) [spring-aop-5.3.9.jar:5.3.9] at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:750) [spring-aop-5.3.9.jar:5.3.9] at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:692) [spring-aop-5.3.9.jar:5.3.9] at com.coder4.homs.demo.server.service.impl.UserServiceImpl$$EnhancerBySpringCGLIB$$cba2db53.getUserByIdV2() [main/:na] at com.coder4.homs.demo.server.web.logic.impl.UserLogicImpl.getUserByIdV2(UserLogicImpl.java:80) [main/:na] at com.coder4.homs.demo.server.web.ctrl.UserController.getByIdV2(UserController.java:51) [main/:na] at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_291] at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_291] at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_291] at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_291] at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:197) [spring-web-5.3.9.jar:5.3.9] at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:141) [spring-web-5.3.9.jar:5.3.9] at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:106) [spring-webmvc-5.3.9.jar:5.3.9] at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:895) [spring-webmvc-5.3.9.jar:5.3.9] at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:808) [spring-webmvc-5.3.9.jar:5.3.9] at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) [spring-webmvc-5.3.9.jar:5.3.9] at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1064) [spring-webmvc-5.3.9.jar:5.3.9] at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:963) [spring-webmvc-5.3.9.jar:5.3.9] at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006) [spring-webmvc-5.3.9.jar:5.3.9] at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898) [spring-webmvc-5.3.9.jar:5.3.9] at javax.servlet.http.HttpServlet.service(HttpServlet.java:655) [tomcat-embed-core-9.0.50.jar:4.0.FR] at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883) [spring-webmvc-5.3.9.jar:5.3.9] at javax.servlet.http.HttpServlet.service(HttpServlet.java:764) [tomcat-embed-core-9.0.50.jar:4.0.FR] at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:228) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53) [tomcat-embed-websocket-9.0.50.jar:9.0.50] at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) [spring-web-5.3.9.jar:5.3.9] at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) [spring-web-5.3.9.jar:5.3.9] at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) [spring-web-5.3.9.jar:5.3.9] at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) [spring-web-5.3.9.jar:5.3.9] at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.springframework.boot.actuate.metrics.web.servlet.WebMvcMetricsFilter.doFilterInternal(WebMvcMetricsFilter.java:96) [spring-boot-actuator-2.5.3.jar:2.5.3] at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) [spring-web-5.3.9.jar:5.3.9] at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) [spring-web-5.3.9.jar:5.3.9] at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) [spring-web-5.3.9.jar:5.3.9] at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:202) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:97) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:542) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:143) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:78) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:357) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:382) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:893) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1723) [tomcat-embed-core-9.0.50.jar:9.0.50] at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49) [tomcat-embed-core-9.0.50.jar:9.0.50] at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149) [na:1.8.0_291] at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) [na:1.8.0_291] at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61) [tomcat-embed-core-9.0.50.jar:9.0.50] at java.lang.Thread.run(Thread.java:748) [na:1.8.0_291] ``` 至此,我们已经熟悉了Resillence4j中的主要组件,并覆盖了yaml中的常见的配置。 更多配置选项,可以参考[这篇文档](https://resilience4j.readme.io/docs/getting-started-3)。 由于篇幅限制,本文并未涉及Retry、Cache两大组件,推荐你阅读[官方文档](https://resilience4j.readme.io/docs/retry)自行探索。 ================================================ FILE: src/ch03-ms-dev2/config.md ================================================ # Spring Boot集成配置中心 Nacos不仅提供了服务的注册与发现,也提供了配置管理的功能。 本节,我们继续使用Nacos,基于其配置管理的功能,实现微服务的配置中心。 首先,我们在Nacos上,新建两个配置: ![f](./nacos-config.png) 如上图所示: - Nacos提供了dataId、group两个字段,用于区分不同的配置 - 我们在group字段填充微服务的名称,例如homs-demo - 我们在dataId字段填写配置的key - Nacos的支持简单的类型检验,例如json、数值、字符串等,但只限于前端校验,存储后多统一为字符串类型 有了配置后,我们来实现Nacos配置管理的驱动部分: ```java public interface NacosConfigService { Optional getConfig(String serviceName, String key); void onChange(String serviceName, String key, Consumer> consumer); } ``` ```java package com.coder4.homs.demo.server.service.impl; import com.alibaba.nacos.api.NacosFactory; import com.alibaba.nacos.api.config.ConfigService; import com.alibaba.nacos.api.config.listener.Listener; import com.alibaba.nacos.api.exception.NacosException; import com.coder4.homs.demo.server.service.spi.NacosConfigService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import javax.annotation.PostConstruct; import java.util.Optional; import java.util.concurrent.Executor; import java.util.function.Consumer; /** * @author coder4 */ @Service public class NacosConfigServiceImpl implements NacosConfigService{ private static final Logger LOG = LoggerFactory.getLogger(NacosConfigServiceImpl.class); @Value("${nacos.server}") private String nacosServer; private ConfigService configService; @PostConstruct public void postConstruct() throws NacosException { configService = NacosFactory .createConfigService(nacosServer); } @Override public Optional getConfig(String serviceName, String key) { try { return Optional.ofNullable(configService.getConfig(key, serviceName, 5000)); } catch (NacosException e) { LOG.error("nacos get config exception for " + serviceName + " " + key, e); return Optional.empty(); } } @Override public void onChange(String serviceName, String key, Consumer> consumer) { try { configService.addListener(key, serviceName, new Listener() { @Override public Executor getExecutor() { return null; } @Override public void receiveConfigInfo(String configInfo) { consumer.accept(Optional.ofNullable(configInfo)); } }); } catch (NacosException e) { LOG.error("nacos add listener exception for " + serviceName + " " + key, e); throw new RuntimeException(e); } } } ``` 上述驱动部分,主要实现了两个功能: - 通过getConfig方法,同步拉取配置 - 通过onChange方法,添加异步监听器,当配置发生改变时,会执行回调 ## 配置的自动注解与更新 我们希望实现一个更加“易用”的配置中心,期望具有如下特性: - 通过注解的方式,自动将类中的字段"绑定"到远程Nacos配置中心对应字段上,并自动初始化。 - 当Nacos配置更新后,本地同步进行修改。 - 支持类型的自动转换 第一步,我们声明注解: ```java package com.coder4.homs.demo.server.annotation; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface HSConfig { String name() default ""; String serviceName() default ""; } ``` 上述关键字段的用途是: - name,远程fdc指定的配置名称,可选,若未填写则使用注解应用的原始字段名。 - serviceName,远程fdc指定的服务名称,可选,若未填写则使用当前本地服务名。 接着,我们借助BeanPostProcessor,来对打了HSConfig注解的字段,进行值注入。 ```java package com.coder4.homs.demo.server.processor; import com.alibaba.nacos.common.utils.StringUtils; import com.coder4.homs.demo.server.HsReflectionUtils; import com.coder4.homs.demo.server.annotation.HSConfig; import com.coder4.homs.demo.server.service.spi.NacosConfigService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.aop.support.AopUtils; import org.springframework.beans.BeansException; import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.core.Ordered; import org.springframework.data.util.ReflectionUtils.AnnotationFieldFilter; import org.springframework.util.ReflectionUtils; import org.springframework.util.ReflectionUtils.FieldFilter; import java.lang.reflect.Field; import java.util.Optional; /** * @author coder4 */ public class HsConfigFieldProcessor implements BeanPostProcessor, Ordered { private static final Logger LOG = LoggerFactory.getLogger(HsConfigFieldProcessor.class); private static final FieldFilter HS_CONFIG_FIELD_FILTER = new AnnotationFieldFilter(HSConfig.class); private NacosConfigService nacosConfigService; private String serviceName; public HsConfigFieldProcessor(NacosConfigService service, String serviceName) { this.nacosConfigService = service; this.serviceName = serviceName; } @Override public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { Class targetClass = AopUtils.getTargetClass(bean); ReflectionUtils.doWithFields( targetClass, field -> processField(bean, field), HS_CONFIG_FIELD_FILTER); return bean; } private void processField(Object bean, Field field) { HSConfig valueAnnotation = field.getDeclaredAnnotation(HSConfig.class); // 优先注解,其次本地代码 String key = StringUtils.defaultIfEmpty(valueAnnotation.name(), field.getName()); String serviceName = StringUtils.defaultIfEmpty(valueAnnotation.serviceName(), this.serviceName); Optional valueOp = nacosConfigService.getConfig(serviceName, key); try { if (!valueOp.isPresent()) { LOG.error("nacos config for serviceName = {} key = {} is empty", serviceName, key); } HsReflectionUtils.setField(bean, field, valueOp.get()); // Future Change nacosConfigService.onChange(serviceName, key, valueOp2 -> { try { HsReflectionUtils.setField(bean, field, valueOp2.get()); } catch (IllegalAccessException e) { LOG.error("nacos config for serviceName = {} key = {} exception", e); } }); } catch (IllegalAccessException e) { LOG.error("setField for " + field.getName() + " exception", e); throw new RuntimeException(e.getMessage()); } } @Override public int getOrder() { return LOWEST_PRECEDENCE; } } ``` 上述代码比较复杂,我们逐步讲解: - 构造函数传入nacosConfigService用于操作nacos配置管理接口 - 构造函数传入的serviceName做为默认的服务名 - postProcessBeforeInitialization方法,会在Bean构造前执行,通过ReflectionUtils来过滤所有打了@HsConfig注解的字段,逐一处理,流程如下: - 首先获取要绑定的服务名、字段名,遵循注解优于本地的顺序 - 调用nacosServer拉取当前配置,并通过HsReflectionUtils工具的反射的注入到字段中。 - 添加回调,以便未来更新时,及时修改本地变量。 HsReflectionUtils中涉及类型的自动转换,代码如下: ```java package com.coder4.homs.demo.server.utils; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import java.lang.reflect.Field; /** * @author coder4 */ public class HsReflectionUtils { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); public static void setField(Object bean, Field field, String valueStr) throws IllegalAccessException { field.setAccessible(true); Class fieldType = field.getType(); if (fieldType == Integer.TYPE || fieldType == Integer.class) { field.set(bean, Integer.parseInt(valueStr)); } else if (fieldType == Long.TYPE || fieldType == Long.class) { field.set(bean, Long.parseLong(valueStr)); } else if (fieldType == Short.TYPE || fieldType == Short.class) { field.set(bean, Short.parseShort(valueStr)); } else if (fieldType == Double.TYPE || fieldType == Double.class) { field.set(bean, Double.parseDouble(valueStr)); } else if (fieldType == Float.TYPE || fieldType == Float.class) { field.set(bean, Float.parseFloat(valueStr)); } else if (fieldType == Byte.TYPE || fieldType == Byte.class) { field.set(bean, Byte.parseByte(valueStr)); } else if (fieldType == Boolean.TYPE || fieldType == Boolean.class) { field.set(bean, Boolean.parseBoolean(valueStr)); } else if (fieldType == Character.TYPE || fieldType == Character.class) { if (valueStr == null || valueStr.isEmpty()) { throw new IllegalArgumentException("can't parse char because value string is empty"); } field.set(bean, valueStr.charAt(0)); } else if (fieldType.isEnum()) { field.set(bean, Enum.valueOf(fieldType, valueStr)); } else { try { field.set(bean, OBJECT_MAPPER.readValue(valueStr, fieldType)); } catch (JsonProcessingException e) { throw new IllegalArgumentException("can't parse json because exception"); } } } } ``` 上述代码中,针对field的类型逐一判断,针对八大基本类型,直接parse,针对复杂类型,使用json反序列化的方式注入。 ## 自动配置的使用 有了上述的基础后,我们还需要添加自动配置类,让其生效: ```java package com.coder4.homs.demo.server.configuration; import com.coder4.homs.demo.constant.HomsDemoConstant; import com.coder4.homs.demo.server.processor.HsConfigFieldProcessor; import com.coder4.homs.demo.server.service.spi.NacosConfigService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * @author coder4 */ @Configuration public class HsConfigProcessorConfiguration { @Bean @ConditionalOnMissingBean(HsConfigFieldProcessor.class) public HsConfigFieldProcessor fieldProcessor(@Autowired NacosConfigService configService) { return new HsConfigFieldProcessor(configService, HomsDemoConstant.SERVICE_NAME); } } ``` 使用时非常简单: ```java @Service public class HomsDemoConfig { @HSConfig private int num; @HSConfig(name = "mapConfig") private Map map; @PostConstruct public void postConstruct() { System.out.println(num); System.out.println(map); } } ``` 只需要添加HSConfig注解,即可完成远程配置的自动注入、绑定、更新。 ================================================ FILE: src/ch03-ms-dev2/mq.md ================================================ ## Spring Boot集成消息队列 [Apache RocketMQ](https://rocketmq.apache.org/)是由开源的轻量级消息队列,于2017年正式成为Apache顶级项目。 在分布式消息队列中间件领域,最热门的项目是Kafka和RocketMQ: - Kafka是较早开源的"消息处理平台",在写吞吐量上,有明显优势,更适合处理日志类消息。 - RocketMQ借鉴了部分Kafka的设计思路,并对实时性、大分区数等方面进行了优化,较适合做为业务类的消息。 因此,本书选用RocketMQ做为业务类的消息队列。 ### 安装并运行RocketMQ RocketMQ的容器化比较落后,基本没有可用的镜像版本,我们采用手工单机部署的方式。 首先,下载最新版二进制文件,当前是4.9.1: ```shell wget https://dlcdn.apache.org/rocketmq/4.9.1/rocketmq-all-4.9.1-bin-release.zip ``` 完成后,解压缩: ```bash unizp rocketmq-all-4.9.1-bin-release.zip ``` 启动Name Server: ```bash nohup sh bin/mqnamesrv & tail -f ~/logs/rocketmqlogs/namesrv.log ``` 最后启动Broker: ```bash nohup sh bin/mqbroker -n 127.0.0.1:9876 & tail -f ~/logs/rocketmqlogs/broker.log ``` 如果启动成功,在上述两个日志中,会有如下的日志: ```shell 2021-10-12 4:30:02 INFO main - tls.client.keyPassword = null 2021-10-12 4:30:02 INFO main - tls.client.certPath = null 2021-10-12 4:30:02 INFO main - tls.client.authServer = false 2021-10-12 4:30:02 INFO main - tls.client.trustCertPath = null 2021-10-12 4:30:02 INFO main - Using JDK SSL provider 2021-10-12 4:30:03 INFO main - SSLContext created for server 2021-10-12 4:30:03 INFO main - Try to start service thread:FileWatchService started:false lastThread:null 2021-10-12 4:30:03 INFO NettyEventExecutor - NettyEventExecutor service started 2021-10-12 4:30:03 INFO FileWatchService - FileWatchService service started 2021-10-12 4:30:03 INFO main - The Name Server boot success. serializeType=JSON 2021-10-12 14:36:09 INFO brokerOutApi_thread_3 - register broker[0]to name server 127.0.0.1:9876 OK 2021-10-12 14:36:09 ERROR DiskCheckScheduledThread1 - Error when measuring disk space usage, file doesn't exist on this path: /Users/coder4/store/commitlog 2021-10-12 14:36:18 ERROR StoreScheduledThread1 - Error when measuring disk space usage, file doesn't exist on this path: /Users/coder4/store/commitlog 2021-10-12 14:36:19 ERROR DiskCheckScheduledThread1 - Error when measuring disk space usage, file doesn't exist on this path: /Users/coder4/store/commitlog ``` 可以发现,NameServer是没有问题的,Broker报了一个"Error when measuring disk space usage"的错,这个是当前版本的Bug,不影响使用。 如果想退出服务,可以直接kill,或者执行: ```shell sh bin/mqshutdown broker sh bin/mqshutdown namesrv ``` ## RocketMQ架构简介 在集成RocketMQ之前,先介绍一下RocketMQ的基本架构: - NameServer:轻量级元信息服务,管理路由信息并提供对应的读写服务 - Broker:支撑TOPIC和QUEUE的存储,支持Push和Pull两种协议,有容错、副本、故障恢复机制。 - Producer:发布端服务,支持分布式部署,并向Broker集群发送 - Consumer:消费端服务,同时支持Push和Pull协议。支持消费、广播、顺序消息等特性。 - Topic:队列,用于区分不同消息。 - Tag:同一个Topic下,可以设定不同Tag(例如前缀),通过Tag来过滤消息,只保留自己感兴趣的。 在使用Producer和Consumer时,需要指定消费组(Consumer Group),这是从Kafka中借鉴过来的机制。相同Consumer Group下的实例会共享同一个GroupId,会被认为是对等的、可负载均衡的。事件会随机分发给相同GroupId下的多个实例中。 ## 在Spring Boot中集成RocketMQ 首先引入依赖: ```groovy implementation 'org.apache.rocketmq:rocketmq-client:4.9.1' ``` 接着,我们创建生产者的抽象基类: ```java package com.coder4.homs.demo.server.mq; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.rocketmq.client.exception.MQClientException; import org.apache.rocketmq.client.producer.DefaultMQProducer; import org.apache.rocketmq.client.producer.SendCallback; import org.apache.rocketmq.client.producer.SendResult; import org.apache.rocketmq.common.message.Message; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.DisposableBean; import javax.annotation.PostConstruct; import java.nio.charset.StandardCharsets; /** * @author coder4 */ public abstract class BaseProducer implements DisposableBean { private final Logger LOG = LoggerFactory.getLogger(getClass()); abstract String getNamesrvAddr(); abstract String getProducerGroup(); abstract String getTopic(); abstract String getTag(); protected DefaultMQProducer producer; private ObjectMapper objectMapper = new ObjectMapper(); public BaseProducer() { producer = new DefaultMQProducer(getProducerGroup()); } @PostConstruct public void postConstruct() { producer.setNamesrvAddr(getNamesrvAddr()); try { producer.start(); } catch (MQClientException e) { LOG.error("producer start exception", e); throw new RuntimeException(e); } } @Override public void destroy() throws Exception { producer.shutdown(); } protected Message buildMessage(String payload) { return new Message(getTopic(), getTag(), payload.getBytes(StandardCharsets.UTF_8) ); } public void publish(T payload) { try { String val = objectMapper.writeValueAsString(payload); producer.send(buildMessage(val)); LOG.info("publish success, topic = {}, tag = {}, msg = {}", getTopic(), getTag(), val); } catch (Exception e) { LOG.error("publish exception", e); } } public void publishAsync(T payload) { try { String val = objectMapper.writeValueAsString(payload); producer.send(buildMessage(val), new SendCallback() { @Override public void onSuccess(SendResult sendResult) { LOG.info("publishAsync success, topic = {}, tag = {}, msg = {}", getTopic(), getTag(), val); } @Override public void onException(Throwable e) { LOG.error("publish async exception", e); } }); } catch (Exception e) { LOG.error("publishAsync exception", e); } } } ``` 如上所示: - nameServr、topic、tag由子类组成 - 我们在构造函数中,创建了Producer对象 - postConstruct中:设定了NameServer地址,并启动producer - publish / publishAsync:发送消息,先根据topic和tag构造消息,然后调用同步 / 异步的接口发送。 - destroy时,停止producer 接下来我们看下Consumer的基类: ```java /** * @(#)BaseConsumer.java, 10月 12, 2021. *

* Copyright 2021 coder4.com. All rights reserved. * CODER4.COM PROPRIETARY/CONFIDENTIAL. Use is subject to license terms. */ package com.coder4.homs.demo.server.mq; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer; import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus; import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently; import org.apache.rocketmq.client.exception.MQClientException; import org.apache.rocketmq.common.message.MessageExt; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.DisposableBean; import org.springframework.util.CollectionUtils; import javax.annotation.PostConstruct; /** * @author coder4 */ public abstract class BaseConsumer implements DisposableBean { protected final Logger LOG = LoggerFactory.getLogger(getClass()); private static final int DEFAULT_BATCH_SIZE = 1; private static final int MAX_RETRY = 1024; abstract String getNamesrvAddr(); abstract String getConsumerGroup(); abstract String getTopic(); abstract String getTag(); abstract Class getClassT(); abstract boolean process(T msg); private ObjectMapper objectMapper = new ObjectMapper(); protected DefaultMQPushConsumer consumer; public BaseConsumer() { consumer = new DefaultMQPushConsumer(getConsumerGroup()); } @PostConstruct public void postConstruct() { consumer.setNamesrvAddr(getNamesrvAddr()); try { consumer.subscribe(getTopic(), getTag()); } catch (MQClientException e) { LOG.error("consumer subscribe exception", e); throw new RuntimeException(e); } consumer.setConsumeMessageBatchMaxSize(DEFAULT_BATCH_SIZE); consumer.registerMessageListener((MessageListenerConcurrently) (msgs, context) -> { if (CollectionUtils.isEmpty(msgs)) { return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } if (msgs.size() != DEFAULT_BATCH_SIZE) { LOG.error("MessageListenerConcurrently callback msgs.size() != 1"); } MessageExt msg = msgs.get(0); if (msg.getReconsumeTimes() >= MAX_RETRY) { LOG.error("reconsume exceed max retry times"); return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } try { if (process(objectMapper.readValue(new String(msg.getBody()), getClassT()))) { return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } else { return ConsumeConcurrentlyStatus.RECONSUME_LATER; } } catch (Exception e) { LOG.error("process exception", e); return ConsumeConcurrentlyStatus.RECONSUME_LATER; } }); try { consumer.start(); } catch (MQClientException e) { LOG.error("consumer start exception", e); throw new RuntimeException(e); } } @Override public void destroy() throws Exception { consumer.shutdown(); } } ``` 与Producer类似,topic、tag、namesrv由子类指定。 - postConstruct:订阅了对应topic和tag的消息,并设定回掉函数,这里设定每批次最多拉取1个消息,以最简化处理失败的情况,你可以根据实际情况做出调整。 - 接受消息时,会调用子类的process进行处理,同时进行json的反序列化操作 接下来,我们来写一个Demo的生产者、消费者: 首先配置nameSrv: ```yaml # rocketmq rocketmq.namesrv: 127.0.0.1:9876 ``` 接着,定义消息: ```java @Data @Builder @NoArgsConstructor @AllArgsConstructor public class DemoMessage { private String msg; private long ts; } ``` 然后是具体的Consumer和Producer: ```java package com.coder4.homs.demo.server.mq; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; /** * @author coder4 */ @Service public class DemoConsumer extends BaseConsumer { @Value("${rocketmq.namesrv}") private String namesrv; @Override String getNamesrvAddr() { return namesrv; } @Override String getConsumerGroup() { return "demo-consumer"; } @Override String getTopic() { return "demo"; } @Override String getTag() { return "*"; } @Override Class getClassT() { return DemoMessage.class; } @Override boolean process(DemoMessage msg) { LOG.info("process msg = {}", msg); return true; } } ``` ```java package com.coder4.homs.demo.server.mq; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; /** * @author coder4 */ @Service public class DemoProducer extends BaseProducer { @Value("${rocketmq.namesrv}") private String namesrv; @Override String getNamesrvAddr() { return namesrv; } @Override String getProducerGroup() { return "demo-producer"; } @Override String getTopic() { return "demo"; } @Override String getTag() { return "*"; } } ``` 我们可以调用Producer发送一个消息,然后会收到如下的日志,说明消息已经被成功处理! ```shell 2021-10-12 8:01:37.340 INFO 6270 --- [MessageThread_1] c.c.homs.demo.server.mq.DemoConsumer : process msg = DemoMessage(msg=123, ts=1634032897315) ``` 由于篇幅所限,我们只实战了基础的消息收发,推荐你根据文档继续探索其他内容,包括:[集群部署]([Deployment - Apache RocketMQ](https://rocketmq.apache.org/docs/rmq-deployment/))、[顺序消息]([Order Message - Apache RocketMQ](https://rocketmq.apache.org/docs/order-example/))、[广播消息]([Broadcasting - Apache RocketMQ](https://rocketmq.apache.org/docs/broadcast-example/))等内容。 ================================================ FILE: src/ch03-ms-dev2/registry1.md ================================================ # Nacos注册中心:注册篇 ![f](amazon-ms-structure.png) 这是一张从互联网上找到的图,你的直观感受是什么?头皮发麻? 实际上,这个球儿是某一年亚马逊的微服务结构图,每一个球的端点,都是一个微服务。 假设某个微服务A,想通过RPC调用另一个微服务B,需要如何实现呢? 1. 微服务B可能有多个实例,他需要先找到一个存活的实例,假设叫做B1。 2. 需要知道B1的IP和端口 3. 建立连接,发起请求,并响应结果。 仔细揣摩上述流程,你会有一些疑问: 1. 怎么知道B的哪个实例还在存活? 2. 怎么知道B1的具体IP和端口? 3. 假设微服务B扩容后,有一个新的B6,如何上服务A感知到呢? 这些都是微服务注册中心要解决的问题。 ## Nacos服务注册中心 Nacos 致力于帮助您发现、配置和管理微服务。它提供了一组简单易用的特性集,帮助应用快速实现动态服务发现、服务配置、服务元数据及流量管理。 为了演示基本原理,我们将采用单机模式,在实际生产环境中,建议你采用[集群部署](https://nacos.io/zh-cn/docs/cluster-mode-quick-start.html)。 ```bash #!/bin/bash NAME="nacos" PUID="1000" PGID="1000" docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ -e MODE=standalone \ -p 8848:8848 \ -p 9848:9848 \ -p 9849:9849 \ --detach \ --restart always \ nacos/nacos-server:2.0.3 ``` 如上,我们采用官方镜像的单机模式,端口介绍如下: - 8848是web界面和rest api端口 - 9848、9849是gRPC端口 启动成功后,访问http://127.0.0.1:8848,会进入如下界面: ![f](./nacos-web.png) 默认的用户名和密码都是nacos。 ## 服务端集成Nacos自动注册 接下来,我们实现微服务的自动注册,即服务启动时,将自身的IP和端口,主动注册到Nacos上。 由于我们的架构体系中,通过gRPC进行服务通信,因此我们只注册RPC的部分。我们沿用第2章中的设定,端口是5000。 在服务端集成Nacos有很多方法,一般常见的都是直接使用spring-cloud-starter,但本书并没有采用这种做法,原因是: - 需要引入大量额外的cloud包,导致技术依赖过于旁杂。 - cloud模式采用注解的方式,并不能很好支持"一个微服务与多个不同微服务通信"的场景。 综上我们直接使用裸客户端的方式,首先是依赖: ```groovy implementation 'com.alibaba.nacos:nacos-client:2.0.3' ``` 接着,我们在第2章的基础上,在RPC服务上做如下修改: ```java @Configuration public class RpcServerConfiguration { private Logger LOG = LoggerFactory.getLogger(RpcServerConfiguration.class); @Autowired private BindableService bindableService; @Autowired private HomsRpcServer server; @Autowired private NacosService nacosService; @Bean public HomsRpcServer createRpcServer() { return new HomsRpcServer(bindableService, 5000); } @PostConstruct public void postConstruct() throws IOException, NacosException { server.start(); // register nacosService.registerRPC(SERVICE_NAME); } @PreDestroy public void preDestory() throws NacosException { try { server.stop(); } catch (InterruptedException e) { LOG.info("stop gRPC server exception", e); } finally { // unregister nacosService.deregisterRPC(SERVICE_NAME); LOG.info("stop gRPC server done"); } } } ``` 如上所示,我们在RPC服务启动的时候,增加了向Nacos的注册、在RPC停止的时候,在Nacos上注销服务。 NacosService是对NacosClient的简单封装,代码如下: ```java @Service public class NacosServiceImpl implements NacosService { @Value("${nacos.server}") private String nacosServer; private NamingService namingService; @PostConstruct public void postConstruct() throws NacosException { namingService = NamingFactory .createNamingService(nacosServer); } @Override public void registerRPC(String serviceName) throws NacosException { namingService.registerInstance(serviceName, getIP(), 5000); } @Override public void deregisterRPC(String serviceName) throws NacosException { namingService.deregisterInstance(serviceName, getIP(), 5000); } private String getIP() { return System.getProperty("POD_IP", "127.0.0.1"); } } ``` 如上所示,我们从yaml中读取Nacos服务的地址,然后从环境变量读取IP地址,并实现了注册、注销功能。 这里,你可以暂时假定环境变量一定可以取到IP,在后续Kubernetes的章节,我们会介绍如何将Pod的IP注入容器的环境变量。 你可以试着启动服务,然后访问Nacos的Web UI,会发现我们的服务正常发现了! 至此,我们实现了服务端的服务注册。至于另一半,服务的发现,请听下回分解! ================================================ FILE: src/ch03-ms-dev2/registry2.md ================================================ # Nacos注册中心:发现篇 经过上一节的努力,我们已经将RPC服务成功的注册到Nacos上了。 我们还是以老生常谈的A调用B为例,B的所有实例B1、B2...都在Nacos上了。我们本节要实现的,都客户端,也就是A的部分。 老规矩,先引入依赖: ```groovy implementation 'com.alibaba.nacos:nacos-client:2.0.3' implementation 'org.springframework.boot:spring-boot-autoconfigure:2.2.0.RELEASE' ``` 上述除了引入nacos的依赖外,还引入了spring-boot的自动配置包,后续做客户端的自动装配时会用到。 ## 客户端改造 在正式对接Nacos前,我们先对客户端的包做一些改造。 首先,引入一个通用的Grpc客户端实现: ```java public abstract class HSGrpcClient implements AutoCloseable { private ManagedChannel channel; private String ip; private int port; public HSGrpcClient(String ip, int port) { this.ip = ip; this.port = port; } public void init() { channel = ManagedChannelBuilder .forTarget(ip + ":" + port) .usePlaintext() .build(); initSub(channel); } protected abstract void initSub(Channel channel); public void close() throws InterruptedException { channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); } } ``` 代码如上所示: - HSGrpcClient管理了ManagedChannel,这是用于实际网络通信的连接池。 - 提供了initStub抽象方法,让子类根据自己的需求,去初始化自己的stub。 - 实现了AutoCloseable接口,让客户端可以通过close方法自动关闭。 在这个基础上,我们改造之前的具体RPC客户端,如下: ```java public class HomsDemoGrpcClient extends HSGrpcClient { private Logger LOG = LoggerFactory.getLogger(HomsDemoGrpcClient.class); private HomsDemoGrpc.HomsDemoFutureStub futureStub; /** * Construct client for accessing HelloWorld server using the existing channel. */ public HomsDemoGrpcClient(String ip, int port) { super(ip, port); } @Override protected void initSub(Channel channel) { futureStub = HomsDemoGrpc.newFutureStub(channel); } public Optional add(int val1, int val2) { AddRequest request = AddRequest.newBuilder().setVal1(val1).setVal2(val2).build(); try { AddResponse response = futureStub.add(request).get(); return Optional.ofNullable(response.getVal()); } catch (Exception e) { LOG.error("grpc add exception", e); return Optional.empty(); } } } ``` 如上,我们改用了FutureStub,并且将Manage的管理部分,移到了基类中。 ## SimpleGrpcClientManager的实现 在正式引入Nacos之前,我们先实现一个“看起来没什么营养”的SimpleGrpcClientManager,它可以提供IP、Port直连的客户端管理。 首先是基类: ```java public abstract class AbstractGrpcClientManager { protected Logger LOG = LoggerFactory.getLogger(getClass()); protected volatile CopyOnWriteArrayList clientPools = new CopyOnWriteArrayList<>(); protected Class kind; public AbstractGrpcClientManager(Class kind) { this.kind = kind; } public Optional getClient() { if (clientPools.size() == 0) { return Optional.empty(); } int pos = ThreadLocalRandom.current().nextInt(clientPools.size()); return Optional.ofNullable(clientPools.get(pos)); } public abstract void init() throws Exception; public void shutdown() { clientPools.forEach(c -> { try { shutdown(c); } catch (InterruptedException e) { LOG.error("shutdown client exception", e); } }); } protected void shutdown(HSGrpcClient client) throws InterruptedException { client.close(); } protected Optional buildHsGrpcClient(String ip, int port) { try { Class[] cArg = {String.class, int.class}; HSGrpcClient client = kind.getDeclaredConstructor(cArg) .newInstance(ip, port); client.init(); return Optional.ofNullable(client); } catch (Exception e) { LOG.error("build MyGrpcClient exception, ip = "+ ip + " port = "+ port, e); return Optional.empty(); } } } ``` 代码如上,解释一下: - clientPools是一组HSGrpcClient对象,即支持同时与多个微服务实例(多组不同的ip和端口)建立连接。在微服务场景下,这一特性尤为重要。 - 而从每一个HSGrpcClient的视角来看,其内置的ManagedChannel内部实现了连接池。因此针对同一个微服务的ip和端口,我们只需要一个HSGrpcClient的实例即可。 下面,我们看一下基础的、不带服务发现的实现: ```java package com.coder4.homs.demo.client; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Arrays; import java.util.concurrent.CopyOnWriteArrayList; /** * @author coder4 */ public class SimpleGrpcClientManager extends AbstractGrpcClientManager { protected Logger LOG = LoggerFactory.getLogger(SimpleGrpcClientManager.class); private String ip; private int port; public SimpleGrpcClientManager(Class kind, String ip, int port) { super(kind); this.ip = ip; this.port = port; } public void init() { // init one client only HSGrpcClient client = buildHsGrpcClient(ip, port) .orElseThrow(() -> new RuntimeException("build HsGrpcClient fail")); clientPools = new CopyOnWriteArrayList(Arrays.asList(client)); } public static void main(String[] args) throws Exception { SimpleGrpcClientManager manager = new SimpleGrpcClientManager(HomsDemoGrpcClient.class, "127.0.0.1", 5000); manager.init(); manager.getClient().ifPresent(t -> System.out.println(t.add(1, 2))); manager.shutdown(); } } ``` 从上述实现中不难发现: - 该实现中,默认只与预先设定的IP和端口,构造一个单独的HSGrpcClient。 - 由于IP和端口通过外部指定,因此使用了CopyOnWriteArrayList以保证线程安全。 ## NacosGrpcClientManager的实现 下面,我们着手实现带Nacos服务发现的版本。 ```java package com.coder4.homs.demo.client; import com.alibaba.nacos.api.naming.NamingFactory; import com.alibaba.nacos.api.naming.NamingService; import com.alibaba.nacos.api.naming.listener.NamingEvent; import com.alibaba.nacos.api.naming.pojo.Instance; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; /** * @author coder4 */ public class NacosGrpcClientManager extends AbstractGrpcClientManager { protected String serviceName; protected String nacosServer; protected NamingService namingService; public NacosGrpcClientManager(Class kind, String nacosServer, String serviceName) { super(kind); this.nacosServer = nacosServer; this.serviceName = serviceName; } @Override public void init() throws Exception { namingService = NamingFactory .createNamingService(nacosServer); namingService.subscribe(serviceName, e -> { if (e instanceof NamingEvent) { NamingEvent event = (NamingEvent) e; rebuildClientPools(event.getInstances()); } }); rebuildClientPools(namingService.selectInstances(serviceName, true)); } private void rebuildClientPools(List instanceList) { ArrayList list = new ArrayList<>(); for (Instance instance : instanceList) { buildHsGrpcClient(instance.getIp(), instance.getPort()).ifPresent(c -> list.add(c)); } CopyOnWriteArrayList oldClientPools = clientPools; clientPools = new CopyOnWriteArrayList(list); // destory old ones oldClientPools.forEach(c -> { try { c.close(); } catch (InterruptedException e) { LOG.error("MyGrpcClient shutdown exception", e); } }); } } ``` 解释如下: - 在init方法中,初始化了NamingService,并订阅对应serviceName服务的更新事件。 - 当第一次,或者有服务更新时,我们会根据最新列表,重建所有的HSGrpcClient - 每次重建后,关闭老的HSGrpcClient 为了让上述客户端使用更加方便,我们添加了如下的自动配置: ```java @Configuration public class HomsDemoGrpcClientManagerConfiguration { @Bean(name = "homsDemoGrpcClientManager") @ConditionalOnMissingBean(name = "homsDemoGrpcClientManager") @ConditionalOnProperty(name = {"nacos.server"}) public AbstractGrpcClientManager nacosManager( @Value("${nacos.server}") String nacosServer) throws Exception { NacosGrpcClientManager manager = new NacosGrpcClientManager<>(HomsDemoGrpcClient.class, nacosServer, HomsDemoConstant.SERVICE_NAME); manager.init(); return manager; } } ``` 如上所示: - nacos的server地址由yaml中配置 - serviceName由client包中的常量文件HomsDemoConstant提供(即homs-demo) 为了让上述自动配置自动生效,我们还需要添加META-INF/spring.factories文件 ```ini org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.coder4.homs.demo.configuration.HomsDemoGrpcClientManagerConfiguration ``` 最后,我们来实验一下服务发现的效果 1. 启动Server进程,检查Nacos上,应当出现了自动注册的RPC服务。 2. 开发客户端驱动的项目,引用上述client包、配置yaml中的nacos服务地址 3. 最后,在客户端驱动项目中,通过Autowired自动装配,代码类似: ```java @Autowired private AbstractGrpcClientManager homsClientManager; // Usage homsClientManager.getClient().ifPresent(client -> client.add(1, 2)); ``` 如果一切顺利,会自动发现nacos上已经注册的服务实例,并成功执行rpc调用。 ================================================ FILE: src/ch04-ms-dev3/README.md ================================================ ## 微服务开发下篇:日志、链路追踪、监控 随着微服务架构的流行,可观测性(Observability)的理念也逐渐升温。 可观测性是一个源于控制论的概念,映射到微服务架构中,主要指三个方面: - 日志:微服务的进程产生日志,分散在各处,系统需要收集、归拢日志,并提供统一的日志查询、分析功能。 - 链路追踪:微服务调用关系错综复杂,如果某一个微服务发生故障,很有可能是来源上有的调用挂掉。通过链路追踪,可以轻松的定位和发现问题。 - 监控:监控系统收集物理机、微服务的各类指标(Metrics),从而反应系统运行情况。更进一步,可以通过图表的方式,可视化地展示需求。 本章,我们将围绕上述三点展开: - 基于ElasticSearch + FileBeats + Kafka + FileBeats + Kibana的日志平台 - 基于SkyWalking的链路追踪系统 - 基于VictorialMetrics + Grafana的监控系统 经过本章的实战,微服务架构的可观测性将得到明显提升。 ================================================ FILE: src/ch04-ms-dev3/elkfk.md ================================================ # 基于ELKFK打造日志平台 微服务的实例数众多,需要一个强大的日志日志平台,它应具有以下功能: - 采集:从服务端进程(k8s的Pod中),自动收集日志 - 存储:将日志按照时间序列,存储在持久化的介质上,以供未来查找。 - 检索:根据关键词,时间等条件,方便地检索特定日志内容。 我们将基于ELKFK,打造自己的日志平台。 你可能听说过ELK,那么ELK后面加上的FK是什么呢? F:Filebeat,轻量级的日志采集插件 K:Kafka,用户缓存日志 日志系统的架构图如下所示: ![f](./elkfk.png) ## 搭建Kafka Kafka消耗的资源较多,一般多采用独立部署的方式。 这里为了演示方便,我们以单机版为例。 首先下载: ```shell wget https://dlcdn.apache.org/kafka/3.0.0/kafka_2.13-3.0.0.tgz ``` 接着,启动zk ```shell bin/zookeeper-server-start.sh config/zookeeper.properties ``` 最后,启动broker ```shell bin/kafka-server-start.sh config/server.properties ``` 我们来创建topic,供后续使用。 ```shell bin/kafka-topics.sh --create --topic k8s-log-homs --partitions 3 --replication-factor 1 --bootstrap-server localhost:9092k8s -> (FileBeat) -> kafka ``` ## 部署FileBeat 有了Kafka之后,我们在Kubernets集群上部署FileBeat,自动采集日志并发送到Kafka的队列中,配置如下: ```yaml --- apiVersion: v1 kind: ConfigMap metadata: name: filebeat-config namespace: kube-system labels: k8s-app: filebeat data: filebeat.yml: |- filebeat.inputs: - type: container paths: - /var/log/containers/homs*.log fields: kafka_topic: k8s-log-homs processors: - add_kubernetes_metadata: host: ${NODE_NAME} matchers: - logs_path: logs_path: "/var/log/containers/" processors: - add_cloud_metadata: - add_host_metadata: cloud.id: ${ELASTIC_CLOUD_ID} cloud.auth: ${ELASTIC_CLOUD_AUTH} output: kafka: enabled: true hosts: ["10.1.172.136:9092"] topic: '%{[fields.kafka_topic]}' max_message_bytes: 5242880 partition.round_robin: reachable_only: true keep-alive: 120 required_acks: 1 --- apiVersion: apps/v1 kind: DaemonSet metadata: name: filebeat namespace: kube-system labels: k8s-app: filebeat spec: selector: matchLabels: k8s-app: filebeat template: metadata: labels: k8s-app: filebeat spec: serviceAccountName: filebeat terminationGracePeriodSeconds: 30 hostNetwork: true dnsPolicy: ClusterFirstWithHostNet containers: - name: filebeat image: docker.elastic.co/beats/filebeat:7.15.2 args: [ "-c", "/etc/filebeat.yml", "-e", ] env: - name: ELASTIC_CLOUD_ID value: - name: ELASTIC_CLOUD_AUTH value: - name: NODE_NAME valueFrom: fieldRef: fieldPath: spec.nodeName securityContext: runAsUser: 0 # If using Red Hat OpenShift uncomment this: #privileged: true resources: limits: memory: 200Mi requests: cpu: 100m memory: 100Mi volumeMounts: - name: config mountPath: /etc/filebeat.yml readOnly: true subPath: filebeat.yml - name: data mountPath: /usr/share/filebeat/data - name: varlibdockercontainers mountPath: /var/lib/docker/containers readOnly: true - name: varlog mountPath: /var/log readOnly: true volumes: - name: config configMap: defaultMode: 0640 name: filebeat-config - name: varlibdockercontainers hostPath: path: /var/lib/docker/containers - name: varlog hostPath: path: /var/log # data folder stores a registry of read status for all files, so we don't send everything again on a Filebeat pod restart - name: data hostPath: # When filebeat runs as non-root user, this directory needs to be writable by group (g+w). path: /var/lib/filebeat-data type: DirectoryOrCreate --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: filebeat subjects: - kind: ServiceAccount name: filebeat namespace: kube-system roleRef: kind: ClusterRole name: filebeat apiGroup: rbac.authorization.k8s.io --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: filebeat namespace: kube-system subjects: - kind: ServiceAccount name: filebeat namespace: kube-system roleRef: kind: Role name: filebeat apiGroup: rbac.authorization.k8s.io --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: filebeat-kubeadm-config namespace: kube-system subjects: - kind: ServiceAccount name: filebeat namespace: kube-system roleRef: kind: Role name: filebeat-kubeadm-config apiGroup: rbac.authorization.k8s.io --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: filebeat labels: k8s-app: filebeat rules: - apiGroups: [""] # "" indicates the core API group resources: - namespaces - pods - nodes verbs: - get - watch - list - apiGroups: ["apps"] resources: - replicasets verbs: ["get", "list", "watch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: filebeat # should be the namespace where filebeat is running namespace: kube-system labels: k8s-app: filebeat rules: - apiGroups: - coordination.k8s.io resources: - leases verbs: ["get", "create", "update"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: filebeat-kubeadm-config namespace: kube-system labels: k8s-app: filebeat rules: - apiGroups: [""] resources: - configmaps resourceNames: - kubeadm-config verbs: ["get"] --- apiVersion: v1 kind: ServiceAccount metadata: name: filebeat namespace: kube-system labels: k8s-app: filebeat --- ``` 配置较多,我们解释一下: - 采集/var/log/containers目录下的homs*.log文件名的日志 - 将这些日志送到k8s-log-homs这个Kafka的topic中 - 配置Kafka的服务器地址 - 配置其他所需的权限 实际上,上述配置是在官方[原始文件](wget https://raw.githubusercontent.com/elastic/beats/7.15/deploy/kubernetes/filebeat-kubernetes.yaml)基础上修改的,更多配置可以参考[官方文档]([Configure the Kafka output | Filebeat Reference [7.15] | Elastic](https://www.elastic.co/guide/en/beats/filebeat/current/kafka-output.html))。 应用上述配置: ```shell kubectl apply -f filebeat.yaml ``` 然后我们查看Kafka收到的日志: ```shell bin/kafka-console-consumer.sh --bootstrap-server 127.0.0.1:9092 --topic k8s-log-homs --from-beginning ``` 符合预期: ```shell ... {"@timestamp":"2021-11-15T03:18:26.487Z","@metadata":{"beat":"filebeat","type":"_doc","version":"7.15.2"},"stream":"stdout","message":"2021-11-15 03:18:26.486 INFO 1 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http)","log":{"offset":1491,"file":{"path":"/var/log/containers/homs-start-deployment-6878f48fcc-65vcr_default_homs-start-server-d37b0467d097c00bd203089a97df371cdbacc156493f6b2d995b80395caf516f.log"}},"input":{"type":"container"},"agent":{"type":"filebeat","version":"7.15.2","hostname":"minikube","ephemeral_id":"335988de-a165-4070-88f1-08c3d6be7ba5","id":"850b6889-85e0-41c5-8a83-bce344b8b2ec","name":"minikube"},"ecs":{"version":"1.11.0"},"container":{"image":{"name":"coder4/homs-start:107"},"id":"d37b0467d097c00bd203089a97df371cdbacc156493f6b2d995b80395caf516f","runtime":"docker"},"kubernetes":{"pod":{"ip":"172.17.0.3","name":"homs-start-deployment-6878f48fcc-65vcr","uid":"7d925249-2a77-4c28-a462-001d189cdeaa"},"container":{"name":"homs-start-server"},"node":{"name":"minikube","uid":"faec4c1a-9188-408a-aeec-95b24aa47a88","labels":{"node-role_kubernetes_io/control-plane":"","node_kubernetes_io/exclude-from-external-load-balancers":"","kubernetes_io/hostname":"minikube","kubernetes_io/os":"linux","minikube_k8s_io/commit":"a03fbcf166e6f74ef224d4a63be4277d017bb62e","minikube_k8s_io/name":"minikube","minikube_k8s_io/updated_at":"2021_11_05T12_15_23_0700","node-role_kubernetes_io/master":"","beta_kubernetes_io/arch":"amd64","beta_kubernetes_io/os":"linux","minikube_k8s_io/version":"v1.22.0","kubernetes_io/arch":"amd64"},"hostname":"minikube"},"labels":{"app":"homs-start","pod-template-hash":"6878f48fcc"},"namespace_uid":"b880885d-c94a-4cf2-ba2c-1e4cb0d1a691","namespace_labels":{"kubernetes_io/metadata_name":"default"},"namespace":"default","deployment":{"name":"homs-start-deployment"},"replicaset":{"name":"homs-start-deployment-6878f48fcc"}},"orchestrator":{"cluster":{"url":"control-plane.minikube.internal:8443","name":"mk"}},"host":{"mac":["02:42:d5:27:3f:31","c6:64:9d:f9:89:7b","5a:b1:a0:66:ee:d3","46:41:6e:14:85:14","02:42:c0:a8:31:02"],"hostname":"minikube","architecture":"x86_64","os":{"kernel":"5.10.47-linuxkit","codename":"Core","type":"linux","platform":"centos","version":"7 (Core)","family":"redhat","name":"CentOS Linux"},"id":"1820c6c61258c329e88764d3dc4484f3","name":"minikube","containerized":true,"ip":["172.17.0.1","192.168.49.2"]}} {"@timestamp":"2021-11-15T03:18:26.573Z","@metadata":{"beat":"filebeat","type":"_doc","version":"7.15.2"},"log":{"offset":2111,"file":{"path":"/var/log/containers/homs-start-deployment-6878f48fcc-65vcr_default_homs-start-server-d37b0467d097c00bd203089a97df371cdbacc156493f6b2d995b80395caf516f.log"}},"stream":"stdout","input":{"type":"container"},"host":{"id":"1820c6c61258c329e88764d3dc4484f3","containerized":true,"ip":["172.17.0.1","192.168.49.2"],"name":"minikube","mac":["02:42:d5:27:3f:31","c6:64:9d:f9:89:7b","5a:b1:a0:66:ee:d3","46:41:6e:14:85:14","02:42:c0:a8:31:02"],"hostname":"minikube","architecture":"x86_64","os":{"family":"redhat","name":"CentOS Linux","kernel":"5.10.47-linuxkit","codename":"Core","type":"linux","platform":"centos","version":"7 (Core)"}},"ecs":{"version":"1.11.0"},"agent":{"version":"7.15.2","hostname":"minikube","ephemeral_id":"335988de-a165-4070-88f1-08c3d6be7ba5","id":"850b6889-85e0-41c5-8a83-bce344b8b2ec","name":"minikube","type":"filebeat"},"message":"2021-11-15 03:18:26.573 INFO 1 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext","container":{"id":"d37b0467d097c00bd203089a97df371cdbacc156493f6b2d995b80395caf516f","runtime":"docker","image":{"name":"coder4/homs-start:107"}},"kubernetes":{"replicaset":{"name":"homs-start-deployment-6878f48fcc"},"node":{"name":"minikube","uid":"faec4c1a-9188-408a-aeec-95b24aa47a88","labels":{"node-role_kubernetes_io/control-plane":"","minikube_k8s_io/commit":"a03fbcf166e6f74ef224d4a63be4277d017bb62e","kubernetes_io/os":"linux","kubernetes_io/arch":"amd64","node_kubernetes_io/exclude-from-external-load-balancers":"","beta_kubernetes_io/arch":"amd64","beta_kubernetes_io/os":"linux","minikube_k8s_io/updated_at":"2021_11_05T12_15_23_0700","node-role_kubernetes_io/master":"","kubernetes_io/hostname":"minikube","minikube_k8s_io/name":"minikube","minikube_k8s_io/version":"v1.22.0"},"hostname":"minikube"},"namespace_labels":{"kubernetes_io/metadata_name":"default"},"namespace":"default","deployment":{"name":"homs-start-deployment"},"pod":{"ip":"172.17.0.3","name":"homs-start-deployment-6878f48fcc-65vcr","uid":"7d925249-2a77-4c28-a462-001d189cdeaa"},"labels":{"app":"homs-start","pod-template-hash":"6878f48fcc"},"container":{"name":"homs-start-server"},"namespace_uid":"b880885d-c94a-4cf2-ba2c-1e4cb0d1a691"},"orchestrator":{"cluster":{"url":"control-plane.minikube.internal:8443","name":"mk"}}} {"@timestamp":"2021-11-15T03:18:27.470Z","@metadata":{"beat":"filebeat","type":"_doc","version":"7.15.2"},"input":{"type":"container"},"orchestrator":{"cluster":{"url":"control-plane.minikube.internal:8443","name":"mk"}},"agent":{"type":"filebeat","version":"7.15.2","hostname":"minikube","ephemeral_id":"335988de-a165-4070-88f1-08c3d6be7ba5","id":"850b6889-85e0-41c5-8a83-bce344b8b2ec","name":"minikube"},"stream":"stdout","message":"2021-11-15 03:18:27.470 INFO 1 --- [ main] com.homs.start.StartApplication : Started StartApplication in 3.268 seconds (JVM running for 3.738)","kubernetes":{"pod":{"name":"homs-start-deployment-6878f48fcc-65vcr","uid":"7d925249-2a77-4c28-a462-001d189cdeaa","ip":"172.17.0.3"},"container":{"name":"homs-start-server"},"labels":{"app":"homs-start","pod-template-hash":"6878f48fcc"},"node":{"labels":{"kubernetes_io/arch":"amd64","node_kubernetes_io/exclude-from-external-load-balancers":"","beta_kubernetes_io/arch":"amd64","kubernetes_io/hostname":"minikube","minikube_k8s_io/name":"minikube","minikube_k8s_io/version":"v1.22.0","kubernetes_io/os":"linux","minikube_k8s_io/commit":"a03fbcf166e6f74ef224d4a63be4277d017bb62e","minikube_k8s_io/updated_at":"2021_11_05T12_15_23_0700","node-role_kubernetes_io/control-plane":"","node-role_kubernetes_io/master":"","beta_kubernetes_io/os":"linux"},"hostname":"minikube","name":"minikube","uid":"faec4c1a-9188-408a-aeec-95b24aa47a88"},"namespace":"default","deployment":{"name":"homs-start-deployment"},"namespace_uid":"b880885d-c94a-4cf2-ba2c-1e4cb0d1a691","namespace_labels":{"kubernetes_io/metadata_name":"default"},"replicaset":{"name":"homs-start-deployment-6878f48fcc"}},"ecs":{"version":"1.11.0"},"host":{"os":{"codename":"Core","type":"linux","platform":"centos","version":"7 (Core)","family":"redhat","name":"CentOS Linux","kernel":"5.10.47-linuxkit"},"id":"1820c6c61258c329e88764d3dc4484f3","containerized":true,"ip":["172.17.0.1","192.168.49.2"],"mac":["02:42:d5:27:3f:31","c6:64:9d:f9:89:7b","5a:b1:a0:66:ee:d3","46:41:6e:14:85:14","02:42:c0:a8:31:02"],"hostname":"minikube","architecture":"x86_64","name":"minikube"},"log":{"offset":2787,"file":{"path":"/var/log/containers/homs-start-deployment-6878f48fcc-65vcr_default_homs-start-server-d37b0467d097c00bd203089a97df371cdbacc156493f6b2d995b80395caf516f.log"}},"container":{"image":{"name":"coder4/homs-start:107"},"id":"d37b0467d097c00bd203089a97df371cdbacc156493f6b2d995b80395caf516f","runtime":"docker"}} ``` 重启deployment ```shell kubectl rollout restart deployment homs-start-deployment ``` 启动ElasticSearch ```shell #!/bin/bash NAME="elasticsearch" PUID="1000" PGID="1000" VOLUME="$HOME/docker_data/elasticsearch" mkdir -p $VOLUME docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ --env discovery.type=single-node \ -p 9200:9200 \ -p 9300:9300 \ --detach \ --restart always \ docker.elastic.co/elasticsearch/elasticsearch:7.15.2 ``` ## 启动ElasticSearch 在配置LogStash前,我们先要启动最终的存储,即ElasticSearch。 为了演示方便,我们使用单机模式启动: ```shell #!/bin/bash NAME="elasticsearch" PUID="1000" PGID="1000" VOLUME="$HOME/docker_data/elasticsearch" mkdir -p $VOLUME docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ --env discovery.type=single-node \ -p 9200:9200 \ -p 9300:9300 \ --detach \ --restart always \ docker.elastic.co/elasticsearch/elasticsearch:7.15.2 ``` 你可以通过curl命令,检查启动是否成功: ```shell curl 127.0.0.1:9200 { "name" : "elasticsearch", "cluster_name" : "docker-cluster", "cluster_uuid" : "yxLELfOmT9OXPXxjh7g7Nw", "version" : { "number" : "7.15.2", "build_flavor" : "default", "build_type" : "docker", "build_hash" : "93d5a7f6192e8a1a12e154a2b81bf6fa7309da0c", "build_date" : "2021-11-04T14:04:42.515624022Z", "build_snapshot" : false, "lucene_version" : "8.9.0", "minimum_wire_compatibility_version" : "6.8.0", "minimum_index_compatibility_version" : "6.0.0-beta1" }, "tagline" : "You Know, for Search" ``` 温馨提示:默认情况是没有用户名、密码的,用于生产环境时请务必开启。 ## 启动Logstash 首先,配置logstash.conf,将其放到pipeline子目录下: ```ini input { kafka { bootstrap_servers => ["10.1.172.136:9092"] group_id => "k8s-log-homs-logstash" topics => ["k8s-log-homs"] codec => json } } filter { if [message] =~ "\tat" { grok { match => ["message", "^(\tat)"] add_tag => ["stacktrace"] } } grok { match => [ "message", "%{TIMESTAMP_ISO8601:logtime}%{SPACE}%{LOGLEVEL:level}%{SPACE}(?.*)" ] } date { match => [ "logtime" , "yyyy-MM-dd HH:mm:ss.SSS" ] } #mutate { # remove_field => ["message"] #} } output { elasticsearch { hosts => "http://10.1.172.136:9200" user =>"elastic" password =>"" index => "k8s-log-homs-%{+YYYY.MM.dd}" } } ``` 这里,我们使用了grok来拆分message字段,你可以在使用[在线工具]([Test grok patterns](https://grokconstructor.appspot.com/do/match))验证规则。 接着,我们启动logstash ```shell #!/bin/bash NAME="logstash" PUID="1000" PGID="1000" VOLUME="$(pwd)/pipeline" docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ --volume "$VOLUME":/usr/share/logstash/pipeline \ --detach \ --restart always \ docker.elastic.co/logstash/logstash:7.15.2 ``` 上述直接挂载了前面配置的pipeline目录。 ## Kibana 最后,我们启动kibana: ```shell #!/bin/bash NAME="kibana" PUID="1000" PGID="1000" docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ --env "ELASTICSEARCH_HOSTS=http://10.1.172.136:9200" \ -p 5601:5601 \ --detach \ --restart always \ docker.elastic.co/kibana/kibana:7.15.2 ``` 如果一切顺利,你会看到如图所示的日志: ![f](./kibana-log.png) 至此,我们已经成功搭建了自己的日志平台。 ================================================ FILE: src/ch04-ms-dev3/micrometer.md ================================================ # 基于MicroMeter实现应用监控指标 提到“监控”(Moniter),你的第一反应是什么? 是老传统监控软件Zabbix、Nagios?还是近几年火爆IT圈的Promethos? 别急着比较系统,这篇文章,我们先聊聊应用监控指标。 顾名思义,“应用监指标”就是根据监控需求,在我们的应用系统中预设埋点,并支持与监控系统对接。 典型的监控项如:接口请求次数、接口响应时间、接口报错次数.... 我们将介绍MicroMeter开源项目,并使用它实现Spring MVC的应用监控指标。 ## MicroMeter简介 Micrometer是社区最流行的监控项项目之一,它提供了一个抽象、强大、易用的抽象门面接口,可以轻松的对接包括Prometheus、JMX等在内的近20种监控系统。它的作用和Slf4j类似,只不过它关注的不是日志,而是应用指标(application metrics)。 ## 自定义应用监控项初探 下面,我们来开始micrometer之旅。 由于网上关于micrometer对接Prometheus的文章已经很多了,这里我特意选择了JMX。 通过JMX Bean暴露的监控项,你可以轻松的对接Zabbix等老牌监控系统。 这里提醒的是JMX不支持类似Prometheus的层级结构,而只支持一级结构(tag会被打平),具体可以参见[官方文档](https://micrometer.io/docs/registry/jmx)。当然,这在代码实现上是完全透明的。 首先,我们新建一个简单的Spring Boot项目,并引入pom文件: ```xml org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-actuator io.micrometer micrometer-registry-jmx 1.8.7 ``` 然后开发如下的Spring MVC接口: ```java package com.coder4.homs.micrometer.web; import com.coder4.homs.micrometer.web.data.UserVO; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; import javax.annotation.PostConstruct; @RestController public class UserController { @Autowired private MeterRegistry meterRegistry; private Counter COUNTER_GET_USER; @PostConstruct public void init() { COUNTER_GET_USER = meterRegistry.counter("app_requests_method_count", "method", "UserController.getUser"); } @GetMapping(path = "/users/{id}") public UserVO getUser(@PathVariable int id) { UserVO user = new UserVO(); user.setId(id); user.setName(String.format("user_%d", id)); COUNTER_GET_USER.increment(); return user; } } ``` 在上面的代码中: 1. 我们实现了UserController这个REST接口,他之中的/users/{id}可以获取用户。 2. UserController注册了一个Counter,Counter由名字和tag组成,用过Prometheus的应该很熟悉这种套路了。 3. 每次请求时,会将上述Counter加一操作。 我们来测试一下,执行2次 ```shell curl "127.0.0.1:8080/users/1" {"id":1,"name":"user_1"} ``` 然后打开本地的jconsole,可以发现JMX Bean暴露出了了metrics、gauge等分类,我们打开"metrics/app_requests_method_..."这个指标,点击进去,可以发现具体的值也就是2。 ![f](./jconsole1.png) ## 借助拦截器批量统计监控项目 上述代码可以实现功能,但是你应该发现了,实现起来很繁琐,如果我们有10个接口,那岂不是要写很多无用代码? 相信你已经想到了,可以用类似AOP (切面编程)的思路,解决问题。 不过针对Spring MVC这个场景,使用AOP有点“大炮打蚊子”的感觉,我们可以使用拦截器实现。 首先自定义拦截器的自动装配: ```java package com.coder4.homs.micrometer.configure; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class MeterConfig implements WebMvcConfigurer { @Bean public MeterInterceptor getMeterInterceptor() { return new MeterInterceptor(); } @Override public void addInterceptors(InterceptorRegistry registry){ registry.addInterceptor(getMeterInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/error") .excludePathPatterns("/static/*"); } } ``` 上面代码很简单,就是新增了新的拦截器MeterInterceptor。 我们看下拦截器做了什么: ```java package com.coder4.homs.micrometer.configure; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.DistributionSummary; import io.micrometer.core.instrument.MeterRegistry; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.util.Optional; public class MeterInterceptor implements HandlerInterceptor { @Autowired private MeterRegistry meterRegistry; private ThreadLocal tlTimer = new ThreadLocal<>(); private static Optional getMethod(HttpServletRequest request, Object handler) { if (handler instanceof HandlerMethod) { return Optional.of(String.format("%s_%s_%s", ((HandlerMethod) handler).getBeanType().getSimpleName(), ((HandlerMethod) handler).getMethod().getName(), request.getMethod())); } else { return Optional.empty(); } } private void recordTimeDistribution(HttpServletRequest request, Object handler, long ms) { Optional methodOp = getMethod(request, handler); if (methodOp.isPresent()) { DistributionSummary.builder("app_requests_time_ms") .tag("method", methodOp.get()) .publishPercentileHistogram() .register(meterRegistry) .record(ms); } } public Optional getCounterOfTotalCounts(HttpServletRequest request, Object handler) { Optional methodOp = getMethod(request, handler); if (methodOp.isPresent()) { return Optional.of(meterRegistry.counter("app_requests_total_counts", "method", methodOp.get())); } else { return Optional.empty(); } } public Optional getCounterOfExceptionCounts(HttpServletRequest request, Object handler) { Optional methodOp = getMethod(request, handler); if (methodOp.isPresent()) { return Optional.of(meterRegistry.counter("app_requests_exption_counts", "method", methodOp.get())); } else { return Optional.empty(); } } public Optional getCounterOfRespCodeCounts(HttpServletRequest request, HttpServletResponse response, Object handler) { Optional methodOp = getMethod(request, handler); if (methodOp.isPresent()) { return Optional.of(meterRegistry.counter(String.format("app_requests_resp%d_counts", response.getStatus()), "method", methodOp.get())); } else { return Optional.empty(); } } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { tlTimer.set(System.currentTimeMillis()); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // record time recordTimeDistribution(request, handler, System.currentTimeMillis() - tlTimer.get()); tlTimer.remove(); // total counts getCounterOfTotalCounts(request, handler).ifPresent(counter -> counter.increment()); // different response code count getCounterOfRespCodeCounts(request, response, handler).ifPresent(counter -> counter.increment()); if (ex != null) { // exception counts getCounterOfExceptionCounts(request, handler).ifPresent(counter -> counter.increment()); } } } ``` 代码有点长,解释一下: 1. 自动注入MeterRegistry,老套路了 2. getCounterOfXXX几个方法,通过request、handler来生成具体的监控项名称和标签,形如:app_requests_method_count.method.UserController.getUser。 3. preHandle中预设了ThreadLocal的定时器 4. recordTimeDistribution使用了Distribution,这是一个可以统计百分位的MicroMeter组件,类似Prometheus的histogram功能的你应该能秒懂。 5. afterCompletion根据前面定时器,计算本次请求时间,并记录到Distributon中。 6. afterCompletion记录总请求数、分resp.code的请求数、出错请求数。 我们打开jconsole看下: ![f](./jconsole2.png) 在之前meters的基础上,新增了histogram分类,里面会详细记录请求时间,比如我这里做了一些本地压测后,.99时间是12ms,.95时间是1ms。 在上面的基础上稍做修改,就可以投入使用了。 感兴趣的话,你可以探索如何对Dubbo、gRPC等RPC接口进行应用程序监控项。 本篇文章的代码,我放到了[homs-micrometer这个github项目](https://github.com/liheyuan/homs-micrometer)中,感兴趣的话可以查阅。 ================================================ FILE: src/ch04-ms-dev3/skywalking.md ================================================ # 基于SkyWalking的链路追踪系统 链路追踪提供了分布式调用链路的还原、统计、分析等功能,是提升微服务诊断效率的重要环节。 本节,我们将基于[SkyWalking](https://skywalking.apache.org/)搭建链路追踪系统。 SkyWalking是一款开源的APM(Application Performance Monitor)工具,以Java Agent + 插件化的方式运行。2019年其从孵化器毕业,正式成为Apache的顶级项目。 ## 单机实验 我们首先跑通单机版的链路追踪。 SkyWalking支持多种后台存储,这里我们选用ElasticSearch: ```shell #!/bin/bash NAME="elasticsearch" PUID="1000" PGID="1000" VOLUME="$HOME/docker_data/elasticsearch" mkdir -p $VOLUME docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ --env discovery.type=single-node \ --volume "$VOLUME:/usr/share/elasticsearch/data" \ -p 9200:9200 \ -p 9300:9300 \ --detach \ --restart always \ docker.elastic.co/elasticsearch/elasticsearch:7.15.2 ``` 接着,我们启动SkyWalking的后台服务: ```shell #!/bin/bash NAME="skywalking" PUID="1000" PGID="1000" docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ -e SW_STORAGE=elasticsearch7 \ -e SW_STORAGE_ES_CLUSTER_NODES="10.1.172.136:9200" \ -p 12800:12800 \ -p 11800:11800 \ --detach \ --restart always \ apache/skywalking-oap-server:8.7.0-es7 ``` 最后,启动SkyWalking的UI服务: ```shell #!/bin/bash NAME="skywalkingui" PUID="1000" PGID="1000" docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ -e SW_OAP_ADDRESS="http://10.1.172.136:12800" \ -p 8080:8080 \ --detach \ --restart always \ apache/skywalking-ui:8.7.0 ``` 上述,我们让容器直接使用了Host Net:10.1.172.136。 下一步,我们下载最新版的[Java Agent](https://dlcdn.apache.org/skywalking/java-agent/8.8.0/apache-skywalking-java-agent-8.8.0.tgz),其支持的框架可以在[这里]([Tracing and Tracing based Metrics Analyze Plugins | Apache SkyWalking](https://skywalking.apache.org/docs/skywalking-java/latest/en/setup/service-agent/java-agent/supported-list/))查看。 解压后,我们直接使用java命令行运行: ```shell java -javaagent:./skywalking-agent/skywalking-agent.jar -Dskywalking.agent.service_name=homs-start -Dskywalking.collector.backend_service=10.1.172.136:11800 -jar ./homs-start-0.0.1-SNAPSHOT.jar ``` 如上所示: - 服务名字:homs-start - SkyWalking后台服务地址:10.1.172.136:11800 启动成功后,我们尝试访问端口: ```shell curl "127.0.0.1:8080" ``` 查看SkyWalking的UI,可以发现,已经统计到了链路追踪! ![f](./skywalking-ui.png) ## Kubernets中部署SkyWalking 在Kubernets环境中,我们倾向只部署无状态服务,以便拓展。 而对于SkyWaling Server这种服务,会占用较大性能,且没有太多需要扩展的场景,因此我们维持其外部部署方式,不上k8s。 回顾下之前的内容,我们的homs-start是通过Docker镜像的方式启动的Pod和Deployment。 我们需要对其进行改造,添加initContainer,注入Java Agent: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: homs-start-deployment labels: app: homs-start spec: selector: matchLabels: app: homs-start replicas: 1 strategy: type: RollingUpdate template: metadata: labels: app: homs-start spec: volumes: - name: skywalking-agent emptyDir: {} containers: - name: homs-start-server image: coder4/homs-start:107 ports: - containerPort: 8080 volumeMounts: - name: skywalking-agent mountPath: /skywalking env: - name: JAVA_TOOL_OPTIONS value: -javaagent:/skywalking/agent/skywalking-agent.jar - name: SW_AGENT_NAME value: homs-start - name: SW_AGENT_COLLECTOR_BACKEND_SERVICES value: 10.1.172.136:11800 resources: requests: memory: 500Mi initContainers: - name: agent-container image: apache/skywalking-java-agent:8.8.0-java8 volumeMounts: - name: skywalking-agent mountPath: /agent command: [ "/bin/sh" ] args: [ "-c", "cp -R /skywalking/agent /agent/" ] ``` 如上所示: - 这里我们没有额外制作agent的镜像,而是使用了[官方的最新版](https://hub.docker.com/r/apache/skywalking-java-agent) - 我们添加了全局的临时Volume:skywalking-agent - 添加了initContainer:agent-container,主要负责启动时拷贝agent的jar包 - 在启动homs-start-server时需要设定一些环境变量参数 启动成功后,我们尝试登录minikube访问服务: ```shell minikube ssh Last login: Tue Nov 16 07:54:28 2021 from 192.168.49.1 docker@minikube:~$ curl "172.17.0.3:8080" {"timestamp":"2021-11-16T07:55:08.669+00:00","status":404,"error":"Not Found","path":"/"} ``` 然后查看SkyWalking的UI,也能成功记录到最新追踪请求! ![f](./skywalking-k8s.png) 至此,我们已经搭建了最基本的链路追踪系统,其还有很大的优化空间: - 官方agent镜像中包含了全量插件,你应当根据实际需要剪裁 - 微服务中会有某些缺乏Agent插件的场景,需要自行定制插件 - 不仅agent,服务的jar包其实也是可以通过initContainer来拷贝的,这可以进一步压缩镜像体积。 上述优化,做为课后作业,留给喜欢挑战的你吧:-) ================================================ FILE: src/ch04-ms-dev3/victorialmetrics.md ================================================ # 基于VictoriaMetrics + Grafana的监控系统 监控(Monitor)与度量(Metrics)是可观测性的重要环节。 在本节中,我们将使用VirtorialMetrics构建自己的监控系统。 提到监控系统的工具,你可能会想到老牌的Zabbix、Nagios,也可能听说过新星的Prometheus。 Prometheus是一个开源的监控系统,凭借开放的生态环境、云原生等特性,逐步成为了微服务架构下的事实标准。 然而,由于Prometheus设计初期并没有考虑存储扩展性,因此当监控的metrics升高到每秒百万级别后,会出现较为明显的性能瓶颈。 [VictoriaMetrics](https://github.com/VictoriaMetrics/VictoriaMetrics)是进来快速崛起的开源监控项目,其在设计之处就支持水平拓展,并且兼容了Prometheus的协议,可以应对日益增长的metrics需求。 Grafana是一款开源的可视化分析工具,通过丰富的仪表盘,让用户能够更直观的理解Metrics。 本节,我们将基于Victoria-Metrics + Grafana搭建监控系统。 ## 安装VictoriaMetrics 在下面的章节,我们将演示搭建vm的single版本,由于VM出色的性能,single已经足以应对中小企业的监控需求。你可以根据实际的需要,[部署集群版本]([HA monitoring setup in Kubernetes via VictoriaMetrics Cluster · VictoriaMetrics](https://docs.victoriametrics.com/guides/k8s-ha-monitoring-via-vm-cluster.html))。 首先添加helm源 ```shell helm repo add vm https://victoriametrics.github.io/helm-charts/ helm repo update ``` ```shell helm search repo vm/ NAME CHART VERSION APP VERSION DESCRIPTION vm/victoria-metrics-agent 0.7.34 v1.69.0 Victoria Metrics Agent - collects metrics from ... vm/victoria-metrics-alert 0.4.14 v1.69.0 Victoria Metrics Alert - executes a list of giv... vm/victoria-metrics-auth 0.2.33 1.69.0 Victoria Metrics Auth - is a simple auth proxy ... vm/victoria-metrics-cluster 0.9.12 1.69.0 Victoria Metrics Cluster version - high-perform... vm/victoria-metrics-k8s-stack 0.5.9 1.69.0 Kubernetes monitoring on VictoriaMetrics stack.... vm/victoria-metrics-operator 0.4.2 0.20.3 Victoria Metrics Operator vm/victoria-metrics-single 0.8.12 1.69.0 Victoria Metrics Single version - high-performa... ``` 我们查看所有可配置的参数选项: ```shell helm show values vm/victoria-metrics-single > values.yaml ``` 将其修改为如下设置: ```yaml server: persistentVolume: enabled: false accessModes: - ReadWriteOnce annotations: {} storageClass: "" existingClaim: "" matchLabels: {} mountPath: /storage subPath: "" size: 16Gi scrape: enabled: true configMap: "" config: global: scrape_interval: 15s scrape_configs: - job_name: victoriametrics static_configs: - targets: [ "localhost:8428" ] - job_name: "kubernetes-apiservers" kubernetes_sd_configs: - role: endpoints scheme: https tls_config: ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt insecure_skip_verify: true bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token relabel_configs: - source_labels: [ __meta_kubernetes_namespace, __meta_kubernetes_service_name, __meta_kubernetes_endpoint_port_name, ] action: keep regex: default;kubernetes;https - job_name: "kubernetes-nodes" scheme: https tls_config: ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt insecure_skip_verify: true bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token kubernetes_sd_configs: - role: node relabel_configs: - action: labelmap regex: __meta_kubernetes_node_label_(.+) - target_label: __address__ replacement: kubernetes.default.svc:443 - source_labels: [ __meta_kubernetes_node_name ] regex: (.+) target_label: __metrics_path__ replacement: /api/v1/nodes/$1/proxy/metrics - job_name: "kubernetes-nodes-cadvisor" scheme: https tls_config: ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt insecure_skip_verify: true bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token kubernetes_sd_configs: - role: node relabel_configs: - action: labelmap regex: __meta_kubernetes_node_label_(.+) - target_label: __address__ replacement: kubernetes.default.svc:443 - source_labels: [ __meta_kubernetes_node_name ] regex: (.+) target_label: __metrics_path__ replacement: /api/v1/nodes/$1/proxy/metrics/cadvisor metric_relabel_configs: - action: replace source_labels: [pod] regex: '(.+)' target_label: pod_name replacement: '${1}' - action: replace source_labels: [container] regex: '(.+)' target_label: container_name replacement: '${1}' - action: replace target_label: name replacement: k8s_stub - action: replace source_labels: [id] regex: '^/system\.slice/(.+)\.service$' target_label: systemd_service_name replacement: '${1}' ``` 如上所述: - 我们禁用了PV,这将默认使用local的emptydir。建议你在生产环境,根据需要自行配置可自动装配的存储插件。 - 从Kubernetes集群抓取信息,并做了一些label上的转化。 - 如果你熟悉Prometheus的话,会发现上述配置和Prometheus基本是兼容的。 安装vmsingle: ```shell helm install vmsingle vm/victoria-metrics-single -f ./values.yaml -n vm W1117 14:46:54.020279 26203 warnings.go:70] policy/v1beta1 PodSecurityPolicy is deprecated in v1.21+, unavailable in v1.25+ W1117 14:46:54.066766 26203 warnings.go:70] policy/v1beta1 PodSecurityPolicy is deprecated in v1.21+, unavailable in v1.25+ NAME: vmsingle LAST DEPLOYED: Wed Nov 17 14:46:53 2021 NAMESPACE: vm STATUS: deployed REVISION: 1 TEST SUITE: None NOTES: The VictoriaMetrics write api can be accessed via port 8428 on the following DNS name from within your cluster: vmsingle-victoria-metrics-single-server.vm.svc.cluster.local Metrics Ingestion: Get the Victoria Metrics service URL by running these commands in the same shell: export POD_NAME=$(kubectl get pods --namespace vm -l "app=server" -o jsonpath="{.items[0].metadata.name}") kubectl --namespace vm port-forward $POD_NAME 8428 Write url inside the kubernetes cluster: http://vmsingle-victoria-metrics-single-server.vm.svc.cluster.local:8428/api/v1/write Read Data: The following url can be used as the datasource url in Grafana:: http://vmsingle-victoria-metrics-single-server.vm.svc.cluster.local:8428 ``` 上述的Read Data地址,后续需要用的,请复制、保存好。 部署成功后,我们查看下Pod,运行成功: ```shell kubectl get pods default vmsingle-victoria-metrics-single-server-0 1/1 Running 0 59s ``` ## 安装Grafana 首先,依然是添加helm源: ```shell helm repo add grafana https://grafana.github.io/helm-charts helm repo update ``` 接下来,自定义参数并安装: ```shell cat < 443/TCP 67m my-nginx LoadBalancer 10.104.5.62 80:30229/TCP 37s 8m18s ``` 需要启用minikube的隧道,来分配"外部IP",这里的外部是相对于minikube而言的,实际上是我们本机网络的IP。 ```bash minikube tunnel kubectl get services NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE kubernetes ClusterIP 10.96.0.1 443/TCP 67m my-nginx LoadBalancer 10.104.5.62 127.0.0.1 80:30229/TCP 24s 9m25s ``` 启动隧道后,发现暴露到了127.0.0.1的80端口上,我们试一下: ```bash curl "http://127.0.0.1:80" Welcome to nginx!

Welcome to nginx!

If you see this page, the nginx web server is successfully installed and working. Further configuration is required.

For online documentation and support please refer to nginx.org.
Commercial support is available at nginx.com.

Thank you for using nginx.

``` minikube也提供了可视化的Dashboard: ```shell minikube dashboard --url http://127.0.0.1:59352/api/v1/namespaces/kubernetes-dashboard/services/http:kubernetes-dashboard:/proxy/ ``` 在浏览器中打开上述连接,可以进入Web版的Dashboard,如下图所示: ![f](./k8s-dashboard.png) 至此,你已经通过在minikube上的实战演练,掌握了kubernetes的基本用法。 在实际生产环境中,建议你搭建真实的分布式集群,不要使用minikube,我将在后续章节,介绍高可用k8s集群的部署。 ================================================ FILE: src/ch05-k8s/k8s-cluster.md ================================================ # 搭建Kubernetes集群 在本章的前几节,我们在minikube集群上,实战了很多内容,是时候搭建真正的集群了。 本节,我们将借助kubeadm的帮助,搭建准生产级的k8s集群。 关于"准生产"的含义,我们先放下不表。 以下的集群搭建假设你使用Ubuntu的发行版,20.04,需要3台机器(可以是物理服务器,也可以是虚拟机,以下我们都简称机器)。 如果你不是Ubuntu,请自行替换部分安装命令,很简单。 ## 1 调整系统参数 我们需要调整一些系统参数,以方便后续集群的搭建。 ```shell lsmod | grep br_netfilter br_netfilter sysctl net.bridge.bridge-nf-call-iptables net.bridge.bridge-nf-call-iptables = 1 sysctl net.bridge.bridge-nf-call-ip6tables net.bridge.bridge-nf-call-ip6tables = 1 swapoff -a ``` 说明如下: - 需要开启netfilter - 调整对应内核参数如上 - 关闭swap,建议你同步修改fstab(保证重启后生效) ## 2 安装Docker 首先安装Docker ```shell sudo apt-get update && sudo apt-get install -y apt-transport-https sudo apt install -y docker.io sudo systemctl start docker sudo systemctl enable docker ``` 接着,调整Docker默认组权限 ```shell # 将自己添加到docker组中 sudo groupadd docker sudo gpasswd -a ${USER} docker # 重启后重新load下权限 sudo service docker restart newgrp - docker ``` 最后,调整以下Docker的默认参数: ```shell sudo vim /etc/docker/daemon.json { "registry-mirrors": [ "https://registry.docker-cn.com" ], "exec-opts": ["native.cgroupdriver=systemd"] } ``` 以上调整包含两部分: - 换成了docker的国内源,稳定但是速度并不快 - 替换了cgroups驱动,这个主要是Ubuntu等几个发行版的问题,可以参考[这篇文章](https://www.coder4.com/archives/7344) 以上操作完成后,我们重启Docker服务: ```shell sudo service docker restart ``` ## 3 安装Kubernetes相关二进制文件 由于众所周知的原因,直接使用Google的apt仓库是不行的,我们直接用aliyun的(暂时没有focal的,这里沿用xenial的)。 ```shell sudo /etc/apt/source/xxx deb http://mirrors.aliyun.com/kubernetes/apt kubernetes-xenial main sudo apt-get update ``` 如果提示错误,自行import一下GPG key即可,请自行搜索。 ```shell sudo apt-get install -y kubelet kubeadm kubectl kubernetes-cni ``` 最后启动 ```shell sudo systemctl status kubelet ``` 如果是Run的状态是正常的,如果是Stopped,请查看日志,自行解决。 ### 4 安装Kubernetes所需要的镜像文件 Kubernets在启动时,会拉取大量了gcr.io上的容器镜像。 由于众所周知的原因,这些国内是无法访问的。 我们可以先将镜像离线下载到本地,再继续安装。 先看一眼需要哪些镜像,这里需要设定版本,我们用当前最新版1.22.1: ```shell kubeadm config images list --kubernetes-version v1.22.1 k8s.gcr.io/kube-apiserver:v1.22.1 k8s.gcr.io/kube-controller-manager:v1.22.1 k8s.gcr.io/kube-scheduler:v1.22.1 k8s.gcr.io/kube-proxy:v1.22.1 k8s.gcr.io/pause:3.5 k8s.gcr.io/etcd:3.5.0-0 k8s.gcr.io/coredns/coredns:v1.8.4 ``` 这里我们使用阿里云的国内镜像,我这里使用awk的方式提供执行命令,你可以将输出结果直接黏贴到shell中执行。 第一步,拉取镜像: ```shell kubeadm config images list --kubernetes-version v1.22.1 | awk -F "/" '{print "docker pull registry.aliyuncs.com/google_containers/"$NF""}' docker pull registry.aliyuncs.com/google_containers/kube-apiserver:v1.22.1 docker pull registry.aliyuncs.com/google_containers/kube-controller-manager:v1.22.1 docker pull registry.aliyuncs.com/google_containers/kube-scheduler:v1.22.1 docker pull registry.aliyuncs.com/google_containers/kube-proxy:v1.22.1 docker pull registry.aliyuncs.com/google_containers/pause:3.5 docker pull registry.aliyuncs.com/google_containers/etcd:3.5.0-0 # 最后这个要稍微特殊处理下 docker pull coredns/coredns:1.8.4 ``` 第二步,镜像tag重命名:(原因:我们换了镜像,一些前缀和tag会不对): ```shell kubeadm config images list --kubernetes-version v1.22.1 | awk -F "/" '{print "docker tag registry.aliyuncs.com/google_containers/"$2" k8s.gcr.io/"$NF""}' docker tag registry.aliyuncs.com/google_containers/kube-apiserver:v1.22.1 k8s.gcr.io/kube-apiserver:v1.22.1 docker tag registry.aliyuncs.com/google_containers/kube-controller-manager:v1.22.1 k8s.gcr.io/kube-controller-manager:v1.22.1 docker tag registry.aliyuncs.com/google_containers/kube-scheduler:v1.22.1 k8s.gcr.io/kube-scheduler:v1.22.1 docker tag registry.aliyuncs.com/google_containers/kube-proxy:v1.22.1 k8s.gcr.io/kube-proxy:v1.22.1 docker tag registry.aliyuncs.com/google_containers/pause:3.5 k8s.gcr.io/pause:3.5 docker tag registry.aliyuncs.com/google_containers/etcd:3.5.0-0 k8s.gcr.io/etcd:3.5.0-0 # 特殊处理 docker tag coredns/coredns:1.8.4 k8s.gcr.io/coredns/coredns:v1.8.4 ``` 第三步,删除重命名之前的废弃tag: ```shell kubeadm config images list --kubernetes-version v1.22.1 | awk -F "/" '{print "docker rmi registry.aliyuncs.com/google_containers/"$2""}' docker rmi registry.aliyuncs.com/google_containers/kube-apiserver:v1.22.1 docker rmi registry.aliyuncs.com/google_containers/kube-controller-manager:v1.22.1 docker rmi registry.aliyuncs.com/google_containers/kube-scheduler:v1.22.1 docker rmi registry.aliyuncs.com/google_containers/kube-proxy:v1.22.1 docker rmi registry.aliyuncs.com/google_containers/pause:3.5 docker rmi registry.aliyuncs.com/google_containers/etcd:3.5.0-0 # 特殊处理 docker rmi coredns/coredns:1.8.4 ``` 最后,让我们确认下本地有哪些镜像: ```shell docker images REPOSITORY TAG IMAGE ID CREATED SIZE k8s.gcr.io/kube-apiserver v1.22.1 f30469a2491a 3 weeks ago 128MB k8s.gcr.io/kube-proxy v1.22.1 36c4ebbc9d97 3 weeks ago 104MB k8s.gcr.io/kube-controller-manager v1.22.1 6e002eb89a88 3 weeks ago 122MB k8s.gcr.io/kube-scheduler v1.22.1 aca5ededae9c 3 weeks ago 52.7MB k8s.gcr.io/etcd 3.5.0-0 004811815584 3 months ago 295MB k8s.gcr.io/coredns/coredns v1.8.4 8d147537fb7d 3 months ago 47.6MB k8s.gcr.io/pause 3.5 ed210e3e4a5b 6 months ago 683kB ``` ## 5 初始化集群 上述准备操作,需要在3台机器都执行。 当准备妥当后,我们要初始化集群了,选择一台机器做为主节点(Master),我们假设这台的地址是192.168.6.91: ```shell sudo kubeadm init --kubernetes-version v1.22.1 --apiserver-advertise-address=192.168.6.91 --pod-network-cidr=10.6.0.0/16 ``` 上述的参数要解释下: - 集群版本1.22.1 - api主控服务器的地址192.168.6.91 - pod网络的地址是10.6.0.0/16,这里强制指定了,后面我们设定网络插件时会用。 上述执行成功后,会有一个提示,类似如下,复制出来,后面要用到: ```shell ... kubeadm join 10.3.96.3:6443 --token w1zh7w.l6chof87e113m8u7 --discovery-token-ca-cert-hash sha256:5c010cce4123abcf6c48fd98f8559b33c1efc80742270d7493035a92adf53602 ... ``` 我们初始化配置: ```shell mkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config ``` 如果一切顺利,我们安装网络插件,这里以Weave为例: ```shell kubectl apply -f "https://cloud.weave.works/k8s/net?k8s-version=$(kubectl version | base64 | tr -d '\n')" ``` 至此,主节点(Master)就配置完成了,我们继续配置其他节点。 ## 6 其他节点加入集群 在其他节点上,执行前面记录的kubeadm join命令,都执行后,等一会,回到Master节点上,集群已经ready: ```shell kubectl get nodes NAME STATUS ROLES AGE VERSION k8s1 Ready master 2m v1.14.3 k8s2 Ready 40s v1.14.3 k8s3 Ready 28s v1.14.3 ``` ## 7 测试和重置 我们部署一个nginx的pod ```shell kubectl run nginx --image=nginx ``` 在某一台机器上测试: ```shell kubectl describe pod nginx | grep ip 10.6.0.194 curl "10.6.0.194" ``` 成功! 至此,我们完成了“准生产集群”的搭建,这里准生产的意思是:他已经具备了集群特性,但还不具备高可用的能力,我们会在下一节介绍Kubernetes集群的高可用。 ================================================ FILE: src/ch05-k8s/k8s-ha-cluster.md ================================================ # 搭建Kubernetes高可用集群 在上一节,我们介绍了Kubernetes集群的搭建,我们说这是一个“准生产”级别的集群。 原因是,他不支持高可用。 设想下,假设Master节点挂掉,会出现什么情况? 由于只有一个主节点,所以集群会直接瘫痪。 本节,我们将借助KeepAlived搭建一个高可用的集群。 我们需要4台机器(物理机 or 虚拟机均可)。假设,这4台机器的IP分别为: - h1:192.168.1.12 - h2:192.168.1.10 - h3:192.168.1.9 - h4:192.168.1.16 同时我们需要一个不冲突的VIP(Virtual IP),当发生主备切换时,KeepAlive会让VIP从主Master切换到备Master上。 注意,如果你使用云主机,由于网络安全性的原因,是无法自由使用云主机的,需要单独HAVIP(高可用VIP),申请地址如下:[腾讯云]([腾讯云运营活动 - 腾讯云](https://curl.qcloud.com/4D1bXeBP)),[阿里云]([阿里云登录 - 欢迎登录阿里云,安全稳定的云计算服务平台](https://page.aliyun.com/form/act367774547/index.htm?spm=a2c4g.11186623.0.0.1acd3d30qHqmc9))。 这里假设你已经有了可用的VIP,其地址为192.168.1.8。 ## 1 部署KeepAlived 这里我们选用h1、h2做为Master节点的主机和备机。 则需要在这两台机器上安装keepalived ```shell yum install -y keepalived ``` 两台机器的配置文件分别如下: h1: ```shell ! Configuration File for keepalived global_defs { router_id LVS_DEVEL } vrrp_script check_apiserver { script " 3m38s v1.22.2 ``` 再等一会后,发现h1挂掉了: ```shell kubectl get nodes NAME STATUS ROLES AGE VERSION h1 NotReady control-plane,master 6m16s v1.22.2 h2 Ready control-plane,master 5m51s v1.22.2 h3 Ready control-plane,master 4m52s v1.22.2 h4 Ready 3m38s v1.22.2 ``` 至此,我们实现了Master的高可用! ## 7 测试高可用恢复 我们重启启动h1,稍等一会,发现一切正常! ```shell kubectl get nodes NAME STATUS ROLES AGE VERSION h1 Ready control-plane,master 8m14s v1.22.2 h2 Ready control-plane,master 7m49s v1.22.2 h3 Ready control-plane,master 6m50s v1.22.2 h4 Ready 5m36s v1.22.2 ``` 至此,你应该已经熟悉了Kubernetes集群高可用的搭建步骤。 这里提一个问题:我们将h1、h2、h3都是Master,但是只在h1和h2上设置了KeepAlived。 - 如果h3挂掉后,集群能正常工作么? - 如果h3挂掉后,h2也挂掉了,集群还能正常工作么? ================================================ FILE: src/ch05-k8s/k8s-ingress.md ================================================ # 通过ingress暴露内部服务 在kubernetes集群中,有一个常见的需求:如何将内部服务暴露出来,供外部访问? 在[快速入门Kubernetes](k8s-101.md)一节中,我们使用了Service(Load Balancer)的方式,对外暴露了nginx服务。试想:如果我们有100个内部Deployment,能够使用LB的方式,对外暴露么? 如果你还有印象,LB的对外暴露,要占用一个独立的端口,当需要暴露的服务增多时,光是端口的占用和分配,就已经是一个头疼的问题了。 实际上,Kubernetes为我们提供了三种暴露内部服务的机制: - NodePort:在Kubernetes的所有节点上,开放一个端口,转发到内部具体的service上,与LoadBalancer相比,它不会绑定外网IP,多用于临时用途(如debug) - LoadBalancer:每个服务可以绑定一个外网IP、端口,当需要暴露的服务不多时,这是官方推荐的选择。 - Ingress:像一个“智能路由器”,对外只暴露一个IP/端口,可以根据路径、头信息等变量,自动转发到内部的多个不同服务上。 本节,我们将介绍两种不同的Ingress,来实现“暴露内部多组服务这个需求”。 ## 七层ingress 首先,我们来看一下Nginx Ingress Controller,这是一款较早退出的Ingress方案,基于Nginx实现了应用层(http)协议的暴露。 我们在上一节的基础上,添加另一组deployment: ```bash kubectl create deployment my-httpd --image=httpd:2.4 kubectl scale deployment my-httpd --replicas=3 ``` 同时,我们将之前创建的ngxin,也缩容为3: ```bash kubectl scale deployment my-nginx --replicas=3 kubectl get pods NAME READY STATUS RESTARTS AGE my-httpd-84bdf5b4d9-jjvwv 1/1 Running 0 46s my-httpd-84bdf5b4d9-n269p 1/1 Running 0 16s my-httpd-84bdf5b4d9-rw2kk 1/1 Running 0 16s my-nginx-7bc876dc4b-226g9 1/1 Running 2 4h46m my-nginx-7bc876dc4b-872v2 1/1 Running 2 4h46m my-nginx-7bc876dc4b-fzr8s 1/1 Running 2 4h46m ``` 接着,我们创建上述两个deployment的service: ```bash kubectl expose deployment/my-nginx --port=80 kubectl expose deployment/my-httpd --port=80 kubectl get services NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE kubernetes ClusterIP 10.96.0.1 443/TCP 9m16s my-httpd ClusterIP 10.102.22.9 80/TCP 4s my-nginx ClusterIP 10.109.10.111 80/TCP 9s ``` 在配置ingress之前,我们首先要启用ingress: ```bash minikube addons enable ingress ``` 如果你使用的是MacOS,可能会报错,此时需要一些额外的配置,请参考这个[帖子](https://github.com/kubernetes/minikube/issues/7332)。 接下来,我们创建ingress.yaml文件: ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: homs-ingress annotations: kubernetes.io/ingress.class: nginx nginx.ingress.kubernetes.io/rewrite-target: /$1 spec: tls: - hosts: - homs.coder4.com secretName: homs-secret rules: - host: homs.coder4.com http: paths: - path: /my-nginx/?(.*) pathType: Prefix backend: service: name: my-nginx port: number: 80 - path: /my-httpd/?(.*) pathType: Prefix backend: service: name: my-httpd port: number: 80 ``` 解释一下: - 我们定义了Nginx的Ingress,并使用了转发前清除前缀(rewrite-target配置) - 定义了两个不同的前缀my-nginx和my-httpd,通过前缀指向内部服务 - 同时支持了http和https解析,但https是自签证书,所以后面我们只用http 然后创建它: ```bash kubectl apply -f ./ingress.yaml ``` 稍等一会,ingress的IP分配成功后如下所示: ```bash kubectl get ingress NAME CLASS HOSTS ADDRESS PORTS AGE homs-ingress homs.coder4.com 192.168.64.3 80, 443 34s ``` 如上所示,“192.168.64.3”就是分配的ingressIP,但我们需要用DNS访问它,这里,我使用nip.io这个黑魔法来避免需要修改hosts的问题,即修改上述yaml中的host为“192.168.64.3.nip.io”。 我们登录到minikube集群内部,尝试访问: ```shell curl -kL "http://192.168.64.3.nip.io/my-httpd"

It works!

curl -kL "http://192.168.64.3.nip.io/my-nginx" Welcome to nginx!

Welcome to nginx!

If you see this page, the nginx web server is successfully installed and working. Further configuration is required.

For online documentation and support please refer to nginx.org.
Commercial support is available at nginx.com.

Thank you for using nginx.

``` 如上,我们成功的用prefix的路径(my-nginx / my-httpd),访问了两个不同的内部service! ## 修改转发前缀 在上述的配置中,我们实现了多服务的转发,但准法后的location存在一些问题,我们换一个service验证一下: ```shell kubectl create deployment service1 --image=mendhak/http-https-echo:23 kubectl create deployment service2 --image=mendhak/http-https-echo:23 ``` 对外暴露服务: ```shell kubectl expose deployment/service1 --port=8080 kubectl expose deployment/service2 --port=808 ``` 修改一下ingress: ```shell apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: homs-ingress annotations: kubernetes.io/ingress.class: nginx nginx.ingress.kubernetes.io/rewrite-target: /$1 spec: tls: - hosts: - homs.coder4.com secretName: homs-secret rules: - host: homs.coder4.com http: paths: - path: /service1/?(.*) pathType: Prefix backend: service: name: service1 port: number: 8080 - path: /service2/?(.*) pathType: Prefix backend: service: name: service2 port: number: 8080 ``` 登录到minikube后curl: ```shell { "path": "/", "headers": { "host": "192.168.64.11.nip.io", "x-request-id": "7a00b30a5d4fd4c084d2bcfbfd44f636", "x-real-ip": "192.168.64.11", "x-forwarded-for": "192.168.64.11", "x-forwarded-host": "192.168.64.11.nip.io", "x-forwarded-port": "443", "x-forwarded-proto": "https", "x-scheme": "https", "user-agent": "curl/7.76.0", "accept": "*/*" }, "method": "GET", "body": "", "fresh": false, "hostname": "192.168.64.11.nip.io", "ip": "192.168.64.11", "ips": [ "192.168.64.11" ], "protocol": "https", "query": {}, "subdomains": [ "11", "64", "168", "192" ], "xhr": false, "os": { "hostname": "service2-5686d4f68c-4vz7d" }, "connection": {} } ``` 观察上述输出,发现转发后的location被重定向了,如果我们的服务想收到完整的请求,如何实现呢? 我们可以修改ingress配置,在路径上添加一段分组匹配,如下: ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: homs-ingress annotations: kubernetes.io/ingress.class: nginx nginx.ingress.kubernetes.io/rewrite-target: /$1$2 nginx.ingress.kubernetes.io/app-root: /service1 spec: tls: - hosts: - homs.coder4.com secretName: homs-secret rules: - host: 192.168.64.11.nip.io http: paths: - path: /(service1/?)(.*) pathType: Prefix backend: service: name: service1 port: number: 8080 - path: /(service2/?)(.*) pathType: Prefix backend: service: name: service2 port: number: 8080 ``` 生效后,再次curl: ```shell curl -kL "http://192.168.64.11.nip.io/service2" { "path": "/service2", "headers": { "host": "192.168.64.11.nip.io", "x-request-id": "b5759cf6f47d0ed713142178ddea4f96", "x-real-ip": "192.168.64.11", "x-forwarded-for": "192.168.64.11", "x-forwarded-host": "192.168.64.11.nip.io", "x-forwarded-port": "443", "x-forwarded-proto": "https", "x-scheme": "https", "user-agent": "curl/7.76.0", "accept": "*/*" }, "method": "GET", "body": "", "fresh": false, "hostname": "192.168.64.11.nip.io", "ip": "192.168.64.11", "ips": [ "192.168.64.11" ], "protocol": "https", "query": {}, "subdomains": [ "11", "64", "168", "192" ], "xhr": false, "os": { "hostname": "service2-5686d4f68c-4vz7d" }, "connection": {} } ``` 成功! Nginx Ingress也支持通过不同的Host来区分不同Service,也支持nginx的部分自定义配置,推荐你阅读[官方ingress例子]([Introduction - NGINX Ingress Controller](https://kubernetes.github.io/ingress-nginx/examples/))。 ## 四层ingress 在上述两个例子中,我们实现了7层http协议的暴露 & 转发,ingress也支持4层的TCP协议。 为了防止影响,我们首先重置集群,并重新启用ingress。 ```shell minikube delete minikube start minikube addons enable ingress ``` 接着,创建一个TCP的服务,我们以redis为例: ```shell kubectl create deployment redis --image=redis:6 kubectl expose deployment/redis --port=6379 ``` 接着,我们创建映射关系,TCP的ingress是通过ConfigMap额外配置的。 ```yaml apiVersion: v1 kind: ConfigMap metadata: name: tcp-services namespace: ingress-nginx data: 6379: "default/redis:6379" ``` 最后,我们将端口映射,修改到ingress上: ```shell kubectl edit service -n ingress-nginx ingress-nginx-controller ``` 在规则处添加如下代码: ```yaml - name: redis port: 6379 protocol: TCP targetPort: 6379 ``` 这里我们并没有填写nodePort,这是系统会自动分配的,不用我们手动处理。 保存成功后,我们尝试通过ingress的端口连接: ```shell kubectl get services --all-namespaces NAMESPACE NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE default kubernetes ClusterIP 10.96.0.1 443/TCP 35m default redis ClusterIP 10.109.20.237 6379/TCP 33m ingress-nginx ingress-nginx-controller NodePort 10.110.48.51 80:30958/TCP,443:32737/TCP,6379:32765/TCP 34m ingress-nginx ingress-nginx-controller-admission ClusterIP 10.103.12.249 443/TCP 34m kube-system kube-dns ClusterIP 10.96.0.10 53/UDP,53/TCP,9153/TCP 35m ``` 我们本地使用redis连接: ```shell redis-cli -h $(minikube ip) -p 32765 > info info # Server redis_version:6.2.6 redis_git_sha1:00000000 redis_git_dirty:0 redis_build_id:1527eab61b27d3bf redis_mode:standalone os:Linux 4.19.182 x86_64 arch_bits:64 multiplexing_api:epoll ..... ``` 成功! 至此,我们成功使用Ingress暴露了内部的TCP端口。 如果你仔细对比HTTP和TCP的Ingress,不难发现: - HTTP的Ingress更加实用,可以通过不同Host甚至不同Path,区分多个内部Service - TCP的Ingress相对来说,比较"凑合",虽然能够工作,但配置繁琐、还需要耗费多个端口,并不方便。 因此,再实际工作中,如果想从k8s集群外访问集群内的TCP服务,多采用网络打通的方式进行,我们将在后续章节介绍这一功能。 ================================================ FILE: src/ch06-cd/README.md ================================================ # 持续交付流水线 持续交付是敏捷开发的一种最佳实践,代码发生变更后,可以自动进行持续集成,测试,并部署到线上系统中。 持续交付贯穿了软件的开发、测试、发布等全生命周期,也是微服务架构的基石。 本节将借助Jenkins + 容器技术,打造自己的流水线,脉络是: 1. Jenkins的部署、插件、基本用法 2. Jenkins的Agent定制 3. 基于Jenkins的交付流水线 4. 交付流水线的改进 经过本章的实战,你将获得一套生产级别的持续交付流水线。 ================================================ FILE: src/ch06-cd/jenkins-custom.md ================================================ # Jenkins定制Agent 上一节,我们实现了最简单的打包任务,在这一节,我们将定制所需的打包环境,为CD流水线做准备。 ## 手动连接Agent 在上一节,我们使用了Kubernetes集群启动新的Slave节点,你可以沿着这条路,继续集成所需的环境,不再展开。 在本节,我们将切换另一种思路,使用手动启动&连接的方式。 首先,在Jenkins中添加一个Agent,路径是:Manage Jenkins -> Manage Nodes and Clouds -> New Node。 关键参数如下: name:自选,这里e1 Number of executors:在这台机器上的并发执行任务数,这里选默认的2 Remote root directory:默认执行目录,这里选/home/jenkins/ateng Labels:自选,这里executor,可以用它对Executor分组(如测试、线上等) Launch method:Launch agent by connecting it to the master,即我们手动连接 保存后,点击进去后,能看到如下提示: ```shell Run from agent command line: java -jar agent.jar -jnlpUrl http://127.0.0.1:8080/computer/e1/jenkins-agent.jnlp -secret b057970bf978f53a8f945d470ac644e44c945e4b7259b216f703dedb95d0cac9 -workDir "/home/jenkins/agent" Run from agent command line, with the secret stored in a file: echo b057970bf978f53a8f945d470ac644e44c945e4b7259b216f703dedb95d0cac9 > secret-file java -jar agent.jar -jnlpUrl http://127.0.0.1:8080/computer/e1/jenkins-agent.jnlp -secret @secret-file -workDir "/home/jenkins/agent" ``` 如上所示,我们需要用上述的Secret来连接Controller(主控)节点。 我们通过Docker启动Executor节点,如下: ```shell #!/bin/bash NAME="jenkins_e1" PUID="1000" PGID="1000" docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --name $NAME \ --env PUID=$PUID \ --env PGID=$PGID \ --detach \ --init jenkins/inbound-agent \ -workDir=/home/jenkins/agent \ -url http://10.1.172.136:8080 \ b057970bf978f53a8f945d470ac644e44c945e4b7259b216f703dedb95d0cac9 \ e1 ``` 温馨提示:上述的workDir需要与Jenkins中的配置保持一致。 当启动成功后,能看到节点上线了,如下图所示: ![f](./jenkins-executor-online.png) 为了不调度到Controller节点,我们可以将其上的执行数量设置为0。 随后,我们尝试修改任务,如下所示: ```groovy pipeline { agent any stages { stage('Test') { steps { sh 'echo hello world' } } } } ``` 如果一起顺利,其会成功地在e1完成执行! ## 定制Executor的环境 从上述例子中,不难理解:真正的打包任务,是在Executor中执行的。 如果我们的打包流程需要用到git、Java、Gradle、Kubernetes的话,我们也需要将这些集成到Executor中。 我们基于Jenkins的官方基础镜像进行定制,Dockerfile如下: ```shell FROM jenkins/inbound-agent:latest-jdk8 ENV GRADLE_VERSION=7.2 ENV K8S_VERSION=v1.22.3 # tool USER root RUN apt-get update && \ apt-get install -y curl unzip docker-ce docker-ce-cli && \ apt-get clean # gradle RUN curl -skL -o /tmp/gradle-bin.zip https://services.gradle.org/distributions/gradle-$GRADLE_VERSION-bin.zip && \ mkdir -p /opt/gradle && \ unzip -q /tmp/gradle-bin.zip -d /opt/gradle && \ ln -sf /opt/gradle/gradle-$GRADLE_VERSION/bin/gradle /usr/local/bin/gradle RUN chown -R 1001:0 /opt/gradle && \ chmod -R g+rw /opt/gradle # kubectl RUN curl -LO https://storage.googleapis.com/kubernetes-release/release/$K8S_VERSION/bin/linux/amd64/kubectl RUN chmod +x ./kubectl RUN mv ./kubectl /usr/local/bin USER jenkins ``` 如上所示: - 我们基于inbound-agent进行定制,这是官方的默认的Agent基础镜像 - 随后,我们使用apt安装curl、unzip等基础工具 - 接着,我们安装gradle、kubectl等二进制文件 - 最后恢复默认的运行环境 制作镜像 ```shell docker build -t "coder4/jenkins-my-agent" . ``` 制作时间会比较长 再次打包 ```groovy pipeline { agent {label 'executor'} stages { stage('git') { steps { sh "echo todo" } } stage('gradle') { steps { sh "gradle -v" } } stage('k8s') { steps { withKubeConfig([credentialsId: "60a8e9d2-0212-4ff4-aa98-f46fced97121",serverUrl: "https://kubernetes:6443"]) { sh "kubectl get nodes" } } } } } ``` 需要指出的是:上述'k8s'阶段,使用的凭据,是我们在[ Jenkins搭建入门](./jenkins-k8s.md)一节中生成的证书+凭据。 运行结果 ```shell Started by user admin [Pipeline] Start of Pipeline [Pipeline] node Running on e1 in /home/jenkins/agent/workspace/test [Pipeline] { [Pipeline] stage [Pipeline] { (git) [Pipeline] sh + git version git version 2.30.2 [Pipeline] } [Pipeline] // stage [Pipeline] stage [Pipeline] { (gradle) [Pipeline] sh + gradle -v Welcome to Gradle 7.2! Here are the highlights of this release: - Toolchain support for Scala - More cache hits when Java source files have platform-specific line endings - More resilient remote HTTP build cache behavior For more details see https://docs.gradle.org/7.2/release-notes.html ------------------------------------------------------------ Gradle 7.2 ------------------------------------------------------------ Build time: 2021-08-17 09:59:03 UTC Revision: a773786b58bb28710e3dc96c4d1a7063628952ad Kotlin: 1.5.21 Groovy: 3.0.8 Ant: Apache Ant(TM) version 1.10.9 compiled on September 27 2020 JVM: 1.8.0_302 (Temurin 25.302-b08) OS: Linux 5.10.47-linuxkit amd64 [Pipeline] } [Pipeline] // stage [Pipeline] stage [Pipeline] { (k8s) [Pipeline] withKubeConfig [Pipeline] { [Pipeline] sh + kubectl get nodes NAME STATUS ROLES AGE VERSION minikube Ready control-plane,master 6h58m v1.21.2 [Pipeline] } [kubernetes-cli] kubectl configuration cleaned up [Pipeline] // withKubeConfig [Pipeline] } [Pipeline] // stage [Pipeline] } [Pipeline] // node [Pipeline] End of Pipeline Finished: SUCCESS ``` ================================================ FILE: src/ch06-cd/jenkins-k8s-optimize.md ================================================ # Jenkins优化Kubernetes部署流水线 在上一节,我们实现了全链路的部署流水线。 本节,我们将继续完善、优化部署水线。 ## Gradle加速 首先,在之前的定制Agent中,我们使用了Gradle(Maven)的默认仓库。 由于众所周知的原因,默认仓库的速度很慢、不稳定。 这回严重降低打包流水线的速度,我们对这一问题进行优化。 修改Agent的Dockerfile如下,增加Gradle仓库配置: ```shell FROM jenkins/inbound-agent:latest-jdk8 ENV GRADLE_VERSION=7.2 ENV K8S_VERSION=v1.22.3 ENV DOCKER_CHANNEL stable ENV DOCKER_VERSION 18.06.3-ce # tool USER root RUN apt-get update && \ apt-get install -y curl unzip sudo && \ apt-get clean # docker RUN curl -fsSL "https://download.docker.com/linux/static/${DOCKER_CHANNEL}/x86_64/docker-${DOCKER_VERSION}.tgz" \ | tar -xzC /usr/local/bin --strip=1 docker/docker # gradle RUN curl -skL -o /tmp/gradle-bin.zip https://services.gradle.org/distributions/gradle-$GRADLE_VERSION-bin.zip && \ mkdir -p /opt/gradle && \ unzip -q /tmp/gradle-bin.zip -d /opt/gradle && \ ln -sf /opt/gradle/gradle-$GRADLE_VERSION/bin/gradle /usr/local/bin/gradle RUN chown -R 1001:0 /opt/gradle && \ chmod -R g+rw /opt/gradle # kubectl RUN curl -LO https://storage.googleapis.com/kubernetes-release/release/$K8S_VERSION/bin/linux/amd64/kubectl RUN chmod +x ./kubectl RUN mv ./kubectl /usr/local/bin # add jenkins user to sudoer without password RUN usermod -aG sudo jenkins RUN echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers # jenkins USER jenkins # gradle mirror ENV GRADLE_CONFIG_DIR=/home/jenkins/.gradle RUN mkdir ${GRADLE_CONFIG_DIR} RUN echo "Ly8gcHJvamVjdAphbGxwcm9qZWN0c3sKICAgIHJlcG9zaXRvcmllcyB7CgltYXZlbkxvY2FsKCkKICAgICAgICBtYXZlbiB7IHVybCAnaHR0cHM6Ly9tYXZlbi5hbGl5dW4uY29tL3JlcG9zaXRvcnkvcHVibGljLycgfQogICAgICAgIG1hdmVuIHsgdXJsICdodHRwczovL21hdmVuLmFsaXl1bi5jb20vcmVwb3NpdG9yeS9qY2VudGVyLycgfQogICAgICAgIG1hdmVuIHsgdXJsICdodHRwczovL21hdmVuLmFsaXl1bi5jb20vcmVwb3NpdG9yeS9nb29nbGUvJyB9CiAgICAgICAgbWF2ZW4geyB1cmwgJ2h0dHBzOi8vbWF2ZW4uYWxpeXVuLmNvbS9yZXBvc2l0b3J5L2dyYWRsZS1wbHVnaW4vJyB9CiAgICAgICAgbWF2ZW4geyB1cmwgJ2h0dHBzOi8vaml0cGFjay5pby8nIH0KICAgIH0KfQoKLy8gcGx1Z2luCnNldHRpbmdzRXZhbHVhdGVkIHsgc2V0dGluZ3MgLT4KICAgIHNldHRpbmdzLnBsdWdpbk1hbmFnZW1lbnQgewogICAgICAgIC8vIFByaW50IHJlcG9zaXRvcmllcyBjb2xsZWN0aW9uCiAgICAgICAgLy8gcHJpbnRsbiAiUmVwb3NpdG9yaWVzIG5hbWVzOiAiICsgcmVwb3NpdG9yaWVzLmdldE5hbWVzKCkKCiAgICAgICAgLy8gQ2xlYXIgcmVwb3NpdG9yaWVzIGNvbGxlY3Rpb24KICAgICAgICByZXBvc2l0b3JpZXMuY2xlYXIoKQoKICAgICAgICAvLyBBZGQgbXkgQXJ0aWZhY3RvcnkgbWlycm9yCiAgICAgICAgcmVwb3NpdG9yaWVzIHsKCSAgICBtYXZlbkxvY2FsKCkKICAgICAgICAgICAgbWF2ZW4gewogICAgICAgICAgICAgICAgdXJsICJodHRwczovL21hdmVuLmFsaXl1bi5jb20vcmVwb3NpdG9yeS9ncmFkbGUtcGx1Z2luLyIKICAgICAgICAgICAgfQogICAgICAgIH0KICAgIH0KfQo=" | base64 --decode > ${GRADLE_CONFIG_DIR}/init.gradle ``` 如上所示,在打包的最后环节: - 添加.gradle目录 - 创建init.gradle脚本 - 由于Dockerfile的语法格式限制,我们将配置文件编码为Base64再写入 配置文件的原文如下: ```groovy // project allprojects{ repositories { mavenLocal() maven { url 'https://maven.aliyun.com/repository/public/' } maven { url 'https://maven.aliyun.com/repository/jcenter/' } maven { url 'https://maven.aliyun.com/repository/google/' } maven { url 'https://maven.aliyun.com/repository/gradle-plugin/' } maven { url 'https://jitpack.io/' } } } // plugin settingsEvaluated { settings -> settings.pluginManagement { // Print repositories collection // println "Repositories names: " + repositories.getNames() // Clear repositories collection repositories.clear() // Add my Artifactory mirror repositories { mavenLocal() maven { url "https://maven.aliyun.com/repository/gradle-plugin/" } } } } ``` 我们使用新镜像重启Agent,会发现编译环节由1分钟缩短到10秒内。 - ## 滚动升级 在之前构建的版本中,我们只考虑了部署,没有考虑升级情况。 可以修改JenkinsFile,采用"yaml + kubectl apply"的方式,让部署支持滚动升级。 ```groovy pipeline { agent any environment { project = "coder4/homs-start" } stages { stage('git') { steps { git credentialsId: 'GITEE', url: 'git@gitee.com:/'+ project + '.git', branch: 'master' } } stage('gradle') { steps { sh "gradle build" } } stage('docker image build') { steps { sh ''' # get right jar jarPath=$(du -a ./build/libs/* | sort -n -r | head -n 1 | cut -f2-) jarFile=$( echo ${jarPath##*/} ) # make Dockerfile cat < Dockerfile FROM openjdk:8 COPY $jarPath $jarFile ENTRYPOINT ["java","-jar","/$jarFile"] EOF # build Docker image sudo docker build -t coder4/${JOB_NAME}:${BUILD_NUMBER} . # push to docker hub sudo docker push coder4/${JOB_NAME}:${BUILD_NUMBER} ''' } } stage('k8s') { steps { withKubeConfig([credentialsId: "60a8e9d2-0212-4ff4-aa98-f46fced97121",serverUrl: "https://kubernetes:6443"]) { sh """ # prepare deployment yaml cat < Dockerfile FROM openjdk:8 COPY $jarPath $jarFile ENTRYPOINT ["java","-jar","/$jarFile"] EOF # build Docker image sudo docker build -t coder4/${JOB_NAME}:${BUILD_NUMBER} . # push to docker hub sudo docker push coder4/${JOB_NAME}:${BUILD_NUMBER} ''' } } } } stage('k8s') { steps { script { env.DEPLOY_VERSION = params.Action.equals("Rollback") ? params.RollbackVersion : env.BUILD_NUMBER withKubeConfig([credentialsId: "60a8e9d2-0212-4ff4-aa98-f46fced97121",serverUrl: "https://kubernetes:6443"]) { sh """ echo "Kubernetes Deploy $JOB_NAME Version $DEPLOY_VERSION" # prepare deployment yaml cat < Status: Running IP: 172.17.0.4 IPs: IP: 172.17.0.4 Controlled By: ReplicaSet/homs-start-deployment-644677f984 Containers: homs-start-server: Container ID: docker://279e11005931dfd8aa876134bb2441294a768766261aeb0bb88b5004047f5060 Image: coder4/homs-start:111 Image ID: docker-pullable://coder4/homs-start@sha256:526640caca84a10254e42ad12dd617eaf45c75c17b4ebb7731fe623509938e5c Port: 8080/TCP Host Port: 0/TCP State: Running Started: Thu, 11 Nov 2021 19:06:31 +0800 Ready: True Restart Count: 0 Environment: Mounts: /var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-gkpv7 (ro) Conditions: Type Status Initialized True Ready True ContainersReady True PodScheduled True Volumes: kube-api-access-gkpv7: Type: Projected (a volume that contains injected data from multiple sources) TokenExpirationSeconds: 3607 ConfigMapName: kube-root-ca.crt ConfigMapOptional: DownwardAPI: true QoS Class: BestEffort Node-Selectors: Tolerations: node.kubernetes.io/not-ready:NoExecute op=Exists for 300s node.kubernetes.io/unreachable:NoExecute op=Exists for 300s Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal Scheduled 37s default-scheduler Successfully assigned default/homs-start-deployment-644677f984-bksl9 to minikube Normal Pulling 37s kubelet Pulling image "coder4/homs-start:111" Normal Pulled 31s kubelet Successfully pulled image "coder4/homs-start:111" in 5.781019732s Normal Created 31s kubelet Created container homs-start-server Normal Started 31s kubelet Started container homs-start-server ``` 接下来,我们回滚到107版本,由于机器上有镜像,因此只耗时1s。 ```shell kubectl describe pod homs-start-deployment-5bf947768c-dt8w2 Name: homs-start-deployment-5bf947768c-dt8w2 Namespace: default Priority: 0 Node: minikube/192.168.49.2 Start Time: Thu, 11 Nov 2021 18:49:22 +0800 Labels: app=homs-start pod-template-hash=5bf947768c Annotations: Status: Running IP: 172.17.0.5 IPs: IP: 172.17.0.5 Controlled By: ReplicaSet/homs-start-deployment-5bf947768c Containers: homs-start-server: Container ID: docker://bc626494af343b6d56b707258e03a85ae668abb21dcc3ca2b72d6239e3b56b3d Image: coder4/homs-start:107 Image ID: docker-pullable://coder4/homs-start@sha256:526640caca84a10254e42ad12dd617eaf45c75c17b4ebb7731fe623509938e5c Port: 8080/TCP Host Port: 0/TCP State: Running Started: Thu, 11 Nov 2021 18:49:27 +0800 Ready: True Restart Count: 0 Environment: Mounts: /var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-dt7g2 (ro) Conditions: Type Status Initialized True Ready True ContainersReady True PodScheduled True Volumes: kube-api-access-dt7g2: Type: Projected (a volume that contains injected data from multiple sources) TokenExpirationSeconds: 3607 ConfigMapName: kube-root-ca.crt ConfigMapOptional: DownwardAPI: true QoS Class: BestEffort Node-Selectors: Tolerations: node.kubernetes.io/not-ready:NoExecute op=Exists for 300s node.kubernetes.io/unreachable:NoExecute op=Exists for 300s Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal Scheduled 16m default-scheduler Successfully assigned default/homs-start-deployment-5bf947768c-dt8w2 to minikube Normal Pulling 16m kubelet Pulling image "coder4/homs-start:107" Normal Pulled 16m kubelet Successfully pulled image "coder4/homs-start:107" in 3.365201023s Normal Created 16m kubelet Created container homs-start-server Normal Started 16m kubelet Started container homs-start-server ``` 在本文中,我们围绕编译、镜像进行了优化,但这还远没有达到"完美"的程度。 我提一些思路,供大家参考: 1. docker镜像瘦身:打Dokcer镜像时,其实无需将jdk+ jar包一起打,可以只打jar包。在生成Deployment时,通过Pod的init container模式,将jar包拷贝进jdk的运行容器中,从而完成启动。 2. 回滚版本选择优化:在前面的实现中,我们筛选了所有成功部署过的版本,将其做为可回滚的版本,但这其中的一部分,实际是通过"回滚"的方式部署成功的,在镜像仓库中,并没有与之对应的镜像版本。我们可以拉取镜像仓库中可用的版本,来实现回滚。 3. 镜像版本优化:目前采用的是Job的"Build Version"做为镜像版本,可以再此基础上,追加Git版本号,以便区分代码拉取。 4. 支持多分之:当前,我们默认用的是master分之,应当可以通过参数的方式,支持不同分之的修改。 5. JenkinsFile共享:目前的JenkinsFile是直接配置在项目中的,如果微服务项目很多,逐一配置势必很麻烦,可以通过 “Jenkins Shared Library”的方式,在多项目间共享脚本配置。 ================================================ FILE: src/ch06-cd/jenkins-k8s.md ================================================ # Jenkins实现Kubernetes部署流水线 在Agent定制环境准备好后,我们将构建完整的部署流水线。 根据我们选用的技术栈,部署流水线划分为如下阶段: 1. checkout代码 2. gradle编译 3. 构建Docker镜像、推送到镜像服务器 4. 发布到Kubernetes中 在开始构建流水线前,我们还需要做一些准备工作。 ## 准备工作 首先,我们需要创建一个新的Spring Boot项目homs-start,用于流水线的演示。 这里使用Sping Boot Starter直接生成的,代码放到了Gitee托管,参考[这里](https://gitee.com/coder4/homs-start)。 第二步,我们需要修改Jenskin的项目名,从test修改为homs-start。 接下来,我们需要在Jenkins上配置Gitee的ssh key凭据。 1. 先确认已在Gitee上配置了公钥,并且保留了对应的私钥,参考[这篇教程](https://gitee.com/help/articles/4181)。 2. 在Jenkins上配置Gitee的凭据,路径是:Jenkins -> Manage Jenkins -> Manage Credentials -> Global 3. SSH Username with private key,填入gitee的用户名和对应私钥,命名为GITEE ![f](./jenkins-gitee-credential.png) 在流水线的步骤3中,我们需要打包一个新的镜像。 如果你还记得前两节的内容,应该知道我们的Agent实际是运行在Docker中的。 因此,我们的Agent需要具有"Docker Inside Docker"的能力,一般常见的有三种方法,可以参考[这篇文章]([如何在Docker容器中运行Docker [3种方法] - 云+社区 - 腾讯云](https://cloud.tencent.com/developer/article/1697053))。 本文中,我们选用socks挂载的模式,对Agent的镜像做一些改造,如下: ```shell FROM jenkins/inbound-agent:latest-jdk8 ENV GRADLE_VERSION=7.2 ENV K8S_VERSION=v1.22.3 ENV DOCKER_CHANNEL stable ENV DOCKER_VERSION 18.06.3-ce # tool USER root RUN apt-get update && \ apt-get install -y curl unzip sudo && \ apt-get clean # docker RUN curl -fsSL "https://download.docker.com/linux/static/${DOCKER_CHANNEL}/x86_64/docker-${DOCKER_VERSION}.tgz" \ | tar -xzC /usr/local/bin --strip=1 docker/docker # gradle RUN curl -skL -o /tmp/gradle-bin.zip https://services.gradle.org/distributions/gradle-$GRADLE_VERSION-bin.zip && \ mkdir -p /opt/gradle && \ unzip -q /tmp/gradle-bin.zip -d /opt/gradle && \ ln -sf /opt/gradle/gradle-$GRADLE_VERSION/bin/gradle /usr/local/bin/gradle RUN chown -R 1001:0 /opt/gradle && \ chmod -R g+rw /opt/gradle # kubectl RUN curl -LO https://storage.googleapis.com/kubernetes-release/release/$K8S_VERSION/bin/linux/amd64/kubectl RUN chmod +x ./kubectl RUN mv ./kubectl /usr/local/bin # add jenkins user to sudoer without password RUN usermod -aG sudo jenkins RUN echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers USER jenkins ``` 如上所述,我们对构建镜像的改动如下: - 增加了docker二进制文件 - 对用户jenkins添加了sudo免密权限 运行脚本也需要做一些改造: ```shell #!/bin/bash NAME="jenkins_e1" PUID="1000" PGID="1000" docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --name $NAME \ --env PUID=$PUID \ --env PGID=$PGID \ --add-host kubernetes:10.1.172.136 \ --volume /var/run/docker.sock:/var/run/docker.sock \ --detach \ --init coder4/jenkins-my-agent \ -workDir=/home/jenkins/agent \ -url http://10.1.172.136:8080 \ b057970bf978f53a8f945d470ac644e44c945e4b7259b216f703dedb95d0cac9 \ e1 ``` 运行脚本的主要是,挂载了/var/run/docker.sock到容器内。 运行后,我们以默认用户登录到容器内,查看docker是否可以正常使用: ```shell jenkins@936e27b3c460:~$ sudo docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 936e27b3c460 coder4/jenkins-my-agent "/usr/local/bin/jenk…" 6 seconds ago Up 4 seconds jenkins_e1 577db2106c7d jenkins/jenkins:lts-jdk11 "/sbin/tini -- /usr/…" 4 days ago Up About an hour 0.0.0.0:8080->8080/tcp, :::8080->8080/tcp, 0.0.0.0:50000->50000/tcp, :::50000->50000/tcp jenkins d44c3e421fb7 registry.cn-hangzhou.aliyuncs.com/google_containers/kicbase:v0.0.25 "/usr/local/bin/entr…" 5 days ago Up About an hour 127.0.0.1:50437->22/tcp, 127.0.0.1:50440->2376/tcp, 127.0.0.1:50442->5000/tcp, 127.0.0.1:50443->8443/tcp, 127.0.0.1:50441->32443/tcp minikube ``` 注意,因为挂载的socks默认是root权限,这里需要使用sudo。 ## 构建脚本 下面,我们按照流水线的步骤,构建脚本如下: ```groovy pipeline { agent any environment { project = "coder4/homs-start" } stages { stage('git') { steps { git credentialsId: 'GITEE', url: 'git@gitee.com:/'+ project + '.git', branch: 'master' } } stage('gradle') { steps { sh "gradle build" } } stage('docker image build') { steps { sh ''' # get right jar jarPath=$(du -a ./build/libs/* | sort -n -r | head -n 1 | cut -f2-) jarFile=$( echo ${jarPath##*/} ) # make Dockerfile cat < Dockerfile FROM openjdk:8 COPY $jarPath $jarFile ENTRYPOINT ["java","-jar","/$jarFile"] EOF # build Docker image sudo docker build -t coder4/${JOB_NAME}:${BUILD_NUMBER} . # push to docker hub sudo docker push coder4/${JOB_NAME}:${BUILD_NUMBER} ''' } } stage('k8s') { steps { withKubeConfig([credentialsId: "60a8e9d2-0212-4ff4-aa98-f46fced97121",serverUrl: "https://kubernetes:6443"]) { sh "kubectl create deployment my-nginx --image=coder4/${JOB_NAME}:${BUILD_NUMBER}" } } } } } ``` 脚本比较长,我们分步解析: 1. git拉代码 1. 这里直接使用的gitee的公开仓库,可以根据实际情况,替换为公司内的gitlab等私有仓库 2. GITEE的凭据,就是在准备工作中配置的那个 2. gradle编译 1. 这里直接使用gradle build命令 2. 编译好后,会在build/libs目录下,生成jar包 3. 打包Docker镜像,上传镜像 1. 首先选择build/libs下尺寸最大的jar包(一般是fat jar,可独立运行的那个) 2. 基于openjdk8的基础镜像,添加打好的jar包,并设定启动为jar包 3. 构建好镜像后,将其推送到镜像仓库。这里选用了Docker Hub共有仓库,你可以换用Harbor等私有仓库。 4. 这里默认使用项目名做为镜像名,构建版本做为镜像版本号 4. 在Kubernetes上部署 1. 使用上面的镜像,创建一个deployment 保存上述JenkinsFile脚本后,点击部署,如果一切顺利,会部署成功,我们看一下部署结果: ```shell kubectl get pods NAME READY STATUS RESTARTS AGE homs-start795f967dd6-7szxp 1/1 Running 0 57s ``` 查看日志: ```shell kubectl logs -f my-nginx-795f967dd6-7szxp . ____ _ __ _ _ /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \ ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \ \\/ ___)| |_)| | | | | || (_| | ) ) ) ) ' |____| .__|_| |_|_| |_\__, | / / / / =========|_|==============|___/=/_/_/_/ :: Spring Boot :: (v2.5.6) 2021-11-10 02:49:45.469 INFO 1 --- [ main] com.homs.start.StartApplication : Starting StartApplication using Java 1.8.0_312 on my-nginx-795f967dd6-7szxp with PID 1 (/homs-start-0.0.1-SNAPSHOT.jar started by root in /) 2021-11-10 02:49:45.473 INFO 1 --- [ main] com.homs.start.StartApplication : No active profile set, falling back to default profiles: default 2021-11-10 02:49:46.866 INFO 1 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http) 2021-11-10 02:49:46.887 INFO 1 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat] 2021-11-10 02:49:46.887 INFO 1 --- [ main] org.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/9.0.54] 2021-11-10 02:49:46.999 INFO 1 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext 2021-11-10 02:49:47.000 INFO 1 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 1450 ms 2021-11-10 02:49:47.964 INFO 1 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path '' 2021-11-10 02:49:47.974 INFO 1 --- [ main] com.homs.start.StartApplication : Started StartApplication in 3.119 seconds (JVM running for 5.216) ``` 如上所示,Pod中的Spring Boot进程已成功启动! 至此,我们已经完整地实现了全链路的部署流水线开发。 同时,上述流水线还有很大的改进空间,我们将在下一节继续优化流水线。 ================================================ FILE: src/ch06-cd/jenkins.md ================================================ ## Jenkins搭建入门 Jenkins是一款开源、强大的持续集成工具,其前身是Hudson(商用软件)。 本节将介绍Jenkins的搭建。从架构上理解,Jenklins由两类角色组成: - Controller:主控节点,负责管理、配置工作,也称作Master节点。 - Agent:执行具体作业的工作节点,也称作Slave节点,或者Executor节点。 严格来说,Master节点也可以执行具体作业,但是处于安全性考虑,不建议这样做。 ## Jeknins的启动与初始配置 首先启动Controller节点: ```bash #!/bin/bash NAME="jenkins" PUID="1000" PGID="1000" VOLUME="$HOME/docker_data/jenkins" mkdir -p $VOLUME docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ -v $VOLUME:/var/jenkins_home \ -p 8080:8080 \ -p 50000:50000 \ --detach \ --restart always \ jenkins/jenkins:lts-jdk11 ``` 如上所示,我们启动了jenkins的主控节点,并对外暴露了8080、5000两个端口。 我们在浏览器中打开如下链接:http://127.0.0.1:8080/ ![f](./jenkins-install.png) 第一次启动会进行初始化,要求输入密码,我们使用如下命令查看: ```shell docker logs -f jenkins .... ************************************************************* ************************************************************* ************************************************************* Jenkins initial setup is required. An admin user has been created and a password generated. Please use the following password to proceed to installation: 9169c97282d64545b36bc96cf7c1aaab This may also be found at: /var/jenkins_home/secrets/initialAdminPassword ************************************************************* ************************************************************* ************************************************************* 2021-11-04 03:15:53.502+0000 [id=49] INFO h.m.DownloadService$Downloadable#load: Obtained the updated data file for hudson.tasks.Maven.MavenInstaller 2021-11-04 03:15:53.502+0000 [id=49] INFO hudson.util.Retrier#start: Performed the action check updates server successfully at the attempt #1 2021-11-04 03:15:53.517+0000 [id=49] INFO hudson.model.AsyncPeriodicWork#lambda$doRun$0: Finished Download metadata. 36,815 ms码 ``` 如上中间部分,即初始密码。 输入初始密码后,会要求安装创建,建议至少安装下述插件: - Gradle:用于Java项目的打包和编译 - Pipeline:用户开发流水线作业 - Git:用于代码拉取 - SSH Build Agents - Kubernetes:用于在Kubernetes集群上启动Slave节点 - Kubernetes CLI:用于执行远程Kubernetes的二进制文件 安装完插件后,需要创建初始管理员账号。 ## Jeknins的Agent节点配置 启动Controller节点后,我们着手配置Slave节点,这里也有多种选项: - 启动固定数量的Slave节点 - 按需启动,用完释放 - 上述两种方案的混合 考虑到并发性、资源利用率,我们选用方案2:在Kubernetes集群上,按需启动Slave容器,执行完毕后销毁。 首先,我们需要登录到Kubernetes集群的Master节点上,查看已有的证书信息。 ```shell cd ~/.kube/config apiVersion: v1 clusters: - cluster: certificate-authority: /Users/coder4/.minikube/ca.crt extensions: - extension: last-update: Thu, 04 Nov 2021 11:23:17 CST provider: minikube.sigs.k8s.io version: v1.22.0 name: cluster_info server: https://127.0.0.1:52058 name: minikube contexts: - context: cluster: minikube extensions: - extension: last-update: Thu, 04 Nov 2021 11:23:17 CST provider: minikube.sigs.k8s.io version: v1.22.0 name: context_info namespace: default user: minikube name: minikube current-context: minikube kind: Config preferences: {} users: - name: minikube user: client-certificate: /Users/coder4/.minikube/profiles/minikube/client.crt client-key: /Users/coder4/.minikube/profiles/minikube/client.key ``` 如上,共包含了3个证书/密钥:ca.crt、client.crt、client.key。 我们使用他们创建新的凭据,供Jenkins使用: ```shell openssl pkcs12 -export -out ./kube-jenkins.pfx -inkey ./client.key -in ./client.crt -certfile ./ca.crt ``` 上述创建过程会要求输入密码,请记牢后续会用到。 此外,上述文件中的ca.crt后面会再次用到。 在Jenkins上配置Kubernetes集群之前,我们假设以下信息: - 10.1.172.136:Jenkins所在的物理机节点 - https://127.0.0.1:52058:Kubernetes集群的api server地址 由于我当前使用的minikube,不难发现,minikube的api server只在本地开了端口,并没有监听到物理机上,因此网段是不通的,所以我们先使用socat进行端口映射。 ```shell socat TCP4-LISTEN:6443,fork TCP4:127.0.0.1:52058 ``` 如上,经过映射后,所有打到本机的公网IP(10.1.172.136)、端口6443上的流量,会被自动转发到52058上。 接下来,我们着手在Jenkins上添加Kubernetes的集群配置。 Manage Jenkins -> Manage Nodes and Clouds -> Configure Clouds -> Add a new cloud -> Kubernetes 截图如下: ![f](./jenkins-k8s-first.png) 其中核心配置如下: - 名称:自选必填,这里选了kubernetes - Kuberenetes地址:https://10.1.172.136:6443 - Kubernetes 服务证书 key:输入上文中ca.crt中的信息,注意换行问题。 - 凭据:上传上述生成的kube-jenkins.pfx,同时输入密码 - Jenkins地址:http://10.1.172.136:8080 上述天禧后,点击"连接测试",如果一切正常,你会发现如下报错: ![f](./jenkins-host-invalid.png) 这是因为我们经过转发后,host与证书中的并不匹配。 我们修改下Jenkins的docker启动脚本,添加hosts参数: ```shell --add-host kubernetes:10.1.172.136 ``` 重启Jenkins后,将上述位置的"Kuberenetes地址"修改为"https://kubernetes:6443",再次重试连接,一切会成功。 记得保存所有配置。 ## 测试任务 我们配置一个测试任务: 新建任务 -> 流水线 代码如下: ```groovy podTemplate { node(POD_LABEL) { stage('Run shell') { sh 'echo hello world' } } } ``` 保存后,点击"立即构建",运行结果如下: ```shell Started by user admin [Pipeline] Start of Pipeline [Pipeline] podTemplate [Pipeline] { [Pipeline] node Created Pod: kubernetes default/test-4-xsc01-4292c-4rkrz [Normal][default/test-4-xsc01-4292c-4rkrz][Scheduled] Successfully assigned default/test-4-xsc01-4292c-4rkrz to minikube [Normal][default/test-4-xsc01-4292c-4rkrz][Pulled] Container image "jenkins/inbound-agent:4.3-4-jdk11" already present on machine [Normal][default/test-4-xsc01-4292c-4rkrz][Created] Created container jnlp [Normal][default/test-4-xsc01-4292c-4rkrz][Started] Started container jnlp Agent test-4-xsc01-4292c-4rkrz is provisioned from template test_4-xsc01-4292c --- apiVersion: "v1" kind: "Pod" metadata: annotations: buildUrl: "http://10.1.172.136:8080/job/test/4/" runUrl: "job/test/4/" labels: jenkins: "slave" jenkins/label-digest: "802a637918cdcb746f1931e3fa50c8f991b59203" jenkins/label: "test_4-xsc01" name: "test-4-xsc01-4292c-4rkrz" spec: containers: - env: - name: "JENKINS_SECRET" value: "********" - name: "JENKINS_AGENT_NAME" value: "test-4-xsc01-4292c-4rkrz" - name: "JENKINS_NAME" value: "test-4-xsc01-4292c-4rkrz" - name: "JENKINS_AGENT_WORKDIR" value: "/home/jenkins/agent" - name: "JENKINS_URL" value: "http://10.1.172.136:8080/" image: "jenkins/inbound-agent:4.3-4-jdk11" name: "jnlp" resources: limits: {} requests: memory: "256Mi" cpu: "100m" volumeMounts: - mountPath: "/home/jenkins/agent" name: "workspace-volume" readOnly: false nodeSelector: kubernetes.io/os: "linux" restartPolicy: "Never" volumes: - emptyDir: medium: "" name: "workspace-volume" Running on test-4-xsc01-4292c-4rkrz in /home/jenkins/agent/workspace/test [Pipeline] { [Pipeline] stage [Pipeline] { (Run shell) [Pipeline] sh + echo hello world hello world [Pipeline] } [Pipeline] // stage [Pipeline] } [Pipeline] // node [Pipeline] } [Pipeline] // podTemplate [Pipeline] End of Pipeline Finished: SUCCESS ``` 至此,我们已经成功配置了基础的Jenkins,并成功在Kubernetes集群上执行了一次构建任务。 ================================================ FILE: src/ch07-tools/.md ================================================ ================================================ FILE: src/ch07-tools/README.md ================================================ # 工具链 微服务架构的成功落地,离不开工具链的辅助。 本节将讨论与研发密切相关的工具链,包括 1. 快速生成微服务的模板工具 2. Ldap及内网认证系统 3. 基于Gitlab的私有代码平台 4. 基于JFrog Artifactory的Maven私有仓库 5. 基于Registry 2的Docker镜像私有仓库 如果你有好工具推荐,请提Issue告诉我 : - ) ================================================ FILE: src/ch07-tools/gitlab.md ================================================ # 基于Gitlab搭建版本控制平台 做为程序员,你一定使用过GitHub / Gitee等开源代码仓库。 对于公司而言,直接将代码上传到开源仓库,对所有用户公开,会面临诸多问题: - 泄露商业机密 - 安全漏洞泄露 - 被抄袭、盗版 因此,在公司内自建一套私有的代码仓库,是十分必要的。 本节,我们将基于Gitlab,搭建私有的版本控制系统。 ## 运行 我们使用Docker版本启动,脚本如下: ```shell #!/bin/bash NAME="gitlab" PUID="1000" PGID="1000" VOLUME="$HOME/docker_data/gitlab" mkdir -p $VOLUME/{data,logs,config} docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ --volume "$VOLUME/config":/etc/gitlab \ --volume "$VOLUME/logs":/var/log/gitlab \ --volume "$VOLUME/data":/var/opt/gitlab \ --env PUID=$PUID \ --env PGID=$PGID \ -p 8888:80 \ -p 10022:22 \ --detach \ --restart always \ gitlab/gitlab-ce:14.1.8-ce.0 ``` 解释一下: - 上述开放了两个端口,8888和22 - gitlab的配置放置于3个不同的位置,我们分别设置了Volume - 由于该镜像内置了多个进程,启动时间会比较久 启动后,我们首先查看初始管理员密码,在config/initial_root_password文件中,只会保留24小时: ```shell # WARNING: This value is valid only in the following conditions # 1. If provided manually (either via `GITLAB_ROOT_PASSWORD` environment variable or via `gitlab_rails['initial_root_password']` setting in `gitlab.rb`, it was provided before database was seeded for the first time (usually, the first reconfigure run). # 2. Password hasn't been changed manually, either via UI or via command line. # # If the password shown here doesn't work, you must reset the admin password following https://docs.gitlab.com/ee/security/reset_user_password.html#reset-your-root-password. Password: Sgh1UigBM6ht5ApoW1z2N4JOLHFoivK/EwpQwZ1PylI= # NOTE: This file will be automatically deleted in the first reconfigure run after 24 hours. ``` ## 配置 首先,修改conf/gitlab.rb,修改host,这里可以命名为实际的IP和端口: ```shell external_url 'http://10.1.172.136:8888' ``` 下一步,修改conf/gitlab.rb,添加ldap配置: ```ruby gitlab_rails['ldap_enabled'] = true gitlab_rails['prevent_ldap_sign_in'] = false gitlab_rails['ldap_servers'] = YAML.load <<-'EOS' main: label: 'LDAP' host: '10.1.172.136' port: 389 uid: 'cn' bind_dn: 'cn=readonly,dc=coder4,dc=com' password: 'readonly123' encryption: 'plain' # "start_tls" or "simple_tls" or "plain" verify_certificates: false smartcard_auth: false active_directory: false allow_username_or_email_login: false lowercase_usernames: false block_auto_created_users: false base: 'ou=rd,dc=coder4,dc=com' user_filter: '' attributes: username: ['cn'] email: ['mail'] name: cn ## EE only group_base: '' admin_group: '' sync_ssh_keys: false EOS ``` 上面的配置信息,与我们在[LDAP](./ldap.md)中的设置的信息维持一致,请根据需要自行修改。 重新应用配置,并重启: ```shell docker exec -it gitlab /bin/bash ./bin/gitlab-ctl reconfigure ``` ![f](./gitlab-ldap.png) 重启后,我们使用LDAP中配置的zhangsan / 123456进行登录,成功! 搭建Gitlab只是起点,你还应熟悉基本用法、开发模式,推荐如下文章: - [安装和使用GitLab]([安装和使用GitLab - 云服务器 ECS - 阿里云](https://help.aliyun.com/document_detail/52857.html)) - [Creating merge requests]([Creating merge requests | GitLab](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)) ================================================ FILE: src/ch07-tools/jfrog-artifactory.md ================================================ # JFrog Artifactory搭建Maven私有仓库 在本书技术架构中,我们选用了Gradle做为Java的依赖管理工具。 实际上,Gradle只提供了构建的前端,实际使用的还是Maven仓库。 出于安全性、速度等因素的考量,我们需要配置私有的Maven仓库。 本节,我们将基于JFrog Artifactory,搭建私有的Maven仓库。 ## 运行 启动脚本如下: ```shell #!/bin/bash NAME="artifactory" PUID="1000" PGID="1000" VOLUME="$HOME/docker_data/artifactory" mkdir -p $VOLUME docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ --volume $VOLUME:/var/opt/jfrog/artifactory \ --env PUID=$PUID \ --env PGID=$PGID \ -p 8081:8081 \ -p 8082:8082 \ --detach \ --restart always \ releases-docker.jfrog.io/jfrog/artifactory-oss:latest ``` 启动的时间会略长,可以访问 http://127.0.0.1:8082/ui/ 登录,默认的用户名是admin,密码是password。 ## 配置 我们首先设置ldap关联,打开如下菜单:Administration -> Security -> LDAP -> Add Setting - LDAP URL:ldap://10.1.172.136:389/dc=coder4,dc=com - User DN Pattern:cn={0},ou=rd - Manager DN:cn=readonly,dc=coder4,dc=com - Manager Password:readonly123 如下图所示: ![f](./artifactory-ldap.png) 接着,我们打开匿名访问权限(默认是关闭的),位于: Administration -> Security -> Security onfiguration 选中Allow Anonymous Access,然后点击保存。如下图所示 ![f](./artifactory-anoymous-access.png) 最后,我们新建一个仓库: Repositories -> Add Repositories -> Local Repositories - Key:homs-release / homs-snapshot - Package Type:Gradle - Repository Key:homs - Handle Releases / Handle Snapshots 同时,需要给用户配置权限: - Application -> Regisigrition -> Artifacts 至此,仓库侧的配置已经完成。 ## 使用私有Maven仓库 首先,添加本地全局配置: 修改文件:~/.gradle/gradle.properties ```groovy mavenReleaseRepo=http://127.0.0.1:8082/artifactory/homs-release/ mavenSnapshotRepo=http://127.0.0.1:8082/artifactory/homs-snapshot/ mavenUsername=zhangsan mavenPassword=123456 ``` 上述配置了私有仓库地址,测试和发布是分开的 接下来,我们在项目中修改 homs-demo/build.gradle: ```groovy plugins { id 'java' id 'idea' id 'org.springframework.boot' version '2.5.3' apply false id 'io.spring.dependency-management' version '1.0.11.RELEASE' apply false id "io.freefair.lombok" version "6.1.0" apply false } subprojects { group = 'com.coder4' version = '0.0.1-SNAPSHOT' sourceCompatibility = '1.8' apply plugin: 'java' apply plugin: 'maven-publish' publishing { publications { "$project.name"(MavenPublication) { groupId project.group artifactId project.name version project.version from components.java } } repositories { maven { credentials { username mavenUsername password mavenPassword } url = version.endsWith('SNAPSHOT') ? mavenSnapshotRepo : mavenReleaseRepo } } } } ``` 如上所示,我们修改了主项目中的配置 - publising会为每个子项目添加发布任务 - repositoreis指定了发布的私有仓库地址 在子项目中,我们需要略作修改,如下: homs-client/build.gradle ```groovy plugins { id 'java' // id 'io.spring.dependency-management' } dependencies { implementation platform('com.coder4:bom-homs:1.0') implementation platform('org.springframework.boot:spring-boot-dependencies:2.5.3') implementation 'com.google.protobuf:protobuf-java' implementation "io.grpc:grpc-stub" implementation "io.grpc:grpc-protobuf" implementation 'io.grpc:grpc-netty-shaded' implementation "org.slf4j:slf4j-api" implementation 'com.alibaba.nacos:nacos-client:2.0.3' implementation 'org.springframework.boot:spring-boot-autoconfigure:2.2.0.RELEASE' } ``` 上述修改,去掉了spring dependency这个插件,转而使用platform模式。 这是一个spring + maven-publish插件共同使用导致的bug,建议都用platform来解决。 最后,我们尝试发布: ```shell gradle publish > Task :publishHoms-demo-clientPublicationToMaven2Repository Cannot upload checksum for snapshot-maven-metadata.xml because the remote repository doesn't support SHA-512. This will not fail the build. Cannot upload checksum for module-maven-metadata.xml because the remote repository doesn't support SHA-512. This will not fail the build. > Task :publishHoms-demo-serverPublicationToMaven2Repository Cannot upload checksum for snapshot-maven-metadata.xml because the remote repository doesn't support SHA-512. This will not fail the build. Cannot upload checksum for module-maven-metadata.xml because the remote repository doesn't support SHA-512. This will not fail the build. BUILD SUCCESSFUL in 2s 4 actionable tasks: 4 executed ``` 成功! ## 引入依赖 首先配置全局依赖: ~/.gradle/init.gradle ```groovy // project allprojects{ repositories { mavenLocal() maven { url mavenSnapshotRepo } maven { url mavenReleaseRepo } maven { url 'https://maven.aliyun.com/repository/public/' } maven { url 'https://maven.aliyun.com/repository/jcenter/' } maven { url 'https://maven.aliyun.com/repository/google/' } maven { url 'https://maven.aliyun.com/repository/gradle-plugin/' } maven { url 'https://jitpack.io/' } } } // plugin settingsEvaluated { settings -> settings.pluginManagement { // Print repositories collection // println "Repositories names: " + repositories.getNames() // Clear repositories collection repositories.clear() // Add my Artifactory mirror repositories { mavenLocal() maven { url "https://maven.aliyun.com/repository/gradle-plugin/" } } } } ``` 在下游中修改homs-start中添加依赖,和往常一样: ```groovy plugins { id 'org.springframework.boot' version '2.5.6' id 'io.spring.dependency-management' version '1.0.11.RELEASE' id 'java' id 'maven-publish' } group = 'com.homs' version = '0.0.1-SNAPSHOT' sourceCompatibility = '1.8' repositories { mavenCentral() } dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' testImplementation 'org.springframework.boot:spring-boot-starter-test' implementation 'com.coder4:homs-demo-client:0.0.1-SNAPSHOT' } test { useJUnitPlatform() } ``` 尝试构建,成功! ```shell gradle build BUILD SUCCESSFUL in 7s ``` 至此,我们成功引入了基于私有Maven仓库。 ================================================ FILE: src/ch07-tools/ldap.md ================================================ # 基于LDAP的内网统一认证 对于任何公司而言,一套“内部通用”的统一认证系统是必不可少的。 请注意两个关键字:内部、通用。 - 内部:认证系统只在公司内部关联的系统使用,并且需要关联具体的员工信息,如:工号、用户名、邮箱等。 - 通用:这套系统不是只提供验证,还要和其他系统共享认证,例如:项目管理系统、版本控制系统、发布系统等等。 在本书中,我们选取LDAP(Lightweight Directory Access Protocol)做为统一认证工具。 LDAP是一个开放的,中立的,工业标准的应用协议,通过IP协议提供访问控制和维护分布式信息的目录信息。 由于LDAP出现的年代比较久远(1993),也并非专门为公司认证设计的,因此其易用性较差。我们选用[LDAP Account Manager](https://www.ldap-account-manager.org/)做为辅助管理工具。 ## 部署open-ldap服务 我们选用开源的open-ldap做为服务端,进行部署: ```shell #!/bin/bash NAME="openldap" PUID="1000" PGID="1000" VOLUME="$HOME/docker_data/openldap/" mkdir -p $VOLUME docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ --volume "$VOLUME:/data/openldap/" \ -e PUID=$PUID \ -e PGID=$PGID \ -e LDAP_TLS=false \ -e LDAP_DOMAIN=coder4.com \ -e LDAP_ADMIN_PASSWORD=admin123 \ -e LDAP_CONFIG_PASSWORD=config123 \ -e LDAP_READONLY_USER=true \ -e LDAP_READONLY_USER_USERNAME=readonly \ -e LDAP_READONLY_USER_PASSWORD=readonly123 \ -p 389:389 \ -p 636:636 \ --detach \ --restart always \ osixia/openldap:1.5.0 ``` 如上所示: - 关闭了TLS加密,在生产环境中,建议配置证书并打开它 - 域名:coder4.com,可以根据需要自行更改,会影响用户的后缀 - 管理员密码:admin123,请根据需要自行更改 - 配置用户密码:config123,请根据需要自行更改 - 只读用户:readonly/readlony123,可自行更改 启动成功后,我们校验下初始化的几个用户: 首先是admin,你会发现用户是通过逗号分割、分组的,你要适用ldap的这种表示方法。 ```shell ldapwhoami -h 127.0.0.1 -p 389 -D "cn=admin,dc=coder4,dc=com" -w admin123 dn:cn=admin,dc=coder4,dc=com ``` 接下来是readonly ```shell ldapwhoami -h 127.0.0.1 -p 389 -D "cn=readonly,dc=coder4,dc=com" -w readonly123 dn:cn=readonly,dc=coder4,dc=com ``` 最后,我们添加两个组织结构,研发部rd和人力资源部hr: ```shell version: 1 # rd org dn: ou=rd,dc=coder4,dc=com objectClass: top objectClass: organizationalUnit ou: rd # hr org dn: ou=hr,dc=coder4,dc=com objectClass: top objectClass: organizationalUnit ou: hr ``` 执行添加动作: ```shell ldapadd -c -h 127.0.0.1 -p 389 -w admin123 -D "cn=admin,dc=coder4,dc=com" -f ./org.ldif ``` ## 启用Ldap Account Manager 我们通过Docker运行LAM,如下: ```shell #!/bin/bash NAME="lam" PUID="1000" PGID="1000" docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ -e PUID=$PUID \ -e PGID=$PGID \ -e LDAP_DOMAIN=coder4.com \ -e LDAP_SERVER=ldap://10.1.172.136:389 \ -e LDAP_USER=cn=admin,dc=coder4,dc=com \ -e LAM_PASSWORD=lam123 \ -p 8080:80 \ --detach \ --restart always \ ldapaccountmanager/lam:7.7 ``` 解释下上述配置: - 域名:与前面openldap服务的配置相关联 - ldap服务器:前面ldap服务的地址 - user:管理员用户名,不用输入密码 - LAM密码:是部分管理功能所需要的密码,请根据需要自行修改 启动成功后,我们访问http://127.0.0.1:8080,出现如下登录界面: ![f](./lam-login.png) 输入前面admin的密码,即可完成登录。 进入后,可以发现氛围User / Group两个主要的Tab。 - User:用户的增删改 - Group:用户组的增删改 我们首先修改下User功能默认的配置。打开右上角Tools -> Profile Editor -> User,这里设置为: - LDAP suffix:rd > coder4 > com - Automatically add this extension: false 接着,我们需要添加一个Posix组,Groups -> New Group -> Unix Group - Suffix:coder4 > com - Group name:user 最后,我们尝试添加一个用户,Users -> New User,在如下界面中填写: ![f](./lam-create-user.png) - Last name: zhangsan - Suffix:rd > coder4 > com - RDN identifier:cn - Password:123456 - Unix Primary Group:user 点击Save保存后,我们验证一下: ```shell ldapwhoami -h 127.0.0.1 -p 389 -D "cn=zhangsan,ou=rd,dc=coder4,dc=com" -w 123456 dn:cn=zhangsan,ou=rd,dc=coder4,dc=com ``` 成功! 如果你想看组织的全貌,可以进入:Tools -> TreeView: ![f](./lam-tree.png) 至此,我们已经成功搭建了基于ldap的内网统一验证。然而,本节只是一个起点,在后续搭建的系统中,我们都会接入ldap认证系统。 ================================================ FILE: src/ch07-tools/microservice-template.md ================================================ # 微服务模板工具 在微服务架构下,我们经常需要按业务领域进行拆分,新建微服务。 频繁的创建新服务,十分繁琐,本文介绍一种微服务创建的模板工具。 在Maven架构下,我们可以用[ArchType]([Maven – Guide to Creating Archetypes](https://maven.apache.org/guides/mini/guide-creating-archetypes.html))快速生成新项目。 但在本文所选的Gradle构建工具下,尚未有类似工具。 我们使用模板替换的方式,新建服务。 ## 构建模板微服务 首先,我们构建模板微服务,代码放到了[这里](https://github.com/liheyuan/homs-microservice-template)。 我们看下目录结构: ```shell . ├── build.gradle ├── gradle │   └── wrapper │   ├── gradle-wrapper.jar │   └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── homs-template-client │   ├── build.gradle │   └── src │   └── main │   ├── java │   │   └── com │   │   └── coder4 │   │   └── homs │   │   └── template │   │   ├── HomsTemplate.proto │   │   ├── HomsTemplateGrpc.java │   │   ├── HomsTemplateProto.java │   │   ├── client │   │   │   ├── AbstractGrpcClientManager.java │   │   │   ├── HSGrpcClient.java │   │   │   ├── HomsAbcGrpcClient.java │   │   │   └── SimpleGrpcClientManager.java │   │   └── constant │   │   └── HomsAbcConstant.java │   └── resources │   └── META-INF │   └── spring.factories ├── homs-template-server │   ├── build.gradle │   ├── lombok.config │   └── src │   ├── main │   │   ├── java │   │   │   └── com │   │   │   └── coder4 │   │   │   └── homs │   │   │   └── template │   │   │   └── server │   │   │   └── server │   │   │   ├── HomsRpcServer.java │   │   │   ├── HomsTemplateApplication.java │   │   │   ├── configuration │   │   │   │   ├── RpcBindableServiceConfiguration.java │   │   │   │   └── RpcServerConfiguration.java │   │   │   ├── grpc │   │   │   │   └── HomsTemplateGrpcImpl.java │   │   │   └── web │   │   │   ├── ctrl │   │   │   │   └── BaseController.java │   │   │   ├── logic │   │   │   │   ├── impl │   │   │   │   └── spi │   │   │   └── vo │   │   └── resources │   │   └── application.yaml │   └── test │   └── java │   └── com │   └── coder4 │   └── homs │   ├── demo │   └── template │   └── server │   └── server │   └── Test.java ├── settings.gradle └── tool ├── compile_grpc.sh └── test_curl.sh ``` 如上图所示,这是一个多模块的子项目,分为client、server两部分。与我们在前文中介绍的保持一致。为了简单起见,这里去掉了MySQL、Redis等依赖。 ## 服务生成工具 接下来,我们开发服务生成工具,脚本如下: ```shell #!/bin/bash if [ x"$#" != x"1" ];then echo "Usage $0 " exit -1 fi PROJECT_NAME=$1 PROJECT_NAME_CAMEL=$(echo $PROJECT_NAME | gsed -r 's/(^|-)([a-z])/\U\2/g') PROJECT_P1=$(echo $PROJECT_NAME | awk -F '-' '{print $1}') PROJECT_P2=$(echo $PROJECT_NAME | awk -F '-' '{print $2}') rm -rf $PROJECT_NAME cp -rf homs-template $PROJECT_NAME # move files mv $PROJECT_NAME/homs-template-client $PROJECT_NAME/${PROJECT_NAME}-client mkdir -p $PROJECT_NAME/${PROJECT_NAME}-client/src/main/java/com/coder4/$PROJECT_P1/$PROJECT_P2 mv $PROJECT_NAME/${PROJECT_NAME}-client/src/main/java/com/coder4/homs/template/* $PROJECT_NAME/${PROJECT_NAME}-client/src/main/java/com/coder4/$PROJECT_P1/$PROJECT_P2 rm -rf $PROJECT_NAME/${PROJECT_NAME}-client/src/main/java/com/coder4/homs/template mv $PROJECT_NAME/homs-template-server $PROJECT_NAME/${PROJECT_NAME}-server mkdir -p $PROJECT_NAME/${PROJECT_NAME}-server/src/main/java/com/coder4/$PROJECT_P1/$PROJECT_P2 mv $PROJECT_NAME/${PROJECT_NAME}-server/src/main/java/com/coder4/homs/template/* $PROJECT_NAME/${PROJECT_NAME}-server/src/main/java/com/coder4/$PROJECT_P1/$PROJECT_P2 rm -rf $PROJECT_NAME/${PROJECT_NAME}-server/src/main/java/com/coder4/homs/template find $PROJECT_NAME -type file -exec gsed -i "s/HomsTemplate/$PROJECT_NAME_CAMEL/g" {} + find $PROJECT_NAME -type file -exec gsed -i "s/homs\.template/$PROJECT_P1\.$PROJECT_P2/g" {} + find $PROJECT_NAME -type file -exec gsed -i "s/homs-template/$PROJECT_P1-$PROJECT_P2/g" {} + for file in $(find $PROJECT_NAME -type file);do target=$(echo $file|sed -e "s/HomsTemplate/$PROJECT_NAME_CAMEL/g") mv $file $target done ``` 如上所示: - 输入项目"homs-abc"后,会获取其驼峰命名如"HomsAbc" - 拷贝上述template项目后,会对文件夹进行重命名 - 接着,对文件中的template进行替换 - 最后,对部分文件名进行替换 我们试着这运行下: ```shell ./generate.sh homs-abc ├── build.gradle ├── gradle │   └── wrapper │   ├── gradle-wrapper.jar │   └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── homs-abc-client │   ├── build.gradle │   └── src │   └── main │   ├── java │   │   └── com │   │   └── coder4 │   │   └── homs │   │   └── abc │   │   ├── HomsAbc.proto │   │   ├── HomsAbcGrpc.java │   │   ├── HomsAbcProto.java │   │   ├── client │   │   │   ├── AbstractGrpcClientManager.java │   │   │   ├── HSGrpcClient.java │   │   │   ├── HomsAbcGrpcClient.java │   │   │   └── SimpleGrpcClientManager.java │   │   └── constant │   │   └── HomsAbcConstant.java │   └── resources │   └── META-INF │   └── spring.factories ├── homs-abc-server │   ├── build.gradle │   ├── lombok.config │   └── src │   ├── main │   │   ├── java │   │   │   └── com │   │   │   └── coder4 │   │   │   └── homs │   │   │   └── abc │   │   │   └── server │   │   │   └── server │   │   │   ├── HomsAbcApplication.java │   │   │   ├── HomsRpcServer.java │   │   │   ├── configuration │   │   │   │   ├── RpcBindableServiceConfiguration.java │   │   │   │   └── RpcServerConfiguration.java │   │   │   ├── grpc │   │   │   │   └── HomsAbcGrpcImpl.java │   │   │   └── web │   │   │   ├── ctrl │   │   │   │   └── BaseController.java │   │   │   ├── logic │   │   │   │   ├── impl │   │   │   │   └── spi │   │   │   └── vo │   │   └── resources │   │   └── application.yaml │   └── test │   └── java │   └── com │   └── coder4 │   └── homs │   ├── demo │   └── template │   └── server │   └── server │   └── Test.java ├── settings.gradle └── tool ├── compile_grpc.sh └── test_curl.sh ``` 如上,非常快速的生成了新的微服务! 在实际项目中,你还可以在初始化脚本中,集成如下功能: - 自动创建远程的git repo - 创建jenkins打包项目 - 创建监控项 由于篇幅所限,这里不再讨论上述功能改进。 ================================================ FILE: src/ch07-tools/registry2.md ================================================ ## 使用Registry2搭建Docker私有仓库 在[打造持续交付流水线](../ch06-cd/README.md)一章中,在部署前,需要先打包Docker镜像,并上传到DockerHub镜像仓库。 DockerHub是由Docker推出的共有镜像仓库,使用广泛,但存在一下问题: - 由于众所周知的原因,从国内访问速度较慢 - 对公网所有用户可见,存在泄密风险 - 存在泄露风险 因此,搭建私有的容器镜像仓库,十分必要。 本节,我们将基于Docker官方的registry2,搭建私有镜像仓库。 ## 启动 我们用Docker启动Docker镜像仓库:-) ```shell #!/bin/bash NAME="registry2" PUID="1000" PGID="1000" VOLUME="$HOME/docker_data/registry2" mkdir -p $VOLUME docker ps -q -a --filter "name=$NAME" | xargs -I {} docker rm -f {} docker run \ --hostname $NAME \ --name $NAME \ --volume $VOLUME:/var/lib/registry \ --env REGISTRY_STORAGE_DELETE_ENABLED=true \ --env PUID=$PUID \ --env PGID=$PGID \ -p 5000:5000 \ --detach \ --restart always \ registry:2 ``` 如上所示,我们添加了允许删除镜像的配置。 启动成功后,镜像仓库运行在 http://127.0.0.1:5000 地址上。 由于我们未启用https证书校验,因此需要在客户端上配置: /etc/docker/daemon.json中添加一行 ```json "insecure-registries":["10.1.172.136:5000","127.0.0.1:5000"], ``` ## 上传镜像 打tag ```shell docker tag 7aa22139eca1 127.0.0.1:5000/jenkins-my-agent:latest ``` 上传,成功! ```shell docker push 127.0.0.1:5000/jenkins-my-agent:latest The push refers to repository [127.0.0.1:5000/jenkins-my-agent] 25af0e804bd9: Pushed d481382bb71b: Pushed 9a0d9a003e42: Pushed d90590887490: Pushed 2e10e3c8baa6: Pushed 260e081d58bf: Pushed 545b9645e192: Pushed ed0f1dee792d: Pushed ebb837d412f9: Pushed b80c59a58a8e: Pushed 953a3e11bab6: Pushed 833c84c9f2ea: Pushed 7a45298bdd53: Pushed 62a747bf1719: Pushed latest: digest: sha256:3b7ebd6948da5d7d9267e02b58698c3046e940f565eab9687243aaa8727ace29 size: 3266 ``` 我们查询下历史版本,这里发现有一个latest的版本了 ```shell curl "127.0.0.1:5000/v2/jenkins-my-agent/tags/list" {"name":"jenkins-my-agent","tags":["latest"]} ``` 尝试删除镜像,成功! ```shell registry='localhost:5000' name='jenkins-my-agent' curl -v -sSL -X DELETE "http://${registry}/v2/${name}/manifests/$( curl -sSL -I \ -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ "http://${registry}/v2/${name}/manifests/$( curl -sSL "http://${registry}/v2/${name}/tags/list" | jq -r '.tags[0]' )" \ | awk '$1 == "Docker-Content-Digest:" { print $2 }' \ | tr -d $'\r' \ )" ``` 至此,我们成功搭建了私有镜像。以下是拓展练习,留给你来实现: - 启用https证书(自签) - 支持每个容器保留最近5个tag - 将[打造持续交付流水线](../ch06-cd/README.md)中的镜像仓库,替换为私有仓库 ================================================ FILE: src/ch07-tools/seafile.md ================================================ # 搭建Seafile共享云盘 在企业内部,文件的共享,交换是十分重要的需求。 本节,我们将搭建基于Seafile的共享云盘。 ## 安装 首先,确保你的机器上已经安装了docker-compose。 接着,下载最新docker-compose,见[地址]([Seafile Server](https://download.seafile.com/d/320e8adf90fa43ad8fee/files/?p=/docker/docker-compose.yml)) 我们需要略做修改: ```yaml version: '2.0' services: db: image: mariadb:10.5 container_name: seafile-mysql environment: - MYSQL_ROOT_PASSWORD=seafile123 # Requested, set the root's password of MySQL service. - MYSQL_LOG_CONSOLE=true volumes: - /Users/coder4/docker_data/seafile-mysql/db:/var/lib/mysql # Requested, specifies the path to MySQL data persistent store. networks: - seafile-net memcached: image: memcached:1.5.6 container_name: seafile-memcached entrypoint: memcached -m 256 networks: - seafile-net seafile: image: seafileltd/seafile-mc:latest container_name: seafile ports: - "80:80" - "443:443" # If https is enabled, cancel the comment. volumes: - /Users/coder4/docker_data/seafile-data:/shared # Requested, specifies the path to Seafile data persistent store. environment: - DB_HOST=db - DB_ROOT_PASSWD=seafile123 # Requested, the value shuold be root's password of MySQL service. - TIME_ZONE=Etc/UTC # Optional, default is UTC. Should be uncomment and set to your local time zone. - SEAFILE_ADMIN_EMAIL=me@example.com # Specifies Seafile admin user, default is 'me@example.com'. - SEAFILE_ADMIN_PASSWORD=123456 # Specifies Seafile admin password, default is 'asecret'. - SEAFILE_SERVER_LETSENCRYPT=true # Whether to use https or not. - SEAFILE_SERVER_HOSTNAME=seafile.coder4.com # Specifies your host name if https is enabled. depends_on: - db - memcached networks: - seafile-net networks: seafile-net: ``` 如上: - 修改了数据库的默认密码 - 修改了volume的路径到本地~/docker_data下 - 修改seafile的管理员密码 - 并开启https和域名,要求域名必须公网可见 最后,我们创建所需的本地volume目录,并重启: ```shell mkdir /Users/coder4/docker_data/seafile-mysql/ mkdir /Users/coder4/docker_data/seafile-data ``` ```shell docker-compose up -d ``` 浏览器 打开 地址 https://seafile.coder4.com 应该能出现如下登录界面了: ![f](./seafile.png) ## 配置ldap 接下来,我们配置ldap 打开文件~/docker_data/seafile-data/seafile/conf/ccnet.conf,添加如下内容: ```shell [LDAP] HOST = ldap://10.1.172.136:389/ BASE = dc=coder4,dc=com USER_DN = cn=readonly,dc=coder4,dc=com PASSWORD = readonly123 LOGIN_ATTR = cn ``` 重启服务 ```shell docker-compose ``` 使用zhangsan / 123546登录,成功! 下面,你可以尝试上传文件、共享群组了,这里不再赘述。