Podman和Docker使用备忘

Docker

debian12 安装 docker

# Add Docker's official GPG key:
apt-get update
apt-get install ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc

# Add the repository to Apt sources:
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get update
apt-get install -y  docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

debian13 安装 docker

相比 Debian12 的区别仅是使用 DEB822 格式的 sources.list 文件

# Add Docker's official GPG key:
apt-get update
apt-get install ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc

# Add the repository to Apt sources:
tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF

apt-get update
apt-get install -y  docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

centos 9 安装 docker

dnf -y install dnf-plugins-core
dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker

普通用户不需要sudo即可使用docker cli

sudo usermod -aG docker $USER
newgrp docker
docker ps

设置 docker daemon 代理

这个是用于 pull 镜像时的代理设置。

mkdir -p /etc/systemd/system/docker.service.d
touch /etc/systemd/system/docker.service.d/http-proxy.conf

if ! grep HTTP_PROXY /etc/systemd/system/docker.service.d/http-proxy.conf;
then
cat >> /etc/systemd/system/docker.service.d/http-proxy.conf <<EOF
[Service]
Environment="HTTP_PROXY=http://127.0.0.1:3128/" "HTTPS_PROXY=http://127.0.0.1:3128/" "NO_PROXY=arloor.com,localhost,127.0.0.1,docker-registry.somecorporation.com"
EOF
fi

# Flush changes:
systemctl daemon-reload
#Restart Docker:
systemctl restart docker
#Verify that the configuration has been loaded:
systemctl show --property=Environment docker --no-pager
# 像这样:Environment=HTTP_PROXY=http://127.0.0.1:8081/ NO_PROXY=localhost,127.0.0.1,docker-registry.so

或者:

mkdir -p /etc/docker
cat > /etc/docker/daemon.json  <<EOF
{
    "proxies": {
        "http-proxy": "http://127.0.0.1:3128",
        "https-proxy": "http://127.0.0.1:3128",
        "no-proxy": "*.arloor.com,.example.org,127.0.0.0/8,localhost,127.0.0.1,docker-registry.somecorporation.com"
    }
}
EOF
systemctl restart docker

设置 docker CLI 的 HTTP 代理

这个是用于 build 和 run 时的代理设置。详见:Use a proxy server with the Docker CLI

全局设置

注意,这个配置文件中还有其他的配置,例如 docker registry 的账户密码,不建议直接覆盖。

mkdir -p ~/.docker
cat > ~/.docker/config.json <<EOF
{
 "proxies": {
   "default": {
     "httpProxy": "http://127.0.0.1:3128",
     "httpsProxy": "http://127.0.0.1:3128",
     "noProxy": "*.arloor.com,.example.org,127.0.0.0/8,localhost,127.0.0.1,docker-registry.somecorporation.com"
   }
 }
}
EOF

这个配置会在 docker run 时自动给新容器注入 HTTP_PROXYHTTPS_PROXYNO_PROXY 及对应小写环境变量;docker build 时也会自动注入对应的 build args。

注意:如果代理服务监听在宿主机的 127.0.0.1:3128,容器默认 bridge 网络下访问 127.0.0.1 会指向容器自身,不是宿主机。此时需要配合 --network=host(Linux),或把代理地址改成容器能访问到的宿主机地址,例如 host.docker.internal / bridge 网关 IP。

单次设置

# build
docker build --build-arg HTTP_PROXY="${HTTP_PROXY:-}" --build-arg HTTPS_PROXY="${HTTPS_PROXY:-}" --network=host
# run
docker run --env HTTP_PROXY="http://proxy.example.com:3128" redis --network=host

Linux 上如果不用 host network,也可以显式增加宿主机名映射:

docker run \
  --add-host=host.docker.internal:host-gateway \
  --env HTTP_PROXY="http://host.docker.internal:3128" \
  --env HTTPS_PROXY="http://host.docker.internal:3128" \
  redis

也可以直接把代理地址写成默认 bridge 网关 IP(常见是 172.17.0.1):

{
  "proxies": {
    "default": {
      "httpProxy": "http://172.17.0.1:3128",
      "httpsProxy": "http://172.17.0.1:3128"
    }
  }
}

查看实际网关 IP:

docker network inspect bridge | grep Gateway

注意:代理服务本身也必须监听这个地址。如果代理只监听 127.0.0.1:3128,容器访问 172.17.0.1:3128host.docker.internal:3128 仍然不通;代理需要监听 0.0.0.0:3128,或至少监听 bridge 网关 IP,并用防火墙限制来源只允许 Docker 网段。

清理 Docker 构建缓存和无用镜像

查看 Docker 占用空间:

docker system df

清理悬空镜像:

docker image prune

清理构建缓存:

docker builder prune

如果使用 buildx

docker buildx prune

清理所有未被容器使用的镜像和构建缓存:

docker system prune -a

注意:docker system prune -a 会删除所有未被容器使用的镜像,不只是中间镜像;如果加 --volumes 还会删除未使用的数据卷,需谨慎。

Docker Compose 使用

Compose V2 使用 docker compose 子命令;老版本的 docker-compose 是独立二进制。

compose.yml 示例

services:
  app:
    image: nginx:1.27-alpine
    container_name: demo-nginx
    depends_on:
      redis:
        condition: service_healthy
    ports:
      - "8080:80"
    volumes:
      - ./html:/usr/share/nginx/html:ro
    environment:
      TZ: Asia/Shanghai
    restart: unless-stopped

  redis:
    image: redis:7.4-alpine
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    restart: unless-stopped

volumes:
  redis-data:

新版 Compose 规范不再强制写顶层 version,旧示例里的 version: "3" 可以省略。

常用命令

# 启动
docker compose up -d

# 查看服务状态
docker compose ps

# 查看日志
docker compose logs -f
docker compose logs -f app

# 进入容器
docker compose exec app sh

# 重启服务
docker compose restart app

# 拉取镜像
docker compose pull

# 重新构建并启动
docker compose up -d --build

# 强制重新创建容器,即使配置和镜像没有变化
docker compose up -d --force-recreate

# 重新创建指定服务,并同时重新创建它依赖的服务
docker compose up -d --force-recreate --always-recreate-deps app

# 只启动指定服务,不自动启动 depends_on 依赖
docker compose up -d --no-deps app

# 停止并删除容器、网络
docker compose down

# 同时删除匿名卷和 compose 文件中声明的 named volume
docker compose down -v

Compose 常用参数是 --force-recreate,不是 --force-restartdocker compose restart 只是重启已有容器,不会按新配置重新创建容器。

常用参数

# 指定项目名,影响容器、网络、卷名前缀
docker compose -p myapp up -d

# 指定 compose 文件
docker compose -f compose.yml -f compose.prod.yml up -d

# 展开并检查最终配置
docker compose config

环境变量

同目录下的 .env 默认用于 Compose 文件变量替换:

NGINX_PORT=8080
services:
  app:
    image: nginx:1.27-alpine
    ports:
      - "${NGINX_PORT}:80"

如果要把环境变量传进容器,用 environmentenv_file

services:
  app:
    image: nginx:1.27-alpine
    env_file:
      - app.env

.env 主要给 Compose 自己做变量替换;env_file 才是批量传给容器的环境变量文件。

depends_on

短语法只保证依赖服务先启动,不保证依赖服务已经可用:

services:
  app:
    depends_on:
      - redis

如果需要等待依赖服务健康后再启动,用长语法加 healthcheck

services:
  app:
    depends_on:
      redis:
        condition: service_healthy

  redis:
    image: redis:7.4-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3

常见 condition

condition 含义
service_started 依赖服务已启动,短语法等价于这个
service_healthy 依赖服务的 healthcheck 通过
service_completed_successfully 依赖服务成功退出,常用于一次性初始化任务

Podman

删除 podman build 中途退出的中间镜像

https://github.com/containers/podman/issues/7889

podman ps --all --storage
buildah rm --all
podman rmi -a

设置 podman CLI 的 HTTP 代理设置

podman 的 build 和 run 都会默认使用 podman 进程的 http 代理环境变量,如果容器不需要的话,可以使用 --http-proxy=false 来关闭。

podman 保存密码

podman login quay.io -u arloor -p ${token}
cat /run/user/0/containers/auth.json #密码在此
cp /run/user/0/containers/auth.json ~/.config/podman_auth.json
if ! grep REGISTRY_AUTH_FILE ~/.zshrc;then
 export REGISTRY_AUTH_FILE=~/.config/podman_auth.json
 echo "export REGISTRY_AUTH_FILE=~/.config/podman_auth.json" >> ~/.zshrc
 echo " add REGISTRY_AUTH_FILE to ~/.zshrc"
else
 echo "REGISTRY_AUTH_FILE already exists in ~/.zshrc"
fi

why login registry auth.json always been cleared?:默认保存在 /run/user/0 下,在用户 log out 的时候会被清空。如果需要持久化保存,可以将其复制到其他位置,并设置环境变量 REGISTRY_AUTH_FILE 指向该文件或使用 --authfile 命令行参数。

Podman 使用 systemd 管理容器

podman generate systemd

参考 man podman-generate-systemdpodman-generate-systemd(1)

podman run -d \
--pull=newer \
--name redis \
--network host \
--replace \
--rm \
redis

podman generate systemd --new --name redis --env=HTTP_PROXY --env=HTTPS_PROXY| tee /lib/systemd/system/redis.service
systemctl daemon-reload
# systemctl enable redis --now

其中 podman run 的 --rm 和 podman generate systemd 的--new 含义都是停止容器时删除容器,下次全新创建。所以 –rm 和 –new 要么同时出现,要么同时缺失。

其中 --pull=newer 表示每次都 pull 镜像,这在使用 latest 镜像时很有用

qualet 运行

参考podman-systemd.unit.5或者 man podman-systemd.unit

cat > /tmp/proxy.container <<'EOF'
[Unit]
Wants=network-online.target
After=network-online.target
[Container]
Image=docker.io/arloor/rust_http_proxy:bpf
ContainerName=proxy
Pull=newer
PodmanArgs=--privileged
Volume=/tmp:/tmp
Volume=/root/.acme.sh:/root/.acme.sh
Volume=/usr/share/nginx/html:/usr/share/nginx/html
Network=host
Exec=-p 444 -p 443 -p 9443 \
-w /usr/share/nginx/html/blog \
-r arloor \
--never-ask-for-auth
[Service]
Environment=HTTPS_PROXY=http://127.0.0.1:3128
Environment=HTTP_PROXY=http://127.0.0.1:3128
Restart=on-failure
TimeoutStopSec=70
[Install]
WantedBy=default.target
EOF

QUADLET_UNIT_DIRS=/tmp /usr/libexec/podman/quadlet -dryrun #测试
mkdir -p /etc/containers/systemd;mv /tmp/proxy.container /etc/containers/systemd/proxy.container

rm -f /lib/systemd/system/proxy.service
systemctl daemon-reload
systemctl restart proxy

生成在 /run/systemd/generator/proxy.service®

Docker Hub pull-through cache

https://distribution.github.io/distribution/about/configuration/ 注意 Registry:2 已经不维护的,并且有明显 bug,推荐使用 Registry:3

  1. 启动 Registry:3
mkdir -p /etc/distribution
cat > /etc/distribution/config.yml <<EOF
version: 0.1
log:
  level: info
http:
  addr: :6666
  debug:
    addr: :3001
    prometheus:
      enabled: true
proxy:
  remoteurl: https://registry-1.docker.io
  username: ${your_docker_hub_username}
  password: ${your_docker_hub_token}
  ttl: 1h
storage:
  cache:
    blobdescriptor: inmemory
  filesystem:
    rootdirectory: /var/lib/registry
EOF
docker stop registry&&docker rm registry
docker run -d --restart=always --network host --name registry \
            -e http_proxy="http://localhost:3128" -e https_proxy="http://localhost:3128" \
            -e HTTP_PROXY="http://localhost:3128" -e HTTPS_PROXY="http://localhost:3128" \
            -v /etc/distribution/config.yml:/etc/distribution/config.yml \
            registry:3 /etc/distribution/config.yml
docker logs -f registry
  1. 配置 docker daemon
mkdir -p /etc/docker
cat > /etc/docker/daemon.json  <<EOF
{
    "registry-mirrors": ["http://ttl.arloor.com:6666"]
}
EOF
systemctl restart docker
  1. 配置 podman
mkdir -p $HOME/.config/containers
cat > $HOME/.config/containers/registries.conf<<EOF
unqualified-search-registries = ["docker.io"]

[[registry]]
location = "docker.io"

[[registry.mirror]]
location = "ttl.arloor.com:6666"  # 你的镜像加速地址
insecure = true  # 如果镜像站使用HTTP而非HTTPS,设为true
EOF
  1. 配置 K3s 的 containerd

K3s 会自己生成 containerd 配置,不要直接修改生成的 config.tomlhosts.toml。在每个可能运行 Pod 的 Server/Agent 节点配置 /etc/rancher/k3s/registries.yaml

mkdir -p /etc/rancher/k3s
cat > /etc/rancher/k3s/registries.yaml <<'EOF'
mirrors:
  docker.io:
    endpoint:
      - "http://ttl.arloor.com:6666"
EOF

# Server 节点
systemctl restart k3s
# Agent 节点执行 systemctl restart k3s-agent

之后 Pod 仍然使用原始镜像名,例如 docker.io/library/nginx:latest,containerd 会优先请求 pull-through cache。默认情况下,cache 不可用时会回退到 Docker Hub;如果必须强制经过 cache,可为 K3s 增加 --disable-default-registry-endpoint。详见 K3s Private Registry Configuration

  1. 配置普通 K8s 的 containerd

containerd 1.5 之后推荐使用 certs.d/hosts.toml,旧的 registry.mirrors 配置已废弃。先在每个 K8s 节点创建 Docker Hub 的 host namespace:

mkdir -p /etc/containerd/certs.d/docker.io
cat > /etc/containerd/certs.d/docker.io/hosts.toml <<'EOF'
server = "https://registry-1.docker.io"

[host."http://ttl.arloor.com:6666"]
  capabilities = ["pull", "resolve"]
EOF

并确认 /etc/containerd/config.toml 已指向该目录。containerd 1.x 使用:

[plugins."io.containerd.grpc.v1.cri".registry]
  config_path = "/etc/containerd/certs.d"

containerd 2.x 使用:

[plugins."io.containerd.cri.v1.images".registry]
  config_path = "/etc/containerd/certs.d"

如果修改了 config_path,需要重启 containerd:

systemctl restart containerd
systemctl restart kubelet

可通过 CRI 验证拉取,并同时观察 Registry:3 日志:

crictl pull docker.io/library/alpine:latest
docker logs -f registry

ctr images pull 不一定读取 CRI 的 registry 配置,手工测试时需要指定 ctr images pull --hosts-dir /etc/containerd/certs.d ...。详见 containerd Registry Configuration

本例只代理 docker.io,不会加速 registry.k8s.ioghcr.io 等其他 registry。另外,cache 使用了 Docker Hub 账号凭据,不要将未鉴权的 HTTP 端口直接暴露到公网,至少应使用防火墙限制到集群节点网段。

Docker 构建参考

Dockerfile 规范和常见范式

通用规范

  1. 固定 Dockerfile 语法版本,启用 BuildKit 新能力。
  2. 基础镜像尽量固定到小版本,生产环境建议再加 digest(防止上游 tag 漂移)。
  3. 优先使用多阶段构建(builder/runtime 分离),减小最终镜像体积和攻击面。
  4. 优化缓存命中:先 COPY 依赖描述文件,再安装依赖,最后 COPY 业务代码。
  5. 包管理器操作要“同层完成并清理”:
    • Debian/Ubuntu:apt-get update && apt-get install ... && rm -rf /var/lib/apt/lists/*
  6. 默认使用非 root 用户运行应用。
  7. ENTRYPOINT/CMD 使用 JSON 格式(exec form),避免 shell 包裹导致信号处理异常。
  8. 区分 ARGENVARG 只在构建期有效,ENV 会进入运行时环境。
  9. 敏感信息不要写进镜像层,使用 BuildKit secret mount。
  10. 配置 .dockerignore,避免把 .git、日志、构建产物打进 build context。

指令详解(ARG/ENV/COPY/ADD/VOLUME/WORKDIR)

指令 作用 生效范围 / 时机 常见写法 注意事项(常见坑)
ARG 声明构建参数,用于镜像构建时动态传参 仅构建期有效;不会自动进入容器运行时环境 ARG APP_VER=1.2.3FROM node:${NODE_VER}docker build --build-arg APP_VER=2.0 . 1) FROM 前定义的 ARG 仅能用于 FROM 行;若后续指令还要用,需要在阶段内再声明一次。2) 不要传密钥,ARG 可能出现在构建历史/元数据中。
ENV 设置环境变量,供后续构建步骤和容器运行时使用 当前阶段后续指令可见;并写入镜像配置,容器启动后默认存在 ENV APP_ENV=prodENV PATH=/app/bin:$PATH 1) 会进入最终镜像,避免放敏感信息。2) ENV 可覆盖同名 ARG 在后续步骤中的效果。
COPY 将本地构建上下文文件(或其他阶段产物)复制进镜像 构建期执行,产生新层 COPY . /appCOPY --from=build /out/app /usr/local/bin/appCOPY --chown=10001:10001 . /app 1) 只复制 build context 内文件,不能 ../ 越界。2) 优先于 ADD 使用,行为更可预测。3) 先复制依赖文件再安装依赖,可提升缓存命中。
ADD 类似 COPY,但支持额外能力(本地 tar 自动解包、URL 源) 构建期执行,产生新层 ADD app.tar.gz /opt/app/ADD https://example.com/a.tgz /tmp/ 1) 除非你明确需要“自动解包/远程 URL”,否则用 COPY2) URL 下载会引入额外不确定性,通常建议改为 RUN curl/wget 并做校验。
VOLUME 声明容器运行时挂载点(数据卷) 主要影响运行时;镜像中记录为元数据 VOLUME ["/var/lib/mysql"]VOLUME /data 1) 更推荐在编排层(Compose/K8s)声明挂载,Dockerfile 里慎用。2) 该路径在运行时会被卷接管,若依赖镜像内后续写入该目录,行为易混淆。
WORKDIR 设置后续指令默认工作目录 对后续 RUN/CMD/ENTRYPOINT/COPY/ADD 生效 WORKDIR /appWORKDIR /app/bin 1) 目录不存在会自动创建。2) 相对路径会基于上一个 WORKDIR 叠加,建议尽量用绝对路径降低歧义。

速记:可预测复制用 COPY,特殊场景(解包/URL)才用 ADD;构建参数用 ARG,运行时变量用 ENV

常用骨架(Debian 系)

# syntax=docker/dockerfile:1.7
FROM debian:12.9-slim

SHELL ["/bin/bash", "-euxo", "pipefail", "-c"]
WORKDIR /app

RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates curl tzdata \
 && rm -rf /var/lib/apt/lists/*

COPY . /app

范式 1:Go 多阶段构建(推荐)

# syntax=docker/dockerfile:1.7
FROM golang:1.24-bookworm AS build
WORKDIR /src

COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download

COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app"]

范式 2:Node.js 生产镜像(依赖和运行时分层)

# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev

FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production

COPY --from=deps /app/node_modules /app/node_modules
COPY . .

RUN useradd -r -u 10001 nodeapp && chown -R nodeapp:nodeapp /app
USER nodeapp

EXPOSE 3000
CMD ["node", "server.js"]

常用检查命令

# lint Dockerfile
docker run --rm -i hadolint/hadolint < Dockerfile

# 使用 BuildKit 构建
DOCKER_BUILDKIT=1 docker build -t myapp:dev .

# 构建时挂载 secret(示例:npm 私有源)
DOCKER_BUILDKIT=1 docker build \
  --secret id=npmrc,src=$HOME/.npmrc \
  -t myapp:dev .

BuildKit:原理、作用和使用

BuildKit 是 Docker 的镜像构建后端,docker buildx 是常用的客户端入口。它会把 Dockerfile 转成依赖图,按内容计算缓存并执行各阶段,最后把结果输出为本地镜像、文件或 registry 镜像。现在使用 docker buildx build 就是在使用 BuildKit,不需要再单独设置 DOCKER_BUILDKIT=1

下面不展开内部的 LLB、Solver 和 Worker,直接以 tuke 为例,看 BuildKit 如何构建并发布一个同时包含 Go 服务、Node.js、Chromium 和 agent-browser 的生产镜像。

tuke 的完整 Dockerfile

# syntax=docker/dockerfile:1.7

FROM golang:1.26.5-alpine AS build

WORKDIR /src

COPY go.mod go.sum ./
RUN --mount=type=cache,id=tuke-go-mod,target=/go/pkg/mod,sharing=locked \
    go mod download

COPY cmd ./cmd
COPY internal ./internal
RUN --mount=type=cache,id=tuke-go-mod,target=/go/pkg/mod,sharing=locked \
    --mount=type=cache,id=tuke-go-build,target=/root/.cache/go-build,sharing=locked \
    CGO_ENABLED=0 GOOS=linux go build \
    -trimpath \
    -ldflags="-s -w" \
    -o /out/tuke-agent \
    ./cmd/server

FROM node:24-trixie-slim AS runtime

ARG AGENT_BROWSER_VERSION=0.33.2

RUN --mount=type=cache,id=tuke-apt-lists,target=/var/lib/apt/lists,sharing=locked \
    --mount=type=cache,id=tuke-apt-cache,target=/var/cache/apt,sharing=locked \
    rm -f /etc/apt/apt.conf.d/docker-clean \
    && apt-get update \
    && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
        bash \
        bc \
        ca-certificates \
        chromium \
        curl \
        dnsutils \
        file \
        fonts-liberation \
        fonts-noto-cjk \
        fonts-noto-color-emoji \
        git \
        iproute2 \
        iputils-ping \
        jq \
        less \
        lsof \
        nano \
        netcat-openbsd \
        openssh-client \
        procps \
        psmisc \
        python-is-python3 \
        python3 \
        ripgrep \
        rsync \
        tree \
        tzdata \
        unzip \
        vim-tiny \
        wget \
        zip \
    && install -d -o root -g root -m 0700 /data /data/sessions /workspace

RUN npm install --global --omit=dev --allow-scripts=agent-browser \
        "agent-browser@${AGENT_BROWSER_VERSION}" \
    && npm cache clean --force

COPY --from=build /out/tuke-agent /usr/local/bin/tuke-agent
COPY .agents/skills /opt/tuke/skills

ENV TZ=Asia/Shanghai \
    AGENT_BROWSER_CONTENT_BOUNDARIES=1 \
    AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium \
    AGENT_BROWSER_MAX_OUTPUT=50000 \
    TUKE_ADDR=0.0.0.0:8080 \
    TUKE_DATABASE_PATH=/data/sessions/tuke.db \
    TUKE_BASH_WORKDIR=/workspace \
    TUKE_SKILLS_DIR=/opt/tuke/skills

WORKDIR /workspace
EXPOSE 8080

ENTRYPOINT ["/usr/local/bin/tuke-agent"]

这个 Dockerfile 把编译与运行环境分成了 buildruntime 两个 stage,并同时使用普通层缓存、cache mount 和后面 deploy.sh 配置的 registry cache。

Dockerfile frontend

第一行固定 Dockerfile frontend 版本:

# syntax=docker/dockerfile:1.7

# syntax=docker/dockerfile:1.7 让 Dockerfile 可以使用 RUN --mount=type=cache 等 BuildKit 语法。BuildKit 构建开始时会先解析并拉取这个 frontend,所以网络异常时,甚至可能在拉取第一个 FROM 之前就看到 docker/dockerfile:1.7 的报错。

Go 依赖与编译缓存

Go stage 没有一开始就 COPY . .,而是先复制 go.modgo.sum,执行依赖下载后才复制业务源码。这里叠加使用了两种缓存:

  1. COPY go.mod go.sum 产生的是普通构建层缓存。业务源码变化不会让 go mod download 失效;只有依赖文件变化才需要重新执行。
  2. type=cache 给命令挂载可复用目录。tuke-go-mod 保存下载的模块,tuke-go-build 保存 Go 编译缓存;即使对应的 RUN 必须重新执行,也不必从零下载和编译。

两个 RUN 使用相同的 id=tuke-go-mod,因此看到的是同一份模块缓存。sharing=locked 会在并发构建写入同一缓存时加锁,避免多个 Go 进程同时修改目录。cache mount 属于 builder 的构建状态,不会因为挂载到了 RUN 中就被写进最终镜像。

apt 缓存与运行时镜像

runtime stage 基于 Node.js 镜像,需要安装 Chromium、字体和常用命令行工具,下载量远大于最终的 Go 二进制。Debian 基础镜像默认会清理 apt 缓存,所以先删除 docker-clean 配置,再把 package lists 和 deb 缓存分别挂载到命名 cache mount。它们主要用于加速同一个 builder 后续需要重新执行的 apt-get,不用于填充最终镜像层。

AGENT_BROWSER_VERSION 使用 ARG,只参与构建。需要升级时修改默认值,或在手工构建时传入:

--build-arg AGENT_BROWSER_VERSION=0.34.0

最终仅通过 COPY --from=build 取出 /out/tuke-agent,Go 编译器、源码和中间文件都留在 build stage,不会进入发布镜像。runtime stage 还复制内置 skills,设置服务、数据库、工作目录和 Chromium 环境变量,最后以 tuke-agent 作为入口。.dockerignore 排除了 .git.env、数据库和本地 data,既缩小 build context,也避免把本地数据或密钥误送给 builder。

缓存失效范围大致如下:

发生变化的内容 需要重新执行的主要步骤
go.mod / go.sum 下载 Go 依赖、编译服务及其后续层
cmd/ / internal/ 编译服务及其后续层,依赖下载层仍可复用
AGENT_BROWSER_VERSION 安装 agent-browser 及其后续层
apt 软件包列表或对应 RUN 安装系统软件及其后续层
.agents/skills 复制 skills;此前的大部分层仍可复用

这就是安排 Dockerfile 指令顺序的意义:越稳定、越昂贵的步骤尽量靠前,频繁变化的源码和 skills 靠后。

tuke 的完整 deploy.sh

#!/usr/bin/env bash
# 构建并推送 docker.io/arloor/app:tuke,然后滚动重启 k3s Deployment。
# 在 WSL debian 中执行;仓库需位于 WSL 原生文件系统(不要用 /mnt 路径)。
set -euo pipefail
cd "$(dirname "$0")"

IMAGE=docker.io/arloor/app:tuke
BUILDER=cache-builder
DEPLOYMENT=tuke-agent

# 必须用 docker-container driver 的 builder(默认 docker driver 不支持 registry 缓存导出);
# host 网络 + 继承系统 HTTP 代理环境变量。不存在则创建。
if ! docker buildx inspect "$BUILDER" >/dev/null 2>&1; then
  docker buildx create \
    --name "$BUILDER" \
    --driver docker-container \
    --driver-opt network=host \
    --driver-opt "env.HTTP_PROXY=${HTTP_PROXY:-}" \
    --driver-opt "env.HTTPS_PROXY=${HTTPS_PROXY:-${HTTP_PROXY:-}}" \
    --driver-opt "\"env.NO_PROXY=${NO_PROXY:-localhost}\"" \
    --buildkitd-flags '--allow-insecure-entitlement=network.host'
fi

docker buildx build . \
  --builder "$BUILDER" \
  --file Dockerfile \
  --tag "$IMAGE" \
  --network host \
  --cache-from "type=registry,ref=${IMAGE}-buildcache" \
  --cache-to "type=registry,ref=${IMAGE}-buildcache,mode=max" \
  --push

kubectl rollout restart "deployment/$DEPLOYMENT"
kubectl rollout status "deployment/$DEPLOYMENT"
创建独立 builder

tuke 不直接使用 Docker 自带的默认 builder,而是创建名为 cache-builder 的独立 docker-container builder。这样可以使用独立的 BuildKit daemon,并稳定支持 registry cache 导入和导出。builder 的数据保存在 Docker volume 中,因此脚本运行结束后,本地构建状态仍然存在。

这里有两个容易混淆的网络设置:

  • --driver-opt network=host 设置 BuildKit daemon 容器的网络。它拉取 Dockerfile frontend、基础镜像以及 registry cache 时使用这个网络。
  • 后面 docker buildx build --network host 设置 Dockerfile 中 RUN 步骤的网络,例如 go mod downloadapt-getnpm install

--allow-insecure-entitlement=network.host 只是允许构建请求使用 host network,并不代表关闭 HTTPS 或证书校验。

NO_PROXY 那一行的两层引号是有意为之。Bash 会去掉最外层引号,把里面的 " 作为字面量双引号传给 buildx;buildx 又会把每个 --driver-opt 按 CSV 解析,因此这对字面量引号可以保护 NO_PROXY=localhost,127.0.0.1,.example.com 中的逗号,使整串内容保持为一个 option。CSV 解析结束后,引号本身不会进入 BuildKit 容器。若只写成 --driver-opt "env.NO_PROXY=${NO_PROXY:-localhost}",默认值 localhost 没问题,但带逗号的常见 NO_PROXY 会被 buildx 拆成多个字段并报 expecting k=v

代理变量在执行 docker buildx create 时展开并写入 builder 配置,并不是每次运行 deploy.sh 都动态刷新。脚本发现同名 builder 已存在就不会重新创建;如果宿主机代理地址变了,应先用 docker buildx inspect cache-builder 检查现有配置。

registry cache 与镜像推送

构建命令会在 Docker Hub 使用两个 tag:

地址 用途
docker.io/arloor/app:tuke 生产镜像
docker.io/arloor/app:tuke-buildcache BuildKit registry cache

--cache-from 在构建前拉取远程缓存,换机器或重建 builder 后仍有机会命中;--cache-to ...,mode=max 会把中间 stage 在内的构建缓存导出到 registry,而不只保存最终镜像相关缓存。第一次执行时 tuke-buildcache 还不存在,出现 not found 警告是正常的,成功推送一次后就有了。

registry cache 与 RUN --mount=type=cache 不应视为同一件事:前者让不同机器共享可复用的构建记录和层,后者给正在执行的包管理器提供持久目录。本机 builder 上的 cache mount 在某个步骤必须重跑时尤其有用;远程层缓存命中时则可以直接跳过整个步骤。

--push 会直接把结果推到 Docker Hub,而不是自动加载到本机 Docker image store。公开镜像拉取可以匿名获取短期 Bearer token;推送 arloor/app 前需执行一次:

docker login docker.io

Buildx 会使用 ~/.docker/config.json 或 credential store 中的登录凭据,自动向 auth.docker.io 换取 registry Bearer token,不需要在 deploy.sh 中保存或手工传 OAuth token。

镜像推送成功后,脚本才会用 kubectl rollout restart 滚动重启 tuke-agent Deployment,并用 kubectl rollout status 等待更新完成。脚本开头的 set -euo pipefail 保证构建、推送或滚动更新任一步失败都会立即退出。例如 Docker Hub 超时或推送失败时,不会继续重启仍引用旧镜像的 Deployment。

实际执行与排查

常用检查命令:

# builder 类型、网络、代理、BuildKit 版本和运行状态
docker buildx inspect cache-builder

# BuildKit 容器及其日志
docker ps --filter name=buildx_buildkit_cache-builder
docker logs buildx_buildkit_cache-builder0

# 本地 BuildKit 缓存占用
docker buildx du --builder cache-builder

# 展开构建日志,排查具体是哪一步没有命中缓存或网络失败
BUILDKIT_PROGRESS=plain ./deploy.sh

脚本只判断同名 builder 是否存在,不会校验它是不是 docker-container driver,也不会自动更新代理和网络配置。如果 builder 配置已经不符合预期,可以删除后让脚本重建:

docker buildx rm cache-builder
./deploy.sh

删除 builder 会同时丢失它的本地构建状态;已经推送到 docker.io/arloor/app:tuke-buildcache 的 registry cache 不受影响,下次构建仍可通过 --cache-from 拉取。

BuildKit 常见用法速查

上面的 tuke 已经实际使用了语法版本、cache mount 和 registry cache。下面把这些能力连同 secret mount、SSH mount 和多平台构建整理成可直接套用的最小示例。使用 docker buildx build 时天然启用 BuildKit;只有在仍使用旧式 docker build 且环境没有默认启用 BuildKit 时,才需要前缀 DOCKER_BUILDKIT=1

指定 Dockerfile 语法版本

建议在 Dockerfile 第一行固定 frontend 版本,以便稳定使用 cache、secret 和 SSH mount 等语法:

# syntax=docker/dockerfile:1.7

这个指令不是普通注释。BuildKit 会解析并拉取对应的 docker/dockerfile frontend;它必须位于所有 Dockerfile 指令之前。

cache mount:缓存包管理器下载
# syntax=docker/dockerfile:1.7
FROM python:3.13-slim

WORKDIR /app
COPY requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

cache mount 把指定目录保存在 builder 的缓存状态中。当这个 RUN 因依赖或源码变化而重新执行时,pip 可以复用已经下载的包。挂载目录中的内容不会因为这条指令自动写入镜像层,因此它适合 Maven、Gradle、npm、Go、apt、pip 等工具的下载缓存,不适合保存最终运行时必须存在的文件。

多个构建可能同时写同一缓存时,可以显式命名并加锁:

RUN --mount=type=cache,id=my-pip-cache,target=/root/.cache/pip,sharing=locked \
    pip install -r requirements.txt
secret mount:临时使用密钥

先从客户端把本地文件作为 secret 交给 BuildKit:

docker buildx build \
  --secret id=npmrc,src="$HOME/.npmrc" \
  -t myapp:dev \
  .

再在 Dockerfile 的单条 RUN 中挂载:

# syntax=docker/dockerfile:1.7
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

secret 只在这条 RUN 执行期间可见,不会被 COPY 进构建上下文,也不会作为 ARGENV 或普通文件留在镜像层。不要为了图省事改成 --build-arg TOKEN=...;构建参数和环境变量不适合承载密钥。

需要注意,secret 的内容变化不会自动使构建缓存失效。如果命令输出依赖 secret 的具体值,需要额外改变一个非敏感的 cache-bust ARG,或显式让对应步骤重新执行。

SSH mount:访问私有 Git 仓库

先确保宿主机的 SSH agent 已经加载所需私钥,然后把 agent socket 转发给构建:

eval "$(ssh-agent -s)"
ssh-add "$HOME/.ssh/id_ed25519"

docker buildx build \
  --ssh default="$SSH_AUTH_SOCK" \
  -t myapp:dev \
  .

Dockerfile 中只有声明了 SSH mount 的 RUN 能使用该 agent:

# syntax=docker/dockerfile:1.7
RUN --mount=type=ssh \
    git clone git@github.com:your/private-repo.git

SSH 私钥本身不会复制进镜像。实际使用时还应预先配置目标服务器的 known_hosts,不要用永久关闭 host key 校验来绕过验证。

导出和导入 registry cache

本地 builder 缓存只属于当前 BuildKit 实例。CI runner 经常是临时机器,此时可以把缓存作为独立对象推送到 OCI registry:

docker buildx build \
  --cache-from type=registry,ref=registry.example.com/ns/myapp:buildcache \
  --cache-to type=registry,ref=registry.example.com/ns/myapp:buildcache,mode=max \
  --tag registry.example.com/ns/myapp:latest \
  --push \
  .

--cache-from 导入已有缓存;--cache-to 导出本次的新缓存。mode=max 会连中间 stage 的缓存一起导出,适合多阶段构建,但缓存体积也比默认的 mode=min 更大。缓存应使用单独的 ref,不要让 buildcache 和生产镜像使用同一个 tag。

多平台构建

支持目标架构的 builder 可以在一次构建中生成多平台镜像索引:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag registry.example.com/ns/myapp:latest \
  --push \
  .

多平台构建需要 builder 具备对应的原生节点或 QEMU 模拟能力。--push 会把各平台镜像和镜像索引一起推到 registry,拉取时容器运行时会自动选择当前机器的架构。使用经典 Docker image store 时,不能把这种多平台结果整体 --load 到本机,因此发布场景通常直接使用 --push