初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+707
View File
@@ -0,0 +1,707 @@
//! 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;
}
}