기본동작 + option 2 (실패를 감지할 경우 지정된 프로세스에 시그널 보내기) 를 구현함.
heart beat의 기본 동작은 다음과 같습니다.
옵션 2의 동작은 다음과 같습니다.
실패를 감지할 경우 지정된 스크립트 실행하기
—fail에 지정한 스크립트 실행 중 다음 인터벌이 올 경우
그 다음의 인터벌을 block 하여 수행하지 않고 있다가 스크립트 실행이 모두 끝나면 또 다음 인터벌의 도착 유무와 상관없이 바로 스크립트를 실행 시킴
실패 스크립트를 실행할 때 환경변수에 각종 값을 전달
우선 파싱하는 부분을 살펴보겠습니다.
#[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);
}
위 코드의 핵심만을 설명하겠습니다.
signal_handler 함수는 SIGALRM 시그널을 처리합니다. 이 함수는 ALARM_RECEIVED라는 AtomicBool을 true로 설정합니다.SigAction을 사용하여 SIGALRM 시그널에 대한 핸들러로 signal_handler 함수를 등록합니다. SaFlags::SA_RESTART는 시그널 처리 후 시스템 호출이 중단되지 않고 재개되도록 합니다.sigaction 함수를 호출하여 시그널 핸들러를 설정합니다. unsafe 블록 내에서 실행되며, 실패할 경우 프로그램은 오류 메시지와 함께 종료됩니다.alarm::set 함수를 호출하여 SIGALRM 시그널을 interval 초 후에 발생시키도록 설정합니다.SigSet을 사용하여 SIGALRM 시그널을 마스킹합니다. 이는 시그널이 해당 시점에 프로그램을 중단하지 않도록 합니다.if ALARM_RECEIVED.load(Ordering::SeqCst) 조건을 확인하여 ALARM_RECEIVED가 true로 설정되었는지 검사합니다. 이것은 SIGALRM 시그널이 수신되었음을 나타냅니다.ALARM_RECEIVED가 true라면, 주어진 로직을 실행하고 ALARM_RECEIVED를 다시 false로 설정합니다.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)
}
execute_command 함수:Args 구조체의 인스턴스를 매개변수로 받습니다. 앞서 [**cli.rs](http://cli.rs)** 의 구조체 맞습니다.script_path가 Some인 경우, 즉 사용자가 스크립트 경로를 제공한 경우, 해당 스크립트의 실행 가능 여부를 is_executable 함수를 사용하여 검사합니다. 스크립트가 실행 가능하지 않은 경우, 오류 메시지를 출력하고 프로그램을 종료합니다.Command::new(script_path)를 사용하여 스크립트를 실행합니다.sh -c와 함께 사용자가 제공한 커맨드를 실행합니다.cpid)를 추출하고, wait_with_output 함수를 사용하여 프로세스의 실행 결과를 기다립니다. (옵션 1을 구현하기 위해)Output과 PID를 튜플로 반환합니다.(옵션 1)is_executable 함수:0o111)이 있는지 확인합니다.웹서버의 코드는 이 과제와 상관이 없는 코드이므로 어떻게 동작하는 지 아주 간단하게 설명드리겠습니다.
SIGHUP 시그널을 처리하기 위한 핸들러가 설정됩니다. 이 핸들러는 SIGHUP_RECEIVED라는 글로벌 AtomicBool 변수의 상태를 변경하여 시그널의 수신을 표시합니다.AtomicUsize 타입의 counter가 사용됩니다. 이 카운터는 요청을 처리할 때마다 증가하며, 특정 수치(예: 5)를 넘으면 추가 요청을 거절합니다..즉, 방문자 카운터를 통해 몇번의 curl 요청에는 성공적으로 응답하다가 그 이상이 되면 BadRequest 로 응답합니다. 또 recovery를 위한 sighup 핸들링을 통해 카운터를 초기화 시키는 부분이 있습니다. recovery가 되면 서버는 다시 성공적으로 응답합니다.
우선 실패 스크립트는 다음과 같습니다.
실패 시 사용자에게 정보를 주기 위해 로깅을 해야한다고 판단하여 이렇게 작성했습니다.
#!/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");
}
}
args.failure_script를 검사하여 스크립트 경로가 제공되었는지 확인합니다.is_executable 함수를 호출하여 스크립트 파일이 실행 가능한지 확인합니다.Command::new를 사용하여 스크립트를 실행합니다.env 메서드를 통해 환경 변수 HEAT_FAIL_CODE, HEAT_FAIL_TIME, HEAT_FAIL_INTERVAL, HEAT_FAIL_PID를 설정합니다. 이 변수들은 envs 구조체에서 제공된 값으로 설정됩니다.spawn을 호출하여 스크립트를 비동기적으로 실행합니다. 실행에 실패하면 예외가 발생합니다.우선 프로젝트를 설치하기 앞서서 이 프로젝트를 실행할 디렉토리를 만들겠습니다.
(unix 시스템을 기준으로 설명함)
mkdir temp
여기서 이 프로그램 실행을 위한 파일은 도커파일 3개입니다.
https://github.com/why-arong/heart-beat 참고
**(dockerfile.heat dockerfile.sws, dockerfile.heatx)**
그렇다면 다음과 같은 화면이 나올 것입니다.
ls

그후 다음의 명령어를 입력합니다.
docker build -f dockerfile.heat --no-cache -t heart-beat .
docker build -f dockerfile.sws --no-cache -t simple-web-server .
컨테이너 끼리 통신하는 아주 쉬운 방법으로 docker network create 을 사용하겠습니다.
docker network create heat
위 명령어를 입력해 주세요. 이제 준비는 마쳤습니다.
먼저 simple-web-server 동작시키겠습니다.
이 웹서버는 curl 명령을 할 곳이기 때문에 실행이 안되어 있다면 fail이 될 것입니다.
docker run -d -p 8080:8080 --name sws-container --network heat simple-web-server

그 후 이번엔 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 명령어를 직접 입력할 경우만 출력됩니다.)
따라서 이러한 출력을 볼 수 있습니다.

(프로그램 시작하자마자 알람 시그널이 세팅되어 4초를 기다린 후 처음 curl 명령을 보내게 될 것입니다.)
그러면 이번에는 스크립트를 이용해서 같은 동작을 확인해보겠습니다.
이 실행을 하기 전에 웹서버를 초기화하기 위해서 다음의 명령어를 먼저 실행합니다.
docker kill --signal=SIGHUP sws-container
(recovery script를 대신하는 명령어)
기존의 dockerfile에서는 check 스크립트의 실행권한이 없어 다음과 같은 에러가 발생할 것입니다.
check가 executable 하지 않으면 이렇게 에러 처리를 하도록 구현.

그래서 모든 스크립트에 실행권한을 준 도커파일인 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

위와 동일한 작동을 합니다.
그 다음 추가적인 에러처리를 살펴보겠습니다.

스크립트나 커맨드가 없어 에러가 나는 모습입니다.
옵션 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

위 출력은 웹서버를 의도적으로 꺼서 실패가 뜨게 한 뒤에 실행했습니다.
(docker kill --signal=SIGHUP sws-container) (옵션 3을 구현할 경우 recovery에 해당)
실패가 감지될 경우 할 행동은 failure.sh의 환경변수를 그저 출력하도록 했습니다. 웹서버를 고치는 것은 recovery 스크립트가 해야 한다고 판단했습니다.
제가 구현한 사항까지만으로 어떻게 추가적으로 적용할 수 있는 지 설명하겠습니다.
(가독성을 위해서 docker 커맨드를 생략하고 heartbeat라고 작성합니다.)
heartbeat 앱을 사용하여 시스템의 CPU 사용률, 메모리 사용량, 디스크 사용량 등을 주기적으로 체크할 수 있습니다.heartbeat -i 60 -s check_system_resources.sh (60초마다 check_system_resources.sh 스크립트를 실행하여 시스템 자원을 체크)heartbeat -i 300 -s check_web_server_status.sh (5분마다 웹 서버 상태를 체크하는 스크립트 실행)heartbeat을 사용할 수 있습니다.heartbeat -i 86400 -s backup_database.sh (매일 데이터베이스를 백업하는 스크립트 실행)heartbeat -i 120 -s monitor_logs.sh (2분마다 로그 파일을 모니터링하는 스크립트 실행)heartbeat -i 120 -s check_api_status.sh (2분마다 외부 API의 상태를 체크하는 스크립트 실행)heartbeat -i 300 -s check_network_connection.sh (5분마다 네트워크 연결 상태를 체크하는 스크립트 실행)