HeatBeat - final

김필모·2023년 12월 9일

1. HEAT 설계

기본동작 + option 2 (실패를 감지할 경우 지정된 프로세스에 시그널 보내기) 를 구현함.

heart beat의 기본 동작은 다음과 같습니다.

  1. 각 interval 마다 지정된 command or script을 실행하기
  2. exit code를 통해 성공/실패 여부를 처리하기
  3. 각 에러케이스에 해당하는 핸들링

옵션 2의 동작은 다음과 같습니다.

  1. 실패를 감지할 경우 지정된 스크립트 실행하기

  2. —fail에 지정한 스크립트 실행 중 다음 인터벌이 올 경우

    그 다음의 인터벌을 block 하여 수행하지 않고 있다가 스크립트 실행이 모두 끝나면 또 다음 인터벌의 도착 유무와 상관없이 바로 스크립트를 실행 시킴

  3. 실패 스크립트를 실행할 때 환경변수에 각종 값을 전달

2. HEAT 구현

2- 1 기본 구현

우선 파싱하는 부분을 살펴보겠습니다.

#[clap(group(ArgGroup::new("execution").required(true).args(&["script", "command"])))]
script 나 command 둘 중 하나라도 지정되어 있지 않으면 에러가 나도록 하는 부분입니다.
// cli.rs

use clap::{Parser, ArgGroup};

/// Simple heartbeat command-line app
#[derive(Parser)]
#[clap(author, version, about, long_about = None)]
#[clap(group(ArgGroup::new("execution").required(true).args(&["script", "command"])))]
pub struct Args {
    /// Interval in seconds between checks
    #[clap(short, long, value_parser)]
    pub interval: u64,

    /// Shell script to execute
    #[clap(short = 's', long, value_parser, group = "execution")]
    pub script: Option<String>,

    /// The command to execute
    #[clap(value_parser, trailing_var_arg = true, group = "execution")]
    pub command: Vec<String>,

    #[clap(long, value_parser)]
    pub pid: Option<u32>,
    
    #[clap(long, value_parser)]
    pub signal: Option<String>,

    #[clap(long = "fail", value_parser)]
    pub failure_script: Option<String>,

    /// Recovery script to execute after consecutive failures
    #[clap(long = "recovery", value_parser)]
    pub recovery_script: Option<String>,

    /// Threshold for number of consecutive failures to trigger recovery
    #[clap(long = "threshold", value_parser)]
    pub threshold: Option<u32>,

    /// Timeout in seconds before executing the recovery script
    #[clap(long = "recovery-timeout", value_parser)]
    pub recovery_timeout: Option<u64>,
    
}

Args 구조체를 만들어 커맨드 라인 인자를 어떻게 받을 지 정해주었습니다.

기본동작을 위한 구현으로 핵심적인 로직은 시그널 알람입니다.

alarm을 설정하여 정해진 간격마다 시그널을 발생시키고 만약 스크립트가 실행 중에 알람이 오면 블럭되었다가 스크립트 실행이 끝나면 시그널 핸들링 하도록 구현했습니다.

15초걸리는 스크립트 실행 중 3초 인터벌의 alarm 이 ****왔다고 가정해보겠습니다. 시그널은 블럭되었다가 15초 스크립트가 끝나면 block 이 해제되고 다시 스크립트가 바로 실행되게 됩니다. 즉, 제가 생각한 interval은 마냥 독립적으로만 흐르는 것이 아닌 프로그램 실행 중 이 인터벌이 있었을 경우 다음의 인터벌을 기다리지 않고 실행하도록 했습니다.

해당하는 코드는 다음과 같습니다.

static ALARM_RECEIVED: AtomicBool = AtomicBool::new(false);
extern fn signal_handler(_: nix::libc::c_int) { 
    ALARM_RECEIVED.store(true, Ordering::SeqCst);
}

fn main() {
			... some logic ...
			let sa = SigAction::new(
        SigHandler::Handler(signal_handler),
        SaFlags::SA_RESTART,
        SigSet::empty()
    );
    unsafe {
        sigaction(Signal::SIGALRM, &sa).expect("Failed to set signal handler");
    }
		alarm::set(interval as u32);
    let mut old_mask = SigSet::empty();
    let mut mask = SigSet::empty();
    mask.add(Signal::SIGALRM);

		if ALARM_RECEIVED.load(Ordering::SeqCst) {
            ALARM_RECEIVED.store(false, Ordering::SeqCst);
						... some logic ...
		// Unblock SIGALRM
    let _ = sigprocmask(SigmaskHow::SIG_UNBLOCK, Some(&mask), None);

}

위 코드의 핵심만을 설명하겠습니다.

  1. 시그널 핸들러 설정:
    • signal_handler 함수는 SIGALRM 시그널을 처리합니다. 이 함수는 ALARM_RECEIVED라는 AtomicBooltrue로 설정합니다.
    • SigAction을 사용하여 SIGALRM 시그널에 대한 핸들러로 signal_handler 함수를 등록합니다. SaFlags::SA_RESTART는 시그널 처리 후 시스템 호출이 중단되지 않고 재개되도록 합니다.
    • sigaction 함수를 호출하여 시그널 핸들러를 설정합니다. unsafe 블록 내에서 실행되며, 실패할 경우 프로그램은 오류 메시지와 함께 종료됩니다.
  2. 알람 설정:
    • alarm::set 함수를 호출하여 SIGALRM 시그널을 interval 초 후에 발생시키도록 설정합니다.
  3. 시그널 마스킹:
    • SigSet을 사용하여 SIGALRM 시그널을 마스킹합니다. 이는 시그널이 해당 시점에 프로그램을 중단하지 않도록 합니다.
  4. 시그널 수신 체크 및 로직 실행:
    • if ALARM_RECEIVED.load(Ordering::SeqCst) 조건을 확인하여 ALARM_RECEIVEDtrue로 설정되었는지 검사합니다. 이것은 SIGALRM 시그널이 수신되었음을 나타냅니다.
    • 만약 ALARM_RECEIVEDtrue라면, 주어진 로직을 실행하고 ALARM_RECEIVED를 다시 false로 설정합니다.
  5. 시그널 언블로킹:
    • sigprocmask를 호출하여 SIGALRM 시그널의 마스킹을 해제합니다.

그 다음으로 curl 같은 커맨드를 실행하는 부분에 대해 설명하겠습니다.

우선 코드는 이렇습니다.

pub fn execute_command(args: &super::cli::Args) -> (Output, u32) {
    let child = if let Some(script_path) = &args.script {
        if !is_executable(script_path) {
            eprintln!("Error: The script '{}' is not executable.", script_path);
            std::process::exit(1);
        }
        Command::new(script_path).spawn().expect("Failed to execute script")
    } else {
        Command::new("sh")
            .arg("-c")
            .arg(args.command.join(" "))
            .spawn()
            .expect("Failed to execute command")
    };

    let cpid = child.id();
    let output = child.wait_with_output().expect("Failed to wait on child");

    (output, cpid)
}

pub fn is_executable<P: AsRef<Path>>(path: P) -> bool {
    fs::metadata(path)
        .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
        .unwrap_or(false)
}
  1. execute_command 함수:
    • Args 구조체의 인스턴스를 매개변수로 받습니다. 앞서 [**cli.rs](http://cli.rs)** 의 구조체 맞습니다.
    • script_pathSome인 경우, 즉 사용자가 스크립트 경로를 제공한 경우, 해당 스크립트의 실행 가능 여부를 is_executable 함수를 사용하여 검사합니다. 스크립트가 실행 가능하지 않은 경우, 오류 메시지를 출력하고 프로그램을 종료합니다.
    • 스크립트가 실행 가능하면, Command::new(script_path)를 사용하여 스크립트를 실행합니다.
    • 사용자가 스크립트를 제공하지 않은 경우, sh -c와 함께 사용자가 제공한 커맨드를 실행합니다.
    • 실행된 프로세스의 PID(cpid)를 추출하고, wait_with_output 함수를 사용하여 프로세스의 실행 결과를 기다립니다. (옵션 1을 구현하기 위해)
    • 프로세스의 Output과 PID를 튜플로 반환합니다.(옵션 1)
  2. is_executable 함수:
    • 파일 경로를 매개변수로 받아 해당 파일의 메타데이터를 가져옵니다.
    • 파일의 권한을 검사하여 실행 권한(0o111)이 있는지 확인합니다.
    • 파일이 실행 가능한지 여부를 불리언 값으로 반환합니다.

웹서버의 코드는 이 과제와 상관이 없는 코드이므로 어떻게 동작하는 지 아주 간단하게 설명드리겠습니다.

웹서버의 구현 내용

  1. 시그널 핸들링 설정: SIGHUP 시그널을 처리하기 위한 핸들러가 설정됩니다. 이 핸들러는 SIGHUP_RECEIVED라는 글로벌 AtomicBool 변수의 상태를 변경하여 시그널의 수신을 표시합니다.
  2. 방문자 카운터: 웹 요청의 수를 추적하기 위해 AtomicUsize 타입의 counter가 사용됩니다. 이 카운터는 요청을 처리할 때마다 증가하며, 특정 수치(예: 5)를 넘으면 추가 요청을 거절합니다..

즉, 방문자 카운터를 통해 몇번의 curl 요청에는 성공적으로 응답하다가 그 이상이 되면 BadRequest 로 응답합니다. 또 recovery를 위한 sighup 핸들링을 통해 카운터를 초기화 시키는 부분이 있습니다. recovery가 되면 서버는 다시 성공적으로 응답합니다.

옵션 2 구현 내용

우선 실패 스크립트는 다음과 같습니다.

실패 시 사용자에게 정보를 주기 위해 로깅을 해야한다고 판단하여 이렇게 작성했습니다.

#!/bin/bash
echo "HEAT_FAIL_CODE set to $HEAT_FAIL_CODE"
echo "HEAT_FAIL_TIME set to $HEAT_FAIL_TIME"
echo "HEAT_FAIL_INTERVAL set to $HEAT_FAIL_INTERVAL"
echo "HEAT_FAIL_PID set to $HEAT_FAIL_PID"

다음으로 이 환경변수를 넘기는 부분의 코드입니다.

pub fn execute_failure_script(args: &super::cli::Args, envs: FailureScriptEnv) {
    if let Some(script_path) = args.failure_script.as_ref().map(|s| AsRef::<Path>::as_ref(s)) {
        if !is_executable(script_path) {
            eprintln!("Error: The script '{}' is not executable.", script_path.display());
            std::process::exit(1);
        }
        Command::new(script_path)
            .env("HEAT_FAIL_CODE", envs.exit_code.to_string())
            .env("HEAT_FAIL_TIME", envs.unix_time.to_string())
            .env("HEAT_FAIL_INTERVAL", envs.interval.to_string())
            .env("HEAT_FAIL_PID", envs.fail_pid.to_string())
            .spawn()
            .expect("Failed to execute failure.sh");
    } else {
        eprintln!("No failure script provided");
    }
}
  1. 스크립트 실행 여부 확인:
    • args.failure_script를 검사하여 스크립트 경로가 제공되었는지 확인합니다.
    • is_executable 함수를 호출하여 스크립트 파일이 실행 가능한지 확인합니다.
    • 실행 불가능한 경우, 오류 메시지를 출력하고 프로그램을 종료합니다.
  2. 환경 변수 설정과 스크립트 실행:
    • Command::new를 사용하여 스크립트를 실행합니다.
    • env 메서드를 통해 환경 변수 HEAT_FAIL_CODE, HEAT_FAIL_TIME, HEAT_FAIL_INTERVAL, HEAT_FAIL_PID를 설정합니다. 이 변수들은 envs 구조체에서 제공된 값으로 설정됩니다.
    • spawn을 호출하여 스크립트를 비동기적으로 실행합니다. 실행에 실패하면 예외가 발생합니다.
  3. 스크립트 미제공 시 처리:
    • 스크립트 경로가 제공되지 않은 경우, 오류 메시지를 출력합니다.

3. HEAT 결과

3-1 설치하기

우선 프로젝트를 설치하기 앞서서 이 프로젝트를 실행할 디렉토리를 만들겠습니다.

(unix 시스템을 기준으로 설명함)

mkdir temp

여기서 이 프로그램 실행을 위한 파일은 도커파일 3개입니다.

3개의 dockerfile을 그 디렉토리 밑에 넣습니다.

https://github.com/why-arong/heart-beat 참고

**(dockerfile.heat dockerfile.sws, dockerfile.heatx)**
그렇다면 다음과 같은 화면이 나올 것입니다.

ls

Screenshot 2023-12-09 at 8.20.24 PM.png

그후 다음의 명령어를 입력합니다.

docker build -f dockerfile.heat --no-cache -t heart-beat .
docker build -f dockerfile.sws --no-cache -t simple-web-server .

docker container 끼리 통신

컨테이너 끼리 통신하는 아주 쉬운 방법으로 docker network create 을 사용하겠습니다.

docker network create heat

위 명령어를 입력해 주세요. 이제 준비는 마쳤습니다.

3-2 빠르게 시작하기

먼저 simple-web-server 동작시키겠습니다.

이 웹서버는 curl 명령을 할 곳이기 때문에 실행이 안되어 있다면 fail이 될 것입니다.

docker run -d -p 8080:8080 --name sws-container --network heat simple-web-server

Screenshot 2023-12-09 at 3.09.42 PM.png

그 후 이번엔 heart-beat를 실행시켜 보겠습니다.

가장 먼저 기본 동작을 확인해보도록 하겠습니다.

4초 주기로 curl 명령어를 실행하도록 해보겠습니다. (30초는 너무 길어 4초로 했습니다.)

그러기 위해서는 다음과 같이 명령어를 작성하면 됩니다.

docker run -it --init --name heat-container --network heat heart-beat -i 4 \
"curl -sf http://sws-container:8080/"

4초의 interval을 주었고 각 curl 요청마다 number가 1씩 증가하다 5가 되면 웹서버는 실패했음을 response하도록 구현했습니다.

(Hello! You are visitor number ~~ 부분은 확인 시켜드리기 위해 추가했습니다. curl 명령어를 직접 입력할 경우만 출력됩니다.)

따라서 이러한 출력을 볼 수 있습니다.

Screenshot 2023-12-09 at 3.53.59 PM.png

(프로그램 시작하자마자 알람 시그널이 세팅되어 4초를 기다린 후 처음 curl 명령을 보내게 될 것입니다.)

그러면 이번에는 스크립트를 이용해서 같은 동작을 확인해보겠습니다.


이 실행을 하기 전에 웹서버를 초기화하기 위해서 다음의 명령어를 먼저 실행합니다.

docker kill --signal=SIGHUP sws-container

(recovery script를 대신하는 명령어)


기존의 dockerfile에서는 check 스크립트의 실행권한이 없어 다음과 같은 에러가 발생할 것입니다.

스크립트가 executable하지 않다면 에러처리

check가 executable 하지 않으면 이렇게 에러 처리를 하도록 구현.

Screenshot 2023-12-09 at 5.07.35 PM.png

그래서 모든 스크립트에 실행권한을 준 도커파일인 dockerfile.heatx 를 빌드합니다.

docker build -f dockerfile.heatx --no-cache -t heart-beat .

그 후 이 명령어를 입력합니다.

docker run -it --init --name heat-container --network heat heart-beat -i 4 \
-s /usr/local/bin/check

Screenshot 2023-12-09 at 6.29.37 PM.png

위와 동일한 작동을 합니다.

그 다음 추가적인 에러처리를 살펴보겠습니다.

검사 스크립트 혹은 명령어가 모두 지정되지 않을 경우 에러 처리

Screenshot 2023-12-09 at 5.24.16 PM.png

스크립트나 커맨드가 없어 에러가 나는 모습입니다.

3-3 사용방법

옵션 2번의 케이스의 경우 사용 방법에 대해 설명 드리겠습니다.

  1. 사용자는 기본동작을 했을 때, 실패를 경험해 어떤 문제가 있는 지 알고자 합니다. 따라서 로깅된 것을 찾아보려 합니다.
  2. 따라서 기본 동작에 추가적으로 —fail 을 붙이고 실패 스크립트를 지정합니다.

옵션 2: 실패를 감지할 경우 지정된 프로세스에 시그널 보내기

다음과 같은 명령을 통해 확인 할 수 있습니다.

편의를 위해 컨테이너를 삭제 했다가 다시 만들겠습니다.

docker rm heat-container
docker run -it --init --name heat-container --network heat heart-beat -i 4 \
-s /usr/local/bin/check --fail /usr/local/bin/failure.sh

Screenshot 2023-12-09 at 7.34.46 PM.png

위 출력은 웹서버를 의도적으로 꺼서 실패가 뜨게 한 뒤에 실행했습니다.

(docker kill --signal=SIGHUP sws-container) (옵션 3을 구현할 경우 recovery에 해당)

실패가 감지될 경우 할 행동은 failure.sh의 환경변수를 그저 출력하도록 했습니다. 웹서버를 고치는 것은 recovery 스크립트가 해야 한다고 판단했습니다.

4. 적용 사례

제가 구현한 사항까지만으로 어떻게 추가적으로 적용할 수 있는 지 설명하겠습니다.

(가독성을 위해서 docker 커맨드를 생략하고 heartbeat라고 작성합니다.)

  1. 시스템 자원 모니터링:
    • heartbeat 앱을 사용하여 시스템의 CPU 사용률, 메모리 사용량, 디스크 사용량 등을 주기적으로 체크할 수 있습니다.
    • 예: heartbeat -i 60 -s check_system_resources.sh (60초마다 check_system_resources.sh 스크립트를 실행하여 시스템 자원을 체크)
  2. 웹 서버 상태 확인:
    • 웹 서버의 응답 상태를 주기적으로 체크하여 서버가 정상적으로 작동하고 있는지 확인할 수 있습니다.
    • 예: heartbeat -i 300 -s check_web_server_status.sh (5분마다 웹 서버 상태를 체크하는 스크립트 실행)
  3. 데이터베이스 백업:
    • 데이터베이스의 정기적인 백업을 위해 heartbeat을 사용할 수 있습니다.
    • 예: heartbeat -i 86400 -s backup_database.sh (매일 데이터베이스를 백업하는 스크립트 실행)
  4. 로그 파일 모니터링:
    • 시스템이나 애플리케이션의 로그 파일을 주기적으로 확인하여 문제를 조기에 발견할 수 있습니다.
    • 예: heartbeat -i 120 -s monitor_logs.sh (2분마다 로그 파일을 모니터링하는 스크립트 실행)
  5. API 상태 모니터링:
    • 외부 API 서비스의 가용성과 응답 시간을 주기적으로 체크하여 서비스 중단을 감지할 수 있습니다.
    • 예: heartbeat -i 120 -s check_api_status.sh (2분마다 외부 API의 상태를 체크하는 스크립트 실행)
  6. 네트워크 연결성 체크:
    • 네트워크 연결 상태를 주기적으로 체크하여 인터넷 또는 특정 서버와의 연결 문제를 감지할 수 있습니다.
    • 예: heartbeat -i 300 -s check_network_connection.sh (5분마다 네트워크 연결 상태를 체크하는 스크립트 실행)

0개의 댓글