LY DB tech blog
Published on

Watchdog thread of MongoDB

Authors
  • avatar
    Name
    박붕어
    Twitter

What is watchdog_prob_{pid}.txt file? 

LINE에서는 다양한 서비스에서 MongoDB를 이용하고 있습니다. MongoDB의 운영을 간편화하기 위해 자동화 업무를 진행하는 도중 다음과 같은 에러가 발생했습니다. 

error message
du: cannot access 'watchdog_prob_7882.txt': No such file or directory

이 에러는 du 명령 수행 시점에는 존재했던 파일이 더 이상 존재하지 않아 발생하는 에러인데요. 원인은 watchdog_prob_7882.txt 파일이 생성 이후 삭제되었기 때문입니다. 에러는 --exclude 옵션으로 해결했지만, 여전히 제 머리속에는 watchdog_prob_7882.txt 파일에 대한 궁금증이 남아있었습니다. MongoDB에서는 해당 파일을 빈번하게 생성하고, 삭제하는 것으로 보여졌는데요. watchdog_prob_7882.txt 파일이 어떤 파일이길래 이런 동작이 반복되는 걸까요?? 궁금증을 해결하기 위해 MongoDB의 watchdog이라는 키워드에 대해 더 탐구해보게 되었습니다. 

What is Watchdog? 

Watchdog? 

        

watchdog이란 하드웨어, 임베디드 시스템, 소프트웨어, 네트워크 등에서 사용되는 시스템입니다. watchdog은 시스템의 상태를 모니터링하고 이상이 발생하면 알림을 보내거나 자동으로 문제를 해결하는 역할을 합니다. 예를 들어, 감시중인 시스템에 오류가 생기는 경우 watchdog은 타임아웃 신호를 생성하고 시스템을 안전한 상태로 유지하거나 원상복구하는 동작을 수행합니다. 

Why use watchdog? 

watchdog은 인간이 쉽게 접근할 수 없거나, 오류에 제때 반응하기 어려운 시스템에서 사용됩니다. 주로 임베디드 시스템에서 사용되는데, 이는 시스템에 오류가 발생하는 경우 사람이 해결하거나 대응하는 것에 의존할 수 없기 때문입니다. 예를 들어, 우주 탐사기와 같은 시스템의 경우 운영자가 물리적으로 접근할 수 없기 때문에 이런 시스템들은 자립적으로 오류를 해결할 수 있어야 합니다. 이러한 요구사항을 바탕으로 시스템에 문제가 생겼을 때 스스로 문제를 해결하기 위해 watchdog을 사용합니다. 보통은 껐다 켜주는 것 같습니다. 

     컴퓨터 고장 정보 - 나무위키

Why MongoDB use watchdog? 

그럼 MongoDB에는 watchdog이 왜 있는걸까요? 

Purpose of watchdog 

다음과 같은 Replica Set이 있다고 가정합시다. 

만약, Primary 멤버의 Storage Layer에서 문제가 생기면, 네트워크는 정상이므로 다른 Secondary 멤버들의 health check에는 계속해서 응답할 수가 있습니다. 하지만, I/O가 필요한 사용자의 요청은 처리를 할 수 없기 때문에, 대기 상태로 계속해서 쌓이게 됩니다. 그리고 결국에는 새로운 connection을 생성할 수 없게 되고 장애가 발생하게 됩니다. 데이터가 Disk에 저장되지 못하고 있으니 신규 쿼리를 통해 들어온 데이터들은 MongoDB Process가 재시작되는 경우 데이터가 유실될 수 있습니다. 

What exactly watchdog do? 

이러한 경우를 방지하기 위해 Storage watchdog을 통해 Storage Layer에 대한 감시를 진행합니다. 
watchdog thread는 다음 디렉터리들을 감시합니다. 하단에 Source Code에서 다시 등장하니 기억하시면 좋습니다.

  • dbpath : 실제로 데이터가 저장되는 볼륨, 디렉터리
  • journalpath : journal(WAL)을 쓰는 볼륨, 디렉터리 
  • logpath: log를 쓰는 볼륨, 디렉터리 
  • auditpath : auditlog를 쓰는 볼륨, 디렉터리 

다음 디렉터리들이나 볼륨에 데이터를 쓰거나, 읽는 것이 제대로 동작하지 않는 경우 MongoDB Process는 다운됩니다. 

이를 통해서 Failover가 발생하고 사용자는 문제없이 다시 MongoDB를 사용할 수 있습니다.

기본적으로 MongoDB에서 watchdog thread는 비활성화되어있으며, watchdogPeriodSeconds 설정을 통해 모니터링 주기를 설정할 수 있습니다. 

Pros and Cons 

watchdog은 장애가 발생하거나 중단된 스토리지를 사용하는 노드를 Replica Set에서 일시적으로 제거해주기 때문에 스토리지 장애에 대한 조치 속도가 빨라지고, Replica Set에 대한 쓰기가 무기한으로 중단되는 상황을 방지할 수 있습니다. 예를 들어, Primary가 watchdog에 의해 다운되는 경우, Secondary 노드로의 Failover를 통해서 어플리케이션은 읽기와 쓰기를 지속할 수 있습니다.

하지만 스토리지 계층이 중단되면 일관된 레코드를 디스크에 완전히 쓰지 못한 채 mongod 프로세스가 종료될 수 있습니다. 대부분은 journal을 사용하여 일관된 지점으로 복구가 가능하고, 이후 복제 프로세스를 통해 멤버를 현재 상태로 업데이트할 수 있습니다. 그러나 드물게 스토리지에 장애가 발생하면 여러 물리적 블록에 걸쳐 있는 쓰기가 OS에서 요청한대로 완료되지 않을 수 있습니다. 이로 인해 데이터 파일이 손상된 상태로 남아 mongod가 다시 시작되지 않을 수 있습니다. 이런 경우 데이터파일은 복구할 수 없고 Replica Set의 멤버로 추가하기 위해서는 InitialSync가 필요합니다. 

How watchdog implemented? 

그럼 실제로 watchdog은 어떻게 구현되어있을까요? 실제로 MongoDB의 코드를 보면서 살펴봅시다. 코드는 재미없고 지루할 수도 있으니 가볍게 읽어주세요. 

src/mongo/db/mognod_main.cpp
ExitCode _initAndListen(ServiceContext* serviceContext, int listenPort) {
    Client::initThread("initandlisten", serviceContext->getService(ClusterRole::ShardServer));
    ....
    // Start watchdog!
    startWatchdog(serviceContext);
    ....
    return waitForShutdown();
}

https://github.com/mongodb/mongo/blob/f73853799415472d54e11248b6167edbd03e92da/src/mongo/db/mongod_main.cpp#L658

먼저 mongod_main.cpp를 보시면 startWatchdog 함수를 호출하고 있는 코드를 보실 수 있습니다. 

src/mongo/watchdog/watchdog_mongod.cpp
void startWatchdog(ServiceContext* service) {
    Seconds period{gWatchdogPeriodSeconds.load()};
    if (period < Seconds::zero()) {
        // Skip starting the watchdog if the user has not asked for it.
        watchdogEnabled = false;
        return;
    }
 
    watchdogEnabled = true;
    std::vector<std::unique_ptr<WatchdogCheck>> checks;
 
    // Check for the data directory.
    auto dataCheck =
        std::make_unique<DirectoryCheck>(boost::filesystem::path(storageGlobalParams.dbpath));
    checks.push_back(std::move(dataCheck));
 
    // Check for the journal.
    auto journalDirectory = boost::filesystem::path(storageGlobalParams.dbpath);
    journalDirectory /= "journal";
    if (boost::filesystem::exists(journalDirectory)) {
        auto journalCheck = std::make_unique<DirectoryCheck>(journalDirectory);
 
        checks.push_back(std::move(journalCheck));
    }
    ....
 
    // Check for the log directory.
    if (!serverGlobalParams.logpath.empty()) {
        boost::filesystem::path logFile(serverGlobalParams.logpath);
        auto logPath = logFile.parent_path();
 
        auto logCheck = std::make_unique<DirectoryCheck>(logPath);
        checks.push_back(std::move(logCheck));
    }
 
    // Check for the audit directories.
    for (auto&& path : getWatchdogPaths()) {
        auto auditCheck = std::make_unique<DirectoryCheck>(path);
        checks.push_back(std::move(auditCheck));
    }
 
    WatchdogMonitorInterface::set(
        service,
        std::make_unique<WatchdogMonitor>(
            std::move(checks), watchdogCheckPeriod, period, watchdogTerminate));
 
    // Install the new WatchdogMonitor
    auto staticMonitor = WatchdogMonitorInterface::get(service);
    // start!
    staticMonitor->start();
}
 
}

https://github.com/mongodb/mongo/blob/f1952c5ac393868a4eeffb0301bcf5d78c74059d/src/mongo/watchdog/watchdog_mongod.cpp#L142C1-L206C2

startWatchdog 함수에서는 DirectoryCheck 객체를 생성하고 이를 checks라고 하는 Vector에 넣어준 뒤 startMonitor->start()를 호출합니다. 이를 통해 확인하고 싶은 디렉터리들에 대한 정보를 전달합니다. 확인하려고 하는 데이터 디렉터리들은 앞서 말씀드린것 처럼 data 디렉터리, journal 디렉터리, log 디렉터리, audit 디렉터리 입니다. 

src/mongo/watchdog/watchdog.cpp
void WatchdogPeriodicThread::start() {
    {
        stdx::lock_guard<Latch> lock(_mutex);
 
        invariant(_state == State::kNotStarted);
        _state = State::kStarted;
 
        // Start the thread.
        // do Loop!
        _thread = stdx::thread([this] { this->doLoop(); });
    }
}

https://github.com/mongodb/mongo/blob/f73853799415472d54e11248b6167edbd03e92da/src/mongo/watchdog/watchdog.cpp#L83C1-L93C2

WatchdogPeriodicThread 객체의 start 함수에서는 쓰레드를 생성하고 doLoop() 함수를 호출하고 있습니다. 

src/mongo/watchdog/watchdog.cpp
void WatchdogPeriodicThread::doLoop() {
    ....
 
    // infinite loop
    while (true) {
        ....
        // timer start
        Date_t startTime = preciseClockSource->now();
 
        {
            stdx::unique_lock<Latch> lock(_mutex);
            MONGO_IDLE_THREAD_BLOCK;
 
            // wait while period
            while ((startTime + _period) > preciseClockSource->now()) {
                auto oldPeriod = _period;
                try {
                    opCtx->waitForConditionOrInterruptUntil(
                        _condvar, lock, startTime + _period, [&] {
                            return oldPeriod != _period || _state == State::kShutdownRequested;
                        });
                }
        ....
 
        // Run watchdog!
        run(opCtx.get());
    }
}

https://github.com/mongodb/mongo/blob/f73853799415472d54e11248b6167edbd03e92da/src/mongo/watchdog/watchdog.cpp#L147C1-L219C2

그 이후 doLoop 함수에서는 특정 기간(mongod.conf 파일에서 정의한 watchdogPeriodSeconds )동안 while 문에서 기다린 뒤 run()을 호출합니다. 

src/mongo/watchdog/watchdog.cpp
public:
    WatchdogCheckThread(std::vector<std::unique_ptr<WatchdogCheck>> checks, Milliseconds period);
...
void WatchdogCheckThread::run(OperationContext* opCtx) {
    for (auto& check : _checks) {
        ...
        if (_shouldRunChecks.load()) {
            // Run checkers before inserted.
            check->run(opCtx);
        ...
        }
    }
}

https://github.com/mongodb/mongo/blob/a819bca2cc5b3937ebb74a6996259c7f55de911f/src/mongo/watchdog/watchdog.cpp#L253C1-L278C2

그리고는 run() 함수는 checks 벡터를 반복하면서 앞서 생성한 DirectoryCheck 객체의 run() 함수를 호출합니다. 

src/mongo/watchdog/watchdog.cpp
constexpr StringData DirectoryCheck::kProbeFileName;
constexpr StringData DirectoryCheck::kProbeFileNameExt;
 
void DirectoryCheck::run(OperationContext* opCtx) {
    // Ensure we have unique file names if multiple processes share the same logging directory
    // This is why file named "watchdog_prob_{pid}.txt" is occurred.
    boost::filesystem::path file = _directory;
    file /= kProbeFileName.toString();
    file += ProcessId::getCurrent().toString();
    file += kProbeFileNameExt.toString();
 
    // Check files.
    checkFile(opCtx, file);
    ....
}

https://github.com/mongodb/mongo/blob/a819bca2cc5b3937ebb74a6996259c7f55de911f/src/mongo/watchdog/watchdog.cpp#L695C1-L716C2

DirectoryCheck 객체의 run() 함수에서는 checkFile() 함수에 file 이름을 전달해주고 실행합니다. 

src/mongo/watchdog/watchdog.cpp
/**
 * Check a directory is ok
 * 1. Open up a direct_io to a new file
 * 2. Write to the file
 * 3. Read from the file
 * 4. Close file
 */
void checkFile(OperationContext* opCtx, const boost::filesystem::path& file) {
    Date_t now = opCtx->getServiceContext()->getPreciseClockSource()->now();
    std::string nowStr = now.toString();
 
    // Create file descriptor.
    int fd = open(file.generic_string().c_str(), O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
    ....
 
    struct stat st;
    if (fstat(fd, &st) < 0) {
        ....
        st.st_blksize = 4096;
    }
 
    unsigned long alignment = st.st_blksize;
    unsigned long alignedSize = boost::alignment::align_up(nowStr.size(), alignment);
    char* alignedBuf = static_cast<char*>(boost::alignment::aligned_alloc(alignment, alignedSize));
    ScopeGuard cleanupBuf([alignedBuf]() { boost::alignment::aligned_free(alignedBuf); });
 
    memset(alignedBuf, 0, alignedSize);
    memcpy(alignedBuf, nowStr.c_str(), nowStr.size());
 
    ssize_t bytesWrittenInWrite = -1;
    while (bytesWrittenInWrite == -1) {
        // Write now.toString().
        bytesWrittenInWrite = write(fd, alignedBuf, alignedSize);
        ....
    }
    ....
 
    // Write to disk through fsync.
    if (fsync(fd)) {
        ....
    }
 
    ssize_t bytesReadInRead = -1;
    while (bytesReadInRead == -1) {
        // Read from disk.
        bytesReadInRead = pread(fd, alignedBuf, alignedSize, 0);
        if (bytesReadInRead == -1) {
            auto ec = lastSystemError();
            if (ec != systemError(EINTR)) {
                ....
                fassertNoTrace(4083, !ec);
            }
        }
    }
 
    // Close file descriptor.
    if (close(fd)) {
        ...
        fassertNoTrace(4084, !ec);
    }
}

https://github.com/mongodb/mongo/blob/a819bca2cc5b3937ebb74a6996259c7f55de911f/src/mongo/watchdog/watchdog.cpp#L453C1-L559C2

마지막으로 checkFile() 함수에서는 4가지 일을 수행합니다. 

  1. 새로운 파일의 File Descriptor를 생성합니다.
  2. 해당 파일에 쓰기를 수행합니다. 
  3. 해당 파일에 읽기를 수행합니다. 
  4. 파일을 닫습니다. 

이 과정에서 에러가 발생하는 경우 assert로 인해 mongod가 down 되게 됩니다. 

결론적으로 MongoDB의 watchdog thread는

  1. watchdogPeriodSeconds 파라미터에 설정된 주기마다
  2. dbpath, journalpath, logpath, auditpath 에 설정된 디렉터리들을 대상으로 
  3. File Descriptor 생성, 파일에 쓰기, 파일에 읽기, 파일 닫기를 수행하면서 
  4. 스토리지에 대해 읽기와 쓰기가 가능한지를 모니터링하고 있습니다. 

이를 통해서 스토리지에 장애가 발생한 노드들을 일시적으로 제거하여 사용자의 쿼리가 수행되지 않는 장애 상황을 최소화할 수 있지만, 스토리지에 데이터를 쓰는데 문제가 발생하여 데이터 파일이 깨지는 경우에는 복제 프로세스에 의한 복구가 불가능하고, InitialSync를 통해서만 복구가 가능합니다. 

Conclusion

지금까지 MongoDB의 watchdog thread에 대해 알아보았습니다.
저는 MongoDB의 watchdog thread를 공부하고 이해하는 과정에 다음과 같은 교훈을 얻을 수 있었습니다. 

  1. MongoDB의 watchdog thread에 대해 이해할 수 있었습니다.
  2. MongoDB Source Code를 찾아보는 과정에서 MongoDB Code의 entry point를 알 수 있었고, MongoDB의 소스코드를 재밌게 볼 수 있었습니다.
  3. 이러한 thread가 다른 DBMS에도 있을지 궁금하여 찾아보았고, 다른 DBMS에도 비슷한 watchdog thread가 구현되어있는 것 같습니다. 만약 다른 DBMS를 운영중이시라면 운영 중이신 DBMS에서 watchdog을 찾아보시는 것도 재밌을 것 같습니다! 
  4. MongoDB에 대해서 새로운 지식을 습득하는 과정이 즐거웠습니다. 

지금까지 긴 글을 읽어주셔서 감사합니다.

Reference