① Docker가 설치된 리눅스 서버(SSH로 접속 가능해야 함) ② 그 서버 위에서 동작 중인 n8n (Docker로 띄워져 있어도 되고 다른 방식이어도 무관) ③ 텔레그램 앱 — 알림을 받을 본인 휴대폰에 설치되어 있으면 됩니다. 셋 다 없어도 글을 읽으면서 순서대로 만들어갈 수 있지만, n8n은 미리 설치되어 있다고 가정하고 설명합니다.
전체적인 구조에 대한 설명
먼저 “무엇을, 왜 이렇게 만드는지”를 이해하고 시작하면 나중에 문제가 생겼을 때 스스로 고칠 수 있습니다. 코드를 그대로 베끼는 것보다 이 부분이 훨씬 중요합니다.
우리가 풀어야 하는 문제
Docker 컨테이너는 어떤 이유로든 멈출 수 있습니다. 메모리 부족, 의존하는 다른 컨테이너가 아직 안 떠서 연결 실패, 설정 실수 등 원인은 다양합니다. 문제는 컨테이너가 멈춰도 알아서 알려주는 사람이 없다는 점입니다. 사람이 직접 서버에 들어가서 docker ps를 쳐보기 전까지는 아무도 모릅니다.
왜 “n8n이 직접 점검”하지 않는가
가장 먼저 떠오르는 방법은 n8n 안에 “5분마다 실행” 스케줄을 넣고, n8n이 직접 점검 명령을 실행하는 것입니다. 하지만 n8n도 Docker 컨테이너이고, 컨테이너는 자기만의 독립된 작은 우주라서 호스트(진짜 서버)와는 분리되어 있습니다.
n8n 컨테이너 안에서 curl http://localhost:8080을 실행하면, 이건 호스트 서버의 8080 포트가 아니라 n8n 컨테이너 자기 자신의 8080 포트를 가리킵니다. 다른 컨테이너(예: nginx)가 호스트의 8080 포트에 떠있어도 n8n 컨테이너 안에서는 닿지 않습니다. 이 개념을 모르고 시작하면 “왜 자꾸 연결 실패가 나지?”라며 한참 헤매게 됩니다.
그래서 이렇게 설계합니다: 점검은 호스트(서버 자체)에서 하고, 결과만 n8n에게 보내준다.
전체 흐름 그림으로 보기
① 서버(호스트)의 cron이 5분마다 깨어남
↓
② 점검 스크립트(.sh) 실행 → Docker 컨테이너 상태 확인
↓
③ 결과를 JSON 형태로 만들어서
↓
④ curl 명령으로 n8n의 Webhook 주소에 전송 (POST)
↓
⑤ n8n이 받아서 "문제가 있는지" 판단
↓
⑥ 문제 있으면 → 텔레그램으로 메시지 발송
문제 없으면 → 그냥 조용히 끝남각 구성요소가 하는 일 — 용어부터 정리
| 용어 | 쉬운 설명 |
|---|---|
| cron / crontab | 리눅스에 내장된 “정해진 시간마다 명령어를 실행해주는” 예약 기능. 별도 설치 필요 없음 |
| bash 스크립트(.sh) | 명령어들을 순서대로 모아놓은 텍스트 파일. 한 번에 실행 가능 |
| JSON | {"key": "value"} 형태로 데이터를 정리하는 표준 형식. 프로그램끼리 데이터 주고받을 때 가장 흔히 씀 |
| Webhook | “이 주소로 데이터를 보내면 내가 받아서 뭔가 할게”라고 미리 약속해둔 URL |
| n8n | 코드를 거의 안 짜고 마우스로 블록을 연결해서 자동화를 만드는 도구 (Zapier와 비슷하지만 직접 설치해서 무료로 사용) |
| n8n 노드(Node) | n8n에서 하나의 작업 단위를 나타내는 블록 (예: “Webhook 받기” 블록, “텔레그램 보내기” 블록) |
점검(호스트)과 알림 처리(n8n)가 완전히 분리되어 있어서, 둘 중 하나에 문제가 생겨도 다른 쪽에 영향을 덜 줍니다. 또한 점검 스크립트는 순수 bash라서 Docker 환경이 있는 어떤 리눅스 서버에서도 그대로 재사용할 수 있습니다.
사용하는 도커 예시
설명을 위해 누구나 따라할 수 있는 아주 간단한 예시 3개짜리 스택을 하나 만들어보겠습니다. 실제로는 이미 운영 중인 컨테이너가 있다면 이 단계는 건너뛰고 컨테이너 이름만 본인 환경에 맞게 바꿔서 STEP 03으로 가셔도 됩니다.
예시 docker-compose.yml — 웹서버 + DB + 캐시
services:
example-web:
image: nginx:alpine
container_name: example-web
restart: always
ports:
- "8081:80"
example-db:
image: postgres:16-alpine
container_name: example-db
restart: always
environment:
POSTGRES_PASSWORD: examplepass
volumes:
- example-db-data:/var/lib/postgresql/data
example-cache:
image: redis:alpine
container_name: example-cache
restart: always
volumes:
example-db-data:실행 및 확인
# 폴더 만들고 위 내용을 docker-compose.yml로 저장한 뒤 mkdir -p ~/example-stack && cd ~/example-stack # (nano나 vi로 docker-compose.yml 파일 작성) # 실행 docker compose up -d # 3개 컨테이너가 모두 Up 상태인지 확인 docker ps --filter "name=example-"
꼭 이 세 개여야 하는 건 아닙니다. “항상 켜져 있어야 하는 서비스”의 가장 흔한 조합(웹서버·데이터베이스·캐시)을 예시로 든 것뿐입니다. 본인이 실제로 쓰는 컨테이너(예: n8n 자신, Watchtower, 기타 자동화 도구)로 그대로 바꿔서 따라하셔도 됩니다 — 어차피 다음 단계에서 우리가 점검할 건 “컨테이너 이름과 살아있는지 여부”뿐입니다.
앞으로 이 글에서는 이 3개 컨테이너(example-web, example-db, example-cache)를 점검 대상으로 계속 사용합니다.
system status 간략 소스 작성 (도커 2~3개용)
이제 핵심인 점검 스크립트를 만듭니다. 처음부터 거대한 스크립트를 만들지 않고, 딱 3개 컨테이너만 보는 작은 버전으로 시작해서 한 줄 한 줄 무슨 뜻인지 설명합니다.
스크립트가 할 일 — 미리 정리
각 컨테이너가 지금 켜져 있는지(running) 확인
docker inspect로 상태값을 가져옴
결과를 사람이 읽기 좋은 형태로 화면에 출력
디버깅할 때 직접 봐야 하므로
동시에 JSON 형태로도 만들어서
n8n에 보낼 때는 이 JSON을 사용
전체 스크립트
#!/bin/bash # 점검할 컨테이너 이름을 여기에 나열 (본인 환경에 맞게 수정) CONTAINERS=("example-web" "example-db" "example-cache") OK_COUNT=0 FAIL_COUNT=0 CHECKS_JSON=() for name in "${CONTAINERS[@]}"; do # docker inspect로 해당 컨테이너의 실행 상태를 가져옴 # 컨테이너가 없으면 빈 값이 나옴 state=$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null) if [[ "$state" == "running" ]]; then echo "✓ $name : 정상 실행 중" status="OK" OK_COUNT=$((OK_COUNT+1)) elif [[ -z "$state" ]]; then echo "✗ $name : 컨테이너를 찾을 수 없음 (이름 확인 필요)" status="FAIL" state="not_found" FAIL_COUNT=$((FAIL_COUNT+1)) else echo "✗ $name : 비정상 상태 ($state)" status="FAIL" FAIL_COUNT=$((FAIL_COUNT+1)) fi # JSON 한 조각을 만들어 배열에 추가 CHECKS_JSON+=("{\"check\":\"$name\",\"status\":\"$status\",\"detail\":\"$state\"}") done echo "" echo "정상 $OK_COUNT개 / 실패 $FAIL_COUNT개" # 전체 결과를 하나의 JSON으로 합치기 JSON_OUTPUT=$(printf '{"timestamp":"%s","hostname":"%s","summary":{"ok":%d,"fail":%d},"checks":[%s]}' \ "$(date -Iseconds)" "$(hostname)" "$OK_COUNT" "$FAIL_COUNT" \ "$(IFS=,; echo "${CHECKS_JSON[*]}")") # --json 옵션을 주면 사람용 출력 대신 JSON만 한 줄 출력 (n8n 연동용) if [[ "$1" == "--json" ]]; then echo "$JSON_OUTPUT" fi
한 줄씩 무슨 뜻인지
| 코드 | 의미 |
|---|---|
CONTAINERS=("a" "b" "c") | bash의 배열 문법. 점검할 컨테이너 이름 목록 |
docker inspect -f '{{.State.Status}}' 이름 | 해당 컨테이너의 상태값만 딱 뽑아옴 (running, exited 등) |
2>/dev/null | 컨테이너가 없을 때 나오는 에러 메시지를 화면에 안 보이게 숨김 |
[[ -z "$state" ]] | “$state가 비어있다면” — 컨테이너 자체가 존재하지 않는 경우 |
CHECKS_JSON+=(...) | 배열에 항목 추가. 나중에 합쳐서 JSON 배열로 만듦 |
$(IFS=,; echo "${CHECKS_JSON[*]}") | 배열의 각 항목을 쉼표로 이어붙이는 bash 관용구 |
docker inspect는 컨테이너 이름이 틀려도 에러 메시지 없이 그냥 빈 값을 돌려줍니다(2>/dev/null로 숨겼기 때문). 그래서 오타가 있으면 “컨테이너를 찾을 수 없음”으로 잘못 잡힙니다. 실행 전에 docker ps --format "{{.Names}}"로 정확한 이름을 먼저 복사해두는 걸 추천합니다.
실행 권한 부여 및 테스트
# 스크립트 파일로 저장 (예: 홈 디렉토리에) nano ~/check_containers.sh # 위 전체 스크립트 내용 붙여넣기 후 저장 (Ctrl+O → Enter → Ctrl+X) # 실행 권한 부여 — 이게 없으면 "Permission denied" chmod +x ~/check_containers.sh # 사람이 보는 일반 출력으로 먼저 테스트 ~/check_containers.sh # JSON 출력 테스트 ~/check_containers.sh --json
지금은 “켜져 있는지”만 보지만, CONTAINERS 배열에 컨테이너를 더 추가하기만 하면 됩니다. 더 나아가 HTTP 응답 확인(curl), 재시작 횟수 확인(docker inspect -f '{{.RestartCount}}'), 디스크 사용량(df -h) 등도 같은 패턴(상태 확인 → if문 → JSON에 추가)으로 얼마든지 늘릴 수 있습니다. 항목이 많아지면 함수로 정리하는 걸 권장합니다.
crontab 등록방법
스크립트를 사람이 매번 실행할 순 없으니, 리눅스의 예약 실행 기능인 cron으로 자동화합니다.
crontab 문법 기초
* * * * * 명령어 │ │ │ │ │ │ │ │ │ └── 요일 (0=일요일 ~ 6=토요일, *는 매일) │ │ │ └─────── 월 (1~12, *는 매월) │ │ └──────────── 일 (1~31, *는 매일) │ └───────────────── 시 (0~23, *는 매시간) └────────────────────── 분 (0~59, */5는 5분마다)
| 표현 | 의미 |
|---|---|
*/5 * * * * | 5분마다 |
0 * * * * | 매 시 정각 |
0 9 * * * | 매일 오전 9시 |
*/1 * * * * | 1분마다 (테스트용으로 짧게) |
등록하기
# crontab 편집 화면 열기 (처음이면 편집기 선택하라고 나올 수 있음 — nano 추천)
crontab -e편집기가 열리면 맨 아래에 한 줄 추가합니다. YOUR_N8N_URL 부분은 STEP 05에서 만들 n8n Webhook 주소로 나중에 바꿉니다 (일단 그대로 두고 진행해도 무방합니다).
*/5 * * * * /root/check_containers.sh --json | curl -s -X POST -H "Content-Type: application/json" -d @- "YOUR_N8N_URL" > /dev/null 2>&1
저장: nano라면 Ctrl+O → Enter → Ctrl+X
터미널에서 잘 되던 스크립트가 cron으로 등록하면 조용히 실패하는 경우가 매우 흔합니다. 이유는 cron이 실행될 때는 일반 로그인 셸과 다른, 훨씬 적은 PATH(명령어를 찾는 경로 목록)를 사용하기 때문입니다. docker나 curl 명령어를 못 찾아서 그냥 아무 일도 안 일어난 것처럼 보입니다.
해결책 — 스크립트 안에서 명령어를 절대경로로 쓰거나, 맨 위에 PATH를 직접 지정:
#!/bin/bash
# cron 환경에서도 docker, curl 명령어를 찾을 수 있도록 PATH 명시
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin등록 확인 & 디버깅용 로그 남기기
# 등록된 crontab 목록 확인 crontab -l # cron이 제대로 도는지 확인하고 싶으면, 결과를 파일로도 남기게 바꿔서 테스트 */5 * * * * /root/check_containers.sh --json >> /var/log/check_containers.log 2>&1 # 잠시 후 로그 확인 tail -20 /var/log/check_containers.log
5분을 매번 기다릴 필요 없습니다. crontab에 등록한 것과 똑같은 명령을 터미널에 그대로 입력해서 바로 실행해볼 수 있습니다. 이게 정상 동작하면, cron으로 등록했을 때도 똑같이 동작합니다(PATH 문제만 없다면).
n8n 워크플로우 작성
이제 n8n에서 결과를 받아 텔레그램으로 보내는 부분을 만듭니다. 노드 4개로 끝나는 짧은 워크플로우입니다.
사전 준비 — 텔레그램 봇 만들기 (처음이신 경우)
텔레그램으로 알림을 받으려면 “봇(bot)”이 하나 있어야 합니다. 이미 n8n에서 쓰던 봇이 있다면 이 부분은 건너뛰세요.
텔레그램에서 @BotFather 검색해서 대화 시작
텔레그램 공식 봇 생성 도구
/newbot 입력 → 봇 이름과 username 설정
username은 반드시 끝에 “bot”이 붙어야 함 (예: my_status_bot)
발급된 토큰(Token) 복사해서 저장
긴 문자열, 이후 n8n Credential에 입력
방금 만든 내 봇과 대화 시작 — 아무 말이나 한 번 보내기
이 단계를 빼먹으면 나중에 메시지를 못 받습니다 (아래 트러블슈팅 참고)
@userinfobot에게 말을 걸어서 내 chat ID 확인
숫자로만 된 ID가 나옴 (예: 123456789)
n8n 노드 구성 — 전체 그림
| 순서 | 노드 | 역할 |
|---|---|---|
| 1 | Webhook | cron이 보낸 JSON을 받는 입구 |
| 2 | Code | JSON을 분석해서 “알릴 게 있는지” 판단 + 메시지 글 작성 |
| 3 | IF | 알릴 게 있으면 true, 없으면 false로 분기 |
| 4 | Telegram | true일 때만 메시지 발송 |
① Webhook 노드 만들기
n8n에서 새 워크플로우 생성 → “+” 눌러 노드 추가
검색창에 “Webhook” 입력해서 선택
HTTP Method를 POST로 설정
기본값은 GET이라 반드시 바꿔야 함
Path에 원하는 경로 입력 (예: container-status)
전체 URL은 노드 상단에 자동으로 표시됨
Webhook 노드를 클릭하면 URL이 두 가지 보입니다. Test URL은 n8n 편집 화면을 열어두고 “Listen for test event”를 누른 상태에서만 동작하고, Production URL은 워크플로우가 Active(활성화) 상태일 때만 동작합니다. crontab에는 반드시 Production URL을 넣고, 워크플로우 우측 상단의 Active 토글을 켜야 합니다.
② Code 노드 — 메시지 만들기
Webhook 다음에 Code 노드를 추가하고, 아래 JavaScript를 그대로 붙여넣습니다.
// cron이 보낸 데이터 꺼내기 (Webhook은 보통 .body 안에 실제 내용이 들어있음)
const input = $input.first().json;
const data = input.body || input;
const summary = data.summary || { ok: 0, fail: 0 };
const checks = data.checks || [];
const hostname = data.hostname || 'unknown';
// status가 FAIL인 항목만 골라내기
const fails = checks.filter(c => c.status === 'FAIL');
const shouldAlert = fails.length > 0;
let message = `🚨 *컨테이너 상태 알림*\n\`${hostname}\`\n정상 ${summary.ok} / 실패 ${summary.fail}\n\n`;
for (const f of fails) {
message += `• ${f.check} — ${f.detail}\n`;
}
return [{ json: { shouldAlert, message } }];③ IF 노드 — 분기하기
Code 노드 다음에 IF 노드 추가
검색창에 “If” 입력
조건 입력: {{ $json.shouldAlert }} 가 true인지
타입은 Boolean으로 선택
④ Telegram 노드 — 발송
IF 노드의 true 출력 쪽에 Telegram 노드 연결
IF 노드는 출력이 true/false 두 갈래로 나뉨
Credential 추가 → 아까 BotFather에게 받은 토큰 입력
Credential은 한 번 만들어두면 다른 워크플로우에서도 재사용 가능
Chat ID에 아까 확인한 본인 숫자 ID 입력
Text 필드에는 {{ $json.message }} 입력
완성된 워크플로우 — 통째로 Import 하기
위 4단계를 직접 클릭하며 만드는 대신, 아래 JSON을 파일로 저장해서 n8n의 Import from File로 한 번에 불러올 수도 있습니다.
{
"name": "컨테이너 상태 모니터링",
"nodes": [
{
"parameters": { "httpMethod": "POST", "path": "container-status",
"responseMode": "onReceived", "responseData": "success", "options": {} },
"id": "n1", "name": "Webhook",
"type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [-440, 0]
},
{
"parameters": { "jsCode": "/* STEP 05의 Code 노드 본문과 동일 */" },
"id": "n2", "name": "메시지 생성",
"type": "n8n-nodes-base.code", "typeVersion": 2, "position": [-220, 0]
},
{
"parameters": { "conditions": { "options": { "caseSensitive": true,
"leftValue": "", "typeValidation": "loose" },
"conditions": [{ "id": "c1", "leftValue": "={{ $json.shouldAlert }}",
"rightValue": true, "operator": { "type": "boolean", "operation": "true" } }],
"combinator": "and" }, "options": {} },
"id": "n3", "name": "IF",
"type": "n8n-nodes-base.if", "typeVersion": 2, "position": [0, 0]
},
{
"parameters": { "resource": "message", "operation": "sendMessage",
"chatId": "YOUR_CHAT_ID", "text": "={{ $json.message }}",
"additionalFields": { "parse_mode": "Markdown" } },
"id": "n4", "name": "텔레그램 발송",
"type": "n8n-nodes-base.telegram", "typeVersion": 1.2, "position": [220, -50],
"credentials": { "telegramApi": { "id": "YOUR_CRED_ID", "name": "Telegram account" } }
}
],
"connections": {
"Webhook": { "main": [[{ "node": "메시지 생성", "type": "main", "index": 0 }]] },
"메시지 생성": { "main": [[{ "node": "IF", "type": "main", "index": 0 }]] },
"IF": { "main": [[{ "node": "텔레그램 발송", "type": "main", "index": 0 }], []] }
},
"active": false,
"settings": { "executionOrder": "v1" }
}YOUR_CHAT_ID와 텔레그램 Credential을 실제 값으로 바꿔야 합니다. placeholder 그대로 두면 워크플로우 Active 전환 자체가 막힙니다.
테스트 및 트러블슈팅
수동으로 즉시 테스트
# 1. 정상 상태에서 한 번 전송 — 텔레그램 조용해야 정상 (FAIL 없으니까) ~/check_containers.sh --json | curl -s -X POST -H "Content-Type: application/json" -d @- "여기에_n8n_Production_URL" # 2. 의도적으로 하나 정지시켜서 알림이 오는지 확인 docker stop example-cache # 3. 다시 전송 — 이번엔 텔레그램이 와야 함 ~/check_containers.sh --json | curl -s -X POST -H "Content-Type: application/json" -d @- "여기에_n8n_Production_URL" # 4. 확인 후 복구 docker start example-cache
자주 겪는 문제 모음
원인: 워크플로우가 Active(활성화) 상태가 아닙니다.
해결: n8n에서 워크플로우 우측 상단 토글을 ON으로 켜고 저장하세요. Test URL이 아니라 Production URL을 쓰고 있는지도 다시 확인하세요.
원인 A: 정말로 FAIL이 0건이라 정상적으로 알림을 안 보낸 것 (이게 제일 흔함 — 의도된 동작).
원인 B: 텔레그램 봇과 대화를 한 번도 시작 안 한 경우. 봇은 사용자가 먼저 말을 걸기 전에는 메시지를 보낼 수 없습니다.
확인 방법: n8n 좌측 메뉴 Executions에서 방금 실행 기록을 열어 Code 노드의 출력값 shouldAlert가 true/false인지 직접 확인하세요.
원인: STEP 04에서 설명한 PATH 문제, 또는 스크립트 파일 경로를 잘못 입력했을 가능성이 큽니다.
확인 방법:
# cron 자체의 실행 로그 확인 (Ubuntu/Debian 계열) grep CRON /var/log/syslog | tail -20 # 또는 journalctl -u cron --since "10 min ago"
이건 지금 만든 간단한 스크립트에는 해당 없지만, 나중에 “재시작 횟수”를 점검 항목에 추가하면 만나게 될 함정입니다. Docker의 재시작 횟수(RestartCount)는 평생 누적값이라 한 번 임계치를 넘으면 컨테이너가 멀쩐해진 뒤에도 절대 줄어들지 않습니다. 나중에 이 항목을 추가하실 때는 “누적값”이 아니라 “직전 점검 대비 늘어난 양(delta)”으로 판단하도록 만드는 것을 추천합니다. 이전 점검 시점의 값을 파일에 저장해두고 비교하는 방식입니다.
완성 체크리스트
- ✓스크립트 실행 권한 —
chmod +x했는지 - ✓crontab에 정확한 절대경로로 등록했는지 (
~대신/root/...같은 전체 경로 권장) - ✓n8n 워크플로우 Active 상태인지
- ✓텔레그램 봇과 먼저 대화를 시작했는지
- ✓docker stop 테스트로 실제 알림 수신까지 확인했는지
📋 핵심 요약: 점검은 호스트의 cron이 하고, n8n은 결과를 받아서 판단·알림만 담당하는 구조입니다. 처음엔 2~3개 컨테이너의 “켜져 있는지”만 보는 작은 스크립트로 시작해서, 익숙해지면 HTTP 헬스체크·재시작 횟수·디스크 사용량 등으로 점검 항목을 하나씩 늘려가시면 됩니다.



