489 lines
23 KiB
Bash
Executable File
489 lines
23 KiB
Bash
Executable File
#!/bin/sh
|
|
|
|
set -eu
|
|
|
|
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
|
|
COMPOSE_FILE="$REPO_ROOT/etc/servers/work-server/docker-compose.yml"
|
|
PROXY_SERVICE="${WORK_SERVER_PROXY_SERVICE:-work-server}"
|
|
PROXY_CONTAINER="${WORK_SERVER_PROXY_CONTAINER:-work-server}"
|
|
BLUE_SERVICE="${WORK_SERVER_BLUE_SERVICE:-work-server-blue}"
|
|
GREEN_SERVICE="${WORK_SERVER_GREEN_SERVICE:-work-server-green}"
|
|
BLUE_CONTAINER="${WORK_SERVER_BLUE_CONTAINER:-work-server-blue}"
|
|
GREEN_CONTAINER="${WORK_SERVER_GREEN_CONTAINER:-work-server-green}"
|
|
ACTIVE_SLOT_FILE="${WORK_SERVER_ACTIVE_SLOT_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/runtime/active-slot}"
|
|
PROXY_CONFIG_FILE="${WORK_SERVER_PROXY_CONFIG_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/proxy/default.conf}"
|
|
HEALTH_ENDPOINT="${WORK_SERVER_HEALTH_ENDPOINT:-http://127.0.0.1:3100/health}"
|
|
RUNTIME_ENDPOINT="${WORK_SERVER_RUNTIME_ENDPOINT:-http://127.0.0.1:3100/api/runtime}"
|
|
RECOVERY_ENDPOINT="${WORK_SERVER_RECOVERY_ENDPOINT:-http://127.0.0.1:3100/api/runtime/recover-interrupted-chat}"
|
|
PREVIOUS_SLOT_DRAIN_TIMEOUT_SECONDS="${WORK_SERVER_PREVIOUS_SLOT_DRAIN_TIMEOUT_SECONDS:-900}"
|
|
LOCK_FILE="${WORK_SERVER_RESTART_LOCK_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/runtime/restart-in-progress.json}"
|
|
DEPLOY_STATE_FILE="${WORK_SERVER_DEPLOY_STATE_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/runtime/deployment-state.json}"
|
|
DEPLOY_FINISHED="false"
|
|
LAST_DEPLOY_ERROR=""
|
|
LAST_DEPLOY_LOG=""
|
|
PREVIOUS_ACTIVE_COUNT=""
|
|
PREVIOUS_QUEUED_COUNT=""
|
|
RECOVERED_SESSION_COUNT=""
|
|
RECOVERED_RESTARTED_COUNT=""
|
|
RECOVERED_REQUEUED_COUNT=""
|
|
|
|
cd "$REPO_ROOT"
|
|
|
|
mkdir -p "$(dirname "$ACTIVE_SLOT_FILE")" "$(dirname "$PROXY_CONFIG_FILE")" "$(dirname "$LOCK_FILE")" "$(dirname "$DEPLOY_STATE_FILE")"
|
|
write_deploy_state() {
|
|
DEPLOY_STATUS="$1"
|
|
DEPLOY_PHASE="$2"
|
|
DEPLOY_SUMMARY="$3"
|
|
DEPLOY_STEP_KEY="${4:-}"
|
|
DEPLOY_STEP_STATUS="${5:-}"
|
|
DEPLOY_STEP_DETAIL="${6:-}"
|
|
DEPLOY_LAST_ERROR="${7:-}"
|
|
DEPLOY_LOG_EXCERPT="${8:-}"
|
|
DEPLOY_ACTIVE_SLOT_VALUE="${ACTIVE_SLOT:-}"
|
|
DEPLOY_TARGET_SLOT_VALUE="${TARGET_SLOT:-}"
|
|
DEPLOY_PREVIOUS_SLOT_VALUE="${PREVIOUS_SLOT:-}"
|
|
DEPLOY_TARGET_CONTAINER_VALUE="${TARGET_CONTAINER:-}"
|
|
DEPLOY_PREVIOUS_CONTAINER_VALUE="${PREVIOUS_CONTAINER:-}"
|
|
DEPLOY_PREVIOUS_ACTIVE_COUNT_VALUE="${PREVIOUS_ACTIVE_COUNT:-}"
|
|
DEPLOY_PREVIOUS_QUEUED_COUNT_VALUE="${PREVIOUS_QUEUED_COUNT:-}"
|
|
DEPLOY_RECOVERED_SESSION_COUNT_VALUE="${RECOVERED_SESSION_COUNT:-}"
|
|
DEPLOY_RECOVERED_RESTARTED_COUNT_VALUE="${RECOVERED_RESTARTED_COUNT:-}"
|
|
DEPLOY_RECOVERED_REQUEUED_COUNT_VALUE="${RECOVERED_REQUEUED_COUNT:-}"
|
|
export \
|
|
DEPLOY_STATUS \
|
|
DEPLOY_PHASE \
|
|
DEPLOY_SUMMARY \
|
|
DEPLOY_STEP_KEY \
|
|
DEPLOY_STEP_STATUS \
|
|
DEPLOY_STEP_DETAIL \
|
|
DEPLOY_LAST_ERROR \
|
|
DEPLOY_LOG_EXCERPT \
|
|
DEPLOY_ACTIVE_SLOT_VALUE \
|
|
DEPLOY_TARGET_SLOT_VALUE \
|
|
DEPLOY_PREVIOUS_SLOT_VALUE \
|
|
DEPLOY_TARGET_CONTAINER_VALUE \
|
|
DEPLOY_PREVIOUS_CONTAINER_VALUE \
|
|
DEPLOY_PREVIOUS_ACTIVE_COUNT_VALUE \
|
|
DEPLOY_PREVIOUS_QUEUED_COUNT_VALUE \
|
|
DEPLOY_RECOVERED_SESSION_COUNT_VALUE \
|
|
DEPLOY_RECOVERED_RESTARTED_COUNT_VALUE \
|
|
DEPLOY_RECOVERED_REQUEUED_COUNT_VALUE
|
|
node - "$DEPLOY_STATE_FILE" <<'NODE'
|
|
const fs = require('fs');
|
|
const filePath = process.argv[2];
|
|
const env = process.env;
|
|
const stepKeys = [
|
|
'build-target-slot',
|
|
'verify-target-health',
|
|
'switch-proxy',
|
|
'drain-previous-slot',
|
|
'rebuild-previous-slot',
|
|
'recover-interrupted-chat',
|
|
];
|
|
const readJson = () => {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
const parseIso = (value) => {
|
|
if (!value || typeof value !== 'string') {
|
|
return null;
|
|
}
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
};
|
|
const parseSlot = (value) => (value === 'blue' || value === 'green' ? value : null);
|
|
const parseCount = (value) => {
|
|
if (value == null || value === '') {
|
|
return null;
|
|
}
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
};
|
|
const current = readJson() || {};
|
|
const shouldResetForNewRun =
|
|
env.DEPLOY_STATUS === 'running'
|
|
&& env.DEPLOY_PHASE === 'build-target-slot'
|
|
&& !env.DEPLOY_STEP_KEY;
|
|
const now = new Date().toISOString();
|
|
const stepsByKey = new Map();
|
|
for (const stepKey of stepKeys) {
|
|
const existing = !shouldResetForNewRun && Array.isArray(current.steps)
|
|
? current.steps.find((item) => item && item.key === stepKey)
|
|
: null;
|
|
stepsByKey.set(stepKey, {
|
|
key: stepKey,
|
|
status:
|
|
existing?.status === 'running' || existing?.status === 'completed' || existing?.status === 'failed'
|
|
? existing.status
|
|
: 'pending',
|
|
detail: typeof existing?.detail === 'string' ? existing.detail : null,
|
|
updatedAt: parseIso(existing?.updatedAt) || null,
|
|
});
|
|
}
|
|
if (env.DEPLOY_STEP_KEY && stepsByKey.has(env.DEPLOY_STEP_KEY)) {
|
|
const target = stepsByKey.get(env.DEPLOY_STEP_KEY);
|
|
target.status =
|
|
env.DEPLOY_STEP_STATUS === 'running'
|
|
|| env.DEPLOY_STEP_STATUS === 'completed'
|
|
|| env.DEPLOY_STEP_STATUS === 'failed'
|
|
? env.DEPLOY_STEP_STATUS
|
|
: 'pending';
|
|
target.detail = env.DEPLOY_STEP_DETAIL || target.detail || null;
|
|
target.updatedAt = now;
|
|
}
|
|
const payload = {
|
|
status:
|
|
env.DEPLOY_STATUS === 'running' || env.DEPLOY_STATUS === 'completed' || env.DEPLOY_STATUS === 'failed'
|
|
? env.DEPLOY_STATUS
|
|
: 'idle',
|
|
phase:
|
|
env.DEPLOY_PHASE === 'build-target-slot'
|
|
|| env.DEPLOY_PHASE === 'verify-target-health'
|
|
|| env.DEPLOY_PHASE === 'switch-proxy'
|
|
|| env.DEPLOY_PHASE === 'drain-previous-slot'
|
|
|| env.DEPLOY_PHASE === 'rebuild-previous-slot'
|
|
|| env.DEPLOY_PHASE === 'recover-interrupted-chat'
|
|
|| env.DEPLOY_PHASE === 'completed'
|
|
|| env.DEPLOY_PHASE === 'failed'
|
|
? env.DEPLOY_PHASE
|
|
: 'idle',
|
|
summary: env.DEPLOY_SUMMARY || (!shouldResetForNewRun ? current.summary : null) || null,
|
|
startedAt: shouldResetForNewRun ? now : parseIso(current.startedAt) || now,
|
|
updatedAt: now,
|
|
completedAt:
|
|
shouldResetForNewRun
|
|
? null
|
|
: env.DEPLOY_STATUS === 'completed' || env.DEPLOY_STATUS === 'failed'
|
|
? now
|
|
: parseIso(current.completedAt),
|
|
activeSlot: parseSlot(env.DEPLOY_ACTIVE_SLOT_VALUE) || (!shouldResetForNewRun ? parseSlot(current.activeSlot) : null),
|
|
targetSlot: parseSlot(env.DEPLOY_TARGET_SLOT_VALUE) || (!shouldResetForNewRun ? parseSlot(current.targetSlot) : null),
|
|
previousSlot: parseSlot(env.DEPLOY_PREVIOUS_SLOT_VALUE) || (!shouldResetForNewRun ? parseSlot(current.previousSlot) : null),
|
|
targetContainer: env.DEPLOY_TARGET_CONTAINER_VALUE || (!shouldResetForNewRun ? current.targetContainer : null) || null,
|
|
previousContainer: env.DEPLOY_PREVIOUS_CONTAINER_VALUE || (!shouldResetForNewRun ? current.previousContainer : null) || null,
|
|
previousSlotActiveChatRequestCount:
|
|
parseCount(env.DEPLOY_PREVIOUS_ACTIVE_COUNT_VALUE)
|
|
?? (!shouldResetForNewRun ? parseCount(current.previousSlotActiveChatRequestCount) : null),
|
|
previousSlotQueuedChatRequestCount:
|
|
parseCount(env.DEPLOY_PREVIOUS_QUEUED_COUNT_VALUE)
|
|
?? (!shouldResetForNewRun ? parseCount(current.previousSlotQueuedChatRequestCount) : null),
|
|
recoveredSessionCount:
|
|
parseCount(env.DEPLOY_RECOVERED_SESSION_COUNT_VALUE)
|
|
?? (!shouldResetForNewRun ? parseCount(current.recoveredSessionCount) : null),
|
|
recoveredRestartedCount:
|
|
parseCount(env.DEPLOY_RECOVERED_RESTARTED_COUNT_VALUE)
|
|
?? (!shouldResetForNewRun ? parseCount(current.recoveredRestartedCount) : null),
|
|
recoveredRequeuedCount:
|
|
parseCount(env.DEPLOY_RECOVERED_REQUEUED_COUNT_VALUE)
|
|
?? (!shouldResetForNewRun ? parseCount(current.recoveredRequeuedCount) : null),
|
|
lastError:
|
|
env.DEPLOY_STATUS === 'completed'
|
|
? null
|
|
: env.DEPLOY_LAST_ERROR || (!shouldResetForNewRun ? current.lastError : null) || null,
|
|
logExcerpt:
|
|
env.DEPLOY_STATUS === 'completed'
|
|
? null
|
|
: env.DEPLOY_LOG_EXCERPT || (!shouldResetForNewRun ? current.logExcerpt : null) || null,
|
|
steps: stepKeys.map((stepKey) => stepsByKey.get(stepKey)),
|
|
};
|
|
fs.writeFileSync(filePath, JSON.stringify(payload) + '\n', 'utf8');
|
|
NODE
|
|
}
|
|
cleanup_restart_lock() {
|
|
EXIT_CODE="$1"
|
|
if [ "$DEPLOY_FINISHED" != "true" ]; then
|
|
if [ "$EXIT_CODE" -ne 0 ]; then
|
|
SUMMARY="WORK-SERVER 배포가 중단되었습니다."
|
|
DETAIL="${LAST_DEPLOY_LOG:-알 수 없는 오류로 배포가 중단되었습니다.}"
|
|
ERROR_TEXT="${LAST_DEPLOY_ERROR:-$SUMMARY}"
|
|
else
|
|
SUMMARY="WORK-SERVER 배포 완료 표기 전에 스크립트가 종료되었습니다."
|
|
DETAIL="${LAST_DEPLOY_LOG:-completed 상태를 기록하기 전에 스크립트가 종료되었습니다.}"
|
|
ERROR_TEXT="${LAST_DEPLOY_ERROR:-$SUMMARY}"
|
|
fi
|
|
write_deploy_state failed failed "$SUMMARY" "" "" "" "$ERROR_TEXT" "$DETAIL"
|
|
fi
|
|
rm -f "$LOCK_FILE"
|
|
}
|
|
trap 'cleanup_restart_lock "$?"' EXIT INT TERM
|
|
|
|
read_active_slot() {
|
|
if [ -f "$ACTIVE_SLOT_FILE" ]; then
|
|
SLOT=$(tr -d '[:space:]' <"$ACTIVE_SLOT_FILE")
|
|
if [ "$SLOT" = "blue" ] || [ "$SLOT" = "green" ]; then
|
|
printf '%s' "$SLOT"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
printf 'blue'
|
|
}
|
|
|
|
container_is_running() {
|
|
CONTAINER_NAME="$1"
|
|
STATUS=$(docker inspect -f '{{.State.Status}}' "$CONTAINER_NAME" 2>/dev/null || true)
|
|
[ "$STATUS" = "running" ]
|
|
}
|
|
|
|
resolve_active_slot() {
|
|
SLOT=$(read_active_slot)
|
|
|
|
if [ "$SLOT" = "blue" ] && ! container_is_running "$BLUE_CONTAINER" && container_is_running "$GREEN_CONTAINER"; then
|
|
printf 'green'
|
|
return 0
|
|
fi
|
|
|
|
if [ "$SLOT" = "green" ] && ! container_is_running "$GREEN_CONTAINER" && container_is_running "$BLUE_CONTAINER"; then
|
|
printf 'blue'
|
|
return 0
|
|
fi
|
|
|
|
printf '%s' "$SLOT"
|
|
}
|
|
|
|
write_proxy_config() {
|
|
SLOT="$1"
|
|
TARGET_CONTAINER="$BLUE_CONTAINER"
|
|
|
|
if [ "$SLOT" = "green" ]; then
|
|
TARGET_CONTAINER="$GREEN_CONTAINER"
|
|
fi
|
|
|
|
cat >"$PROXY_CONFIG_FILE" <<EOF2
|
|
server {
|
|
listen 3100;
|
|
server_name _;
|
|
|
|
location /ws/chat {
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host \$host;
|
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
|
proxy_set_header X-Forwarded-Host \$host;
|
|
proxy_set_header X-Forwarded-Port \$server_port;
|
|
proxy_set_header Upgrade \$http_upgrade;
|
|
proxy_set_header Connection "upgrade";
|
|
proxy_pass http://$TARGET_CONTAINER:3100;
|
|
}
|
|
|
|
location / {
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host \$host;
|
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
|
proxy_set_header X-Forwarded-Host \$host;
|
|
proxy_set_header X-Forwarded-Port \$server_port;
|
|
proxy_set_header Connection "";
|
|
proxy_pass http://$TARGET_CONTAINER:3100;
|
|
}
|
|
}
|
|
EOF2
|
|
}
|
|
|
|
wait_for_container_runtime_ready() {
|
|
TARGET_CONTAINER="$1"
|
|
TARGET_SLOT="$2"
|
|
ATTEMPT=0
|
|
STABLE_SUCCESS_COUNT=0
|
|
|
|
while [ "$ATTEMPT" -lt 90 ]; do
|
|
if docker exec "$TARGET_CONTAINER" node -e "const validatePayload = (payload, expectedSlot, requireSlot) => { if (payload?.ok !== true) process.exit(1); if (requireSlot && payload?.slot !== expectedSlot) process.exit(1); if (payload?.draining === true || payload?.canAcceptNewChatRequests === false) process.exit(1); }; Promise.all([fetch(process.argv[1]), fetch(process.argv[2])]).then(async ([healthResponse, runtimeResponse]) => { if (!healthResponse.ok || !runtimeResponse.ok) process.exit(1); const healthPayload = await healthResponse.json(); const runtimePayload = await runtimeResponse.json(); validatePayload(healthPayload, process.argv[3], true); validatePayload(runtimePayload, process.argv[3], false); }).catch(() => process.exit(1));" "$HEALTH_ENDPOINT" "$RUNTIME_ENDPOINT" "$TARGET_SLOT" >/dev/null 2>&1; then
|
|
STABLE_SUCCESS_COUNT=$((STABLE_SUCCESS_COUNT + 1))
|
|
if [ "$STABLE_SUCCESS_COUNT" -ge 3 ]; then
|
|
return 0
|
|
fi
|
|
else
|
|
STABLE_SUCCESS_COUNT=0
|
|
fi
|
|
|
|
ATTEMPT=$((ATTEMPT + 1))
|
|
sleep 1
|
|
done
|
|
|
|
echo "runtime readiness check failed for $TARGET_CONTAINER slot $TARGET_SLOT" >&2
|
|
return 1
|
|
}
|
|
|
|
wait_for_proxy_slot_health() {
|
|
TARGET_SLOT="$1"
|
|
ATTEMPT=0
|
|
STABLE_SUCCESS_COUNT=0
|
|
|
|
while [ "$ATTEMPT" -lt 90 ]; do
|
|
if node -e "const validatePayload = (payload, expectedSlot, requireSlot) => { if (payload?.ok !== true) process.exit(1); if (requireSlot && payload?.slot !== expectedSlot) process.exit(1); if (payload?.draining === true || payload?.canAcceptNewChatRequests === false) process.exit(1); }; Promise.all([fetch(process.argv[1]), fetch(process.argv[2])]).then(async ([healthResponse, runtimeResponse]) => { if (!healthResponse.ok || !runtimeResponse.ok) process.exit(1); const healthPayload = await healthResponse.json(); const runtimePayload = await runtimeResponse.json(); validatePayload(healthPayload, process.argv[3], true); validatePayload(runtimePayload, process.argv[3], false); }).catch(() => process.exit(1));" "$HEALTH_ENDPOINT" "$RUNTIME_ENDPOINT" "$TARGET_SLOT" >/dev/null 2>&1; then
|
|
STABLE_SUCCESS_COUNT=$((STABLE_SUCCESS_COUNT + 1))
|
|
if [ "$STABLE_SUCCESS_COUNT" -ge 3 ]; then
|
|
return 0
|
|
fi
|
|
else
|
|
STABLE_SUCCESS_COUNT=0
|
|
fi
|
|
|
|
ATTEMPT=$((ATTEMPT + 1))
|
|
sleep 1
|
|
done
|
|
|
|
echo "proxy runtime readiness check failed for slot $TARGET_SLOT via $HEALTH_ENDPOINT" >&2
|
|
return 1
|
|
}
|
|
|
|
read_runtime_value() {
|
|
TARGET_CONTAINER="$1"
|
|
FIELD_NAME="$2"
|
|
|
|
docker exec "$TARGET_CONTAINER" node -e "fetch(process.argv[1]).then((response) => response.json()).then((payload) => { const value = payload?.[process.argv[2]]; if (typeof value === 'boolean') { process.stdout.write(value ? 'true' : 'false'); return; } if (value == null) { process.stdout.write(''); return; } process.stdout.write(String(value)); }).catch(() => process.exit(1));" "$RUNTIME_ENDPOINT" "$FIELD_NAME"
|
|
}
|
|
|
|
set_container_draining() {
|
|
TARGET_CONTAINER="$1"
|
|
DRAINING_VALUE="$2"
|
|
|
|
docker exec "$TARGET_CONTAINER" node -e "fetch(process.argv[1], { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ draining: process.argv[2] === 'true' }) }).then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1));" "${RUNTIME_ENDPOINT}/drain" "$DRAINING_VALUE"
|
|
}
|
|
|
|
recover_interrupted_chat_requests() {
|
|
TARGET_CONTAINER="$1"
|
|
|
|
docker exec "$TARGET_CONTAINER" node -e "fetch(process.argv[1], { method: 'POST' }).then(async (response) => { if (!response.ok) { process.stderr.write(await response.text()); process.exit(1); } process.stdout.write(await response.text()); }).catch((error) => { process.stderr.write(String(error)); process.exit(1); });" "$RECOVERY_ENDPOINT"
|
|
}
|
|
|
|
wait_for_previous_slot_drain() {
|
|
TARGET_CONTAINER="$1"
|
|
ELAPSED=0
|
|
|
|
while [ "$ELAPSED" -lt "$PREVIOUS_SLOT_DRAIN_TIMEOUT_SECONDS" ]; do
|
|
ACTIVE_COUNT=$(read_runtime_value "$TARGET_CONTAINER" activeChatRequestCount 2>/dev/null || printf '0')
|
|
QUEUED_COUNT=$(read_runtime_value "$TARGET_CONTAINER" queuedChatRequestCount 2>/dev/null || printf '0')
|
|
PREVIOUS_ACTIVE_COUNT="${ACTIVE_COUNT:-0}"
|
|
PREVIOUS_QUEUED_COUNT="${QUEUED_COUNT:-0}"
|
|
write_deploy_state running drain-previous-slot "이전 슬롯 요청을 새 슬롯으로 이관하는 중입니다." "drain-previous-slot" running "active ${PREVIOUS_ACTIVE_COUNT} · queued ${PREVIOUS_QUEUED_COUNT}"
|
|
|
|
if [ "${ACTIVE_COUNT:-0}" = "0" ] && [ "${QUEUED_COUNT:-0}" = "0" ]; then
|
|
return 0
|
|
fi
|
|
|
|
sleep 2
|
|
ELAPSED=$((ELAPSED + 2))
|
|
done
|
|
|
|
echo "drain timeout reached for $TARGET_CONTAINER" >&2
|
|
return 1
|
|
}
|
|
|
|
ensure_proxy_running() {
|
|
docker compose -f "$COMPOSE_FILE" up -d --no-deps "$PROXY_SERVICE" >/dev/null
|
|
docker exec "$PROXY_CONTAINER" nginx -s reload >/dev/null
|
|
}
|
|
|
|
ACTIVE_SLOT=$(resolve_active_slot)
|
|
TARGET_SLOT="green"
|
|
TARGET_SERVICE="$GREEN_SERVICE"
|
|
TARGET_CONTAINER="$GREEN_CONTAINER"
|
|
PREVIOUS_SERVICE="$BLUE_SERVICE"
|
|
PREVIOUS_CONTAINER="$BLUE_CONTAINER"
|
|
PREVIOUS_SLOT="blue"
|
|
|
|
if [ "$ACTIVE_SLOT" = "green" ]; then
|
|
TARGET_SLOT="blue"
|
|
TARGET_SERVICE="$BLUE_SERVICE"
|
|
TARGET_CONTAINER="$BLUE_CONTAINER"
|
|
PREVIOUS_SERVICE="$GREEN_SERVICE"
|
|
PREVIOUS_CONTAINER="$GREEN_CONTAINER"
|
|
PREVIOUS_SLOT="green"
|
|
fi
|
|
|
|
write_deploy_state running build-target-slot "비활성 슬롯 빌드와 기동을 시작했습니다."
|
|
|
|
if BUILD_OUTPUT=$(docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps "$TARGET_SERVICE" 2>&1); then
|
|
[ -n "$BUILD_OUTPUT" ] && printf '%s\n' "$BUILD_OUTPUT"
|
|
write_deploy_state running build-target-slot "비활성 슬롯 빌드와 기동을 완료했습니다." "build-target-slot" completed "대상 슬롯 ${TARGET_SLOT} 준비 완료"
|
|
else
|
|
BUILD_STATUS=$?
|
|
LAST_DEPLOY_ERROR="대기 슬롯 빌드에 실패했습니다."
|
|
LAST_DEPLOY_LOG="${BUILD_OUTPUT:-docker compose build failed}"
|
|
[ -n "$BUILD_OUTPUT" ] && printf '%s\n' "$BUILD_OUTPUT" >&2
|
|
write_deploy_state failed failed "대기 슬롯 빌드에 실패했습니다." "build-target-slot" failed "대상 슬롯 ${TARGET_SLOT} 빌드 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
|
exit "$BUILD_STATUS"
|
|
fi
|
|
|
|
write_deploy_state running verify-target-health "새 슬롯 API 준비 상태를 확인합니다." "verify-target-health" running "대상 컨테이너 ${TARGET_CONTAINER}"
|
|
if wait_for_container_runtime_ready "$TARGET_CONTAINER" "$TARGET_SLOT"; then
|
|
write_deploy_state running verify-target-health "새 슬롯 API 준비 상태 확인이 완료되었습니다." "verify-target-health" completed "대상 슬롯 ${TARGET_SLOT} health/runtime 정상 응답"
|
|
else
|
|
LAST_DEPLOY_ERROR="새 슬롯 API 준비 상태 확인에 실패했습니다."
|
|
LAST_DEPLOY_LOG="runtime readiness check failed for ${TARGET_CONTAINER}"
|
|
write_deploy_state failed failed "새 슬롯 API 준비 상태 확인에 실패했습니다." "verify-target-health" failed "대상 슬롯 ${TARGET_SLOT} health/runtime 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
|
exit 1
|
|
fi
|
|
|
|
write_deploy_state running switch-proxy "프록시를 새 슬롯으로 전환합니다." "switch-proxy" running "활성 ${ACTIVE_SLOT} -> 대상 ${TARGET_SLOT}"
|
|
write_proxy_config "$TARGET_SLOT"
|
|
if ensure_proxy_running && wait_for_proxy_slot_health "$TARGET_SLOT"; then
|
|
printf '%s\n' "$TARGET_SLOT" >"$ACTIVE_SLOT_FILE"
|
|
ACTIVE_SLOT="$TARGET_SLOT"
|
|
write_deploy_state running switch-proxy "프록시 전환을 완료했습니다." "switch-proxy" completed "프록시 3100 -> 대상 슬롯 ${TARGET_SLOT} 안정 응답 확인"
|
|
else
|
|
LAST_DEPLOY_ERROR="프록시 전환에 실패했습니다."
|
|
LAST_DEPLOY_LOG="nginx reload or proxy health verification failed"
|
|
write_deploy_state failed failed "프록시 전환에 실패했습니다." "switch-proxy" failed "대상 슬롯 ${TARGET_SLOT} 프록시 응답 확인 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$PREVIOUS_SERVICE" != "$TARGET_SERVICE" ]; then
|
|
set_container_draining "$PREVIOUS_CONTAINER" true
|
|
if wait_for_previous_slot_drain "$PREVIOUS_CONTAINER"; then
|
|
write_deploy_state running drain-previous-slot "이전 슬롯 요청 이관이 완료되었습니다." "drain-previous-slot" completed "active ${PREVIOUS_ACTIVE_COUNT:-0} · queued ${PREVIOUS_QUEUED_COUNT:-0}"
|
|
else
|
|
LAST_DEPLOY_ERROR="이전 슬롯 드레인 대기 시간이 초과되었습니다."
|
|
LAST_DEPLOY_LOG="drain timeout reached for ${PREVIOUS_CONTAINER}"
|
|
write_deploy_state failed failed "이전 슬롯 요청 이관이 시간 안에 끝나지 않았습니다." "drain-previous-slot" failed "active ${PREVIOUS_ACTIVE_COUNT:-0} · queued ${PREVIOUS_QUEUED_COUNT:-0}" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
|
exit 1
|
|
fi
|
|
|
|
write_deploy_state running rebuild-previous-slot "이전 슬롯을 대기 슬롯으로 복구합니다." "rebuild-previous-slot" running "대상 컨테이너 ${PREVIOUS_CONTAINER}"
|
|
if PREVIOUS_BUILD_OUTPUT=$(docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps "$PREVIOUS_SERVICE" 2>&1); then
|
|
[ -n "$PREVIOUS_BUILD_OUTPUT" ] && printf '%s\n' "$PREVIOUS_BUILD_OUTPUT"
|
|
else
|
|
PREVIOUS_BUILD_STATUS=$?
|
|
LAST_DEPLOY_ERROR="이전 슬롯 대기 복구 빌드에 실패했습니다."
|
|
LAST_DEPLOY_LOG="${PREVIOUS_BUILD_OUTPUT:-docker compose rebuild failed}"
|
|
[ -n "$PREVIOUS_BUILD_OUTPUT" ] && printf '%s\n' "$PREVIOUS_BUILD_OUTPUT" >&2
|
|
write_deploy_state failed failed "이전 슬롯 대기 복구 빌드에 실패했습니다." "rebuild-previous-slot" failed "대기 슬롯 ${PREVIOUS_SLOT} 복구 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
|
exit "$PREVIOUS_BUILD_STATUS"
|
|
fi
|
|
|
|
if wait_for_container_runtime_ready "$PREVIOUS_CONTAINER" "$PREVIOUS_SLOT"; then
|
|
write_deploy_state running rebuild-previous-slot "이전 슬롯을 대기 슬롯으로 복구했습니다." "rebuild-previous-slot" completed "대기 슬롯 ${PREVIOUS_SLOT} health/runtime 정상 응답"
|
|
else
|
|
LAST_DEPLOY_ERROR="이전 슬롯 복구 API 준비 상태 확인에 실패했습니다."
|
|
LAST_DEPLOY_LOG="runtime readiness check failed for ${PREVIOUS_CONTAINER}"
|
|
write_deploy_state failed failed "이전 슬롯 복구 API 준비 상태 확인에 실패했습니다." "rebuild-previous-slot" failed "대기 슬롯 ${PREVIOUS_SLOT} health/runtime 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
write_deploy_state running recover-interrupted-chat "중단된 채팅 요청 복구를 확인합니다." "recover-interrupted-chat" running "대상 슬롯 ${TARGET_SLOT}"
|
|
if RECOVERY_JSON=$(recover_interrupted_chat_requests "$TARGET_CONTAINER" 2>&1); then
|
|
[ -n "$RECOVERY_JSON" ] && printf '%s\n' "$RECOVERY_JSON"
|
|
RECOVERY_COUNTS=$(printf '%s' "$RECOVERY_JSON" | node -e "let raw=''; process.stdin.on('data', (chunk) => raw += chunk); process.stdin.on('end', () => { try { const parsed = JSON.parse(raw); const recovered = parsed?.recovered ?? {}; process.stdout.write([recovered.sessionCount ?? '', recovered.restartedCount ?? '', recovered.requeuedCount ?? ''].join('\t')); } catch { process.stdout.write('\t\t'); } });")
|
|
RECOVERED_SESSION_COUNT=$(printf '%s' "$RECOVERY_COUNTS" | awk -F '\t' '{print $1}')
|
|
RECOVERED_RESTARTED_COUNT=$(printf '%s' "$RECOVERY_COUNTS" | awk -F '\t' '{print $2}')
|
|
RECOVERED_REQUEUED_COUNT=$(printf '%s' "$RECOVERY_COUNTS" | awk -F '\t' '{print $3}')
|
|
write_deploy_state running recover-interrupted-chat "중단된 채팅 요청 복구 확인이 완료되었습니다." "recover-interrupted-chat" completed "session ${RECOVERED_SESSION_COUNT:-0} · restarted ${RECOVERED_RESTARTED_COUNT:-0} · requeued ${RECOVERED_REQUEUED_COUNT:-0}"
|
|
else
|
|
RECOVERY_STATUS=$?
|
|
LAST_DEPLOY_ERROR="중단된 채팅 요청 복구 확인에 실패했습니다."
|
|
LAST_DEPLOY_LOG="${RECOVERY_JSON:-recover interrupted chat failed}"
|
|
[ -n "$RECOVERY_JSON" ] && printf '%s\n' "$RECOVERY_JSON" >&2
|
|
write_deploy_state failed failed "중단된 채팅 요청 복구 확인에 실패했습니다." "recover-interrupted-chat" failed "대상 슬롯 ${TARGET_SLOT}" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
|
exit "$RECOVERY_STATUS"
|
|
fi
|
|
|
|
DEPLOY_FINISHED="true"
|
|
write_deploy_state completed completed "WORK-SERVER 무중단 배포를 완료했습니다."
|
|
printf 'work-server zero-downtime switch completed: %s -> %s\n' "$PREVIOUS_SLOT" "$TARGET_SLOT"
|