Tag: docker

7 notes found.

三国里,托孤是刘备把阿斗交给诸葛亮;Linux 里,托孤是内核把孤儿进程交给 PID 1。 区别在于:诸葛亮会管,很多容器里的 PID 1 不会。

什么是「托孤」

Linux 里每个进程都有父进程。父进程先退出,子进程就成了孤儿进程;内核不会让孤儿无家可归,会把它过继给当前 PID namespace 里的 PID 1。

普通系统里 PID 1 是 init / systemd,它的职责之一就是不断调用 wait(),回收退出的子进程。这就是「托孤」:父进程走了,孩子交给 PID 1。

为什么容器里会出问题

容器里 PID 1 往往不是 init,而是应用本身(litestream、node、nginx、python…)。这些应用只 wait() 自己 fork 出来的子进程,托孤来的不认账,于是每个孤儿退出后都留在进程表里——ps 里显示为 <defunct>。

容器里这类孤儿的常见来源是 healthcheck:每 30s 一次,跑得越久堆得越多。docker exec(healthcheck 走同一条路)创建的进程,其父进程在容器 PID namespace 之外、对容器不可见,因此被托孤给 namespace 里的 PID 1。

僵尸不占 CPU、也不占内存(只剩进程表里一个槽位),但会一直占用 PID 表项;攒够了,容器连新进程都创建不了,排查别的问题时也是噪音。

解法:给容器一个真正的 PID 1

把 tini 放到 PID 1——它只做两件事:reap 托孤来的进程、把信号转发给真正的应用。

RUN apt-get update \
    && apt-get install -y --no-install-recommends tini \
    && rm -rf /var/lib/apt/lists/*

# 应用变成 tini 的子进程,托孤来的孤儿由 tini 回收
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]

Alpine 用 apk add --no-cache tini,路径是 /sbin/tini。

不改镜像时,docker run --init(或 compose init: true)等效,但镜像内建更省事——调用方不会忘。Kubernetes 没有等价的开关,做法同上:把 tini 作为镜像的 ENTRYPOINT,让容器里的 PID 1 真正承担「家长」的责任。应用若自己实现了 SIGCHLD 处理与 wait() 循环,也可以不用 tini——但大多数业务应用并不想管这些事。

数僵尸

容器里常常没装 ps/procps,直接读 /proc:

for p in /proc/[0-9]*; do
  [ "$(awk '{print $3}' "$p/stat" 2>/dev/null)" = Z ] && \
    awk '{print "pid="$1" ppid="$4" comm="$2}' "$p/stat"
done

ppid 就是那个没 reap 的 PID 1。

同类工具:tini 之外的选择

tini 只做「reap + 转发信号」,单进程容器够了。但容器里一旦要跑多个进程、要按顺序初始化、要进程挂了自动重启,就需要一套完整的 init 了。

  • tini / docker --init:只 reap + 转发信号,单进程容器(最常见)
  • dumb-init:同一类,C 写的更小,能当 entrypoint 处理 shell 形式的 CMD
  • catatonit:同一类,Podman 默认
  • s6-overlay:完整 init + 进程监督(oneshot 初始化、依赖顺序、自动重启、优雅停机)

以 s6-overlay v3 为例(ARG S6_OVERLAY_VERSION=3.2.3.2),装法就是解两个 tar 包,然后 ENTRYPOINT ["/init"]:

RUN apt-get update && apt-get install -y nginx xz-utils
ARG S6_OVERLAY_VERSION=3.2.3.2
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp
RUN tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-x86_64.tar.xz /tmp
RUN tar -C / -Jxpf /tmp/s6-overlay-x86_64.tar.xz
ENTRYPOINT ["/init"]

(x86_64 换成目标架构的包名。)启动后 s6-svscan 当 PID 1,分三阶段:阶段 1 跑 /etc/cont-init.d/*(一次性初始化)→ 阶段 2 起 s6-rc 服务 → 阶段 3 监督 /etc/services.d/*。所以老的 cont-init.d / services.d 写法在 v3 里照旧可用,老镜像迁移不用改。

v3 新写法是声明式服务:

/etc/s6-overlay/s6-rc.d/myapp/type        → longrun(常驻,受监督)或 oneshot(跑一次)
/etc/s6-overlay/s6-rc.d/myapp/run         → 启动脚本,结尾 exec 你的进程
/etc/s6-overlay/s6-rc.d/myapp/dependencies.d/base      → 空文件 = 声明依赖
/etc/s6-overlay/user-bundles.d/user/contents.d/myapp   → 空文件 = 加入默认启动集

它本来就是给容器当 PID 1 用的,所以「托孤」这件事它天然接着:孤儿由 s6 回收、信号由 s6 转发、服务挂了 s6-supervise 自动拉起。选型上:单进程用 tini(--init 一行搞定),多进程 / 要顺序初始化 / 要自动重启再上 s6-overlay。

总结

「托孤」听起来很人文,在 Linux 里却是一次很具体的责任转移:内核把孤儿进程交给 PID 1。

PID 1 不只是一个编号,它意味着收养孤儿、回收僵尸、转发信号。容器里让普通应用当 PID 1,却不给它配一个 tini 这样的「诸葛亮」,结果往往就是一群没人收尸的僵尸,慢慢把容器拖死。

所以,别让 PID 1 只挂名,不干活。

在 GHCR 里看到很多 untagged,不一定代表清理任务漏删了垃圾镜像。

如果一个 Docker 镜像是 multi-arch 构建,比如同时构建:

platforms: linux/amd64,linux/arm64

那么一个 tag 通常不是直接贴在某个具体平台镜像上,而是贴在一个“目录”上。这个目录的正式名字叫 OCI image index 或 manifest list。

可以把它理解成:

ghcr.io/org/app:sha-xxxxxxx
  -> 一个总入口 / 目录
      -> amd64 机器用的镜像
      -> arm64 机器用的镜像

用户拉镜像时还是用同一个 tag:

docker pull ghcr.io/org/app:sha-xxxxxxx

Docker 会根据本机架构自动挑正确的子镜像。amd64 机器拿 amd64,arm64 机器拿 arm64。

为什么会显示成 1 tagged + 4 untagged

以一个同时构建 linux/amd64 和 linux/arm64,并且没有关掉 buildx 默认 provenance 的镜像为例,一次构建大致会产生:

1 个 tagged image index
2 个 untagged platform image manifest
2 个 untagged provenance attestation manifest

所以 GHCR 页面上看起来就是:

1 tagged + 4 untagged

这里的 2 个 platform manifest 是真正给不同 CPU 架构运行用的镜像。它们没有自己的 tag,因为 tag 贴在上层 image index 上。

另外 2 个 provenance attestation manifest 不是运行镜像,而是构建证明。它们记录“这个镜像是从哪个 repo、哪个 commit、哪个 Dockerfile、哪次 GitHub Actions run 构建出来的,以及用了哪些基础镜像/构建依赖”。常见格式是 in-toto statement,predicate 是 SLSA provenance。

这些 untagged 可以删吗

不要只看 GHCR UI 上写着 untagged 就手动删。

如果这些 untagged 是被某个保留 tag 引用的子 manifest,删掉以后可能会破坏 multi-arch 镜像,或者丢掉 provenance 证明。正确的清理逻辑应该理解 image index 和它下面的子 manifest,而不是把所有 UI 上的 untagged 都当成孤立垃圾。

像 dataaxiom/ghcr-cleanup-action 这类专门处理 GHCR 清理的工具,会把 multi-arch image、attestation、referrer 这些关系一起考虑。删除旧 tagged image 时,它会连同下面的子 manifest 一起处理;保留新 tagged image 时,被引用的 untagged 子 manifest 也应该留下。

GitHub 能避免这种显示吗

GHCR 目前没有一个开关可以说“不要把被 image index 引用的子 manifest 显示为 untagged”。它是按 registry 里的 manifest/version 展示,所以 UI 会显得比较吵。

能减少数量,但都有代价:

  • 关掉 provenance:每批少两个 attestation,代价是没有构建来源证明。
  • 只构建单平台:少一个平台镜像,代价是不再同时支持 amd64/arm64。
  • 单平台并关掉 provenance:最干净,但功能和供应链信息都缩水。

更实用的判断是:不要追求 GHCR 页面上完全没有 untagged,而是区分“孤立 untagged”和“被保留 tag 引用的 untagged 子 manifest”。前者可以清理,后者是镜像结构的一部分。

docker-compose.yml 中 tty: true 和 stdin_open: true 这两个配置项分别对应 docker run 的 -t 和 -i 参数。

tty: true

分配虚拟终端(Pseudo-TTY),让程序输出像在普通命令行中运行,保留颜色和交互式输出。

stdin_open: true

保持标准输入开启,即使没有 attach 到容器也能接收指令。

适用场景

Minecraft 服务器等需要交互式控制台的服务。

为什么 Minecraft 必须用它们

Minecraft 服务器有交互式控制台,可以:

  • 手动输入命令:/op yourname 给自己管理员权限,或 /stop 安全关闭服务器
  • 使用 docker attach 进入游戏控制台直接输入指令

如果没开这两个选项,docker attach 只能看输出,无法输入命令。

services:
  mc:
    image: itzg/minecraft-server
    tty: true
    stdin_open: true

总结:tty 负责输出,stdin_open 负责输入。

Docker Compose 默认会给卷名加上项目前缀(目录名),使用 name 属性可自定义卷名。

services:
  db:
    image: postgres
    volumes:
      - my_custom_volume:/var/lib/postgresql/data

volumes:
  my_custom_volume:
    name: "production_db_volume"

运行 docker compose up 后,docker volume ls 会显示 production_db_volume,而非 my-app_my_custom_volume。

适用场景:

  • 多项目共享卷
  • 固定名字便于脚本编写和迁移

Docker Compose 中的 healthcheck 配置会覆盖 Dockerfile 中的 HEALTHCHECK 指令。

# docker-compose.yml
services:
  web:
    image: myapp
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

优先级:Dockerfile < Docker Compose。

为什么 Compose 优先?

遵循”运行时配置优于镜像构建配置”原则。Compose 覆盖 Dockerfile 的优势:

  • 灵活性:不同环境(开发/测试/生产)可配置不同的检查频率或超时时间
  • 依赖管理:配合 depends_on 的 condition: service_healthy 控制容器启动顺序
  • 集中管理:在 Compose 文件中一目了然,无需翻阅各镜像源码

vps 内存爆炸了,上来查原因,使用 bottom 查看之后发现是 uwsgi 进程占用高内存,但我印象中我是我没有部署类似的服务的,因为我基本都是使用 docker 来部署的。

通过 cgroup 信息定位问题

Linux 通过 cgroup 来管理容器资源,每个进程都会记录自己属于哪个 cgroup。我们可以利用这一点来找到它所属的容器。

  1. 找到 uwsgi 进程的 PID (Process ID)

    pidof uwsgi

    这个命令会返回一个或多个数字,就是 uwsgi 的进程 ID。我们假设返回的是 12345。

  2. 查看该 PID 的 cgroup 信息

    cat /proc/12345/cgroup

    在输出中,你会看到类似下面这样的行:

    11:perf_event:/docker/a1b2c3d4e5f6...
    10:cpuset:/docker/a1b2c3d4e5f6...
    ...
    1:name=systemd:/docker/a1b2c3d4e5f6...

    注意 /docker/ 后面那串以 a1b2c3d4e5f6 开头的字符串,这就是容器的完整 ID。

  3. 根据容器 ID 找到容器名

    docker inspect a1b2c3d4e5f6 | grep "Name"

    或者更简单地,列出所有容器,手动找到这个 ID:

    docker ps

    在 CONTAINER ID 列中找到对应的 ID,它旁边的 NAMES 列就是你容器的名字,例如 my-web-app。

小技巧: 你可以组合成一条命令,一步到位: cat /proc/$(pidof uwsgi)/cgroup | grep -o '[0-9a-f]\{64\}' | xargs -I {} docker inspect {} | grep "Name"

线上有个小服务,经常使用内存超标,想限制一下。我本来以为 docker compose 这些配置相关的都是跟 swarm 相关的,没想到其实是可以使用的。

在 docker compose 中添加 deploy 相关限制即可:

services:
  frontend:
    image: example/webapp
    deploy:
      resources:
        limits:
          cpus: '0.50'
          memory: 50M
          pids: 1
        reservations:
          cpus: '0.25'
          memory: 20M

参考链接:https://docs.docker.com/reference/compose-file/deploy/#resources