Java로 Builder Program 만들기 (2) - Java로 Shell Script를 Controller하여 Docker로 Source Build하기

Java Docker Library를 찾던 중 대표적으로 많이 쓰이는 2가지를 찾게 되었지만 둘 다 사용하고 싶지 않았다. 첫 번째로 찾은 Library는 docker-java. 최소 2가지의 dependency를 주입받아야 했고 그 이외에 gradle repository를 추가하거나 데몬을 사용해야 하는 등 내가 필요한 기능 이외에 것들을 많이 설정해야 해서 resource 차지가 심하다고 생각이 들었다. 

 

두 번째로 찾은 Library는 gradle-docker-plugin. bmuschko라는 닉네임을 사용하는 특정 개발자가 만든 library로써 사용설명에 이후의 업데이트는 없다고 명시해 놨기 때문에 사용성 측면에 있어서 어려움이 있었다.

 

그래서 사용자가 원하는 Docker Image를 사용하여 원하는 source를 빌드하는 DockerManager Class를 직접 만들기로 했다. 


DockerManager Class 구상안

DockerManager Class는 어떤 Job들을 처리해야 할까?? 머릿속에 있는걸 적당하게 그려봤다. 

우선 선행 작업으로 이전 포스팅에서 만든 GitManager로 Pull 받은 최신 source code가 있어야 한다. 그리고 그 source code를 build 할 수 있는 Language(jdk, php 등) 혹은 Language Package Manager(NPM, Composer 등)가 설치되어 있는 DockerImage가 필요했다.

 

위 선행된 2가지의 재료만 있다면 DockerManager Class에서 사용자가 원하는 이미지를 다운로드(Docker Pull) 받아서 Container를 만들고(Docker Run) 컨테이너를 사용하여 source code를 Build 하고, 완료된 후에는 container를 중지 및 삭제처리 하면 DockerManager Class의 역할은 종료된다.

 

ShellScript를 실행할 수 있는 Class 구현

실제로 Docker 기능을 어떻게 구현할 것인가? 에 대한 해답으로 나는 Runtime.getRuntime().exec()를 찾았다. 내가 만든 Java Program이 돌아가는 환경이라면 OS가 windows, MacOS, Linux에 구분되지 말고 모든 환경에서 잘 돌아가게 하기 위해 모든 OS에서 사용할 수 있는 CLI를 활용했다.

package mingyu.libraries;


import org.json.simple.JSONObject;
import java.io.BufferedReader;
import java.io.InputStreamReader;

public class ShellScriptLib {
    private static ShellScriptLib instance;
    private boolean isWindows = System.getProperty("os.name").toLowerCase().startsWith("windows");

    public static ShellScriptLib getInstance() {
        if (instance == null) {
            synchronized (ShellScriptLib.class) {
                instance = new ShellScriptLib();
            }
        }
        return instance;
    }

    public String exec(String execCommand) {
        String[] input = null;
        StringBuilder output = new StringBuilder();
        JSONObject returnData = new JSONObject();
        
        // 프로그램 동작 OS 구분지어 command 명령어 동작 프로그램 변경
        if (isWindows) {
            input = new String[]{"cmd.exe", "/c", execCommand};
        } else {
            input = new String[]{"/bin/bash", "-c", execCommand};
        }

        try {
            // Shell Script 실행
            Process process = Runtime.getRuntime().exec(input);

            // 실행 결과 읽어오기
            BufferedReader outputReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
            String line;
            while ((line = outputReader.readLine()) != null) {
                output.append(line);
            }

            // return Data 작성
            returnData.put("code",200);
        } catch (Exception exception) {
            output.append(exception.getStackTrace());
            output.append(exception.getMessage());

            // return Data 작성
            returnData.put("code",-400);
        }
        returnData.put("message",output.toString());

        return returnData.toJSONString();
    }
}

위의 ShellScriptLib Class는 싱글턴 패턴을 사용하였고, PHP에 있는 exec() 함수를 Java로 구현한 것과 같다. 리턴은 Json 형식의 String으로 하기 위해 json-simple dependency를 주입받아 사용하였다.

 

Make DockerUtil Class

이전 포스팅과 마찬가지로 DockerManager는 만들어진 기능들을 활용하는 Class임으로 Docker와 관련된 기능들을 실제로 구현할 DockerUtil Class를 만들어 사용했다. 첫 번째로 DockerUtil Class에서 사용할 method의 정의를 Docker 공식 문서를 참조하여 만들어 봤다.

package mingyu.interfaces.util;

import java.util.ArrayList;

public interface DockerUtilInterface {

    public abstract String run(ArrayList<String> options, String dockerImage, String command, ArrayList<String> argument);

    public abstract String push(ArrayList<String> options, String dockerImage);

    public abstract String pull(ArrayList<String> options, String dockerImage);

    public abstract String exec(ArrayList<String> options, String dockerContainerName, String command, ArrayList<String> arguments);

    public abstract String rm(ArrayList<String> options, String dockerContainerName);

    public abstract String stop(ArrayList<String> options, String dockerContainerName);

    public abstract String ps(ArrayList<String> options);

    public abstract String rmi(ArrayList<String> options, String dockerImage);

    public abstract String images(ArrayList<String> options);

}

 

options를 ArrayList<String>으로 받은 이유는 도커 공식 홈페이지 문서를 보면, 모든 도커 명령어에는 각기 다른 개수의 String 형태의 배열인 options가 들어 가는데, 각 method마다 개수가 지정되어진 String 형태의 배열을 선언하기에는 코드 작성에 있어서 resource가 많이 차지된다고 생각이 들었기 때문이다.

 

또한 위의 interface를 보면 파라미터 개수 혹은 파라미터에 사용되는 순서들이 같으면서도 다르다. 이 점 때문에 코드가 지저분 해질 수 있다고 생각해서 makeDockerCommand() method를 아래와 같이 만들어 주었다.

private String makeDockerCommand(String first, String second, String third, String fourth) {
    if (!first.isEmpty() && !first.isBlank()) {
        dockerCommand = String.format(dockerCommand+"%s ",first);
    }
    if (!second.isEmpty() && !second.isBlank()) {
        dockerCommand = String.format(dockerCommand+"%s ",second);
    }
    if (!third.isEmpty() && !third.isBlank()) {
        dockerCommand = String.format(dockerCommand+"%s ",third);
    }
    if (!fourth.isEmpty() && !fourth.isBlank()) {
        dockerCommand = String.format(dockerCommand+"%s ",fourth);
    }
    return dockerCommand;
}

해당 method를 작성해둔 덕분에 코드가 지저분해지지 않고 필요한 기능만 깔끔하게 작성이 가능했다. 각자의 options 및 arguments만 String 형태로 변경해준다면 위에서 만든 ShellScriptLib Class의 exec() method를 더 쉽게 사용할 수 있기 때문에 해당 메소드들은 command 명령어를 만들어 return시키는 용도로 활용하였다.

@Override
public String run(ArrayList<String> options, String dockerImage, String command, ArrayList<String> arguments) {
    String option = makeArrListToString(options);
    String argument = makeArrListToString(arguments);
    dockerCommand = "docker run ";
    return makeDockerCommand(dockerImage, command, option, argument);
}

@Override
public String push(ArrayList<String> options, String dockerImage) {
    String option = makeArrListToString(options);
    dockerCommand = "docker push ";
    return makeDockerCommand(option, dockerImage, "", "");
}

@Override
public String pull(ArrayList<String> options, String dockerImage) {
    String option = makeArrListToString(options);
    dockerCommand = "docker pull ";
    return makeDockerCommand(option, dockerImage, "", "");
}

@Override
public String exec(ArrayList<String> options, String dockerContainerName, String command, ArrayList<String> arguments) {
    String option = makeArrListToString(options);
    String argument = makeArrListToString(arguments);
    dockerCommand = "docker exec ";
    return makeDockerCommand(dockerContainerName, command, option, argument);
}

@Override
public String rm(ArrayList<String> options, String dockerContainerName) {
    String option = makeArrListToString(options);
    dockerCommand = "docker rm ";
    return makeDockerCommand(option,dockerContainerName,"","");
}

@Override
public String stop(ArrayList<String> options, String dockerContainerName) {
    String option = makeArrListToString(options);
    dockerCommand = "docker stop ";
    return makeDockerCommand(option,dockerContainerName,"","");
}

@Override
public String ps(ArrayList<String> options) {
    String option = makeArrListToString(options);
    dockerCommand = "docker ps ";
    return makeDockerCommand(option,"","","");
}

@Override
public String rmi(ArrayList<String> options, String dockerImage) {
    String option = makeArrListToString(options);
    dockerCommand = "docker rmi ";
    return makeDockerCommand(option,dockerImage,"","");
}

@Override
public String images(ArrayList<String> options) {
    String option = makeArrListToString(options);
    dockerCommand = "docker images ";
    return makeDockerCommand(option,"","","");
}

만들어진 command 명령어는 excuteShellScript() method를 별도로 만들어서 직접 실행 및 Json형태의 String을 return해주게 하였다.

public String excuteShellScript(String execCommand) {
    ShellScriptLib shellScriptLib = ShellScriptLib.getInstance();
    return shellScriptLib.exec(execCommand);
}

 

Make DockerManager Class

위에서 만든 도커 관련 기능들을 실제로 사용할 DockerManager Class. 해당 클래스 내부에 startBuild() method를 아래와 같이 생성해 주면 모든 작업이 끝난다.

package mingyu.classes;

import mingyu.utiles.DockerUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;
import java.util.List;

public class DockerManager {

    private Logger logger;
    private String dockerImage = null;
    private String gitRepositoryURL = null;
    private DockerUtil dockerUtil;

    public String getDockerImage() {
        return dockerImage;
    }

    public void setDockerImage(String dockerImage) {
        this.dockerImage = dockerImage;
    }

    public String getGitRepositoryURL() {
        return gitRepositoryURL;
    }

    public void setGitRepositoryURL(String gitRepositoryURL) {
        this.gitRepositoryURL = gitRepositoryURL;
    }

    public DockerManager(String dockerImage, String gitRepositoryURL) {
        logger = LoggerFactory.getLogger("BuilderManagerLogger");
        dockerUtil = DockerUtil.getInstance();
        setDockerImage(dockerImage);
        setGitRepositoryURL(gitRepositoryURL);
    }

    public void startBuild(String sourceDirectoryPath) {
        logger.info("docker Build Start !! \n");
        ArrayList<String> options = new ArrayList<String>(List.of(""));
        ArrayList<String> arguments = new ArrayList<String>(List.of(""));

        // docker pull images
        String dockerPullCommand = dockerUtil.pull(options, getDockerImage());
        String result = dockerUtil.excuteShellScript(dockerPullCommand);
        logger.info("docker pull message :: \n", dockerPullCommand, result);

        // docker run image when using docker build && remove after build
        // PHP Laravel Project가 Test Source이기 때문에 아래 스크립트로 작성함.
        // 추후 별도의 config.properties 등의 파일로 만들어 각 docker image명에 있는
        // Language 구분하여 별도로 관리 예정.
        String[] commandArray = new String[]{ // 만들어진 컨테이너에서 실행할 명령어
                "/bin/bash",
                "-c",
                "'cd /app && " +
                "rm -rf .git && " +
                "cp .env.example .env && " +
                "composer install && " +
                "php artisan config:clear && " +
                "php artisan cache:clear && " +
                "php artisan route:clear && " +
                "php artisan config:cache'"
        };
        options = new ArrayList<String>(List.of("-w", "/app", "-v", sourceDirectoryPath + ":/app", "--name", "builder", "--rm"));
        // command의 경우 java 8버전 이상부터 지원하는 String.join을 사용한 String Array to String
        // 으로 편하게 작성
        String dockerRunCommand = dockerUtil.run(options, getDockerImage(), String.join(" ", commandArray), arguments);
        result = dockerUtil.excuteShellScript(dockerRunCommand);
        logger.info("docker run message :: \n", dockerRunCommand, result);       
        // build source using docker container
        // PHP Laravel Project가 Test Source이기 때문에 아래 스크립트로 작성함.
        // 추후 별도의 config.properties 등의 파일로 만들어 각 docker image명에 있는
        // Language 구분하여 별도로 관리 예정.
//        String[] commandArray = new String[]{
//                "/bin/bash",
//                "-c",
//                "'cd /app && " +
//                "rm -rf .git && " +
//                "cp .env.example .env && " +
//                "composer install && " +
//                "php artisan config:clear && " +
//                "php artisan cache:clear && " +
//                "php artisan route:clear && " +
//                "php artisan config:cache'"
//        };
//        String dockerExecCommand = dockerUtil.exec(options, "builder", String.join(" ", commandArray), arguments);
//        result = dockerUtil.excuteShellScript(dockerExecCommand);
//        logger.info("docker exec message :: \n", dockerExecCommand, result);

        // docker container stop
//        String dockerStopCommand = dockerUtil.stop(options,"builder");
//        result = dockerUtil.excuteShellScript(dockerStopCommand);
//        logger.info("docker stop result :: \n",dockerStopCommand,result);

        // docker container remove
//        options = new ArrayList<String>(List.of(""));
//        String dockerRmCommand = dockerUtil.rm(options, "builder");
//        result = dockerUtil.excuteShellScript(dockerRmCommand);
//        logger.info("docker rm message :: \n", dockerRmCommand, result);                

    }
}

여기서 주의할 점은 최신화 된 source code는 .git 파일(해당 프로젝트에 연결된 git 설정들)을 포함하고 있기 때문에 .git 파일은 지운 후에 배포해야 한다. 때문에 위에 코드를 잘 보면 rm -rf 명령어로 .git을 삭제 한 후에 build 관련 작업을 실행하게 했다.

 

또한 만들어지는 docker container의 명칭은 "builder" 로 통일했다. 위 코드의 주석에 잘 보면 명시되어 있겠지만 demo용으로 만들고 있기 때문에 멋대로 명칭을 지정한 것이고, 추후 배포할 기회가 생긴다면 별도의 config.properties 등의 파일로 만들어 사용자들이 원하는 내용으로 컨트롤 할 수 있게 변경 예정이다.

 

코드를 작성하다 docker run의 --rm optioin으로 command 실행 이후 프로세스를 강제로 종료시킬 수 있는 것이 생각나서 -d 옵션을 ---rm 옵션으로 바꿔주고 exec에서 진행하였던 command를 run에서 실행하여 원큐에 끝내주었다. 때문에 아래 주석된 코드들은 필요 없어 주석처리를 해 주었다. 

Update BuilderManager Class

Main Class로 활용하는 BuilderManager.java의 main method에서 startBuild()만 실행하면 된다. main method에 아래와 같이 도커로 최신 source code build하기 기능을 추가하면 끝.

 public static void main(String[] args) throws Exception {
    // args[0] : "git repository url" when use git pull command
    // args[1] : "dockerRepo/dockerImage:tag" when use build
    // args[2] : CI_COMMIT_BRANCH

    String gitRepositoryURL = args[0];
    String dockerRepositoryImage = args[1];
    String gitCommitBranch = args[2];

    BuilderManager builderManager = new BuilderManager(gitRepositoryURL, dockerRepositoryImage, gitCommitBranch);

    // 해당 날짜 지정
    DateFormat dateFormat = new SimpleDateFormat("yyyyMMddHHmmss");
    String dateTime = dateFormat.format(new Date());
    String sourceDirectoryPath = "/Users/mingyukim/tmp/build/source/" + dateTime;

    // 최신 source code 다운로드
    builderManager.gitManager.sourceCodeDownload(dateTime,  sourceDirectoryPath);

    // 도커로 다운로드한 최신 source code build 하기
    builderManager.dockerManager.startBuild(sourceDirectoryPath);

}

이제 main method를 실행해주면 이전 포스팅에서 구현한 GitManager에서 다운받은 최신 소스를 DockerManager에서 원하는 Image로 Build작업을 끝마친 아래와 같은 결과물을 얻을 수 있다.

원하는 Git Repository URL에서 원하는 Branch의 Source를 다운받은 후, 원하는 Docker Image를 사용하여 원하는 빌드 command(나의 경우 .git 삭제, .env 복사, vendoer폴더 생성 등)실행을 완료하는 일련의 build 작업이 끝나는 데 까지 1분 9초 걸렸다.

빌드 시작 시간은 2022/12/28 16:14:59 , 빌드 종료 시간은 2022/12/28 16:16:09 로 git pull을 진행하는 새로운 폴더 생성 시간인 2022/12/28 16:15:02 가 빌드 이후 0.10초만에 진행된 것을 확인할 수 있다.

 

다음 포스팅에서는 이 녀석을 DockerImage 혹은 Tgz(압축)파일로 만드는 작업을 구현해 볼 예정이다.