技术总结

Kubernetes中gRPC Load Balancing分析和解决

背景 #

第一次,线上遇到大量接口RT超过10s触发了系统告警,运维反馈k8s集群无异常,负载无明显上升。将报警接口相关的服务重启一番后发现并无改善。但是开发人员使用链路追踪系统发现,比较慢的请求总是某个gRPC服务中的几个POD导致,由其他POD处理的请求并不会出现超时告警。

第二次,同样遇到接口RT超过阈值触发告警,从k8s中查到某个gRPC服务(关键服务)重启次数异常,查看重启原因时发现是OOM KilledOOM killed并不是负载不均衡直接导致的,但是也有一定的关系,这个后面再说。前两次由于监控不够完善(于我而言,运维的很多面板都没有权限,没办法排查)。期间利用pprof分析了该服务内存泄漏点,并修复上线观察。经过第二次问题并解决之后,线上超时告警恢复正常水平,但是该 deployment 下的几个POD占用资源(Mem / CPU / Network-IO),差距甚大。

第二张图是运维第一次发现该服务OOM killed 之后调整了内存上限从 512MB => 1G,然而只是让它死得慢一点而已。 从上面两张图能够石锤的是该服务一定存在内存泄漏。Go项目内存占用的分析,我总结了如下的排查步骤:

1. 代码泄漏(pprof)(可能原因 goroutine泄漏;闭包)
2. Go Runtime + Linux 内核(RSS虚高导致OOM)https://github.com/golang/go/issues/23687
3. 采集指标不正常(container_memory_working_set_bytes)

2,3 是基于第1点能基本排除代码问题的后续步骤。

解决和排查手段:

1. pprof 通过heap + goroutine 是否异常,来定位泄漏点
运行`go tool pprof`命令时加上--nodefration=0.05参数,表示如果调用的子函数使用的CPU、memory不超过 5%,就忽略它。

2. 确认go版本和内核版本,确认是否开启了MADV_FREE,导致RSS下降不及时(1.12+ 和 linux内核版本大于 4.5)
3.  RSS + Cache 内存检查

> Cache 过大的原因 https://www.cnblogs.com/zh94/p/11922714.html 
// IO密集:手动释放或者定期重启

查看服务器内存使用情况: `free -g`
查看进程内存情况:      `pidstat -rI -p 13744`
查看进程打开的文件:    `lsof -p 13744`
查看容器内的PID:      `docker inspect --format "{{ .State.Pid}}" 6e7efbb80a9d`
查看进程树,找到目标:   `pstree -p 13744`

参考:https://eddycjy.com/posts/why-container-memory-exceed/

通过上述步骤,我发现了该POD被OOM killed还有另一个元凶就是,日志文件占用。这里就不过多的详述了,搜索方向是 “一个运行中程序在内存中如何组织 + Cache内存是由哪些部分构成的”。这部分要达到的目标是:一个程序运行起来它为什么占用了这么些内存,而不是更多或者更少。

...

近期使用Docker打包镜像遇到的问题总结

背景 #

github.com/yeqown/goreportcard 项目中我改造了 goreportcard。 后续为了方便部署,我准备将其打包成为docker镜像并上传到 DockerHub。期间遇到了下面的问题,并一一解决,这里做一个记录帮助以后遇到类似的问题可以快速解决。

初期的目标是:将goreportcardgolangci-lint编译好,尽可能较小镜像的体积。因此第一次尝试,我使用了分阶段编译,用golang:1.14.1编译,alpine来发布。

基本 Dockerfile 如下:

# building stage
FROM golang:1.14-alpine3.11 as build
WORKDIR /tmp/build

COPY . .
RUN export GOPROXY="https://goproxy.cn,direct" \
    && go mod download \
    && go build -o app ./cmd/goreportcard-cli/ \
    && go get github.com/golangci/golangci-lint && go install github.com/golangci/golangci-lint/cmd/golangci-lint

# release stage
FROM golang:1.14-alpine3.11 as release
WORKDIR /app/goreportcard

COPY --from=build /tmp/build/app .
COPY --from=build /tmp/build/tpl ./tpl
COPY --from=build /tmp/build/assets ./assets

# FIXED: 不能使用golangci-lint, `File not found` 错误
COPY --from=build /go/bin/golangci-lint /usr/local/bin

EXPOSE 8000

ENTRYPOINT ["./app", "start-web", "&"]

问题清单和解决方案 #

由于并不是所有的问题都和Docker有关,因此我会使用 [分类] 在标题上注明。

...

Redis主从复制

redis主从复制是高可用方案中的一部分,那主从复制是如何进行的?又是如何实现的?怎么支撑了redis的高可用性?在主从模式下Master和Slave节点分别做了哪些事情?

redis高可用方案是什么? #

我理解的redis高可用的特点有:

  1. 高QPS,主从 => 读写分离
  2. 高容量,集群分片 => 高容量
  3. 故障转移,sentinel => 故障转移
  4. 故障恢复,数据持久 => 故障恢复 ~ 这里我简单的理解(RDB + AOF)= 故障恢复

主从复制 #

redis 主从复制有两个版本:旧版(Ver2.8-),新版(Ver2.8+,增加PSYNC命令来解决旧版中的问题)

讨论复制时都需要考虑两种场景:

  • 场景1:从节点刚刚上线,需要去同步主节点时,这部分可以理解为 全量复制
  • 场景2:从节点掉线,恢复上线后需要同步数据,使自己和主节点达到一致状态。这部分在旧版复制里等价于全量复制,在新版里可以理解为增量复制

当然你肯定会想到如果主节点掉线,这时候会怎么样?这个场景当然也在redis高可用方案中,之时不是本文的重点,属于Sentinel机制的内容了。

旧版主从复制 #

前文说过了,旧版主从复制只有全量复制用于应付上述两个场景,因此下面的流程也只有一份:

  1. 从服务器向主服务器发送sync命令。
  2. 主服务器在收到sync命令之后,调用bgsave命令生成最新的rdb文件,将这个文件同步给从服务器,这样从服务器载入这个rdb文件之后,状态就会和主服务器执行bgsave命令时候的一致。
  3. 主服务器将保存在命令缓冲区中的写命令同步给从服务器,从服务器执行这些命令,这样从服务器的状态就跟主服务器当前状态一致了。

如果你不知道redis中还有个缓冲区的话,建议系统的了解下redis中缓冲区的设计。这里缓冲区特指命令缓冲区,后面还会讲到复制缓冲区。

但是这样的实现在 场景2 下的缺点很明显:如果说从节点断线后迅速上线,这段时间内的产生的写命令很少,却要全量复制主库的数据,传输了大量重复数据。

SYNC命令产生的消耗:
1. 主节点生成RDB,需要消耗大量的CPU,内存和磁盘IO
2. 网络传输大量字节数据,需要消耗主从服务器的网络资源
3. 从节点需要从RDB文件恢复,会造成阻塞无法接受客户端请求

优点就是:简单暴力。个人看来在redis架构中不合适的用法,不代表说实际场景中也一定不合适,简单暴力也是一个很大的优点。

新版主从复制 #

新版的主从复制跟旧版的区别就在于:对场景2的优化。

场景2的缺点上文已经提到过了,那么优化的方向就是**“尽量不使用全量复制;增加增量复制(PSYNC)的功能”**。为此还要解决下列问题:

  1. 如果某个从节点断线了,重新上线该从节点如何知道自己是否应该全量还是增量复制呢?
  2. 该从节点断线恢复后,又怎么知道自己缺失了哪些数据呢?
  3. 主节点又如何补偿该从节点在断线期间丢失的那部分数据呢?旧版的复制除了RDB,还有从命令缓冲区中的写命令来保持数据一致。

为此新版中使用了以下概念:

运行ID - runid #

每个redis服务器都有其runid,runid由服务器在启动时自动生成,主服务器会将自己的runid发送给从服务器,而从服务器会将主服务器的runid保存起来。从服务器redis断线重连之后进行同步时,就是根据runid来判断同步的进度:

  1. 如果前后两次主服务器runid一致,则认为这一次断线重连还是之前复制的主服务器,主服务器可以继续尝试部分同步操作。
  2. 如果前后两次主服务器runid不相同,则全同步流程
复制偏移量 - offset #

主从节点,分别会维护一个复制偏移量: 主服务器每次向从服务器同步了N字节数据之后,将修改自己的复制偏移量+N。从服务器每次从主服务器同步了N字节数据之后,将修改自己的复制偏移量+N。通过对比主从节点的偏移量很容易就可以发现,主从节点是否处于一致状态。

...

SSH Tunnel小工具

背景 #

生产环境数据库不允许直接访问,但是又经常有需要直接操作数据库的需求😂。先不说合不合理,背景就是这个背景,因此只能通过跳板机来连接数据库,一(就)般(我)来(而)说(言)会使用ssh隧道,就轻松能解决这个问题,然鹅,事情并不简单。这里陈述一下:

  1. 生产环境数据库不让直接访问;
  2. 跳板机上没有公钥,没有权限;
  3. 我一次可能需要开3+个隧道才能启动服务【敲重点】

解决 #

本着“我不造轮子,谁来造轮子”的想法,这里就造一个小轮子:用Go来实现SSH隧道多开,并支持配置。成果预览:

原理简要分析 #

如果代理原理有点了解,这里的原理差不多是一样的:Local <-> SSH tunnel <-> Remote Server,对于隧道来说把Local的请求传给Remote, 把Remote的响应告诉Local。直接上代码:

// Start .
// TODO: support random port by using localhost:0
func (tunnel *SSHTunnel) Start() error {
    listener, err := net.Listen("tcp", tunnel.LocalAddr)
    if err != nil {
        return err
    }
    defer listener.Close()
    // tunnel.Local.Port = listener.Addr().(*net.TCPAddr).Port
    for {
        conn, err := listener.Accept()
        if err != nil {
            return err
        }
        logger.Infof(tunnel.name() + " accepted connection")
        go tunnel.forward(conn)
    }
}

// 创建隧道并传递消息,分别有两个端点,一个是本地隧道口,另一个是远程服务器上的隧道口
func (tunnel *SSHTunnel) forward(localConn net.Conn) {
    // 创建本地到跳板机的SSH连接
    serverSSHClient, err := ssh.Dial("tcp", tunnel.ServerAddr, tunnel.SSHConfig)
    if err != nil {
        logger.Infof(tunnel.name()+" server dial error: %s", err)
        return
    }
    logger.Infof(tunnel.name()+" connected to server=%s (1 of 2)", tunnel.ServerAddr)
    // 创建跳板机到远程服务器的连接
    remoteConn, err := serverSSHClient.Dial("tcp", tunnel.RemoteAddr)
    if err != nil {
        logger.Infof(tunnel.name()+" remote dial error: %s", err)
        return
    }
    logger.Infof(tunnel.name()+" connected to remote=%s (2 of 2)", tunnel.RemoteAddr)

    copyConn := func(writer, reader net.Conn) {
        _, err := io.Copy(writer, reader)
        if err != nil {
            logger.Infof(tunnel.name()+" io.Copy error: %s", err)
        }
    }

    // local(w) => 远程(r)
    go copyConn(localConn, remoteConn)
    // 远程(w) => 本地(r)
    go copyConn(remoteConn, localConn)
}

代码中需要注意的是:

...

TCP拆包粘包

一些名词 #

MTU(Maximum Transmission Unit) #

the maximum transmission unit (MTU) is the size of the largest protocol data unit (PDU) that can be communicated in a single network layer transaction. ——from wiki

MTU 物理接口(数据链路层)提供给其上层(通常是IP层)最大一次传输数据的大小。一般来说MTU=1500byte。如果MSS + TCP首部 + IP首部 > MTU,那么IP报文就会存在分片,否则就不需要分片。

MSS (Maximum Segment Size) #

The maximum segment size (MSS) is a parameter of the options field of the TCP header that specifies the largest amount of data, specified in bytes, that a computer or communications device can receive in a single TCP segment. ——from wiki

...

go-watcher-热重载轮子

Golang编写的热重载工具,自定义命令,支持监视文件及路径配置,环境变量配置。这是一个重复的轮子~

安装使用 #

go install github.com/yeqown/go-watcher/cmd/go-watcher

命令行 #

➜  go-watcher git:(master) ✗ ./go-watcher -h   
NAME:
   go-watcher - A new cli application

USAGE:
   go-watcher [global options] command [command options] [arguments...]

VERSION:
   2.0.0

AUTHOR:
   [email protected]

COMMANDS:
     init     generate a config file to specified postion
     run      execute a command, and watch the files, if any change to these files, the command will reload
     help, h  Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --help, -h     show help
   --version, -v  print the version

配置文件 #

watcher:                   # 监视器配置
  duration: 2000           # 文件修改时间间隔,只有高于这个间隔才回触发重载
  included_filetypes:      # 监视的文件扩展类型
  - .go                    # 
  excluded_regexps:        # 不被监视更改的文件正则表达式
  - ^.gitignore$
  - '*.yml$'
  - '*.txt$'
additional_paths: []       # 除了当前文件夹需要额外监视的文件夹
excluded_paths:            # 不需要监视的文件名,若为相对路径,只能对于当前路径生效
- vendor
- .git
envs:                      # 额外的环境变量
- GOROOT=/path/to/your/goroot
- GOPATH=/path/to/your/gopath

使用范例日志 #

➜  go-watcher git:(master) ✗ ./package/osx/go-watcher run -e "make" -c ./config.yml
[INFO] directory (/Users/yeqown/Projects/opensource/go-watcher) is under watching
[INFO] directory (/Users/yeqown/Projects/opensource/go-watcher/cmd) is under watching
[INFO] directory (/Users/yeqown/Projects/opensource/go-watcher/cmd/go-watcher) is under watching
[INFO] directory (/Users/yeqown/Projects/opensource/go-watcher/internal) is under watching
[INFO] directory (/Users/yeqown/Projects/opensource/go-watcher/internal/command) is under watching
[INFO] directory (/Users/yeqown/Projects/opensource/go-watcher/internal/log) is under watching
[INFO] directory (/Users/yeqown/Projects/opensource/go-watcher/internal/testdata) is under watching
[INFO] directory (/Users/yeqown/Projects/opensource/go-watcher/internal/testdata/exclude) is under watching
[INFO] directory (/Users/yeqown/Projects/opensource/go-watcher/internal/testdata/testdata_inner) is under watching
[INFO] directory (/Users/yeqown/Projects/opensource/go-watcher/package) is under watching
[INFO] directory (/Users/yeqown/Projects/opensource/go-watcher/package/archived) is under watching
[INFO] directory (/Users/yeqown/Projects/opensource/go-watcher/package/linux) is under watching
[INFO] directory (/Users/yeqown/Projects/opensource/go-watcher/package/osx) is under watching
[INFO] directory (/Users/yeqown/Projects/opensource/go-watcher/resources) is under watching
[INFO] directory (/Users/yeqown/Projects/opensource/go-watcher/utils) is under watching
[INFO] directory (/Users/yeqown/Projects/opensource/go-watcher/utils/testdata) is under watching
[INFO] directory (/Users/yeqown/Projects/opensource/go-watcher/utils/testdata/testdata_inner) is under watching
rm -fr package
go build -o package/osx/go-watcher cmd/go-watcher/main.go
GOOS=linux GOARCH=amd64 go build -o package/linux/go-watcher cmd/go-watcher/main.go
mkdir -p package/archived
tar -zcvf package/archived/go-watcher.osx.tar.gz package/osx
a package/osx
a package/osx/go-watcher
tar -zcvf package/archived/go-watcher.linux.tar.gz package/linux
a package/linux
a package/linux/go-watcher
[INFO] command executed done!
[INFO] (/Users/yeqown/Projects/opensource/go-watcher/package/osx/go-watcher) is skipped, not target filetype
[INFO] (/Users/yeqown/Projects/opensource/go-watcher/package/osx) is skipped, not target filetype
[INFO] (/Users/yeqown/Projects/opensource/go-watcher/package) is skipped, not target filetype
[INFO] (/Users/yeqown/Projects/opensource/go-watcher/package/linux/go-watcher) is skipped, not target filetype
[INFO] (/Users/yeqown/Projects/opensource/go-watcher/package/linux) is skipped, not target filetype
[INFO] (/Users/yeqown/Projects/opensource/go-watcher/package/archived/go-watcher.linux.tar.gz) is skipped, not target filetype
[INFO] (/Users/yeqown/Projects/opensource/go-watcher/package/archived) is skipped, not target filetype
[INFO] (/Users/yeqown/Projects/opensource/go-watcher/VERSION) is skipped, not target filetype
[INFO] [/Users/yeqown/Projects/opensource/go-watcher/cmd/go-watcher/main.go] changed
rm -fr package
mkdir -p package/osx
mkdir -p package/linux
echo "2.0.0" > VERSION
cp VERSION package/osx
cp VERSION package/linux
go build -o package/osx/go-watcher cmd/go-watcher/main.go
GOOS=linux GOARCH=amd64 go build -o package/linux/go-watcher cmd/go-watcher/main.go
mkdir -p package/archived
tar -zcvf package/archived/go-watcher.osx.tar.gz package/osx
a package/osx
a package/osx/go-watcher
a package/osx/VERSION
tar -zcvf package/archived/go-watcher.linux.tar.gz package/linux
a package/linux
a package/linux/go-watcher[INFO] (/Users/yeqown/Projects/opensource/go-watcher/package/osx) is skipped, not target filetype
[INFO] (/Users/yeqown/Projects/opensource/go-watcher/package/linux) is skipped, not target filetype

a package/linux/VERSION
[INFO] command executed done!
[INFO] (/Users/yeqown/Projects/opensource/go-watcher/package/osx) is skipped, not target filetype
[INFO] (/Users/yeqown/Projects/opensource/go-watcher/package/archived) is skipped, not target filetype
[INFO] (/Users/yeqown/Projects/opensource/go-watcher/package) is skipped, not target filetype
[INFO] (/Users/yeqown/Projects/opensource/go-watcher/VERSION) is skipped, not target filetype
[INFO] (/Users/yeqown/Projects/opensource/go-watcher/package) is skipped, not target filetype
^C[INFO] quit signal captured!
[INFO] go-watcher exited
➜  go-watcher git:(master)

怎么才叫熟悉http协议?

“熟悉http协议”,肯定很多IT小伙伴都在招聘岗位上看得到过,但是怎么才叫熟悉http协议呢?抽空梳理了一下,也算是对这一部分知识的笔记吧!

可能对于大部分人来说,网络web编程就是使用一些第三方库来进行请求和响应的处理,再多说一点就是这个URI要使用POST方法,对于携带的数据需要处理成为formdata

基础知识 #

Q1: HTTP协议是什么?用来干什么?

HTTP协议是基于TCP/IP协议的应用层协议,主要规定了客户端和服务端之间的通信格式。主要作用也就是传输数据(HTML,图片,文件,查询结果)。

#网络分层 #

互联网的实现分成了几层,如何分层有不同的模型(七层,五层,四层),这里按五层模型来解释:

(靠近用户)应用层 < 传输层 < 网络层 < 链接层 < 物理层(靠近硬件)

层级 作用 拥有协议
物理层 传送电信号0 1
数据链路层 定义数据包;网卡MAC地址;广播的发送方式; Ethernet 802.3; Token Ring 802.5
网络层 引进了IP地址,用于区分不同的计算机是否属于同一网络 IP; ARP; RARP
传输层 建立端口到端口的通信,实现程序时间的交流,也就是socket TCP; UDP
应用层 约定应用程序的数据格式 HTTP; FTP; DNS

每一层级,都是解决问题而诞生的,也就是他们各自作用对应的问题,推荐参考资料中的“互联网协议入门”。

#HTTP通信流程 #

http通信传输流

#拓展–三次握手和四次挥手 #

经常在其他地方看到这些,一直不知道了解这部分有什么用,但是syn Flood攻击,恰恰是利用了TCP三次握手中的环节。利用假IP伪造SYN请求,服务端会多次尝试发送SYN-ACK给客户端,但是IP并不存在也就无法成功建立连接。在一定时间内伪造大量这种请求,会导致服务器资源耗尽无法为正常的连接服务。(注:服务器SYN连接数量有限制,SYN-ACK超时重传机制)

三次握手流程:

  1. The client requests a connection by sending a SYN (synchronize) message to the server.
  2. The server acknowledges this request by sending SYN-ACK back to the client.
  3. The client responds with an ACK, and the connection is established.

TCP three-way handshake

...

api-gateway中实现基于权重的轮询调度

背景和目标 #

背景 #

项目需要在现有项目的基础上实现权限系统,但为了低耦合,选择实现了一个基于ne7ermore/gRBAC的auth-server,用于实现权限,角色,用户的管理,以及提供鉴权服务。在开发环境对接没有问题,正常的鉴权访问。到了线上部署的时候,才发现:

  1. 线上某服务部署在多台机器上;
  2. 目前的api-gateway并不支持同一服务配置多个node;

想的办法有:

序号 描述 优点 缺点
1 api-gateway通过url来转发请求,之前是配置IP加端口 api-gateway改动小 影响web和APP升级
2 api-gateway能支持多台机器,并进行调度 api-gateway功能更强大,把以后要做的事情提前做好基础 好像没啥缺点,只是费点时间支持下多节点配置,并调度

如果没说清,请看下图:

api-gateway-changing

目标 #

那么,目标也就明确了,需要实现api-gateway中实现基于权重的调度。为啥要基于权重?其一是仿照nginx基于权重的负载均衡,其二是服务器性能差异。

轮询调度算法介绍 #

轮询调度算法: #

轮询调度算法的原理是每一次把来自用户的请求轮流分配给内部中的服务器,从1开始,直到N(内部服务器个数),然后重新开始循环。该算法的优点是其简洁性,它无需记录当前所有连接的状态,所以它是一种无状态调度。

假设有一组服务器N台,S = {S1, S2, …, Sn},一个指示变量i表示上一次选择的服务器ID。变量i被初始化为N-1。其算法如下:

j = i;
do {
  j = (j + 1) mod n;
  i = j;
  return Si;
} while (j != i);
return NULL;

平滑加权轮询调度算法#

上述的轮询调度算法,并没有考虑服务器性能的差异,实际生产环境中,每一台服务器配置和安装的业务并不一定相同,处理能力不完全一样。因此需要根据服务器能力,分配不同的权值,以免服务的超负荷和过分闲余。

...

自己写一个手机菜谱APP

需要的技术及工具:

  • Python3 + Selenuium
  • Golang net/http
  • React-Native 相关(使用了react-navigation)
  • MongoDB
  • Redis

代码地址:

项目构思及构成 #

食谱类型的App,应用市场肯定有更好的的食谱APP。所以自己开发的目的,首先是写代码,其次是定制APP~ 好的,现在化身产品经理,设计一下APP有哪些功能:

  • 每日菜谱推荐,推荐可更换
  • 每天需要准备的材料提醒
  • 发现更多菜谱
  • 分类筛选菜谱
  • 搜索菜谱
  • 查看菜谱详情
  • 设置(不知道设置啥,提前准备吧)

设计稿?不存在的,随心所欲。

现在分析下我需要做的事情:

  1. 能跑起来的APP,与restful web api 交互。
  2. 能跑起来的web-api,提供菜谱数据,筛选,推荐,搜索等功能
  3. 能跑起来的简易spider,从网上获取菜谱信息。(这个爬虫能解析动态生成网站就够用了,姑且称之为爬虫吧)

没有考虑大量数据,因此爬虫并不通用,只适合特定XX网站。

实战爬虫 #

这个APP里面最重要的就是菜谱数据了,那么开发之前,需要明确的数据格式,如下:

{
    "name": "name",
    "cat": "cat",
    "img": "img_url",
    "mark_cnt": 19101,
    "view_cnt": 181891,
    "setps": [
        {
            "desc": "",
            "img": "",
        },
        // more step
    ],
    "material": {
        "ingredients": [
            {
                "name": "ingredients_name",
                "weight": "ingredients_weight",
            },
            // more ingredients
        ],
        "seasoning": [
            {
                "name": "seasoning_name",
                "weight": "seasoning_weight",
            },
            // more seasoning
        ],
    },
    "create_time": "2018xxxxxx",
    "update_time": "2018xxxxxx",
}

目标 #

前提:无法直接获取到该网站的服务API,才使用爬虫间接获取数据。

...

gorm使用记录

关于Gorm #

gorm文档

遇见问题 #

无法通过结构体的方式更新或查询零值 #

这里零值是说,各个类型的默认值。

关于这一点是在这里中注明了的,也提供了解决方案:

WARNING when update with struct, GORM will only update those fields that with non blank value

For below Update, nothing will be updated as “”, 0, false are blank values of their types

NOTE When query with struct, GORM will only query with those fields has non-zero value, that means if your field’s value is 0, ‘’, false or other zero values, it won’t be used to build query conditions,

...

访问量 访客数