架构

sche – 一种人类能够看懂的 cron 语法

在 Linux 系统上,我们一般使用 cron 来设置定时任务,然而 cron 的语法还是有些佶屈聱牙的,几乎每次要修改的时候都需要查一下文档才知道什么意思,以至于有 crontab.guru 这种网站专门来解释 cron 的语法。

想象一下,能不能有一种让人一眼就能看懂的语法来表达周期性的调度操作呢?比如说这样:

every 10 minutes         , curl apple.com
every hour               , echo 'time to take some coffee'
every day at 10:30       , eat
every 5 to 10 minutes    , firefox http://news.ycombinator.com
every monday             , say 'Good week'
every wednesday at 13:15 , rm -rf /
every minute at :17      , ping apple.com
every 90 minutes         , echo 'time to stand up'

这样的配置文件是不是很容易懂呢?如果要写成 crontab 的格式大概是这样的:

*/10 * * * *    curl apple.com
0 * * * *       echo 'time to take some coffee'
30 10 * * *     eat
*/7 * * * *     firefox http://news.ycombinator.com  # 实际上是不对的,因为 cron 没法随机
0 0 * * MON     say 'Good week'
15 13 * * WED   rm -rf /
# every minute at :17  无法实现,因为 cron 中没有秒
0 0-21/3 * * *  echo 'time to stand up'  # 需要两条命令来完成每隔 90 分钟的操作
30 1-22/3 * * * echo 'time to stand up'

可以很明显看出,cron 的语法可读性还是差一些的,关键是维护起来更是像读天书一样。幸运的是,我在周末刚刚做了一个小工具,虽然还比较粗糙,但是也已经可以解析上面这种可读性比较好的语法。下面简单介绍一下如何使用:

介绍 sche

sche 是一个 Python 程序,所以可以使用 pip 直接安装:

pip install sche

安装之后,就会得到一个 sche 命令,帮助文件如下:

-> % sche -h
usage: sche [-h] [-f FILE] [-t]

A simple command like `cron`, but more human friendly.

The default configuration file is /etc/schetab, syntax goes like:

    # (optional) set time zone first
    timezone = +0800

    # line starts with # is a comment
    every 60 minutes, echo "wubba lubba dub dub"

    # backup database every day at midnight
    every day at 00:00, mysqldump -u backup

    # redirect logs so you can see them
    every minute, do_some_magic >> /some/output/file 2>&1

optional arguments:
  -h, --help            show this help message and exit
  -f FILE, --file FILE  configuration file to use
  -t, --test            test configuration and exit

我们只需要把需要执行的命令放到 /etc/schetab 文件下就好了,这里显然是在致敬 /etc/crontab。比如说:

-> % cat /etc/schetab
timzone = +0800
every 5 seconds, echo "wubba lubba dub dub"
every 10 seconds, date

-> % sche
wubba lubba dub dub
Tue Sep  1 22:15:01 CST 2020
wubba lubba dub dub
wubba lubba dub dub
Tue Sep  1 22:15:11 CST 2020
wubba lubba dub dub
wubba lubba dub dub
Tue Sep  1 22:15:21 CST 2020
wubba lubba dub dub

如何让 sche 像 cron 一样作为一个守护进程呢?秉承 Unix 一个命令只做一件事的哲学,sche 本身显然是不提供这个功能的,可以使用 systemd 实现,几行配置写个 unit 文件就搞定了。

sche 的来源

sche 是 schedule — 另一个 Python 库的一个 fork, schedule 支持这样的 Python 语句:

schedule.every(10).minutes.do(job)
schedule.every().hour.do(job)
schedule.every().day.at("10:30").do(job)
schedule.every().monday.do(job)
schedule.every().wednesday.at("13:15").do(job)
schedule.every().minute.at(":17").do(job)

然而我的需求是把时间配置独立出来,能够像 cron 一样存到一个文本文件里,而不是写成 Python 代码,于是提了一个 PR,增加了 when 这个方法来解析表达式。同时我还强烈需求时区支持,然而原版的 schedule 也不支持。所以就创建了一个 fork.

sche.when("every wednesday at 13:15").do(job)
sche.timezone("+0800").every().day.at("00:00").do(job)

最后,原生的 cron 命令实际上(至少我)已经极少用了,然而 crontab 的语法流传还是非常广的,在所有需要定时任务的地方,几乎都能看到 cron 的身影,比如说 Kubernetes job 等等,如果能够使用一种让正常人能随时看懂的语法,感觉还是很有意义的。

参考

  1. https://schedule.readthedocs.io/en/stable/
  2. https://crontab.guru/
  3. https://stackoverflow.com/q/247626/1061155

故障排查记录

Case 1:

CPU 和内存都被打满了,同时发现硬盘 IO 也很高。

ps -eo pid,ppid,cmd,%mem,mem,%cpu --sort=-%mem | head -n 50

发现 kswapd0 偶尔会占用很高的 CPU,而这个进程是在内存满的时候负责在内存和 swap 之间交换。也就是问题的根源是内存满了,而 kswapd0 开始在内存和 swap 之间反复读写文件,导致 CPU 和 IO 也涨了起来,这时候应该找到内存暴涨的根源,进而解决问题。

[1] https://askubuntu.com/questions/259739/kswapd0-is-taking-a-lot-of-cpu

使用 Nomad 编排服务

2019-01-02 更新:相对于 Kubernetes 来说,Nomad 还是太简陋了,弃坑

Nomad 是 HashiCorp 出品的一个容器编排服务,相较于重量级的 Kubernetes 来说,Nomad 的特点在于

  1. 轻量级,只有一个二进制文件。K8s 的安装可能就要花上半天,在国内还有万恶的防火墙问题。
  2. 概念也比较清晰,专注于任务的调度的编排,而不像 Kubernetes 一样引入了各种五花八门的概念。
  3. 除了编排容器之外,Nomad 还可以直接编排普通应用,使用 cgroups 安全运行应用。

安装

从官网下载二进制文件,复制到 /usr/local/bin 就好了,不再赘述

使用

$ sudo nomad agent -dev

$ nomad node status
ID        DC   Name   Class   Drain  Eligibility  Status
171a583b  dc1  nomad  <none>  false  eligible     ready

$ nomad server members
Name          Address    Port  Status  Leader  Protocol  Build  Datacenter  Region
nomad.global  127.0.0.1  4648  alive   true    2         0.7.0  dc1         global

Job

Nomad 的调度单元称作 Job,Job 分为了三种类型:

  1. Batch,也就是一次批处理,程序运行之后就结束了。不过也可以通过 cron 字段指定任务定期运行
  2. Service,程序是一个常驻内存的服务,如果退出之后,Nomad 会按照给定的策略重启
  3. System,在每一个 Nomad 节点上都需要运行的服务

Job 可以使用 HCL 文件来定义,HCL 文件在语义上和 JSON 是等价的,只不过是省去了一些多余的引号逗号之类的。也可以使用 JSON 文件来定义。

创建一个新的 Job

创建一个空白的 job 文件

$ nomad job init
Example job file written to example.nomad

打开生成的 example.nomad 文件,我们看到生成了一大推配置,默认定义了一个 redis 服务器的 job。Job 中包含了 Group,Group 中包含了 Task,task 可以认为是我们最终需要运行服务的那个命令。比如这里就是定义了运行 redis:3.2 这个 docker 镜像。

task "redis" {
  # The "driver" parameter specifies the task driver that should be used to
  # run the task.
  driver = "docker"

  # The "config" stanza specifies the driver configuration, which is passed
  # directly to the driver to start the task. The details of configurations
  # are specific to each driver, so please see specific driver
  # documentation for more information.
  config {
    image = "redis:3.2"
    port_map {
      db = 6379
    }
  }

我们可以运行一下这个 job

-> % nomad job run example.nomad
==> Monitoring evaluation "4f5559e0"
    Evaluation triggered by job "example"
    Allocation "98959767" created: node "ecf9f7cd", group "cache"
    Evaluation within deployment: "e66e0957"
    Evaluation status changed: "pending" -> "complete"
==> Evaluation "4f5559e0" finished with status "complete"

然后查看一下 job 的运行状态:

$ nomad status example
...
Allocations
ID        Node ID   Task Group  Version  Desired  Status   Created  Modified
8ba85cef  171a583b  cache       0        run      running  5m ago   5m ago

在最下面一行我们可以看到 Allocation 的状态。Allocation 可以理解为一个 Job 的一个实例化。

我们可以再查看这个 Alloc 的状态:

$ nomad alloc status 8ba85cef
...
Recent Events:
Time                   Type        Description
10/31/17 22:58:49 UTC  Started     Task started by client
10/31/17 22:58:40 UTC  Driver      Downloading image redis:3.2
10/31/17 22:58:40 UTC  Task Setup  Building Task Directory
10/31/17 22:58:40 UTC  Received    Task received by client

查看 Alloc 的日志

$ nomad alloc logs 8ba85cef redis

修改 Job

比如说,我们可以把这个 Job 中 cache task group 需要运行的副本数量改为 3

count = 3

使用 nomad job plan 来 dry run 一下。

$ nomad job plan example.nomad

+/- Job: "example"
+/- Task Group: "cache" (2 create, 1 in-place update)
  +/- Count: "1" => "3" (forces create)
      Task: "redis"
...
Job Modify Index: 7
To submit the job with version verification run:

nomad job run -check-index 7 example.nomad
...

注意到其中返回了一个 check-index 这个是为了避免同时更改同一个 job 造成冲突。

$ nomad job run -check-index 7 example.nomad

集群

在生产环境中,我们当然应该使用集群模式,而不是单机。nomad 可以直接利用 consul 来实现 bootstrap 集群。

服务端配置:

# /etc/nomad.d/server.hcl

data_dir = "/etc/nomad.d"

server {
  enabled          = true
  bootstrap_expect = 3
}

启动:

$ nomad agent -config=/etc/nomad.d/server.hcl

客户端配置:

# /etc/nomad.d/client.hcl

datacenter = "dc1"
data_dir   = "/etc/nomad.d"

client {
  enabled = true
}

启动:

$ nomad agent -config=/etc/nomad.d/client.hcl

图解一致性哈希

起源

比如你有 N 个 cache 服务器(后面简称 cache ),那么如何将一个对象 object 映射到 N 个 cache 上呢,你很可能会采用类似下面的通用方法计算 object 的 hash 值,然后均匀的映射到到 N 个 cache ;

hash(object) % N

一切都运行正常,再考虑如下的两种情况;

  1. 一个 cache 服务器 m down 掉了(在实际应用中必须要考虑这种情况),这样所有映射到 cache m 的对象都会失效,怎么办,需要把 cache m 从 cache 中移除,这时候 cache 是 N-1 台,映射公式变成了 hash(object) % (N-1)

  2. 由于访问加重,需要添加 cache ,这时候 cache 是 N+1 台,映射公式变成了 hash(object) % (N+1)

1 和 2 意味着什么?这意味着突然之间几乎所有的 cache 都失效了。对于服务器而言,这是一场灾难,洪水般的访问都会直接冲向后台服务器;

再来考虑第三个问题,由于硬件能力越来越强,你可能想让后面添加的节点多做点活,显然上面的 hash 算法也做不到。

有什么方法可以改变这个状况呢,这就是 consistent hashing…

一致性哈希

一致性哈希把哈希值想象成一个环,比如说在 0 ~ 2^32-1 这个范围内,然后将节点(名字、IP等)求哈希之后分不到环上。当有访问请求时,把请求信息求哈希之后,寻找小于该哈希值的下一个节点。

当有节点宕机的时候,请求会依次查找下一个节点,从而不让所有节点的缓存都失效。

当加入新节点的时候,只会影响一个区间内的请求,也不会影响其他区间。

如下图所示:

虚拟节点

以上虽然解决了大部分问题,但是还有三个问题:

  1. 节点有可能在分布不均匀。
  2. 当一个节点因为负载过重宕机以后,所有请求会落到下一台主机,这样就有可能使下一台主机也宕机,这就是雪崩问题。
  3. 不同主机处理能力不同如何配置不同的量。

这时候可以引入虚拟节点。原始的一致性哈希中,每个节点通过哈希之后在环上占有一个位置,可以通过对每个节点多次计算哈希来获得过个虚拟节点。

比如说,本来我们通过节点的 IP 来计算哈希

hash("10.1.1.1")  => n1
hash("10.1.1.2")  => n2
hash("10.1.1.3")  => n3

现在引入两倍的虚拟节点之后

hash("10.1.1.1-1")  => n1-1
hash("10.1.1.1-2")  => n1-2
hash("10.1.1.2-1")  => n2-1
hash("10.1.1.2-2")  => n2-2
hash("10.1.1.3-1")  => n3-1
hash("10.1.1.3-2")  => n3-2

如图所示

引入虚拟节点之后:

  1. 平衡性得到了直接改善
  2. 主机是交替出现的,所以当一个节点宕机后,所有流量会随机分配给剩余节点
  3. 可以给处理能力强的节点配置更多地虚拟节点。

最后,一致性哈希可以用跳表或者平衡二叉树来实现

参考文档

  1. https://blog.csdn.net/MBuger/article/details/76189561
  2. https://www.cnblogs.com/23lalala/p/3588553.html
  3. https://crossoverjie.top/2018/01/08/Consistent-Hash/

序列化协议的选择 json vs msgpack vs thrift vs protobuf

当我们的程序需要保存一些对象到硬盘上供下次运行时使用,或者需要和其他程序交换数据
的时候,需要把对象用某种方式编程二进制字符串然后保存到硬盘上或者发送出去,这种方
法我们一般称作序列化。序列化有很多不同的方法,一般考虑三个方面:

  1. 速度,序列化和反序列化的速度越快越好
  2. 体积,序列化之后的文件体积越小越好
  3. 跨语言,序列化能够支持的语言越多越好

下面考察几种序列化的方法

  1. 语言内置的序列化。比如 Python 的 pickle,显然这种协议只能在一种语言内部使用,
    而且对于 Python 来说,甚至不同版本的 pickle 协议都是不兼容的。
  2. json / xml。这两个都可以把对象序列化成人类可读的字符串的形式,但是序列化后之
    后体积都变大不少,而且性能也不好,适合于简单的场景。另外一点就是 json 不能定
    义 schema(接口规范),在大型项目中 schema 是必须的
  3. msgpack 序列化之后的体积也比较紧致,但是同样不能定义 schema。
  4. 专门的序列化库。比如 protobuf/thrift。这些库都支持多个语言,需要预先定义
    schema, 并且把对象序列化成二进制的模式,性能也都不错,所以我们重点关注一下。

考虑到需要定义接口规范,所以我们只考虑 thrift 和 protobuf 两种

Thrift 的缺点:

  • 不支持 uint64。
  • 查过一些文档之后,发现 thrift 的性能差于 pb。

所以先淘汰了 thrift。我们选择 protobuf

编译步骤放在哪里?

protobuf 和 thrift 两个的用法都是先定义 IDL(接口)文件,然后由编译器编译生成对应的语言
的代码。对于 C++ 这样的编译语言来说问题不大,我们可以把 IDL 编译的过程放到
makefile 里面去,但是对于 Python 这种没有编译的动态语言就尴尬了。具体来说,IDL
文件是需要提交到代码仓库的,但是生成的 Python 代码需不需要呢?

  1. 不提交,在运行之前多一个编译步骤,不过可以把编译这一步写到 dockerfile 里面
  2. 提交,这样会造成提交的代码冗余,相当于把二进制文件提交到了仓库

所以我还是倾向于只向代码库中提交 *.proto 或者 *.thrift 源文件,而不提交编译过后的文件。

Protobuf

基本语法

protobuf 现在有两个主流版本,显然 proto2 要被逐渐废弃,本文使用的是 proto3。

syntax = "proto3";
package foo.bar;
import "myproject/other_protos.proto";

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
  enum Corpus {
    UNIVERSAL = 0;
    WEB = 1;
    IMAGES = 2;
    LOCAL = 3;
    NEWS = 4;
    PRODUCTS = 5;
    VIDEO = 6;
  }
  Corpus corpus = 4;
}

message SearchResponse {
  repeated Result result = 1;
}

message Result {
  string url = 1;
  string title = 2;
  repeated string snippets = 3;
}

上面的结构和 C 语言的 struct 定义很像。

  1. message 关键字用于声明一个结构,后面加结构的名字
  2. protobuf 3 不支持默认值。
  3. 类型。protobuf 中定义的标量类型有 double/float/int32(64)/uint32(64)/bool/string/bytes.
    其中 bytes 用来表示任意的二进制字符串
  4. 序号,每个字段后面的数字表示的是序号。protobuf 用这个序号来进行高效编码,需要
    注意的是,如果要增添字段不能复用已有的序号。
  5. 枚举。可以使用 enum 关键字定义枚举。枚举可以定义在 message 的外面或者里面
  6. 在一个文件中可以定义多个 message。像是 enum 一样,message 也可以嵌套在另一个
    message 中。比如可以把上面的 Result 嵌套在 SearchResponse 中。不过这时候再引用
    Result,需要使用 SearchResponse.Result
  7. message 中可以使用另一个 message 作为类型。
  8. 使用 import 语句来引入其他的 proto 文件。这样就可以直接使用引入
  9. package 语句用来声明定义的 message 所处的命名空间(namespace)

编译

在 Python 中使用

ParseFromString: 从字符串中解析 protobuf 对象。虽然这个方法名字中包含了 string,但是实际上使用的是 bytes。

r = SearchResponse()
r.ParseFromString(data)

SerializeToString: 序列化成字符串。同样使用 bytes。

data = r.SerializeToString()

属性可以直接访问和设置,如果属性名或者类型出错会抛出异常。

repeated 类型的基础类型属性可以像一个数组一样访问,map 类型可以像字典一样访问。但是赋值必须通过 append 和 extend 赋值,而不能直接赋值。

repeated 类型的 message 类型不能使用 append,而必须使用 add 或者 extend 方法。这样可以确保 message 类型被拷贝进去。

REF

  1. https://tech.meituan.com/serializationvsdeserialization.html
  2. https://my.oschina.net/fir01/blog/468123
  3. http://colobu.com/2015/01/07/Protobuf-language-guide/
  4. https://developers.google.com/protocol-buffers/docs/pythontutorial
  5. 在 Python 中使用 ProtoBuf
  6. https://developers.google.com/protocol-buffers/docs/reference/python-generated

软件工程中的 “3” 的规则

我注意到了一个神奇的软件工程法则:在你正确地解决问题之前,你至少需要3个例子。

具体说来是这样的:

  1. 不要试图在两个类之间共享代码,至少等到你有三个类的时候。
  2. 解决问题的前两次尝试一定会失败,因为你还没完全理解这个问题。第三次才行
  3. 任何想要早期就能设计好的尝试都会导致对于巧合情形的过度拟合。

你在说什么?请给个例子

比如说你在实现一个类,从银行抓取数据。下面是一个非常傻瓜的版本,但是应该说明了问题:

class ChaseScraper:
    def __init__(self, username, password):
        self._username = username
        self._password = password

    def scrape(self):
        session = requests.Session()
        sessions.get("https://chase.com/rest/login.aspx",
                 data={"username": self._username,
                   "password": self._password})
    sessions.get("https://chase.com/rest/download_current_statement.aspx")

现在你想添加第二个类 CitiBankScraper 来实现相同的接口,但是改变了一些实现细节。实际上假设CitiBank只是有一个不同的 url 和表单元素名称而已。让我们来添加一个新的爬虫:

class CitibankScraper:
    def __init__(self, username, password):
        self._username = username
        self._password = password

    def scrape(self):
        session = requests.Session()
        sessions.get("https://citibank.com/cgi-bin/login.pl",
                 data={"user": self._username,
                   "pass": self._password})
        sessions.get("https://citibank.com/cgi-bin/download-stmt.pl")

因为经过了多年DRY原则的教育,这时候我们发现这两个类的代码几乎是重复的!我们应该重构一下,把所有的重复代码都放到一个基类中。在这里,我们需要Inserve of Control 模式,让基类来控制逻辑。

class BaseScraper:
    def __init__(self, username, password):
        self._username = username
        self._password = password

    def scrape(self):
        session = requests.Session()
        sessions.get(self._LOGIN_URL,
                 data={self._USERNAME_FORM_KEY: self._username,
                   self._PASSWORD_FORM_KEY: self._password})
        sessions.get(self._STATEMENT_URL)


class ChaseScraper(BaseScraper):
    _LOGIN_URL = "https://chase.com/rest/login.aspx"
    _STATEMENT_URL = "https://chase.com/rest/download_current_statement.aspx"
    _USERNAME_FORM_KEY = "username"
    _PASSWORD_FORM_KEY = "password"


class CitibankScraper(BaseScraper):
    _LOGIN_URL = "https://citibank.com/cgi-bin/login.pl"
    _STATEMENT_URL = "https://citibank.com/cgi-bin/download-stmt.pl"
    _USERNAME_FORM_KEY = "user"
    _PASSWORD_FORM_KEY = "pass"

这应该让我们删掉了不少代码。这已经是最简单的方法之一了。所以问题在哪里呢?(出去我们实现继承的方法不好之外)

问题是我们过度拟合了!过度拟合是什么意思呢?我们正在抽象出并不能很好泛化的模式!

facepalm

为了验证这一点,假设我们又需要从第三个银行抓取数据。也许它需要如下几点:

  • 他需要两步验证
  • 密码是使用 JSON 传递的
  • 登录使用了POST而不是GET
  • 需要同时访问多个页面
  • 要访问的url是根据当前日期动态生成的

…… 或者随便什么东西,有1000中方式让我们的代码不能工作。我希望你已经感觉到问题所在了。我们以为我们通过前两个爬虫发现了一个模式!然鹅悲剧的是,我们的爬虫根本不能泛化到第三个银行(或者更多,第n个)。也就是说,我们过拟合了。

过拟合到底是什么意思?

过拟合指的是我们在数据发现了一个模式,但是这个模式并不能很好地泛化。当我们在写代码的时候,我们经常对于优化代码重复非常警觉,我们会发现一些偶然出现的模式,但是如果我们查看整个程序的话,我们知道这些模式可能并不能很好地代表整个程序的模式。所以当我们实现了两个银行的爬虫之后,我们以为我们发现了一个广泛的模式,实际上并不是。

注意到,代码重复并不总是一件坏事。工程师们通常过分关注减少重复代码,但是也应该注意区分偶然的代码重复和系统性的代码重复之间的区别。

因此,让我来引入第一个 “3” 之规则。如果你只有两个类或者对象,不要过分关注代码重复。当你在三个不同的地方看到同一个模式的时候在考虑如何重构。

“3” 之规则应用到架构上

同样的推理可以应用到系统设计上,但是会得出一个非常不同的结论。当你从头构建一个新的系统的时候,你不知道他最终会被如何使用,不要被假设所限制。在第一代和第二代产品上,我们认为需要的限制可能真的是需要的,但是当实现第三代产品的时候,我们会发现假设是完全错误的,并最终实现正确的版本。

比如说,Luigi就是解决问题的第三次尝试。前两个尝试解决了错误的问题,并且为错误的方向做了优化。比如第一个版本依赖于在 XML 中设计依赖图。但是很显然这是非常不友好的,因为你一般县要在代码里生成依赖图比较好。而且,在前两次设计中看起来很有用的一些新设计,比如任务解耦输出,最终只给一些非常少见的例子添加了支持,但是有添加了不少复杂度。

第一个版本中看起来很奇怪的问题可能在后来是很重要的问题,反过来也是。

I was reminded of this when we built an email ingestion system at Better. The first attempt failed because we built it in a poor way (basically shoehorning it into a CRUD request). The second one had a solid microservice design but failed for usability reasons (we built a product that no one really asked for). We’re halfway through the third attempt and I’m having a good feeling about it.

这个故事告诉了我们第二个 “3” 之规则——在系统设计上,直到第三次你才能够做对。

更重要的是,如果你的第一版有一些奇怪的位置问题,不要假设你需要搞定他们。走捷径。绕开奇怪的问题。估计你也不会运行这个系统很长时间——总有一天他会坏的。第二个版本大多数时候也是坏的。第三个版本值得你把它雕琢到完美。

three cupcakes

原文:https://erikbern.com/amp/2017/08/29/the-software-engineering-rule-of-3.html

使用 supervisord 部署服务

在某一刻你会意识到你需要写一个长期运行的服务。如果有错误发生,这些脚本不应该停止运行,而且当系统重启的时候应该自动把这些脚本拉起来。为了实现这一点,我们需要一些东西来监控脚本。这些工具在脚本挂掉的时候重启他们,并且在系统启动的时候拉起他们。

脚本

这样的工具应该是怎样的呢?我们安装的大多数东西都带了某种进程监控的机制。比如说 Upstart 和 Systemd。这些工具被许多系统用来监控重要的进程。当我们安装 php5-fpm,Apache 和 nginx 的时候,他们通常已经和系统集成好了,以便于他们不会默默挂掉。

然而,我们有时候需要一些简单点儿的解决方案。比如说我经常写一些 nodejs 的脚本来监控 github 上的某个动态并作相应的动作。node 可以处理 http 请求并且同时处理他们,也就是很适合作为一个一次性运行的服务。这些小的脚本可能不值得使用 Upstart 或者 Systemd 这种重量级的东西。

下面是我们的例子, 把它放在 /srv/http.js 中

var http = require("http");

function serve(ip, port) {
    http.createServer(function (req, res) {
        res.writeHead(200, {"Content-Type": "text/plain"});
        res.write("\nSome Secrets:");
        res.write("\n"+process.env.SECRET_PASSPHRASE);
        res.write("\n"+process.env.SECRET_TWO);
        res.end("\nThere"s no place like "+ip+":"+port+"\n");
    }).listen(port, ip);
    console.log("Server running at http://"+ip+":"+port+"/");
}

// Create a server listening on all networks
serve("0.0.0.0", 9000);

这个服务仅仅是接受一个 http 请求并打印一条消息。在现实中并没有什么卵用,但是用来演示很好。我们只是需要一个服务来运行和监控。

注意到这个服务打印两个变量 “SECRETPASSPHRASE” 和 “SECRETTWO”。我们将会演示如何把这个传递个被监控的进程。

Supervisord

Supervisord 是一个使用很广也很简单的进程监控工具。

安装

supervisor 支持 python 3,也建议用这个版本。

brew install supervisor

在 linux 上可以通过 apt-get 来安装 supervisor,同样的命令。Centos 的命令请自己查询。

% sudo apt-get install supervisor
% sudo systemctl status supervisor
● supervisor.service - Supervisor process control system for UNIX
   Loaded: loaded (/lib/systemd/system/supervisor.service; enabled; vendor preset: enabled)
   Active: active (running) since Mon 2019-12-09 18:05:36 CST; 1min 15s ago
     Docs: http://supervisord.org
 Main Pwp_id: 1356 (supervisord)
    Tasks: 1 (limit: 4915)
   CGroup: /system.slice/supervisor.service
           └─1356 /usr/bin/python /usr/bin/supervisord -n -c /etc/supervisor/supervisord.conf

用系统的包管理器安装的好处是默认会设置 supervisord 的 systemd unit 文件,也就是已经开机启动了。

要想安装最新版本的 supervisor,可以使用 pip。但是需要自己设计开机启动

pip install supervisor

配置

下面我们来配置一个 supervisor 服务。

打开 /etc/supervisor/supervisord.conf,我们可以看到最后一行:

[include]
files = /etc/supervisor/conf.d/*.conf

所以我们只需要把我们的配置文件放在 /etc/supervisor/conf.d 文件夹下就好了。

[program:nodehook]
command=/usr/bin/node /srv/http.js
directory=/srv
autostart=true
autorestart=true
startretries=3
stderr_logfile=/var/log/webhook/nodehook.err.log
stdout_logfile=/var/log/webhook/nodehook.out.log
user=www-data
environment=SECRET_PASSPHRASE="this is secret",SECRET_TWO="another secret"

每个选项如下:

  • [program:xxx] 定义运行的服务的名字。
  • command 启动被监控的服务的命令,如果你需要传递命令行参数的话,也放在这里
  • directory 设定进程的运行目录
  • autostart 是否需要在 supervisord 启动的时候自动拉起
  • autorestart 是否在程序挂掉的时候自动重新拉起
  • startretries 如果启动失败,重试多少次
  • stderr_logfile 标准错误输出写入到哪个文件
  • stdout_logfile 标准输出写入到哪个文件
  • user 运行进程的用户
  • environment 传递给进程的环境变量

需要注意的是,supervisor 不会自动创建日志文件夹,所以需要我们首先创建好。

sudo mkdir /var/log/webhook

supervisor 配置文件的搜索路径包括:

/usr/local/etc/supervisord.conf
/usr/local/supervisord.conf
supervisord.conf  # 当前目录
etc/supervisord.conf
/etc/supervisord.conf
/etc/supervisor/supervisord.conf

控制进程

可以使用 supervisorctl 来控制对应的服务了。不过需要首先启动 supervisord 的 daemon 才行。

supervisorctl reread
supervisorctl update

这样就可以启动刚刚定义的服务。supervisorctl 的其他功能可以查看帮助

Web 界面

supervisor 自带了一个 web 界面。这样我们就可以通过浏览器来管理进程了。

在 /etc/supervisord.conf 中添加:

[inet_http_server]
port = 9001
username = user # Basic auth username
password = pass # Basic auth password

If we access our server in a web browser at port 9001, we’ll see the web interface:

注意

千万不要在 Docker 内部使用 supervisord 来启动多个进程,你这是在玩儿火!Docker 本身就是一个进程管理器,他的设计理念也是进程挂了就重启,如果你加了 supervisord 的话,很可能进程挂了,但是 supervisord 还活着,这样在 docker 看来整个服务就是健康的,殊不知 supervisord 可能在循环重启服务。总之,禁止套娃!

参考

  1. https://serversforhackers.com/c/monitoring-processes-with-supervisord

分布式系统中的锁

分布式系统需要使用分布锁。首先我们来回忆一下在单机情况下的锁。

当我们的程序在需要访问临界区的时候,我们可以加一个锁,如果是多线程程序,可以使用线程锁,如果是多进程程序,可以使用进程级别的锁。但是在分布式的环境中,如果在不同主机上部署的程序要访问同一个临界区是该怎么做呢?这时候我们需要分布式的锁。

当部署的服务或者脚本不在同一台机器上时,使用分布式的锁,可以使用 zookeeper 或者 redis 实现一个分布式锁。这里主要介绍一下基于 redis 的分布式锁。

redis 官方给出的单机 redis 分布式锁:

加锁

NX 命令指定了只有在不存在的时候才会创建,如果已经存在,则会返回失败。EX 指定了过期时间,避免进程挂掉后死锁。值设定为了一个随机数,这样只有加锁的进程才知道锁的值是多少

SET resource-name my_random_string NX EX max-lock-time

解锁

因为解锁时会检查是否提供了随机数的值,所以只有创建锁的进程才能够解锁。

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
EVAL "script..." 1 resource-name my_random_string

参考:

[1] https://redis.io/topics/distlock

systemd

YN:如何使安装的服务开机启动?是更改 wantedby 吗?如果是,wantedby 的值应该是什么? 对于 nginx 这样的 daemon 服务如何管理?

大多数的 Linux 系统已经选择了 systemd 来作为进程管理器。之前打算使用 supervisord 来部署服务,思考之后发现还不如直接使用 systemd 呢。这篇文章简单介绍下 systemd。 # 例子

我们从一个例子开始,比如说我们有如下的 go 程序:

<pre class="code">package main

import (
    fmt
    net/http
)

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, Hi there!)
}

func main() {
    http.HandleFunc(/, handler)
    http.ListenAndServe(:8181, nil)
}

编译到 /opt/listen/listen 这里。 首先我们添加一个用户,用来运行我们的服务:

<pre class="code">adduser -r -M -s /bin/false www-data

记下这条命令,如果需要添加用户来运行服务,可以使用这条。

Unit 文件

Unit 文件定义了一个 systemd 服务。/usr/lib/systemd/system/ 存放了系统安装的软件的 unit 文件,/etc/systemd/system/ 存放了系统自带的服务的 unit 文件。 我们编辑 /etc/systemd/system/listen.service 文件:

<pre class="code">[Unit]
Description=Listen

[Service]
User=www-data
Group=www-data
Restart=on-failure
ExecStart=/opt/listen/listen
WorkingDirectory=/opt/listen

Environment=VAR1=whatever VAR2=something else
EnvironmentFile=/path/to/file/with/variables

[Install]
WantedBy=multi-user.target

然后

<pre class="code">sudo systemctl enable listen
sudo systemctl status listen
sudo systemctl start listen

其他一些常用的操作还包括:

<pre class="code">systemctl start/stop/restart    
systemctl reload/reload-or-restart  
systemctl enable/disable    
systemctl status    
systemctl is-active 
systemctl is-enabled
systemctl is-failed
systemctl list-units [--all] [--state=…]    
systemctl list-unit-files
systemctl daemon-reload 
systemctl cat [unit-name]   
systemctl edit [uni-name]
systemctl list-dependencies [unit]

依赖管理

In that case add Requires=B and After=B to the [Unit] section of A. If the dependency is optional, add Wants=B and After=B instead. Note that Wants= and Requires= do not imply After=, meaning that if After= is not specified, the two units will be started in parallel. if you service depends on another service, use requires= + after= or wants= + after=

类型

Type: simple / forking 关于每个字段的含义,可以参考这篇文章

使用 journalctl 查看日志

首先吐槽一下, 为什么要使用 journal 这么一个拗口的单词, 叫做 logctl 不好么…

<pre class="code">journalctl -u service-name.service

还可以添加 -b 仅查看本次重启之后的日志.

启动多个实例

https://unix.stackexchange.com/questions/288236/have-systemd-spawn-n-processeshttp://0pointer.de/blog/projects/instances.html