UniACE 发布的文章

Redis Lua解析器支持的库

  • base lib.
  • table lib.
  • string lib.
  • math lib.
  • debug lib.
  • bitop lib.
  • redis.sha1hex function.
  • struct lib. 外部库
  • cjson lib. 外部库
  • cmsgpack lib. 外部库

转换规则:Redis <-> Lua

  • int <-> number
  • bulk/str <-> str
  • multi bulk <-> table,表内可以有其他数据类型
  • status <-> table,表内ok包含状态信息
  • error <-> table,表内err包含错误信息
  • Nil/Nil multi <-> false
  • 1 <-> true
EVAL "return true" 0
(integer) 1
EVAL "return false" 0
(nil)

使用 EVAL 或 EVALSHA

help @scripting

# vim demo.lua
local _M = { 
  KEYS[1],KEYS[2],
  ARGV[1],ARGV[2]
}
return _M
redis-cli --eval demo.lua key1 key2 , first second

EVAL "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second

SCRIPT LOAD "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}"
"a42059b356c875f0717db19a51f6aaca9ae659ea"
EVALSHA a42059b356c875f0717db19a51f6aaca9ae659ea 2 key1 key2 first second

1) "key1"
2) "key2"
3) "first"
4) "second"

Lua脚本的阻塞

  • 单实例,阻塞不能对缓存有任何操作
  • 集群

    • 阻塞的主节点上,阻塞;
    • 阻塞的主节点的从节点上,重定向到主节点,阻塞。从节点可查看;
    • 阻塞的节点上操作非阻塞的节点的缓存没有影响,可重定向到其他主机实例进行缓存操作
    • 不影响集群中非阻塞实例中的缓存操作,包括lua脚本的执行
(error) BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.

SCRIPT KILL:仅能中断对数据集没有修改的操作。当中出现写入操作时无效
SHUTDOWN NOSAVE:硬中断并关闭服务。数据不会改变。

遇到死循环阻塞时,为何SCRIPT KILL仍可操作?

Lua引擎提供各种各样的钩子函数。比如每执行10W条指令执行一次某个hook函数。Redis在钩子函数里会忙里偷闲的处理客户端的请求,并且在发现超时5s后才真正执行。


Lua脚本内的redis调用

redis.call()
执行命令过程发生错误时停止执行,并返回一个脚本错误信息。

redis.pcall()
同上。仅区别于发生错误时。出错时并不引发(raise)错误,而是返回一个带 err 域的 Lua 表(table),用于表示错误

redis.log(loglevel,message)
记录在Redis日志中

loglevel

  • redis.LOG_DEBUG
  • redis.LOG_VERBOSE
  • redis.LOG_NOTICE
  • redis.LOG_WARNING
EVAL "redis.log(redis.LOG_NOTICE, 'HELLO REDIS LOG')" 0

redis.breakpoint()
打断点。配合--ldb调试

容器

沙盒,把应用装起来的技术。这样一来,应用之间不仅没有互相干扰,还能方便搬运。是理想的 PaaS 状态。

但沙盒了哪些东西?

对于应用而言,静态表现为文件,存放于磁盘上。而动态表现为进程,变成了计算机里的数据和状态的。

所以说,容器其实是特殊的进程而已

容器技术的核心

通过约束和修改进程的动态表现,创造出一个边界。

  • CGroups:制造约束,隔离
  • Namespace:修改进程视图,限制

Namespace 技术

领导给组长划分一个圈,圈内组长管工仔。业绩由组长向上汇报,对工仔来说,这个圈就是公司,组长是编号NO1的人物。但其实在这个公司,组长编号可能只是NO199,而领导才是真正NO1。

这个“障眼法”有什么用?

让工仔看到某些指定的内容,保持专注。

这是一个Linux容器最基本的实现原理。/proc/<PID>/ns
障眼法种类有:

  • PID,CLONE_NEWPID,进程编号
  • Netork,CLONE_NEWNET,网络栈
  • IPC,CLONE_NEWIPC,信号量、消息队列、共享内存
  • User,CLONE_NEWUSER,用户和用户组
  • Mount,CLONE_NEWNS,挂载点
  • UTS,CLONE_NEWUTS,主机名和域名
  • CGROUP,CLONE_NEWCGROUP,资源限制

使用clone()系统调用创建一个进程时,可以实施不同的障眼法

# PID Namespace
int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);

对比虚拟机,这种隔离有哪些优缺点?

  • 虚拟机里运行一个完整的GuestOS;而容器无需
  • 虚拟机隔离更加彻底;而容器隔离不彻底,比如:时间,/proc
  • 虚拟机更牢固;容器更容易越狱
虽然确实有Seccomp等技术,对容器发起的所有系统调用进行过滤和甄别来进行安全加固,但因为又多一层过滤,性能一定有影响。何况默认情况下,都不知道到底该开启哪些系统调用,禁止哪些系统调用

CGroups 技术

有了隔离,但当前组与其他组之间依旧是平等竞争关系。谁都不想自己组内的资源被他人挪用吧。

Linux Control Group,限制一个进程组能够使用的资源上限,包括CPU、内存、磁盘、网络带宽

CGroups 暴露给用户的操作接口是文件系统,以文件和目录的方式组织在操作系统的/sys/fs/cgroup/路径下。

ls -l /sys/fs/cgroupmount -t cgroup都能展示出子系统

  • CPU子系统
  • 内存子系统
  • IO子系统
  • 等等...

在每类子系统下创建一个目录,那么这个目录称为一个控制组。你会发现控制组内自动生成该子系统对应的资源限制文件。

实际上,CGroups 就是 一个子系统目录 + 一组资源限制文件 的组合。非常易用。

# 进入CPU子系统
cd /sys/fs/cgroup/cpu/
# 创建一个控制组
mkdir -pv container
# 运行一个吃CPU的进程,并得到PID。可top看效果
while :; do :; done &
[1] 226
# 查看并修改限制,1ms = 1000us
cat container/cpu.cfs_quota_us
-1
cat container/cpu.cfs_period_us
100000
echo 20000 > container/cpu.cfs_quota_us
# 将所需进程PID写入tasks。再用top看效果
echo 226 > container/tasks

CGroups不完善的地方

  • /proc 文件系统
    Linux下/proc记录着当前内核运行状态的一系列特殊文件,即整个公司的资料库。容器内应该仅能查询容器自身的数据。
1.lxcfs
2.Cgroup Namespace

容器技术的本质

容器技术非常重要的概念,容器是一个 “单进程” 模型。不是说容器不支持运行多进程,而是更希望容器本身和应用有相同的生命周期

避免容器是正常运行的,但应用早已挂了,这种情况。同时也便于后续容器的编排

深入理解容器镜像

Mount Namespace,对容器进程视图的改变,一定是伴随着挂载操作才能生效的。

如果将宿主机系统目录挂载到容器里使用,还谈不上独立和隔离。容器对系统目录的修改也很有可能毁掉宿主机。那该怎么办?

改变进程的根目录到你指定的位置:chroot

mkdir -p $HOME/test
mkdir -p $HOME/test/{bin,lib64,lib}
T=$HOME/test
cp -v /bin/{bash,ls} ${T}/bin
list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')"
for i in $list; do cp -v "$i" "${T}${i}"; done
chroot ${T} /bin/bash

Mount Namespace基于对chroot的改良,成为Linux里的第一个Namespace。
为了更加真实,一般在容器的根目录下挂载一个完整的文件系统。随后在容器中ls看到的内容就像一个完整的系统。

这个挂载在容器上、用来为容器进程提供隔离后执行环境的文件系统,就是容器镜像,也叫作rootfs(根文件系统)

这里体现了容器的另一特性:一致性

rootfs里打包的不只应用,还有整个操作系统的文件和目录,这就是一个完整的依赖库嘛。所以轻松解决了线下线上环境不一致的问题。

每个应用涉及不同开发环境,那每个应用都要打包一份rootfs吗?能不能实现增量?

Docker引入分层概念,镜像制作的每一步都会生成一个layer,即一个增量rootfs。此处用到了一种Union File System(联合文件系统)的能力。

UnionFS,最主要的功能是将多个不通位置的目录联合挂载到同一个目录下
tree .
├── A
│  ├── a
│  └── x
└── B
  ├── b
  └── x
mkdir -p C
mount -t aufs -o dirs=./A:./B none ./C
tree ./C
├── a
├── b
└── x

Mount Namespace 和 rootfs 的结合使用,容器为进程构建出一个完善的文件系统隔离环境。


看清一个容器

运行一个Docker项目,为待创建的用户进程完成以下操作:

  • 启用 Linux Namespace 配置
  • 指定 CGroups 参数
  • 切换进程根目录(chroot)

By Jeff.W

Namespace 构建了四周围墙(进程隔离)
CGroups 构建了受控的天空优先使用阳光雨露(资源限制)
Mount Namespace 和 rootfs 构建了脚下的大地(文件系统隔离)
一切都是熟悉的,没有丝毫陌生感(容器一致性)

如何进入容器内部

docker exec命令如何进入容器里?

Nampespace创建的隔离空间虽然见不着,但一个进程的Namespace信息确实存在宿主机上,且以文件方式存在于/proc/<PID>/ns/下。通过加入某个进程已有的Namespace,达到“进入”这个进程所在容器的目的。这是docker exec的实现原理。

加入指定名称空间的小程序

#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
 
#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE);} while (0)
 
int main(int argc, char *argv[]) {
    int fd;
    
    fd = open(argv[1], O_RDONLY);
    if (setns(fd, 0) == -1) {
        errExit("setns");
    }
    execvp(argv[2], &argv[2]); 
    errExit("execvp");
}

argv[1]当前进程要加入的Namespace文件路径,比如“/proc/1314/ns/net”;argv[2]表示运行的进程,比如“/bin/bash”。

gcc -o set_ns set_ns.c
./set_ns /proc/1314/ns/net /bin/bash
ifconfig

容器数据卷

使用Mount Namespacerootfs实现文件系统隔离,思考两个问题:

  1. 容器里新建文件,宿主机如何获取
  2. 宿主机文件,容器如何访问

这正是 Docker Volume 要解决的问题。允许将宿主机上指定的目录或文件,挂载到容器里面进行读取和修改。

Volume声明有两种,本质都是把宿主机目录挂载进容器/test目录

docker run -v /test ...
docker run -v /home:/test ...

第一种没声明宿主机目录,Docker默认使用宿主机上/var/lib/docker/volumes/[VOLUME_ID]/_data进行挂载。

那么,具体如何实现挂载?

容器进程被创建后,在执行chroot/pivot_root之前,容器进程一直可以看到宿主机的整个文件系统,包括容器镜像的各个层。这些层通过联合挂载在/var/lib/docker/aufs/mnt/,这样所需的rootfs就准备好了。而后在执行chroot之前,把Volume指定的宿主机目录(/home)挂载到指定容器目录(/test)在宿主机上对应的目录/var/lib/docker/aufs/mnt/[rwlayer_id]/test

容器进程,是Docker创建容器的初始化进程(dockerinit),而不是应用进程(ENTRYPOINT+CMD)。dockerinit负责完成根目录的准备、挂载设备和目录、配置hostname等一系列需要在容器内进行的初始化操作。最后调用execv(),让应用进程取代自己成为容器里PID=1的进程

PaaS是什么

PaaS之前,IaaS,主流普遍的用法就是租一批虚拟机,用脚本部署应用。
但难以绕过的问题就是,云端虚拟机和本地环境不一致

PaaS应用托管的能力可以很好解决这个问题

PaaS最核心的能力:应用打包、分发机制、应用隔离
打包:将可执行文件和启动脚本打进一个压缩包
分发:通过调度器选择运行该应用的虚拟机
隔离:调用系统CGroupsNamespace,为应用创建运行的隔离环境(沙盒)

容器是什么

就是沙盒。依赖操作系统内核支持。

  • CGroups
  • Namespace

Docker为何能迅速崛起

早期PaaS项目代表(Cloud Foundry),用户必须为每种语言、每种框架、甚至每个版本的应用维护一个打好的包。费尽心机。

Docker如何解决打包问题:镜像

镜像也是一个压缩包,大多数Docker镜像除了应用可执行文件和启停脚本外,还包含一个完整操作系统的所有文件和目录(不包括内核),赋予本地环境和云端环境高度一致的能力。

  • 完整操作系统的所有文件、目录

    • 在Dockerfile中对应FROM参数,指定引用的基础镜像
  • 应用可执行文件、启停脚本

    • 在Dockerfile中对应其他参数
# 构建镜像
docker build -t NAME .
# 解压镜像,创建沙盒运行应用
docker run NAME

因为镜像的便利,Docker Swarm,推了一波CaaS(Container as a Service)

编排

通过某些具体或者配置完成一组虚拟机以及关联资源的定义、配置、创建、删除等工作,然后由云计算平台按照这些指定的逻辑来完成的过程。

  • Docker Compose
  • Docker Swarm
  • Mesos Marathon
  • Kubernetes

Docker Compose

fig项目,通过配置文件定义容器间的关联。仅支持单机。

# 启动
docker-compose up

Docker Swarm

用户创建容器的请求会被Swarm拦截,通过具体调度算法找到合适的Daemon运行。Compose集群版。

# Docker由单机到多机部署
docker run -H "Swarm API" NAME

Mesos Marathon

有超大规模集群的管理经验。

Kubernetes

简称K8s。从API到容器运行时的每一层,K8s项目都为开发者暴露出了可以扩展的插件机制,鼓励用户通过代码的方式介入到K8s项目的每一个阶段。

催生出很多优秀的项目

  • Istio,微服务治理
  • Operator,有状态应用部署框架
  • Rook,把重量级Ceph封装成简单易用的容器存储插件

PaaS 什么有价值

容器本身没有价值,有价值的是容器编排。

接口

基础类型 -> struct

type cat struct {...}
type dog struct {...}
func (c cat) eat() {...}
func (d doy) eat() {...}
// 该函数其实是想,传入什么,我就撸什么。如果x的类型是cat,将来只能传递cat类型的变量
// 那dog类型的怎么办?该函数无法支持。struct解决不了这类问题
func lu(x cat) {...}
lu(cat)
lu(dog)  // 无法传入dog类型

struct -> interface

// 引入接口
type eater interface {
    eat()
}
func lu(x eater) {...}
lu(cat)
lu(dog)

接口就是一个特殊类型。它规定了有哪些方法。一个变量实现了接口内的所有方法,就是这个特殊类型的变量。

var d dog
var e eater
// d实例实现了eater的eat(),那么d就是这个接口类型的变量
// 此时d既是struct类型,又是interface类型
e = d

接口变量保存以下 2 个部分:

  • 动态类型:零值nil
  • 动态值:零值nil
fmt.Printf("%T, %v\n", e, e)

这样就能实现接口变量的动态性。可以理解,接口就是一个动态变量。

函数参数:接口

相当于给函数传入实例对象的方法集,能实现方法集内的方法的实例对象,都能执行该函数。

实现接口时:值接收者 VS 指针接收者

  • 值接收者:结构体类型、结构体指针类型的变量都支持
  • 指针接收者:仅支持结构体指针类型的变量

接口嵌套

type sleeper interface {
    sleep()
}
type eater interface {
    eat()
}
type animal interface {
    sleeper
    eater
}

空接口

没有方法,所有类型都实现可以实现空接口。因此 interface{} 通常用来表示任意类型。

// 接口作参数
func show(a interface{}) {
    fmt.Printf("%T, %v", a, a)
}
// 还不知道存入的值是什么类型时
var m = make(map[string]interface{}, 8)

如何判断接口这个动态变量的类型

  • 类型断言
  • 反射

类型断言

x.(type)
// x:    类型为 interface{} 的变量
// type: 断言这个x变量可能的类型
// 返回2个值 v, ok
// 如果ok=true,那么v的类型就是type;如果ok=false,那么v的类型就不是type
func guessType(a interface{}) {
    if s, ok := a.(string); ok {
        fmt.Printf("is a string, value is %v\n", s)
    } else {
        fmt.Printf("unknown type\n")
    }
}
func guessType(a interface{}) {
    switch v := a.(type) {
    case string:
        fmt.Printf("is a string, value is %v\n", v)
    case int:
        fmt.Printf("is a int, value is %v\n", v)    
    case bool:
        fmt.Printf("is a bool, value is %v\n", v)
    default:
        fmt.Printf("unknown type\n")
    }
}

反射

等待学习反射

return 做了什么?

  1. 返回值 赋值
  2. return 返回值

defer 什么时候执行?

  1. 返回值 赋值
  2. defer func
  3. return 返回值

返回值 看作一个中间商,比如一个tmp变量。先回忆一下函数如何定义返回值。

  • func 使用 有名返回值

    func f() x int {
      ...
      return
    }
  • func 使用 匿名返回值

    func f() int {
      ...
      return x
    }

我豁然开朗的关键点

返回值 看作和x、y不一样的另一个变量即可,咱们只关注它的值的指向。


栗子1

func f1() int {
    x := 5
    defer func() {
        x++
    }()
    return x
}
  1. 返回值 = x = 5,返回值指向5
  2. 不管defer如何操作
  3. return 返回值,即 return 5

栗子2

func f2() (x int) {
    defer func() {
        x++
    }()
    return 5
}
  1. 返回值x,x指向5
  2. 闭包defer修改x的指向为6
  3. return 返回值,即 return x,return 6

栗子3

func f3() (y int) {
    x := 5
    defer func() {
        x++
    }()
    return x
}
  1. 返回值y,y=x=5,y指向5
  2. 闭包defer修改x的指向为6
  3. return 返回值,即 return y,return 5

栗子4

func f4() (x int) {
    defer func(x int) {
        x++
    }(x)
    return 5
}
  1. 返回值x,x=5,x指向5
  2. defer值拷贝,内部操作与外部无关
  3. return 返回值,即 return x,return 5