高可用性计算:如何在不冒误报风险的情况下处理非返回系统调用?

High availability computing: How to deal with a non-returning system call, without risking false positives?

我在 Linux 计算机上有一个 运行 进程作为高可用性系统的一部分。该进程有一个主线程,它接收来自网络上其他计算机的请求并响应它们。还有一个心跳线程定期发送多播心跳数据包,让网络上的其他进程知道这个进程仍然存在并且可用——如果他们有一段时间没有收到来自它的任何心跳数据包,其中之一他们会假设这个进程已经死了,并会接管它的职责,这样整个系统就可以继续工作。

一切正常,但前几天整个系统出现故障,当我调查原因时发现以下内容:

  1. 由于(显然)盒子 Linux 内核中的错误,此进程的主线程进行的系统调用引发了内核 "oops"。
  2. 由于内核"oops",系统调用从未返回,导致进程的主线程永久挂起。
  3. 心跳线程 OTOH 继续正常运行,这意味着网络上的其他节点从未意识到该节点已发生故障,其中 none 介入接管了它的职责。 .因此请求的任务没有执行,系统的运行实际上停止了。

我的问题是,是否有可以处理此类故障的优雅解决方案? (显然,要做的一件事是修复 Linux 内核,使其不 "oops",但考虑到 Linux 内核的复杂性,如果我的软件能够处理未来的其他问题,那就太好了内核错误也更优雅)。

我不喜欢的一个解决方案是将心跳生成器放入主线程,而不是 运行 它作为一个单独的线程,或者以其他方式将它绑定到主线程,以便如果主线程无限期挂起,则不会发送心跳。我不喜欢这个解决方案的原因是因为主线程不是实时线程,所以这样做会引入偶尔误报的可能性,即缓慢完成的操作被误认为是节点故障。如果可以的话,我想避免误报。

理想情况下,有一些方法可以确保失败的系统调用 returns 错误代码,或者如果那不可能,则使我的进程崩溃;其中任何一个都会停止心跳数据包的生成并允许进行故障转移。有什么办法可以做到这一点,或者不可靠的内核是否也会使我的用户进程也变得不可靠?

一种可能的方法是让另一组心跳消息从主线程到心跳线程。如果它在一定时间内停止接收消息,它也会停止发送消息。 (并且可以尝试其他恢复,例如重新启动进程。)

为了解决主线程实际上只是处于长时间睡眠的问题,有一个(正确同步的)标志,心跳线程在确定主线程一定已经失败时设置 - 并且主线程应该在适当的时候检查这个标志(例如在潜在的等待之后)以确保它没有被报告为死亡。如果是,它将停止 运行,因为它的工作已经被另一个节点占用。

主线程也可以在其他时间向心跳线程发送 I-am-alive 事件,而不是在循环中发送一次——例如,如果它要进入一个 long-运行 操作。没有这个,就无法区分失败的主线程和休眠的主线程。

我想你需要一个共享的 activity 标记。

让主线程(或在更一般的应用程序中,所有工作线程)使用当前时间(或时钟节拍,例如通过计算 "current" 纳秒来更新共享的 activity 标记clock_gettime(CLOCK_MONOTONIC, ...)),并让心跳线程定期检查此 activity 标记最后一次更新的时间,如果在一个周期内没有任何 activity 更新,则取消自身(从而停止心跳广播)合理的时间。

如果工作负载非常零散,可以使用状态标志轻松扩展此方案。主工作线程在开始一个工作单元时设置标志并更新 activity 标记,并在工作完成时清除标志。如果没有工作正在进行,则发送心跳而不检查 activity 标记。如果正在完成工作,则如果自 activity 标记更新以来的时间超过工作单元允许的最大处理时间,则心跳停止。 (在这种情况下,多个工作线程每个都需要自己的 activity 标记和标志,心跳线程可以设计为在任何一个工作线程卡住时停止,或者仅在所有工作线程卡住时停止,具体取决于它们的目的以及对整个系统的重要性)。

(activity 标记值(和工作标志)当然必须由必须在读取或写入值之前获取的互斥体保护。)

也许心跳线程也可以导致整个进程自杀(例如kill(getpid(), SIGQUIT)),以便可以通过在包装脚本中循环调用它来重新启动,特别是如果进程重新启动清除内核中首先会导致问题的条件。

我的第二个建议是使用ptrace 来查找当前指令指针。您可以有一个父线程跟踪您的进程并每秒中断它以检查当前的 RIP 值。这有点复杂,所以我写了一个演示程序:(仅x86_64,但应该可以通过更改寄存器名称来解决。)

#define _GNU_SOURCE
#include <unistd.h>
#include <sched.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <linux/ptrace.h>
#include <sys/user.h>
#include <time.h>

// this number is arbitrary - find a better one.
#define STACK_SIZE (1024 * 1024)

int main_thread(void *ptr) {
    // "main" thread is now running under the monitor
    printf("Hello from main!");
    while (1) {
        int c = getchar();
        if (c == EOF) { break; }
        nanosleep(&(struct timespec) {0, 200 * 1000 * 1000}, NULL);
        putchar(c);
    }
    return 0;
}

int main(int argc, char *argv[]) {
    void *vstack = malloc(STACK_SIZE);
    pid_t v;
    if (clone(main_thread, vstack + STACK_SIZE, CLONE_PARENT_SETTID | CLONE_FILES | CLONE_FS | CLONE_IO, NULL, &v) == -1) { // you'll want to check these flags
        perror("failed to spawn child task");
        return 3;
    }
    printf("Target: %d; %d\n", v, getpid());
    long ptv = ptrace(PTRACE_SEIZE, v, NULL, NULL);
    if (ptv == -1) {
        perror("failed monitor sieze");
        exit(1);
    }
    struct user_regs_struct regs;
    fprintf(stderr, "beginning monitor...\n");
    while (1) {
        sleep(1);
        long ptv = ptrace(PTRACE_INTERRUPT, v, NULL, NULL);
        if (ptv == -1) {
            perror("failed to interrupt main thread");
            break;
        }
        int status;
        if (waitpid(v, &status, __WCLONE) == -1) {
            perror("target wait failed");
            break;
        }
        if (!WIFSTOPPED(status)) { // this section is messy. do it better.
            fputs("target wait went wrong", stderr);
            break;
        }
        if ((status >> 8) != (SIGTRAP | PTRACE_EVENT_STOP << 8)) {
            fputs("target wait went wrong (2)", stderr);
            break;
        }
        ptv = ptrace(PTRACE_GETREGS, v, NULL, &regs);
        if (ptv == -1) {
            perror("failed to peek at registers of thread");
            break;
        }
        fprintf(stderr, "%d -> RIP %x RSP %x\n", time(NULL), regs.rip, regs.rsp);
        ptv = ptrace(PTRACE_CONT, v, NULL, NULL);
        if (ptv == -1) {
            perror("failed to resume main thread");
            break;
        }
    }
    return 2;
}

请注意,这不是生产质量代码。你需要做很多修复工作。

据此,你应该可以判断出程序计数器是否在递增,并可以结合其他信息(例如/proc/PID/status)来判断它是否处于忙状态系统调用。您还可以扩展 ptrace 的使用,以检查正在使用的系统调用,这样您就可以检查等待的系统调用是否合理。

这是一个 hacky 解决方案,但我认为您不会找到解决此问题的非 hacky 解决方案。尽管有 hackiness,但我不认为(这是未经测试的)它会特别慢;我的实现每秒暂停一次被监视的线程,持续很短的时间——我猜这将在 100 微秒的范围内。理论上,这大约是 0.01% 的效率损失。