In the context of the rapid development of cloud computing and microservice architecture, there is an increasing need to provide the ability to dynamically execute code for various programming languages ​​with a guarantee of security, scalability and high performance. This article describes a project that implements code execution in an isolated environment, and discusses the advantages of the chosen architectural solution for a modern WEB IDE. The system is built on Go, uses gRPC for effective interservice interaction, Redis as a message broker and Docker to isolate the execution environment. A WebSocket server is used to display results in real time.


We will describe in detail how the main components of the system are structured, how they differ from alternative solutions and why the choice of these technologies allows achieving high performance and security.


1. Architectural overview and main components

The project is built on the principle of microservice architecture, which allows you to divide functionality into independent services. Each component is responsible for a highly specialized task, which ensures flexibility, scalability, and fault tolerance of the system.


Main components:


  1. Binary protocol (Protocol Buffers): ensures fast and compact data transfer.
  2. Strict typing: helps to avoid errors in data transfer and processing.
  3. Low latency: which is critical for internal calls between services (for example, between a gRPC server and a Redis queue).





  1. Compiler and Docker Runner: A module responsible for running Docker commands with stream logging, allowing real-time monitoring of the compilation and execution process.
  2. Language Runners: Combine logic for validation, compilation, and execution of code for various languages ​​(C, C++, C#, Python, JavaScript, TypeScript). Each runner implements a single interface, which simplifies the expansion of functionality for new languages.




2. Technologies and rationale for choosing

Advantages of Go:


Advantages of gRPC:




Comparison: Unlike REST API, gRPC provides more efficient and reliable communication between services, which is critical for highly concurrent systems.

Why Redis?



The role of Docker:



WebSocket



3. Benefits of Microservice Architecture

This project uses a microservice approach, which has a number of significant advantages:







4. Comparative analysis of architectural approaches

When building modern WEB IDEs for remote code execution, various architectural solutions are often compared. Let’s consider two approaches:

Approach A: Microservice architecture (gRPC + Redis + Docker)


Features:

This approach provides fast and reliable inter-service communication, high isolation of code execution, and flexible scaling due to containerization. It is perfect for modern WEB IDEs, where responsiveness and security are important.

Approach B: Traditional Monolithic Architecture (HTTP REST + Centralized Execution)


Features:

Monolithic solutions, often used in early versions of web IDEs, are based on HTTP REST and centralized code execution. Such systems face scaling issues, increased latency, and difficulties in ensuring security when executing someone else’s code.


Note: In the modern context of WEB IDE development, the HTTP REST and centralized execution approach is inferior to the advantages of a microservices architecture, since it does not provide the necessary flexibility and scalability.

Visualization of comparative metrics

The graph clearly shows that the microservices architecture (Approach A) provides lower latency, higher throughput, better security and scalability compared to the monolithic solution (Approach B).


5. Docker architecture: isolation and scalability

One of the key elements of system security and stability is the use of Docker. In our solution, all services are deployed in separate containers, which ensures:






6. Small sections of code

Below is a minified version of the main sections of code that demonstrates how the system:


  1. Determines which language to run using the global runner registry.
  2. Starts a Docker container to run user code using the RunInDockerStreaming function.


1. Language detection through runner registration

The system uses a global registry, where each language has its own runner. This allows you to easily add support for new languages, it is enough to implement the runner interface and register it:


package languages

import (
 "errors"
 "sync"
)

var (
 registry   = make(map[string]Runner)
 registryMu sync.RWMutex
)

type Runner interface {
 Validate(projectDir string) error
 Compile(ctx context.Context, projectDir string) (<-chan string, error)
 Run(ctx context.Context, projectDir string) (<-chan string, error)
}

func Register(language string, runner Runner) {
 registryMu.Lock()
 defer registryMu.Unlock()
 registry[language] = runner
}

func GetRunner(language string) (Runner, error) {
 registryMu.RLock()
 defer registryMu.RUnlock()
 if runner, exists := registry[language]; exists {
  return runner, nil
 }
 return nil, errors.New("unsupported language")
}

// Example of registering a new language
func init() { 
  languages.Register("python", NewGenericRunner("python")) 
  languages.Register("javascript", NewGenericRunner("javascript"))
}


and receives the corresponding runner to execute the code.


runner, err := languages.GetRunner(req.Language)

2. Launching a Docker container to execute code

For each user code request, a separate Docker container is created. This is done inside the runner methods (for example, in Run). The main logic for running the container is in the RunInDockerStreaming function:


package compiler

import (
    "bufio"
    "fmt"
    "io"
    "log"
    "os/exec"
    "time"
)

func RunInDockerStreaming(image, dir, cmdStr string, logCh chan < -string) error {
    timeout: = 50 * time.Second
    cmd: = exec.Command("docker", "run",
        "--memory=256m", "--cpus=0.5", "--network=none",
        "-v", fmt.Sprintf("%s:/app", dir), "-w", "/app",
        image, "sh", "-c", cmdStr)
    cmd.Stdin = nil

    stdoutPipe,
    err: = cmd.StdoutPipe()
    if err != nil {
        return fmt.Errorf("error getting stdout: %v", err)
    }
    stderrPipe,
    err: = cmd.StderrPipe()
    if err != nil {
        return fmt.Errorf("error getting stderr: %v", err)
    }
    if err: = cmd.Start();err != nil {
        return fmt.Errorf("Error starting command: %v", err)
    }

    // Reading logs from the container
    go func() {
        reader: = bufio.NewReader(io.MultiReader(stdoutPipe, stderrPipe))
        for {
            line, isPrefix, err: = reader.ReadLine()
            if err != nil {
                if err != io.EOF {
                    logCh < -fmt.Sprintf("[Error reading logs: %v]", err)
                }
                break
            }
            msg: = string(line)
            for isPrefix {
                more, morePrefix, err: = reader.ReadLine()
                if err != nil {
                    break
                }
                msg += string(more)
                isPrefix = morePrefix
            }
            logCh < -msg
        }
        close(logCh)
    }()

    doneCh: = make(chan error, 1)
    go func() {
        doneCh < -cmd.Wait()
    }()

    select {
        case err:
            = < -doneCh:
                return err
        case <-time.After(timeout):
            if cmd.Process != nil {
                cmd.Process.Kill()
            }
            return fmt.Errorf("Execution timed out")
    }
}


This function generates the docker run command, where:





Thus, when calling the Run method of the runner, the following happens:




3. Integrated execution process

Minimized fragment of the main logic of code execution (executor.ExecuteCode):


func ExecuteCode(ctx context.Context, req CodeRequest, logCh chan string) CodeResponse {
    // Create a temporary directory and write files
    projectDir, err: = util.CreateTempProjectDir()
    if err != nil {
        return CodeResponse {
            "", fmt.Sprintf("Error: %v", err)
        }
    }
    defer os.RemoveAll(projectDir)
    for fileName, content: = range req.Files {
        util.WriteFileRecursive(filepath.Join(projectDir, fileName), [] byte(content))
    }

    // Get a runner for the selected language
    runner, err: = languages.GetRunner(req.Language)
    if err != nil {
        return CodeResponse {
            "", err.Error()
        }
    }
    if err: = runner.Validate(projectDir);
    err != nil {
        return CodeResponse {
            "", fmt.Sprintf("Validation error: %v", err)
        }
    }

    // Compile (if needed) and run code in Docker container
    compileCh, _: = runner.Compile(ctx, projectDir)
    for msg: = range compileCh {
        logCh < -"[Compilation]: " + msg
    }
    runCh, _: = runner.Run(ctx, projectDir)
    var output string
    for msg: = range runCh​​ {
        logCh < -"[Run]: " + msg
        output += msg + "\n"
    }

    return CodeResponse {
        Output: output
    }
}


In this minimal example:




These key fragments show how the system supports extensibility (easy addition of new languages) and provides isolation by creating a separate Docker container for each request. This approach improves the security, stability and scalability of the platform, which is especially important for modern WEB IDEs.


7. Conclusion

This article discusses a platform for remote code execution built on a microservice architecture using the gRPC + Redis + Docker stack. This approach allows you to:






A comparative analysis shows that the microservice architecture significantly outperforms traditional monolithic solutions in all key metrics. The advantages of this approach are confirmed by real data, which makes it an attractive solution for creating high-performance and fault-tolerant systems.



Author: Oleksii Bondar

Date: 2025–02–07