En el contexto del rápido desarrollo de la computación en la nube y la arquitectura de microservicios, existe una creciente necesidad de proporcionar la capacidad de ejecutar código de forma dinámica para varios lenguajes de programación con garantía de seguridad, escalabilidad y alto rendimiento. Este artículo describe un proyecto que implementa la ejecución de código en un entorno aislado y analiza las ventajas de la solución arquitectónica elegida para un IDE WEB moderno. El sistema está construido sobre
Describiremos en detalle cómo están estructurados los principales componentes del sistema, en qué se diferencian de las soluciones alternativas y por qué la elección de estas tecnologías permite alcanzar un alto rendimiento y seguridad.
El proyecto se basa en el principio de la arquitectura de microservicios, que permite dividir la funcionalidad en servicios independientes. Cada componente es responsable de una tarea altamente especializada, lo que garantiza la flexibilidad, la escalabilidad y la tolerancia a fallas del sistema.
Componentes principales:
Ventajas de Go:
Rendimiento y escalabilidad: Go tiene una alta velocidad de ejecución, lo que es especialmente importante para manejar una gran cantidad de solicitudes paralelas.
Soporte de concurrencia incorporado: Los mecanismos de goroutines y canales permiten implementar interacción asincrónica entre componentes sin patrones complejos de subprocesamiento múltiple.
Ventajas de gRPC:
Comparación: a diferencia de la API REST, gRPC proporciona una comunicación más eficiente y confiable entre servicios, lo cual es fundamental para sistemas altamente concurrentes.
¿Por qué Redis?
Alto rendimiento: Redis puede manejar una gran cantidad de operaciones por segundo, lo que lo hace ideal para colas de tareas y resultados.
Compatibilidad con Pub/Sub y listas: la simplicidad de implementar colas y mecanismos de suscripción facilita la organización de interacciones asincrónicas entre servicios.
Comparación con otros agentes de mensajes: a diferencia de RabbitMQ o Kafka, Redis requiere menos configuración y proporciona un rendimiento suficiente para sistemas en tiempo real.
El papel de Docker:
Aislamiento del entorno: los contenedores Docker permiten ejecutar código en un entorno completamente aislado, lo que aumenta la seguridad de ejecución y reduce el riesgo de conflictos con el sistema principal.
Manejabilidad y consistencia: el uso de Docker proporciona el mismo entorno para compilar y ejecutar código, independientemente del sistema host.
Comparación: ejecutar código directamente en el host puede representar un riesgo de seguridad y generar conflictos de dependencia, mientras que Docker le permite resolver estos problemas.
Este proyecto utiliza un enfoque de microservicio, que tiene una serie de ventajas importantes:
Al crear IDEs WEB modernos para la ejecución remota de código, se suelen comparar distintas soluciones arquitectónicas. Consideremos dos enfoques:
Enfoque A: Arquitectura de microservicios (gRPC + Redis + Docker)
Características:
Este enfoque proporciona una comunicación rápida y confiable entre servicios, un alto aislamiento de la ejecución del código y una escalabilidad flexible gracias a la contenedorización. Es perfecto para los IDE WEB modernos, donde la capacidad de respuesta y la seguridad son importantes.
Enfoque B: Arquitectura monolítica tradicional (HTTP REST + ejecución centralizada)
Características:
Las soluciones monolíticas, que se utilizan a menudo en las primeras versiones de los IDE web, se basan en HTTP REST y en la ejecución centralizada de código. Estos sistemas se enfrentan a problemas de escalabilidad, mayor latencia y dificultades para garantizar la seguridad al ejecutar el código de otra persona.
Nota: En el contexto moderno del desarrollo de WEB IDE, el enfoque de ejecución centralizada y HTTP REST es inferior a las ventajas de una arquitectura de microservicios, ya que no proporciona la flexibilidad y escalabilidad necesarias.
El gráfico muestra claramente que la arquitectura de microservicios (Enfoque A) proporciona menor latencia, mayor rendimiento, mejor seguridad y escalabilidad en comparación con la solución monolítica (Enfoque B).
Uno de los elementos clave de la seguridad y estabilidad del sistema es el uso de Docker. En nuestra solución, todos los servicios se implementan en contenedores separados, lo que garantiza:
A continuación se muestra una versión minimizada de las secciones principales del código que demuestra cómo funciona el sistema:
El sistema utiliza un registro global, donde cada idioma tiene su propio ejecutor. Esto permite agregar fácilmente soporte para nuevos idiomas, basta con implementar la interfaz del ejecutor y registrarla:
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") }
Ejemplo de registro de un nuevo idioma:
func init() { languages.Register("python", NewGenericRunner("python")) languages.Register("javascript", NewGenericRunner("javascript")) }
Así, al recibir una solicitud, el sistema llama a:
runner, err := languages.GetRunner(req.Language)
y recibe el corredor correspondiente para ejecutar el código.
Para cada solicitud de código de usuario, se crea un contenedor Docker independiente. Esto se hace dentro de los métodos de ejecución (por ejemplo, en Run). La lógica principal para ejecutar el contenedor se encuentra en la función RunInDockerStreaming:
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") } }
Esta función genera el comando docker run, donde:
Así, al llamar al método Run del corredor, ocurre lo siguiente:
Fragmento minimizado de la lógica principal de ejecución del código (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 } }
En este ejemplo mínimo:
Estos fragmentos clave muestran cómo el sistema admite la extensibilidad (fácil incorporación de nuevos idiomas) y proporciona aislamiento mediante la creación de un contenedor Docker independiente para cada solicitud. Este enfoque mejora la seguridad, la estabilidad y la escalabilidad de la plataforma, lo que es especialmente importante para los IDE WEB modernos.
En este artículo se analiza una plataforma para la ejecución remota de código basada en una arquitectura de microservicios que utiliza la pila gRPC + Redis + Docker. Este enfoque le permite:
Un análisis comparativo muestra que la arquitectura de microservicios supera significativamente a las soluciones monolíticas tradicionales en todas las métricas clave. Las ventajas de este enfoque se confirman con datos reales, lo que lo convierte en una solución atractiva para crear sistemas de alto rendimiento y tolerantes a fallas.
Autor: Oleksii Bondar
Fecha: 2025–02–07