From 89cdebf0447a31d0f8ac9c1a552eea08ecfa8044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:21:53 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20blue-green=20=EC=8A=AC=EB=A1=AF=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=C2=B7=EC=A0=84=ED=99=98=20=EA=B3=B5=EC=9A=A9?= =?UTF-8?q?=20=EB=B8=94=EB=A1=9D=20=EC=8B=A0=EC=84=A4=20(slot=5Fdecide?= =?UTF-8?q?=C2=B7slot=5Fswitch)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core·extractor 에 복제된 blue-green 오케스트레이션에서 범용 골격 둘을 블록으로 추출: 상태 파일 기반 슬롯 판정(slot_decide)과 실패 시 원복하는 upstream 전환(slot_switch). 복제 구현에서 실증된 두 사고가 동인: 포트 추출이 host 의 127 을 먼저 무는 함정, 전환 실패 시 상태·현실 괴리로 다음 배포가 서빙 슬롯을 오제거하는 결함(#37) - 블록 원칙 준수: 포트·이름·URL 은 default 없는 필수 인자(값 무소유), 순수 bash(실행 위치 중립), sudo 는 비루트일 때만 - decide 출력은 eval 가능한 한 줄 - 소비는 할당 후 eval 2단계로 문서화 (eval "$(...)" 직결은 치환 실패가 성공으로 위장) - switch 의 verify-cmd 까지 원복 경계 안에 둔다 - 전환 후 검증 실패도 이전 슬롯으로 복귀 - 셀프 테스트 28케이스(판정 11 + 전환 17, PATH 스텁으로 nginx·systemctl 대체) + CI 등록 --- .github/workflows/ci.yml | 6 ++ blocks/slot_decide.sh | 79 ++++++++++++++++++++++++ blocks/slot_decide.test.sh | 79 ++++++++++++++++++++++++ blocks/slot_switch.sh | 82 +++++++++++++++++++++++++ blocks/slot_switch.test.sh | 120 +++++++++++++++++++++++++++++++++++++ 5 files changed, 366 insertions(+) create mode 100755 blocks/slot_decide.sh create mode 100755 blocks/slot_decide.test.sh create mode 100755 blocks/slot_switch.sh create mode 100755 blocks/slot_switch.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cee38b1..dfe60d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,3 +40,9 @@ jobs: - name: Run run_container.sh self-test run: REQUIRE_DOCKER=1 ./blocks/run_container.test.sh + + - name: Run slot_decide.sh self-test + run: ./blocks/slot_decide.test.sh + + - name: Run slot_switch.sh self-test + run: ./blocks/slot_switch.test.sh diff --git a/blocks/slot_decide.sh b/blocks/slot_decide.sh new file mode 100755 index 0000000..3035a4f --- /dev/null +++ b/blocks/slot_decide.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# +# 공통 배포 블록: blue-green 슬롯 판정 +# +# nginx upstream 상태 파일(현재 서빙 슬롯의 single source, 예: "server 127.0.0.1:18090;")을 +# 읽어 ACTIVE(서빙 중)/INACTIVE(이번 배포 대상) 슬롯을 판정한다. +# 실행 위치 중립 - 순수 bash 만 쓴다. transport 는 호출자 소관 (conventions/blocks.md 1번). +# +# 포트 추출은 콜론 뒤 숫자만 문다 - "server 127.0.0.1:18090;" 처럼 host 에 숫자가 섞이면 +# 무차별 숫자 추출은 "127" 을 먼저 물어 슬롯 판정이 조용히 어긋난다 (extractor 실측 함정. +# "server localhost:8080;" 류는 우연히 무사해서 복제 구현에 잠복해 있었다). +# +# 상태 파일이 없거나 포트가 두 슬롯 어느 쪽도 아니면 부트스트랩으로 본다: +# ACTIVE 는 빈 값, INACTIVE 는 --slot-a (첫 배포는 a 슬롯으로 간다). +# +# 출력: eval 가능한 한 줄 (stdout) +# ACTIVE=blue ACTIVE_PORT=18090 INACTIVE=green INACTIVE_PORT=18091 +# 호출자는 2단계로 소비한다 - eval "$(...)" 직결은 치환 실패가 빈 문자열 eval(성공)로 +# 위장되므로, 할당으로 실패를 먼저 드러낸 뒤 eval 한다: +# DECIDED=$(bash slot_decide.sh --state-file ... --slot-a blue:18090 --slot-b green:18091) +# eval "$DECIDED" +# +# 인자: +# --state-file (필수) upstream 상태 파일 경로 +# --slot-a (필수) 첫 슬롯 NAME:PORT (예: blue:18090). 부트스트랩의 배포 대상 +# --slot-b (필수) 둘째 슬롯 NAME:PORT (예: green:18091) +# +# 종료 코드: 성공 0, 인자 오류 2 (판정 자체는 실패하지 않는다 - 모르는 상태는 부트스트랩) + +set -euo pipefail + +STATE_FILE="" +SLOT_A="" +SLOT_B="" + +while [ $# -gt 0 ]; do + case "$1" in + --state-file) STATE_FILE="${2:-}"; shift 2;; + --slot-a) SLOT_A="${2:-}"; shift 2;; + --slot-b) SLOT_B="${2:-}"; shift 2;; + *) echo "unknown arg: $1" >&2; exit 2;; + esac +done + +[ -n "$STATE_FILE" ] || { echo "--state-file is required" >&2; exit 2; } +[ -n "$SLOT_A" ] || { echo "--slot-a is required" >&2; exit 2; } +[ -n "$SLOT_B" ] || { echo "--slot-b is required" >&2; exit 2; } + +parse_slot() { + # NAME:PORT 를 검증하며 분해한다. 전역 PARSED_NAME/PARSED_PORT 로 반환. + local arg_name="$1" value="$2" + PARSED_NAME="${value%%:*}" + PARSED_PORT="${value##*:}" + if [ "$value" = "$PARSED_NAME" ] || [ -z "$PARSED_NAME" ] || [ -z "$PARSED_PORT" ]; then + echo "$arg_name must be NAME:PORT (got: $value)" >&2 + exit 2 + fi + case "$PARSED_PORT" in + *[!0-9]*) echo "$arg_name port must be numeric (got: $value)" >&2; exit 2;; + esac +} + +parse_slot "--slot-a" "$SLOT_A" +NAME_A="$PARSED_NAME" PORT_A="$PARSED_PORT" +parse_slot "--slot-b" "$SLOT_B" +NAME_B="$PARSED_NAME" PORT_B="$PARSED_PORT" + +CURRENT_PORT="" +if [ -f "$STATE_FILE" ]; then + CURRENT_PORT=$(grep -oE ':[0-9]+' "$STATE_FILE" | head -1 | tr -d ':' || true) +fi + +if [ "$CURRENT_PORT" = "$PORT_A" ]; then + echo "ACTIVE=$NAME_A ACTIVE_PORT=$PORT_A INACTIVE=$NAME_B INACTIVE_PORT=$PORT_B" +elif [ "$CURRENT_PORT" = "$PORT_B" ]; then + echo "ACTIVE=$NAME_B ACTIVE_PORT=$PORT_B INACTIVE=$NAME_A INACTIVE_PORT=$PORT_A" +else + echo "ACTIVE= ACTIVE_PORT= INACTIVE=$NAME_A INACTIVE_PORT=$PORT_A" +fi diff --git a/blocks/slot_decide.test.sh b/blocks/slot_decide.test.sh new file mode 100755 index 0000000..a584bd5 --- /dev/null +++ b/blocks/slot_decide.test.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# +# blocks/slot_decide.sh 셀프 테스트 +# +# 상태 파일 케이스(부트스트랩·a 활성·b 활성·모르는 포트)와 인자 오류, 그리고 이 블록의 +# 존재 이유인 포트 추출 함정(host 의 127 이 먼저 잡히는 것) 회귀를 실측한다. +# conventions/blocks.md 5번 원칙(셀프 검증 가능)의 실행체. +# +# 실행: ./blocks/slot_decide.test.sh + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DECIDE="$SCRIPT_DIR/slot_decide.sh" + +WORKDIR=$(mktemp -d) +cleanup() { rm -rf "$WORKDIR"; } +trap cleanup EXIT + +FAILURES=0 + +check() { + local desc="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + echo "PASS: $desc" + else + echo "FAIL: $desc (expected=[$expected] actual=[$actual])" >&2 + FAILURES=$((FAILURES + 1)) + fi +} + +# 1. 필수 인자 누락 +"$DECIDE" --slot-a blue:18090 --slot-b green:18091 >/dev/null 2>&1 +check "state-file 없음 -> exit 2" 2 "$?" +"$DECIDE" --state-file "$WORKDIR/up.conf" --slot-a blue:18090 >/dev/null 2>&1 +check "slot-b 없음 -> exit 2" 2 "$?" + +# 2. 알 수 없는 인자 +"$DECIDE" --state-file "$WORKDIR/up.conf" --slot-a blue:18090 --slot-b green:18091 --bogus x >/dev/null 2>&1 +check "알 수 없는 인자 -> exit 2" 2 "$?" + +# 3. 슬롯 형식 위반 +"$DECIDE" --state-file "$WORKDIR/up.conf" --slot-a blue18090 --slot-b green:18091 >/dev/null 2>&1 +check "콜론 없는 슬롯 -> exit 2" 2 "$?" +"$DECIDE" --state-file "$WORKDIR/up.conf" --slot-a blue:1809x --slot-b green:18091 >/dev/null 2>&1 +check "비숫자 포트 -> exit 2" 2 "$?" + +# 4. 부트스트랩: 상태 파일 없음 -> INACTIVE = slot-a +OUT=$("$DECIDE" --state-file "$WORKDIR/none.conf" --slot-a blue:18090 --slot-b green:18091) +check "부트스트랩 판정" "ACTIVE= ACTIVE_PORT= INACTIVE=blue INACTIVE_PORT=18090" "$OUT" + +# 5. 포트 추출 함정 회귀: host 에 숫자(127.0.0.1)가 있어도 콜론 뒤 포트만 문다 +echo "server 127.0.0.1:18090;" >"$WORKDIR/up.conf" +OUT=$("$DECIDE" --state-file "$WORKDIR/up.conf" --slot-a blue:18090 --slot-b green:18091) +check "127.0.0.1 host 에서 a 활성 판정" "ACTIVE=blue ACTIVE_PORT=18090 INACTIVE=green INACTIVE_PORT=18091" "$OUT" + +# 6. b 활성 (core 의 localhost 표기도 같은 추출 규칙으로 동작) +echo "server localhost:8081;" >"$WORKDIR/up.conf" +OUT=$("$DECIDE" --state-file "$WORKDIR/up.conf" --slot-a blue:8080 --slot-b green:8081) +check "localhost 표기에서 b 활성 판정" "ACTIVE=green ACTIVE_PORT=8081 INACTIVE=blue INACTIVE_PORT=8080" "$OUT" + +# 7. 모르는 포트 -> 부트스트랩 +echo "server 127.0.0.1:9999;" >"$WORKDIR/up.conf" +OUT=$("$DECIDE" --state-file "$WORKDIR/up.conf" --slot-a blue:18090 --slot-b green:18091) +check "모르는 포트 -> 부트스트랩" "ACTIVE= ACTIVE_PORT= INACTIVE=blue INACTIVE_PORT=18090" "$OUT" + +# 8. 출력이 eval 가능한 계약인지 (문서의 2단계 소비 패턴 그대로) +echo "server 127.0.0.1:18091;" >"$WORKDIR/up.conf" +DECIDED=$("$DECIDE" --state-file "$WORKDIR/up.conf" --slot-a blue:18090 --slot-b green:18091) +eval "$DECIDED" +check "eval 소비: ACTIVE" "green" "${ACTIVE:-}" +check "eval 소비: INACTIVE_PORT" "18090" "${INACTIVE_PORT:-}" + +if [ "$FAILURES" -gt 0 ]; then + echo "$FAILURES case(s) failed" >&2 + exit 1 +fi + +echo "all slot_decide.sh cases passed" diff --git a/blocks/slot_switch.sh b/blocks/slot_switch.sh new file mode 100755 index 0000000..382cff2 --- /dev/null +++ b/blocks/slot_switch.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# +# 공통 배포 블록: blue-green upstream 전환 (실패 시 원복) +# +# upstream 상태 파일을 새 슬롯으로 갱신하고 nginx 검증(-t)·reload 를 수행한다. +# 어느 단계가 실패해도(구성 검증, reload, 사후 검증) 이전 상태 파일을 원복해 +# "상태 파일만 새 슬롯을 가리키고 실제 서빙은 옛 슬롯"인 괴리를 남기지 않는다. +# 이 괴리는 다음 배포의 슬롯 판정(slot_decide.sh)을 뒤집어, 실제 서빙 중인 슬롯을 +# 비활성으로 오판·제거하게 만든다 (extractor 이행 배포 실사고의 일반화). +# +# 실행 위치 중립 - root 가 아니면 sudo 를 앞에 붙인다 (SSH runner 비루트 / SSM root 양쪽). +# reload 는 systemctl reload -> 실패 시 restart 폴백이라 nginx 가 정지 상태여도 전환된다. +# +# 사용 예: +# slot_switch.sh --state-file /etc/nginx/piki-extractor-upstream.conf --server 127.0.0.1:18091 \ +# --verify-cmd "bash /tmp/piki-blocks/healthcheck.sh --url http://localhost:8090/actuator/health --interval 2 --attempts 10" +# +# 인자: +# --state-file (필수) upstream 상태 파일 경로. "server <값>;" 한 줄로 덮어쓴다 +# --server (필수) 새 upstream server 값 (예: 127.0.0.1:18091) +# --verify-cmd (선택) reload 후 실행할 검증 명령(bash -c 로 실행). 비-0 이면 원복 후 실패. +# 전환 후 프론트 경유 헬스체크(healthcheck.sh 호출)가 대표 소비자 - 검증까지 +# 원복 경계 안에 있어야 "전환됐지만 안 서빙되는" 상태가 남지 않는다 +# +# 부트스트랩(이전 상태 파일 없음)은 원복 대상이 없다 - 실패해도 새 값이 남고 exit 1 로만 +# 알린다 (되돌아갈 서빙 슬롯 자체가 없는 1회성 창이라 호출자·운영자가 수동 개입). +# +# 종료 코드: 성공 0, 전환 실패(가능하면 원복 수행) 1, 인자 오류 2 + +set -euo pipefail + +STATE_FILE="" +SERVER="" +VERIFY_CMD="" + +while [ $# -gt 0 ]; do + case "$1" in + --state-file) STATE_FILE="${2:-}"; shift 2;; + --server) SERVER="${2:-}"; shift 2;; + --verify-cmd) VERIFY_CMD="${2:-}"; shift 2;; + *) echo "unknown arg: $1" >&2; exit 2;; + esac +done + +[ -n "$STATE_FILE" ] || { echo "--state-file is required" >&2; exit 2; } +[ -n "$SERVER" ] || { echo "--server is required" >&2; exit 2; } + +run_priv() { + if [ "$(id -u)" = "0" ]; then "$@"; else sudo "$@"; fi +} + +PREV="" +[ -f "$STATE_FILE" ] && PREV=$(cat "$STATE_FILE") + +restore() { + local stage="$1" + if [ -z "$PREV" ]; then + echo "switch FAILED at $stage - no previous state to restore (bootstrap)" >&2 + return 0 + fi + printf '%s\n' "$PREV" | run_priv tee "$STATE_FILE" >/dev/null + run_priv systemctl reload nginx 2>/dev/null || true + echo "switch FAILED at $stage - restored previous upstream" >&2 +} + +printf 'server %s;\n' "$SERVER" | run_priv tee "$STATE_FILE" >/dev/null + +if ! run_priv nginx -t; then + restore "nginx -t" + exit 1 +fi +if ! { run_priv systemctl reload nginx || run_priv systemctl restart nginx; }; then + restore "reload/restart" + exit 1 +fi +if [ -n "$VERIFY_CMD" ] && ! bash -c "$VERIFY_CMD"; then + restore "verify" + exit 1 +fi + +echo "switched upstream -> server $SERVER; ($STATE_FILE)" +exit 0 diff --git a/blocks/slot_switch.test.sh b/blocks/slot_switch.test.sh new file mode 100755 index 0000000..6f87091 --- /dev/null +++ b/blocks/slot_switch.test.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# +# blocks/slot_switch.sh 셀프 테스트 +# +# nginx·systemctl·sudo 를 PATH 스텁으로 대체해 전환·원복 계약(종료 코드 + 상태 파일 내용)을 +# 실측한다. 이 블록의 존재 이유가 "실패 시 원복"이므로, 각 실패 지점(-t·reload·verify)마다 +# 상태 파일이 이전 값으로 돌아오는지를 검사한다. conventions/blocks.md 5번 원칙의 실행체. +# +# 실행: ./blocks/slot_switch.test.sh + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SWITCH="$SCRIPT_DIR/slot_switch.sh" + +WORKDIR=$(mktemp -d) +cleanup() { rm -rf "$WORKDIR"; } +trap cleanup EXIT + +# ── PATH 스텁: 실패 주입은 FAKE_DIR 의 마커 파일로 제어한다 ── +mkdir -p "$WORKDIR/bin" +export FAKE_DIR="$WORKDIR" +cat >"$WORKDIR/bin/sudo" <<'EOF' +#!/usr/bin/env bash +exec "$@" +EOF +cat >"$WORKDIR/bin/nginx" <<'EOF' +#!/usr/bin/env bash +[ -f "$FAKE_DIR/nginx_t_fail" ] && exit 1 +exit 0 +EOF +cat >"$WORKDIR/bin/systemctl" <<'EOF' +#!/usr/bin/env bash +echo "$*" >>"$FAKE_DIR/systemctl.log" +[ -f "$FAKE_DIR/systemctl_fail" ] && exit 1 +exit 0 +EOF +chmod +x "$WORKDIR/bin/sudo" "$WORKDIR/bin/nginx" "$WORKDIR/bin/systemctl" +export PATH="$WORKDIR/bin:$PATH" + +STATE="$WORKDIR/upstream.conf" + +FAILURES=0 + +check() { + local desc="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + echo "PASS: $desc" + else + echo "FAIL: $desc (expected=[$expected] actual=[$actual])" >&2 + FAILURES=$((FAILURES + 1)) + fi +} + +reset_fakes() { + rm -f "$FAKE_DIR/nginx_t_fail" "$FAKE_DIR/systemctl_fail" "$FAKE_DIR/systemctl.log" +} + +# 1. 필수 인자 누락 / 알 수 없는 인자 +"$SWITCH" --server 127.0.0.1:18091 >/dev/null 2>&1 +check "state-file 없음 -> exit 2" 2 "$?" +"$SWITCH" --state-file "$STATE" >/dev/null 2>&1 +check "server 없음 -> exit 2" 2 "$?" +"$SWITCH" --state-file "$STATE" --server x --bogus y >/dev/null 2>&1 +check "알 수 없는 인자 -> exit 2" 2 "$?" + +# 2. 정상 전환: 상태 파일 갱신 + reload 수행 +reset_fakes +echo "server 127.0.0.1:18090;" >"$STATE" +"$SWITCH" --state-file "$STATE" --server 127.0.0.1:18091 >/dev/null 2>&1 +check "정상 전환 -> exit 0" 0 "$?" +check "정상 전환 -> 상태 파일 갱신" "server 127.0.0.1:18091;" "$(cat "$STATE")" +grep -q "reload nginx" "$FAKE_DIR/systemctl.log" +check "정상 전환 -> reload 호출" 0 "$?" + +# 3. nginx -t 실패 -> 원복 +reset_fakes +echo "server 127.0.0.1:18090;" >"$STATE" +touch "$FAKE_DIR/nginx_t_fail" +"$SWITCH" --state-file "$STATE" --server 127.0.0.1:18091 >/dev/null 2>&1 +check "-t 실패 -> exit 1" 1 "$?" +check "-t 실패 -> 상태 파일 원복" "server 127.0.0.1:18090;" "$(cat "$STATE")" + +# 4. reload·restart 모두 실패 -> 원복 +reset_fakes +echo "server 127.0.0.1:18090;" >"$STATE" +touch "$FAKE_DIR/systemctl_fail" +"$SWITCH" --state-file "$STATE" --server 127.0.0.1:18091 >/dev/null 2>&1 +check "reload/restart 실패 -> exit 1" 1 "$?" +check "reload/restart 실패 -> 상태 파일 원복" "server 127.0.0.1:18090;" "$(cat "$STATE")" + +# 5. verify 실패 -> 원복 + 원복 반영 reload +reset_fakes +echo "server 127.0.0.1:18090;" >"$STATE" +"$SWITCH" --state-file "$STATE" --server 127.0.0.1:18091 --verify-cmd "false" >/dev/null 2>&1 +check "verify 실패 -> exit 1" 1 "$?" +check "verify 실패 -> 상태 파일 원복" "server 127.0.0.1:18090;" "$(cat "$STATE")" +check "verify 실패 -> reload 2회(전환+원복)" 2 "$(grep -c "reload nginx" "$FAKE_DIR/systemctl.log")" + +# 6. verify 성공 -> 전환 유지 +reset_fakes +echo "server 127.0.0.1:18090;" >"$STATE" +"$SWITCH" --state-file "$STATE" --server 127.0.0.1:18091 --verify-cmd "true" >/dev/null 2>&1 +check "verify 성공 -> exit 0" 0 "$?" +check "verify 성공 -> 상태 파일 유지" "server 127.0.0.1:18091;" "$(cat "$STATE")" + +# 7. 부트스트랩(-t 실패, 이전 상태 없음) -> 원복 대상 없음, 새 값 유지 + exit 1 +reset_fakes +rm -f "$STATE" +touch "$FAKE_DIR/nginx_t_fail" +"$SWITCH" --state-file "$STATE" --server 127.0.0.1:18090 >/dev/null 2>&1 +check "부트스트랩 -t 실패 -> exit 1" 1 "$?" +check "부트스트랩 -t 실패 -> 새 값 유지(원복 대상 없음)" "server 127.0.0.1:18090;" "$(cat "$STATE")" + +if [ "$FAILURES" -gt 0 ]; then + echo "$FAILURES case(s) failed" >&2 + exit 1 +fi + +echo "all slot_switch.sh cases passed"