实战:Docker 容器管理插件
用 dockerode 对接 Docker 引擎,把容器与镜像搬进侧边栏:树形列表启停删除、Webview 实时日志流、一键附加终端,再注册一个带 ProblemMatcher 的构建任务,构建错误直接进「问题」面板。
目标
- 容器管理:dockerode 列出容器,启动/停止/删除
- 镜像管理:列表展示与删除
- 日志流:Webview 监听容器日志,逐行追加渲染
- 终端附加:
docker exec+ 集成终端进入容器 shell - 构建任务:
docker build用 Task 封装,ProblemMatcher 解析错误输出
项目结构
docker-manager/
├── package.json
├── tsconfig.json
├── src/
│ ├── extension.ts # 激活入口:树、命令、任务、日志面板
│ ├── dockerService.ts # dockerode 封装:列表/启停/日志流
│ ├── dockerTree.ts # 容器与镜像树
│ └── logView.ts # Webview 日志面板package.json
{
"name": "docker-manager",
"displayName": "Docker Manager",
"description": "Docker 容器与镜像管理:树形操作、日志流、终端附加、构建任务",
"version": "1.0.0",
"publisher": "mypublisher",
"engines": { "vscode": "^1.80.0" },
"categories": ["Other"],
"main": "./out/extension.js",
"activationEvents": ["onStartupFinished"],
"contributes": {
"commands": [
{ "command": "docker.refresh", "title": "Docker: 刷新" },
{ "command": "docker.start", "title": "Docker: 启动容器" },
{ "command": "docker.stop", "title": "Docker: 停止容器" },
{ "command": "docker.remove", "title": "Docker: 删除容器" },
{ "command": "docker.removeImage", "title": "Docker: 删除镜像" },
{ "command": "docker.logs", "title": "Docker: 查看日志" },
{ "command": "docker.attach", "title": "Docker: 附加终端" },
{ "command": "docker.build", "title": "Docker: 构建镜像" }
],
"viewsContainers": {
"activitybar": [
{ "id": "docker", "title": "Docker", "icon": "media/icon.svg" }
]
},
"views": {
"docker": [
{ "id": "dockerContainers", "name": "容器" },
{ "id": "dockerImages", "name": "镜像" }
]
},
"menus": {
"view/title": [
{ "command": "docker.refresh", "when": "view == dockerContainers", "group": "navigation" },
{ "command": "docker.build", "when": "view == dockerImages", "group": "navigation" }
],
"view/item/context": [
{ "command": "docker.start", "when": "view == dockerContainers && viewItem == stopped", "group": "1_ops" },
{ "command": "docker.stop", "when": "view == dockerContainers && viewItem == running", "group": "1_ops" },
{ "command": "docker.logs", "when": "view == dockerContainers", "group": "2_view" },
{ "command": "docker.attach", "when": "view == dockerContainers && viewItem == running", "group": "2_view" },
{ "command": "docker.remove", "when": "view == dockerContainers", "group": "3_destructive" },
{ "command": "docker.removeImage", "when": "view == dockerImages", "group": "3_destructive" }
]
},
"taskDefinitions": [
{
"type": "dockerbuild",
"required": ["imageName"],
"properties": {
"imageName": { "type": "string", "description": "镜像名" }
}
}
],
"problemMatchers": [
{
"name": "dockerBuildError",
"owner": "dockerbuild",
"fileLocation": ["relative", "${workspaceFolder}"],
"pattern": {
"regexp": "^(?:.*?error|ERROR)(?:\\s*:\\s*(.*))?$",
"message": 1,
"severity": 2,
"line": 3,
"column": 4,
"file": 5
}
}
],
"configuration": {
"title": "Docker",
"properties": {
"docker.host": {
"type": "string",
"default": "",
"description": "Docker 主机地址,留空使用本机默认 socket/命名管道"
},
"docker.certPath": {
"type": "string",
"default": "",
"description": "TLS 证书目录(远程 Docker 时需要)"
}
}
}
},
"scripts": { "compile": "tsc -p ./" },
"dependencies": {
"dockerode": "^4.0.0"
},
"devDependencies": {
"@types/vscode": "^1.80.0",
"@types/dockerode": "^3.3.0",
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
}
}dockerService.ts:dockerode 封装
dockerode 是本机 Docker 引擎的官方 Node 客户端。Windows 默认走命名管道,Linux 走 unix socket,macOS 走 ~/.docker/run/docker.sock,留空让 dockerode 自动探测:
import * as vscode from 'vscode';
import Dockerode from 'dockerode';
export interface ContainerInfo {
id: string;
name: string; // 去掉开头的斜杠
image: string;
state: 'running' | 'exited' | 'created';
status: string; // 如 "Up 5 minutes"
}
export interface ImageInfo {
id: string; // 短 id
repository: string; // 如 "node"
tag: string;
size: number;
}
export class DockerService {
private docker: Dockerode | null = null;
// 惰性初始化:首次使用时根据配置创建客户端
private ensureClient(): Dockerode {
if (this.docker) return this.docker;
const cfg = vscode.workspace.getConfiguration('docker');
const host = cfg.get<string>('host') ?? '';
// 本机:自动选择平台默认端点
this.docker = host
? new Dockerode({ host, port: 2375 })
: new Dockerode();
return this.docker;
}
async listContainers(all = true): Promise<ContainerInfo[]> {
const list = await this.ensureClient().listContainers({ all });
return list.map((c) => ({
id: c.Id.slice(0, 12),
name: (c.Names[0] ?? '').replace(/^\//, ''),
image: c.Image,
state: c.State as ContainerInfo['state'],
status: c.Status
}));
}
async listImages(): Promise<ImageInfo[]> {
const list = await this.ensureClient().listImages();
return list.map((img) => ({
id: img.Id.slice(7, 19), // sha256:xxx 取短 id
repository: (img.RepoTags?.[0] ?? 'none').split(':')[0],
tag: (img.RepoTags?.[0] ?? 'none').split(':')[1] ?? 'latest',
size: img.Size
}));
}
// ---------- 容器操作 ----------
async startContainer(id: string): Promise<void> {
await this.ensureClient().getContainer(id).start();
}
async stopContainer(id: string): Promise<void> {
await this.ensureClient().getContainer(id).stop();
}
async removeContainer(id: string): Promise<void> {
// force 跳过运行中容器的保护
await this.ensureClient().getContainer(id).remove({ force: true });
}
async removeImage(id: string): Promise<void> {
await this.ensureClient().getImage(id).remove();
}
/**
* 订阅容器日志流,逐行回调 onLine。
* dockerode 的 logs 输出带 8 字节帧头(流类型+长度),需按帧剥离。
*/
async followLogs(
id: string,
onLine: (line: string) => void
): Promise<() => void> {
const stream = await this.ensureClient().getContainer(id).logs({
follow: true, // 持续跟随
stdout: true,
stderr: true,
timestamps: false
});
// 帧格式:1 字节流类型 + 3 字节保留 + 4 字节 payload 长度 + payload
let buffer = Buffer.alloc(0);
const onData = (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length >= 8) {
const len = buffer.readUInt32BE(4); // payload 长度
if (buffer.length < 8 + len) break; // 等完整一帧
const payload = buffer.subarray(8, 8 + len);
buffer = buffer.subarray(8 + len);
onLine(payload.toString('utf8'));
}
};
stream.on('data', onData);
// 返回取消订阅函数
return () => {
stream.removeListener('data', onData);
stream.destroy();
};
}
}dockerTree.ts:容器与镜像树
两棵树结构不同:容器树有两层(状态分组 → 容器),镜像树是单层列表。各建一个 provider:
import * as vscode from 'vscode';
import { ContainerInfo, DockerService, ImageInfo } from './dockerService';
// ---------- 容器树 ----------
export class ContainerTreeNode {
constructor(
public readonly label: string,
public readonly kind: 'group' | 'container',
public readonly container?: ContainerInfo,
public readonly collapsible = kind === 'group'
? vscode.TreeItemCollapsibleState.Collapsed
: vscode.TreeItemCollapsibleState.None
) {}
}
export class ContainerTreeProvider implements vscode.TreeDataProvider<ContainerTreeNode> {
private emitter = new vscode.EventEmitter<ContainerTreeNode | undefined>();
readonly onDidChangeTreeData = this.emitter.event;
constructor(private readonly docker: DockerService) {}
refresh(): void {
this.emitter.fire(undefined);
}
async getChildren(element?: ContainerTreeNode): Promise<ContainerTreeNode[]> {
const containers = await this.docker.listContainers();
if (!element) {
// 顶层:running / exited 两个分组
const running = new ContainerTreeNode('运行中 (running)', 'group');
const exited = new ContainerTreeNode('已停止 (exited)', 'group');
return [running, exited];
}
if (element.kind === 'group') {
const filter = element.label.includes('running') ? 'running' : 'exited';
return containers
.filter((c) => c.state === filter)
.map((c) => new ContainerTreeNode(c.name, 'container', c));
}
return [];
}
getTreeItem(element: ContainerTreeNode): vscode.TreeItem {
if (element.kind === 'group') {
const item = new vscode.TreeItem(element.label, element.collapsible);
item.iconPath = new vscode.ThemeIcon('type-hierarchy');
return item;
}
const item = new vscode.TreeItem(element.label, element.collapsible);
item.description = `${element.container!.image} · ${element.container!.status}`;
// contextValue 驱动右键菜单显示 start/stop/attach
item.contextValue = element.container!.state;
item.iconPath = new vscode.ThemeIcon(
element.container!.state === 'running' ? 'play' : 'debug-stop'
);
return item;
}
}
// ---------- 镜像树 ----------
export class ImageTreeNode extends vscode.TreeItem {
constructor(public readonly image: ImageInfo) {
super(`${image.repository}:${image.tag}`);
this.description = image.id;
this.tooltip = `大小: ${formatSize(image.size)}`;
this.iconPath = new vscode.ThemeIcon('package');
this.contextValue = 'image';
}
}
export class ImageTreeProvider implements vscode.TreeDataProvider<ImageTreeNode> {
private emitter = new vscode.EventEmitter<ImageTreeNode | undefined>();
readonly onDidChangeTreeData = this.emitter.event;
constructor(private readonly docker: DockerService) {}
refresh(): void {
this.emitter.fire(undefined);
}
async getChildren(): Promise<ImageTreeNode[]> {
const images = await this.docker.listImages();
return images.map((img) => new ImageTreeNode(img));
}
getTreeItem(element: ImageTreeNode): ImageTreeNode {
return element;
}
}
function formatSize(bytes: number): string {
if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
if (bytes > 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${(bytes / 1024).toFixed(1)} KB`;
}logView.ts:Webview 日志流
日志面板订阅 followLogs,把每行文本推送进 Webview,同时保留「断开」能力:
import * as vscode from 'vscode';
import { DockerService } from './dockerService';
export async function openLogs(
extensionUri: vscode.Uri,
docker: DockerService,
containerId: string
): Promise<void> {
const panel = vscode.window.createWebviewPanel(
'dockerLogs',
`日志: ${containerId}`,
vscode.ViewColumn.Two,
{ enableScripts: true, retainContextWhenHidden: true }
);
const scriptUri = panel.webview.asWebviewUri(
vscode.Uri.joinPath(extensionUri, 'media', 'logs.js')
);
panel.webview.html = getLogHtml(scriptUri);
// 订阅日志流;面板关闭时取消订阅,防止内存泄漏
const unsubscribe = await docker.followLogs(containerId, (line) => {
panel.webview.postMessage({ type: 'line', line });
});
panel.onDidDispose(() => unsubscribe());
// 页面滚动到底部由 logs.js 处理
}
function getLogHtml(scriptUri: vscode.Uri): string {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy"
content="default-src 'none';
style-src 'unsafe-inline';
script-src ${scriptUri.toString()};">
<style>
body { margin: 0; font-family: var(--vscode-editor-font-family); font-size: 12px; }
#log { padding: 8px; white-space: pre-wrap; word-break: break-all; }
</style>
</head>
<body>
<div id="log"></div>
<script src="${scriptUri}"></script>
</body>
</html>`;
}media/logs.js
// @ts-check
const vscode = acquireVsCodeApi();
const logEl = document.getElementById('log');
let autoScroll = true;
// 用户手动上滚时暂停自动滚动
logEl.addEventListener('scroll', () => {
const atBottom =
logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40;
autoScroll = atBottom;
});
window.addEventListener('message', (event) => {
if (event.data.type === 'line') {
logEl.textContent += event.data.line;
if (autoScroll) {
logEl.scrollTop = logEl.scrollHeight;
}
}
});extension.ts:装配与任务
容器操作命令统一走一个「执行后刷新」包装,避免每个命令重复刷新逻辑:
import * as vscode from 'vscode';
import { DockerService } from './dockerService';
import { ContainerTreeProvider, ContainerTreeNode, ImageTreeProvider } from './dockerTree';
import { openLogs } from './logView';
export function activate(context: vscode.ExtensionContext) {
const docker = new DockerService();
// ---------- 两棵树 ----------
const containerProvider = new ContainerTreeProvider(docker);
const imageProvider = new ImageTreeProvider(docker);
const containerView = vscode.window.createTreeView('dockerContainers', {
treeDataProvider: containerProvider
});
const imageView = vscode.window.createTreeView('dockerImages', {
treeDataProvider: imageProvider
});
context.subscriptions.push(containerView, imageView);
// 刷新两个视图
const refresh = () => {
containerProvider.refresh();
imageProvider.refresh();
};
context.subscriptions.push(
vscode.commands.registerCommand('docker.refresh', refresh)
);
// 容器操作命令(执行后统一刷新)
context.subscriptions.push(
vscode.commands.registerCommand('docker.start', async (node: ContainerTreeNode) => {
await docker.startContainer(node.container!.id);
refresh();
}),
vscode.commands.registerCommand('docker.stop', async (node: ContainerTreeNode) => {
await docker.stopContainer(node.container!.id);
refresh();
}),
vscode.commands.registerCommand('docker.remove', async (node: ContainerTreeNode) => {
const answer = await vscode.window.showWarningMessage(
`确定删除容器 ${node.container!.name}?`,
{ modal: true }, '删除'
);
if (answer === '删除') {
await docker.removeContainer(node.container!.id);
refresh();
}
}),
vscode.commands.registerCommand('docker.removeImage', async (node: ImageTreeNode) => {
await docker.removeImage(node.image.id);
refresh();
}),
// 查看日志
vscode.commands.registerCommand('docker.logs', async (node: ContainerTreeNode) => {
await openLogs(context.extensionUri, docker, node.container!.id);
}),
// 附加终端
vscode.commands.registerCommand('docker.attach', async (node: ContainerTreeNode) => {
attachToContainer(node.container!.id);
})
);
// ---------- 构建任务:docker build ----------
// 任务类型 dockerbuild,由 provideTasks 动态产出
context.subscriptions.push(
vscode.tasks.registerTaskProvider('dockerbuild', {
provideTasks(): vscode.Task[] {
// 用工作区名做默认镜像名
const folderName = vscode.workspace.workspaceFolders?.[0]?.name ?? 'app';
const definition = { type: 'dockerbuild', imageName: folderName };
const task = new vscode.Task(
definition,
vscode.TaskScope.Workspace,
`构建镜像 ${folderName}`,
'dockerbuild',
new vscode.ShellExecution(`docker build -t ${folderName} .`)
);
// 关联 problemMatcher:构建错误自动进「问题」面板
task.problemMatchers = ['$dockerBuildError'];
return [task];
},
resolveTask(task: vscode.Task): vscode.Task {
// 从 tasks.json 恢复时:补上执行与匹配器
const definition = task.definition as { imageName?: string };
const imageName = definition.imageName ?? 'app';
task.execution = new vscode.ShellExecution(
`docker build -t ${imageName} .`
);
task.problemMatchers = ['$dockerBuildError'];
return task;
}
})
);
// 构建命令:直接运行上面的任务
context.subscriptions.push(
vscode.commands.registerCommand('docker.build', async () => {
const tasks = await vscode.tasks.fetchTasks({ type: 'dockerbuild' });
const task = tasks[0];
if (task) {
vscode.tasks.executeTask(task);
}
})
);
}
function attachToContainer(id: string): void {
// 创建终端,用 docker exec 进入容器 shell
const terminal = vscode.window.createTerminal({
name: `容器 ${id.slice(0, 8)}`,
// 容器内可能没有 bash,用 sh 保证兼容
});
terminal.show(true);
// sendText 交互式命令:进入后终端即容器 shell
terminal.sendText(`docker exec -it ${id} /bin/sh`);
}ProblemMatcher 解析逻辑
docker build 失败时输出形如:
ERROR: failed to solve: process "/bin/sh -c npm install" did not complete successfully匹配器中 pattern 的 regexp 抓 ERROR: 行,message 取第 1 组,severity 固定为 error(没有匹配组时按顺序自动编号,缺省补为 undefined)。上面的 pattern 字段顺序 [message, severity, line, column, file] 按编号对位,没写 severity 正则组时默认缺省,匹配器会自动把整行作为问题输出到「问题」面板。
运行与验证
先 npm install,再按 F5 启动调试:
| 步骤 | 操作 | 预期 |
|---|---|---|
| 1 | 打开侧边栏 Docker 视图 | 容器按运行/停止分组,镜像列表可见 |
| 2 | 右键停止的容器 → 启动 | 状态变为 running,树刷新 |
| 3 | 右键运行中容器 → 查看日志 | Webview 实时滚动输出日志 |
| 4 | 右键运行中容器 → 附加终端 | 集成终端进入容器 shell |
| 5 | 右键镜像 → 删除 | 镜像被移除 |
| 6 | 执行「Docker: 构建镜像」 | 终端执行 docker build |
| 7 | 故意写坏 Dockerfile 再构建 | 错误行出现在「问题」面板 |
常见问题
| 问题 | 处理 |
|---|---|
| 连接不到 Docker | 本机先跑 docker version 确认引擎正常;远程加 host 与 certPath 配置 |
| 日志乱码 | dockerode 帧头剥离逻辑要按 8 字节头处理,见 followLogs |
| 附加终端失败 | 容器内可能没有 /bin/sh,改成 sh 或镜像自带的 shell |
| ProblemMatcher 不生效 | 检查 problemMatchers 数组里写的是 $dockerBuildError(含 $ 前缀) |
| 树不刷新 | 操作成功后务必调用 provider 的 refresh() |
本实战把外部工具链(Docker)完整接入编辑器:dockerode 承担协议细节,树与 Webview 提供界面,Task 体系让构建纳入 VS Code 原生工作流。同样的套路(CLI 封装 + 树 + 日志流 + 任务)可以直接复用到 Kubernetes、数据库、云服务等任何带命令行生态的工具。