feat(release): 정적 프런트엔드와 격리 빌더를 도입한다

Gateway와 프로필 릴리스가 불변 프런트엔드 아티팩트를 원자적으로 게시하고 실패 시 이전 포인터를 복구하도록 한다.

릴리스 빌드는 전용 builder protocol로 분리하고 성공한 Gateway 커밋을 재기동용 Git ref에 고정한다.
This commit is contained in:
2026-08-22 09:32:47 +00:00
parent d74a8e48bb
commit d3e5d12cbf
18 changed files with 991 additions and 48 deletions
+11
View File
@@ -1,6 +1,7 @@
import path from 'node:path';
import { sanitizeManagedProcessEnv } from '@sammo-ts/gateway-api';
import { resolveFrontendServeMode, type FrontendServeMode } from '@sammo-ts/gateway-api';
import { resolvePostgresPoolMax } from '@sammo-ts/infra';
const parsePositiveInt = (value: string | undefined, fallback: number, name: string): number => {
@@ -24,6 +25,11 @@ export interface ReleaseControllerConfig {
gatewayApiPort: number;
gatewayFrontendPort: number;
gatewayBasePath: string;
frontendServeMode?: FrontendServeMode;
frontendArtifactRoot?: string;
frontendReadinessOrigin?: string;
releaseBuilderUrl?: string;
activeReleaseGitRef?: string;
pollIntervalMs: number;
readinessTimeoutMs: number;
postgresPoolMax: number;
@@ -50,6 +56,11 @@ export const resolveReleaseControllerConfig = (env: NodeJS.ProcessEnv = process.
gatewayApiPort: parsePositiveInt(env.GATEWAY_API_PORT, 15001, 'GATEWAY_API_PORT'),
gatewayFrontendPort: parsePositiveInt(env.GATEWAY_FRONTEND_PORT, 15000, 'GATEWAY_FRONTEND_PORT'),
gatewayBasePath: env.GATEWAY_BASE_PATH?.trim() || '/gateway',
frontendServeMode: resolveFrontendServeMode(env.FRONTEND_SERVE_MODE),
frontendArtifactRoot: path.resolve(env.FRONTEND_ARTIFACT_ROOT ?? '/srv/frontend-artifacts'),
frontendReadinessOrigin: env.FRONTEND_READINESS_ORIGIN?.trim() || 'http://caddy',
releaseBuilderUrl: env.RELEASE_BUILDER_URL?.trim() || undefined,
activeReleaseGitRef: env.GATEWAY_ACTIVE_RELEASE_GIT_REF?.trim() || undefined,
pollIntervalMs: parsePositiveInt(env.RELEASE_CONTROLLER_POLL_MS, 5000, 'RELEASE_CONTROLLER_POLL_MS'),
readinessTimeoutMs: parsePositiveInt(
env.RELEASE_CONTROLLER_READINESS_TIMEOUT_MS,
+4 -1
View File
@@ -4,6 +4,7 @@ import {
GitWorkspaceManager,
Pm2ProcessManager,
PnpmBuildRunner,
createReleaseBuildRunner,
} from '@sammo-ts/gateway-api';
import { resolveReleaseControllerConfig } from './config.js';
@@ -28,6 +29,7 @@ const main = async (): Promise<void> => {
baseEnv: config.baseEnv,
});
const buildRunner = new PnpmBuildRunner();
const releaseBuildRunner = createReleaseBuildRunner(config.releaseBuilderUrl, buildRunner);
const processManager = new Pm2ProcessManager();
const controller = new GatewayReleaseController(repository, workspaceManager, buildRunner, processManager, config);
const command = process.argv[2] ?? 'daemon';
@@ -57,7 +59,8 @@ const main = async (): Promise<void> => {
sourceMode,
sourceRef,
workspaceManager,
buildRunner,
buildRunner: releaseBuildRunner,
migrationRunner: buildRunner,
processManager,
config,
});
+105 -30
View File
@@ -17,8 +17,11 @@ import {
type GitWorkspaceManager,
type ProcessDefinition,
type ProcessManager,
createReleaseBuildRunner,
FrontendArtifactManager,
readReleaseManifest,
sanitizeManagedProcessEnv,
sanitizeReleaseBuildEnv,
} from '@sammo-ts/gateway-api';
import { resolvePostgresPoolMax } from '@sammo-ts/infra';
@@ -27,7 +30,7 @@ import type { ReleaseControllerConfig } from './config.js';
const LEASE_DURATION_MS = 10 * 60_000;
const HEARTBEAT_INTERVAL_MS = 60_000;
const CANCELLATION_POLL_INTERVAL_MS = 500;
const PROCESS_NAMES = ['sammo:gateway-api', 'sammo:gateway-frontend', 'sammo:gateway-orchestrator'] as const;
const MANAGED_PROCESS_NAMES = ['sammo:gateway-api', 'sammo:gateway-frontend', 'sammo:gateway-orchestrator'] as const;
const SENSITIVE_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|CLIENT_SECRET|DATABASE_URL|REDIS_URL)/iu;
export const RELEASE_WORKSPACE_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1_000;
@@ -48,13 +51,14 @@ const buildGatewayReleaseCommands = (
needsInstall: boolean,
config: ReleaseControllerConfig
): BuildCommand[] => {
const env = {
...sanitizeManagedProcessEnv(config.baseEnv),
const env = sanitizeReleaseBuildEnv({
...config.baseEnv,
NODE_OPTIONS: config.baseEnv.RELEASE_BUILD_NODE_OPTIONS ?? config.baseEnv.NODE_OPTIONS,
VITE_APP_BASE_PATH: config.gatewayBasePath,
VITE_GATEWAY_API_URL: `${config.gatewayBasePath}/api/trpc`,
VITE_GAME_API_URL_TEMPLATE: '/{profile}/api/trpc',
VITE_GAME_WEB_URL_TEMPLATE: '/{profile}/',
};
});
return [
...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []),
buildTurboReleaseCommand(workspaceRoot, config.workspaceRoot, ['@sammo-ts/gateway-api'], env),
@@ -97,7 +101,7 @@ export const buildGatewayProcessDefinitions = (
GATEWAY_API_PORT: String(config.gatewayApiPort),
GATEWAY_DATABASE_URL: config.gatewayDatabaseUrl,
};
return [
const definitions: ProcessDefinition[] = [
{
name: 'sammo:gateway-api',
script: apiScript,
@@ -108,13 +112,6 @@ export const buildGatewayProcessDefinitions = (
GATEWAY_ROLE: 'api',
},
},
{
name: 'sammo:gateway-frontend',
script: frontendScript,
cwd: frontendCwd,
args: ['preview', '--host', '0.0.0.0', '--port', String(config.gatewayFrontendPort)],
env,
},
{
name: 'sammo:gateway-orchestrator',
script: apiScript,
@@ -126,6 +123,16 @@ export const buildGatewayProcessDefinitions = (
},
},
];
if (config.frontendServeMode !== 'static') {
definitions.splice(1, 0, {
name: 'sammo:gateway-frontend',
script: frontendScript,
cwd: frontendCwd,
args: ['preview', '--host', '0.0.0.0', '--port', String(config.gatewayFrontendPort)],
env,
});
}
return definitions;
};
const isMissingProcessError = (error: unknown): boolean =>
@@ -133,6 +140,8 @@ const isMissingProcessError = (error: unknown): boolean =>
export class GatewayReleaseController {
private readonly ownerId = randomUUID();
private readonly releaseBuildRunner: BuildRunner;
private readonly artifactManager: FrontendArtifactManager;
constructor(
private readonly repository: GatewayReleaseRepository,
@@ -142,7 +151,10 @@ export class GatewayReleaseController {
private readonly config: ReleaseControllerConfig,
private readonly now: () => Date = () => new Date(),
private readonly fetchImpl: typeof fetch = fetch
) {}
) {
this.releaseBuildRunner = createReleaseBuildRunner(config.releaseBuilderUrl, buildRunner, fetchImpl);
this.artifactManager = new FrontendArtifactManager(config.frontendArtifactRoot ?? '/srv/frontend-artifacts');
}
async cleanupStaleWorkspaces(): Promise<{ removed: string[]; skipped: string[] }> {
const [state, processes, workspaces] = await Promise.all([
@@ -317,12 +329,20 @@ export class GatewayReleaseController {
const manifest = await readReleaseManifest(workspace.root);
assertReleaseComponents(manifest, ['gateway-api', 'gateway-frontend']);
await this.appendLog(operation.id, 'build', 'Gateway 구성 요소를 빌드합니다.');
const build = await this.buildRunner.run(
const build = await this.releaseBuildRunner.run(
buildGatewayReleaseCommands(workspace.root, workspace.needsInstall, this.config),
this.buildProgress(operation.id, 'build'),
{ signal }
);
if (!build.ok) throw new Error(`Gateway release build failed: ${build.output.slice(-4000)}`);
const stagedArtifact =
this.config.frontendServeMode === 'static'
? await this.artifactManager.stage({
frontendKey: 'gateway',
sourceRoot: path.join(workspace.root, 'app', 'gateway-frontend', 'dist'),
commitSha,
})
: null;
await this.appendLog(operation.id, 'migration', 'Gateway database migration을 적용합니다.');
await this.assertOperationLease(operation.id);
const migration = await this.buildRunner.run(
@@ -339,8 +359,15 @@ export class GatewayReleaseController {
await this.appendLog(operation.id, 'switch', '기존 Gateway process를 정지합니다.');
await this.assertOperationLease(operation.id);
await this.stopManagedProcesses(operation.id);
const previousArtifactReleaseId =
this.config.frontendServeMode === 'static'
? await this.artifactManager.readCurrentReleaseId('gateway')
: null;
try {
await this.startDefinitions(buildGatewayProcessDefinitions(workspace.root, this.config), operation.id);
if (stagedArtifact) {
await this.artifactManager.activate('gateway', stagedArtifact.releaseId);
}
await this.waitForReadiness(operation.id);
} catch (error) {
await this.appendLog(
@@ -350,6 +377,13 @@ export class GatewayReleaseController {
'ERROR'
);
await this.stopManagedProcesses(operation.id);
if (this.config.frontendServeMode === 'static') {
if (previousArtifactReleaseId) {
await this.artifactManager.activate('gateway', previousArtifactReleaseId);
} else {
await this.artifactManager.deactivate('gateway');
}
}
if (previousDefinitions.length) {
await this.startDefinitions(previousDefinitions, operation.id);
await this.waitForReadiness(operation.id);
@@ -357,12 +391,43 @@ export class GatewayReleaseController {
throw error;
}
await this.appendLog(operation.id, 'publish', '검증된 Gateway 릴리스를 active 상태로 게시합니다.');
await this.repository.publishRelease(operation.id, this.ownerId, {
commitSha,
workspace: workspace.root,
previousCommitSha: state.activeCommitSha,
previousWorkspace: state.activeWorkspace,
});
const activeReleaseGitRef = this.config.activeReleaseGitRef;
const previousPersistentCommit = activeReleaseGitRef
? await this.workspaceManager.readPersistentReleaseRef(activeReleaseGitRef)
: null;
if (activeReleaseGitRef) {
await this.workspaceManager.compareAndSwapPersistentReleaseRef(
activeReleaseGitRef,
previousPersistentCommit,
commitSha
);
}
try {
await this.repository.publishRelease(operation.id, this.ownerId, {
commitSha,
workspace: workspace.root,
previousCommitSha: state.activeCommitSha,
previousWorkspace: state.activeWorkspace,
});
} catch (error) {
if (activeReleaseGitRef) {
try {
await this.workspaceManager.compareAndSwapPersistentReleaseRef(
activeReleaseGitRef,
commitSha,
previousPersistentCommit
);
} catch (rollbackError) {
await this.appendLog(
operation.id,
'rollback',
`Gateway bootstrap ref 복구 실패: ${String(rollbackError)}`,
'ERROR'
);
}
}
throw error;
}
}
private async startDefinitions(definitions: ProcessDefinition[], operationId: string): Promise<void> {
@@ -388,7 +453,7 @@ export class GatewayReleaseController {
private async stopManagedProcesses(operationId: string): Promise<void> {
const existing = new Set((await this.processManager.list()).map((process) => process.name));
const failures: string[] = [];
for (const name of [...PROCESS_NAMES].reverse()) {
for (const name of [...MANAGED_PROCESS_NAMES].reverse()) {
if (!existing.has(name)) continue;
await this.appendLog(operationId, 'switch', `${name} process를 정리합니다.`);
try {
@@ -406,26 +471,36 @@ export class GatewayReleaseController {
}
private async waitForReadiness(operationId: string): Promise<void> {
await this.appendLog(operationId, 'readiness', 'Gateway API, frontend와 PM2 process readiness를 확인합니다.');
await this.appendLog(operationId, 'readiness', 'Gateway API, 정적 frontend와 PM2 process readiness를 확인합니다.');
const deadline = Date.now() + this.config.readinessTimeoutMs;
const apiUrl = `http://127.0.0.1:${this.config.gatewayApiPort}/healthz`;
const frontendUrl = `http://127.0.0.1:${this.config.gatewayFrontendPort}${this.config.gatewayBasePath}/`;
const frontendUrl =
this.config.frontendServeMode === 'static'
? new URL(
`${this.config.gatewayBasePath.replace(/\/$/u, '')}/`,
this.config.frontendReadinessOrigin ?? 'http://caddy'
).toString()
: `http://127.0.0.1:${this.config.gatewayFrontendPort}${this.config.gatewayBasePath}/`;
const expectedNames = buildGatewayProcessDefinitions(this.config.workspaceRoot, this.config).map(
(definition) => definition.name
);
while (Date.now() < deadline) {
try {
const [api, frontend] = await Promise.all([this.fetchImpl(apiUrl), this.fetchImpl(frontendUrl)]);
const [api, frontend] = await Promise.all([
this.fetchImpl(apiUrl),
this.fetchImpl(frontendUrl),
]);
const processes = await this.processManager.list();
const expected = processes.filter((process) =>
PROCESS_NAMES.includes(process.name as (typeof PROCESS_NAMES)[number])
);
const expected = processes.filter((process) => expectedNames.includes(process.name));
const safe = expected.filter(
(process) => process.status.toLowerCase() === 'online' && (process.restartCount ?? 0) === 0
);
if (
api.ok &&
frontend.ok &&
expected.length === PROCESS_NAMES.length &&
safe.length === PROCESS_NAMES.length &&
new Set(safe.map((process) => process.name)).size === PROCESS_NAMES.length
expected.length === expectedNames.length &&
safe.length === expectedNames.length &&
new Set(safe.map((process) => process.name)).size === expectedNames.length
) {
await this.appendLog(operationId, 'readiness', 'Gateway readiness 확인을 통과했습니다.');
return;
+6 -2
View File
@@ -10,6 +10,7 @@ import {
type ProcessManager,
readReleaseManifest,
sanitizeManagedProcessEnv,
sanitizeReleaseBuildEnv,
} from '@sammo-ts/gateway-api';
import type { ReleaseControllerConfig } from './config.js';
@@ -22,7 +23,7 @@ const buildReleaseControllerCommands = (
needsInstall: boolean,
config: ReleaseControllerConfig
): BuildCommand[] => {
const env = sanitizeManagedProcessEnv(config.baseEnv);
const env = sanitizeReleaseBuildEnv(config.baseEnv);
return [
...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []),
buildTurboReleaseCommand(workspaceRoot, config.workspaceRoot, ['@sammo-ts/release-controller'], env),
@@ -56,6 +57,7 @@ export const upgradeReleaseController = async (options: {
sourceRef: string;
workspaceManager: GitWorkspaceManager;
buildRunner: BuildRunner;
migrationRunner?: BuildRunner;
processManager: ProcessManager;
config: ReleaseControllerConfig;
readinessTimeoutMs?: number;
@@ -71,7 +73,9 @@ export const upgradeReleaseController = async (options: {
buildReleaseControllerCommands(workspace.root, workspace.needsInstall, options.config)
);
if (!build.ok) throw new Error(`Release controller build failed: ${build.output.slice(-4000)}`);
const migration = await options.buildRunner.run([buildGatewayMigrationCommand(workspace.root, options.config)]);
const migration = await (options.migrationRunner ?? options.buildRunner).run([
buildGatewayMigrationCommand(workspace.root, options.config),
]);
if (!migration.ok) throw new Error(`Gateway migration failed: ${migration.output.slice(-4000)}`);
const existing = (await options.processManager.list()).find((process) => process.name === CONTROLLER_PROCESS_NAME);