Lakutata Docker 集成
Docker 组件(lakutata/com/docker,@lakutata/docker 包)通过 Docker Engine API 操作 Docker 守护进程,不需要 Docker CLI:拉取、构建(使用 BuildKit)、导入、导出、打标签和推送镜像,创建、运行和管理容器(exec、交互式 TTY、日志、统计、commit、导出文件),以及管理网络。它可以连接本地套接字,也可以通过 HTTP、HTTPS 或 SSH 连接远程守护进程。全部选项见 @lakutata/docker 的 README 和 lakutata/com/docker 的类型声明。
这个组件没有预置:需要在应用的 components 中声明。只运行 containerd、没有 Docker 的机器(嵌入式 Linux 设备)见 ContainerdIntegration。
1. 环境要求与连接
组件需要应用能访问到的 Docker 守护进程(Docker Engine 或 Docker Desktop,Engine API 1.41 或更高版本)。SSH 连接需要可选的 peer 依赖 ssh2(npm install ssh2),以及远程主机上的 Docker CLI。
| 连接方式 | 选项 |
|---|---|
| 环境中的 Docker(默认) | 无:读取 DOCKER_HOST、DOCKER_TLS_VERIFY、DOCKER_CERT_PATH、DOCKER_CLIENT_TIMEOUT,否则使用本地套接字 |
| Unix 套接字 / Windows 命名管道 | socketPath(buildDockerSocketConnectionOptions) |
| TCP,HTTP | host、port(buildDockerHttpConnectionOptions) |
| TCP,HTTPS | host、port,以及 PEM 内容形式的 ca、cert、key(buildDockerHttpsConnectionOptions) |
| SSH | host、port、username、sshAuthAgent(buildDockerSSHConnectionOptions) |
组件创建时会 ping 守护进程,并协商 API 版本:守护进程连不上时,组件创建失败,抛出 DockerConnectionException;组件列在 bootstrap 里时,应用因此启动失败。timeout(毫秒)是请求最多可以空闲多久。
import {readFileSync} from 'node:fs'
import {Application} from 'lakutata'
import {buildDockerHttpsConnectionOptions, buildDockerSocketConnectionOptions, Docker} from 'lakutata/com/docker'
Application.run(() => ({
id: 'docker.app',
name: 'Docker',
components: {
//环境中的 Docker:DOCKER_HOST,否则为本地套接字
docker: {class: Docker},
//套接字,请求超时 60 秒
local: buildDockerSocketConnectionOptions({socketPath: '/var/run/docker.sock', timeout: 60000}),
//TLS:传入 PEM 内容,而不是文件路径
remote: buildDockerHttpsConnectionOptions({
host: 'docker.example.com',
port: 2376,
ca: readFileSync('/etc/docker/certs/ca.pem'),
cert: readFileSync('/etc/docker/certs/cert.pem'),
key: readFileSync('/etc/docker/certs/key.pem')
})
},
bootstrap: ['docker']
}))2. 第一个示例
import {Application} from 'lakutata'
import {Docker, type DockerContainer, type DockerImage} from 'lakutata/com/docker'
Application
.run(() => ({
id: 'docker.app',
name: 'Docker',
components: {
docker: {class: Docker}
}
}))
.onLaunched(async (app: Application): Promise<void> => {
const docker: Docker = await app.getObject<Docker>('docker')
//拉取后按 alpine:3.20 查找
const image: DockerImage = await docker.pullImage({repo: 'alpine', tag: '3.20'})
//创建并启动
const container: DockerContainer = await image.run({
name: 'hello',
cmd: ['sh', '-c', 'echo started; sleep 3600'],
env: {MODE: 'production'}
})
console.log(container.id, container.state.status)
//退出码、stdout 和 stderr
const {exitCode, stdout} = await container.exec({cmd: ['cat', '/etc/alpine-release']})
console.log(exitCode, stdout)
await container.stop({timeout: 10})
await container.remove()
})3. 镜像
import {createReadStream} from 'node:fs'
import {type Docker, type DockerImage} from 'lakutata/com/docker'
async function images(docker: Docker): Promise<void> {
//拉取,附带镜像仓库的凭据和进度消息
await docker.pullImage({
repo: 'registry.example.com/base/node',
tag: '22',
platform: 'linux/amd64',
auth: {serverAddress: 'registry.example.com', username: 'ci', password: 'secret'},
outputCallback: (output: Record<string, any>): void => console.log(output.status, output.progress ?? '')
})
//使用 BuildKit 构建:构建上下文是列出的文件(目录连同其内容),排除 .dockerignore 中的文件
const app: DockerImage = await docker.buildImage({
workdir: '/srv/app',
files: ['Dockerfile', 'package.json', 'src'],
repoTag: 'app:1.0.0',
buildargs: {NODE_ENV: 'production'},
outputCallback: (output: Record<string, any>): void => console.log(output.stream ?? '')
})
//打标签、推送、保存为归档文件、再加载回来
await app.tag({repo: 'registry.example.com/team/app', tag: '1.0.0'})
await app.push({repo: 'registry.example.com/team/app', tag: '1.0.0', auth: {serverAddress: 'registry.example.com', username: 'ci', password: 'secret'}})
await app.export({destination: '/tmp/app.tar', repoTag: 'app:1.0.0'})
const loaded: DockerImage = await docker.importImage({source: createReadStream('/tmp/app.tar')})
console.log(loaded.repoTags)
//按 ID、短 ID 或名称查找,列出,删除(默认强制),清理
console.log((await docker.getImage('app:1.0.0')).id)
console.log((await docker.listImages()).map((image: DockerImage): string[] => image.repoTags))
await app.remove()
await docker.prune({containers: false, images: true, networks: false, volumes: false})
}getImage()按守护进程的规则解析名称:完整或短 ID、alpine(即alpine:latest)、摘要。- 不带选项的
prune()清理已停止的容器和悬空镜像;未使用的网络和匿名卷只在指定时清理(networks: true、volumes: true)。
4. 容器
import {type Docker, type DockerContainer, type DockerContainerTTY, type DockerImage} from 'lakutata/com/docker'
async function containers(docker: Docker): Promise<void> {
const image: DockerImage = await docker.getImage('nginx:1.27')
//创建,但不启动
const web: DockerContainer = await docker.createContainer(image.id, {
name: 'web',
ports: [{port: 80, type: 'tcp', hostPorts: [8080]}],
binds: [{hostPath: '/srv/www', containerPath: '/usr/share/nginx/html', rw: false}],
networks: [{networkName: 'backend', ip: '172.30.0.10'}],
memoryLimit: 256 * 1024 * 1024,
restartPolicy: 'unless-stopped'
})
await web.start()
//目前为止的日志(follow: true 时在容器运行期间保持流打开)
const logs: NodeJS.ReadableStream = await web.logs({follow: false, tail: 100})
logs.pipe(process.stdout)
//一次 CPU、内存、网络和块 I/O 的采样
console.log(await web.stats())
//命令行(由 /bin/sh -c 执行):无论退出码是多少,都返回退出码、stdout 和 stderr
const {exitCode, stdout, stderr} = await web.exec({cmd: 'nginx -t && ls /etc/nginx | wc -l'})
console.log(exitCode, stdout, stderr)
//带 TTY 的交互式 shell(默认 /bin/sh)
const tty: DockerContainerTTY = await web.createTTY({consoleSize: {cols: 120, rows: 40}})
tty.onData((chunk: Buffer): void => {
process.stdout.write(chunk)
})
tty.write('nginx -v\n')
await tty.resize({cols: 160, rows: 50})
tty.write('exit\n')
//内存限制和重启策略在线修改;其他设置通过重建容器修改(其 ID 会变化)
await web.update({memoryLimit: 512 * 1024 * 1024, restartPolicy: 'on-failure:3'})
await web.restart({timeout: 5})
await web.stop()
await web.remove({force: true})
//所有容器,包括已停止的
console.log((await docker.listContainers()).map((container: DockerContainer): string => `${container.name}: ${container.state.status}`))
}DockerContainer是一个快照:它自己的操作会刷新它;其他地方做的修改需要重新获取才能看到(docker.getContainer(id),也接受名称或短 ID)。- 启动正在运行的容器,或者停止已停止的容器,都不会做任何事,与 Docker CLI 相同。
exec()运行时没有 TTY,也没有 stdin。数组形式的命令原样执行;字符串是命令行,由/bin/sh -c执行。kill()默认发送SIGKILL,也可以指定信号。commit()用容器生成镜像,exportDirectory()把容器中的目录写成 tar 归档。- 不提供的功能:附加到主进程、向容器中复制文件、管理卷(
prune()的清理除外),以及守护进程的事件。
5. 网络
import {type Docker, type NetworkInfo} from 'lakutata/com/docker'
async function networks(docker: Docker): Promise<void> {
const network: NetworkInfo = await docker.createNetwork({
name: 'backend',
driver: 'bridge',
NetworkIPAMConfigs: [{subnet: '172.30.0.0/16', gateway: '172.30.0.1'}],
internal: false
})
//由守护进程选择子网
await docker.createNetwork({name: 'frontend'})
console.log(network.id, (await docker.listNetworks()).map((item: NetworkInfo): string => item.name))
await docker.removeNetwork('frontend')
}6. 错误
组件的异常由 lakutata/com/docker 导出:DockerConnectionException(组件创建时连不上守护进程)、DockerContainerNotFoundException、DockerImageNotFoundException、DockerNetworkNotFoundException、DockerImagePullException、DockerImageBuildException、DockerImageImportException、DockerImagePushException、DockerImageRepoTagNotFoundException,以及 DockerRequestException(守护进程拒绝请求;其 statusCode 为 HTTP 状态码,名称已被占用时为 409)。选项不合法时抛出 InvalidMethodAcceptException。组件被销毁时,正在进行的拉取和构建会被中止。