diff --git a/.github/workflows/be-cd.yml b/.github/workflows/be-cd.yml index 9145097c..6e5c2970 100644 --- a/.github/workflows/be-cd.yml +++ b/.github/workflows/be-cd.yml @@ -51,7 +51,7 @@ jobs: docker rm froxy-container fi docker pull ${{ secrets.DOCKER_USERNAME }}/froxy-server:latest && \ - docker run --network froxy-network -d --name froxy-container -p 3000:3000 -v /var/run/docker.sock:/var/run/docker.sock ${{ secrets.DOCKER_USERNAME }}/froxy-server:latest + docker run --network host -d --name froxy-container -v /var/run/docker.sock:/var/run/docker.sock ${{ secrets.DOCKER_USERNAME }}/froxy-server:latest docker image prune -f " diff --git a/.github/workflows/fe-cd.yml b/.github/workflows/fe-cd.yml index 6c88dd99..75f86ca6 100644 --- a/.github/workflows/fe-cd.yml +++ b/.github/workflows/fe-cd.yml @@ -4,7 +4,6 @@ on: push: branches: - deploy - jobs: fe-cd: runs-on: ubuntu-20.04 diff --git a/apps/backend/src/config/queue.config.ts b/apps/backend/src/config/queue.config.ts index 4b515e0d..2575853e 100644 --- a/apps/backend/src/config/queue.config.ts +++ b/apps/backend/src/config/queue.config.ts @@ -1,10 +1,10 @@ -import { BullRootModuleOptions } from '@nestjs/bull'; -import { ConfigService } from '@nestjs/config'; - -export const queueConfig = (configService: ConfigService): BullRootModuleOptions => ({ - redis: { - host: configService.get('REDIS_HOST', { infer: true }), - port: configService.get('REDIS_PORT', { infer: true }), - password: configService.get('REDIS_PASSWORD', { infer: true }) - } -}); +import { BullRootModuleOptions } from '@nestjs/bull'; +import { ConfigService } from '@nestjs/config'; + +export const queueConfig = (configService: ConfigService): BullRootModuleOptions => ({ + redis: { + host: configService.get('REDIS_HOST', { infer: true }), + port: configService.get('REDIS_PORT', { infer: true }), + password: configService.get('REDIS_PASSWORD', { infer: true }) + } +}); diff --git a/apps/backend/src/config/typeorm.config.ts b/apps/backend/src/config/typeorm.config.ts index 0e9fab48..559437c3 100644 --- a/apps/backend/src/config/typeorm.config.ts +++ b/apps/backend/src/config/typeorm.config.ts @@ -15,5 +15,6 @@ export const typeORMConfig = async (configService: ConfigService): Promise('MYSQL_PASSWORD'), database: configService.get('MYSQL_DATABASE'), entities: [User, Lotus, Comment, Tag, History, LotusTag] + //dropSchema: true, //synchronize: true //todo: env로 release에서는 false가 되도록 해야함 }); diff --git a/apps/backend/src/constants/constants.ts b/apps/backend/src/constants/constants.ts index 7935b8db..1fa1b642 100644 --- a/apps/backend/src/constants/constants.ts +++ b/apps/backend/src/constants/constants.ts @@ -1,19 +1,19 @@ -export const GIST_AUTH_HEADER = (gitToken: string = null) => { - return { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${gitToken}`, - 'X-GitHub-Api-Version': '2022-11-28', - 'Content-Type': 'application/json' - }; -}; -export enum HISTORY_STATUS { - PENDING = 'PENDING', - ERROR = 'ERROR', - SUCCESS = 'SUCCESS' -} - -export enum SUPPORTED_LANGUAGES { - JS = '.js' -} - -export const MAX_CONTAINER_CNT = 6; +export const GIST_AUTH_HEADER = (gitToken: string = null) => { + return { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${gitToken}`, + 'X-GitHub-Api-Version': '2022-11-28', + 'Content-Type': 'application/json' + }; +}; +export enum HISTORY_STATUS { + PENDING = 'PENDING', + ERROR = 'ERROR', + SUCCESS = 'SUCCESS' +} + +export enum SUPPORTED_LANGUAGES { + JS = '.js' +} + +export const MAX_CONTAINER_CNT = 10; diff --git a/apps/backend/src/docker/docker.consumer.ts b/apps/backend/src/docker/docker.consumer.ts index d0c73209..fd7e4650 100644 --- a/apps/backend/src/docker/docker.consumer.ts +++ b/apps/backend/src/docker/docker.consumer.ts @@ -1,205 +1,287 @@ -import { Process, Processor } from '@nestjs/bull'; -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; -import { Job } from 'bull'; -import { Container } from 'dockerode'; -import * as tar from 'tar-stream'; -import { DockerContainerPool } from './docker.pool'; -import { MAX_CONTAINER_CNT } from '@/constants/constants'; -import { GistApiFileDto } from '@/gist/dto/gistApiFile.dto'; -import { GistApiFileListDto } from '@/gist/dto/gistApiFileList.dto'; -import { GistService } from '@/gist/gist.service'; - -interface GistFileAttributes { - filename: string; - type: string; - language: string; - raw_url: string; - size: number; - truncated?: boolean; - content?: string; -} - -interface GistFile { - filename: string; - attr: GistFileAttributes; -} - -@Processor('docker-queue') -@Injectable() -export class DockerConsumer { - constructor(private gistService: GistService, private dockerContainerPool: DockerContainerPool) {} - - @Process({ name: 'docker-run', concurrency: MAX_CONTAINER_CNT }) - async handleDockerRun(job: Job) { - const { gitToken, gistId, commitId, mainFileName, inputs } = job.data; - let container; - try { - container = await this.dockerContainerPool.getContainer(); - await container.start(); - const result = await this.runGistFiles(container, gitToken, gistId, commitId, mainFileName, inputs); - return result; - } catch (error) { - throw new Error(`Execution failed: ${error.message}`); - } finally { - await this.dockerContainerPool.returnContainer(container); - } - } - async runGistFiles( - container: Container, - gitToken: string, - gistId: string, - commitId: string, - mainFileName: string, - inputs: any[] - ): Promise { - const gistData: GistApiFileListDto = await this.gistService.getCommit(gistId, commitId, gitToken); - const files: GistApiFileDto[] = gistData.files; - if (!files || !files.some((file) => file.fileName === mainFileName)) { - throw new HttpException('execFile is not found', HttpStatus.NOT_FOUND); - } - //desciption: 컨테이너 시작 - const tarBuffer = await this.parseTarBuffer(files); - - //desciption: tarBuffer를 Docker 컨테이너에 업로드 - await container.putArchive(tarBuffer, { path: '/tmp' }); - if (files.some((file) => file.fileName === 'package.json')) { - await this.packageInstall(container); - } - const stream = await this.dockerExcution(inputs, mainFileName, container); - let output = ''; - const timeout = setTimeout(async () => { - console.log('timeout'); - stream.destroy(new Error('Timeout')); - }, 5000); - //desciption: 스트림 종료 후 결과 반환 - return new Promise((resolve, reject) => { - //desciption: 스트림에서 데이터 수집 - stream.on('data', (chunk) => { - output += chunk.toString(); - }); - stream.on('close', async () => { - let result = await this.filterAnsiCode(output); - clearTimeout(timeout); - if (inputs.length !== 0) { - result = result.split('\n').slice(1).join('\n'); - } - this.initWorkDir(container); - resolve(result); - }); - stream.on('error', reject); - }); - } - - async fetchGistFiles(gitToken: string, gistId: string): Promise<{ name: string; content: string }[]> { - try { - const response = await fetch(`https://api.github.com/gists/${gistId}`, { - headers: { - Authorization: `Bearer ${gitToken}` - }, - method: 'GET' - }); - const json = await response.json(); - const files: GistFile = json.files; - - const fileData: { name: string; content: string }[] = []; - for (const [fileName, file] of Object.entries(files)) { - fileData.push({ name: fileName, content: file.content }); - } - return fileData; - } catch (error) { - throw new Error('Failed to fetch Gist files'); - } - } - - async parseTarBuffer(files: GistApiFileDto[]): Promise { - //desciption: tar 아카이브를 생성 - return new Promise((resolve, reject) => { - const pack = tar.pack(); - - for (const file of files) { - //desciption: 파일 이름과 내용을 tar 아카이브에 추가 - pack.entry({ name: file.fileName }, file.content, (err) => { - if (err) reject(err); - }); - } - - //desciption: 아카이브 완료 - pack.finalize(); - - //desciption: Buffer로 변환 - const buffers: Buffer[] = []; - pack.on('data', (data) => buffers.push(data)); - pack.on('end', () => resolve(Buffer.concat(buffers))); - pack.on('error', reject); - }); - } - async dockerExcution(inputs: any[], mainFileName: string, container: Container) { - const exec = await container.exec({ - AttachStdin: true, - AttachStdout: true, - AttachStderr: true, - Tty: inputs.length !== 0, //true - Cmd: ['node', mainFileName], - workingDir: '/tmp' - }); - //todo: 입력값이 없으면 스킵 - const stream = await exec.start({ hijack: true, stdin: true }); - for (const input of inputs) { - await stream.write(input + '\n'); - await this.delay(100); //각 입력 term - } - // stream.end(); - return stream; - } - - async packageInstall(container: Container): Promise { - const exec = await container.exec({ - AttachStdin: false, - AttachStdout: true, - AttachStderr: true, - Cmd: ['npm', 'install'], - workingDir: '/tmp' - }); - - const stream = await exec.start(); - return new Promise((resolve, reject) => { - stream.on('data', (chunk) => { - const c = chunk; - }); - stream.on('end', resolve); - stream.on('error', reject); - }); - } - - async initWorkDir(container: Container): Promise { - try { - const exec = await container.exec({ - AttachStdin: false, - AttachStdout: true, - AttachStderr: true, - Cmd: ['rm', '-rf', '/tmp/*'] - }); - const stream = await exec.start(); - return new Promise((resolve, reject) => { - stream.on('data', (chunk) => { - const c = chunk; - }); - stream.on('end', resolve); - stream.on('error', reject); - }); - } catch (error) { - console.log(error.message); - throw new Error('container tmp init failed'); - } - } - - filterAnsiCode(output: string): string { - return output - .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\r]/g, '') - .replaceAll('\n)', '\n') - .trim(); - } - delay(ms = 1000) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } -} +import { OnQueueError, OnQueueFailed, Process, Processor } from '@nestjs/bull'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { Job } from 'bull'; +import { error } from 'console'; +import { Container } from 'dockerode'; +import * as tar from 'tar-stream'; +import { DockerContainerPool } from './docker.pool'; +import { MAX_CONTAINER_CNT } from '@/constants/constants'; +import { GistApiFileDto } from '@/gist/dto/gistApiFile.dto'; +import { GistApiFileListDto } from '@/gist/dto/gistApiFileList.dto'; +import { GistService } from '@/gist/gist.service'; + +interface GistFileAttributes { + filename: string; + type: string; + language: string; + raw_url: string; + size: number; + truncated?: boolean; + content?: string; +} + +interface GistFile { + filename: string; + attr: GistFileAttributes; +} + +@Processor('froxy-queue') +@Injectable() +export class DockerConsumer { + queue_num = false; + constructor(private gistService: GistService, private dockerContainerPool: DockerContainerPool) {} + + @Process({ name: 'dynamic-docker-run' }) + async dynamicDockerRun(job: Job) { + const { gitToken, gistId, commitId, mainFileName, inputs } = job.data; + let container; + try { + container = await this.dockerContainerPool.getContainer(); + await container.start(); + const result = await this.runGistFiles(container, gitToken, gistId, commitId, mainFileName, inputs); + return result; + } catch (error) { + throw new Error(`Execution failed: ${error.message}`); + } finally { + await this.cleanWorkDir(container); + this.dockerContainerPool.returnContainer(container); + } + } + + @Process({ name: 'always-docker-run', concurrency: MAX_CONTAINER_CNT }) + async alwaysDockerRun(job: Job) { + const { gitToken, gistId, commitId, mainFileName, inputs, c } = job.data; + let container; + try { + container = await this.dockerContainerPool.getContainer(); + const result = await this.runGistFiles(container, gitToken, gistId, commitId, mainFileName, inputs); + await this.cleanWorkDir(container); + this.dockerContainerPool.pool.push(container); + return result; + } catch (error) { + await this.cleanWorkDir(container); + this.dockerContainerPool.pool.push(container); + throw new Error(`Execution failed: ${error.message}`); + } + } + + @Process({ name: 'multipleIO-docker-run', concurrency: MAX_CONTAINER_CNT }) + async multipleIODockerRun(job: Job) { + const { gitToken, gistId, commitId, mainFileName, inputs, c } = job.data; + let container; + try { + container = await this.dockerContainerPool.pool[0]; + await this.initWorkDir(container, c); + const result = await this.runGistFiles(container, gitToken, gistId, commitId, mainFileName, inputs); + await this.cleanWorkDir(container); + return result; + } catch (error) { + await this.cleanWorkDir(container); + throw new Error(`Execution failed: ${error}`); + } + } + async runGistFiles( + container: Container, + gitToken: string, + gistId: string, + commitId: string, + mainFileName: string, + inputs: any[] + ): Promise { + const gistData: GistApiFileListDto = await this.gistService.getCommit(gistId, commitId, gitToken); + const files: GistApiFileDto[] = gistData.files; + if (!files || !files.some((file) => file.fileName === mainFileName)) { + throw new HttpException('execFile is not found', HttpStatus.NOT_FOUND); + } + //desciption: 컨테이너 시작 + const tarBuffer = await this.parseTarBuffer(files); + //desciption: tarBuffer를 Docker 컨테이너에 업로드 + await container.putArchive(tarBuffer, { path: `/tmp` }); + if (files.some((file) => file.fileName === 'package.json')) { + await this.packageInstall(container); + } + const exec = await this.dockerExcution(inputs, mainFileName, container); + console.log('dockerExcution'); + let output = ''; + const stream = await exec.start({ hijack: true, stdin: true }); + console.log('exec.start'); + return new Promise((resolve, reject) => { + let time = null; + + const onStreamClose = async () => { + try { + let result = await this.filterAnsiCode(output); + clearTimeout(time); + if (inputs.length !== 0) { + result = result.split('\n').slice(1).join('\n'); + } + resolve(result); + } catch (err) { + reject(err); + } + }; + + // Timeout 설정 + time = setTimeout(() => { + stream.destroy(new Error('Timeout')); + }, 10000); + + // 스트림에서 데이터 수집 + stream.on('data', (chunk) => { + output += chunk.toString(); + }); + + // 스트림 종료 대기 + stream.on('close', onStreamClose); + stream.on('end', onStreamClose); + stream.on('error', (err) => { + reject(err); + }); + + (async () => { + try { + for (const input of inputs) { + if (!stream.destroyed && stream.writable) { + await stream.write(input + '\n'); + } + await this.delay(100); //각 입력 term + } + } catch (err) { + reject(err); + } + })(); + }); + } + + async fetchGistFiles(gitToken: string, gistId: string): Promise<{ name: string; content: string }[]> { + try { + const response = await fetch(`https://api.github.com/gists/${gistId}`, { + headers: { + Authorization: `Bearer ${gitToken}` + }, + method: 'GET' + }); + const json = await response.json(); + const files: GistFile = json.files; + + const fileData: { name: string; content: string }[] = []; + for (const [fileName, file] of Object.entries(files)) { + fileData.push({ name: fileName, content: file.content }); + } + return fileData; + } catch (error) { + throw new Error('Failed to fetch Gist files'); + } + } + + async parseTarBuffer(files: GistApiFileDto[]): Promise { + //desciption: tar 아카이브를 생성 + return new Promise((resolve, reject) => { + const pack = tar.pack(); + + for (const file of files) { + //desciption: 파일 이름과 내용을 tar 아카이브에 추가 + pack.entry({ name: file.fileName }, file.content, (err) => { + if (err) reject(err); + }); + } + + //desciption: 아카이브 완료 + pack.finalize(); + + //desciption: Buffer로 변환 + const buffers: Buffer[] = []; + pack.on('data', (data) => buffers.push(data)); + pack.on('end', () => resolve(Buffer.concat(buffers))); + pack.on('error', reject); + }); + } + async dockerExcution(inputs: any[], mainFileName: string, container: Container) { + const exec = await container.exec({ + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Tty: inputs.length !== 0, //true + Cmd: ['node', mainFileName], + workingDir: `/tmp` + }); + + return exec; + } + + async packageInstall(container: Container): Promise { + const exec = await container.exec({ + AttachStdin: false, + AttachStdout: true, + AttachStderr: true, + Cmd: ['npm', 'install'], + workingDir: `/tmp` + }); + + const stream = await exec.start(); + return new Promise((resolve, reject) => { + stream.on('data', (chunk) => { + const c = chunk; + }); + stream.on('end', resolve); + stream.on('error', reject); + }); + } + + async cleanWorkDir(container: Container): Promise { + try { + const exec = await container.exec({ + AttachStdin: false, + AttachStdout: true, + AttachStderr: true, + Cmd: ['sh', '-c', `rm -rf /tmp`] + }); + const stream = await exec.start(); + return new Promise((resolve, reject) => { + stream.on('data', (chunk) => { + const c = chunk; + }); + stream.on('end', resolve); + stream.on('error', reject); + }); + } catch (error) { + console.log(error.message); + throw new Error('container tmp init failed'); + } + } + + async initWorkDir(container: Container, dirId: any): Promise { + try { + const exec = await container.exec({ + AttachStdin: false, + AttachStdout: true, + AttachStderr: true, + Cmd: ['mkdir', `/tmp/${dirId}`] + }); + const stream = await exec.start(); + return new Promise((resolve, reject) => { + stream.on('data', (chunk) => { + const c = chunk; + }); + stream.on('end', () => { + console.log(`${dirId}번째 디렉토리 생성`); + resolve(); + }); + stream.on('error', reject); + }); + } catch (error) { + console.log(error.message); + throw new Error('container tmp init failed'); + } + } + + filterAnsiCode(output: string): string { + return output + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\r]/g, '') + .replaceAll('\n)', '\n') + .trim(); + } + delay(ms = 1000) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/apps/backend/src/docker/docker.controller.ts b/apps/backend/src/docker/docker.controller.ts index 124770cc..d664e76b 100644 --- a/apps/backend/src/docker/docker.controller.ts +++ b/apps/backend/src/docker/docker.controller.ts @@ -1,46 +1,73 @@ -import { Controller, Get, Param } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { DockerProducer } from './docker.producer.js'; - -@Controller('docker') -export class DockerController { - constructor(private readonly dockerProducer: DockerProducer, private configService: ConfigService) {} - - @Get('get') - async getDockersTest(): Promise { - const mainFileName = 'FunctionDivide.js'; - // const gitToken = this.configService.get('STATIC_GIST_ID'); - const gistId = this.configService.get('DYNAMIC_GIST_ID'); - const gitToken = this.configService.get('GIT_TOKEN'); - const inputs = ['1 1 1 1', '1 1 1 1', '1 1 1 1', '1 1 1 1']; - const commit = '654dd3f1d7f17d172132aebae283e73356197d18'; - console.log('docker Test'); - const value = await this.dockerProducer.getDocker( - gitToken, - '25cf4713b2386b4ad4ce7c8dbbecebe8', - 'e717102aefed1f1f8b27b63eb7f46ce1f1516c86', - 'main.js', - inputs - ); - return value; - } - - @Get('get2') - async getDockersTest2(): Promise { - const mainFileName = 'FunctionDivide.js'; - // const gitToken = this.configService.get('STATIC_GIST_ID'); - const gistId = this.configService.get('DYNAMIC_GIST_ID'); - const gitToken = this.configService.get('GIT_TOKEN'); - const inputs = ['1 1 1 1', '1 1 1 1', '1 1 1 1', '1 1 1 1']; - const commit = '654dd3f1d7f17d172132aebae283e73356197d18'; - console.log('docker Test2'); - const value = await this.dockerProducer.getDocker( - gitToken, - '7f93da28e2522409a2274eff51b5dc20', - '57944932d1ec6f05415b5e067f23c8a358e79d84', - 'main.js', - inputs - ); - return value; - } -} +import { Controller, Get, Param } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DockerProducer } from './docker.producer.js'; + +@Controller('docker') +export class DockerController { + constructor(private readonly dockerProducer: DockerProducer, private configService: ConfigService) {} + + @Get('get') + async getDockersTest(): Promise { + try { + const mainFileName = 'FunctionDivide.js'; + // const gitToken = this.configService.get('STATIC_GIST_ID'); + const gistId = this.configService.get('DYNAMIC_GIST_ID'); + const gitToken = this.configService.get('GIT_TOKEN'); + const inputs = ['1 1 1 1', '1 1 1 1', '1 1 1 1', '1 1 1 1']; + const commit = '654dd3f1d7f17d172132aebae283e73356197d18'; + const value = await this.dockerProducer.getDocker( + gitToken, + '25cf4713b2386b4ad4ce7c8dbbecebe8', + 'e717102aefed1f1f8b27b63eb7f46ce1f1516c86', + 'main.js', + inputs + ); + console.log(value); + return value; + } catch (e) { + console.error(e.message); + return e.message; + } + } + + @Get('get2') + async getDockersTest2(): Promise { + try { + const gitToken = this.configService.get('GIT_TOKEN'); + const inputs = ['1 1 1 1', '1 1 1 1', '1 1 1 1', '1 1 1 1']; + const value = await this.dockerProducer.getDocker( + gitToken, + '7f93da28e2522409a2274eff51b5dc20', + '57944932d1ec6f05415b5e067f23c8a358e79d84', + 'main.js', + inputs + ); + console.log(value); + return value; + } catch (e) { + console.error(e.message); + return e.message; + } + } + + @Get('get3') + async getDockersTest3(): Promise { + //무한루프 + const gitToken = this.configService.get('GIT_TOKEN'); + const inputs = ['1 1 1 1', '1 1 1 1', '1 1 1 1', '1 1 1 1']; + try { + const value = await this.dockerProducer.getDocker( + gitToken, + '2574b42a40e9ea6d35a9434a88694720', + '2b98cc9dd44bf0c8ddf43a715d2443d7261e25fc', + 'main.js', + inputs + ); + console.log(value); + return value; + } catch (e) { + console.error(e.message); + return e.message; + } + } +} diff --git a/apps/backend/src/docker/docker.module.ts b/apps/backend/src/docker/docker.module.ts index 3e64cbd5..3f8b1652 100644 --- a/apps/backend/src/docker/docker.module.ts +++ b/apps/backend/src/docker/docker.module.ts @@ -9,14 +9,8 @@ import { GistModule } from '@/gist/gist.module'; @Module({ imports: [ GistModule, - BullModule.forRoot({ - redis: { - host: '211.188.48.24', // Redis 호스트 주소 - port: 6379 // Redis 포트 - } - }), BullModule.registerQueue({ - name: 'docker-queue' // 큐 이름 + name: 'froxy-queue' }) ], controllers: [DockerController], diff --git a/apps/backend/src/docker/docker.pool.ts b/apps/backend/src/docker/docker.pool.ts index d9efc268..e49cb309 100644 --- a/apps/backend/src/docker/docker.pool.ts +++ b/apps/backend/src/docker/docker.pool.ts @@ -1,50 +1,106 @@ -import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; -import * as Docker from 'dockerode'; -import { Container } from 'dockerode'; -import { MAX_CONTAINER_CNT } from '@/constants/constants'; - -@Injectable() -export class DockerContainerPool implements OnApplicationBootstrap { - docker = new Docker(); - pool: Container[] = []; - lock = false; - async onApplicationBootstrap() { - await this.createContainer(); - } - - async createContainer() { - for (let i = 0; i < MAX_CONTAINER_CNT; i++) { - const container = await this.docker.createContainer({ - Image: 'node:latest', - Tty: false, - OpenStdin: true, - AttachStdout: true, - AttachStderr: true, - Env: [ - 'NODE_DISABLE_COLORS=true', // 색상 비활성화 - 'TERM=dumb' // dumb 터미널로 설정하여 색상 비활성화 - ] - }); - this.pool.push(container); - } - } - - async getContainer(): Container | null { - while (this.lock || this.pool.length === 0) { - await this.delay(10); // 풀 비어 있음 처리 - } - this.lock = true; - const container = this.pool.pop(); - this.lock = false; - return container; - } - - async returnContainer(container: Container) { - await container.stop(); - this.pool.push(container); - } - - delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } -} +import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; +import * as Docker from 'dockerode'; +import { Container } from 'dockerode'; +import { MAX_CONTAINER_CNT } from '@/constants/constants'; + +@Injectable() +export class DockerContainerPool implements OnApplicationBootstrap { + docker = new Docker({ socketPath: '/var/run/docker.sock' }); + pool: Container[] = []; + lock = false; + async onApplicationBootstrap() { + await this.clearContainer(); + await this.createAlwaysContainer(); + } + + async clearContainer() { + const containersToDelete = await this.docker.listContainers({ all: true }); + await Promise.all( + containersToDelete + .filter((container) => container.Names.some((name) => name.startsWith('/froxy-run'))) + .map(async (container) => { + const removeContainer = await this.docker.getContainer(container.Id); + await removeContainer.remove({ force: true }); + }) + ); + } + async createDynamicContainer() { + for (let i = 0; i < MAX_CONTAINER_CNT; i++) { + const container = await this.docker.createContainer({ + Image: 'node:latest', + Tty: false, + OpenStdin: true, + AttachStdout: true, + AttachStderr: true, + Env: ['NODE_DISABLE_COLORS=true', 'TERM=dumb'], + name: `froxy-run${i + 1}`, + HostConfig: { + Memory: 1024 * 1024 * 1024, + MemorySwap: 1024 * 1024 * 1024 + } + }); + this.pool.push(container); + } + } + + async createAlwaysContainer() { + for (let i = 0; i < MAX_CONTAINER_CNT; i++) { + const container = await this.docker.createContainer({ + Image: 'node:latest', + Tty: false, + OpenStdin: true, + AttachStdout: true, + AttachStderr: true, + Env: [ + 'NODE_DISABLE_COLORS=true', // 색상 비활성화 + 'TERM=dumb' // dumb 터미널로 설정하여 색상 비활성화 + ], + name: `froxy-run${i + 1}`, + HostConfig: { + Memory: 1024 * 1024 * 1024, + MemorySwap: 1024 * 1024 * 1024, + networkMode: 'host' + } + }); + container.start(); + + this.pool.push(container); + } + } + + async createSingleContainer() { + const container = await this.docker.createContainer({ + Image: 'node:latest', + Tty: false, + OpenStdin: true, + AttachStdout: true, + AttachStderr: true, + Env: [ + 'NODE_DISABLE_COLORS=true', // 색상 비활성화 + 'TERM=dumb' // dumb 터미널로 설정하여 색상 비활성화 + ], + name: `single-run` + }); + container.start(); + this.pool.push(container); + } + + async getContainer(): Container | null { + while (this.lock || this.pool.length === 0) { + await this.delay(10); // 풀 비어 있음 처리 + } + this.lock = true; + const container = this.pool.pop(); + this.lock = false; + return container; + } + + async returnContainer(container: Container) { + await container.stop(); + this.pool.push(container); + } + + delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/apps/backend/src/docker/docker.producer.ts b/apps/backend/src/docker/docker.producer.ts index bc0194ab..45011bd6 100644 --- a/apps/backend/src/docker/docker.producer.ts +++ b/apps/backend/src/docker/docker.producer.ts @@ -1,41 +1,50 @@ -import { InjectQueue } from '@nestjs/bull'; -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; -import { DockerContainerPool } from './docker.pool'; - -@Injectable() -export class DockerProducer { - constructor( - @InjectQueue('docker-queue') - private readonly dockerQueue, - private dockerContainerPool: DockerContainerPool - ) {} - - async getDocker( - gitToken: string, - gistId: string, - commitId: string, - mainFileName: string, - inputs: any[] - ): Promise { - const job = await this.dockerQueue.add( - 'docker-run', - { - gitToken, - gistId, - commitId, - mainFileName, - inputs - }, - { removeOnComplete: true, removeOnFail: true } - ); - // Job 완료 대기 및 결과 반환 - return new Promise((resolve, reject) => { - job - .finished() - .then((result) => { - resolve(result); - }) - .catch((error) => reject(error)); - }); - } -} +import { InjectQueue } from '@nestjs/bull'; +import { HttpException, HttpStatus, Injectable, OnApplicationBootstrap } from '@nestjs/common'; +import { Queue } from 'bull'; +import { DockerContainerPool } from './docker.pool'; + +@Injectable() +export class DockerProducer implements OnApplicationBootstrap { + cnt = 0; + constructor( + @InjectQueue('froxy-queue') + private readonly dockerQueue: Queue, + private dockerContainerPool: DockerContainerPool + ) {} + onApplicationBootstrap() { + this.dockerQueue.setMaxListeners(1000); + } + + async getDocker( + gitToken: string, + gistId: string, + commitId: string, + mainFileName: string, + inputs: any[] + ): Promise { + this.cnt++; + const c = this.cnt; + try { + const job = await this.dockerQueue.add( + 'multipleIO-docker-run', + { + gitToken, + gistId: gistId, + commitId: commitId, + mainFileName, + inputs, + c + }, + { + jobId: `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`, + removeOnComplete: true, + removeOnFail: true + } + ); + console.log('jobId추가 :', job.id); + return await job.finished(); + } catch (error) { + throw error; + } + } +} diff --git a/apps/backend/src/history/history.service.ts b/apps/backend/src/history/history.service.ts index 96ee376a..a90e825b 100644 --- a/apps/backend/src/history/history.service.ts +++ b/apps/backend/src/history/history.service.ts @@ -48,6 +48,7 @@ export class HistoryService { const result = await this.dockerProducer.getDocker(gitToken, lotusId, commitId, execFilename, inputs); await this.historyRepository.update(historyId, { status: HISTORY_STATUS.SUCCESS, result }); } catch (error) { + console.error(`HTTP ${404} Error: ${error.message} | Path: lotus/${lotusId}/history/${historyId}`); await this.historyRepository.update(historyId, { status: HISTORY_STATUS.ERROR, result: error.message diff --git a/apps/frontend/src/feature/codeView/type.ts b/apps/frontend/src/feature/codeView/type.ts new file mode 100644 index 00000000..0f985b88 --- /dev/null +++ b/apps/frontend/src/feature/codeView/type.ts @@ -0,0 +1,5 @@ +export interface CodeViewValue { + filename: string; + language: string; + content: string; +} diff --git a/apps/frontend/src/feature/user/type.ts b/apps/frontend/src/feature/user/type.ts new file mode 100644 index 00000000..e3aa9ae1 --- /dev/null +++ b/apps/frontend/src/feature/user/type.ts @@ -0,0 +1,5 @@ +export interface UserType { + id: string; + nickname: string; + profile: string; +} diff --git a/apps/frontend/src/widget/navigation/index.tsx b/apps/frontend/src/widget/navigation/index.tsx new file mode 100644 index 00000000..7d4eecd3 --- /dev/null +++ b/apps/frontend/src/widget/navigation/index.tsx @@ -0,0 +1,2 @@ +export * from './CreateLotusButton'; +export * from './LogoutButton'; diff --git a/mermaild.md b/mermaild.md new file mode 100644 index 00000000..f033e352 --- /dev/null +++ b/mermaild.md @@ -0,0 +1,12 @@ +stateDiagram-v2 + direction LR + 사용자request --> Pool: 코드실행 요청 + + state "Pool" as Pool + Pool --> Allocated: 컨테이너 할당 + Allocated --> Started: 컨테이너 Start + Started --> Running: 작업 실행 중 + Running --> Finish: 작업 완료 + Finish --> Exited: 컨테이너 stop + Finish --> 사용자request: 코드실행결과 반환 + Exited --> Pool: 컨테이너 Stop 후 반납 \ No newline at end of file diff --git a/npx b/npx new file mode 100644 index 00000000..5a312b43 --- /dev/null +++ b/npx @@ -0,0 +1,79 @@ +Test run id: t6neg_99a5dhhx98qw3gywhgwh56k4chpnx_87ef +Phase started: unnamed (index: 0, duration: 1s) 22:13:38(+0900) + +Phase completed: unnamed (index: 0, duration: 1s) 22:13:39(+0900) + +-------------------------------------- +Metrics for period to: 22:13:40(+0900) (width: 1s) +-------------------------------------- + +http.request_rate: ............................................................. 100/sec +http.requests: ................................................................. 100 +vusers.created: ................................................................ 100 +vusers.created_by_name.0: ...................................................... 100 + + +-------------------------------------- +Metrics for period to: 22:13:50(+0900) (width: 1.503s) +-------------------------------------- + +errors.ETIMEDOUT: .............................................................. 95 +http.codes.200: ................................................................ 5 +http.downloaded_bytes: ......................................................... 550 +http.request_rate: ............................................................. 5/sec +http.requests: ................................................................. 5 +http.response_time: + min: ......................................................................... 9408 + max: ......................................................................... 9691 + mean: ........................................................................ 9510.4 + median: ...................................................................... 9416.8 + p95: ......................................................................... 9607.1 + p99: ......................................................................... 9607.1 +http.response_time.2xx: + min: ......................................................................... 9408 + max: ......................................................................... 9691 + mean: ........................................................................ 9510.4 + median: ...................................................................... 9416.8 + p95: ......................................................................... 9607.1 + p99: ......................................................................... 9607.1 +http.responses: ................................................................ 5 +vusers.failed: ................................................................. 95 + + +-------------------------------------- +Metrics for period to: 22:14:00(+0900) (width: 0.241s) +-------------------------------------- + +errors.ETIMEDOUT: .............................................................. 5 +vusers.failed: ................................................................. 5 + + +All VUs finished. Total time: 21 seconds + +-------------------------------- +Summary report @ 22:14:00(+0900) +-------------------------------- + +errors.ETIMEDOUT: .............................................................. 100 +http.codes.200: ................................................................ 5 +http.downloaded_bytes: ......................................................... 550 +http.request_rate: ............................................................. 35/sec +http.requests: ................................................................. 105 +http.response_time: + min: ......................................................................... 9408 + max: ......................................................................... 9691 + mean: ........................................................................ 9510.4 + median: ...................................................................... 9416.8 + p95: ......................................................................... 9607.1 + p99: ......................................................................... 9607.1 +http.response_time.2xx: + min: ......................................................................... 9408 + max: ......................................................................... 9691 + mean: ........................................................................ 9510.4 + median: ...................................................................... 9416.8 + p95: ......................................................................... 9607.1 + p99: ......................................................................... 9607.1 +http.responses: ................................................................ 5 +vusers.created: ................................................................ 100 +vusers.created_by_name.0: ...................................................... 100 +vusers.failed: ................................................................. 100