쉘 스크립트로 지금까지 모두 구현했지만, 이제는 Java 애플리케이션으로 실행하는 것을 구현하려고 한다. 방법 자체는 어렵지 않다.

이전에 작성한 스크립트를 Java 애플리케이션에서 환경 변수를 주입하고, 스크립트를 실행하면 된다. 바로 작성해 보자.

 

Firecrakcer Controller

package firecracker_application.firecracker_instance.controller;

import firecracker_application.firecracker_instance.controller.dto.StartVMRequest;
import firecracker_application.firecracker_instance.controller.dto.ToWarmUpRequest;
import firecracker_application.firecracker_instance.util.VMManager;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;

@RequiredArgsConstructor
@RestController
public class FirecrackerController {

    private final VMManager vmManager;

    @PostMapping("/instance-start")
    public ResponseEntity<Object> request(@RequestBody StartVMRequest request) throws IOException, InterruptedException {
        return ResponseEntity.ok(vmManager.instanceStart(request));
    }
}

 

코드 자체는 2번 요청 애플리케이션의 요청을 받는 API 하나만 존재한다. Request는 조금 변했다. 

 

@Getter
public class StartVMRequest {
    private Integer requestMemory;
    private String architect;
    private String language;
    private String arn;
    private String bucketName;
    private String filePath;
    private String env;
}

 

VMManager

Service 계층에 해당한다. Firecracker 실행 스크립트의 환경 변수를 주입하고 실행시킨다. 이전에 스크립트에서 작성했던, 랜덤 IP 설정과 현재 사용 중인지 확인하는 로직이 구현돼 있다. 다른 점은 Java 애플리케이션에서 별도로 스크립트를 실행하므로 프로세스를 생성해야 한다는 점이다. 

 

package firecracker_application.firecracker_instance.util;

import firecracker_application.firecracker_instance.controller.dto.ResourceRequest;
import firecracker_application.firecracker_instance.controller.dto.ResourceResponse;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Value;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.*;

@Component
public class VMManager {

    @Value("${firecracker.file.ssh-key}")
    private String SSH_KEY_PATH;

    @Value("${firecracker.file.rootfs}")
    private String ROOTFS_PATH;

    @Value("${firecracker.file.kernel}")
    private String KERNEL_PATH;

    @Value("${firecracker.file.logfile}")
    private String LOGFILE_PATH;

    @Value("${firecracker.file.start_script}")
    private String START_SCRIPT_PATH;

    public ResourceResponse instanceStart(final StartVMRequest startVMRequest) throws IOException, InterruptedException {
        List<String> outputLines = new ArrayList<>();
        Map<String, String> env = getEnv(startVMRequest);
        ProcessBuilder processBuilder = new ProcessBuilder("bash", START_SCRIPT_PATH);
        processBuilder.environment().putAll(env);
        processBuilder.redirectErrorStream(true);

        try {
            Process process = processBuilder.start();
            final boolean[] withinOutput = {false}; // 배열 형태로 무상태 유지
        
            // 출력 스트림을 비동기적으로 읽어들이기 위한 스레드 실행
            Thread outputThread = new Thread(() -> {
                try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
                    String line;
                    while ((line = reader.readLine()) != null) {
                        // SCRIPT_OUTPUT_START와 SCRIPT_OUTPUT_END 기준에 따라 처리합니다.
                        if (line.contains("SCRIPT_OUTPUT_START")) {
                            withinOutput[0] = true; // 시작 지점 발견
                            continue; // 이 줄은 출력하지 않음
                        } else if (line.contains("SCRIPT_OUTPUT_END")) {
                            withinOutput[0] = false; // 종료 지점 발견
                            continue; // 이 줄은 출력하지 않음
                        }
                        System.out.println(line);
                        // 현재 섹션 내에 있을 때만 출력 및 추가
                        if (withinOutput[0]) {
                            if (line.contains("[INFO]")) {
                                outputLines.add(line);
                                System.out.println(line); // 원하는 경우 출력
                            }
                        }
                    }
                } catch (IOException e) {
                    System.err.println("Error reading output: " + e.getMessage());
                }
            });
        
            outputThread.start(); // 출력 읽기 스레드 시작
        
            int exitCode = process.waitFor(); // 프로세스 종료 대기
            outputThread.join(); // 출력 스레드가 종료될 때까지 대기합니다.
        
            if (exitCode != 0) {
                throw new RuntimeException("Process exited with non-zero status: " + exitCode);
            }
        
        } catch (Exception e) {
            return new CommonResponse(Collections.singletonList("Error: " + e.getMessage()));
        }
        

        return new CommonResponse(outputLines);
    }

    public Map<String, String> getEnv(final StartVMRequest startVMRequest) {
        System.out.println(KERNEL_PATH.replace("vmlinux", startVMRequest.getArchitect() + "_vmlinux"));
        System.out.println(ROOTFS_PATH.replace("ubuntu", startVMRequest.getLanguage() + "_ubuntu"));
        Map<String, String> env = new HashMap<>(System.getenv());
        int sbId = generateRandomSbId();

        env.put("VCPU", String.valueOf(Math.max(1, Math.round(startVMRequest.getRequestMemory() / 2048 * 2) / 2)));
        System.out.println(env.get("VCPU"));
        env.put("MEMORY", String.valueOf(startVMRequest.getRequestMemory()));
        env.put("SB_ID", String.valueOf(sbId));
        env.put("TAP_DEV", "tap" + sbId);
        env.put("BUCKET_NAME", startVMRequest.getBucketName());
        env.put("FILE_PATH", startVMRequest.getFilePath());
        env.put("MASK_LONG", "255.255.255.252");
        env.put("MASK_SHORT", "/30");
        env.put("FC_IP", String.format("176.12.0.%d", ((4 * sbId + 1) % 256)));
        env.put("TAP_IP", String.format("176.12.0.%d", ((4 * sbId + 2) % 256)));
        env.put("FC_MAC", String.format("02:FC:00:00:%02X:%02X", sbId / 256, sbId % 256));
        env.put("ROOTFS", ROOTFS_PATH.replace("ubuntu", startVMRequest.getLanguage() + "_ubuntu"));
        env.put("LOGFILE", LOGFILE_PATH + sbId + "-log");
        env.put("API_SOCKET", "/tmp/firecracker-sb" + sbId + ".socket");
        env.put("KERNEL_BOOT_ARGS", String.format("console=ttyS0 reboot=k panic=1 pci=off ip=%s::%s:%s::eth0:off",
                env.get("FC_IP"), env.get("TAP_IP"), env.get("MASK_LONG")));
        env.put("KERNEL", KERNEL_PATH.replace("vmlinux", startVMRequest.getArchitect() + "_vmlinux"));
        env.put("SSH_KEY_PATH",SSH_KEY_PATH);
        env.put("ENV", startVMRequest.getEnv());
        return env;
    }

    private static int generateRandomSbId() {
        Random random = new Random();
        int id;
        do {
            id = random.nextInt(255);
        } while (isIpAddressInUse(id));
        return id;
    }

    private static boolean isIpAddressInUse(int id) {
        String[] command = {"/bin/sh", "-c", "ip addr | grep " + "tap" + id};

        try {
            Process process = Runtime.getRuntime().exec(command);
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    if (line.contains("tap" + id)) {
                        return true;  // IP 주소가 사용 중
                    }
                }
            }

            int exitCode = process.waitFor();
            if (exitCode != 0) {
                System.out.println("Error checking IP address: " + exitCode);
            }
        } catch (IOException | InterruptedException e) {
            System.out.println("Exception while checking IP address" + e);
        }

        return false;  // IP 주소가 사용 중이지 않음
    }
}

 

주의할 점은 Java로 프로세스를 생성해서, 스크립트를 실행하는 게 처음이라서 process.waitFor()을 실행해 줘야 프로세스가 끝날 때까지 대기한다는 점이었다. 이 메서드를 추가 안 했을 때 VM이 실행되지 않고, 응답을 반환하여 문제가 발생했다. 이것을 추가하고 비동기적으로 출력을 담당하는 스레드를 별도 생성함으로써 해결했다. 스크립트를 실행했을 때 출력을 반환해야 실제로 Lambda와 유사하게 구현할 수 있을 것이라고 생각했다. 문제는 VM 내에서 실행되는 모든 게 출력되다 보니 아래의 로직을 추가하여 실제 코드가 돌아갈 때의 출력물만 반환하도록 구현했다.

if (line.contains("SCRIPT_OUTPUT_START")) {
    withinOutput[0] = true; // 시작 지점 발견
    continue; // 이 줄은 출력하지 않음
} else if (line.contains("SCRIPT_OUTPUT_END")) {
    withinOutput[0] = false; // 종료 지점 발견
    continue; // 이 줄은 출력하지 않음
}
System.out.println(line);
// 현재 섹션 내에 있을 때만 출력 및 추가
if (withinOutput[0]) {
    if (line.contains("[INFO]")) {
        outputLines.add(line);
        System.out.println(line); // 원하는 경우 출력
    }
}

 

SCRIPT_OUTPUT_START는 VM 생성이 완료되고, 실제 코드가 실행되기 전에 출력하여 출력을 시작한다는 것을 알린다. 그 후 INFO가 포함된 문자열을 반환하기 위해 필터링하고, 끝나면 SCRIPT_OUTPUT_END를 읽어 들인다. 프로세스가 끝나면 자동적으로 스레드가 종료된다. 

 

Script

#!/bin/bash

set -euo pipefail

# Function to generate a random SB_ID
curl_put() {
    local URL_PATH="$1"
    local OUTPUT RC
    OUTPUT="$("${CURL[@]}" -X PUT --data @- "http://localhost/${URL_PATH#/}" 2>&1)"
    RC="$?"
    if [ "$RC" -ne 0 ]; then
        echo "Error: curl PUT ${URL_PATH} failed with exit code $RC, output:"
        echo "$OUTPUT"
        return 1
    fi
    # Error if output doesn't end with "HTTP 2xx"
    if [[ "$OUTPUT" != *HTTP\ 2[0-9][0-9] ]]; then
        echo "Error: curl PUT ${URL_PATH} failed with non-2xx HTTP status code, output:"
        echo "$OUTPUT"
        return 1
    fi
}

start_microvm_with_network() {
    # Firecracker 실행
    sudo rm -f "$API_SOCKET"
    sudo /home/rkdlem48/implements/firecracker --api-sock "$API_SOCKET" &

    while [ ! -e "$API_SOCKET" ]; do
        echo "FC $SB_ID still not ready..."
        sleep 0.01
    done

    ip link del "$TAP_DEV" 2> /dev/null || true
    ip tuntap add dev "$TAP_DEV" mode tap
    ip link set dev "$TAP_DEV" up
    ip addr add "${TAP_IP}${MASK_SHORT}" dev "$TAP_DEV"
    sysctl -w net.ipv4.conf.${TAP_DEV}.proxy_arp=1 > /dev/null
    sysctl -w net.ipv6.conf.${TAP_DEV}.disable_ipv6=1 > /dev/null

    # 로그 설정
    curl_put "/logger" <<EOF
{
    "level": "Info",
    "log_path": "$LOGFILE",
    "show_level": false,
    "show_log_origin": false
}
EOF

    # 머신 설정
    curl_put "/machine-config" <<EOF
{
    "vcpu_count": $VCPU,
    "mem_size_mib": $MEMORY
}
EOF

    # 부팅 소스 설정
    curl_put "/boot-source" <<EOF
{
    "kernel_image_path": "$KERNEL",
    "boot_args": "$KERNEL_BOOT_ARGS"
}
EOF

   # 루트 파일 시스템 설정
    curl_put "/drives/$SB_ID" <<EOF
{
    "drive_id": "$SB_ID",
    "path_on_host": "$ROOTFS",
    "is_root_device": true,
    "is_read_only": false
}
EOF

    # 네트워크 인터페이스 설정
    curl_put "/network-interfaces/$SB_ID" <<EOF
{
    "iface_id": "$SB_ID",
    "guest_mac": "$FC_MAC",
    "host_dev_name": "$TAP_DEV"
}
EOF

    # microVM 시작
    curl_put '/actions' <<EOF
{
    "action_type": "InstanceStart"
}
EOF

    echo "InstanceStart command executed"

    sleep 4
}

execute_script_in_microvm() {
    echo "SCRIPT_OUTPUT_START"
    # SSH를 통해 S3에서 스크립트 다운로드 및 실행
    ssh -o StrictHostKeyChecking=no -i $SSH_KEY_PATH root@${FC_IP} << EOF
        echo [INFO] "$FC_IP"

        ip route add default via $TAP_IP dev eth0
        echo 'nameserver 8.8.8.8' > /etc/resolv.conf

        aws s3 cp s3://$BUCKET_NAME/$FILE_PATH /root

        java -Denv="$ENV" $FILE_PATH | while IFS= read -r line; do
            echo "[INFO] \$line"
        done

        echo "SCRIPT_OUTPUT_END"
        nohup reboot &
EOF
}

CURL=(curl --silent --show-error --header "Content-Type: application/json" --unix-socket "${API_SOCKET}" --write-out "HTTP %{http_code}")
touch $LOGFILE

# Start the microVM with networking
start_microvm_with_network

execute_script_in_microvm

exit 0

 

로직과 환경 변수를 자바에서 진행한다는 것을 제외하면 달라진 부분은 없다.

 

Application YAML

spring:
  application:
    name: firecracker-instance

server:
  port: 8081

firecracker:
  file:
    ssh-key: /home/rkdlem48/implements/ubuntu-24.04.id_rsa
    rootfs: /home/rkdlem48/implements/ubuntu-24.04.ext4
    kernel: /home/rkdlem48/implements/vmlinux-5.10.225
    logfile: /home/rkdlem48/implements/output/fc-sb
    start_script: /home/rkdlem48/lambda-implement-firecracker-instance/start_instance.sh

 

바뀐 부분은 많이 없지만 스크립트 위치를 환경변수로 설정하는 부분이 추가 됐다.

 

개선해야 할 점

구현하다 보니 개선해야할 점이 있다. 

 

1. VM 실행동안 프로세스 활성화 문제 

 

하나의 프로세스에서 명령어를 실행하다 보니 Firecracker 프로세스를 종료시키지 않으면, 응답을 반환할 수 없게 된다. 응답 자체도 느려지고, Lambda의 Warm up 방식을 구현할 수 없게 된다. 프로세스를 분리함으로써 다음 글에서 해결하려고 한다. 

 

2. 파일시스템 공유 문제

 

하나의 인스턴스에 여러 개의 microVM을 실행시키는 게 Lambda의 기본이다. 하나의 파일 시스템을 공유해서 microVM들이 실행되다 보니 서로의 파일들을 공유하게 되는 문제가 발생한다. 베이스의 파일 시스템을 두고, 복사해서 별도의 파일 시스템을 사용하게 만드려고 한다.