Files
hunjing/sandbox/src/docker_exec.rs
T
2026-08-21 16:00:13 +08:00

708 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Docker 容器执行封装:在预热池容器中执行命令、构建容器配置、镜像管理。
//!
//! 与 executor.rs 共享 ExecOutcome / ExecError 类型。
//! 调用方(warm_pool)先 acquire 容器句柄,再调用 exec_in_container 执行。
use std::collections::HashMap;
use std::time::{Duration, Instant};
use bollard::container::Config;
use bollard::exec::{CreateExecOptions, StartExecResults};
use bollard::image::CreateImageOptions;
use bollard::container::LogOutput;
use bollard::models::HostConfig;
use bollard::{Docker, API_DEFAULT_VERSION};
use futures_util::StreamExt;
use crate::config::WarmPoolConfig;
use crate::executor::{ExecError, ExecOutcome, StreamEvent};
/// 在已运行的容器中执行一条命令,返回 ExecOutcome(与进程模式相同的接口)。
///
/// # 参数
/// - `argv`:命令及参数数组
/// - `stdin`:可选标准输入
/// - `env`:环境变量键值对
/// - `timeout`:执行超时
/// - `max_output`:单流最大捕获字节数
pub async fn exec_in_container(
docker: &Docker,
container_id: &str,
argv: &[String],
stdin: Option<String>,
env: &[(String, String)],
timeout: Duration,
max_output: usize,
) -> Result<ExecOutcome, ExecError> {
let start = Instant::now();
// 1) 创建 exec 实例
let exec = docker
.create_exec(
container_id,
CreateExecOptions {
cmd: Some(argv.to_vec()),
env: Some(
env.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>(),
),
attach_stdout: Some(true),
attach_stderr: Some(true),
attach_stdin: Some(stdin.is_some()),
..Default::default()
},
)
.await
.map_err(|e| ExecError::Internal {
user_msg: "创建容器 exec 失败",
detail: format!("create_exec 失败: {e}"),
})?;
// 2) 启动 exec 并捕获输出
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut stdout_truncated = false;
let mut stderr_truncated = false;
let mut timeout_hit = false;
let cancelled = false;
let exec_result = tokio::time::timeout(timeout, async {
match docker.start_exec(&exec.id, None).await {
Ok(StartExecResults::Attached {
mut output,
mut input,
}) => {
// 先写入 stdin(若有),然后关闭输入管道
if let Some(data) = &stdin {
use tokio::io::AsyncWriteExt;
let _ = input.write_all(data.as_bytes()).await;
let _ = input.shutdown().await;
}
drop(input); // 关闭 stdin 触发命令的 EOF
while let Some(chunk) = output.next().await {
match chunk {
Ok(LogOutput::StdOut { message }) => {
let data = message.as_ref();
if stdout.len() < max_output {
let room = max_output - stdout.len();
let take = data.len().min(room);
stdout.extend_from_slice(&data[..take]);
if take < data.len() {
stdout_truncated = true;
}
} else {
stdout_truncated = true;
}
}
Ok(LogOutput::StdErr { message }) => {
let data = message.as_ref();
if stderr.len() < max_output {
let room = max_output - stderr.len();
let take = data.len().min(room);
stderr.extend_from_slice(&data[..take]);
if take < data.len() {
stderr_truncated = true;
}
} else {
stderr_truncated = true;
}
}
Ok(_) => {}
Err(e) => {
tracing::warn!(error = %e, exec_id = %exec.id, "容器 exec 流错误");
break;
}
}
}
}
Ok(_) => {
// Detached(无输出者适用),直接检查退出码
}
Err(e) => {
tracing::warn!(error = %e, exec_id = %exec.id, "启动容器 exec 失败");
}
}
})
.await;
if exec_result.is_err() {
timeout_hit = true;
// Docker exec 无 kill 端点;exec 会在容器中继续运行,
// 但容器会在 release 时被销毁,无需额外清理。
}
// 4) 获取退出码
let exit_code = docker
.inspect_exec(&exec.id)
.await
.ok()
.and_then(|i| i.exit_code)
.map(|c| c as i32);
Ok(ExecOutcome {
exit_code,
stdout: String::from_utf8_lossy(&stdout).into_owned(),
stderr: String::from_utf8_lossy(&stderr).into_owned(),
duration_ms: start.elapsed().as_millis(),
timeout: timeout_hit,
cancelled,
stdout_truncated,
stderr_truncated,
})
}
/// 流式执行:在容器中执行命令并通过 channel 实时推送输出事件。
pub async fn exec_in_container_stream(
docker: &Docker,
container_id: &str,
argv: &[String],
stdin: Option<String>,
env: &[(String, String)],
timeout: Duration,
max_output: usize,
) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, ExecError> {
let start = Instant::now();
let exec = docker
.create_exec(
container_id,
CreateExecOptions {
cmd: Some(argv.to_vec()),
env: Some(
env.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>(),
),
attach_stdout: Some(true),
attach_stderr: Some(true),
attach_stdin: Some(stdin.is_some()),
..Default::default()
},
)
.await
.map_err(|e| ExecError::Internal {
user_msg: "创建容器 exec 失败",
detail: format!("create_exec 失败: {e}"),
})?;
let (tx, rx) = tokio::sync::mpsc::channel::<StreamEvent>(64);
// 消费 exec 输出并转发到 channel
let docker_inner = docker.clone();
tokio::spawn(async move {
let mut out_written = 0usize;
let mut err_written = 0usize;
let mut out_trunc = false;
let mut err_trunc = false;
let exec_result = tokio::time::timeout(timeout, async {
match docker_inner.start_exec(&exec.id, None).await {
Ok(StartExecResults::Attached {
mut output,
mut input,
}) => {
// 先写入 stdin(若有),然后关闭输入管道
if let Some(data) = &stdin {
use tokio::io::AsyncWriteExt;
let _ = input.write_all(data.as_bytes()).await;
let _ = input.shutdown().await;
}
drop(input);
while let Some(chunk) = output.next().await {
match chunk {
Ok(log_output) => {
match log_output {
LogOutput::StdOut { message } => {
let data = message.as_ref();
if out_written < max_output {
let room = max_output - out_written;
let take = data.len().min(room);
if take > 0 {
let _ = tx
.send(StreamEvent::Stdout(data[..take].to_vec()))
.await;
}
out_written += take;
if take < data.len() {
out_trunc = true;
}
} else {
out_trunc = true;
}
}
LogOutput::StdErr { message } => {
let data = message.as_ref();
if err_written < max_output {
let room = max_output - err_written;
let take = data.len().min(room);
if take > 0 {
let _ = tx
.send(StreamEvent::Stderr(data[..take].to_vec()))
.await;
}
err_written += take;
if take < data.len() {
err_trunc = true;
}
} else {
err_trunc = true;
}
}
_ => {}
}
}
Err(e) => {
tracing::warn!(error = %e, "容器 exec 流错误");
break;
}
}
}
}
Ok(_) => {}
Err(e) => {
tracing::warn!(error = %e, "启动容器 exec 失败");
}
}
})
.await;
let timeout_hit = exec_result.is_err();
if timeout_hit {
// Docker exec 无 kill 端点;exec 在容器内继续运行,
// 容器会在 release 时被销毁,无需额外清理。
}
let exit_code = docker_inner
.inspect_exec(&exec.id)
.await
.ok()
.and_then(|i| i.exit_code)
.map(|c| c as i32);
let _ = tx
.send(StreamEvent::Done(ExecOutcome {
exit_code,
stdout: String::new(),
stderr: String::new(),
duration_ms: start.elapsed().as_millis(),
timeout: timeout_hit,
cancelled: false,
stdout_truncated: out_trunc,
stderr_truncated: err_trunc,
}))
.await;
});
Ok(rx)
}
/// 为预热容器构建 Docker Config。
///
/// 预热容器使用 `sleep infinity` 待命,实际执行通过 `exec_in_container` 注入命令。
pub fn build_container_config(
config: &WarmPoolConfig,
image: &str,
base_workdir: &str,
) -> Config<String> {
// Docker bind mount 要求绝对路径;相对路径返回 400 错误。
if !base_workdir.starts_with('/') {
tracing::warn!(
base_workdir,
"base_workdir 不是绝对路径,Docker bind mount 将失败"
);
}
let network_mode = if config.container_network.is_empty() {
None
} else {
Some(config.container_network.clone())
};
Config {
image: Some(image.to_string()),
cmd: Some(vec!["sleep".into(), "infinity".into()]),
host_config: Some(HostConfig {
network_mode,
readonly_rootfs: Some(true),
cap_drop: Some(vec!["ALL".into()]),
security_opt: Some(vec!["no-new-privileges:true".into()]),
memory: Some(config.memory_per_container),
memory_swap: Some(config.memory_per_container),
pids_limit: Some(config.pids_limit),
nano_cpus: Some(config.nano_cpus_per_container),
tmpfs: Some(HashMap::from([(
"/tmp".into(),
"size=67108864,mode=1777".into(),
)])),
binds: Some(vec![format!("{base_workdir}:/app/sandbox-work:rw")]),
..Default::default()
}),
..Default::default()
}
}
/// 检查镜像是否存在,不存在则拉取。
pub async fn ensure_image(docker: &Docker, image: &str) -> Result<(), String> {
let images = docker
.list_images::<String>(None)
.await
.map_err(|e| format!("列出镜像失败: {e}"))?;
if images
.iter()
.any(|i| i.repo_tags.contains(&image.to_string()))
{
return Ok(());
}
tracing::info!(image, "拉取容器镜像(首次使用)");
docker
.create_image(
Some(CreateImageOptions {
from_image: image,
..Default::default()
}),
None,
None,
)
.for_each(|_| async {}) // 静默消费拉取流
.await;
tracing::info!(image, "镜像拉取完成");
Ok(())
}
/// 连接 Docker 守护进程。
pub fn connect_docker(socket: &str) -> Result<Docker, String> {
if socket.starts_with("unix://") {
Docker::connect_with_unix(socket.trim_start_matches("unix://"), 120, API_DEFAULT_VERSION)
.map_err(|e| format!("连接 Docker Unix socket 失败: {e}"))
} else if socket.starts_with("tcp://") {
Docker::connect_with_http(socket, 120, API_DEFAULT_VERSION)
.map_err(|e| format!("连接 Docker TCP 失败: {e}"))
} else {
Docker::connect_with_local_defaults()
.map_err(|e| format!("连接本地 Docker 失败: {e}"))
}
}
/// 从 argv 首元素推断语言(用于选择预热池分桶)。
pub fn detect_language_from_argv(argv: &[String]) -> &str {
let cmd = argv.first().map(|s| s.as_str()).unwrap_or("");
if cmd.ends_with("python3") || cmd == "python" || cmd.contains("python") {
"python"
} else if cmd.ends_with("node") || cmd.contains("node") {
"node"
} else if cmd.ends_with("java") || cmd.contains("java") {
"java"
} else {
"generic"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_language_from_argv_python() {
assert_eq!(
detect_language_from_argv(&["python3".into(), "script.py".into()]),
"python"
);
assert_eq!(
detect_language_from_argv(&["python".into(), "-c".into(), "print(1)".into()]),
"python"
);
assert_eq!(
detect_language_from_argv(&["/usr/bin/python3".into(), "script.py".into()]),
"python"
);
}
#[test]
fn detect_language_from_argv_node() {
assert_eq!(
detect_language_from_argv(&["node".into(), "index.js".into()]),
"node"
);
assert_eq!(
detect_language_from_argv(&["/usr/local/bin/node".into(), "app.js".into()]),
"node"
);
}
#[test]
fn detect_language_from_argv_java() {
assert_eq!(
detect_language_from_argv(&["java".into(), "-jar".into(), "app.jar".into()]),
"java"
);
}
#[test]
fn detect_language_from_argv_fallback() {
assert_eq!(
detect_language_from_argv(&["cat".into(), "/etc/passwd".into()]),
"generic"
);
assert_eq!(
detect_language_from_argv(&["sh".into(), "-c".into(), "echo hi".into()]),
"generic"
);
}
#[test]
fn detect_language_from_argv_empty() {
assert_eq!(detect_language_from_argv(&[]), "generic");
}
#[test]
fn build_container_config_sets_basic_fields() {
let mut cfg = WarmPoolConfig::default();
cfg.images.insert(
"python".into(),
"python:3.12-slim".into(),
);
let image = cfg.images.get("python").unwrap();
let config = build_container_config(&cfg, image, "/app/sandbox-work");
assert_eq!(config.image.as_deref(), Some("python:3.12-slim"));
assert!(config.host_config.is_some());
let hc = config.host_config.unwrap();
assert_eq!(hc.network_mode, None, "默认容器网络应为 None(bridge)");
assert_eq!(hc.readonly_rootfs, Some(true));
assert_eq!(hc.cap_drop, Some(vec!["ALL".into()]));
assert_eq!(hc.memory, Some(512 * 1024 * 1024));
}
}
/// Docker 集成测试(需要在运行中的 Docker 守护进程)。
/// cargo test --features docker-tests
#[cfg(feature = "docker-tests")]
#[cfg(test)]
mod docker_integration_tests {
use super::*;
use crate::warm_pool::WarmPool;
fn test_image() -> &'static str {
"alpine:3.20"
}
async fn setup_docker() -> Option<(Docker, String)> {
let docker = connect_docker("unix:///var/run/docker.sock").ok()?;
ensure_image(&docker, test_image()).await.ok()?;
Some((docker, test_image().to_string()))
}
#[tokio::test]
async fn exec_echo_captures_stdout() {
let (docker, image) = match setup_docker().await {
Some(v) => v,
None => {
eprintln!("跳过:Docker 不可用");
return;
}
};
let mut cfg = WarmPoolConfig::default();
cfg.images.insert("generic".into(), image);
let pool = WarmPool::new(cfg, "/tmp".into(), docker.clone());
let config = build_container_config(pool.config(), &image, "/tmp");
let container = docker
.create_container::<String, String>(None, config)
.await
.expect("创建容器");
docker
.start_container::<String>(&container.id, None)
.await
.expect("启动容器");
let outcome = exec_in_container(
&docker,
&container.id,
&["echo".into(), "hello-docker".into()],
None,
&[],
Duration::from_secs(10),
4096,
)
.await
.expect("exec_in_container");
assert_eq!(outcome.exit_code, Some(0));
assert!(outcome.stdout.contains("hello-docker"));
assert!(!outcome.timeout);
assert!(!outcome.cancelled);
docker
.remove_container(
&container.id,
Some(RemoveContainerOptions {
force: true,
v: true,
..Default::default()
}),
)
.await
.ok();
}
#[tokio::test]
async fn exec_timeout_kills() {
let (docker, image) = match setup_docker().await {
Some(v) => v,
None => {
eprintln!("跳过:Docker 不可用");
return;
}
};
let mut cfg = WarmPoolConfig::default();
cfg.images.insert("generic".into(), image);
let pool = WarmPool::new(cfg, "/tmp".into(), docker.clone());
let config = build_container_config(pool.config(), &image, "/tmp");
let container = docker
.create_container::<String, String>(None, config)
.await
.expect("创建容器");
docker
.start_container::<String>(&container.id, None)
.await
.expect("启动容器");
let outcome = exec_in_container(
&docker,
&container.id,
&["sleep".into(), "30".into()],
None,
&[],
Duration::from_millis(200),
4096,
)
.await
.expect("exec_in_container");
assert!(outcome.timeout, "应超时返回");
assert!(outcome.duration_ms < 5000);
docker
.remove_container(
&container.id,
Some(RemoveContainerOptions {
force: true,
v: true,
..Default::default()
}),
)
.await
.ok();
}
#[tokio::test]
async fn exec_in_container_stream_emits_events() {
let (docker, image) = match setup_docker().await {
Some(v) => v,
None => {
eprintln!("跳过:Docker 不可用");
return;
}
};
let mut cfg = WarmPoolConfig::default();
cfg.images.insert("generic".into(), image);
let pool = WarmPool::new(cfg, "/tmp".into(), docker.clone());
let config = build_container_config(pool.config(), &image, "/tmp");
let container = docker
.create_container::<String, String>(None, config)
.await
.expect("创建容器");
docker
.start_container::<String>(&container.id, None)
.await
.expect("启动容器");
let mut rx = exec_in_container_stream(
&docker,
&container.id,
&["echo".into(), "stream-test".into()],
None,
&[],
Duration::from_secs(10),
4096,
)
.await
.expect("exec_in_container_stream");
use futures_util::StreamExt;
let mut found_stdout = false;
let mut found_done = false;
while let Some(event) = rx.recv().await {
match event {
StreamEvent::Stdout(data) => {
if String::from_utf8_lossy(&data).contains("stream-test") {
found_stdout = true;
}
}
StreamEvent::Done(o) => {
assert_eq!(o.exit_code, Some(0));
found_done = true;
}
_ => {}
}
}
assert!(found_stdout, "应收到 stdout 事件");
assert!(found_done, "应以 Done 结束");
docker
.remove_container(
&container.id,
Some(RemoveContainerOptions {
force: true,
v: true,
..Default::default()
}),
)
.await
.ok();
}
#[tokio::test]
async fn warm_pool_acquire_release_cycle() {
let (docker, image) = match setup_docker().await {
Some(v) => v,
None => {
eprintln!("跳过:Docker 不可用");
return;
}
};
let mut cfg = WarmPoolConfig::default();
cfg.images.insert("generic".into(), image);
cfg.max_idle_per_lang = 1;
cfg.global_max_containers = 5;
let pool = WarmPool::new(cfg, "/tmp".into(), docker);
pool.warmup_all().await;
let stats = pool.collect_metrics().await;
let (idle, ..) = stats.get("generic").copied().unwrap_or((0, 0, 0));
assert!(idle >= 1, "预热后应有至少 1 个空闲容器");
let handle = pool.clone().acquire("generic").await.expect("acquire");
assert_eq!(handle.language, "generic");
pool.release(handle).await;
tokio::time::sleep(Duration::from_millis(500)).await;
let stats2 = pool.collect_metrics().await;
let (idle2, ..) = stats2.get("generic").copied().unwrap_or((0, 0, 0));
assert!(idle2 >= 1, "release 后应有替补容器");
pool.shutdown().await;
}
}