From 42cc0cb8acd6e081ec5a44d1c4d077bfadc099e5 Mon Sep 17 00:00:00 2001 From: Jung Do Hyun Date: Sat, 24 Oct 2026 14:17:34 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=20=EB=AA=A9?= =?UTF-8?q?=EC=A0=81=EB=B3=84=20GPU=20=EC=88=98=EB=AA=85=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=EA=B3=BC=20=EB=B0=B0=EC=B9=98=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit interactive(기본, 유휴 30분)/batch(tkf run, 작업 후 GPU 0)/online(유휴 종료 없음) 모드를 resolveUsagePolicy()로 CDK 컨텍스트와 CLI에서 같은 규칙으로 해석한다. 스택 출력 UsageMode/IdleMinutes/DeploymentSettings로 기존 스택의 보안 설정을 보존한 채 갱신한다. Refs #43 Co-Authored-By: Claude Opus 5.5 --- cli/aws.ts | 6 + cli/commands/run.ts | 199 +++++++++++++++ cli/commands/seed.ts | 2 +- cli/commands/status.ts | 3 + cli/commands/up.ts | 104 ++++++-- cli/lock.ts | 46 ++++ cli/program.ts | 95 +++++++- jest.config.js | 2 + lib/deployment-settings.ts | 31 +++ lib/token-forge-stack.ts | 32 ++- lib/usage-mode.ts | 112 +++++++++ scripts/verify-usage-mode-synth.ts | 90 +++++++ test/cli-aws.test.ts | 7 + test/cli-program.test.ts | 46 ++++ test/cli-run.test.ts | 374 +++++++++++++++++++++++++++++ test/cli-usage-mode.test.ts | 205 ++++++++++++++++ test/token-forge-stack.test.ts | 70 ++++++ test/usage-mode.test.ts | 61 +++++ 18 files changed, 1455 insertions(+), 30 deletions(-) create mode 100644 cli/commands/run.ts create mode 100644 cli/lock.ts create mode 100644 lib/deployment-settings.ts create mode 100644 lib/usage-mode.ts create mode 100644 scripts/verify-usage-mode-synth.ts create mode 100644 test/cli-run.test.ts create mode 100644 test/cli-usage-mode.test.ts create mode 100644 test/usage-mode.test.ts diff --git a/cli/aws.ts b/cli/aws.ts index 0cab13d..e376d46 100644 --- a/cli/aws.ts +++ b/cli/aws.ts @@ -52,6 +52,12 @@ export class AwsApi { } } + /** MinSize·Desired 동시 설정 — online READY 후 min=1 고정에 사용 (해제는 setDesired(0)) */ + async setCapacity(asgName: string, min: number, desired: number): Promise { + await this.asg.send(new UpdateAutoScalingGroupCommand( + { AutoScalingGroupName: asgName, MinSize: min, DesiredCapacity: desired })); + } + async getAsgStatus(asgName: string): Promise<{ desired: number; instanceIds: string[]; inServiceIds: string[] }> { const out = await this.asg.send(new DescribeAutoScalingGroupsCommand( { AutoScalingGroupNames: [asgName] })); diff --git a/cli/commands/run.ts b/cli/commands/run.ts new file mode 100644 index 0000000..18628e2 --- /dev/null +++ b/cli/commands/run.ts @@ -0,0 +1,199 @@ +import { stackNameFor } from '../../lib/naming'; +import { UsagePolicy } from '../../lib/usage-mode'; +import { TfState } from '../state'; +import { ensureStackReady, waitReady, UpDeps } from './up'; + +export interface JobResult { code: number | null; signal: NodeJS.Signals | null } +/** kill은 자식의 프로세스 그룹 전체에 보낸다. 자식이 끝난 뒤 호출되면 아무것도 하지 않는다 */ +export interface JobHandle { done: Promise; kill: (sig: NodeJS.Signals) => void } + +export interface RunOpts { + model: string; profile: string; region: string; + policy: UsagePolicy; + command: string[]; // [cmd, ...args] — 셸을 거치지 않고 배열 그대로 실행 + bootTimeoutMs: number; // 스팟 확보·부팅 대기 상한 + jobTimeoutMs: number; // 작업 실행 상한 (0 = 무제한) + killGraceMs: number; // SIGTERM 후 SIGKILL까지 유예 +} + +export interface RunDeps extends Pick { + loadState: () => TfState | null; + /** 같은 호스트 동시 실행 잠금 — 보유 중이면 throw, 반환값은 해제 함수 */ + acquireLock: (key: string) => () => void; + spawnJob: (cmd: string, args: string[], env: Record) => JobHandle; + /** SIGINT/SIGTERM/SIGHUP 핸들러 등록 — 반환값은 해제 함수 */ + onSignal: (fn: (sig: NodeJS.Signals) => void) => () => void; + probe: (url: string) => Promise; + every: (ms: number, fn: () => void) => () => void; + after: (ms: number, fn: () => void) => () => void; +} + +export interface RunResult { exitCode: number; cleanup: 'ok' | 'failed' | 'not-needed'; message: string } + +const HEARTBEAT_MS = 60_000; // 유휴 알람 평가 주기(5분)보다 충분히 짧게 +const SIGNAL_EXIT: Record = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }; + +class Cancelled extends Error { + constructor(readonly sig: NodeJS.Signals) { super(`취소됨(${sig})`); } +} + +/** + * batch 모드 작업 래퍼 (#43): 전용 스택에서 GPU를 확보 → READY → 자식 프로세스 실행 → + * 성공·실패·취소·시간 제한 어느 경우든 GPU 용량을 0으로 되돌린다. + * - 작업 중에는 하트비트가 엔드포인트를 핑해 요청 기반 유휴 종료가 발화하지 않게 한다. + * 하트비트가 끊기면(SIGKILL·호스트 유실) 스택의 백스톱 유휴 알람이 GPU를 내릴 수 있다(보장 아님 — README 한계 참고). + * - 이미 GPU가 있거나 요청된 스택(다른 세션·online)은 인수하지도 종료하지도 않는다. + * 검사는 배포 전(ensureStackReady)과 desired를 올리기 직전 두 번 한다. 다른 호스트와의 동시 시작은 막지 못한다. + * - 정리 실패는 작업 결과와 함께 보고하며 성공(0)으로 끝내지 않는다. + */ +export async function runBatch(opts: RunOpts, d: RunDeps): Promise { + if (opts.policy.mode !== 'batch') throw new Error('tkf run은 batch 모드 전용입니다'); + if (opts.command.length === 0 || !opts.command[0]) throw new Error('실행할 명령이 없습니다 — tkf run --region -- [args...]'); + const stackName = stackNameFor(opts.model, opts.profile); + const release = d.acquireLock(`${stackName}@${opts.region}`); + let unSignal = () => {}; + try { + let cancelled: NodeJS.Signals | null = null; + let child: JobHandle | null = null; + let stopSignalKill = () => {}; + const abortWaiters = new Set<() => void>(); + unSignal = d.onSignal((sig) => { + if (cancelled) { d.log(`${sig} 재수신 — GPU 정리 중입니다. 강제 종료 시 tkf down --region ${opts.region}로 정리하세요`); return; } + cancelled = sig; + d.log(`${sig} 수신 — 작업을 중단하고 GPU를 정리합니다`); + if (child) { + const c = child; + c.kill('SIGTERM'); + stopSignalKill = d.after(opts.killGraceMs, () => c.kill('SIGKILL')); + } + abortWaiters.forEach((w) => w()); + }); + // 부팅 대기 중 신호를 받으면 waitReady의 대기를 즉시 깨워 중단한다 + const abortableSleep = (ms: number) => new Promise((resolve, reject) => { + if (cancelled) { reject(new Cancelled(cancelled)); return; } + let settled = false; + const wake = () => { if (!settled) { settled = true; abortWaiters.delete(wake); reject(new Cancelled(cancelled!)); } }; + abortWaiters.add(wake); + d.sleep(ms).then(() => { + if (settled) return; + settled = true; abortWaiters.delete(wake); + if (cancelled) reject(new Cancelled(cancelled)); else resolve(); + }, (e) => { if (!settled) { settled = true; abortWaiters.delete(wake); reject(e); } }); + }); + + const prevState = d.loadState(); + const restoreState = (): string => { + if (!prevState) return ''; + try { d.saveState(prevState); return ''; } + catch (e) { return ` (참고: 이전 state.json 복원 실패 — ${(e as Error).message}. GPU 정리 결과와는 무관)`; } + }; + + // ① 소유권 검사 + 모드 반영(필요 시 cdk deploy). 거부·배포·시딩 실패는 GPU를 올리기 전이라 정리할 것이 없다 + let ready: { outputs: Record }; + try { + ready = await ensureStackReady(opts, d); + } catch (e) { + const note = restoreState(); + if (note) d.log(note.trim()); + throw e; + } + + let asgName: string | null = null; // 설정되면 이 실행이 desired를 올린 소유자 → 정리 책임 + let exitCode = 1; + let outcome = ''; + try { + if (cancelled) throw new Cancelled(cancelled); + const name = await d.api.getAsgName(stackName); + // ② desired를 올리기 직전 재검사 — 배포·시딩(수 분) 동안 다른 세션이 GPU를 올렸으면 인수하지 않는다 + const st = await d.api.getAsgStatus(name); + if (st.desired > 0 || st.instanceIds.length > 0) { + throw new Error(`준비 중 다른 세션이 ${stackName} @ ${opts.region}의 GPU를 올렸습니다(desired=${st.desired}) — 인수하거나 종료하지 않습니다`); + } + asgName = name; + const { endpoint } = await waitReady({ stackName, outputs: ready.outputs, policy: opts.policy }, + { ...d, sleep: abortableSleep, timeoutMs: opts.bootTimeoutMs }); + if (cancelled) throw new Cancelled(cancelled); + + const key = await d.api.getSecret(ready.outputs.ApiKeySecretArn); + if (cancelled) throw new Cancelled(cancelled); // 키 조회 중 취소 — 자식 명령을 시작하지 않는다 + const env = { + TKF_ENDPOINT: endpoint, TKF_MODEL: ready.outputs.WeightsRepo, TKF_API_KEY: key, + OPENAI_BASE_URL: `${endpoint}/v1`, OPENAI_API_KEY: key, + }; + d.log(`작업 실행: ${JSON.stringify(opts.command)} — env TKF_ENDPOINT=${endpoint} TKF_MODEL=${env.TKF_MODEL} ` + + 'OPENAI_BASE_URL, TKF_API_KEY/OPENAI_API_KEY=(숨김)'); + + let timedOut = false; + let stopHeartbeat = () => {}; + let stopTimeout = () => {}; + let stopTimeoutKill = () => {}; + let result: JobResult | { spawnError: Error }; + try { + stopHeartbeat = d.every(HEARTBEAT_MS, () => { d.probe(`${endpoint}/v1/models`).catch(() => 0); }); + child = d.spawnJob(opts.command[0], opts.command.slice(1), env); + const c = child; + if (opts.jobTimeoutMs > 0) { + stopTimeout = d.after(opts.jobTimeoutMs, () => { + timedOut = true; + d.log(`작업 시간 제한(${Math.round(opts.jobTimeoutMs / 60000)}분) 초과 — SIGTERM`); + c.kill('SIGTERM'); + stopTimeoutKill = d.after(opts.killGraceMs, () => c.kill('SIGKILL')); + }); + } + if (cancelled) { // spawn 직전에 받은 신호 + c.kill('SIGTERM'); + stopSignalKill = d.after(opts.killGraceMs, () => c.kill('SIGKILL')); + } + result = await c.done; + } catch (e) { + result = { spawnError: e as Error }; + } finally { + // 작업이 끝나면 예약된 모든 타이머를 취소한다 — 끝난 프로세스에 늦은 신호를 보내거나 CLI를 붙잡지 않게 + stopHeartbeat(); stopTimeout(); stopTimeoutKill(); stopSignalKill(); + child = null; + } + + if ('spawnError' in result) { + exitCode = 127; outcome = `작업 실행 실패(spawn): ${result.spawnError.message}`; + } else if (timedOut) { + exitCode = 124; outcome = `작업 시간 제한(${Math.round(opts.jobTimeoutMs / 60000)}분) 초과로 중단`; + } else if (cancelled) { + exitCode = SIGNAL_EXIT[cancelled] ?? 130; outcome = `작업 취소됨(${cancelled})`; + } else if (result.code === 0) { + exitCode = 0; outcome = '작업 성공(코드 0)'; + } else if (result.code !== null) { + exitCode = result.code; outcome = `작업 실패(코드 ${result.code})`; + } else { + exitCode = 1; outcome = `작업이 신호로 종료됨(${result.signal})`; + } + } catch (e) { + if (e instanceof Cancelled) { exitCode = SIGNAL_EXIT[e.sig] ?? 130; outcome = `작업 시작 전 취소됨(${e.sig})`; } + else { exitCode = 1; outcome = `작업 시작 전 실패: ${(e as Error).message}`; } + } + + // ③ 정리 — 이 실행이 desired를 올린 경우에만. 호출 성공에 더해 desired=0을 다시 읽어 확인한다. + // desired=0 확인은 종료 요청이 받아들여졌다는 뜻이며, 인스턴스 종료·과금 중단 시점까지 확인한 것은 아니다. + let cleanup: RunResult['cleanup'] = 'not-needed'; + let cleanupMsg = ''; + if (asgName) { + try { + await d.api.setDesired(asgName, 0); + const st = await d.api.getAsgStatus(asgName); + if (st.desired !== 0) throw new Error(`정리 후에도 desired=${st.desired}`); + cleanup = 'ok'; + cleanupMsg = 'GPU 용량을 0으로 되돌렸습니다(min/desired=0, 인스턴스 종료는 수 분 걸릴 수 있음 — tkf status로 확인)'; + } catch (e) { + cleanup = 'failed'; + cleanupMsg = `GPU 정리 실패: ${(e as Error).message} — desired=1이 남아 있을 수 있습니다. ` + + `즉시 tkf down --region ${opts.region} 로 정지하세요`; + if (exitCode === 0) exitCode = 1; + } + } + // 정리 성공 시에만 이전 상태 복원(다른 세션의 tkf down 대상 보존). 실패 시 run 리전 state를 남겨 tkf down이 찾게 한다 + const note = cleanup === 'failed' ? '' : restoreState(); + return { exitCode, cleanup, message: (cleanupMsg ? `${outcome}. ${cleanupMsg}` : outcome) + note }; + } finally { + unSignal(); + release(); + } +} diff --git a/cli/commands/seed.ts b/cli/commands/seed.ts index 0ba2610..c27ad7d 100644 --- a/cli/commands/seed.ts +++ b/cli/commands/seed.ts @@ -24,7 +24,7 @@ export interface SeedDeps { export function mkSeedEnsure( model: string, profile: string, - mkDeps: (region: string) => Pick, + mkDeps: (region: string) => Pick, ): (region: string) => Promise { return async (region: string) => { await ensureStackReady({ model, profile, region }, { ...mkDeps(region), saveState: () => {} }); diff --git a/cli/commands/status.ts b/cli/commands/status.ts index ac0d97c..825fbe8 100644 --- a/cli/commands/status.ts +++ b/cli/commands/status.ts @@ -9,6 +9,9 @@ export async function runStatus({ api, state, probe }: Deps): Promise const lines = [`대상: ${state.model}/${state.profile} @ ${state.region} (${stackName})`]; const outputs = await api.getStackOutputs(stackName); if (!outputs) return [...lines, '스택 없음 — tkf up으로 생성하세요']; + lines.push(outputs.UsageMode + ? `모드: ${outputs.UsageMode} (유휴 종료 ${outputs.IdleMinutes === '0' ? '없음' : `${outputs.IdleMinutes}분`})` + : '모드: 기록 없음(구버전 스택 — 다음 tkf up에서 정책 갱신 배포)'); const asgName = await api.getAsgName(stackName); const { desired, instanceIds } = await api.getAsgStatus(asgName); diff --git a/cli/commands/up.ts b/cli/commands/up.ts index d1b4e0c..d5b5049 100644 --- a/cli/commands/up.ts +++ b/cli/commands/up.ts @@ -4,35 +4,89 @@ import { TfState } from '../state'; import { TfConfig } from '../config'; import { Candidate, rank } from '../placement/engine'; import { RaceEntrant } from './race'; +import { UsagePolicy, resolveUsagePolicy, policyContextArgs, policyMatchesOutputs, describePolicy, DEFAULT_IDLE_MINUTES } from '../../lib/usage-mode'; +import { DeploymentSettings, parseSettings, settingsContextArgs } from '../../lib/deployment-settings'; -export interface UpOpts { model: string; profile: string; region: string } +/** policy 미지정 = seed 경로(기존 스택 정책을 건드리지 않음). 신규 생성 시에는 기본(interactive) 정책 */ +export interface UpOpts { model: string; profile: string; region: string; policy?: UsagePolicy } export interface UpDeps { - api: Pick; + api: Pick; exec: (cmd: string, args: string[]) => Promise; probeAuth: (url: string, key: string) => Promise; sleep: (ms: number) => Promise; saveState: (s: TfState) => void; log: (msg: string) => void; timeoutMs: number; // 기본 30분 (스펙 R8) + /** 같은 호스트 tkf run 잠금 보유 pid — up/seed가 준비 중(desired=0)인 batch를 인수하지 않게 확인 (run 자신은 미주입) */ + batchLockHolder?: (key: string) => string | null; } export async function ensureStackReady( opts: UpOpts, - d: Pick, + d: Pick, ): Promise<{ outputs: Record; stackName: string }> { const stackName = stackNameFor(opts.model, opts.profile); + const holder = d.batchLockHolder?.(`${stackName}@${opts.region}`) ?? null; + if (holder) { + throw new Error(`${stackName} @ ${opts.region}은(는) 이 호스트의 tkf run(pid ${holder})이 사용 중입니다 — 작업이 끝난 뒤 다시 시도하세요`); + } + + // CLI 경로는 항상 minCapacity=0으로 배포한다 (스택 생성·갱신을 스팟 확보와 분리, online의 min=1은 READY 후 적용) + const baseArgs = (policy: UsagePolicy) => ['-c', `model=${opts.model}`, '-c', `profile=${opts.profile}`, + '-c', `region=${opts.region}`, '-c', 'minCapacity=0', ...policyContextArgs(policy)]; + const deploy = async (policy: UsagePolicy, settings?: DeploymentSettings) => { + const code = await d.exec('npx', ['cdk', 'deploy', ...baseArgs(policy), + ...(settings ? settingsContextArgs(settings) : []), '--require-approval', 'never']); + if (code !== 0) throw new Error('cdk deploy 실패 — 위 출력을 확인하세요'); + const out = await d.api.getStackOutputs(stackName); + if (!out) throw new Error('배포 후에도 스택 출력을 읽을 수 없습니다'); + return out; + }; // ① 스택 보장 (GPU 0대로 생성 — 스펙 R7 선시딩 워크플로) let outputs = await d.api.getStackOutputs(stackName); if (!outputs) { d.log(`스택 생성 중: ${stackName} @ ${opts.region} (GPU 0대, 약 5분)`); - const code = await d.exec('npx', ['cdk', 'deploy', - '-c', `model=${opts.model}`, '-c', `profile=${opts.profile}`, - '-c', `region=${opts.region}`, '-c', 'minCapacity=0', - '--require-approval', 'never']); - if (code !== 0) throw new Error('cdk deploy 실패 — 위 출력을 확인하세요'); - outputs = await d.api.getStackOutputs(stackName); - if (!outputs) throw new Error('배포 후에도 스택 출력을 읽을 수 없습니다'); + outputs = await deploy(opts.policy ?? resolveUsagePolicy()); + } else if (opts.policy) { + const policy = opts.policy; + // 소유권: batch 작업이 쓰는 GPU를 다른 모드가 인수하거나, batch가 남의 GPU를 인수하지 않는다 + if (policy.mode === 'batch' || outputs.UsageMode === 'batch') { + const st = await d.api.getAsgStatus(await d.api.getAsgName(stackName)); + // Pending·Terminating 인스턴스도 남아 있으면 활성으로 본다 (desired=0이라도 직전 실행의 잔존일 수 있음) + if (st.desired > 0 || st.instanceIds.length > 0) { + throw new Error(policy.mode === 'batch' + ? `${stackName} @ ${opts.region}에서 GPU가 이미 사용 중입니다(desired=${st.desired}) — 다른 세션/온라인 인스턴스를 인수하거나 종료하지 않습니다. ` + + `tkf status로 확인하고, 본인이 남긴 고아 GPU라면 tkf down --region ${opts.region} 후 다시 실행하세요` + : `${stackName} @ ${opts.region}에서 배치 작업(tkf run)이 GPU를 사용 중입니다 — 작업이 끝난 뒤 다시 시도하세요 ` + + `(중단된 작업의 잔존 GPU라면 tkf down --region ${opts.region})`); + } + } + const legacyDefault = !outputs.UsageMode && policy.mode === 'interactive' && policy.idleMinutes === DEFAULT_IDLE_MINUTES; + if (legacyDefault) { + // 구버전 스택(모드 기록 없음) + 기본 요청: 이전 CLI처럼 그대로 사용한다. 정책·설정을 모르는 채 자동 배포하면 + // 최초 배포 때 쓴 allowedCidrs 등을 잃을 수 있다. + d.log('이 스택에는 사용 모드 기록이 없습니다(구버전) — 기존 설정 그대로 사용합니다. 모드를 바꾸려면 README의 마이그레이션 절 참고'); + } else if (!policyMatchesOutputs(policy, outputs)) { + const cur = outputs.UsageMode ? `${outputs.UsageMode}/${outputs.IdleMinutes}분` : '기록 없음(구버전 스택)'; + let settings: DeploymentSettings; + try { + settings = parseSettings(outputs.DeploymentSettings); + } catch (e) { + throw new Error(`${(e as Error).message} — 기존 스택의 allowedCidrs/azs/alertEmail을 알 수 없어 자동 갱신하지 않습니다. ` + + `최초 배포 때 쓴 컨텍스트를 그대로 붙여 수동 갱신하세요: npx cdk deploy ${baseArgs(policy).join(' ')} ` + + '[-c allowedCidrs=... -c azs=... -c alertEmail=... (사용했다면)]'); + } + d.log(`사용 모드 반영: ${cur} → ${describePolicy(policy)} — 기존 스택 cdk deploy 갱신 (기존 allowedCidrs/azs/alertEmail 유지)`); + outputs = await deploy(policy, settings); + if (outputs.DeploymentSettings !== JSON.stringify(settings)) { + throw new Error(`배포 후 DeploymentSettings가 바뀌었습니다(${outputs.DeploymentSettings ?? '없음'}) — 보안·운영 설정을 확인하세요`); + } + } + } + const skipVerify = opts.policy && !outputs.UsageMode && opts.policy.mode === 'interactive' && opts.policy.idleMinutes === DEFAULT_IDLE_MINUTES; + if (opts.policy && !skipVerify && !policyMatchesOutputs(opts.policy, outputs)) { + throw new Error(`배포 후 스택 정책(${outputs.UsageMode ?? '-'}/${outputs.IdleMinutes ?? '-'})이 요청(${opts.policy.mode}/${opts.policy.idleMinutes})과 다릅니다 — cdk 출력을 확인하세요`); } // 스택 보장 직후 상태 저장 — 이후 단계에서 실패해도 tkf down이 대상을 찾을 수 있도록 (비용 가드) d.saveState({ model: opts.model, profile: opts.profile, region: opts.region }); @@ -49,10 +103,11 @@ export async function ensureStackReady( } export async function waitReady( - args: { stackName: string; outputs: Record }, + args: { stackName: string; outputs: Record; policy?: UsagePolicy }, d: Pick, ): Promise<{ endpoint: string }> { const { stackName, outputs } = args; + const timeoutMin = Math.round(d.timeoutMs / 60000); // ③ 기동 + ④ READY 대기 (상시 프로브가 유휴 알람 발화를 막고, 강등 시 자동 복구) const asgName = await d.api.getAsgName(stackName); @@ -65,6 +120,16 @@ export async function waitReady( while (Date.now() < deadline) { const code = await d.probeAuth(`${outputs.EndpointUrl}/v1/models`, key); if (code === 200) { + // online: 준비 단계는 min=0(스팟 미확보 시에도 정리 가능), READY 이후에만 min=1로 고정 + if (args.policy?.readyMinCapacity === 1) { + try { + await d.api.setCapacity(asgName, 1, 1); + } catch (e) { + throw new Error(`READY지만 online 최소 용량(min=1) 적용 실패: ${(e as Error).message} — GPU는 desired=1로 실행 중입니다. ` + + '다시 tkf up --usage-mode online을 실행하거나 tkf down으로 정지하세요'); + } + d.log('online 모드 — 최소 용량 1 고정 (유휴 종료 없음, 끄려면 tkf down)'); + } d.log(`READY — ${outputs.EndpointUrl}`); d.log('다음: tkf connect claude'); return { endpoint: outputs.EndpointUrl }; @@ -100,13 +165,14 @@ export async function waitReady( } catch (e) { restoreMsg = `용량을 0으로 되돌리는 데 실패해 desired=1이 남아있을 수 있습니다: ${(e as Error).message}`; } - throw new Error(`타임아웃(30분) — 스팟 용량 부족 가능성. ${restoreMsg} 다른 리전으로 tkf up --region 을 시도하세요`); + throw new Error(`타임아웃(${timeoutMin}분) — 스팟 용량 부족 가능성. ${restoreMsg} 다른 리전으로 tkf up --region 을 시도하세요`); } export async function runUp(opts: UpOpts, d: UpDeps): Promise<{ endpoint: string }> { + if (opts.policy?.mode === 'batch') throw new Error('batch 모드는 tkf up이 아니라 tkf run --region -- 로 실행합니다'); const { outputs, stackName } = await ensureStackReady(opts, d); - const { endpoint } = await waitReady({ stackName, outputs }, d); + const { endpoint } = await waitReady({ stackName, outputs, policy: opts.policy }, d); d.saveState({ model: opts.model, profile: opts.profile, region: opts.region }); return { endpoint }; } @@ -115,17 +181,18 @@ export interface UpAutoDeps { config: TfConfig; gather: () => Promise<{ cands: Candidate[]; notices: string[] }>; /** ensureStackReady를 리전만 바꿔 호출하는 클로저 (program.ts에서 조립) */ - ensure: (o: { model: string; profile: string; region: string }) => Promise<{ outputs: Record; stackName: string }>; + ensure: (o: { model: string; profile: string; region: string; policy?: UsagePolicy }) => Promise<{ outputs: Record; stackName: string }>; asgNameFor: (region: string, stackName: string) => Promise; race: (entrants: RaceEntrant[]) => Promise; - wait: (a: { stackName: string; outputs: Record; region: string }) => Promise<{ endpoint: string }>; + wait: (a: { stackName: string; outputs: Record; region: string; policy?: UsagePolicy }) => Promise<{ endpoint: string }>; saveState: (s: TfState) => void; loadState?: () => TfState | null; log: (m: string) => void; now: () => Date; } -export async function runUpAuto(opts: { model: string; profile: string }, d: UpAutoDeps): Promise<{ endpoint: string; region: string }> { +export async function runUpAuto(opts: { model: string; profile: string; policy?: UsagePolicy }, d: UpAutoDeps): Promise<{ endpoint: string; region: string }> { + if (opts.policy?.mode === 'batch') throw new Error('batch 모드는 tkf up이 아니라 tkf run --region -- 로 실행합니다'); const { cands, notices } = await d.gather(); notices.forEach(d.log); const ranked = rank(cands, { score: d.config.scoreTieThreshold, rttMs: d.config.rttTieThresholdMs }); @@ -136,7 +203,8 @@ export async function runUpAuto(opts: { model: string; profile: string }, d: UpA // 스택·시딩은 순차 보장 — cdk.out 충돌 방지 (설계 결정 6) const prepared: { cand: Candidate; outputs: Record; stackName: string; asgName: string }[] = []; for (const cand of top) { - const { outputs, stackName } = await d.ensure({ model: opts.model, profile: opts.profile, region: cand.region }); + // 레이스 후보·lazy 대기 스택 모두 같은 정책으로 보장 — 승자 결정 후 모드 누락이 없게 한다 + const { outputs, stackName } = await d.ensure({ model: opts.model, profile: opts.profile, region: cand.region, policy: opts.policy }); prepared.push({ cand, outputs, stackName, asgName: await d.asgNameFor(cand.region, stackName) }); } @@ -158,7 +226,7 @@ export async function runUpAuto(opts: { model: string; profile: string }, d: UpA standbyRegions, lastUsed }; d.saveState(state); - const { endpoint } = await d.wait({ stackName: winner.stackName, outputs: winner.outputs, region: winner.cand.region }); + const { endpoint } = await d.wait({ stackName: winner.stackName, outputs: winner.outputs, region: winner.cand.region, policy: opts.policy }); // lazy: 패자 스택 정리 제안 (자동 삭제 금지 — 설계 결정 4) if (d.config.standby === 'lazy') { diff --git a/cli/lock.ts b/cli/lock.ts new file mode 100644 index 0000000..4f04409 --- /dev/null +++ b/cli/lock.ts @@ -0,0 +1,46 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +function lockFile(dir: string, key: string): string { + return path.join(dir, 'locks', `${key.replace(/[^A-Za-z0-9._@-]/g, '_')}.lock`); +} + +/** + * 같은 호스트 안의 batch 실행 잠금. pid를 먼저 임시 파일에 다 쓴 뒤 link()로 잠금 경로를 만든다 — + * link는 대상이 있으면 EEXIST로 원자적으로 실패하므로, 다른 실행이 "빈 잠금 파일"을 보는 순간이 없다. + * 남은 잠금(비정상 종료 등)은 자동 회수하지 않는다: 회수의 read→rm→create 사이 경쟁으로 새 소유자 + * 잠금을 지울 수 있기 때문이다. 대신 보유 pid와 파일 경로를 알려 사용자가 확인 후 지우게 한다. + * 다른 호스트 간 잠금이 아니다 — 그 경우는 ASG 상태 검사(소유권 조건)만 적용된다. + * 반환값은 해제 함수 — 자신이 쓴 pid일 때만 지운다. + */ +export function acquireLock(dir: string, key: string, pid: number = process.pid): () => void { + const file = lockFile(dir, key); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.${pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmp, String(pid)); + try { + fs.linkSync(tmp, file); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e; + const holder = readHolder(file); + throw new Error(`${key}에 다른 tkf run(pid ${holder ?? '알 수 없음'})의 잠금이 있습니다 — 실행 중이면 끝난 뒤 다시 시도하세요. ` + + `해당 프로세스가 없음을 확인했다면 잠금 파일을 지우세요: rm ${file}`); + } finally { + fs.rmSync(tmp, { force: true }); + } + return () => { + try { if (fs.readFileSync(file, 'utf8') === String(pid)) fs.rmSync(file, { force: true }); } + catch { /* 이미 없음 */ } + }; +} + +function readHolder(file: string): string | null { + try { return fs.readFileSync(file, 'utf8').trim() || null; } catch { return null; } +} + +/** 잠금 보유 pid (없으면 null) — tkf up/seed가 같은 호스트의 준비 중인 batch를 인수하지 않게 확인 */ +export function lockHolder(dir: string, key: string): string | null { + const file = lockFile(dir, key); + if (!fs.existsSync(file)) return null; + return readHolder(file) ?? '알 수 없음'; +} diff --git a/cli/program.ts b/cli/program.ts index d13c7df..e53591c 100644 --- a/cli/program.ts +++ b/cli/program.ts @@ -22,9 +22,13 @@ import { fetchFeed } from './placement/feed'; import { getRtt, tcpConnector } from './placement/latency'; import { gatherCandidates } from './placement/engine'; import { runRace } from './commands/race'; +import { runBatch, JobHandle } from './commands/run'; +import { acquireLock, lockHolder } from './lock'; +import { resolveUsagePolicy, describePolicy } from '../lib/usage-mode'; import checkbox from '@inquirer/checkbox'; const MODELS_DIR = path.join(__dirname, '..', 'models'); +const TF_DIR = path.join(os.homedir(), '.token-forge'); export function buildProgram(): Command { const program = new Command(); @@ -63,13 +67,22 @@ export function buildProgram(): Command { .description('스팟 LLM 기동 — 리전 생략 시 배치 엔진이 자동 선정 + 병렬 레이스') .option('--profile

', '모델 프로파일 (기본: yaml 첫 프로파일)') .option('--region ', 'AWS 리전 (지정 시 해당 리전만 사용)') - .action(async (model: string, o: { profile?: string; region?: string }) => { + .option('--usage-mode ', '사용 모드 interactive|online (기본 interactive, batch는 tkf run)') + .option('--idle-minutes ', 'interactive 유휴 종료 분 (5분 배수 5-1440, 0=비활성, 기본 30)') + .action(async (model: string, o: { profile?: string; region?: string; usageMode?: string; idleMinutes?: string }) => { const profile = o.profile ?? defaultProfile(MODELS_DIR, model); - if (o.region) { // 기존 단일 리전 경로 (동작 불변) - await runUp({ model, profile, region: o.region }, { + // CLI 경로는 항상 minCapacity=0으로 배포 — online의 min=1은 READY 후 적용 + const policy = resolveUsagePolicy({ usageMode: o.usageMode, idleMinutes: o.idleMinutes, minCapacity: 0 }); + if (policy.mode === 'batch') { + console.error('batch 모드는 tkf run --region -- [args...]로 실행합니다'); process.exit(1); + } + console.log(`사용 모드: ${describePolicy(policy)}`); + if (o.region) { // 단일 리전 경로 + await runUp({ model, profile, region: o.region, policy }, { api: new AwsApi(o.region), exec: execInherit, probeAuth, sleep: (ms) => new Promise((r) => setTimeout(r, ms)), saveState, log: (m) => console.log(m), timeoutMs: 30 * 60 * 1000, + batchLockHolder: (key) => lockHolder(TF_DIR, key), }); return; } @@ -82,8 +95,9 @@ export function buildProgram(): Command { api: new AwsApi(region), exec: execInherit, probeAuth, sleep: (ms: number) => new Promise((r) => setTimeout(r, ms)), saveState, log: (m: string) => console.log(m), timeoutMs: 30 * 60 * 1000, + batchLockHolder: (key: string) => lockHolder(TF_DIR, key), }); - const r = await runUpAuto({ model, profile }, { + const r = await runUpAuto({ model, profile, policy }, { config: cfg, gather: () => gatherCandidates({ types, stackName, weightsRepo: rp.weightsRepo }, { config: cfg, fetchFeed: (u) => fetchFeed(u), apiFor: (rg) => new AwsApi(rg), @@ -97,12 +111,44 @@ export function buildProgram(): Command { sleep: (ms) => new Promise((res) => setTimeout(res, ms)), log: (m) => console.log(m), timeoutMs: 30 * 60 * 1000, }), - wait: (a) => waitReady({ stackName: a.stackName, outputs: a.outputs }, mkUpDeps(a.region)), + wait: (a) => waitReady({ stackName: a.stackName, outputs: a.outputs, policy: a.policy }, mkUpDeps(a.region)), saveState, loadState, log: (m) => console.log(m), now: () => new Date(), }); console.log(`완료 — ${r.region} / ${r.endpoint}`); }); + program.command('run ') + .description('batch 작업 실행 — GPU 기동 → 명령 실행 → 성공·실패·취소·시간 제한 후 GPU 0 (사용: tkf run --region -- [args...])') + .argument('', '실행할 명령과 인자 (-- 뒤에 배열 그대로 전달, 셸 미사용)') + .requiredOption('--region ', 'AWS 리전 (batch는 단일 리전 전용)') + .option('--profile

', '모델 프로파일 (기본: yaml 첫 프로파일)') + .option('--idle-minutes ', '하트비트 유실 시 백스톱 정지 분 (5분 배수 5-1440, 기본 30)') + .option('--boot-timeout ', '스팟 확보·부팅 대기 상한(분)', '30') + .option('--timeout ', '작업 실행 상한(분, 0=무제한)', '120') + .action(async (model: string, command: string[], o: { region: string; profile?: string; idleMinutes?: string; bootTimeout: string; timeout: string }) => { + const profile = o.profile ?? defaultProfile(MODELS_DIR, model); + const policy = resolveUsagePolicy({ usageMode: 'batch', idleMinutes: o.idleMinutes, minCapacity: 0 }); + const bootMin = parseMinutes('--boot-timeout', o.bootTimeout, 1); + const jobMin = parseMinutes('--timeout', o.timeout, 0); + const r = await runBatch({ model, profile, region: o.region, policy, command, + bootTimeoutMs: bootMin * 60000, jobTimeoutMs: jobMin * 60000, killGraceMs: 10000 }, { + api: new AwsApi(o.region), exec: execInherit, probeAuth, probe, + sleep: (ms) => new Promise((res) => setTimeout(res, ms)), + saveState, loadState, log: (m) => console.log(m), + acquireLock: (key) => acquireLock(TF_DIR, key), + spawnJob, + onSignal: (fn) => { + const sigs: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP']; + sigs.forEach((s) => process.on(s, fn)); + return () => sigs.forEach((s) => process.off(s, fn)); + }, + every: (ms, fn) => { const t = setInterval(fn, ms); fn(); return () => clearInterval(t); }, + after: (ms, fn) => { const t = setTimeout(fn, ms); return () => clearTimeout(t); }, + }); + (r.exitCode === 0 ? console.log : console.error)(r.message); + process.exitCode = r.exitCode; + }); + program.command('seed ') .description('가중치 S3 선시딩만 수행 (GPU 0대, up과 분리) — 리전 생략 시 배치 엔진 추천 + 체크박스 다중 선택') .option('--profile

', '모델 프로파일 (기본: yaml 첫 프로파일)') @@ -118,6 +164,7 @@ export function buildProgram(): Command { api: new AwsApi(region), exec: execInherit, probeAuth, sleep: (ms: number) => new Promise((r) => setTimeout(r, ms)), saveState, log: (m: string) => console.log(m), timeoutMs: 30 * 60 * 1000, + batchLockHolder: (key: string) => lockHolder(TF_DIR, key), }); const regions = await runSeed({ model, profile, region: o.region }, { gather: () => gatherCandidates({ types, stackName, weightsRepo: rp.weightsRepo }, { @@ -238,6 +285,44 @@ export function buildProgram(): Command { return program; } +/** 분 단위 옵션 — 정수만 허용 (min 이상, 7일 이하) */ +export function parseMinutes(name: string, raw: string, min: number): number { + if (!/^\d+$/.test(raw.trim()) || Number(raw) < min || Number(raw) > 10080) { + throw new Error(`${name}: ${min}-10080 범위의 정수(분)여야 합니다 (입력: ${raw})`); + } + return Number(raw); +} + +/** + * batch 작업 실행 — 셸 없이 인자 배열 그대로, API 키는 자식 환경변수로만 전달. + * 자식을 별도 프로세스 그룹(detached)으로 띄워 신호를 그룹 전체(손자 포함)에 보낸다. 자식이 끝나면 + * 그룹에 남은 손자도 SIGKILL로 정리하고, 그 뒤로는 kill이 아무것도 하지 않는다(늦은 신호·PID 재사용 방지). + * 분리된 그룹은 터미널 Ctrl-C를 직접 받지 않으므로 tkf가 받은 신호를 SIGTERM으로 전달한다. + * stdin이 TTY이면 백그라운드 그룹의 TTY 읽기(SIGTTIN 정지)를 피하려고 stdin을 닫는다. + * setsid 등으로 그룹을 벗어난 프로세스는 정리 대상이 아니다. + */ +export function spawnJob(cmd: string, args: string[], env: Record): JobHandle { + const p = spawn(cmd, args, { + stdio: [process.stdin.isTTY ? 'ignore' : 'inherit', 'inherit', 'inherit'], + env: { ...process.env, ...env }, + detached: true, + }); + let finished = false; + const killGroup = (sig: NodeJS.Signals) => { + if (p.pid === undefined) return; + try { process.kill(-p.pid, sig); } catch { /* ESRCH: 그룹 없음 */ } + }; + const done = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + p.once('error', (e) => { finished = true; reject(e); }); // ENOENT 등 실행 실패 + p.once('exit', (code, signal) => { + killGroup('SIGKILL'); // 자식이 남긴 손자 정리 — 그룹 리더가 끝난 직후 1회 + finished = true; + resolve({ code, signal }); + }); + }); + return { done, kill: (sig) => { if (!finished) killGroup(sig); } }; +} + function execInherit(cmd: string, args: string[]): Promise { return new Promise((resolve, reject) => { const p = spawn(cmd, args, { stdio: 'inherit', cwd: path.join(__dirname, '..') }); diff --git a/jest.config.js b/jest.config.js index 99fe3da..fa784fb 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,4 +3,6 @@ module.exports = { roots: ['/test'], testMatch: ['**/*.test.ts'], transform: { '^.+\\.tsx?$': 'ts-jest' }, + // tsc가 소스 옆에 만드는 *.js(gitignored)가 .ts를 가리지 않도록 .ts를 먼저 해석한다 + moduleFileExtensions: ['ts', 'tsx', 'js', 'json', 'node'], }; diff --git a/lib/deployment-settings.ts b/lib/deployment-settings.ts new file mode 100644 index 0000000..fc8465e --- /dev/null +++ b/lib/deployment-settings.ts @@ -0,0 +1,31 @@ +// 배포 설정 기록(#43) — 사용 모드와 무관한 보안·운영 컨텍스트(allowedCidrs/azs/alertEmail)를 스택 출력에 남겨, +// tkf CLI가 정책 갱신용 cdk deploy를 할 때 이 값을 그대로 다시 전달하게 한다. 누락하면 IP 허용목록이 풀리거나 +// AZ 선택·알림 구독이 초기화되므로, 기록을 모르거나 해석할 수 없으면 자동 배포하지 않는다. + +export const DEPLOYMENT_SETTINGS_VERSION = 1; +export const SETTINGS_KEYS = ['allowedCidrs', 'azs', 'alertEmail'] as const; + +export interface DeploymentSettings { v: 1; allowedCidrs: string; azs: string; alertEmail: string } + +/** 키 순서를 고정한 JSON — 출력 비교가 직렬화 순서에 흔들리지 않게 한다 */ +export function encodeSettings(s: Omit): string { + return JSON.stringify({ v: DEPLOYMENT_SETTINGS_VERSION, allowedCidrs: s.allowedCidrs, azs: s.azs, alertEmail: s.alertEmail }); +} + +/** 스택 출력 해석. 없거나 손상·미지원 버전이면 throw — 기본값으로 대체하지 않는다 */ +export function parseSettings(raw: string | undefined): DeploymentSettings { + if (raw === undefined) throw new Error('DeploymentSettings 출력 없음'); + let o: unknown; + try { o = JSON.parse(raw); } catch { throw new Error(`DeploymentSettings 출력을 해석할 수 없습니다: ${raw}`); } + const r = o as Record; + if (!r || typeof r !== 'object' || r.v !== DEPLOYMENT_SETTINGS_VERSION + || SETTINGS_KEYS.some((k) => typeof r[k] !== 'string')) { + throw new Error(`DeploymentSettings 출력 형식이 올바르지 않거나 지원하지 않는 버전입니다: ${raw}`); + } + return { v: 1, allowedCidrs: r.allowedCidrs as string, azs: r.azs as string, alertEmail: r.alertEmail as string }; +} + +/** 값이 있는 설정만 -c 인자로 (인자 배열 — 셸을 거치지 않음) */ +export function settingsContextArgs(s: DeploymentSettings): string[] { + return SETTINGS_KEYS.flatMap((k) => (s[k] ? ['-c', `${k}=${s[k]}`] : [])); +} diff --git a/lib/token-forge-stack.ts b/lib/token-forge-stack.ts index 2382895..b3b18d4 100644 --- a/lib/token-forge-stack.ts +++ b/lib/token-forge-stack.ts @@ -18,6 +18,8 @@ import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'; import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'; import { Construct } from 'constructs'; import { ResolvedProfile } from './model-profile'; +import { resolveUsagePolicy, IDLE_STEP_MINUTES } from './usage-mode'; +import { encodeSettings } from './deployment-settings'; export interface TokenForgeStackProps extends cdk.StackProps { resolvedProfile: ResolvedProfile; @@ -44,6 +46,12 @@ export class TokenForgeStack extends cdk.Stack { constructor(scope: Construct, id: string, props: TokenForgeStackProps) { super(scope, id, props); const profile = props.resolvedProfile; + // 사용 모드 정책(#43) — 잘못되거나 모드와 충돌하는 컨텍스트는 synth 단계에서 거부한다 + const policy = resolveUsagePolicy({ + usageMode: this.node.tryGetContext('usageMode'), + idleMinutes: this.node.tryGetContext('idleMinutes'), + minCapacity: this.node.tryGetContext('minCapacity'), + }); // --- 네트워크: 퍼블릭 서브넷 전 AZ (스팟 확보율 극대화), NAT 불필요 --- const vpc = new ec2.Vpc(this, 'Vpc', { @@ -183,7 +191,8 @@ export class TokenForgeStack extends cdk.Stack { capacityRebalance: true, // 중단 경고 시 선제 교체 // -c minCapacity=0: 스팟 용량 고갈 시 CFN 생성을 빈 ASG로 통과시키고 // 배포 후 desired=1로 올려 ASG가 무기한 재시도하게 함 (스펙 §6 의도) - minCapacity: Number(this.node.tryGetContext('minCapacity') ?? 1), + // batch 모드는 기본 0 (작업 래퍼가 수명 동안만 desired=1), online/interactive 기본 1 + minCapacity: policy.minCapacity, maxCapacity: 1, // 스코프: 오토스케일링 없음 healthChecks: autoscaling.HealthChecks.withAdditionalChecks({ // 콜드 부팅 실측(g6e.48xlarge, 도쿄): HF 143GB 다운로드+S3 시딩+이미지 풀+8GPU 로드로 @@ -226,8 +235,9 @@ export class TokenForgeStack extends cdk.Stack { // --- R11 TLS: CloudFront 프론트 도어 (도메인 불요 HTTPS, 설계 결정 1·3·4) --- const allowedCidrs = this.node.tryGetContext('allowedCidrs'); let ipAllowFn: cloudfront.Function | undefined; + let cidrs: string[] = []; if (allowedCidrs) { - const cidrs = String(allowedCidrs).split(',').map((c) => normalizeCidr(c)); + cidrs = String(allowedCidrs).split(',').map((c) => normalizeCidr(c)); // CloudFront Functions(cloudfront-js-2.0)에서 도는 IPv4 CIDR 매칭 — 허용목록 외 403 const fnCode = [ `var CIDRS = ${JSON.stringify(cidrs)};`, @@ -300,10 +310,11 @@ export class TokenForgeStack extends cdk.Stack { }); noCapacityAlarm.addAlarmAction(new cwactions.SnsAction(alertTopic)); - // --- 비용 절감: 유휴 자동 셧다운 (-c idleMinutes=N, 기본 30, 0이면 비활성) --- - // ALB 요청이 idleMinutes 동안 0이면 Lambda가 ASG를 0으로 내린다. + // --- 비용 절감: 유휴 자동 셧다운 (-c idleMinutes=N, 기본 30, 0이면 비활성 / online은 항상 비활성) --- + // ALB 요청이 idleMinutes 동안 0이면 Lambda가 ASG를 0으로 내린다. batch에서는 tkf run의 + // 하트비트가 끊긴 경우(SIGKILL·호스트 유실)에만 발화하는 고아 GPU 백스톱이다. // 재기동은 수동(scripts/start.sh) — 콜드부팅이 있어 자동 웨이크업은 실익 없음. - const idleMinutes = Number(this.node.tryGetContext('idleMinutes') ?? 30); + const idleMinutes = policy.idleMinutes; if (idleMinutes > 0) { const idleStopFn = new lambda.Function(this, 'IdleStopFn', { runtime: lambda.Runtime.NODEJS_20_X, @@ -344,7 +355,7 @@ exports.handler = async () => { threshold: 0, comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD, - evaluationPeriods: Math.max(1, Math.round(idleMinutes / 5)), + evaluationPeriods: idleMinutes / IDLE_STEP_MINUTES, // 해석기가 5분 배수만 허용 — 반올림 없음 treatMissingData: cloudwatch.TreatMissingData.BREACHING, // 무요청=결측 }); new events.Rule(this, 'IdleAlarmRule', { @@ -369,5 +380,14 @@ exports.handler = async () => { new cdk.CfnOutput(this, 'WeightsBucketName', { value: weightsBucket.bucketName }); // scripts/seed-weights.sh가 시딩 대상 리포를 읽는 용도 new cdk.CfnOutput(this, 'WeightsRepo', { value: profile.weightsRepo }); + // tkf CLI가 기존 스택의 정책과 요청 정책을 비교해 cdk deploy 필요 여부를 판단하는 용도 + new cdk.CfnOutput(this, 'UsageMode', { value: policy.mode }); + new cdk.CfnOutput(this, 'IdleMinutes', { value: String(policy.idleMinutes) }); + // 정책 갱신 배포가 보안·운영 컨텍스트를 잃지 않도록 실제 적용된 값을 기록 (lib/deployment-settings.ts) + new cdk.CfnOutput(this, 'DeploymentSettings', { value: encodeSettings({ + allowedCidrs: cidrs.join(','), + azs: this.node.tryGetContext('azs') ? String(this.node.tryGetContext('azs')) : '', + alertEmail: alertEmail ? String(alertEmail) : '', + }) }); } } diff --git a/lib/usage-mode.ts b/lib/usage-mode.ts new file mode 100644 index 0000000..584ac89 --- /dev/null +++ b/lib/usage-mode.ts @@ -0,0 +1,112 @@ +// 사용 모드(#43) — 모델 양자화 프로파일과 별개인 "GPU를 언제 켜고 끄는가" 정책. +// CDK 컨텍스트(-c usageMode/-c idleMinutes/-c minCapacity)와 CLI(--usage-mode/--idle-minutes)가 +// 같은 해석기를 써서 두 경로의 정책이 어긋나지 않게 한다. + +export const USAGE_MODES = ['interactive', 'batch', 'online'] as const; +export type UsageMode = typeof USAGE_MODES[number]; + +/** 유휴 알람은 ALB RequestCount 5분 주기로 평가된다 — 그 배수만 허용해 반올림으로 일찍 꺼지는 일을 막는다 */ +export const IDLE_STEP_MINUTES = 5; +/** 평가 기간 상한: 5분 × 288 = 24시간 (CloudWatch 평가 기간 총합 1일 이내) */ +export const IDLE_MAX_MINUTES = 1440; +/** 하위 호환: 기존 기본값 30분을 유지한다 (단축은 활동 감시가 생길 때까지 보류 — 계획 문서 참고) */ +export const DEFAULT_IDLE_MINUTES = 30; + +export interface UsagePolicy { + mode: UsageMode; + /** 0이면 유휴 알람 없음. batch에서는 작업 하트비트가 끊겼을 때만 발화하는 고아 GPU 백스톱 */ + idleMinutes: number; + /** CDK가 ASG에 거는 MinSize (CLI 경로는 항상 0으로 생성하고 READY 이후 별도 적용) */ + minCapacity: 0 | 1; + /** READY 이후 CLI가 적용할 MinSize — online만 1 */ + readyMinCapacity: 0 | 1; +} + +export interface UsagePolicyInput { + usageMode?: unknown; + idleMinutes?: unknown; + minCapacity?: unknown; +} + +function isUnset(v: unknown): boolean { + return v === undefined || v === null || v === ''; +} + +function parseMode(raw: unknown): UsageMode { + if (isUnset(raw)) return 'interactive'; + const s = String(raw).trim(); + if (!(USAGE_MODES as readonly string[]).includes(s)) { + throw new Error(`usageMode: 알 수 없는 값 "${s}" — ${USAGE_MODES.join('|')} 중 하나여야 합니다`); + } + return s as UsageMode; +} + +/** 정수 문자열/숫자만 허용 ("30", 30). "1e2", "12.5", "-5" 등은 거부 */ +function parseNonNegInt(name: string, raw: unknown): number { + const s = typeof raw === 'number' ? String(raw) : String(raw).trim(); + if (!/^\d+$/.test(s)) throw new Error(`${name}: 0 이상의 정수여야 합니다 (입력: "${String(raw)}")`); + return Number(s); +} + +function validateIdleWindow(mode: UsageMode, n: number): void { + if (n % IDLE_STEP_MINUTES !== 0 || n < IDLE_STEP_MINUTES || n > IDLE_MAX_MINUTES) { + throw new Error(`idleMinutes(${mode}): ${IDLE_STEP_MINUTES}의 배수이면서 ${IDLE_STEP_MINUTES}-${IDLE_MAX_MINUTES} 범위여야 합니다 ` + + `(입력: ${n}) — CloudWatch가 ${IDLE_STEP_MINUTES}분 단위로 평가하므로 반올림하지 않습니다`); + } +} + +/** + * 우선순위: 명시값 > 모드 기본값. 모드 목적과 충돌하는 명시값은 조용히 무시하지 않고 거부한다. + * - interactive: idle 기본 30, 명시 0(비활성, 기존 호환) 또는 5분 배수. minCapacity 기본 1(직접 CDK 호환). + * - batch: idle은 백스톱(기본 30, 0 거부 — 클라이언트 유실 시 GPU를 내릴 장치가 사라짐). minCapacity 1 거부. + * - online: idle 비활성(0만 허용). minCapacity 기본 1, READY 이후 min=1. + */ +export function resolveUsagePolicy(input: UsagePolicyInput = {}): UsagePolicy { + const mode = parseMode(input.usageMode); + + let idleMinutes: number; + if (isUnset(input.idleMinutes)) { + idleMinutes = mode === 'online' ? 0 : DEFAULT_IDLE_MINUTES; + } else { + idleMinutes = parseNonNegInt('idleMinutes', input.idleMinutes); + if (mode === 'online' && idleMinutes !== 0) { + throw new Error(`idleMinutes: online 모드는 유휴 종료를 쓰지 않습니다 (입력: ${idleMinutes}) — ` + + '유휴 종료가 필요하면 usageMode=interactive를 쓰세요'); + } + if (mode === 'batch' && idleMinutes === 0) { + throw new Error('idleMinutes: batch 모드에서 0은 허용하지 않습니다 — 작업 래퍼가 비정상 종료(SIGKILL·호스트 유실)됐을 때 ' + + 'GPU를 내리는 백스톱이 사라집니다'); + } + if (idleMinutes !== 0) validateIdleWindow(mode, idleMinutes); + } + + let minCapacity: 0 | 1; + if (isUnset(input.minCapacity)) { + minCapacity = mode === 'batch' ? 0 : 1; + } else { + const n = parseNonNegInt('minCapacity', input.minCapacity); + if (n > 1) throw new Error(`minCapacity: 0 또는 1이어야 합니다 (maxCapacity=1, 입력: ${n})`); + if (mode === 'batch' && n === 1) { + throw new Error('minCapacity: batch 모드는 작업 수명 동안만 GPU를 켭니다 — minCapacity=1과 함께 쓸 수 없습니다'); + } + minCapacity = n as 0 | 1; + } + + return { mode, idleMinutes, minCapacity, readyMinCapacity: mode === 'online' ? 1 : 0 }; +} + +/** CLI가 cdk deploy에 넘기는 컨텍스트 — 해석된 값을 전부 명시해 CDK 쪽 기본값에 기대지 않는다 */ +export function policyContextArgs(p: UsagePolicy): string[] { + return ['-c', `usageMode=${p.mode}`, '-c', `idleMinutes=${p.idleMinutes}`]; +} + +/** 스택 출력(UsageMode/IdleMinutes)과 요청 정책 비교. 출력이 없는 구버전 스택은 불일치로 본다 */ +export function policyMatchesOutputs(p: UsagePolicy, outputs: Record): boolean { + return outputs.UsageMode === p.mode && outputs.IdleMinutes === String(p.idleMinutes); +} + +export function describePolicy(p: UsagePolicy): string { + if (p.mode === 'online') return 'online (유휴 종료 없음, READY 후 min=1 — 끄려면 tkf down)'; + if (p.mode === 'batch') return `batch (작업 종료 시 GPU 0, 하트비트 유실 ${p.idleMinutes}분 후 백스톱 정지)`; + return p.idleMinutes === 0 ? 'interactive (유휴 종료 비활성)' : `interactive (무요청 ${p.idleMinutes}분 후 자동 정지)`; +} diff --git a/scripts/verify-usage-mode-synth.ts b/scripts/verify-usage-mode-synth.ts new file mode 100644 index 0000000..aa70e69 --- /dev/null +++ b/scripts/verify-usage-mode-synth.ts @@ -0,0 +1,90 @@ +// 사용 모드(#43) 오프라인 합성 검증 — AWS 호출 없이 cdk synth만 실행한다. +// 사용법: npx ts-node scripts/verify-usage-mode-synth.ts +// 리포지토리의 cdk.context.json(계정 AZ 캐시)을 건드리지 않도록 임시 디렉터리에 별도 cdk.json과 +// 더미 AZ 컨텍스트를 만들고, 각 케이스마다 새 출력 디렉터리를 써서 이전 템플릿 재사용을 막는다. +// cdk 호출 인자는 배열로 전달하고(셸 단어 분리 없음) 종료 코드와 템플릿 값을 케이스별로 확인한다. +// cdk.json의 app은 cdk CLI가 셸로 실행하는 문자열이므로 경로를 POSIX 작은따옴표로 인용한다(macOS/Linux 전용). +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const ROOT = path.join(__dirname, '..'); +const CDK = path.join(ROOT, 'node_modules', '.bin', 'cdk'); +const ACCOUNT = '111111111111'; +const REGION = 'us-east-2'; +const STACK = 'TokenForge-qwen3-coder-30b-fp8'; + +interface Expect { mode: string; idle: string; min: string; idleAlarms: number; periods?: number; settings?: Record } +const EMPTY_SETTINGS = { v: 1, allowedCidrs: '', azs: '', alertEmail: '' }; +/** POSIX 셸 작은따옴표 인용 — 공백·$·따옴표가 있는 경로도 한 인자로 유지 */ +const sq = (v: string) => `'${v.replace(/'/g, `'\\''`)}'`; +const OK: Array<[string[], Expect]> = [ + [[], { mode: 'interactive', idle: '30', min: '1', idleAlarms: 1, periods: 6 }], + [['-c', 'usageMode=interactive', '-c', 'minCapacity=0'], { mode: 'interactive', idle: '30', min: '0', idleAlarms: 1, periods: 6 }], + [['-c', 'idleMinutes=60'], { mode: 'interactive', idle: '60', min: '1', idleAlarms: 1, periods: 12 }], + [['-c', 'idleMinutes=0'], { mode: 'interactive', idle: '0', min: '1', idleAlarms: 0 }], // 합성만 — 배포하지 않음 + [['-c', 'usageMode=batch'], { mode: 'batch', idle: '30', min: '0', idleAlarms: 1, periods: 6 }], + [['-c', 'usageMode=online', '-c', 'minCapacity=0'], { mode: 'online', idle: '0', min: '0', idleAlarms: 0 }], + [['-c', 'usageMode=online'], { mode: 'online', idle: '0', min: '1', idleAlarms: 0 }], + [['-c', 'usageMode=online', '-c', 'minCapacity=0', '-c', 'allowedCidrs=203.0.113.7,198.51.100.0/24', '-c', `azs=${REGION}a`, + '-c', 'alertEmail=ops@example.com'], + { mode: 'online', idle: '0', min: '0', idleAlarms: 0, + settings: { v: 1, allowedCidrs: '203.0.113.7/32,198.51.100.0/24', azs: `${REGION}a`, alertEmail: 'ops@example.com' } }], +]; +const BAD: Array<[string[], RegExp]> = [ + [['-c', 'usageMode=online', '-c', 'idleMinutes=30'], /idleMinutes: online/], + [['-c', 'usageMode=batch', '-c', 'minCapacity=1'], /minCapacity: batch/], + [['-c', 'usageMode=batch', '-c', 'idleMinutes=0'], /idleMinutes: batch/], + [['-c', 'idleMinutes=12'], /idleMinutes\(interactive\)/], + [['-c', 'usageMode=bogus'], /usageMode: 알 수 없는 값/], +]; + +const work = fs.mkdtempSync(path.join(os.tmpdir(), 'tkf-synth-')); +fs.writeFileSync(path.join(work, 'cdk.json'), JSON.stringify({ + app: ['npx', '--prefix', ROOT, 'ts-node', '--project', path.join(ROOT, 'tsconfig.json'), '--prefer-ts-exts', + path.join(ROOT, 'bin', 'token-forge.ts')].map((a) => (a === 'npx' ? a : sq(a))).join(' '), +})); +fs.writeFileSync(path.join(work, 'cdk.context.json'), JSON.stringify({ + [`availability-zones:account=${ACCOUNT}:region=${REGION}`]: [`${REGION}a`, `${REGION}b`, `${REGION}c`], +})); +const env = { ...process.env, AWS_EC2_METADATA_DISABLED: 'true', CDK_DEFAULT_ACCOUNT: ACCOUNT, CDK_DEFAULT_REGION: REGION }; + +function synth(extra: string[]): { status: number | null; out: string; template?: string } { + const outDir = fs.mkdtempSync(path.join(work, 'out-')); + const r = spawnSync(CDK, ['synth', '-c', 'model=qwen3-coder-30b', '-c', 'profile=fp8', ...extra, + '--no-lookups', '-o', outDir, '--quiet'], { cwd: work, env, encoding: 'utf8' }); + const file = path.join(outDir, `${STACK}.template.json`); + return { status: r.status, out: `${r.stdout}\n${r.stderr}`, template: r.status === 0 && fs.existsSync(file) ? file : undefined }; +} + +let failures = 0; +const fail = (msg: string) => { failures += 1; console.error(`FAIL ${msg}`); }; + +for (const [args, exp] of OK) { + const label = args.join(' ') || '(기본)'; + const r = synth(args); + if (r.status !== 0 || !r.template) { fail(`${label}: exit=${r.status}\n${r.out.slice(-800)}`); continue; } + const t = JSON.parse(fs.readFileSync(r.template, 'utf8')); + const res = Object.values(t.Resources) as Array<{ Type: string; Properties: Record }>; + const asg = res.find((x) => x.Type === 'AWS::AutoScaling::AutoScalingGroup')!.Properties; + const idle = res.filter((x) => x.Type === 'AWS::CloudWatch::Alarm' && x.Properties.MetricName === 'RequestCount'); + const got = { mode: t.Outputs.UsageMode.Value, idle: t.Outputs.IdleMinutes.Value, min: asg.MinSize, max: asg.MaxSize, + idleAlarms: idle.length, periods: idle[0]?.Properties.EvaluationPeriods, + settings: t.Outputs.DeploymentSettings ? JSON.parse(t.Outputs.DeploymentSettings.Value) : undefined }; + const ok = got.mode === exp.mode && got.idle === exp.idle && got.min === exp.min && got.max === '1' && + got.idleAlarms === exp.idleAlarms && (exp.periods === undefined || got.periods === exp.periods) && + JSON.stringify(got.settings) === JSON.stringify(exp.settings ?? EMPTY_SETTINGS); + if (ok) console.log(`ok ${label} → ${JSON.stringify(got)}`); else fail(`${label}: 기대 ${JSON.stringify(exp)} / 실제 ${JSON.stringify(got)}`); +} +for (const [args, re] of BAD) { + const label = args.join(' '); + const r = synth(args); + if (r.status === 0) fail(`${label}: 거부되어야 하는데 exit=0`); + else if (!re.test(r.out)) fail(`${label}: exit=${r.status}이지만 기대 오류(${re}) 없음\n${r.out.slice(-800)}`); + else console.log(`ok ${label} → 거부(exit=${r.status})`); +} + +fs.rmSync(work, { recursive: true, force: true }); +console.log(failures === 0 ? `PASS ${OK.length + BAD.length} cases` : `FAILED ${failures} case(s)`); +process.exit(failures === 0 ? 0 : 1); diff --git a/test/cli-aws.test.ts b/test/cli-aws.test.ts index e3e7dc2..94f24b1 100644 --- a/test/cli-aws.test.ts +++ b/test/cli-aws.test.ts @@ -178,3 +178,10 @@ describe('배치 엔진용 확장 메서드', () => { expect(await new AwsApi('ap-northeast-1').getPlacementScore(['g6e.12xlarge'], 'ap-northeast-1')).toBeNull(); }); }); + +test('setCapacity — MinSize와 DesiredCapacity를 함께 설정 (online READY 후 min=1)', async () => { + asgMock.on(UpdateAutoScalingGroupCommand).resolves({}); + await new AwsApi('ap-northeast-2').setCapacity('my-asg', 1, 1); + expect(asgMock.commandCalls(UpdateAutoScalingGroupCommand)[0].args[0].input) + .toEqual({ AutoScalingGroupName: 'my-asg', MinSize: 1, DesiredCapacity: 1 }); +}); diff --git a/test/cli-program.test.ts b/test/cli-program.test.ts index 7b62504..ebad9fb 100644 --- a/test/cli-program.test.ts +++ b/test/cli-program.test.ts @@ -6,3 +6,49 @@ test('프로그램 이름은 tkf, --version 옵션이 등록돼 있다', () => { // program.version() getter는 commander 버전에 따라 동작이 달라 옵션 등록 여부로 검증 expect(program.options.some((o: any) => o.long === '--version')).toBe(true); }); + +test('run 명령: 필수 --region, -- 뒤 가변 명령 인자, 타임아웃 옵션 등록', () => { + const run = buildProgram().commands.find((c) => c.name() === 'run')!; + expect(run).toBeDefined(); + const longs = run.options.map((o: any) => o.long); + expect(longs).toEqual(expect.arrayContaining(['--region', '--profile', '--idle-minutes', '--boot-timeout', '--timeout'])); + expect(run.options.find((o: any) => o.long === '--region')!.mandatory).toBe(true); + expect((run as any).registeredArguments.map((a: any) => a.variadic)).toEqual([false, true]); +}); + +test('up 명령: --usage-mode, --idle-minutes 등록', () => { + const up = buildProgram().commands.find((c) => c.name() === 'up')!; + expect(up.options.map((o: any) => o.long)).toEqual(expect.arrayContaining(['--usage-mode', '--idle-minutes'])); +}); + +test('parseMinutes — 정수 범위만 허용', () => { + const { parseMinutes } = require('../cli/program'); + expect(parseMinutes('--timeout', '0', 0)).toBe(0); + expect(parseMinutes('--boot-timeout', '45', 1)).toBe(45); + for (const bad of ['0', '1.5', '-1', 'x', '99999']) expect(() => parseMinutes('--boot-timeout', bad, 1)).toThrow(/boot-timeout/); +}); + +test('run 파싱: -- 뒤 옵션 모양 인자도 명령 배열로 보존', async () => { + const program = buildProgram(); + const run = program.commands.find((c) => c.name() === 'run')!; + let got: unknown[] = []; + run.action((model: string, command: string[], o: { region: string; timeout: string }) => { got = [model, command, o.region, o.timeout]; }); + await program.parseAsync(['node', 'tkf', 'run', 'qwen3-coder-30b', '--region', 'ap-northeast-2', '--', 'python', '-v', '--region', 'x y']); + expect(got).toEqual(['qwen3-coder-30b', ['python', '-v', '--region', 'x y'], 'ap-northeast-2', '120']); +}); + +describe('spawnJob — 실제 자식 프로세스 (AWS 무관)', () => { + const { spawnJob } = require('../cli/program'); + test('인자 배열·env 전달, 종료 코드 보존 (셸 해석 없음)', async () => { + const j = spawnJob(process.execPath, ['-e', 'process.exit(process.argv[1] === "a b; echo x" && process.env.TKF_MODEL === "M" ? 7 : 1)', 'a b; echo x'], { TKF_MODEL: 'M' }); + expect(await j.done).toEqual({ code: 7, signal: null }); + }); + test('없는 명령은 error(ENOENT)로 reject', async () => { + await expect(spawnJob('tkf-no-such-cmd-43', [], {}).done).rejects.toThrow(/ENOENT/); + }); + test('kill(SIGTERM)으로 종료', async () => { + const j = spawnJob(process.execPath, ['-e', 'setTimeout(() => {}, 60000)'], {}); + setTimeout(() => j.kill('SIGTERM'), 100); + expect(await j.done).toEqual({ code: null, signal: 'SIGTERM' }); + }); +}); diff --git a/test/cli-run.test.ts b/test/cli-run.test.ts new file mode 100644 index 0000000..d1bbe6b --- /dev/null +++ b/test/cli-run.test.ts @@ -0,0 +1,374 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { runBatch, RunDeps, JobResult } from '../cli/commands/run'; +import { acquireLock, lockHolder } from '../cli/lock'; +import { resolveUsagePolicy } from '../lib/usage-mode'; + +const batch = resolveUsagePolicy({ usageMode: 'batch' }); +const outputs = { EndpointUrl: 'https://cdn', ApiKeySecretArn: 'arn:sec', WeightsBucketName: 'bkt', WeightsRepo: 'Org/Repo', + UsageMode: 'batch', IdleMinutes: '30' }; +const opts = { model: 'qwen3-coder-30b', profile: 'fp8', region: 'ap-northeast-2', policy: batch, + command: ['python', 'job.py', '--flag', 'a b; rm -rf /'], bootTimeoutMs: 1000, jobTimeoutMs: 0, killGraceMs: 10 }; + +interface Harness { + d: RunDeps; + logs: string[]; + desired: number[]; + spawned: Array<{ cmd: string; args: string[]; env: Record }>; + kills: string[]; + saved: unknown[]; + probes: string[]; + fireSignal: (s: NodeJS.Signals) => void; + fireTimers: () => void; + finishJob: (r: JobResult) => void; + failJob: (e: Error) => void; + released: () => boolean; + heartbeatStopped: () => boolean; +} + +function harness(o: { + asg?: { desired: number; instanceIds: string[]; inServiceIds: string[] }; + outputs?: Record | null; + probeAuth?: () => Promise; + setDesiredFail?: (n: number) => boolean; + desiredAfterCleanup?: number; + spawnThrows?: Error; + lockThrows?: Error; + onSpawn?: (h: Harness) => void; + onReadyProbe?: (h: Harness) => void; + prevState?: unknown; +} = {}): Harness { + let signalHandler: ((s: NodeJS.Signals) => void) | null = null; + let resolveJob: (r: JobResult) => void = () => undefined; + let rejectJob: (e: Error) => void = () => undefined; + const timers: Array<() => void> = []; + let released = false; let hbStopped = false; + let cleanedUp = false; + const h = { + logs: [] as string[], desired: [] as number[], spawned: [] as Harness['spawned'], kills: [] as string[], saved: [] as unknown[], + probes: [] as string[], + } as unknown as Harness; + h.fireSignal = (s) => signalHandler?.(s); + h.fireTimers = () => timers.splice(0).forEach((t) => t()); + h.finishJob = (r) => resolveJob(r); + h.failJob = (e) => rejectJob(e); + h.released = () => released; + h.heartbeatStopped = () => hbStopped; + h.d = { + api: { + getStackOutputs: async () => (o.outputs === undefined ? outputs : o.outputs), + getAsgName: async () => 'asg-1', + getAsgStatus: async () => (cleanedUp ? { desired: o.desiredAfterCleanup ?? 0, instanceIds: [], inServiceIds: [] } + : o.asg ?? { desired: 0, instanceIds: [], inServiceIds: [] }), + setDesired: async (_a: string, n: number) => { + if (o.setDesiredFail?.(n)) throw new Error(`setDesired(${n}) denied`); + h.desired.push(n); if (n === 0) cleanedUp = true; + }, + setCapacity: async () => undefined, + getSecret: async () => 'SECRET-KEY', + headObject: async () => true, + }, + exec: async () => 0, + probeAuth: async () => { const r = o.probeAuth ? await o.probeAuth() : 200; if (r === 200) o.onReadyProbe?.(h); return r; }, + probe: async (u: string) => { h.probes.push(u); return 401; }, + sleep: async () => undefined, + log: (m: string) => { h.logs.push(m); }, + saveState: (s: unknown) => { h.saved.push(s); }, + loadState: () => (o.prevState ?? null) as never, + acquireLock: () => { if (o.lockThrows) throw o.lockThrows; return () => { released = true; }; }, + spawnJob: (cmd: string, args: string[], env: Record) => { + if (o.spawnThrows) throw o.spawnThrows; + h.spawned.push({ cmd, args, env }); + const done = new Promise((res, rej) => { resolveJob = res; rejectJob = rej; }); + setImmediate(() => o.onSpawn?.(h)); + return { done, kill: (s: NodeJS.Signals) => { h.kills.push(s); } }; + }, + onSignal: (fn: (s: NodeJS.Signals) => void) => { signalHandler = fn; return () => { signalHandler = null; }; }, + every: (_ms: number, fn: () => void) => { fn(); return () => { hbStopped = true; }; }, + after: (_ms: number, fn: () => void) => { timers.push(fn); return () => { const i = timers.indexOf(fn); if (i >= 0) timers.splice(i, 1); }; }, + } as never; + return h; +} + +test('성공: 인자 배열 그대로 spawn, 키는 env로만 전달·로그 비노출, 종료 후 GPU 0', async () => { + const h = harness({ onSpawn: (x) => x.finishJob({ code: 0, signal: null }) }); + const r = await runBatch(opts, h.d); + expect(r.exitCode).toBe(0); + expect(h.spawned[0].cmd).toBe('python'); + expect(h.spawned[0].args).toEqual(['job.py', '--flag', 'a b; rm -rf /']); // 셸 결합 없음 + expect(h.spawned[0].env).toMatchObject({ TKF_ENDPOINT: 'https://cdn', TKF_API_KEY: 'SECRET-KEY', TKF_MODEL: 'Org/Repo', + OPENAI_BASE_URL: 'https://cdn/v1', OPENAI_API_KEY: 'SECRET-KEY' }); + expect(h.logs.join('\n')).not.toContain('SECRET-KEY'); + expect(r.message).not.toContain('SECRET-KEY'); + expect(h.desired).toEqual([1, 0]); + expect(h.released()).toBe(true); + expect(h.heartbeatStopped()).toBe(true); +}); + +test('작업 중 하트비트가 엔드포인트를 핑해 무요청 유휴 종료를 막는다', async () => { + const h = harness({ onSpawn: (x) => x.finishJob({ code: 0, signal: null }) }); + await runBatch(opts, h.d); + expect(h.probes).toContain('https://cdn/v1/models'); +}); + +test('자식 실패 코드 보존 + 정리', async () => { + const h = harness({ onSpawn: (x) => x.finishJob({ code: 3, signal: null }) }); + const r = await runBatch(opts, h.d); + expect(r.exitCode).toBe(3); + expect(r.cleanup).toBe('ok'); + expect(h.desired).toEqual([1, 0]); +}); + +test('spawn 오류(ENOENT 동기 throw / 비동기 error) → 127 + 정리', async () => { + const h1 = harness({ spawnThrows: Object.assign(new Error('spawn nope ENOENT'), { code: 'ENOENT' }) }); + const r1 = await runBatch(opts, h1.d); + expect(r1.exitCode).toBe(127); + expect(h1.desired).toEqual([1, 0]); + const h2 = harness({ onSpawn: (x) => x.failJob(new Error('spawn nope ENOENT')) }); + const r2 = await runBatch(opts, h2.d); + expect(r2.exitCode).toBe(127); + expect(r2.message).toContain('ENOENT'); + expect(h2.desired).toEqual([1, 0]); +}); + +test('작업 시간 제한: SIGTERM → 유예 후 SIGKILL, 124 + 정리', async () => { + const h = harness({ onSpawn: (x) => { x.fireTimers(); x.fireTimers(); x.finishJob({ code: null, signal: 'SIGKILL' }); } }); + const r = await runBatch({ ...opts, jobTimeoutMs: 60000 }, h.d); + expect(h.kills).toEqual(['SIGTERM', 'SIGKILL']); + expect(r.exitCode).toBe(124); + expect(r.message).toMatch(/시간 제한/); + expect(h.desired).toEqual([1, 0]); +}); + +test.each([['SIGINT', 130], ['SIGTERM', 143]] as const)('작업 중 %s → 자식 종료 요청 + 정리, 코드 %d', async (sig, code) => { + const h = harness({ onSpawn: (x) => { x.fireSignal(sig); x.finishJob({ code: null, signal: 'SIGTERM' }); } }); + const r = await runBatch(opts, h.d); + expect(h.kills[0]).toBe('SIGTERM'); + expect(r.exitCode).toBe(code); + expect(r.message).toContain(sig); + expect(h.desired).toEqual([1, 0]); +}); + +test('부팅 대기 중 SIGINT → 작업을 시작하지 않고 정리', async () => { + let n = 0; + const h = harness({ probeAuth: async () => { n += 1; if (n === 1) h.fireSignal('SIGINT'); return 503; } }); + const r = await runBatch(opts, h.d); + expect(h.spawned).toHaveLength(0); + expect(r.exitCode).toBe(130); + expect(h.desired[h.desired.length - 1]).toBe(0); +}); + +test('부팅 시간 제한 → 작업 미실행, 실패 코드, GPU 0', async () => { + const h = harness({ probeAuth: async () => 503 }); + const r = await runBatch({ ...opts, bootTimeoutMs: 0 }, h.d); + expect(h.spawned).toHaveLength(0); + expect(r.exitCode).not.toBe(0); + expect(r.message).toMatch(/타임아웃/); + expect(h.desired[h.desired.length - 1]).toBe(0); +}); + +test('정리 실패 시 성공으로 보고하지 않고 작업 결과와 정리 실패를 모두 보존', async () => { + const ok = harness({ onSpawn: (x) => x.finishJob({ code: 0, signal: null }), setDesiredFail: (n) => n === 0 }); + const r = await runBatch(opts, ok.d); + expect(r.exitCode).toBe(1); + expect(r.cleanup).toBe('failed'); + expect(r.message).toMatch(/작업 성공/); + expect(r.message).toMatch(/정리 실패.*denied/); + expect(r.message).toContain('tkf down --region ap-northeast-2'); + + const bad = harness({ onSpawn: (x) => x.finishJob({ code: 5, signal: null }), setDesiredFail: (n) => n === 0 }); + const r2 = await runBatch(opts, bad.d); + expect(r2.exitCode).toBe(5); + expect(r2.message).toMatch(/코드 5/); + expect(r2.message).toMatch(/정리 실패/); +}); + +test('정리 호출이 성공해도 desired가 0으로 확인되지 않으면 정리 실패', async () => { + const h = harness({ onSpawn: (x) => x.finishJob({ code: 0, signal: null }), desiredAfterCleanup: 1 }); + const r = await runBatch(opts, h.d); + expect(r.cleanup).toBe('failed'); + expect(r.exitCode).toBe(1); +}); + +test('소유권: 이미 GPU가 도는 스택(다른 세션/online)은 인수·종료하지 않는다', async () => { + const h = harness({ asg: { desired: 1, instanceIds: ['i-9'], inServiceIds: ['i-9'] }, + outputs: { ...outputs, UsageMode: 'online', IdleMinutes: '0' } }); + await expect(runBatch(opts, h.d)).rejects.toThrow(/인수하거나 종료하지 않습니다/); + expect(h.desired).toEqual([]); // setDesired 호출 없음 + expect(h.spawned).toHaveLength(0); + expect(h.released()).toBe(true); +}); + +test('동시 실행 잠금 실패 시 아무것도 건드리지 않는다', async () => { + const h = harness({ lockThrows: new Error('다른 tkf run이 실행 중') }); + await expect(runBatch(opts, h.d)).rejects.toThrow(/실행 중/); + expect(h.desired).toEqual([]); +}); + +test('batch가 아닌 정책·빈 명령은 거부', async () => { + const h = harness(); + await expect(runBatch({ ...opts, policy: resolveUsagePolicy() }, h.d)).rejects.toThrow(/batch/); + await expect(runBatch({ ...opts, command: [] }, h.d)).rejects.toThrow(/명령/); +}); + +test('정리 성공 시 이전 상태(state.json) 복원 — 다른 리전 대화형 세션의 down 대상 보존', async () => { + const prev = { model: 'm', profile: 'p', region: 'us-west-2' }; + const h = harness({ onSpawn: (x) => x.finishJob({ code: 0, signal: null }), prevState: prev }); + await runBatch(opts, h.d); + expect(h.saved[h.saved.length - 1]).toEqual(prev); +}); + +test('정리 실패 시에는 run 리전 상태를 남겨 tkf down이 찾을 수 있게 한다', async () => { + const prev = { model: 'm', profile: 'p', region: 'us-west-2' }; + const h = harness({ onSpawn: (x) => x.finishJob({ code: 0, signal: null }), prevState: prev, setDesiredFail: (n) => n === 0 }); + await runBatch(opts, h.d); + expect(h.saved[h.saved.length - 1]).toMatchObject({ region: 'ap-northeast-2' }); +}); + +describe('acquireLock — 같은 호스트 잠금 (자동 회수 없음)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tkf-lock-')); + test('보유 중이면 pid·파일 경로와 함께 거부, 해제 후 재획득', () => { + const rel = acquireLock(dir, 'S@r', 111); + expect(lockHolder(dir, 'S@r')).toBe('111'); + expect(() => acquireLock(dir, 'S@r', 222)).toThrow(/pid 111.*rm /); + rel(); + expect(lockHolder(dir, 'S@r')).toBeNull(); + acquireLock(dir, 'S@r', 222)(); + }); + test('남은(죽은 프로세스·빈) 잠금도 자동 회수하지 않는다 — 수동 해제 안내', () => { + acquireLock(dir, 'S@x', 999999); // 해제하지 않음 = 비정상 종료 흉내 + expect(() => acquireLock(dir, 'S@x', 444)).toThrow(/999999/); + fs.writeFileSync(path.join(dir, 'locks', 'S@e.lock'), ''); + expect(() => acquireLock(dir, 'S@e', 444)).toThrow(/알 수 없음/); + }); + test('다른 pid의 잠금은 해제 함수가 지우지 않는다, 임시 파일을 남기지 않는다', () => { + const rel = acquireLock(dir, 'S@y', 1); + fs.writeFileSync(path.join(dir, 'locks', 'S@y.lock'), '2'); + rel(); + expect(lockHolder(dir, 'S@y')).toBe('2'); + expect(fs.readdirSync(path.join(dir, 'locks')).filter((f) => f.endsWith('.tmp'))).toEqual([]); + }); + test('키의 경로 문자는 파일명으로 정규화', () => { + acquireLock(dir, '../evil/S@r', 1)(); + expect(fs.existsSync(path.join(dir, '..', 'evil'))).toBe(false); + }); +}); + +test('소유권: desired=0이어도 Pending/Terminating 인스턴스가 남아 있으면 거부', async () => { + const h = harness({ asg: { desired: 0, instanceIds: ['i-term'], inServiceIds: [] } }); + await expect(runBatch(opts, h.d)).rejects.toThrow(/인수하거나 종료하지 않습니다/); + expect(h.desired).toEqual([]); +}); + +test('배포 후 desired를 올리기 직전 재검사 — 그 사이 다른 세션이 올린 GPU는 건드리지 않는다', async () => { + let calls = 0; + const h = harness(); + const api = (h.d as unknown as { api: { getAsgStatus: () => Promise } }).api; + api.getAsgStatus = async () => (++calls === 1 ? { desired: 0, instanceIds: [], inServiceIds: [] } + : { desired: 1, instanceIds: ['i-other'], inServiceIds: [] }); + const r = await runBatch(opts, h.d); + expect(r.exitCode).toBe(1); + expect(r.cleanup).toBe('not-needed'); + expect(r.message).toMatch(/다른 세션/); + expect(h.desired).toEqual([]); // setDesired(1)도 정리 setDesired(0)도 없음 + expect(h.released()).toBe(true); +}); + +test('취소 신호로 예약한 SIGKILL 타이머는 작업 종료 시 취소된다 (늦은 신호 없음)', async () => { + const h = harness({ onSpawn: (x) => { x.fireSignal('SIGINT'); x.finishJob({ code: 0, signal: null }); } }); + await runBatch(opts, h.d); + h.fireTimers(); // 남은 타이머가 있다면 여기서 SIGKILL을 보낼 것 + expect(h.kills).toEqual(['SIGTERM']); +}); + +test('loadState 실패 등 잠금 이후 예외에도 잠금·신호 핸들러 해제', async () => { + const h = harness(); + let unsub = false; + (h.d as unknown as Record).loadState = () => { throw new Error('state.json 손상'); }; + const onSignal = (h.d as unknown as { onSignal: (f: unknown) => () => void }).onSignal; + (h.d as unknown as Record).onSignal = (f: unknown) => { const u = onSignal(f); return () => { unsub = true; u(); }; }; + await expect(runBatch(opts, h.d)).rejects.toThrow(/손상/); + expect(h.released()).toBe(true); + expect(unsub).toBe(true); +}); + +test('이전 상태 복원 실패는 GPU 정리 실패와 구분된다', async () => { + const prev = { model: 'm', profile: 'p', region: 'us-west-2' }; + const h = harness({ onSpawn: (x) => x.finishJob({ code: 0, signal: null }), prevState: prev }); + (h.d as unknown as Record).saveState = (s: unknown) => { if (s === prev) throw new Error('disk full'); }; + const r = await runBatch(opts, h.d); + expect(r.cleanup).toBe('ok'); + expect(r.exitCode).toBe(0); + expect(r.message).toMatch(/state\.json 복원 실패.*disk full/); +}); + +describe('실제 로컬 자식 프로세스 (AWS는 mock, spawnJob·타이머는 실물)', () => { + const { spawnJob } = require('../cli/program'); + const realTimers = { + every: (ms: number, fn: () => void) => { const t = setInterval(fn, ms); fn(); return () => clearInterval(t); }, + after: (ms: number, fn: () => void) => { const t = setTimeout(fn, ms); return () => clearTimeout(t); }, + }; + const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } }; + const waitDead = async (pid: number) => { for (let i = 0; i < 50 && alive(pid); i++) await new Promise((r) => setTimeout(r, 50)); return !alive(pid); }; + // 자식: SIGTERM을 무시하고, 손자(sleep 대용 node)를 띄운 뒤 그 pid를 파일에 남긴다 + const script = (pidFile: string) => [process.execPath, '-e', + `process.on('SIGTERM',()=>{});const c=require('child_process').spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'});` + + `require('fs').writeFileSync(${JSON.stringify(pidFile)},String(c.pid));setInterval(()=>{},1000)`]; + + function realHarness(extra: Record = {}) { + const h = harness(); + Object.assign(h.d as unknown as Record, { spawnJob, ...realTimers, ...extra }); + return h; + } + + test('시간 제한: SIGTERM 무시 자식 → 유예 후 SIGKILL, 손자까지 종료, 124 + 정리', async () => { + const pidFile = path.join(os.tmpdir(), `tkf-gc-${process.pid}-1`); + const h = realHarness(); + const r = await runBatch({ ...opts, command: script(pidFile), jobTimeoutMs: 1500, killGraceMs: 300 }, h.d); + expect(r.exitCode).toBe(124); + expect(h.desired).toEqual([1, 0]); + expect(await waitDead(Number(fs.readFileSync(pidFile, 'utf8')))).toBe(true); + }, 15000); + + test('SIGINT 취소: 실제 자식에 SIGTERM→SIGKILL, 130 + 정리', async () => { + const pidFile = path.join(os.tmpdir(), `tkf-gc-${process.pid}-2`); + let fire: (s: NodeJS.Signals) => void = () => undefined; + const h = realHarness({ onSignal: (fn: (s: NodeJS.Signals) => void) => { fire = fn; return () => undefined; } }); + const p = runBatch({ ...opts, command: script(pidFile), killGraceMs: 300 }, h.d); + for (let i = 0; i < 100 && !fs.existsSync(pidFile); i++) await new Promise((r) => setTimeout(r, 50)); + fire('SIGINT'); + const r = await p; + expect(r.exitCode).toBe(130); + expect(h.desired).toEqual([1, 0]); + expect(await waitDead(Number(fs.readFileSync(pidFile, 'utf8')))).toBe(true); + }, 15000); + + test('정상 종료한 자식이 남긴 손자도 정리, 종료 코드 보존', async () => { + const pidFile = path.join(os.tmpdir(), `tkf-gc-${process.pid}-3`); + const h = realHarness(); + const r = await runBatch({ ...opts, command: [process.execPath, '-e', + `const c=require('child_process').spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'});` + + `require('fs').writeFileSync(${JSON.stringify(pidFile)},String(c.pid));process.exit(4)`] }, h.d); + expect(r.exitCode).toBe(4); + expect(await waitDead(Number(fs.readFileSync(pidFile, 'utf8')))).toBe(true); + }, 15000); + + test('없는 명령 → 127 + 정리', async () => { + const h = realHarness(); + const r = await runBatch({ ...opts, command: ['tkf-no-such-cmd-43'] }, h.d); + expect(r.exitCode).toBe(127); + expect(h.desired).toEqual([1, 0]); + }); +}); + +test('READY 후 API 키 조회 중 SIGINT → 자식 명령을 시작하지 않고 정리', async () => { + const h = harness(); + const api = (h.d as unknown as { api: { getSecret: () => Promise } }).api; + let n = 0; + api.getSecret = async () => { n += 1; if (n === 2) h.fireSignal('SIGINT'); return 'SECRET-KEY'; }; // 1회차는 waitReady, 2회차는 runBatch + const r = await runBatch(opts, h.d); + expect(h.spawned).toHaveLength(0); + expect(r.exitCode).toBe(130); + expect(h.desired).toEqual([1, 0]); +}); diff --git a/test/cli-usage-mode.test.ts b/test/cli-usage-mode.test.ts new file mode 100644 index 0000000..03e8cd3 --- /dev/null +++ b/test/cli-usage-mode.test.ts @@ -0,0 +1,205 @@ +import { ensureStackReady, runUp, runUpAuto, waitReady } from '../cli/commands/up'; +import { runDown } from '../cli/commands/down'; +import { DEFAULT_CONFIG } from '../cli/config'; +import { resolveUsagePolicy } from '../lib/usage-mode'; + +const base = { EndpointUrl: 'http://alb', ApiKeySecretArn: 'arn:sec', WeightsBucketName: 'bkt', WeightsRepo: 'Org/Repo' }; +const EMPTY = JSON.stringify({ v: 1, allowedCidrs: '', azs: '', alertEmail: '' }); +const SECURED = JSON.stringify({ v: 1, allowedCidrs: '203.0.113.0/24,198.51.100.7/32', azs: 'us-east-1a,us-east-1b', alertEmail: 'ops@example.com' }); +const withMode = (mode: string, idle: string, ds = EMPTY) => ({ ...base, UsageMode: mode, IdleMinutes: idle, DeploymentSettings: ds }); +const opts = { model: 'qwen3-coder-30b', profile: 'fp8', region: 'ap-northeast-2' }; + +function mk(o: { outputs?: Record | null; afterDeploy?: Record; asg?: { desired: number; instanceIds: string[]; inServiceIds: string[] }; setCapacity?: () => Promise } = {}) { + const execs: string[] = []; + const caps: Array<[number, number]> = []; + const desired: number[] = []; + let deployed = false; + const d = { + api: { + getStackOutputs: async () => (deployed ? (o.afterDeploy ?? base) : o.outputs === undefined ? base : o.outputs), + getAsgName: async () => 'asg-1', + getAsgStatus: async () => o.asg ?? { desired: 0, instanceIds: [], inServiceIds: [] }, + setDesired: async (_a: string, n: number) => { desired.push(n); }, + setCapacity: o.setCapacity ?? (async (_a: string, min: number, des: number) => { caps.push([min, des]); }), + getSecret: async () => 'KEY', + headObject: async () => true, + }, + exec: async (cmd: string, args: string[]) => { execs.push([cmd, ...args].join(' ')); deployed = true; return 0; }, + probeAuth: async () => 200, + sleep: async () => undefined, + saveState: () => undefined, + log: () => undefined, + timeoutMs: 1000, + }; + return { d: d as never, execs, caps, desired }; +} + +describe('기존 스택 정책 비교 → cdk deploy 결정', () => { + test('신규 스택: minCapacity=0 + 요청 모드 컨텍스트로 생성', async () => { + const { d, execs } = mk({ outputs: null, afterDeploy: { ...base, UsageMode: 'online', IdleMinutes: '0' } }); + await ensureStackReady({ ...opts, policy: resolveUsagePolicy({ usageMode: 'online' }) }, d); + expect(execs[0]).toContain('minCapacity=0'); + expect(execs[0]).toContain('usageMode=online'); + expect(execs[0]).toContain('idleMinutes=0'); + }); + test('정책이 같으면 배포를 건너뛴다', async () => { + const { d, execs } = mk({ outputs: { ...base, UsageMode: 'interactive', IdleMinutes: '30' } }); + await ensureStackReady({ ...opts, policy: resolveUsagePolicy() }, d); + expect(execs).toEqual([]); + }); + test('기존 스택의 모드가 다르면 cdk deploy로 반영한다 (존재만 보고 건너뛰지 않음)', async () => { + const { d, execs } = mk({ outputs: withMode('interactive', '30'), afterDeploy: withMode('online', '0') }); + await ensureStackReady({ ...opts, policy: resolveUsagePolicy({ usageMode: 'online' }) }, d); + expect(execs).toHaveLength(1); + expect(execs[0]).toContain('usageMode=online'); + }); + test('idleMinutes만 달라도 반영한다', async () => { + const { d, execs } = mk({ outputs: withMode('interactive', '30'), afterDeploy: withMode('interactive', '60') }); + await ensureStackReady({ ...opts, policy: resolveUsagePolicy({ idleMinutes: 60 }) }, d); + expect(execs[0]).toContain('idleMinutes=60'); + }); + test('배포 후에도 출력이 요청 정책과 다르면 실패 (조용한 누락 방지)', async () => { + const { d } = mk({ outputs: withMode('interactive', '30'), afterDeploy: withMode('interactive', '30') }); + await expect(ensureStackReady({ ...opts, policy: resolveUsagePolicy({ usageMode: 'online' }) }, d)).rejects.toThrow(/정책/); + }); + test('policy 미지정(seed 경로)은 기존 스택을 건드리지 않는다', async () => { + const { d, execs } = mk({ outputs: base }); + await ensureStackReady(opts, d); + expect(execs).toEqual([]); + }); + test('배치 작업이 도는 스택을 대화형/온라인으로 바꾸지 않는다', async () => { + const { d, execs } = mk({ outputs: { ...base, UsageMode: 'batch', IdleMinutes: '30' }, + asg: { desired: 1, instanceIds: ['i-1'], inServiceIds: ['i-1'] } }); + await expect(ensureStackReady({ ...opts, policy: resolveUsagePolicy({ usageMode: 'online' }) }, d)).rejects.toThrow(/배치/); + expect(execs).toEqual([]); + }); +}); + +describe('정책 갱신 시 allowedCidrs·azs·alertEmail 보존', () => { + const hasCtx = (cmd: string, kv: string) => cmd.split(' ').includes(kv); + test.each([ + ['interactive→online', withMode('interactive', '30', SECURED), { usageMode: 'online' }], + ['idleMinutes 변경', withMode('interactive', '30', SECURED), { idleMinutes: 60 }], + ['online→interactive', withMode('online', '0', SECURED), {}], + ['interactive→batch', withMode('interactive', '30', SECURED), { usageMode: 'batch' }], + ])('%s: 기존 설정을 그대로 다시 전달', async (_n, outputs, input) => { + const policy = resolveUsagePolicy({ ...input, minCapacity: 0 }); + const { d, execs } = mk({ outputs, afterDeploy: { ...withMode(policy.mode, String(policy.idleMinutes), SECURED) } }); + await ensureStackReady({ ...opts, policy }, d); + expect(execs).toHaveLength(1); + for (const kv of ['allowedCidrs=203.0.113.0/24,198.51.100.7/32', 'azs=us-east-1a,us-east-1b', 'alertEmail=ops@example.com']) { + expect(hasCtx(execs[0], kv)).toBe(true); + } + }); + test('빈 설정은 컨텍스트를 추가하지 않는다 (CLI 신규 생성 스택)', async () => { + const { d, execs } = mk({ outputs: withMode('interactive', '30'), afterDeploy: withMode('online', '0') }); + await ensureStackReady({ ...opts, policy: resolveUsagePolicy({ usageMode: 'online' }) }, d); + expect(execs[0]).not.toMatch(/allowedCidrs|azs=|alertEmail/); + }); + test('배포 후 설정 출력이 달라지면 실패 (보존 실패를 성공으로 보고하지 않음)', async () => { + const { d } = mk({ outputs: withMode('interactive', '30', SECURED), afterDeploy: withMode('online', '0', EMPTY) }); + await expect(ensureStackReady({ ...opts, policy: resolveUsagePolicy({ usageMode: 'online' }) }, d)).rejects.toThrow(/설정/); + }); + test('설정 출력이 없는 스택은 자동 배포하지 않고 수동 갱신 명령을 안내', async () => { + const { d, execs } = mk({ outputs: { ...base, UsageMode: 'interactive', IdleMinutes: '30' } }); + await expect(ensureStackReady({ ...opts, policy: resolveUsagePolicy({ usageMode: 'online' }) }, d)) + .rejects.toThrow(/npx cdk deploy .*usageMode=online.*allowedCidrs/); + expect(execs).toEqual([]); + }); + test.each(['not json', JSON.stringify({ v: 2, allowedCidrs: '', azs: '', alertEmail: '' }), JSON.stringify({ v: 1, allowedCidrs: 5 })])( + '손상·미지원 설정(%s)은 기본값으로 바꾸지 않고 거부', async (ds) => { + const { d, execs } = mk({ outputs: withMode('interactive', '30', ds) }); + await expect(ensureStackReady({ ...opts, policy: resolveUsagePolicy({ usageMode: 'online' }) }, d)).rejects.toThrow(/DeploymentSettings/); + expect(execs).toEqual([]); + }); + test('구버전 스택 + 기본 interactive 요청은 배포 없이 그대로 사용 (기존 동작 호환)', async () => { + const { d, execs } = mk({ outputs: base }); + await ensureStackReady({ ...opts, policy: resolveUsagePolicy({ minCapacity: 0 }) }, d); + expect(execs).toEqual([]); + }); + test('구버전 스택 + 모드 변경 요청은 자동 배포하지 않는다', async () => { + const { d, execs } = mk({ outputs: base }); + await expect(ensureStackReady({ ...opts, policy: resolveUsagePolicy({ usageMode: 'online' }) }, d)).rejects.toThrow(/수동/); + expect(execs).toEqual([]); + }); + test('정책이 같으면 설정 출력이 없어도 배포하지 않는다', async () => { + const { d, execs } = mk({ outputs: { ...base, UsageMode: 'online', IdleMinutes: '0' } }); + await ensureStackReady({ ...opts, policy: resolveUsagePolicy({ usageMode: 'online' }) }, d); + expect(execs).toEqual([]); + }); +}); + +describe('같은 호스트 batch 준비 중 인수 방지', () => { + test('tkf run 잠금이 있으면 up/seed의 ensure는 아무것도 하지 않고 거부', async () => { + const { d, execs } = mk({ outputs: { ...base, UsageMode: 'batch', IdleMinutes: '30' } }); + (d as { batchLockHolder?: unknown }).batchLockHolder = (k: string) => (k === 'TokenForge-qwen3-coder-30b-fp8@ap-northeast-2' ? '4242' : null); + await expect(ensureStackReady({ ...opts, policy: resolveUsagePolicy() }, d)).rejects.toThrow(/pid 4242/); + await expect(ensureStackReady(opts, d)).rejects.toThrow(/pid 4242/); + expect(execs).toEqual([]); + }); + test('batch 스택에 Terminating 인스턴스가 남아 있으면 모드 변경 거부', async () => { + const { d } = mk({ outputs: { ...base, UsageMode: 'batch', IdleMinutes: '30' }, asg: { desired: 0, instanceIds: ['i-t'], inServiceIds: [] } }); + await expect(ensureStackReady({ ...opts, policy: resolveUsagePolicy() }, d)).rejects.toThrow(/배치/); + }); +}); + +describe('online READY 후 min=1, down은 min/desired=0', () => { + test('online: READY 후 setCapacity(min1, desired1)', async () => { + const { d, caps } = mk({ outputs: { ...base, UsageMode: 'online', IdleMinutes: '0' } }); + await runUp({ ...opts, policy: resolveUsagePolicy({ usageMode: 'online' }) }, d); + expect(caps).toEqual([[1, 1]]); + }); + test('interactive: min은 건드리지 않는다', async () => { + const { d, caps } = mk({ outputs: { ...base, UsageMode: 'interactive', IdleMinutes: '30' } }); + await runUp({ ...opts, policy: resolveUsagePolicy() }, d); + expect(caps).toEqual([]); + }); + test('online min=1 적용 실패는 성공으로 보고하지 않는다', async () => { + const { d } = mk({ outputs: { ...base, UsageMode: 'online', IdleMinutes: '0' }, setCapacity: async () => { throw new Error('denied'); } }); + const saved: unknown[] = []; + (d as { saveState: unknown }).saveState = (s: unknown) => { saved.push(s); }; + await expect(runUp({ ...opts, policy: resolveUsagePolicy({ usageMode: 'online' }) }, d)).rejects.toThrow(/min=1.*denied.*tkf down/); + expect(saved).toEqual([{ model: opts.model, profile: opts.profile, region: opts.region }]); // tkf down이 대상을 찾을 수 있음 + }); + test('online 준비 단계(READY 전)에는 min=1을 걸지 않는다 — 타임아웃 시 0으로 복원', async () => { + const { d, caps, desired } = mk({ outputs: { ...base, UsageMode: 'online', IdleMinutes: '0' } }); + (d as { probeAuth: unknown }).probeAuth = async () => 503; + (d as { timeoutMs: number }).timeoutMs = 0; + await expect(waitReady({ stackName: 'S', outputs: base, policy: resolveUsagePolicy({ usageMode: 'online' }) }, d)).rejects.toThrow(/타임아웃/); + expect(caps).toEqual([]); + expect(desired[desired.length - 1]).toBe(0); + }); + test('runUp에 batch 정책은 거부 (tkf run 전용)', async () => { + const { d } = mk(); + await expect(runUp({ ...opts, policy: resolveUsagePolicy({ usageMode: 'batch' }) }, d)).rejects.toThrow(/tkf run/); + }); + test('down: online 스택도 setDesired(0) (= MinSize 0, Desired 0) 로 종료', async () => { + const set: number[] = []; + await runDown(opts, false, { api: { getStackOutputs: async () => ({ ...base, UsageMode: 'online' }), getAsgName: async () => 'a', + setDesired: async (_a: string, n: number) => { set.push(n); }, emptyAndDeleteBucket: async () => undefined }, exec: async () => 0 } as never); + expect(set).toEqual([0]); + }); +}); + +describe('자동 리전 경로도 모드를 전달', () => { + test('ensure·wait 양쪽에 policy 전달 (race·lazy 공통)', async () => { + for (const standby of ['race', 'lazy', 'single'] as const) { + const ensured: unknown[] = []; const waited: unknown[] = []; + const policy = resolveUsagePolicy({ usageMode: 'online' }); + await runUpAuto({ model: 'm', profile: 'p', policy }, { + config: { ...DEFAULT_CONFIG, standby }, + gather: async () => ({ cands: [ + { region: 'r1', score: 8, scoreSource: 'feed48h', rttMs: 30, price: 2, cached: true }, + { region: 'r2', score: 7, scoreSource: 'feed48h', rttMs: 30, price: 2, cached: true }], notices: [] }), + ensure: async (o: { policy?: unknown }) => { ensured.push(o.policy); return { outputs: { EndpointUrl: 'e' }, stackName: 'S' }; }, + asgNameFor: async (r: string) => `asg-${r}`, + race: async (e: unknown[]) => e[0], + wait: async (a: { policy?: unknown }) => { waited.push(a.policy); return { endpoint: 'e' }; }, + saveState: () => undefined, log: () => undefined, now: () => new Date(), + } as never); + expect(ensured.length).toBeGreaterThan(0); + expect(ensured.every((p) => p === policy)).toBe(true); + expect(waited).toEqual([policy]); + } + }); +}); diff --git a/test/token-forge-stack.test.ts b/test/token-forge-stack.test.ts index 5cfcb32..cd773ad 100644 --- a/test/token-forge-stack.test.ts +++ b/test/token-forge-stack.test.ts @@ -393,3 +393,73 @@ describe('R11: allowedCidrs 소스 IP 허용목록', () => { expect(code).toContain('203.0.113.7/32'); }); }); + +describe('usage modes (#43)', () => { + function tpl(context: Record): Template { + return makeTemplateWithContext(new cdk.App({ context })); + } + function idleAlarms(t: Template) { + return Object.values(t.findResources('AWS::CloudWatch::Alarm')) + .filter((r) => (r as { Properties: { MetricName?: string } }).Properties.MetricName === 'RequestCount'); + } + + test('생략 시 interactive — 유휴 알람 30분, ASG min 1, 출력에 모드 기록', () => { + const t = makeTemplate(); + expect(idleAlarms(t)).toHaveLength(1); + t.hasResourceProperties('AWS::AutoScaling::AutoScalingGroup', { MinSize: '1', MaxSize: '1' }); + t.hasOutput('UsageMode', { Value: 'interactive' }); + t.hasOutput('IdleMinutes', { Value: '30' }); + }); + + test('-c idleMinutes=60 → 12 평가 주기 (5분 배수 그대로)', () => { + tpl({ idleMinutes: '60' }).hasResourceProperties('AWS::CloudWatch::Alarm', { MetricName: 'RequestCount', EvaluationPeriods: 12 }); + }); + + test('5분 배수가 아닌 idleMinutes는 반올림하지 않고 synth 실패', () => { + expect(() => tpl({ idleMinutes: '12' })).toThrow(/idleMinutes/); + }); + + test('online — 유휴 알람·Lambda 없음, 출력 IdleMinutes=0', () => { + const t = tpl({ usageMode: 'online', minCapacity: 0 }); + expect(idleAlarms(t)).toHaveLength(0); + expect(Object.keys(t.findResources('AWS::Lambda::Function', { + Properties: { Environment: { Variables: { ASG_NAME: Match.anyValue() } } }, + }))).toHaveLength(0); + t.hasResourceProperties('AWS::AutoScaling::AutoScalingGroup', { MinSize: '0' }); + t.hasOutput('UsageMode', { Value: 'online' }); + t.hasOutput('IdleMinutes', { Value: '0' }); + }); + + test('online + idleMinutes>0은 synth 실패', () => { + expect(() => tpl({ usageMode: 'online', idleMinutes: '30' })).toThrow(/online/); + }); + + test('batch — 백스톱 유휴 알람 유지, ASG 기본 min 0', () => { + const t = tpl({ usageMode: 'batch' }); + expect(idleAlarms(t)).toHaveLength(1); + t.hasResourceProperties('AWS::AutoScaling::AutoScalingGroup', { MinSize: '0' }); + t.hasOutput('UsageMode', { Value: 'batch' }); + }); + + test('batch + minCapacity=1, 잘못된 모드는 synth 실패', () => { + expect(() => tpl({ usageMode: 'batch', minCapacity: '1' })).toThrow(/batch/); + expect(() => tpl({ usageMode: 'fp8' })).toThrow(/usageMode/); + }); + + test('모드 변경은 Launch Template(user-data)을 바꾸지 않는다 — 기존 스택 갱신 시 인스턴스 교체 유발 없음', () => { + const lt = (t: Template) => JSON.stringify(t.findResources('AWS::EC2::LaunchTemplate')); + expect(lt(tpl({ usageMode: 'online', minCapacity: 0 }))).toBe(lt(tpl({ usageMode: 'interactive', minCapacity: 0 }))); + }); +}); + +describe('DeploymentSettings 출력 (#43 — 정책 갱신 시 보안·운영 컨텍스트 보존)', () => { + const settings = (t: Template) => JSON.parse((Object.values(t.findOutputs('DeploymentSettings'))[0] as { Value: string }).Value); + test('기본: 빈 설정을 버전과 함께 기록', () => { + expect(settings(makeTemplate())).toEqual({ v: 1, allowedCidrs: '', azs: '', alertEmail: '' }); + }); + test('allowedCidrs(정규화)·azs·alertEmail 기록', () => { + const t = makeTemplateWithContext(new cdk.App({ context: { + allowedCidrs: '203.0.113.7, 198.51.100.0/24', azs: 'dummy1a', alertEmail: 'ops@example.com' } })); + expect(settings(t)).toEqual({ v: 1, allowedCidrs: '203.0.113.7/32,198.51.100.0/24', azs: 'dummy1a', alertEmail: 'ops@example.com' }); + }); +}); diff --git a/test/usage-mode.test.ts b/test/usage-mode.test.ts new file mode 100644 index 0000000..2d564e3 --- /dev/null +++ b/test/usage-mode.test.ts @@ -0,0 +1,61 @@ +import { resolveUsagePolicy, policyContextArgs, policyMatchesOutputs } from '../lib/usage-mode'; + +describe('resolveUsagePolicy — 모드 기본값', () => { + test('생략 시 interactive, 유휴 30분, min 1 (기존 동작 호환)', () => { + expect(resolveUsagePolicy()).toEqual({ mode: 'interactive', idleMinutes: 30, minCapacity: 1, readyMinCapacity: 0 }); + expect(resolveUsagePolicy({ usageMode: '' })).toMatchObject({ mode: 'interactive', idleMinutes: 30 }); + }); + test('batch: 백스톱 30분, min 0, READY 후에도 min 0', () => { + expect(resolveUsagePolicy({ usageMode: 'batch' })).toEqual({ mode: 'batch', idleMinutes: 30, minCapacity: 0, readyMinCapacity: 0 }); + }); + test('online: 유휴 종료 없음, READY 후 min 1', () => { + expect(resolveUsagePolicy({ usageMode: 'online' })).toEqual({ mode: 'online', idleMinutes: 0, minCapacity: 1, readyMinCapacity: 1 }); + }); + test('알 수 없는 모드는 거부', () => { + expect(() => resolveUsagePolicy({ usageMode: 'int4' })).toThrow(/usageMode/); + }); +}); + +describe('resolveUsagePolicy — 명시값 우선순위와 검증', () => { + test('명시 idleMinutes가 모드 기본값보다 우선 (문자열 컨텍스트도 해석)', () => { + expect(resolveUsagePolicy({ idleMinutes: '60' }).idleMinutes).toBe(60); + expect(resolveUsagePolicy({ usageMode: 'batch', idleMinutes: 120 }).idleMinutes).toBe(120); + }); + test('interactive의 idleMinutes=0은 기존처럼 비활성', () => { + expect(resolveUsagePolicy({ idleMinutes: 0 }).idleMinutes).toBe(0); + expect(resolveUsagePolicy({ idleMinutes: '0' }).idleMinutes).toBe(0); + }); + test.each(['12', 3, '7.5', '-5', 'abc', '1e2', 1445, '2000'])('5분 배수·5-1440 밖 또는 비정수 %p는 거부 (반올림 금지)', (v) => { + expect(() => resolveUsagePolicy({ idleMinutes: v })).toThrow(/idleMinutes/); + }); + test('online에 유휴 종료를 명시하면 거부, 0은 허용', () => { + expect(() => resolveUsagePolicy({ usageMode: 'online', idleMinutes: 30 })).toThrow(/online/); + expect(resolveUsagePolicy({ usageMode: 'online', idleMinutes: 0 }).idleMinutes).toBe(0); + }); + test('batch에 idleMinutes=0(백스톱 해제)은 거부', () => { + expect(() => resolveUsagePolicy({ usageMode: 'batch', idleMinutes: 0 })).toThrow(/batch/); + }); + test('batch에 minCapacity=1은 거부, minCapacity는 0/1만', () => { + expect(() => resolveUsagePolicy({ usageMode: 'batch', minCapacity: 1 })).toThrow(/batch/); + expect(() => resolveUsagePolicy({ minCapacity: 2 })).toThrow(/minCapacity/); + expect(resolveUsagePolicy({ usageMode: 'online', minCapacity: '0' }).minCapacity).toBe(0); + }); +}); + +describe('CLI ↔ CDK 정책 일치', () => { + test.each([ + [{}], [{ usageMode: 'batch' }], [{ usageMode: 'online' }], [{ idleMinutes: 60 }], [{ idleMinutes: 0 }], + ])('policyContextArgs를 다시 해석하면 같은 정책 (%p)', (input) => { + const p = resolveUsagePolicy({ ...input, minCapacity: 0 }); + const args = policyContextArgs(p); + const ctx: Record = {}; + for (let i = 0; i < args.length; i += 2) { const [k, v] = args[i + 1].split('='); ctx[k] = v; } + expect(resolveUsagePolicy({ ...ctx, minCapacity: 0 })).toEqual(p); + }); + test('출력 비교 — 같으면 true, 다르거나 구버전(출력 없음)이면 false', () => { + const p = resolveUsagePolicy({ usageMode: 'online' }); + expect(policyMatchesOutputs(p, { UsageMode: 'online', IdleMinutes: '0' })).toBe(true); + expect(policyMatchesOutputs(p, { UsageMode: 'interactive', IdleMinutes: '30' })).toBe(false); + expect(policyMatchesOutputs(resolveUsagePolicy(), {})).toBe(false); + }); +}); From 0a584591531386c39663bca10659b70385026015 Mon Sep 17 00:00:00 2001 From: Jung Do Hyun Date: Sat, 24 Oct 2026 14:17:34 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20=EC=82=AC=EC=9A=A9=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=EB=B3=84=20=EB=B9=84=EC=9A=A9=C2=B7=EC=9D=B4=EA=B4=80?= =?UTF-8?q?=C2=B7=EB=B0=B0=ED=8F=AC=20=EA=B2=80=EC=A6=9D=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #43 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 27 +- README.ja.md | 118 +++++++- README.ko.md | 111 ++++++- README.md | 133 ++++++++- .../plans/2026-10-24-usage-modes.md | 281 ++++++++++++++++++ docs/value-proposition.md | 17 +- 6 files changed, 641 insertions(+), 46 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-24-usage-modes.md diff --git a/CLAUDE.md b/CLAUDE.md index 71f63dd..44a0e2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ npm run build # tsc 타입 체크 # synth/배포 — 컨텍스트 파라미터가 인터페이스의 전부 npx cdk synth -c model=solar-open2-250b -c profile=int4 --quiet npx cdk deploy -c model= -c profile= -c region= \ - [-c azs=us-east-1a,us-east-1b] [-c minCapacity=0] [-c idleMinutes=60] [-c alertEmail=...] + [-c azs=us-east-1a,us-east-1b] [-c minCapacity=0] [-c idleMinutes=60] [-c usageMode=interactive|batch|online] [-c alertEmail=...] npx cdk deploy -c collector=1 # 스팟 배치점수 수집기 (별도 스택, 상시 가동) npx cdk destroy -c collector=1 # 수집기 삭제 (수집 이력도 함께 삭제됨) @@ -27,6 +27,11 @@ npx cdk destroy -c collector=1 # 수집기 삭제 (수집 이력도 scripts/start.sh # desired=1 (기동) scripts/stop.sh # min=0, desired=0 (GPU 비용 정지) scripts/smoke-test.sh # /v1/models + /v1/chat/completions 검증 + +# tkf CLI 사용 모드 +tkf up [--usage-mode online] [--idle-minutes 30] +tkf run --region [--timeout 120] -- [args...] # batch: 작업 후 GPU 0 +npx ts-node scripts/verify-usage-mode-synth.ts # 사용 모드 오프라인 합성 검증 (AWS 호출 없음) ``` ## 아키텍처 핵심 @@ -48,7 +53,20 @@ capacity-optimized) + `capacityRebalance`. 프로파일의 `instanceType`은 콤 배정이 장기간 거부될 수 있음 — `docs/ec2-quota-guide.md` 참고). **비용 가드**: `idleMinutes`(기본 30) 동안 ALB 요청 0이면 Lambda가 ASG를 0으로 내린다 -(`-c idleMinutes=0`으로 비활성). p5.48xlarge는 시간당 $30-50이므로 이 가드를 끄지 말 것. +(`-c idleMinutes=0`으로 비활성). p5.48xlarge는 시간당 $30-50이므로 interactive·batch에서 이 가드를 +끄지 말 것. online 모드(유휴 종료 없음)는 사용자가 비용(24시간 × 단가)을 알고 명시적으로 고르는 경우에만 +쓰고, 작업·데모 기본값으로 쓰지 않는다. + +**사용 모드(#43)**: `lib/usage-mode.ts`의 `resolveUsagePolicy()`가 CDK 컨텍스트와 CLI 옵션을 같은 +규칙으로 해석한다. interactive(기본, 유휴 30분) / batch(`tkf run`, 작업 중 1분 하트비트로 유휴 알람 +억제, 알람은 SIGKILL·호스트 유실용 백스톱) / online(유휴 알람 없음, READY 후 CLI가 min=1). idleMinutes는 +5의 배수(5-1440) 또는 0만 허용하며 모드와 충돌하는 값은 synth에서 거부한다. 스택 출력 +`UsageMode`/`IdleMinutes`를 CLI가 요청 정책과 비교해 다르면 기존 스택도 `cdk deploy`로 갱신한다 +(`ensureStackReady`). 이 자동 갱신은 스택 출력 `DeploymentSettings`(v1 JSON: allowedCidrs/azs/alertEmail)를 그대로 다시 전달하며, +기록이 없거나 손상되면 자동 배포하지 않는다(구버전 스택 + 기본 interactive 요청은 배포 없이 사용). 스택에 새 컨텍스트를 +추가하면 `lib/deployment-settings.ts`에도 넣을 것. batch는 desired>0이거나 인스턴스가 남은 스택을 인수·종료하지 않고(배포 전·기동 직전 2회 검사), +같은 호스트의 `~/.token-forge/locks/` 잠금(자동 회수 없음)이 있으면 up/seed도 거부한다. 호스트 간 잠금은 없다. 모드 관련 출력·컨텍스트를 +바꾸면 `lib/usage-mode.ts`, 스택, `cli/commands/up.ts`, 관련 테스트를 함께 수정할 것. **새 모델 추가** = `models/.yaml` 1개 (스키마: 최상위 `model`/`vllmImage`/`profiles`, 프로파일별 `weightsRepo`/`instanceType`/`vllmFlags`/`maxModelLen` — 파서가 누락·빈 문자열 거부). @@ -58,7 +76,8 @@ solar-open2-250b는 Upstage 포크 vLLM 이미지 필수, `maxModelLen` 131072 - CDK 단위 테스트 환경의 AZ는 `dummy1a`/`dummy1b`로 합성된다 — `-c azs=` 관련 테스트는 실제 AZ 이름이 아닌 dummy 이름으로 작성. -- 스테일 컴파일 산출물(`*.js`/`*.d.ts`, gitignored)이 ts-jest에서 `.ts`를 가릴 수 있다 — - 테스트가 설명 불가하게 실패하면 먼저 삭제. +- 스테일 컴파일 산출물(`*.js`/`*.d.ts`, gitignored)이 ts-jest에서 `.ts`를 가릴 수 있었다 — + jest.config.js의 `moduleFileExtensions`를 `.ts` 우선으로 바꿔 막았지만, 테스트가 설명 불가하게 + 실패하면 여전히 먼저 삭제해 볼 것. - 스택 테스트는 `makeTemplate()` 헬퍼(solar int4 프로파일 기준)를 재사용하고, 컨텍스트가 필요한 케이스만 별도 `cdk.App({ context })`로 만든다. diff --git a/README.ja.md b/README.ja.md index f7f23b0..7aebebd 100644 --- a/README.ja.md +++ b/README.ja.md @@ -12,7 +12,7 @@ OpenAI・Anthropic互換APIを提供し、プロンプト・レスポンス・ | モデル | クラス | インスタンス | 備考 | |---|---|---|---| -| [Qwen3-Coder-30B](https://huggingface.co/Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8) | 30B MoE | g6e.12xlarge(約$2.6/hスポット) | 推奨デフォルト — 月額$100-200目標の基準 | +| [Qwen3-Coder-30B](https://huggingface.co/Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8) | 30B MoE | g6e.12xlarge(約$2.6/hスポット) | 推奨デフォルト — コスト例で使う時間単価の基準 | | [GLM-4.6](https://huggingface.co/zai-org/GLM-4.6-FP8) | 355B MoE | p5.48xlarge | 大規模向けの選択肢 | | [Solar-Open2-250B](https://huggingface.co/upstage/Solar-Open2-250B) | 250B MoE | p5.48xlarge / g6e.48xlarge | 専用vLLMフォークが必要 | @@ -23,10 +23,13 @@ OpenAI・Anthropic互換APIを提供し、プロンプト・レスポンス・ 3つの軸が核心であり、すべて実デプロイでの実測に裏付けられている — 根拠と数値は **[コスト・セキュリティ・利便性の詳細](docs/value-proposition.md)**(韓国語)を参照。 -- **コスト — 使った時間分だけ**: 100%スポット(オンデマンドの約3分の1から4分の1) + - 30分アイドルでの自動停止 + 各失敗経路でGPUを0に戻すコストガード + 重みは低コストの - CPUスポットで事前シーディング(実測8分/$0.03)。30Bクラスで**月$100-200**、使わない月は - GPU料金0。 +- **コスト — 起動・アイドルを含むGPU稼働時間分**: 100%スポット(オンデマンドの約3分の1から + 4分の1) + GPUの停止時点を決める利用モード(interactive: リクエストなし30分、batch: ジョブ直後、 + online: 停止なし) + 各失敗経路でGPUを0に戻すコストガード + 重みは低コストのCPUスポットで + 事前シーディング(実測8分/$0.03)。30Bクラスの月$100-200という目標は、利用時間が限られた + interactive・batch利用でのみ成り立つ。24時間稼働するonlineエンドポイントは$2.60/h換算で月約$1,872。 + GPUを起動しない月のGPU料金は0だが、ALBとS3キャッシュは`tkf down --purge`まで課金される。 + [利用モードと実際のコスト](#利用モードと実際のコスト)を参照。 - **セキュリティ — 持ち出さないことがアーキテクチャ**: 推論・プロンプト・利用統計はすべて 自分のアカウント内で完結し、テレメトリなし(外部通信はHFダウンロード・AWS API・フィードへの 匿名GETの3種類のみ — フィードすら自前の収集器に置き換え可能)。通信はCloudFront TLSが @@ -49,7 +52,7 @@ OpenAI・Anthropic互換APIを提供し、プロンプト・レスポンス・ 場合。推論・プロンプト・使用量統計まで自分のAWSアカウント内で完結し、全体がOSSなので 監査可能。このグループにとって代替手段は商用APIではなく「何もない」である。 2. **利用上限に縛られたヘビーパワーユーザー** — エージェントのファンアウトで1日100M+ - トークンを消費する場合。時間定額(月約$100-200)はトークン無制限であり、この規模から + トークンを消費する場合。トークンではなくGPU時間に課金されるため、GPU稼働中はトークン無制限であり、この規模から 同一モデルAPIと同等、商用ファーストパーティAPI比では数倍安くなる。128Kコンテキストの 定額利用もティア課金APIに対する構造的な利点。 3. **バッチワークロード運用者** — 夜間の大量コード分析・生成のようにGPUを埋めて使う @@ -80,7 +83,7 @@ flowchart LR ASG --> EC2["EC2 GPU
DLAMI + Docker(vLLM)
/v1/chat/completions + /v1/messages"] EC2 <-- "重みキャッシュのロード/シーディング" --> S3[("S3バケット
(Retain)")] SM["Secrets Manager
(APIキー)"] -.-> EC2 - IDLE["アイドル監視Lambda
30分間リクエストなし → 自動停止"] -.-> ASG + IDLE["アイドル監視Lambda
interactive: 30分間リクエストなし → 自動停止
(onlineはなし、batchはバックストップ)"] -.-> ASG end ``` @@ -113,8 +116,8 @@ robocoが常時運用している**GPUスポット確保可能性(プレイス **[EC2クォータ引き上げ申請ガイド](docs/ec2-quota-guide.md)**(韓国語)のアピール文の 書き方を参考にすること。 - コスト目安(スポット、リージョン・時期により変動): g6e.12xlargeが約**$2.6/hr**、 - g6e.48xlargeが約**$10-13/hr**、p5.48xlargeが約**$30-50/hr**。アイドル自動停止が - デフォルトで有効だが、長期間使わない場合は`cdk destroy`を推奨。 + g6e.48xlargeが約**$10-13/hr**、p5.48xlargeが約**$30-50/hr**。デフォルトのinteractive + モードではアイドル自動停止が有効だが、長期間使わない場合は`cdk destroy`を推奨。 - Node 20以上、ブートストラップ済みアカウント(`cdk bootstrap`)。AWS CDK CLIは パッケージの依存関係に含まれているため個別インストールは不要(ソースから インストールする場合のみ別途必要)。 @@ -132,6 +135,8 @@ tkf placement qwen3-coder-30b # リージョン推奨テーブル( tkf seed qwen3-coder-30b # 重みのS3事前シーディングのみ(GPU 0台、リージョン選択プロンプト) tkf up qwen3-coder-30b # リージョン自動選定 + 並行レース起動(R10) tkf up qwen3-coder-30b --region ap-northeast-2 # リージョンを直接指定 +tkf up qwen3-coder-30b --usage-mode online # online: アイドル停止なし、READY後は最小容量1 +tkf run qwen3-coder-30b --region ap-northeast-2 -- python job.py # batch: ジョブの間だけGPU tkf status # ステータス確認 tkf connect claude # Claude Code接続(source ~/.token-forge/env.sh) tkf down # GPU停止(--purge: 完全削除、--region: 対象指定) @@ -139,7 +144,7 @@ tkf rotate-key # APIキーローテーション(稼 tkf config set standby single # スタンバイポリシー: race(デフォルト、K=2) | single | lazy ``` -`--region`を省略すると、プレイスメントエンジンが公開フィードの48時間プレイスメントスコア +`tkf up`で`--region`を省略すると(`tkf run`では常に必須)、プレイスメントエンジンが公開フィードの48時間プレイスメントスコア 平均、EC2エンドポイントのRTT(24hキャッシュ)、スポット価格、アカウントクォータを総合して 候補リージョンを序列化し(安定性 → レイテンシ → 価格の順)、上位K個のリージョンに同時に スポットをリクエストして最初に確保できたリージョンだけを残す(First-Acquired-Wins)。 @@ -157,6 +162,7 @@ npm install cdk deploy -c model=solar-open2-250b -c profile=int4-g6e -c region=ap-northeast-1 # プロファイル: int4(p5) / int4-g6e(g6e、低コスト) / bf16(p5) # オプション: -c azs=... -c minCapacity=0 -c idleMinutes=60 -c alertEmail=you@example.com +# -c usageMode=interactive|batch|online (利用モードの節を参照) # -c allowedCidrs=203.0.113.0/24 (送信元IPアローリスト — それ以外はすべて403) ``` @@ -207,11 +213,97 @@ OpenAI SDK: `base_url="/v1"`, `api_key=${API_KEY}`。 | スポット中断通知を受信 | 正常 — ASGが自動的に再プロビジョニングする。S3キャッシュから約15分以内に復旧 | | HFダウンロードが3回失敗 | ログを確認した上でインスタンスを終了(ASGが置き換え)するか、ネットワークを点検 | +## 利用モードと実際のコスト + +GPU料金はプロンプトを入力した時間ではなく、インスタンスが稼働した時間に比例する。 + +> GPUコスト = 時間あたりスポット単価 × (推論時間 + 起動時間 + アイドル稼働時間) + +起動時間(S3キャッシュから約8分)と自動停止までのアイドル時間も、推論と同じ単価で課金される。 +ALB、S3、EBS、Secrets Manager、CloudWatchの料金は別途かかる。`tkf down`はASGを0に設定し、インスタンスの終了には通常数分かかる(`tkf status`で +確認)。GPUの課金はコマンドが終わった時点ではなく、インスタンスが終了した時点で止まる。その後も +ALB(リージョンあたり約$16/月)、S3の重みキャッシュ、シークレットは`tkf down --purge`まで残る。 +EBSルートボリュームはインスタンスが存在する間だけ課金される。以下の金額は$2.60/h(g6e.12xlarge +スポット)を仮定した例であり、リアルタイム価格ではない。実際のスポット価格はリージョンと時期で変わる。 + +「リクエストなし」だけでは、バッチ処理の完了、対話ユーザーの休憩、トラフィックを待つオンライン +サービスを区別できない。そのため`tkf`では利用モードを選択する。利用モードはモデルの量子化 +プロファイル(`--profile`)とは別のものである。 + +| モード | GPUの停止方法 | 停止ポリシーから生じる月額GPUコストの例 | +| --- | --- | --- | +| `interactive`(デフォルト) | ALBリクエストがN分(デフォルト30)ない場合に停止 | アイドル30分 × 1日2回 × 30日 = $78、10分なら$26 | +| `batch` | `tkf run`がジョブ終了(成功・失敗・取消・時間制限)直後に停止 | 起動 + ジョブ時間、および停止要求・インスタンス終了までの数分。アイドル待機はなし | +| `online` | 自動停止なし、READY後は最小容量1 | $2.60 × 24 × 30 = $1,872 | + +**interactive** — `tkf up `(または`--usage-mode interactive`)。アイドル時間は +`--idle-minutes N`(CLI)または`-c idleMinutes=N`(CDK)で変更する。Nは5-1440の範囲の5の倍数、 +または無効化を意味する0でなければならない。CloudWatchがALBの`RequestCount`を5分単位で評価するため、 +それ以外の値は切り捨てずに拒否する。実際の停止はN分より数分遅れることがある。デフォルトの30分は +意図的に維持している。アイドル監視はリクエスト数しか見ないため、実行中の長いストリーミング応答や +待機中の処理を検知できない。デフォルトを安全に短くするには、実行中・待機中のリクエスト数を確認する +別の活動監視が必要である。それまでは、長い無応答リクエストがない使い方の場合に限り、自分で短い値を設定する。 + +**batch** — ジョブの存続期間だけGPUを所有するジョブを実行する。 + +```bash +tkf run qwen3-coder-30b --region ap-northeast-2 [--timeout 120] [--boot-timeout 30] -- python analyze.py --all +``` + +`tkf run`はスタックをbatchモードでデプロイまたは更新し、GPUを1台起動してREADYを待った後、`--`以降の +コマンドをシェルを使わず引数配列のまま実行する。子プロセスは`TKF_ENDPOINT`、`TKF_MODEL`、`TKF_API_KEY`、 +`OPENAI_BASE_URL`、`OPENAI_API_KEY`の環境変数を受け取り、キーは表示されない。ジョブ実行中は`tkf run`が +1分ごとにエンドポイントへpingするため、リクエストベースのアイドル停止は発火しない。ジョブが成功・失敗した +とき、取消(Ctrl-C、SIGTERM、SIGHUP)されたとき、または`--timeout`(デフォルト120分、`0` = 無制限)を +超えたとき、`tkf run`はASGをmin/desired 0に設定し、ASGを読み直してdesired=0を確認する。この確認は停止要求が受け付けられたことを意味し、 +インスタンスがすでに終了したことは意味しない。コマンドは別のプロセスグループで実行される。時間制限や取消の +際はSIGTERMを受け、10秒後にSIGKILLを受ける。コマンドが終わるとグループに残ったプロセス(孫プロセス)も +終了される。`setsid`などでグループを離れたプロセスは追跡しない。標準入力が端末の場合はコマンドにstdinを +渡さず、パイプで来たstdinはそのまま渡す。終了コードはジョブの終了コード +である(124 = 時間制限、127 = コマンドを起動できない、130/143 = 取消)。クリーンアップに失敗した場合は +ジョブ結果とクリーンアップエラーを両方報告し、ジョブが成功していても0以外のコードで終了する。 + +`tkf run`は、そのリージョンのスタックでGPUがすでに稼働中または要求済み(別セッション、オンライン +サービス、以前の実行)の場合は開始を拒否する。そのため見えているインスタンスは引き継いだり停止したりしない。 +同時に開始する別ホストは見えない(下記参照)。 +ASGの確認はスタック更新前と容量を上げる直前の2回行う。`~/.token-forge/locks/`のロックファイルは、実行中に +同一ホストの2つ目の`tkf run`と`tkf up`/`tkf seed`がそのスタックを使わないようにする。異常終了後に残った +ロックは自動では削除しない。エラーメッセージに保持PIDとファイルパスが表示されるので、そのプロセスが +ないことを確認してから削除する。ホスト間のロックはない。2つのホストが同時に開始すると、両方がASGの確認を +通過する可能性がある。 + +`tkf run`自体がSIGKILLで終了したりホストが失われたりすると、クリーンアップできない。その場合はpingが止まり、 +スタックのアイドルアラーム(バックストップ、デフォルト30分、`--idle-minutes`で変更、0は不可)がGPUを停止 +する可能性がある。ただし保証はなく、時刻も正確ではない。停止LambdaはアラームがALARMに変わったときだけ +実行される。起動プローブがアラームをALARMから抜け出させる前(GPU要求後のおよそ最初の5-10分)に実行が +終了すると、新しい状態変化が起きずGPUは稼働し続ける。正常な場合でも5分単位の評価とイベント配信のため、 +設定時間より数分遅れて停止する。異常終了の後は必ず`tkf down --region `を実行し、`tkf status`で確認する。 + +**online** — `tkf up --usage-mode online`はアイドル停止を無効にし、READY後にASGの最小容量を1に +設定する。インスタンスは引き続きスポットなので、中断時はASGが置き換える数分間応答が途切れる。スタック +作成と起動の間は最小容量を0のままにし、`tkf down`は常にmin/desiredを0に設定する。 + +利用モードはスタック(モデル・プロファイル・リージョンごとに1つ)に属する。要求したモードやアイドル時間が +既存スタックと異なる場合、`tkf up`/`tkf run`はGPUを起動する前に`cdk deploy`でスタックを更新する。 +更新デプロイはスタックが`DeploymentSettings`出力に記録した`allowedCidrs`、`azs`、`alertEmail`をそのまま再度渡すため、 +IP許可リスト、AZ選択、アラート購読は維持される。変わるのはアイドルアラーム・Lambda、ASGの最小容量、出力だけなので、 +Launch Templateと稼働中のインスタンスは置き換えられない。この記録がない、または解釈できない場合、`tkf`はスタックを +自動更新せず、最初のデプロイ時のコンテキストを付けて実行する`npx cdk deploy`コマンドを案内する。 + +この機能より前に作成したスタックの移行: これらのスタックにはモードと設定の記録がない。オプションなしの`tkf up` +(interactive、30分)は従来どおりそのまま使う。`--usage-mode online`、別の`--idle-minutes`、`tkf run`を使うには、 +まず最初のデプロイと同じコンテキストで一度手動更新する。例: +`npx cdk deploy -c model= -c profile=

-c region= -c minCapacity=0 -c usageMode=interactive -c idleMinutes=30 [-c allowedCidrs=... -c azs=... -c alertEmail=...]`。 +CDKを直接使う場合は +`-c usageMode=interactive|batch|online`を使う。矛盾する設定はsynth時に失敗する: `online`に0以外の +`idleMinutes`、`batch`に`idleMinutes=0`または`minCapacity=1`、未知のモード。 + ## コスト削減(実験用運用) -- **アイドル自動シャットダウン(デフォルトで有効)**: ALBへのリクエストが30分間なければ、 - インスタンスを自動的に0台に減らしSNSで通知する。間隔変更は`-c idleMinutes=60`、 - 無効化は`-c idleMinutes=0`。 +- **アイドル自動シャットダウン(interactiveモードでデフォルト有効)**: ALBへのリクエストが30分間なければ、 + インスタンスを自動的に0台に減らしSNSで通知する。間隔変更は`-c idleMinutes=60`(5の倍数、5-1440)、 + 無効化は`-c idleMinutes=0`。onlineモードにはアイドル停止がなく、batchモードではバックストップとしてのみ使う。 + [利用モード](#利用モードと実際のコスト)を参照。 - **手動オン/オフ**: ```bash scripts/stop.sh # GPUコスト停止(ALB/S3のみ維持、約$16/月) diff --git a/README.ko.md b/README.ko.md index 153c338..19181ae 100644 --- a/README.ko.md +++ b/README.ko.md @@ -12,7 +12,7 @@ | 모델 | 급 | 인스턴스 | 비고 | |---|---|---|---| -| [Qwen3-Coder-30B](https://huggingface.co/Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8) | 30B MoE | g6e.12xlarge (약 $2.6/h 스팟) | 권장 기본 — 월 $100-200 목표의 기준 | +| [Qwen3-Coder-30B](https://huggingface.co/Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8) | 30B MoE | g6e.12xlarge (약 $2.6/h 스팟) | 권장 기본 — 비용 예시에 쓰는 시간당 단가의 기준 | | [GLM-4.6](https://huggingface.co/zai-org/GLM-4.6-FP8) | 355B MoE | p5.48xlarge | 대형 선택지 | | [Solar-Open2-250B](https://huggingface.co/upstage/Solar-Open2-250B) | 250B MoE | p5.48xlarge / g6e.48xlarge | 전용 vLLM 포크 사용 | @@ -23,9 +23,12 @@ 세 가지 축이 핵심이고, 전부 실배포 실측으로 뒷받침된다 — 근거와 수치는 **[비용·보안·편의 상세](docs/value-proposition.md)** 참고. -- **비용 — 쓴 시간만큼만**: 100% 스팟(온디맨드의 약 3분의 1에서 4분의 1) + 유휴 30분 - 자동 정지 + 실패 경로마다 GPU를 0으로 되돌리는 비용 가드 + 가중치는 저가 CPU 스팟이 - 선시딩(실측 8분/$0.03). 30B급 기준 **월 $100-200**, 안 쓰는 달은 GPU 요금 0. +- **비용 — 부팅·유휴를 포함한 GPU 실행 시간만큼**: 100% 스팟(온디맨드의 약 3분의 1에서 4분의 1) + + GPU 정지 시점을 정하는 사용 모드(interactive: 무요청 30분, batch: 작업 직후, online: 정지 없음) + + 실패 경로마다 GPU를 0으로 되돌리는 비용 가드 + 가중치는 저가 CPU 스팟이 선시딩(실측 8분/$0.03). + 30B급 월 $100-200 목표는 사용 시간이 제한된 interactive·batch 사용에서만 성립한다. 24시간 도는 + online 엔드포인트는 $2.60/h 기준 월 약 $1,872다. GPU를 켜지 않은 달은 GPU 요금이 0이지만, ALB와 + S3 캐시는 `tkf down --purge` 전까지 과금된다. [사용 모드와 실제 비용](#사용-모드와-실제-비용) 참고. - **보안 — 불반출이 아키텍처**: 추론·프롬프트·사용량 통계 모두 내 계정 안에서 완결, 텔레메트리 없음(외부 통신은 HF 다운로드·AWS API·피드 익명 GET 3종뿐 — 피드마저 자가 수집기로 대체 가능). 전송은 CloudFront TLS가 기본이고 ALB 우회 접근은 403, @@ -47,7 +50,7 @@ 경우. 추론·프롬프트·사용량 통계까지 자기 AWS 계정 안에서 완결되고 전체가 OSS라 감사 가능하다. 이 그룹에게 대안은 상용 API가 아니라 "아무것도 없음"이다. 2. **한도에 묶인 헤비 파워유저** — 에이전트 팬아웃으로 일 100M+ 토큰을 태우는 경우. - 시간 정액(월 약 $100-200)은 토큰 무제한이라, 이 물량부터 동일 모델 API와 동률이 되고 + 토큰이 아니라 GPU 시간에 과금되므로 GPU가 도는 동안 토큰은 무제한이라, 이 물량부터 동일 모델 API와 동률이 되고 상용 1차 API 대비 수 배 저렴해진다. 128K 컨텍스트 정액도 티어 과금 API 대비 이점. 3. **배치 워크로드 운영자** — 야간 대량 코드 분석·생성처럼 GPU를 채워 쓰는 경우. 가동률이 오르면 토큰 단가가 3-5배 개선되는 스팟의 최적 지점이다. @@ -75,7 +78,7 @@ flowchart LR ASG --> EC2["EC2 GPU
DLAMI + Docker(vLLM)
/v1/chat/completions + /v1/messages"] EC2 <-- "가중치 캐시 로드/시딩" --> S3[("S3 버킷
(Retain)")] SM["Secrets Manager
(API 키)"] -.-> EC2 - IDLE["유휴 감시 Lambda
30분 무요청 → 자동 정지"] -.-> ASG + IDLE["유휴 감시 Lambda
interactive: 30분 무요청 → 자동 정지
(online은 없음, batch는 백스톱)"] -.-> ASG end ``` @@ -105,7 +108,7 @@ roboco가 상시 운영하는 **GPU 스팟 확보 가능성(배치점수) × 가 신청. 신규 계정은 부분 승인이 흔하므로 **[EC2 쿼터 증설 요청 가이드](docs/ec2-quota-guide.md)** 의 어필 문안 작성법 참고. - 비용 참고 (스팟, 리전·시점 변동): g6e.12xlarge 약 **$2.6/hr**, g6e.48xlarge 약 - **$10-13/hr**, p5.48xlarge 약 **$30-50/hr**. 유휴 자동 정지가 기본이지만 장기 + **$10-13/hr**, p5.48xlarge 약 **$30-50/hr**. 기본 interactive 모드는 유휴 자동 정지가 켜져 있지만 장기 미사용 시 `cdk destroy` 권장. - Node 20+, 부트스트랩된 계정(`cdk bootstrap`). AWS CDK CLI는 패키지 의존성에 포함되어 있어 별도 설치가 필요 없다 (소스로 설치할 때만 별도 설치 필요). @@ -123,6 +126,8 @@ tkf placement qwen3-coder-30b # 리전 추천 표 (배치점수 48 tkf seed qwen3-coder-30b # 가중치 S3 선시딩만 (GPU 0대, 리전 선택 프롬프트) tkf up qwen3-coder-30b # 리전 자동 선정 + 병렬 레이스 기동 (R10) tkf up qwen3-coder-30b --region ap-northeast-2 # 리전 직접 지정 +tkf up qwen3-coder-30b --usage-mode online # online: 유휴 정지 없음, READY 후 최소 용량 1 +tkf run qwen3-coder-30b --region ap-northeast-2 -- python job.py # batch: 작업 동안만 GPU tkf status # 상태 확인 tkf connect claude # Claude Code 연결 (source ~/.token-forge/env.sh) tkf down # GPU 정지 (--purge: 완전 삭제, --region: 대상 지정) @@ -130,7 +135,7 @@ tkf rotate-key # API 키 회전 (가동 중이면 tkf config set standby single # 스탠바이 정책: race(기본, K=2) | single | lazy ``` -`--region`을 생략하면 배치 엔진이 공개 피드의 48시간 배치점수 평균, EC2 엔드포인트 +`tkf up`에서 `--region`을 생략하면(`tkf run`은 항상 필수) 배치 엔진이 공개 피드의 48시간 배치점수 평균, EC2 엔드포인트 RTT(24h 캐시), 스팟 가격, 계정 쿼터를 종합해 후보 리전을 서열화하고(안정성 → 레이턴시 → 가격), 상위 K개 리전에 동시에 스팟을 요청해 먼저 확보한 리전만 남긴다(First-Acquired- Wins). 피드가 대상 타입을 커버하지 않으면 실시간 배치점수로 자동 폴백한다. @@ -146,6 +151,7 @@ npm install cdk deploy -c model=solar-open2-250b -c profile=int4-g6e -c region=ap-northeast-1 # 프로파일: int4(p5) / int4-g6e(g6e, 저비용) / bf16(p5) # 옵션: -c azs=... -c minCapacity=0 -c idleMinutes=60 -c alertEmail=you@example.com +# -c usageMode=interactive|batch|online (사용 모드 절 참고) # -c allowedCidrs=203.0.113.0/24 (소스 IP 허용목록 — 그 외 전부 403) ``` @@ -194,10 +200,95 @@ OpenAI SDK: `base_url="/v1"`, `api_key=${API_KEY}`. | 스팟 중단 알림 수신 | 정상 — ASG가 자동 재프로비저닝. S3 캐시로 약 15분 내 복구 | | HF 다운로드 3회 실패 | 로그 확인 후 인스턴스 종료(ASG 교체) 또는 네트워크 점검 | +## 사용 모드와 실제 비용 + +GPU 요금은 프롬프트를 입력한 시간이 아니라 인스턴스가 실행된 시간에 비례한다. + +> GPU 비용 = 시간당 스팟 단가 × (추론 시간 + 부팅 시간 + 유휴 실행 시간) + +부팅 시간(S3 캐시 기준 약 8분)과 자동 정지 전까지의 유휴 시간도 추론과 같은 단가로 과금된다. +ALB, S3, EBS, Secrets Manager, CloudWatch 요금은 별도다. `tkf down`은 ASG를 0으로 설정하고, 인스턴스 종료에는 보통 몇 분이 걸린다(`tkf status`로 +확인). GPU 과금은 명령이 끝난 시점이 아니라 인스턴스가 종료된 시점에 멈춘다. 그 뒤에도 +ALB(리전당 약 $16/월), S3 가중치 캐시, 시크릿은 `tkf down --purge` 전까지 남는다. EBS 루트 볼륨은 +인스턴스가 있는 동안에만 과금된다. 아래 금액은 $2.60/h(g6e.12xlarge 스팟)를 가정한 예시이며 +실시간 가격이 아니다. 실제 스팟 가격은 리전과 시점에 따라 바뀐다. + +"요청 없음"만으로는 배치 작업 완료, 대화형 사용자의 휴식, 트래픽을 기다리는 온라인 서비스를 +구분할 수 없다. 그래서 `tkf`는 사용 모드를 선택하게 한다. 사용 모드는 모델 양자화 +프로파일(`--profile`)과 별개다. + +| 모드 | GPU 정지 방식 | 정지 정책에서 생기는 월 GPU 비용 예시 | +| --- | --- | --- | +| `interactive` (기본) | ALB 요청이 N분(기본 30) 없으면 정지 | 유휴 30분 × 하루 2회 × 30일 = $78, 10분이면 $26 | +| `batch` | `tkf run`이 작업 종료(성공·실패·취소·시간 제한) 즉시 정지 | 부팅 + 작업 시간, 그리고 정지 요청·인스턴스 종료까지의 몇 분. 유휴 대기는 없음 | +| `online` | 자동 정지 없음, READY 후 최소 용량 1 | $2.60 × 24 × 30 = $1,872 | + +**interactive** — `tkf up `(또는 `--usage-mode interactive`). 유휴 시간은 +`--idle-minutes N`(CLI) 또는 `-c idleMinutes=N`(CDK)으로 바꾼다. N은 5-1440 범위의 5의 배수이거나, +비활성화를 뜻하는 0이어야 한다. CloudWatch가 ALB `RequestCount`를 5분 단위로 평가하므로 그 밖의 값은 +내림하지 않고 거부하며, 실제 정지는 N분보다 몇 분 늦을 수 있다. 기본값 30분은 의도적으로 유지한다. +유휴 감시는 요청 수만 보므로 실행 중인 긴 스트리밍 응답이나 대기 중인 작업을 알지 못한다. 기본값을 +안전하게 줄이려면 실행·대기 요청 수를 확인하는 별도 활동 감시가 필요하다. 그 전까지는 긴 무응답 +요청이 없는 사용 패턴일 때만 직접 짧은 값을 설정한다. + +**batch** — 작업 수명 동안만 GPU를 소유하는 작업을 실행한다. + +```bash +tkf run qwen3-coder-30b --region ap-northeast-2 [--timeout 120] [--boot-timeout 30] -- python analyze.py --all +``` + +`tkf run`은 스택을 batch 모드로 배포 또는 갱신하고, GPU 1대를 기동해 READY를 기다린 뒤 `--` 뒤의 +명령을 셸 없이 인자 배열 그대로 실행한다. 자식 프로세스는 `TKF_ENDPOINT`, `TKF_MODEL`, `TKF_API_KEY`, +`OPENAI_BASE_URL`, `OPENAI_API_KEY` 환경변수를 받으며, 키는 출력하지 않는다. 작업이 도는 동안 +`tkf run`이 1분마다 엔드포인트를 핑하므로 요청 기반 유휴 정지는 발화하지 않는다. 작업이 성공·실패하거나, +취소(Ctrl-C, SIGTERM, SIGHUP)되거나, `--timeout`(기본 120분, `0` = 무제한)을 넘으면 `tkf run`이 ASG를 +min/desired 0으로 설정하고 ASG를 다시 읽어 desired=0을 확인한다. 이 확인은 정지 요청이 받아들여졌다는 +뜻이며, 인스턴스가 이미 종료됐다는 뜻은 아니다. 명령은 별도 프로세스 그룹에서 실행된다. 시간 제한이나 +취소 시 SIGTERM을 받고 10초 뒤 SIGKILL을 받으며, 명령이 끝나면 그룹에 남은 프로세스(손자 프로세스)도 +종료된다. `setsid` 등으로 그룹을 벗어난 프로세스는 추적하지 않는다. 표준 입력이 터미널이면 명령에 +stdin을 주지 않고, 파이프로 들어온 stdin은 그대로 전달한다. 종료 코드는 작업의 종료 코드다(124 = 시간 제한, +127 = 명령 실행 불가, 130/143 = 취소). 정리에 실패하면 작업 결과와 정리 오류를 함께 보고하고, 작업이 +성공했더라도 0이 아닌 코드로 끝난다. + +`tkf run`은 해당 리전의 스택에서 GPU가 이미 실행 중이거나 요청된 상태(다른 세션, 온라인 서비스, +이전 실행)이면 시작을 거부한다. 따라서 보이는 인스턴스는 인수하거나 정지하지 않는다. 같은 순간에 시작하는 +다른 호스트는 볼 수 없다(아래 참고). ASG 검사는 +스택 갱신 전과 용량을 올리기 직전에 두 번 한다. `~/.token-forge/locks/`의 잠금 파일은 실행 중에 같은 +호스트의 두 번째 `tkf run`과 `tkf up`/`tkf seed`가 그 스택을 쓰지 못하게 한다. 비정상 종료 후 남은 잠금은 +자동으로 지우지 않는다. 오류 메시지에 보유 PID와 파일 경로가 나오므로 그 프로세스가 없음을 확인한 뒤 +지운다. 호스트 간 잠금은 없다. 두 호스트가 같은 순간에 시작하면 둘 다 ASG 검사를 통과할 수 있다. + +`tkf run` 자체가 SIGKILL로 죽거나 호스트가 유실되면 정리를 할 수 없다. 이때는 핑이 끊기고, 스택의 +유휴 알람(백스톱, 기본 30분, `--idle-minutes`로 변경, 0은 허용하지 않음)이 GPU를 정지할 수 있다. 그러나 +이는 보장되지 않고 시각도 정확하지 않다. 정지 Lambda는 알람이 ALARM으로 바뀔 때만 실행된다. 부팅 프로브가 +알람을 ALARM에서 벗어나게 하기 전(GPU 요청 후 대략 처음 5-10분)에 실행이 죽으면 새 상태 변화가 없어 GPU가 +계속 실행된다. 정상적인 경우에도 5분 단위 평가와 이벤트 전달 때문에 설정 시간보다 몇 분 늦게 정지된다. +비정상 종료 뒤에는 항상 `tkf down --region `을 실행하고 `tkf status`로 확인한다. + +**online** — `tkf up --usage-mode online`은 유휴 정지를 끄고, READY 후 ASG 최소 용량을 1로 +설정한다. 인스턴스는 여전히 스팟이므로 중단되면 ASG가 교체하는 몇 분 동안 응답이 끊긴다. 스택 생성과 +부팅 중에는 최소 용량을 0으로 두며, `tkf down`은 항상 min/desired를 0으로 설정한다. + +사용 모드는 스택(모델·프로파일·리전마다 1개)에 속한다. 요청한 모드나 유휴 시간이 기존 스택과 다르면 +`tkf up`/`tkf run`이 GPU를 올리기 전에 `cdk deploy`로 스택을 갱신한다. +갱신 배포는 스택이 `DeploymentSettings` 출력에 기록한 `allowedCidrs`, `azs`, `alertEmail`을 그대로 다시 전달하므로 +IP 허용목록, AZ 선택, 알림 구독이 유지된다. 바뀌는 것은 유휴 알람·Lambda, ASG 최소 용량, 출력뿐이므로 Launch +Template과 실행 중인 인스턴스는 교체되지 않는다. 이 기록이 없거나 해석할 수 없으면 `tkf`는 스택을 자동으로 +갱신하지 않고, 최초 배포 때의 컨텍스트를 붙여 실행할 `npx cdk deploy` 명령을 안내한다. + +이 기능 이전에 만든 스택의 마이그레이션: 이런 스택에는 모드와 설정 기록이 없다. 옵션 없는 `tkf up`(interactive, +30분)은 이전처럼 그대로 사용한다. `--usage-mode online`, 다른 `--idle-minutes`, `tkf run`을 쓰려면 먼저 최초 배포와 +같은 컨텍스트로 한 번 수동 갱신한다. 예: +`npx cdk deploy -c model= -c profile=

-c region= -c minCapacity=0 -c usageMode=interactive -c idleMinutes=30 [-c allowedCidrs=... -c azs=... -c alertEmail=...]`. +CDK를 직접 쓸 때는 `-c usageMode=interactive|batch|online`을 쓴다. +충돌하는 설정은 synth 단계에서 실패한다: `online`에 0이 아닌 `idleMinutes`, `batch`에 +`idleMinutes=0` 또는 `minCapacity=1`, 알 수 없는 모드. + ## 비용 절감 (실험용 운영) -- **유휴 자동 셧다운(기본 켜짐)**: ALB 요청이 30분간 없으면 인스턴스를 자동으로 0대로 내리고 SNS로 알린다. - 간격 변경 `-c idleMinutes=60`, 비활성화 `-c idleMinutes=0`. +- **유휴 자동 셧다운(interactive 모드 기본 켜짐)**: ALB 요청이 30분간 없으면 인스턴스를 자동으로 0대로 내리고 SNS로 알린다. + 간격 변경 `-c idleMinutes=60`(5의 배수, 5-1440), 비활성화 `-c idleMinutes=0`. online 모드에는 유휴 정지가 없고, + batch 모드는 백스톱으로만 쓴다. [사용 모드](#사용-모드와-실제-비용) 참고. - **수동 온/오프**: ```bash scripts/stop.sh # GPU 비용 정지 (ALB/S3만 유지, 약 $16/월) diff --git a/README.md b/README.md index 35731f9..7b5f2e0 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ usage stats never leave your account. | Model | Class | Instance | Notes | |---|---|---|---| -| [Qwen3-Coder-30B](https://huggingface.co/Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8) | 30B MoE | g6e.12xlarge (about $2.6/h spot) | Recommended default — baseline for the $100-200/month target | +| [Qwen3-Coder-30B](https://huggingface.co/Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8) | 30B MoE | g6e.12xlarge (about $2.6/h spot) | Recommended default — the hourly price used in the cost examples | | [GLM-4.6](https://huggingface.co/zai-org/GLM-4.6-FP8) | 355B MoE | p5.48xlarge | Large-scale option | | [Solar-Open2-250B](https://huggingface.co/upstage/Solar-Open2-250B) | 250B MoE | p5.48xlarge / g6e.48xlarge | Requires a dedicated vLLM fork | @@ -25,11 +25,15 @@ Three pillars matter here, and all three are backed by measurements from real de see **[cost, security, and convenience details](docs/value-proposition.md)** (Korean) for the evidence and numbers. -- **Cost — pay only for the time you use**: 100% spot (roughly a quarter to a third of - on-demand pricing) + automatic shutdown after 30 minutes idle + a cost guard that resets - GPU capacity to zero on every failure path + weights pre-seeded via cheap CPU spot - instances (measured at 8 minutes / $0.03). About **$100-200/month** for a 30B-class - model, and $0 in GPU charges during months you don't use it. +- **Cost — you pay for GPU running time, including boot and idle time**: 100% spot (roughly + a quarter to a third of on-demand pricing) + a usage mode that decides when the GPU stops + (interactive: 30 minutes without requests, batch: right after the job, online: never) + a + cost guard that resets GPU capacity to zero on every failure path + weights pre-seeded via + cheap CPU spot instances (measured at 8 minutes / $0.03). A $100-200/month target for a + 30B-class model holds only for interactive or batch use with limited hours; an online + endpoint that runs 24 hours costs about $1,872/month at $2.60/h. GPU charges are $0 in + months you don't start a GPU, but the ALB and S3 cache are still billed until + `tkf down --purge`. See [Usage modes and what you actually pay](#usage-modes-and-what-you-actually-pay). - **Security — no-exfiltration by architecture**: inference, prompts, and usage stats stay entirely inside your account, with no telemetry (the only outbound traffic is HF downloads, AWS API calls, and anonymous GET requests to the feed — and even the feed can @@ -55,7 +59,7 @@ volumes, or run GPU workloads in concentrated bursts**. inside your own AWS account, and the whole stack is open source and auditable. For this group the alternative is not a commercial API but "nothing at all". 2. **A heavy power user hitting subscription limits** — agent fan-out burning 100M+ - tokens a day. Flat GPU-hours (about $100-200/month) mean unlimited tokens: from this + tokens a day. You pay for GPU hours, not tokens, so tokens are unlimited while the GPU runs: from this volume on, self-hosting matches same-model API pricing and beats first-party commercial APIs by multiples. Flat-rate 128K context also avoids context-tiered API billing. 3. **A batch workload operator** — nightly bulk code analysis or generation that fills @@ -86,7 +90,7 @@ flowchart LR ASG --> EC2["EC2 GPU
DLAMI + Docker (vLLM)
/v1/chat/completions + /v1/messages"] EC2 <-- "load/seed weights cache" --> S3[("S3 bucket
(Retain)")] SM["Secrets Manager
(API key)"] -.-> EC2 - IDLE["Idle-watch Lambda
30 min no requests → auto stop"] -.-> ASG + IDLE["Idle-watch Lambda
interactive: 30 min no requests → auto stop
(off in online, backstop in batch)"] -.-> ASG end ``` @@ -123,7 +127,7 @@ To run the same collector directly in your own account, use how to write an effective appeal. - Cost reference (spot pricing, varies by region and time): about **$2.6/hr** for g6e.12xlarge, about **$10-13/hr** for g6e.48xlarge, and about **$30-50/hr** for - p5.48xlarge. Idle auto-shutdown is on by default, but `cdk destroy` is recommended for + p5.48xlarge. Idle auto-shutdown is on in the default interactive mode, but `cdk destroy` is recommended for extended periods of non-use. - Node 20+, and a bootstrapped account (`cdk bootstrap`). The AWS CDK CLI is bundled as a package dependency, so you don't need to install it separately (only required as a @@ -143,6 +147,8 @@ tkf placement qwen3-coder-30b # region recommendation table (48h p tkf seed qwen3-coder-30b # pre-seed weights to S3 only (0 GPUs, prompts for a region) tkf up qwen3-coder-30b # auto region selection + parallel race launch (R10) tkf up qwen3-coder-30b --region ap-northeast-2 # specify a region directly +tkf up qwen3-coder-30b --usage-mode online # online: no idle stop, min capacity 1 after READY +tkf run qwen3-coder-30b --region ap-northeast-2 -- python job.py # batch: GPU only for the job tkf status # check current status tkf connect claude # connect Claude Code (source ~/.token-forge/env.sh) tkf down # stop GPUs (--purge: full teardown, --region: target a specific region) @@ -150,7 +156,7 @@ tkf rotate-key # rotate the API key (applied on next tkf config set standby single # standby policy: race (default, K=2) | single | lazy ``` -If you omit `--region`, the placement engine ranks candidate regions by combining the +If you omit `--region` in `tkf up` (`tkf run` always requires it), the placement engine ranks candidate regions by combining the public feed's 48-hour placement-score average, EC2 endpoint RTT (24h cache), spot pricing, and account quota (in priority order: stability -> latency -> price), then requests spot capacity simultaneously across the top K regions and keeps only whichever region acquires @@ -169,6 +175,7 @@ npm install cdk deploy -c model=solar-open2-250b -c profile=int4-g6e -c region=ap-northeast-1 # profiles: int4 (p5) / int4-g6e (g6e, lower cost) / bf16 (p5) # options: -c azs=... -c minCapacity=0 -c idleMinutes=60 -c alertEmail=you@example.com +# -c usageMode=interactive|batch|online (see Usage modes) # -c allowedCidrs=203.0.113.0/24 (source-IP allowlist — everything else gets 403) ``` @@ -221,11 +228,111 @@ See `models/solar-open2-250b.yaml` for the schema (`vllmImage`, | Received a spot interruption notice | Normal — the ASG reprovisions automatically. Recovers from the S3 cache in about 15 minutes | | HF download failed 3 times | Check the logs, then either terminate the instance (triggering an ASG replacement) or investigate network issues | +## Usage modes and what you actually pay + +GPU charges follow the time an instance is running, not the time you spend typing prompts: + +> GPU cost = hourly spot price × (inference time + boot time + idle running time) + +Boot time (about 8 minutes from the S3 cache) and the idle time before an automatic stop are +billed at the same hourly rate as inference. ALB, S3, EBS, Secrets Manager, and CloudWatch are +billed separately. `tkf down` sets the ASG to 0 and the instance then terminates, which usually +takes a few minutes (check with `tkf status`); GPU billing ends when the instance terminates, not +when the command returns. The ALB (about $16/month per +region), the S3 weight cache, and the secrets remain until you run `tkf down --purge`. The EBS +root volume is billed only while an instance exists. The prices below are examples that assume +$2.60/h (g6e.12xlarge spot); they are not live prices, and real spot prices change by region +and time. + +Because "no requests" cannot tell a finished batch job, a user on a coffee break, and an online +service waiting for traffic apart, `tkf` lets you choose a usage mode. The mode is separate from +the model quantization profile (`--profile`). + +| Mode | How the GPU is stopped | Example monthly GPU cost of the stop policy alone | +| --- | --- | --- | +| `interactive` (default) | Stops after N minutes without ALB requests (default 30) | 30 min idle × 2 sessions/day × 30 days = $78; with 10 min = $26 | +| `batch` | `tkf run` stops the GPU as soon as the job ends (success, failure, cancel, or timeout) | Boot + job time, plus the few minutes until the stop request is sent and the instance terminates; no idle wait | +| `online` | Never stopped automatically; minimum capacity 1 after READY | $2.60 × 24 × 30 = $1,872 | + +**interactive** — `tkf up ` (or `--usage-mode interactive`). Change the idle window with +`--idle-minutes N` (CLI) or `-c idleMinutes=N` (CDK). N must be a multiple of 5 between 5 and +1440, or 0 to disable. CloudWatch evaluates ALB `RequestCount` in 5-minute periods, so other +values are rejected instead of being rounded down, and the actual stop can happen several +minutes after N. The default stays at 30 minutes on purpose. The idle watch counts requests +only, so it cannot see a long streaming response that is still running or work that is +queued. A shorter safe default needs a separate activity monitor that checks running and +waiting requests; until then, set a shorter value yourself only if your sessions do not have +long silent requests. + +**batch** — run a job that owns the GPU for its lifetime: + +```bash +tkf run qwen3-coder-30b --region ap-northeast-2 [--timeout 120] [--boot-timeout 30] -- python analyze.py --all +``` + +`tkf run` deploys or updates the stack in batch mode, starts one GPU, waits for READY, and runs +the command after `--` as an argument array without a shell. The child process receives +`TKF_ENDPOINT`, `TKF_MODEL`, `TKF_API_KEY`, `OPENAI_BASE_URL`, and `OPENAI_API_KEY`; the key is +never printed. While the job runs, `tkf run` pings the endpoint every minute so the request +based idle stop does not fire. When the job succeeds, fails, is cancelled (Ctrl-C, SIGTERM, +SIGHUP), or exceeds `--timeout` (default 120 minutes, `0` = no limit), `tkf run` sets the ASG to +min/desired 0 and reads the ASG back to confirm desired=0. This confirms that the stop request was +accepted, not that the instance has already terminated. The command runs in its own process group: +on timeout or cancel it receives SIGTERM, then SIGKILL after 10 seconds, and any processes left in +the group (grandchildren) are killed when the command exits. Processes that leave the group (for +example with `setsid`) are not tracked. If your terminal is the standard input, the command gets no +stdin; a piped stdin is passed through. The exit code is the job's exit code (124 = +timeout, 127 = command could not start, 130/143 = cancelled). If the cleanup fails, the run +reports both the job result and the cleanup error and exits non-zero even if the job succeeded. + +`tkf run` refuses to start when the stack in that region already has a running or requested +GPU (another session, an online service, or an earlier run), so it does not take over or stop an +instance that it can see. It cannot see another host that starts at the same moment (see below). It checks the ASG twice: before the stack update and again right before +it raises the capacity. A lock file in `~/.token-forge/locks/` stops a second `tkf run`, and also +`tkf up`/`tkf seed`, on the same host from using that stack while a run is in progress. The lock +is not removed automatically after a crash; the error message shows the holder's PID and the +file to delete after you confirm that process is gone. There is no lock across hosts: if two +hosts start at the same moment, both can pass the ASG check. + +If `tkf run` itself is killed with SIGKILL or the host is lost, it cannot clean up. The pings +stop, and the stack's idle alarm (backstop, default 30 minutes, `--idle-minutes` to change, 0 is +not allowed) can stop the GPU, but this is not guaranteed and not exact: the stop Lambda runs +only when the alarm changes to ALARM. If the run dies before the boot probes have moved the alarm +out of ALARM (roughly the first 5-10 minutes after the GPU was requested), no new change happens and +the GPU keeps running. Even in the normal case the stop comes some minutes after the configured +time because of 5-minute evaluation and event delivery. After any crash, run +`tkf down --region ` and check `tkf status`. + +**online** — `tkf up --usage-mode online` disables the idle stop and, after READY, sets +the ASG minimum to 1. The instance is still spot, so an interruption causes a gap of several +minutes while the ASG replaces it. During stack creation and boot the minimum stays 0, and +`tkf down` always sets min/desired to 0. + +The usage mode belongs to the stack (one per model, profile, and region). When the requested +mode or idle window differs from the existing stack's, `tkf up`/`tkf run` runs `cdk deploy` to +update the stack before starting the GPU. The update passes the same `allowedCidrs`, `azs`, and +`alertEmail` that the stack recorded in its `DeploymentSettings` output, so the IP allowlist, AZ +selection, and alert subscription are kept. Only the idle alarm and Lambda, the ASG minimum, and the +outputs change, so the Launch Template and a running instance are not replaced. If that record is +missing or cannot be read, `tkf` does not update the stack automatically and prints the +`npx cdk deploy` command to run with the contexts you used originally. + +Migration of stacks created before this feature: they have no recorded mode or settings. A plain +`tkf up` (interactive, 30 minutes) keeps using them unchanged, as before. To use `--usage-mode +online`, a different `--idle-minutes`, or `tkf run`, first update the stack once by hand with the +same contexts as the original deployment, for example +`npx cdk deploy -c model= -c profile=

-c region= -c minCapacity=0 -c usageMode=interactive -c idleMinutes=30 [-c allowedCidrs=... -c azs=... -c alertEmail=...]`. +With CDK directly, use `-c usageMode=interactive|batch|online`. +Conflicting settings fail at synth time: `online` with `idleMinutes` other than 0, `batch` with +`idleMinutes=0` or `minCapacity=1`, and unknown modes. + ## Cost reduction (for experimental use) -- **Idle auto-shutdown (on by default)**: if the ALB sees no requests for 30 minutes, - instances are automatically scaled down to 0 and an SNS notification is sent. - Change the interval with `-c idleMinutes=60`, or disable with `-c idleMinutes=0`. +- **Idle auto-shutdown (on by default in interactive mode)**: if the ALB sees no requests + for 30 minutes, instances are automatically scaled down to 0 and an SNS notification is + sent. Change the interval with `-c idleMinutes=60` (multiple of 5, 5-1440), or disable with + `-c idleMinutes=0`. Online mode has no idle stop; batch mode uses it only as a backstop. + See [Usage modes](#usage-modes-and-what-you-actually-pay). - **Manual on/off**: ```bash scripts/stop.sh # stop GPU billing (keeps ALB/S3, about $16/month) diff --git a/docs/superpowers/plans/2026-10-24-usage-modes.md b/docs/superpowers/plans/2026-10-24-usage-modes.md new file mode 100644 index 0000000..25678f5 --- /dev/null +++ b/docs/superpowers/plans/2026-10-24-usage-modes.md @@ -0,0 +1,281 @@ +# 사용 모드(interactive / batch / online)와 비용 설명 — 구현 계획·작업 기록 (#43) + +이슈: https://github.com/roboco-io/token-forge/issues/43. 이 문서는 이슈의 설계 초안을 구현 가능한 규칙으로 +구체화한 결론, 초안과 다르게 정한 부분, 테스트 결과와 검증 한계, Ralph Loop 반복 리뷰 기록을 담는다. +작업 방식은 사용자의 지시에 따라 Ralph Loop(같은 프롬프트로 세션을 반복해 구현 → 검증 → 리뷰를 되풀이)로 +진행했다. 반복 1-4의 기능 검증은 mock과 오프라인 합성으로 수행했고, 그 단계에서는 실제 배포·GPU 기동을 하지 않았다. +이후 사용자 지시로 기존 스택 1개에 실제 배포했다(GPU 기동 없음). 결과는 7절에 있다. + +## 1. 배경 + +README의 기본 유휴 종료 30분에 대해 비용 피드백이 있었다. 예시 단가 $2.60/h를 가정하면 세션 종료 후 30분은 +$1.30이고, 하루 2회 × 30일이면 $78이다. 기존 유휴 감시는 ALB 요청 수만 보므로 배치 작업 완료, 대화형 사용자의 +휴식, 트래픽을 기다리는 온라인 서비스를 구분하지 못한다. 그래서 사용 목적별 기본 정책(사용 모드)을 도입하고 +README의 비용 설명을 정정한다. + +## 2. 설계 결론 + +### 2.1 공통 정책 해석기 (`lib/usage-mode.ts`) + +CDK 컨텍스트(`-c usageMode`, `-c idleMinutes`, `-c minCapacity`)와 CLI 옵션(`--usage-mode`, `--idle-minutes`)은 +같은 함수 `resolveUsagePolicy()`로 해석한다. 두 경로가 서로 다른 기본값이나 검증 규칙을 갖지 않게 하기 위해서다. +CLI는 해석된 값을 `policyContextArgs()`로 전부 명시해 `cdk deploy`에 넘기며, CDK 쪽 기본값에 기대지 않는다. +이 왕복이 같은 정책을 만드는지는 `test/usage-mode.test.ts`가 확인한다. + +| 모드 | idleMinutes 기본 | 명시값 규칙 | CDK minCapacity 기본 | READY 후 CLI가 거는 min | +| --- | --- | --- | --- | --- | +| interactive (생략 시) | 30 | 0(비활성, 기존 호환) 또는 5의 배수 5-1440 | 1 (직접 CDK 호환) | 0 | +| batch | 30 (백스톱) | 5의 배수 5-1440, 0은 거부 | 0, 1은 거부 | 0 | +| online | 0 | 0만 허용, 그 외 거부 | 1 | 1 | + +우선순위는 "명시값 > 모드 기본값"이다. 모드 목적과 충돌하는 명시값은 무시하지 않고 오류로 거부한다. +idleMinutes는 정수 문자열·숫자만 받으며 5의 배수가 아니면 거부한다. 기존 구현은 +`Math.round(idleMinutes / 5)`로 반올림해서, 예를 들어 12분을 10분 평가로 만들어 더 일찍 종료할 수 있었다. +상한 1440분은 5분 × 288 평가 주기 = 24시간으로, CloudWatch 알람 평가 기간 총합 1일 이내로 둔 값이다. +기본 30분은 초안대로 유지했다(단축하지 않음). + +### 2.2 CDK 스택 + +스택은 정책을 해석해 ASG `MinSize`와 유휴 알람·Lambda 생성 여부를 정하고, 출력 `UsageMode`와 `IdleMinutes`를 +남긴다. 모드는 유휴 알람·Lambda와 ASG MinSize만 바꾸며 Launch Template(user-data)은 바꾸지 않는다. 그래서 +기존 스택의 모드를 바꾸는 `cdk deploy`가 실행 중인 인스턴스 교체를 일으키지 않는다(LT 동일성 테스트로 확인). + +### 2.3 CLI `tkf up` — 기존 스택에도 정책 반영 + +`ensureStackReady()`는 기존 스택이 있으면 출력의 `UsageMode`/`IdleMinutes`를 요청 정책과 비교한다. 다르면 +GPU를 올리기 전에 `cdk deploy`로 갱신하고, 배포 후 출력이 여전히 다르면 실패한다. 출력이 없는 구버전 스택의 처리는 +반복 3에서 바뀌었다(아래 반복 3 기록 참고: 기본 요청은 배포 없이 사용, 그 외는 수동 갱신 안내). 정책을 넘기지 않는 `tkf seed` 경로는 기존 스택을 건드리지 +않는다(신규 생성 시에는 기본 interactive). CLI는 항상 `-c minCapacity=0`으로 배포하므로 스택 생성·갱신은 스팟 +확보와 분리된다. online은 READY(인증 프로브 200) 직후 `setCapacity(asg, min=1, desired=1)`을 적용하고, 이 적용이 +실패하면 성공으로 보고하지 않는다. `tkf down`은 기존처럼 `setDesired(0)` = MinSize 0 + Desired 0이므로 online도 +항상 내릴 수 있다. 자동 리전 경로(`runUpAuto`)는 race·single·lazy 모두 후보별 `ensure`와 승자 `wait`에 같은 +정책을 넘긴다. + +### 2.4 batch 작업 래퍼 `tkf run` + +``` +tkf run --region [--profile p] [--idle-minutes N] [--boot-timeout 30] [--timeout 120] -- [args...] +``` + +`runBatch()`(`cli/commands/run.ts`)의 순서는 다음과 같다. 로컬 잠금 획득 → 신호 핸들러 등록 → `ensureStackReady` +(소유권 검사 + batch 정책 반영) → ASG 이름 확인(이 시점부터 이 실행이 정리 책임을 짐) → `waitReady`(부팅 제한 +시간, 신호를 받으면 대기 즉시 중단) → API 키 조회 → 자식 프로세스 실행 → 정리 → 이전 state.json 복원. + +자식 프로세스는 셸 없이 `spawn(cmd, args)`로 실행하고, `TKF_ENDPOINT`, `TKF_MODEL`, `TKF_API_KEY`, +`OPENAI_BASE_URL`, `OPENAI_API_KEY`를 환경변수로만 받는다. 로그와 결과 메시지에는 키를 넣지 않는다. +종료 코드는 자식의 코드이고, 124 = 작업 시간 제한, 127 = spawn 실패, 130/143/129 = SIGINT/SIGTERM/SIGHUP 취소다. +시간 제한과 취소 시에는 SIGTERM을 보내고 10초 유예 후 SIGKILL을 보낸다. 정리는 `setDesired(0)` 후 ASG를 다시 읽어 +desired=0인지 확인하며, 실패하면 작업 결과와 정리 오류를 함께 메시지에 남기고 작업이 성공했더라도 종료 코드를 +0이 아닌 값으로 만든다. + +소유권 조건은 다음과 같다(반복 2에서 강화). batch 요청 시 스택에 desired>0이거나 인스턴스(Pending·InService· +Terminating 등 상태 무관)가 남아 있으면 시작을 거부하고 아무 ASG 설정도 바꾸지 않는다. 이 검사는 배포 전 +(`ensureStackReady`)과 desired를 올리기 직전(배포·시딩이 수 분 걸리므로) 두 번 한다. 반대로 batch 스택에서 GPU가 있으면 +interactive/online 요청(`tkf up`)도 거부한다. 같은 호스트에서는 `~/.token-forge/locks/@.lock`이 +두 번째 `tkf run`과, 준비 단계(desired=0)의 batch를 `tkf up`/`tkf seed`가 인수하는 경쟁을 막는다. 잠금은 pid를 다 쓴 +임시 파일을 `link()`로 만들어(대상이 있으면 EEXIST로 원자적 실패) 빈 잠금이 보이는 순간이 없고, 남은 잠금은 자동 +회수하지 않는다(회수의 read→rm→create 경쟁으로 새 소유자 잠금을 지울 수 있어서). 오류 메시지에 pid와 파일 경로를 +보여 사용자가 확인 후 지운다. 호스트 간 잠금은 없다. + +자식은 별도 프로세스 그룹(`detached`)으로 실행하고 신호는 그룹 전체에 보낸다. 자식이 끝나면 그룹에 남은 손자를 +SIGKILL로 정리하고, 이후 kill 호출은 아무것도 하지 않는다. 취소·시간 제한으로 예약한 SIGKILL 타이머는 작업 종료 시 +모두 취소한다. 분리된 그룹은 터미널 Ctrl-C를 직접 받지 않으므로 tkf가 받은 신호를 SIGTERM으로 전달하며, stdin이 +TTY면 백그라운드 그룹의 TTY 읽기 정지(SIGTTIN)를 피하려고 stdin을 주지 않는다(파이프 stdin은 전달). 잠금 이후의 +모든 단계는 바깥 `try/finally`에서 신호 핸들러와 잠금을 해제한다. 이전 state.json 복원 실패는 GPU 정리 실패와 +별개 메모로 보고하며 종료 코드에 영향을 주지 않는다. + +## 3. 초안과 다르게 정한 부분 (결정과 이유) + +1. **batch 스택은 유휴 알람을 "백스톱"으로 유지한다.** 초안은 "작업 동안 요청 기반 종료를 사용하지 않는다"였다. + 이 요구는 작업 중 1분 간격 하트비트(무인증 `/v1/models` 핑, 기존 race가 쓰던 방식)로 알람 발화를 막아 충족한다. + 알람 자체를 없애면 `tkf run`이 SIGKILL로 죽거나 호스트가 유실됐을 때 GPU를 내릴 장치가 없어진다. 이 프로젝트의 + 비용 규칙(CLAUDE.local.md: 유휴 방치 금지)을 우선해, 하트비트가 끊긴 뒤 idleMinutes(기본 30분) 후 GPU가 정지되게 + 했다. 그래서 batch에서 `idleMinutes=0`은 거부한다. +2. **batch는 `--region` 필수(단일 리전).** 레이스로 여러 리전 GPU를 동시에 올리면 소유권 확인과 정리 대상이 늘어난다. + 초안의 명령 형태도 `--region`을 포함하므로 이번 범위에서는 단일 리전만 지원한다. +3. **작업 시간 제한 기본 120분.** 하트비트가 작업 동안 유휴 정지를 막으므로, 멈춘 작업은 무기한 과금될 수 있다. + 기본 제한을 두고 `--timeout 0`으로만 무제한을 허용한다. +4. **state.json 처리.** `ensureStackReady`가 run 리전을 state에 저장하므로 실행 중에는 `tkf down`이 run 리전을 + 대상으로 한다(비정상 종료 복구용). 정리가 성공하면 실행 전 state를 복원해 다른 리전 대화형 세션의 `tkf down` + 대상을 보존하고, 정리가 실패하면 run 리전 state를 남긴다. +5. **jest 해석 순서 변경.** 기본 `moduleFileExtensions`가 `.js`를 먼저 찾아 tsc 산출물이 `.ts`를 가렸다. + `.ts` 우선으로 바꿔 테스트가 항상 실제 소스를 검증하게 했다. +6. **docs/value-proposition.md도 정정.** README가 비용 근거로 링크하는 문서에 같은 "쓴 시간만큼만" 표현이 있어 + 함께 고쳤다. + +## 4. 알려진 한계 + +- interactive의 ALB RequestCount 감시는 실행 중인 긴 스트리밍 응답이나 대기 작업을 보지 못한다. 기본값을 안전하게 + 줄이려면 실행·대기 요청 수와 최근 활동을 보는 별도 활동 감시가 필요하며, 이번 변경 범위가 아니다. +- CloudWatch 5분 주기 평가와 결측 데이터 처리 때문에 실제 정지는 설정값보다 몇 분 늦을 수 있다(정확한 과금 종료 + 시각이 아님). +- 잠금은 같은 호스트 안에서만 동작한다. 두 호스트가 같은 순간에 시작하면 둘 다 ASG 검사를 통과할 수 있다. +- `tkf run`이 SIGKILL·호스트 유실로 끝나면 자동 정리는 보장되지 않는다. 정지 Lambda는 유휴 알람이 ALARM으로 + "바뀔 때"만 실행되므로, 부팅 프로브가 알람을 OK로 돌리기 전(GPU 요청 후 대략 처음 5-10분)에 죽으면 새 상태 변화가 + 없어 GPU가 계속 실행된다. 정상적으로 발화하더라도 5분 평가와 이벤트 전달 지연으로 설정 시각보다 늦다. 복구는 + `tkf down --region ` 후 `tkf status` 확인이다(README 3개 언어에 기록). 이 구멍을 닫으려면 주기적으로 알람 상태와 + ASG를 보는 별도 감시가 필요하지만, 부팅 중 인스턴스를 잘못 내릴 위험이 있어 이번 범위에서 제외했다. +- 정리 성공은 ASG desired=0이 받아들여졌다는 뜻이며 인스턴스 종료·과금 중단 시점까지 확인한 것이 아니다. +- `setsid` 등으로 프로세스 그룹을 벗어난 손자 프로세스는 정리하지 않는다. +- 실제 AWS 배포·스팟 확보·CloudWatch 알람 발화·CFN 갱신이 인스턴스를 교체하지 않는지는 실환경에서 검증하지 않았다. + LT 동일성은 합성 템플릿 비교로만 확인했다. + +## 5. 검증 결과 (반복 1, 2026-10-24) + +| 항목 | 명령 | 결과 | +| --- | --- | --- | +| 기준선(변경 전) | `npx jest --runInBand` (stale JS 삭제 후) | 146 통과 | +| 전체 테스트 | `npx jest --runInBand` | 26 스위트, 223 통과 | +| 타입 검사·빌드 | `npm run build` | 통과 | +| stale JS 차단 | `cli/commands/run.js`를 일부러 깨진 내용으로 바꾼 뒤 `test/cli-run.test.ts` 실행 | 19 통과(.ts 사용 확인) 후 원복 | +| 오프라인 합성 | 임시 디렉터리 + 더미 AZ 컨텍스트, `AWS_EC2_METADATA_DISABLED=true CDK_DEFAULT_ACCOUNT=111111111111 CDK_DEFAULT_REGION=us-east-2 cdk synth --no-lookups` | 아래 표 | + +| 합성 컨텍스트 (qwen3-coder-30b/fp8) | UsageMode | IdleMinutes | ASG Min/Max | 유휴 알람 평가 주기 | +| --- | --- | --- | --- | --- | +| `usageMode=interactive` | interactive | 30 | 1/1 | 6 | +| `usageMode=batch` | batch | 30 | 0/1 | 6 | +| `usageMode=online -c minCapacity=0` | online | 0 | 0/1 | 없음 | +| `usageMode=online` | online | 0 | 1/1 | 없음 | +| `idleMinutes=60` | interactive | 60 | 1/1 | 12 | +| `solar-open2-250b/int4` 기본 | interactive | 30 | 1/1 | 6 | + +거부 확인(합성 rc=1): `online + idleMinutes=30`, `batch + minCapacity=1`, `idleMinutes=12`, `usageMode=bogus`. +리포지토리의 `cdk.context.json`(사용자 계정 AZ 캐시)은 건드리지 않기 위해 합성은 `/tmp`의 별도 `cdk.json`과 +더미 AZ 컨텍스트로 실행했다. 첫 합성 시도는 zsh가 `$m`을 단어 분리하지 않아 컨텍스트가 전달되지 않았고, +`${=m}`으로 고쳐 다시 실행한 결과가 위 표다. 이 과정에서 비용 검사 훅이 합성 명령에 포함된 +`idleMinutes=0` 케이스를 지적했다. 해당 명령은 `cdk synth`뿐이라 리소스가 생성되지 않았고, 이후 합성에서는 그 값을 쓰지 않았다 +(`idleMinutes=0` 해석은 단위 테스트로 검증). + +추가된 테스트 파일: `test/usage-mode.test.ts`(정책 해석·우선순위·왕복), `test/token-forge-stack.test.ts`의 +`usage modes (#43)`(합성), `test/cli-usage-mode.test.ts`(기존 스택 비교·배포 결정, online min=1, down, 자동 경로 전달), +`test/cli-run.test.ts`(batch 래퍼: 성공/실패/spawn 오류/시간 제한/SIGINT·SIGTERM/부팅 중 취소/부팅 제한/정리 실패·검증/ +소유권/잠금/state 복원), `test/cli-program.test.ts`(명령 등록·`--` 파싱·실제 자식 프로세스 spawn/ENOENT/kill), +`test/readme-cost.test.ts`(README 3개 언어 비용 문구). 스택·CLI 테스트는 구현 전에 추가해 실패를 먼저 확인했다. + +## 6. 반복 리뷰 기록 + +### 반복 1 + +구현과 위 검증을 마쳤다. diff와 완료 조건에 대한 독립 리뷰는 반복 2에서 수행한다. + +### 반복 2 (2026-10-24) + +리뷰 메모(소유권, 잠금, 프로세스 정리, 문서, 합성 검증, 테스트 범위)와 실제 diff를 다시 검토해 다음 결함을 고쳤다. + +| 지적 | 판단 | 조치 | +| --- | --- | --- | +| 소유권 검사가 desired=0·InService 없음이면 Pending/Terminating 인스턴스를 놓침 | 결함 | `instanceIds` 전체로 판정, batch·기존 batch 스택 양쪽 회귀 테스트 | +| 배포·시딩(수 분) 사이 다른 세션이 GPU를 올리면 batch가 인수 | 결함 | desired를 올리기 직전 재검사, 테스트(설정 호출 0회 확인) | +| 같은 호스트 `tkf up`/`seed`가 준비 중 batch를 인수 | 결함 | ensure가 같은 호스트 잠금을 확인(`batchLockHolder`, run 자신은 미주입), 테스트 | +| 잠금 wx→pid 쓰기 사이 빈 파일, 죽은 잠금 회수 경쟁 | 결함 | `link()` 원자 생성, 자동 회수 제거·수동 해제 안내, 테스트 | +| 취소 신호의 SIGKILL 타이머 미취소 | 결함 | 작업 종료 시 모든 타이머 취소, 테스트 | +| 잠금 이후 예외(loadState 등)에서 잠금·핸들러 누수 | 결함 | 바깥 try/finally, 테스트 | +| 상태 복원 실패와 GPU 정리 실패 혼동 | 결함 | 별개 메모로 보고, 테스트 | +| 신호 무시 자식·손자 잔존 | 결함 | 프로세스 그룹 kill + 종료 후 그룹 SIGKILL, 실제 자식 프로세스 테스트 4개(시간 제한·SIGINT·정상 종료 손자·ENOENT) | +| online min=1 실패 시 상태·복구 안내 | 확인 | 이미 실패 보고. state 보존과 `tkf down` 안내를 테스트로 고정 | +| 백스톱이 정확한 종료를 보장하는 것처럼 서술 | 문서 결함 | ALARM 상태 유지 시 미발화 한계를 README·이 문서에 명시 | +| `tkf down`/정리 = 즉시 과금 0으로 읽히는 표현 | 문서 결함 | "용량 0 요청 수락, 종료는 수 분" 으로 정정 | +| CLAUDE.md "가드를 끄지 말 것"과 online 모순 | 문서 결함 | interactive·batch 유지, online은 명시 선택으로 정리 | +| `--region` 생략 자동 선택 설명이 run과 혼동 | 문서 결함 | `tkf up`에 한정 | +| 합성 검증이 셸 문자열·파이프로 재현성 부족 | 결함 | `scripts/verify-usage-mode-synth.ts`(배열 인자, 케이스별 새 출력 디렉터리, exit·값 assert) | +| README 문구 고정 테스트 | 저가치 | `test/readme-cost.test.ts` 삭제, 문서는 이 검토 기록으로 확인 | + +반복 2의 회귀 테스트는 수정과 같은 단계에서 추가했으며, 수정 전 코드에서 실패하는 것을 별도로 관찰하지는 않았다 +(반복 1의 스택·CLI 테스트는 구현 전 실패를 관찰했다). + +검증(반복 2, 수정 후 실행): + +| 항목 | 명령 | 결과 | +| --- | --- | --- | +| 전체 테스트 | `npx jest --runInBand` | 25 스위트, 231 통과 | +| 타입 검사·빌드 | `npm run build` | exit 0 | +| 오프라인 합성 | `npx ts-node scripts/verify-usage-mode-synth.ts` | exit 0, `PASS 12 cases` (정상 7, 거부 5) | +| 잔존 프로세스 | 테스트 후 `pgrep -fl setInterval` | 없음 | + +합성 스크립트의 정상 케이스: 기본(interactive/30/min1/알람 6주기), interactive+min0, idleMinutes=60(12주기), +idleMinutes=0(합성만, 알람 없음), batch(30/min0/알람 6주기), online+min0, online(min1, 알람 없음). +거부 케이스: online+idle30, batch+min1, batch+idle0, idle12, bogus 모드. + +### 반복 3 (2026-10-24) + +필수 추가 리뷰 4건을 반영했다. + +1. **기존 스택 갱신 시 보안·운영 컨텍스트 유실(치명).** 스택이 읽는 컨텍스트는 `usageMode`, `idleMinutes`, + `minCapacity`, `azs`, `allowedCidrs`, `alertEmail`(앱: `model`, `profile`, `region`, `collector`)이다. 반복 2의 자동 + `cdk deploy`는 `azs`/`allowedCidrs`/`alertEmail`을 넘기지 않아, 최초 배포 때 지정한 IP 허용목록이 풀리고 AZ 선택·알림 + 구독이 초기화될 수 있었다. 스택이 실제 적용한 세 값을 출력 `DeploymentSettings`(v1 JSON, `lib/deployment-settings.ts`)에 + 기록하고, 정책 갱신 배포 때 이 값을 인자 배열로 다시 전달한다. 배포 후 기록이 달라지면 실패한다. 기록이 없거나 + 손상·미지원 버전이면 기본값으로 바꾸지 않고 자동 배포를 거부하며 수동 `npx cdk deploy` 명령을 안내한다. + 구버전 스택(모드 기록 없음) + 기본 interactive/30분 요청은 이전 CLI와 같이 배포 없이 그대로 사용하고, 그 외 요청은 수동 + 갱신을 안내한다(반복 1의 "구버전은 자동 1회 갱신"을 철회 — 보안 보존 우선). 정책이 같으면 기록 유무와 관계없이 배포하지 + 않는다. 회귀 테스트: interactive→online, idleMinutes 변경, online→interactive, interactive→batch에서 세 값 보존, 빈 설정은 + 인자 미추가, 배포 후 설정 변화 실패, 기록 없음·손상 3종 거부(배포 0회), 구버전 기본 요청 무배포, 구버전 모드 변경 거부, + 정책 동일 시 무배포. 스택 합성 테스트로 출력 기록(정규화된 CIDR 포함)을 확인. 이 테스트들은 구현 전에 추가해 11건 실패를 + 먼저 관찰했다. +2. **키 조회 중 취소 시 자식이 잠깐 실행됨.** `getSecret` 직후 취소를 다시 확인해 자식을 시작하지 않는다. 해당 구간 SIGINT + 테스트를 먼저 추가해 실패(타임아웃)를 관찰한 뒤 수정했다. +3. **합성 스크립트의 app 문자열 인용.** cdk CLI는 `cdk.json`의 `app`을 셸로 실행하므로 각 경로를 POSIX 작은따옴표로 + 인용했다(macOS/Linux 전용). 저장소를 공백·작은따옴표가 포함된 임시 경로(예: `/tf root's dir`)의 심볼릭 링크로 열고 + `--preserve-symlinks`로 실행해 스크립트의 ROOT가 그 경로임을 확인했고, 13케이스가 통과했다. 스크립트에 보안 설정 케이스 + (allowedCidrs/azs/alertEmail → DeploymentSettings)와 모든 케이스의 DeploymentSettings 검사를 추가했다. +4. **README 절대 표현.** batch 표의 "유휴 꼬리 없음"을 "정지 요청·인스턴스 종료까지 몇 분은 남음, 유휴 대기는 없음"으로, + "인수·정지하지 않는다"를 "보이는 인스턴스는 인수·정지하지 않으며 같은 순간 시작하는 다른 호스트는 볼 수 없음"으로 + 3개 언어 모두 고쳤다. 마이그레이션 절(구버전 스택 처리와 수동 갱신 명령)을 추가했다. CLAUDE.md에 DeploymentSettings 규칙을 추가했다. + +검증(반복 3, 마지막 코드 수정 후 실행): + +| 항목 | 명령 | 결과 | +| --- | --- | --- | +| 전체 테스트 | `npx jest --runInBand` | 25 스위트, 246 통과 | +| 타입 검사·빌드 | `npm run build` | exit 0 | +| 오프라인 합성 | `npx ts-node scripts/verify-usage-mode-synth.ts` | exit 0, `PASS 13 cases` (정상 8, 거부 5) | +| 공백·따옴표 경로 합성 | 위 스크립트를 공백·작은따옴표 포함 임시 경로로 실행 | exit 0, `PASS 13 cases` | +| 잔존 프로세스 | `pgrep -fl setInterval` | 없음 | + +합성 이후 수정한 파일은 `cli/commands/run.ts`(취소 재확인)와 문서뿐이며, 합성 대상인 스택·앱 코드는 합성 후 바뀌지 않았다. +실제 AWS 배포는 하지 않았으므로 DeploymentSettings 출력이 실제 CFN 스택에서 기대대로 읽히는지, 갱신 배포가 CloudFront +Function·SNS 구독을 바꾸지 않는지는 합성·단위 테스트 수준에서만 확인했다. + +### 반복 4 (2026-10-24) + +Stop hook이 같은 프롬프트로 다시 시작해 반복 3 이후 상태를 다시 검토했다. 검토 범위는 반복 3 필수 리뷰 4건의 반영 여부와 +그 외 경로였다. 그 외 경로는 `tkf down --purge`의 `cdk destroy`, `tkf seed`의 정책 미지정 경로, 자동 리전 `ensure`의 같은 호스트 +잠금 확인, 재전달된 `allowedCidrs`(정규화 값 재입력 시 동일)와 `alertEmail` 구독의 불변성이다. 새 치명·중요 결함은 찾지 못했다. + +이번 반복에서는 코드를 바꾸지 않았다. 반복 3 검증 로그 이후 수정된 `.ts` 파일이 없는 것을 `find -newer`로 확인했다. +그래서 검증을 다시 실행하지 않고 반복 3 결과를 근거로 삼는다(테스트 246 통과, 빌드 exit 0, 합성 `PASS 13 cases`). + +알려진 동작으로 기록만 하는 사항이 하나 있다. `waitReady`는 READY 전에 desired=0을 보면 desired=1로 복구한다. 이것은 이번 변경 전부터 +있던 동작이다. 그래서 부팅 중에 다른 터미널에서 `tkf down`을 실행하면 GPU가 다시 올라갈 수 있다. 이번 범위에서는 바꾸지 않았다. + +## 7. 실제 배포 기록 (2026-10-24) + +사용자의 "배포해" 지시에 따라 기존 스택 한 개에 이 변경을 배포했다. 대상은 운영 AWS 계정(``, 로컬 AWS 프로파일 사용), +리전 ap-northeast-2, 스택 `TokenForge-qwen3-coder-30b-fp8`(모델 `qwen3-coder-30b`, 프로파일 `fp8`)이다. 배포 전 ASG는 +min=0, desired=0, 인스턴스 0개였다. 기존 설정을 유지하려고 `-c usageMode=interactive -c idleMinutes=30 -c minCapacity=0`을 +주었고, `allowedCidrs`·`azs`·`alertEmail`은 기존 스택처럼 지정하지 않았다. + +배포는 두 단계로 했다. 먼저 `cdk deploy --method prepare-change-set --change-set-name tkf-usage-modes-43`으로 변경 세트만 +만들고, `describe-change-set`으로 내용을 검토한 뒤 `execute-change-set`을 실행했다. 실행 시각은 2026-10-24T05:04:26Z이고, +05:04:43Z에 `UPDATE_COMPLETE`가 되었다. 실패나 롤백은 없었다. + +변경 세트의 리소스 변경은 `LaunchTemplate04EC5460` Modify 1건이었다(Replacement=False). 이벤트상 ASG는 새 Launch Template +버전을 참조하도록 갱신만 되었다. 이 변경의 원인은 두 가지다. 첫째, 이전 배포 때 템플릿에 저장된 user-data 주석과 알람 설명의 +비ASCII 문자(한국어, em dash)가 `?`로 손실되어 있었고, 이번 템플릿은 원래 문자를 담고 있다. 비ASCII를 마스킹하면 두 템플릿의 +해당 리소스는 같다. 둘째, SSM 파라미터로 가져오는 DLAMI latest 값이 바뀌었다. Launch Template 이미지는 이전 DLAMI AMI(버전 2)에서 +새 DLAMI AMI(버전 3)로 바뀌었다. 소스의 이미지 참조 방식(SSM latest 파라미터)은 바뀌지 않았다. +리소스 추가·삭제, 보안 규칙 변경은 없었고, 새 출력 3개(`UsageMode`, `IdleMinutes`, `DeploymentSettings`)가 추가되었다. + +배포 후 실제 조회로 다음을 확인했다. 출력은 `UsageMode=interactive`, `IdleMinutes=30`, +`DeploymentSettings={"v":1,"allowedCidrs":"","azs":"","alertEmail":""}`이고, `EndpointUrl`은 기존 +CloudFront 엔드포인트(`https://.cloudfront.net`) 그대로다. 템플릿에는 CloudFront Function과 SNS 이메일 구독이 없고, ASG는 서브넷 4개를 +사용하며 온디맨드 비율은 0%(100% 스팟, capacity-optimized)다. ASG는 min=0, desired=0, 인스턴스 0개다. 유휴 알람은 5분 × 6회 +(30분) 그대로다. + +글로벌 `tkf`는 `npm link --ignore-scripts`로 이 작업 트리의 빌드에 연결했다. `tkf run --help`, `tkf up --help`(`--usage-mode`, +`--idle-minutes` 표시), `tkf status`(모드 interactive, 유휴 종료 30분, desired=0)로 적용을 확인했다. npm 배포, 버전 변경, +커밋은 하지 않았다. 종료 전 ap-northeast-2, ap-northeast-1, us-east-1, us-east-2에서 pending/running 인스턴스가 0개인 것을 +확인했다. + +한계: GPU를 기동하지 않았으므로 새 AMI로의 부팅, vLLM 추론, `tkf run`의 실제 batch 실행, online 모드 전환은 실환경에서 +검증하지 않았다. 증거(변경 세트 JSON, 이벤트, 배포 전후 템플릿, 로그)는 로컬 임시 디렉터리에만 보관했다. diff --git a/docs/value-proposition.md b/docs/value-proposition.md index 8bb79e0..0b22d88 100644 --- a/docs/value-proposition.md +++ b/docs/value-proposition.md @@ -4,7 +4,7 @@ > 나왔고(2026-07-23 - 2026-08-24, 계정 실측), 재현 경로를 함께 적는다. > 제품 서사는 [PR/FAQ](prfaq.md), 요건 근거는 [v1 요건 정의](superpowers/specs/2026-08-22-token-forge-v1-requirements.md) 참고. -## 1. 비용 — 같은 GPU를 몇 분의 일 가격에, 쓴 시간만큼만 +## 1. 비용 — 같은 GPU를 몇 분의 일 가격에, GPU 실행 시간만큼 ### 스팟이 기본, 온디맨드는 예외 @@ -21,9 +21,14 @@ 리전 간 병렬 레이스(First-Acquired-Wins)로 상쇄한다 — 값싼 대신 불안정한 것이 아니라, 값싸고 **확보를 데이터로 관리하는** 구조다. -### 쓴 시간만큼만 — 유휴에 과금되지 않는 4중 장치 +### GPU 실행 시간을 줄이는 4중 장치 -1. **유휴 자동 정지**: ALB 요청이 30분간 0이면 Lambda가 GPU를 0대로 내린다 (기본 켜짐). +GPU 비용은 시간당 단가 × (추론 + 부팅 + 유휴 실행 시간)이다. 아래 장치는 부팅·유휴 시간을 줄이지만 +없애지는 않는다. 사용 모드별 비용 예시는 README의 "사용 모드와 실제 비용" 절을 참고한다. + +1. **유휴 자동 정지**: interactive 모드(기본)에서 ALB 요청이 30분간 0이면 Lambda가 GPU를 0대로 내린다. + 이 30분도 과금된다($2.60/h 가정 시 1회 $1.30, 하루 2회 × 30일이면 $78). batch 모드(`tkf run`)는 + 작업 직후 정지하고, online 모드는 자동 정지하지 않는다. 2. **실패 경로 비용 가드**: `tkf up` 타임아웃·레이스 실패·허용목록 차단(403) 등 모든 실패 경로에서 desired를 0으로 되돌리고, 되돌리기 실패까지 사용자에게 보고한다. 3. **선시딩 분리**: 가중치 다운로드를 GPU가 아닌 저가 CPU 스팟이 수행 — @@ -34,8 +39,8 @@ ### 월 비용으로 환산하면 -30B급 코딩 모델(Qwen3-Coder-30B, g6e.12xlarge) × 일 3-4시간 × 20일 = -**월 $100-200**. 정액 구독과 같은 자릿수이면서 토큰 한도가 없고, 안 쓰는 달은 +30B급 코딩 모델(Qwen3-Coder-30B, g6e.12xlarge, $2.60/h 가정) × 부팅·유휴를 포함한 GPU 실행 일 2-4시간 × 20일 = +**월 약 $100-200**. 이 범위는 사용 시간이 제한된 interactive·batch 사용에만 해당하며, 24시간 online은 월 약 $1,872다. 정액 구독과 같은 자릿수이면서 토큰 한도가 없고, 안 쓰는 달은 GPU 요금이 0이다. 부수 비용은 가중치 S3 보관(리전당 $1-8/월)과 스택 ALB (리전당 약 $16/월, `tkf down --purge`로 제거 가능)뿐. @@ -118,7 +123,7 @@ tkf connect claude # source ~/.token-forge/env.sh && claude | | token-forge | 구독형 코딩 AI | 직접 vLLM 운영 | |---|---|---|---| | 코드 반출 | 없음 (계정 내 완결) | 외부 API 전송 | 없음 | -| 비용 구조 | 쓴 시간만 (월 $100-200) | 정액 + 한도 | 동일하나 운영 비용 추가 | +| 비용 구조 | GPU 실행 시간 과금 (제한된 사용 시 월 약 $100-200) | 정액 + 한도 | 동일하나 운영 비용 추가 | | 시작까지 | 명령 2개, 약 20분(첫 회) | 즉시 | 리전 선정·스팟 운영·TLS 전부 수동 | | 스팟 확보 | 배치점수 + 병렬 레이스 자동 | 해당 없음 | 수동 재시도 | | 토큰 한도 | 없음 | 있음 | 없음 | From f88dd905d985a66e8186fa5d5d3da58151d0f6da Mon Sep 17 00:00:00 2001 From: Jung Do Hyun Date: Sat, 24 Oct 2026 14:17:34 +0900 Subject: [PATCH 3/3] =?UTF-8?q?release:=20v1.1.0=20=E2=80=94=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EB=AA=A9=EC=A0=81=EB=B3=84=20GPU=20=EC=88=98?= =?UTF-8?q?=EB=AA=85=20=EC=A0=95=EC=B1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #43 Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3fe17d9..a157e9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@roboco/token-forge", - "version": "1.0.2", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@roboco/token-forge", - "version": "1.0.2", + "version": "1.1.0", "license": "MIT", "dependencies": { "@aws-sdk/client-auto-scaling": "^3.1116.0", diff --git a/package.json b/package.json index f8f3ef4..c944a95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@roboco/token-forge", - "version": "1.0.2", + "version": "1.1.0", "description": "Private vibe-coding LLM in your own AWS account — open-weight models on 100% spot instances with placement-score intelligence", "keywords": [ "llm",