Project/WEB IDE

Docker로 각 컨테이너마다 Java, C++, Python, Node.js 언어 실행시키기 | Web IDE 구축하기 - 2

경아 (KyungA) 2024. 4. 6. 23:52
반응형

 

Java, C++, Python 언어가 설치되지 않은 환경에서 어떻게 실행시킬까?


저번 글에는 Javascript 코드를 isolated-vm 가상머신 환경에서 실행시키는 작업을 했습니다

이제 그다음 스텝인 Python, Java, C++ 언어를 받아서 실행시켜야 하는데 어떻게 해야 할까요?

여기서 제가 한 가지 놓친 점이, JS를 뺀 나머지 언어들을 실행시키기 위해선 내 로컬에 해당 언어가 깔려있어야 된다는 것입니다

이 부분을 몰랐던 건 아닌데... 왜 JS 작업할 때 놓치고 있었던 걸까요...?😥

그래서 제 노트북에 모든 언어를 설치해야 하나? 싶었는데,

가뜩이나 똥컴인 노트북에 너무 부담이 되더라고요 (어차피 용량 때문에 깔지도 못했을 듯)

그래서 Docker를 사용하게 되었습니다

Docker 또한 가상머신의 개념인데 우리가 직접 가상머신을 구축하는 것보다 쉽다고 볼 수 있겠습니다

Docker, VM 차이점 검색하면 여러 내용이 나오는데,

제 플젝에서 가장 눈여겨봐야 할 차이점은 보안의 차이였습니다

Docker 같은 경우에는 커널에 보안 취약점이 발견될 수 있다고 하고,

VM을 구축하여 코드를 실행하는 게 Docker에 비하면 안정성이 더 보장된다고 말하네요

 

 

 

Docker 컨테이너에서 Python 실행하기


 가장 먼저 제 로컬에 클라이언트에게 받은 code를 파일로 저장해야 합니다

(파일 쌓이는건 따로 삭제 로직을 넣어주면 됩니다)

import fs from "fs";

fs.writeFileSync("code.py", code);

Node에서 파일을 생성할 때 fs.writeFile, fs.writeFileSync 둘 다 쓸 수 있습니다

writeFile 비동기인데, writeFile를 쓰게 된다면 callback 함수를 필수로 작성해 줘야 합니다

저 같은 경우에는 파일이 무조건 있을 때 뒤에 로직들이 진행되어야 하므로 writeFileSync를 사용했습니다

const command = `docker run --rm -v %cd%:/dist python:3 python /dist/code.py ${input}`;

그리고 docker를 실행할 명령어를 작성해 줬습니다

docker 빌드와 실행을 바로 시키는 명령어입니다

  • python:3 : python:3 이라는 Docker image를 생성
  • %cd%:/dist  : 작업할 디렉터리를 Docker 컨테이너 내의 /dist 디렉터리에 마운트 한다는 의미
  • python : Docker 컨테이너 환경
  • /dist/code.py : 실행시킬 파일의 위치
  • --rm : 컨테이너 실행이 끝나면 --rm 명령어로 인해 컨테이너가 바로 삭제
  • ${input} : 매개변수를 동적으로 할당하기 위해 작성했습니다

최종 코드는 아래와 같습니다

fs.writeFileSync("code.py", code);

testcase?.testcase.forEach((test) => {
	const input = test.input;
	const command = `docker run --rm -v %cd%:/dist python:3 python /dist/code.py ${input}`;

 	exec(command, (error, stdout, stderr) => {
		if (error) {
			console.error(`Error: ${error.message}`);
			return;
		}
		if (stderr) {
			console.error(`Error: ${stderr}`);
			return;
		}

		const result = JSON.parse(stdout);

		if (result === test.output) {
			console.log("정답!");
		} else {
			console.log("오답!");
		}
	});
});

exec란 Child Process 패키지의 메서드 중 하나입니다

 

Child Process란?

Node에서 다른 프로그램을 실행하고 싶거나, 명령어를 수행하고 싶을 때 유용합니다

Node가 가동되는 프로세스 외에 별도의 프로세스 생성하여 명령을 수행하고 결괏값을 Node 프로세스에 돌려줍니다

child_process exec()를 이용하면 터미널에 입력하는 명령어를 사용할 수 있습니다
(시스템 환경변수, 콘솔 셸 실행)

다시 돌아와서...

testcode는 문제마다 개수가 매번 다르고, 동적으로 할당되어야 합니다

그래서 반복문을 작성하고, 그 안에 각각 테스트케이스마다 Docker 컨테이너를 띄우는 로직을 작성했습니다

(테스트케이스가 5개면 5개의 컨테이너가 생성됩니다)

exec() 함수 같은 경우에는 비동기이기 때문에 테스트케이스 실행 순서는 보장되지 않습니다

그 다음, 클라이언트는 동적으로 할당된 매개변수를 어떻게 알 수 있을까요?

import sys
arg = sys.argv[1:]

이렇게 받아와서 알 수 있습니다

근데 문제는 arg를 print 해보면,

["컴파일된 py 파일 경로", "3", "5"] 이렇게 찍힌다는 게 조금 찜찜한 부분이었습니다

컴파일된 파일 경로까지 찍히더라고요?

이 부분은 나중에 수정하게 되는데 좀 이따가 아래에서 설명하도록 하겠습니다

 

 

 

Docker 컨테이너에서 Java 실행하기


Java도 맨 처음에는 Python과 같이 동일한 로직으로 작성했습니다

const command = `docker run --rm -v %cd%:/app openjdk:11 bash -c "apt-get update && apt-get install -y openjdk-11-jdk && cd /app && javac Main.java && java Main"`;

 

여기서 Python과 다른 점은 java는 컴파일이 된 후 class 파일이 생성되어야 한다는 점인데요

그래서 javac Main.java 라는 명령어로 컴파일하고 다시 java Main 이렇게 실행하는 형식이더라고요

참고로, apt-get update && apt-get install -y openjdk-11-jdk 명령어가 컨테이너 내에서 필요한 패키지를 설치하는 명령어입니다

쨌든, 그렇다 보니 제 로컬에도 Main.java, Main.class 파일 2개가 생성됩니다

하지만, 여기서 다른 문제를 마주하게 됩니다

Docker 빌드와 실행, Java 종속성 설치(종속성 설치인진 모르겠음)가 모두 한꺼번에 실행돼서 그런 걸까요?

터미널과 호환이 안된다길래 -e TERM=xterm 라는 명령어를 추가해 줬는데

드디어! 터미널에 출력이 되어 봤더니 모든 log들과 결괏값이 다 같이 반환되더라고요...

그래서 고민고민하다가 떠오른 게, 빌드 먼저 완료한 후, java 코드를 실행시키자! 였습니다

FROM openjdk:11
COPY dist/Main.java  /usr/src/
WORKDIR /usr/src/
RUN javac Main.java
CMD ["java", "Main"]

 Docker 파일을 만들어서 위와 같이 작성해 줬습니다

여기서 apt-get update && apt-get install -y openjdk-11-jdk 라는 명령어를 별도로 써주지 않았는데

FROM openjdk:11 라는 명령어가 알아서 이미지 내부에 java 환경을 구성한다고 하더라고요

사실상 위에 작성한 command에서 빼도 될 것 같습니다

최종 Java 실행 로직은 아래와 같습니다

  fs.writeFileSync("Main.java", code);

  try {
    execSync(
      "docker build -t openjdk:11 -f src/docker/java/Dockerfile ."
    );
  } catch (error) {
    console.error(
      `Error occurred while building Docker image: ${
        (error as any).message
      }`
    );
    process.exit(1);
  }

  testcase?.testcase.forEach((test, i) => {
    exec(
      `echo ${test.input} | docker run --rm -i openjdk:11`,
      (error, stdout, stderr) => {
        if (error) {
          console.error(`Error: ${error.message}`);
          socket.emit("error", error.message);
          return;
        }
        if (stderr) {
          console.error(`Error: ${stderr}`);
          socket.emit("error", stderr);
          return;
        }

        const result = JSON.parse(stdout);

        if (result === test.output) {
          clientResult[i] = true;
          socket.emit("test", clientResult);
        } else {
          clientResult[i] = false;
          socket.emit("test", clientResult);
        }
      }
    );
  });

 

Docker 컨테이너가 생성된 후 실행되어야 하기 때문에 execSync() 함수를 사용해 줬습니다

여기서 달라진 점이 exec() 함수 부분인데요

 exec(`echo ${test.input} | docker run --rm -i openjdk:11`, () => {});

 

동적 매개변수 할당

백준, 프로그래머스 코드를 보면 기본으로 제공하는 코드, 또는 입력을 받아오는 코드들이 있습니다

// Python
a = int(input())
// java
import java.util.Scanner;

public class Solution {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        String a = sc.next();
        String b = sc.next();
    }
}

예를 들면 위와 같은 코드들입니다

input, Scanner 같은 함수, 메서드들은 사용자에게 입력을 받는 기능인데요

한마디로 서버에서 python, java 코드가 실행될 때 동적으로 터미널을 통해 매개변수를 할당한다라는 뜻이겠죠?

현재 저는 Docker 빌드하는 과정에서 매개변수를 포함하는 형태인데,

이걸 대화형 입력으로 바꿔준 것입니다

echo ${test.input} | docker run --rm -i openjdk:11
  • echo : 리눅스 환경에서 터미널 또는 명령 프롬프트에서 주어진 텍스트를 화면에 출력하는 데 사용
  • | : 파이프(pipe)를 의미. 한 프로그램의 실행 결과를 다른 프로그램에서 처리할 수 있도록 전달
  • -i : Docker를 실행시킬 때 -i 표준입력(STDUN) 옵션을 추가
    • 표준입력 : 컨테이너로 제공하는 메커니즘이며, 사용자가 명령을 입력하거나 프로그램에 데이터를 전달할 때 사용됩니다

정리하자면 echo "Hollo World!" 라고 입력하면 | 를 통해 docker에게 전달됩니다

이렇게 docker 빌드를 먼저 수행 후 완료되면 exec() 함수를 실행하는 형태로 바꾸고,

매개변수를 할당하는 것도 서버에서 직접 입력하고 docker 컨테이너에 넘겨주는 형태로 진행되니

더 이상 java log들이 전체 반환되는 것도 사라졌고,

클라이언트에서 매개변수를 출력할 때 친숙한 함수, 메서드를 쓸 수 있으니 불편함도 사라졌습니다!!

 

 

 

Docker 컨테이너에서 C++ 실행하기


C++ 로직도 Java와 완전 동일합니다

다만, Dockerfile 내용이 좀 다릅니다

FROM alpine:latest
RUN apk update && apk add --no-cache gcc g++ make
WORKDIR /usr/src/
COPY dist/main.cpp  /usr/src/
RUN g++ -o main main.cpp
CMD ["./main"]

C++를 실행시키려면 gcc 컴파일러가  Docker 컨테이너에 설치되어야 합니다

  • FROM alpine:latest :  Alpine Linux 환경을 설치, image 생성
  • apk : Alpine 패키지 관리자를 사용해 gcc와 g++을 설치합니다
  • RUN g++ -o main main.cpp : g++ 컴파일러 호출, 컴파일된 바이너리 파일의 이름을 main으로 지정. main.cpp는 컴파일할 파일 이름

 

 

 

마무리


이렇게 간단한 코테정도는 문제없이 풀고, 결괏값을 반환해 주는 로직이 완성되었습니다

앞으로 복잡한 문제에서도 위 코드가 정상 작동할지도 테스트해봐야 하고,

에러 반환, 테스트코드 실행이 아닌 일반 실행, 내가 작성한 코드 저장 등등... 짜잘짜잘하게 작업할게 많지만

MVP 정도는 완성한 게 아닌가 싶습니다 ㅎㅎ

혹시 제가 잘못 이해한 부분이 있다면 댓글 부탁드립니다! 🙏

반응형