Go 进阶 · 分布式爬虫实战day43-故障容错:如何在Worker崩溃时进行重新调度?

225 阅读10分钟

上一节课,我们用随机的方式为资源分配了它所属的 Worker。这一节课,让我们更进一步优化资源的分配。

对资源进行分配不仅发生在正常的事件内,也可能发生在 Worker 节点崩溃等特殊时期。这时,我们需要将崩溃的 Worker 节点中的任务转移到其他节点。

Master 调度的时机

具体来说,分配资源的时机可能有下面三种情况。

  • 当 Master 成为 Leader 时。
  • 当客户端调用 Master API 进行资源的增删查改时。
  • 当 Master 监听到 Worker 节点发生变化时。 其中,第二点“调用 Master API 进行资源的增删查改”我们会在这节课的最后完成,下面让我们实战一下剩下两点是如何实现资源的调度的。

Master 成为 Leader 时的资源调度

在日常实践中,Leader 的频繁切换并不常见。不管是 Master 在初始化时选举成为了 Leader,还是在中途由于其他 Master 异常退出导致 Leader 发生了切换,我们都要全量地更新一下当前 Worker 的节点状态以及资源的状态。

在 Master 成为 Leader 节点时,我们首先要利用 m.updateWorkNodes 方法全量加载当前的 Worker 节点,同时利用 m.loadResource 方法全量加载当前的爬虫资源。

func (m *Master) BecomeLeader() error {
  m.updateWorkNodes()
  if err := m.loadResource(); err != nil {
    return fmt.Errorf("loadResource failed:%w", err)
  }

  m.reAssign()

  atomic.StoreInt32(&m.ready, 1)
  return nil
}

接下来,调用 reAssign 方法完成一次资源的分配。m.reAssign 会遍历资源,当发现有资源还没有分配节点时,将再次尝试将资源分配到 Worker 中。如果发现资源都已经分配给了对应的 Worker,它就会查看当前节点是否存活。如果当前节点已经不存在了,就将该资源分配给其他的节点。

func (m *Master) reAssign() {
  rs := make([]*ResourceSpec, 0, len(m.resources))

  for _, r := range m.resources {
    if r.AssignedNode == "" {
      rs = append(rs, r)
      continue
    }

    id, err := getNodeID(r.AssignedNode)

    if err != nil {
      m.logger.Error("get nodeid failed", zap.Error(err))
    }

    if _, ok := m.workNodes[id]; !ok {
      rs = append(rs, r)
    }
  }
  m.AddResources(rs)
}

func (m *Master) AddResources(rs []*ResourceSpec) {
  for _, r := range rs {
    m.addResources(r)
  }
}

之前我们已经维护了资源的 ID、事件以及分配的 Worker 节点等信息。 在这里让我们更进一步,当资源分配到节点上时,更新节点的状态。

为此我抽象出了一个新的结构 NodeSpec,我们用它来描述 Worker 节点的状态。NodeSpec 封装了 Worker 注册到 etcd 中的节点信息 registry.Node。同时,我们额外增加了一个 Payload 字段,用于标识当前 Worker 节点的负载。当资源分配到对应的 Worker 节点上时,则更新 Worker 节点的状态,让 Payload 负载加 1。

type NodeSpec struct {
  Node    *registry.Node
  Payload int
}

func (m *Master) addResources(r *ResourceSpec) (*NodeSpec, error) {
  ns, err := m.Assign(r)
  ...
  r.AssignedNode = ns.Node.Id + "|" + ns.Node.Address
  _, err = m.etcdCli.Put(context.Background(), getResourcePath(r.Name), encode(r))
  m.resources[r.Name] = r
  ns.Payload++
  return ns, nil
}

Worker 节点发生变化时的资源更新

当我们发现 Worker 节点发生变化时,也需要全量完成一次更新。这是为了及时发现当前已经崩溃的 Worker 节点,并将这些崩溃的 Worker 节点下的任务转移给其他 Worker 节点运行。

如下所示,当 Master 监听 workerNodeChange 通道,发现 Worker 节点产生了变化之后,就会像成为 Leader 一样,更新当前节点与资源的状态,然后调用 m.reAssign 方法重新调度资源。

func (m *Master) Campaign() {
  ...
  for {
    select {
    case resp := <-workerNodeChange:
      m.logger.Info("watch worker change", zap.Any("worker:", resp))
      m.updateWorkNodes()
      if err := m.loadResource(); err != nil {
        m.logger.Error("loadResource failed:%w", zap.Error(err))
      }
      m.reAssign()
    }
  }
}

负载均衡的资源分配算法

接下来,我们再重新看看资源的分配。上节课我们都是将资源随机分配到某一个 Worker 上的,但是在实践中很可能会有多个 Worker,而为了对资源进行合理的分配,需要实现负载均衡,让 Worker 节点分摊工作量。

负载均衡分配资源的算法有很多,例如轮询法、加权轮询法、随机法、最小负载法等等,而根据实际场景,还可能需要有特殊的调度逻辑。这里我们实现一种简单的调度算法:最小负载法。在我们当前的场景中,最小负载法能够比较均衡地将爬虫任务分摊到 Worker 节点中。它每一次都将资源分配给具有最低负载的 Worker 节点,这依赖于我们维护的节点的状态。

如下所示,第一步我们遍历所有的 Worker 节点,找到合适的 Worker 节点。其实这一步可以完成一些简单的筛选,过滤掉一些不匹配的 Worker。举一个例子,有些任务比较特殊,在计算时需要使用到 GPU,那么我们就只能将它调度到有 GPU 的 Worker 节点中。这里我们没有实现更复杂的筛选逻辑,把当前全量的 Worker 节点都作为候选节点,放入到了 candidates 队列中。

func (m *Master) Assign(r *ResourceSpec) (*NodeSpec, error) {
  candidates := make([]*NodeSpec, 0, len(m.workNodes))

  for _, node := range m.workNodes {
    candidates = append(candidates, node)
  }

  //  找到最低的负载
  sort.Slice(candidates, func(i, j int) bool {
    return candidates[i].Payload < candidates[j].Payload
  })

  if len(candidates) > 0 {
    return candidates[0], nil
  }

  return nil, errors.New("no worker nodes")
}

第二步,根据负载对 Worker 队列进行排序。这里我使用了标准库 sort 中的 Slice 函数。Slice 函数的第一个参数为 candidates 队列;第二个参数是一个函数,它可以指定排序的优先级条件,这里我们指定负载越小的 Worker 节点优先级越高。所以在排序之后,负载最小的 Worker 节点会排在前面。

第三步,取排序之后的第一个节点作为目标 Worker 节点。现在,让我们来验证一下资源分配是否成功实现了负载均衡。首先,启动两个 Worker 节点。

» go run main.go worker --id=1 --http=:8080  --grpc=:9090
» go run main.go worker --id=2 --http=:8079  --grpc=:9089

接着,我们在配置文件中加入 5 个任务,并启动一个 Master 节点。

» go run main.go master --id=2 --http=:8081  --grpc=:9091

Master 在初始化时就会完成任务的分配,我们可以在 etcd 中查看资源的分配情况,结果如下所示。

» docker exec etcd-gcr-v3.5.6 /bin/sh -c "/usr/local/bin/etcdctl get --prefix /resources"                                                                  jackson@bogon
/resources/douban_book_list
{"ID":"1604065810010083328","Name":"douban_book_list","AssignedNode":"go.micro.server.worker-2|192.168.0.107:9089","CreationTime":1671274065865783000}
/resources/task-test-1
{"ID":"1604066677018857472","Name":"task-test-1","AssignedNode":"go.micro.server.worker-1|192.168.0.107:9090","CreationTime":1671274272579882000}
/resources/task-test-2
{"ID":"1604066699756179456","Name":"task-test-2","AssignedNode":"go.micro.server.worker-2|192.168.0.107:9089","CreationTime":1671274278001122000}
/resources/task-test-3
{"ID":"1604066716206239744","Name":"task-test-3","AssignedNode":"go.micro.server.worker-1|192.168.0.107:9090","CreationTime":1671274281922539000}
/resources/xxx
{"ID":"1604065810026860544","Name":"xxx","AssignedNode":"go.micro.server.worker-1|192.168.0.107:9090","CreationTime":1671274065869756000}

观察资源分配的 Worker 节点,会发现当前有 3 个任务分配到了 go.micro.server.worker-2,有 2 个节点分配到了 go.micro.server.worker-1,说明我们现在的负载均衡策略符合预期。

接下来,让我们删除 worker-1 节点,验证一下 worker-1 中的资源是否会自动迁移到 worker-2 中。输入 Ctrl+C 退出 worker-1 节点,然后回到 etcd 中查看资源分配的情况,发现所有的资源都已经迁移到了 worker-2 中。这说明当 Worker 节点崩溃后,重新调度任务的策略是符合预期的。

» docker exec etcd-gcr-v3.5.6 /bin/sh -c "/usr/local/bin/etcdctl get --prefix /resources"                                                                  jackson@bogon
/resources/douban_book_list
{"ID":"1604065810010083328","Name":"douban_book_list","AssignedNode":"go.micro.server.worker-2|192.168.0.107:9089","CreationTime":1671274065865783000}
/resources/task-test-1
{"ID":"1604069265235775488","Name":"task-test-1","AssignedNode":"go.micro.server.worker-2|192.168.0.107:9089","CreationTime":1671274889679244000}
/resources/task-test-2
{"ID":"1604066699756179456","Name":"task-test-2","AssignedNode":"go.micro.server.worker-2|192.168.0.107:9089","CreationTime":1671274278001122000}
/resources/task-test-3
{"ID":"1604069265252552704","Name":"task-test-3","AssignedNode":"go.micro.server.worker-2|192.168.0.107:9089","CreationTime":1671274889683174000}
/resources/xxx
{"ID":"1604069265269329920","Name":"xxx","AssignedNode":"go.micro.server.worker-2|192.168.0.107:9089","CreationTime":1671274889687807000}

最后我们来看看 Master Leader 切换时的情况。我们新建一个 Master,它的 ID 为 3。输入 Ctrl+C 中断之前的 Master 节点。

» go run main.go master --id=3 --http=:8082  --grpc=:9092

这时再次查看 etcd 中的资源分配情况,会发现资源的信息没有任何变化。这是符合预期的,因为当前的资源在之前都已经分配给了 Worker,不需要再重新分配了。

实战 Master 资源处理 API

接下来,让我们为 Master 实现对外暴露的 API,方便外部客户端进行访问,实现资源的增删查改。按照惯例,我们仍然会为 API 实现 GRPC 协议和 HTTP 协议。

**首先,我们要在 crawler.proto 中书写 Master 服务的 Protocol Buffer 协议。 **

我们先为 Master 加入两个 RPC 接口。其中,AddResource 接口用于增加资源,参数为结构体 ResourceSpec,表示添加资源的信息。其中最重要的参数是 name,它标识了具体启动哪一个爬虫任务。返回值为结构体 NodeSpec,NodeSpec 描述了资源分配到 Worker 节点的信息。 DeleteResource 接口用于删除资源,请求参数为资源信息,而不需要有任务返回值信息,因此这里定义为空结构体 Empty。为了引用 Empty,在这里我们导入了 google/protobuf/empty.proto 库。

syntax = "proto3";
option go_package = "proto/crawler";
import "google/api/annotations.proto";
import "google/protobuf/empty.proto";

service CrawlerMaster {
  rpc AddResource(ResourceSpec) returns (NodeSpec) {
      option (google.api.http) = {
            post: "/crawler/resource"
            body: "*"
        };
  }
  rpc DeleteResource(ResourceSpec) returns (google.protobuf.Empty){
      option (google.api.http) = {
            delete: "/crawler/resource"
            body: "*"
        };
  }
}

message ResourceSpec {
      string id = 1;
    string name = 2;
    string assigned_node = 3;
    int64 creation_time = 4;
}

message NodeSpec {
    string id = 1;
    string Address = 2;
}

代码中的 option 是 GRPC-gateway 使用的信息,用于生成与 GRPC 方法对应的 HTTP 代理请求。在 option 中,AddResource 对应的 HTTP 方法为 POST,URL 为 /crawler/resource。

DeleteResource 对应的 URL 仍然为 /crawler/resource,不过 HTTP 方法为 DELETE。 body: "*" 表示 GRPC-gateway 将接受 HTTP Body 中的信息,并会将其解析为对应的请求。

下一步,执行 protoc 命令,生成对应的 micro GRPC 文件和 HTTP 代理文件。

» protoc -I $GOPATH/src  -I .  --micro_out=. --go_out=.  --go-grpc_out=.  --grpc-gateway_out=logtostderr=true,allow_delete_body=true,register_func_suffix=Gw:. crawler.proto

这里的 allow_delete_body 表示对于 HTTP DELETE 方法,HTTP 代理服务也可以解析 Body 中的信息,并将其转换为请求参数。

接下来,我们需要为 Master 书写对应的方法,让 Master 实现 micro 生成的 CrawlerMasterHandler 接口。

type CrawlerMasterHandler interface {
  AddResource(context.Context, *ResourceSpec, *NodeSpec) error
  DeleteResource(context.Context, *ResourceSpec, *empty.Empty) error
}

实现 DeleteResource 和 AddResource 这两个方法比较简单。其中,DeleteResource 负责判断当前的任务名是否存在,如果存在则调用 etcd delete 方法删除资源 Key,并更新节点的负载。而 AddResource 方法可以调用我们之前就写好的 m.addResources 方法来添加资源,返回资源分配的节点信息。

func (m *Master) DeleteResource(ctx context.Context, spec *proto.ResourceSpec, empty *empty.Empty) error {
  r, ok := m.resources[spec.Name]

  if !ok {
    return errors.New("no such task")
  }

  if _, err := m.etcdCli.Delete(context.Background(), getResourcePath(spec.Name)); err != nil {
    return err
  }

  if r.AssignedNode != "" {
    nodeID, err := getNodeID(r.AssignedNode)
    if err != nil {
      return err
    }

    if ns, ok := m.workNodes[nodeID]; ok {
      ns.Payload -= 1
    }
  }
  return nil
}

func (m *Master) AddResource(ctx context.Context, req *proto.ResourceSpec, resp *proto.NodeSpec) error {
  nodeSpec, err := m.addResources(&ResourceSpec{Name: req.Name})
  if nodeSpec != nil {
    resp.Id = nodeSpec.Node.Id
    resp.Address = nodeSpec.Node.Address
  }
  return err
}

最后,我们还要调用 micro 生成的 crawler.RegisterCrawlerMasterHandler 函数,将 Master 注册为 GRPC 服务。 这之后就可以正常处理客户端的访问了。

func RunGRPCServer(MasterService *master.Master, logger *zap.Logger, reg registry.Registry, cfg ServerConfig) {
  ...
  if err := crawler.RegisterCrawlerMasterHandler(service.Server(), MasterService); err != nil {
    logger.Fatal("register handler failed", zap.Error(err))
  }

  if err := service.Run(); err != nil {
    logger.Fatal("grpc server stop", zap.Error(err))
  }
}

让我们来验证一下 Master 是否正在正常对外提供服务。首先,启动 Master。接着,通过 HTTP 访问 Master 提供的添加资源的接口。如下所示,添加资源“task-test-4”。

» go run main.go master --id=2 --http=:8081  --grpc=:9091
» curl -H "content-type: application/json" -d '{"id":"zjx","name": "task-test-4"}' http://localhost:8081/crawler/resource
{"id":"go.micro.server.worker-2", "Address":"192.168.0.107:9089"}

通过返回值可以看到,当前资源分配到了 worker-2,worker-2 的 IP 地址为"192.168.0.107:9089"。查看 etcd 中的资源,发现资源已经成功写入了 etcd,而且其中分配的 Worker 节点信息与 HTTP 接口返回的信息相同。

» docker exec etcd-gcr-v3.5.6 /bin/sh -c "/usr/local/bin/etcdctl get /resources/task-test-4" 
/resources/task-test-4
{"ID":"1604109125694787584","Name":"task-test-4","AssignedNode":"go.micro.server.worker-2|192.168.0.107:9089","CreationTime":1671284393144648000}

接着,我们尝试调用 Master 服务的删除资源接口,删除我们刚刚生成添加的资源。

» curl -X DELETE  -H "content-type: application/json" -d '{"name": "task-test-4"}' http://localhost:8081/crawler/resource

再次查看 etcd 中的资源"task-test-4",发现资源已经被删除了。Master API 提供的添加和删除功能验证成功。

本文章来源于极客时间《Go 进阶 · 分布式爬虫实战day》。