zhangguanzhang's Blog

docker 重启非 host 网络容器造成 dns 异常的梳理和pr修复

字数统计: 6.3k阅读时长: 35 min
2025/11/12
loading

docker 重启非 host 网络容器造成 dns 异常的梳理和pr修复

由来

内部产品是 toB 和 toG,针对 toG 的完全内网,测试会在搭建的 K8S 集群上所有节点配置个假的 DNS,这样黑盒下测功能,避免业务访问公网而造成功能问题。但是有些后续新业务是依赖公网的,测试测完没公网的部分后就会配置真实 dns 后测这部分功能,之前就遇到过好几次配置节点 DNS 后,个别 Pod 内 DNS 内容不是 k8s 的,而是下面这样类似变成宿主机:

1
2
3
4
5
6
7
8
# Generated by Docker Engine.
# This file can be edited; Docker Engine will not make further changes once it
# has been modified.

nameserver 10.xx.xx.xxx

# Based on host file: '/run/systemd/resolve/resolv.conf' (legacy)
# Overrides: []

我们是使用的 cri-dockerd + k8s 组合。

过程

之前反馈了几次,但是一直没稳定复现手段,就先去看了下大概这块 docker 源码,然后给测试说,下次开发环境遇到了别删除 Pod 和对应容器,直接喊我,2025/11/11 下午反馈找到稳定复现步骤了,我们有个 dashboard,测试环境上开发在上面重启了他的 Pod 下的容器,该 Pod 的 spec.containers 只有一个,勾选的是类似 docker ps -a 的那样,/pause 容器和他的容器都勾选点重启的。上面点了下确实发生了。

日志

找到容器所在节点上去看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
$ kubectl get pod -o wide -A | grep ai-aixxxxapi
default ai-aixxxxapi-6698f696d8-5w8kw 1/1 Running 1 (27m ago) 35m 10.187.x.34 10.1x.5x.251 <none> <none>
$ ip r g 1
1.0.0.0 via 10.1x.5x.1 dev eth0 src 10.1x.5x.251 uid 0
cache
$ docker ps -a | grep ai-aixxxxapi-6698f696d8-5w8kw
8b4ae64cecc0 reg.xxx.lan:5000/xxx/ai-aixxxxapi "/usr/local/bin/star…" 27 minutes ago Up 27 minutes k8s_ai-aixxxxapi_ai-aixxxxapi-6698f696d8-5w8kw_default_79fc032c-e1d3-4603-b21f-e5400ac6e3b6_1
e8eb18aaca94 reg.xxx.lan:5000/xxx/pause:3.9 "/pause" 27 minutes ago Up 27 minutes k8s_POD_ai-aixxxxapi-6698f696d8-5w8kw_default_79fc032c-e1d3-4603-b21f-e5400ac6e3b6_1
96ea991f9bb3 reg.xxx.lan:5000/xxx/ai-aixxxxapi "/usr/local/bin/star…" 34 minutes ago Exited (2) 27 minutes ago k8s_ai-aixxxxapi_ai-aixxxxapi-6698f696d8-5w8kw_default_79fc032c-e1d3-4603-b21f-e5400ac6e3b6_0
e4c64897be98 reg.xxx.lan:5000/xxx/pause:3.9 "/pause" 35 minutes ago Exited (0) 27 minutes ago k8s_POD_ai-aixxxxapi-6698f696d8-5w8kw_default_79fc032c-e1d3-4603-b21f-e5400ac6e3b6_0
$ docker inspect 96ea991f9bb3 | grep Resolv
"ResolvConfPath": "/data/kube/docker/containers/e4c64897be9891d88b999e81bfd55bb0cc1c21d626708749691d43158062f2bb/resolv.conf",
$ cat /data/kube/docker/containers/e4c64897be9891d88b999e81bfd55bb0cc1c21d626708749691d43158062f2bb/resolv.conf
# Generated by Docker Engine.
# This file can be edited; Docker Engine will not make further changes once it
# has been modified.

nameserver 10.xx.41.103

# Based on host file: '/run/systemd/resolve/resolv.conf' (legacy)
# Overrides: []
$ stat /data/kube/docker/containers/e4c64897be9891d88b999e81bfd55bb0cc1c21d626708749691d43158062f2bb/resolv.conf
File: /data/kube/docker/containers/e4c64897be9891d88b999e81bfd55bb0cc1c21d626708749691d43158062f2bb/resolv.conf
Size: 238 Blocks: 8 IO Block: 4096 regular file
Device: 811h/2065d Inode: 32449846 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2025-11-11 15:52:45.692564762 +0800
Modify: 2025-11-11 15:52:45.648562824 +0800
Change: 2025-11-11 15:52:45.655896480 +0800
Birth: -

容器 id e4c64897be98 找下日志看看:

1
2
3
4
5
$ journalctl -xe --no-pager -u docker | grep -P '96ea991f9bb3|e4c64897be98'
Nov 11 15:52:45 ubuntu2004chenxxxx7YX6V70 dockerd[3974]: time="2025-11-11T15:52:45.083407489+08:00" level=warning msg="cleaning up after shim disconnected" id=96ea991f9bb3454bec28712cb91c97f684e8f113e1b45697244190347a8c8305 namespace=moby
Nov 11 15:52:45 ubuntu2004chenxxxx7YX6V70 dockerd[3974]: time="2025-11-11T15:52:45.587632737+08:00" level=warning msg="cleaning up after shim disconnected" id=e4c64897be9891d88b999e81bfd55bb0cc1c21d626708749691d43158062f2bb namespace=moby
Nov 11 15:53:10 ubuntu2004chenxxxx7YX6V70 dockerd[3974]: time="2025-11-11T15:53:10.794469549+08:00" level=warning msg="cleaning up after shim disconnected" id=96ea991f9bb3454bec28712cb91c97f684e8f113e1b45697244190347a8c8305 namespace=moby
Nov 11 15:53:11 ubuntu2004chenxxxx7YX6V70 dockerd[3974]: time="2025-11-11T15:53:11.417476169+08:00" level=warning msg="cleaning up after shim disconnected" id=e4c64897be9891d88b999e81bfd55bb0cc1c21d626708749691d43158062f2bb namespace=moby

也看下容器时间相关:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ docker inspect e4c
[
{
"Id": "e4c64897be9891d88b999e81bfd55bb0cc1c21d626708749691d43158062f2bb",
"Created": "2025-11-11T07:44:57.093893019Z", 👈 创建时间
"Path": "/pause",
"Args": [],
"State": {
"Status": "exited",
"Running": false,
"Paused": false,
"Restarting": false,
"OOMKilled": false,
"Dead": false,
"Pid": 0,
"ExitCode": 0,
"Error": "",
"StartedAt": "2025-11-11T07:52:45.856187062Z",
"FinishedAt": "2025-11-11T07:53:11.408195153Z"
},

也看下 cri-dockerd 的日志:

1
2
$ journalctl -xe --no-pager -u cri-dockerd | grep e4c64897be9891d88b999e81bfd55bb0cc1c21d626708749691d43158062f2bb
Nov 11 15:45:03 ubuntu2004chenxxxx7YX6V70 cri-dockerd[51041]: time="2025-11-11T15:45:03+08:00" level=info msg="Will attempt to re-write config file /data/kube/docker/containers/e4c64897be9891d88b999e81bfd55bb0cc1c21d626708749691d43158062f2bb/resolv.conf as [nameserver 10.186.0.2 search default.svc.cluster1.local. svc.cluster1.local. cluster1.local. options ndots:5]"

根据上面日志总结时间线:

  1. 15.44.47 创建 /pause 容器
  2. 15:45:03 cri-dockerd 拉完业务镜像后创建 sandbox 容器,re-write 了容器的 resolv.conf,这块源码逻辑可以搜 Will attempt to re-write
  3. 15.52.45 重启了容器
  4. 容器的 resolv.conf 根据 mtime 看发生改变

最小复现

后端重启容器逻辑是同事写的,我记得大概逻辑是 python docker client 调用 docker 重启的,直接二分,如果 docker restart 复现了就不是 dashboard 后端逻辑造成的。然后环境上复现了,然后自己搭建个干净 K8S 也复现了。

1
2
3
4
5
6
7
8
9
10
$ cat testpod.yml
apiVersion: v1
kind: Pod
metadata:
name: testpod
spec:
containers:
- name: testpod
image: m.daocloud.io/docker.io/library/nginx:latest
# nodeName: xxx

单节点,如果固定节点的话设置下 nodeName 即可,复现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ docker ps -a | grep testpod
73b346e11ef4 m.daocloud.io/docker.io/library/nginx "/docker-entrypoint.…" 3 minutes ago Up 3 minutes k8s_vulnerable-container_testpod_default_f8215913-32b2-4e18-8536-69e5ecce7c84_0
ad255b51f1b3 reg.xxx.lan:5000/xxx/pause:3.9 "/pause" 3 minutes ago Up 3 minutes k8s_POD_testpod_default_f8215913-32b2-4e18-8536-69e5ecce7c84_0
$ docker inspect 73b346e11ef4 | grep ResolvConfPath
"ResolvConfPath": "/data/kube/docker/containers/ad255b51f1b396fdea0d2579b373ac5c497fbd707031fa53936e805ac1b30cc9/resolv.conf",
$ cat /data/kube/docker/containers/ad255b51f1b396fdea0d2579b373ac5c497fbd707031fa53936e805ac1b30cc9/resolv.conf
nameserver 10.186.0.2
search default.svc.cluster1.local. svc.cluster1.local. cluster1.local.
options ndots:5
$ docker restart 73b346e11ef4 ad255b51f1b3
73b346e11ef4
ad255b51f1b3
$ cat /data/kube/docker/containers/ad255b51f1b396fdea0d2579b373ac5c497fbd707031fa53936e805ac1b30cc9/resolv.conf
# Generated by Docker Engine.
# This file can be edited; Docker Engine will not make further changes once it
# has been modified.

nameserver 10.x3.41.103

# Based on host file: '/run/systemd/resolve/resolv.conf' (legacy)
# Overrides: []

Pod 的创建流程是容器运行时先创建一个 /pause 容器,然后 pod.spec.containers 的容器会 join 到 /pause 上,而 docker 下容器的 hosts、resolv.conf 和 hostname 这些是单独一层 init 层处理的,会创建文件,Pod 的所有容器都使用同一份:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ docker ps -a | grep testpod
f6ef003b4864 xxx
340e173d875b xxxx
$ docker inspect 340e173d875b | grep ResolvConfPath
"ResolvConfPath": "/data/kube/docker/containers/340e173d875b00b6aca32f8770493b9f1d86159340bcea6bc01b93992763bab7/resolv.conf",
$ docker inspect f6ef003b4864 | grep ResolvConfPath
"ResolvConfPath": "/data/kube/docker/containers/340e173d875b00b6aca32f8770493b9f1d86159340bcea6bc01b93992763bab7/resolv.conf",
$ cat /data/kube/docker/containers/340e173d875b00b6aca32f8770493b9f1d86159340bcea6bc01b93992763bab7/resolv.conf
nameserver 10.186.0.2
search default.svc.cluster2.local. svc.cluster2.local. cluster2.local.
options ndots:5

$ ls -1 /data/kube/docker/containers/340e173d875b00b6aca32f8770493b9f1d86159340bcea6bc01b93992763bab7/
340e173d875b00b6aca32f8770493b9f1d86159340bcea6bc01b93992763bab7-json.log
checkpoints
config.v2.json
hostconfig.json
hostname
hosts
mounts
resolv.conf
resolv.conf.hash

然后发现最小化复现是只重启 /pause 容器发生:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ docker ps -a | grep testpod
ef9bb3c69dfa reg.xxx.lan:5000/xxx/nginx "/docker-entrypoint.…" 2 minutes ago Up 2 minutes k8s_vulnerable-container_testpod_default_ebb8ace6-84ab-4a28-814c-109c41827908_1
e6868ab3d8ef reg.xxx.lan:5000/xxx/pause:3.9 "/pause" 2 minutes ago Up 2 minutes k8s_POD_testpod_default_ebb8ace6-84ab-4a28-814c-109c41827908_1
$ docker inspect e6868ab3d8ef | grep ResolvConfPath
"ResolvConfPath": "/data/kube/docker/containers/e6868ab3d8ef8fa1238a82a15faa88b1d13967a71a1e16c99618663610d21286/resolv.conf",
$ cat /data/kube/docker/containers/e6868ab3d8ef8fa1238a82a15faa88b1d13967a71a1e16c99618663610d21286/resolv.conf
nameserver 10.186.0.2
search default.svc.cluster2.local. svc.cluster2.local. cluster2.local.
options ndots:5
$ docker restart e6868ab3d8ef
e6868ab3d8ef
$ cat /data/kube/docker/containers/e6868ab3d8ef8fa1238a82a15faa88b1d13967a71a1e16c99618663610d21286/resolv.conf
# Generated by Docker Engine.
# This file can be edited; Docker Engine will not make further changes once it
# has been modified.

nameserver 223.5.5.5

# Based on host file: '/etc/resolv.conf' (legacy)
# Overrides: []

相关源码

既然确定是 docker 逻辑造成,那就需要看下这块源码逻辑了,可以看到有生成注释的,代码里搜关键字 Generated by Docker Engine. 搜到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// https://github.com/moby/moby/blob/v26.1.4/libnetwork/sandbox_dns_unix.go#L248-L278
// loadResolvConf reads the resolv.conf file at path, and merges in overrides for
// nameservers, options, and search domains.
func (sb *Sandbox) loadResolvConf(path string) (*resolvconf.ResolvConf, error) {
rc, err := resolvconf.Load(path)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
// Proceed with rc, which might be zero-valued if path does not exist.

rc.SetHeader(`# Generated by Docker Engine.
# This file can be edited; Docker Engine will not make further changes once it
# has been modified.`)
if len(sb.config.dnsList) > 0 {
var dnsAddrs []netip.Addr
for _, ns := range sb.config.dnsList {
addr, err := netip.ParseAddr(ns)
if err != nil {
return nil, errors.Wrapf(err, "bad nameserver address %s", ns)
}
dnsAddrs = append(dnsAddrs, addr)
}
rc.OverrideNameServers(dnsAddrs)
}
if len(sb.config.dnsSearchList) > 0 {
rc.OverrideSearch(sb.config.dnsSearchList)
}
if len(sb.config.dnsOptionsList) > 0 {
rc.OverrideOptions(sb.config.dnsOptionsList)
}
return &rc, nil
}

逆向思维,搜了下 loadResolvConf( 发现有在

  • func (sb *Sandbox) setupDNS() error {
  • func (sb *Sandbox) updateDNS(ipv6Enabled bool) error {
  • func (sb *Sandbox) rebuildDNS() error {

断点调试

golang 二进制调试构建的时候需要设置 -gcflags=all=-N -l 才能 dlv 调试,编译 dockerd 开启调试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ DOCKER_DEBUG=1 ./hack/make.sh binary-daemon

Removing bundles/

---> Making bundle: binary-daemon (in bundles/binary-daemon)
Building static bundles/binary-daemon/dockerd (linux/amd64)...
+++ ./hack/with-go-mod.sh go build -mod=vendor -modfile=vendor.mod -o bundles/binary-daemon/dockerd -tags 'netgo osusergo static_build ' -ldflags ' -X "github.com/docker/docker/dockerversion.Version=dev" -X "github.com/docker/docker/dockerversion.GitCommit=de5c9cf0b96e4e172b96db54abababa4a328462f" -X "github.com/docker/docker/dockerversion.BuildTime=2025-11-11T10:42:16.000000000+00:00" -X "github.com/docker/docker/dockerversion.PlatformName=" -X "github.com/docker/docker/dockerversion.ProductName=" -X "github.com/docker/docker/dockerversion.DefaultProductLicense=" -extldflags -static ' '-gcflags=all=-N -l' github.com/docker/docker/cmd/dockerd
+ tee /root/github/moby/go.mod
module github.com/docker/docker

go 1.21
+ trap 'rm -f "${ROOTDIR}/go.mod"' EXIT
+ GO111MODULE=on
+ GOTOOLCHAIN=local
+ go build -mod=vendor -modfile=vendor.mod -o bundles/binary-daemon/dockerd -tags 'netgo osusergo static_build ' -ldflags ' -X "github.com/docker/docker/dockerversion.Version=dev" -X "github.com/docker/docker/dockerversion.GitCommit=de5c9cf0b96e4e172b96db54abababa4a328462f" -X "github.com/docker/docker/dockerversion.BuildTime=2025-11-11T10:42:16.000000000+00:00" -X "github.com/docker/docker/dockerversion.PlatformName=" -X "github.com/docker/docker/dockerversion.ProductName=" -X "github.com/docker/docker/dockerversion.DefaultProductLicense=" -extldflags -static ' '-gcflags=all=-N -l' github.com/docker/docker/cmd/dockerd
+ rm -f /root/github/moby/go.mod
Created binary: bundles/binary-daemon/dockerd

替换启动,找到 pid:

1
2
3
4
5
6
systemctl stop docker
d_dir=$(dirname $(which docker))
cp $(which dockerd) $(which dockerd).bak
cp bundles/binary-daemon/dockerd ${d_dir}
systemctl start docker
ps -ef | grep /docker[d]

然后附加 pid 上调试,dlv 打了几个断点后发现在 setupDNS() 里:

1
2
3
4
5
6
7
8
$ go install github.com/go-delve/delve/cmd/dlv@master
$ dlv attach 19034
Type 'help' for list of commands.
(dlv) b libnetwork/sandbox_dns_unix.go:285
Breakpoint 1 set at 0x24c6c8b for github.com/docker/docker/libnetwork.(*Sandbox).setupDNS() ./libnetwork/sandbox_dns_unix.go:285
(dlv) b libnetwork/sandbox_dns_unix.go:300
Breakpoint 2 set at 0x24c6f52 for github.com/docker/docker/libnetwork.(*Sandbox).updateDNS() ./libnetwork/sandbox_dns_unix.go:300
(dlv) c

然后另一个终端上重启下 /pause 容器,这边终端的 dlv 就走到断点了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(dlv) c
> [Breakpoint 1] github.com/docker/docker/libnetwork.(*Sandbox).setupDNS() ./libnetwork/sandbox_dns_unix.go:285 (hits goroutine(2045):1 total:1) (PC: 0x24c6c8b)
280: // For a new sandbox, write an initial version of the container's resolv.conf. It'll
281: // be a copy of the host's file, with overrides for nameservers, options and search
282: // domains applied.
283: func (sb *Sandbox) setupDNS() error {
284: // Make sure the directory exists.
=> 285: sb.restoreResolvConfPath()
286: dir, _ := filepath.Split(sb.config.resolvConfPath)
287: if err := createBasePath(dir); err != nil {
288: return err
289: }
290:
(dlv) p sb
Sending output to pager...
("*github.com/docker/docker/libnetwork.Sandbox")(0xc0028d2400)
*github.com/docker/docker/libnetwork.Sandbox {
id: "1695c2a715872e25bcaa9c0268eeea65e528fa3ec6c275dfbf567344cd2cb30c",
containerID: "176c492dc77f3ed8020c1d4e59896311fea359135be39c26c04d28460546cf38",
config: github.com/docker/docker/libnetwork.containerConfig {

分析

大致看了下 docker 这块逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// https://github.com/moby/moby/blob/v26.1.4/libnetwork/sandbox_dns_unix.go#L280C1-L296C2
// For a new sandbox, write an initial version of the container's resolv.conf. It'll
// be a copy of the host's file, with overrides for nameservers, options and search
// domains applied.
func (sb *Sandbox) setupDNS() error {
// Make sure the directory exists.
sb.restoreResolvConfPath()
dir, _ := filepath.Split(sb.config.resolvConfPath)
if err := createBasePath(dir); err != nil {
return err
}

rc, err := sb.loadResolvConf(sb.config.getOriginResolvConfPath())
if err != nil {
return err
}
return rc.WriteFile(sb.config.resolvConfPath, sb.config.resolvConfHashFile, filePerm)
}

sb.restoreResolvConfPath() 填充变量,也就是实际的 ResolvConfPath 和他的 .hash 文件:

1
2
3
4
5
6
func (sb *Sandbox) restoreResolvConfPath() {
if sb.config.resolvConfPath == "" {
sb.config.resolvConfPath = defaultPrefix + "/" + sb.id + "/resolv.conf"
}
sb.config.resolvConfHashFile = sb.config.resolvConfPath + ".hash"
}

sb.loadResolvConf 方法就是把 Linux 的 DNS 内容解析成结构体,传入的 sb.config.getOriginResolvConfPath() 是获取宿主机的 dns 文件路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(dlv) n
> github.com/docker/docker/libnetwork.(*containerConfig).getOriginResolvConfPath() ./libnetwork/sandbox_dns_unix.go:242 (PC: 0x24c64ab)
237: }
238: }
239:
240: func (c *containerConfig) getOriginResolvConfPath() string {
241: if c.originResolvConfPath != "" {
=> 242: return c.originResolvConfPath
243: }
244: // Fallback if not specified.
245: return resolvconf.Path()
246: }
247:
(dlv) p c.originResolvConfPath
"/etc/resolv.conf"

机器如果使用了 systemd-resolv 下 originResolvConfPath 则会是 /run/systemd/resolve/resolv.conf, 然后继续调试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(dlv) list
> [Breakpoint 1] github.com/docker/docker/libnetwork.(*Sandbox).setupDNS() ./libnetwork/sandbox_dns_unix.go:291 (hits goroutine(52319):1 total:3) (PC: 0x24c6d7a)
> github.com/docker/docker/libnetwork/internal/resolvconf.(*ResolvConf).WriteFile() ./libnetwork/internal/resolvconf/resolvconf.go:377 (PC: 0xfad513)
372:
373: // WriteFile generates content and writes it to path. If hashPath is non-zero, it
374: // also writes a file containing a hash of the content, to enable UserModified()
375: // to determine whether the file has been modified.
376: func (rc *ResolvConf) WriteFile(path, hashPath string, perm os.FileMode) error {
=> 377: content, err := rc.Generate(true)
378: if err != nil {
379: return err
380: }
381:
382: // Write the resolv.conf file - it's bind-mounted into the container, so can't
(dlv) p path
"/data/kube/docker/containers/c213ae91753573a17948caf3cfa6421045d11e7e269d57cbc129f784f188d99b/resolv.conf"
(dlv) p hashPath
"/data/kube/docker/containers/c213ae91753573a17948caf3cfa6421045d11e7e269d57cbc129f784f188d99b/resolv.conf.hash"

有个 hash 文件,那看起来就最下面的 rc.WriteFile 改写的内容了,看下它的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// https://github.com/moby/moby/blob/v26.1.4/libnetwork/internal/resolvconf/resolvconf.go#L373-L402
// WriteFile generates content and writes it to path. If hashPath is non-zero, it
// also writes a file containing a hash of the content, to enable UserModified()
// to determine whether the file has been modified.
func (rc *ResolvConf) WriteFile(path, hashPath string, perm os.FileMode) error {
content, err := rc.Generate(true)
if err != nil {
return err
}

// Write the resolv.conf file - it's bind-mounted into the container, so can't
// move a temp file into place, just have to truncate and write it.
if err := os.WriteFile(path, content, perm); err != nil {
return errdefs.System(err)
}

// Write the hash file.
if hashPath != "" {
hashFile, err := ioutils.NewAtomicFileWriter(hashPath, perm)
if err != nil {
return errdefs.System(err)
}
defer hashFile.Close()

digest := digest.FromBytes(content)
if _, err = hashFile.Write([]byte(digest)); err != nil {
return err
}
}

return nil
}

content 就是生成最后写入的内容写入,以及计算当前 content 的 hash 写入到 .hash 文件。这个逻辑是 docker daemon 检查到容器的 resolv.conf 文件的计算 hash 对不上 .hash 保存的的,就说明客户修改过容器的 DNS,docker daemon 不再修改它。

那还是得看 setupDNS() 的更上层调用,搜到 setupResolutionFiles(),而调用它的有两个:

  • func (c *Controller) NewSandbox(
  • func (sb *Sandbox) Refresh(

俩都打断点看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ dlv attach 19035
Type 'help' for list of commands.
(dlv) b libnetwork/sandbox.go:263
Breakpoint 1 set at 0x24be2d8 for github.com/docker/docker/libnetwork.(*Sandbox).Refresh() ./libnetwork/sandbox.go:263
(dlv) b libnetwork/controller.go:944
Breakpoint 2 set at 0x2470d2f for github.com/docker/docker/libnetwork.(*Controller).NewSandbox() ./libnetwork/controller.go:944
(dlv) c
> [Breakpoint 2] github.com/docker/docker/libnetwork.(*Controller).NewSandbox() ./libnetwork/controller.go:944 (hits goroutine(58233):1 total:1) (PC: 0x2470d2f)
939: }
940: c.mu.Unlock()
941: }
942: }()
943:
=> 944: if err := sb.setupResolutionFiles(); err != nil {
945: return nil, err
946: }
947: if err := c.setupOSLSandbox(sb); err != nil {
948: return nil, err
949: }

走的 NewSandbox ,打印下 backtrace:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(dlv) bt
0 0x0000000002470d2f in github.com/docker/docker/libnetwork.(*Controller).NewSandbox
at ./libnetwork/controller.go:944
1 0x0000000002f855e5 in github.com/docker/docker/daemon.(*Daemon).connectToNetwork
at ./daemon/container_operations.go:762
2 0x0000000002f82825 in github.com/docker/docker/daemon.(*Daemon).allocateNetwork
at ./daemon/container_operations.go:525
3 0x0000000002f87d05 in github.com/docker/docker/daemon.(*Daemon).initializeNetworking
at ./daemon/container_operations.go:950
4 0x000000000302d26b in github.com/docker/docker/daemon.(*Daemon).containerStart
at ./daemon/start.go:117
5 0x0000000003028736 in github.com/docker/docker/daemon.(*Daemon).containerRestart
at ./daemon/restart.go:69
6 0x00000000030281e5 in github.com/docker/docker/daemon.(*Daemon).ContainerRestart
at ./daemon/restart.go:24
7 0x00000000028a9c15 in github.com/docker/docker/api/server/router/container.(*containerRouter).postContainersRestart
at ./api/server/router/container/container_routes.go:267
8 0x00000000028b594c in github.com/docker/docker/api/server/router/container.(*containerRouter).postContainersRestart-fm
at <autogenerated>:1

以及另一块大概的相关逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
0  0x000000000247da42 in github.com/docker/docker/libnetwork.(*Endpoint).sbJoin
at ./libnetwork/endpoint.go:529
1 0x000000000247cc3a in github.com/docker/docker/libnetwork.(*Endpoint).Join
at ./libnetwork/endpoint.go:467
2 0x0000000002f85ad1 in github.com/docker/docker/daemon.(*Daemon).connectToNetwork
at ./daemon/container_operations.go:780
3 0x0000000002f82ac5 in github.com/docker/docker/daemon.(*Daemon).allocateNetwork
at ./daemon/container_operations.go:530
4 0x0000000002f87fa5 in github.com/docker/docker/daemon.(*Daemon).initializeNetworking
at ./daemon/container_operations.go:955
5 0x000000000302d50b in github.com/docker/docker/daemon.(*Daemon).containerStart
at ./daemon/start.go:117
6 0x00000000030289d6 in github.com/docker/docker/daemon.(*Daemon).containerRestart
at ./daemon/restart.go:69
7 0x0000000003028485 in github.com/docker/docker/daemon.(*Daemon).ContainerRestart
at ./daemon/restart.go:24
8 0x00000000028a9c95 in github.com/docker/docker/api/server/router/container.(*containerRouter).postContainersRestart
at ./api/server/router/container/container_routes.go:267

sandbox change

1
2
3
4
5
6
7
8
9
10
11
$ docker ps -a | grep testpod
256cdbac0fc8 reg.xxx.lan:5000/xxx/nginx "/docker-entrypoint.…" 12 minutes ago Up 12 minutes k8s_vulnerable-container_testpod_default_ebb8ace6-84ab-4a28-814c-109c41827908_20
2f79aa18d3f6 reg.xxx.lan:5000/xxx/pause:3.9 "/pause" 12 minutes ago Up 12 minutes k8s_POD_testpod_default_ebb8ace6-84ab-4a28-814c-109c41827908_20
$ docker inspect 2f79aa18d3f6 | grep SandboxI
"SandboxID": "08b8e08676b30c527f76ef551de3dff3fec048af4486b69f8eb0071b2af0a9cf",
"SandboxKey": "/var/run/docker/netns/08b8e08676b3",
$ docker restart 2f79aa18d3f6
2f79aa18d3f6
$ docker inspect 2f79aa18d3f6 | grep SandboxI
"SandboxID": "c87e60bef5f4d95feb723a1ec8818bf2021c900fa5974e0e2107189ab90661ba",
"SandboxKey": "/var/run/docker/netns/c87e60bef5f4",

可以看到 sandbox 变了导致重建了 DNS。

containerd 没复现

顺便找人 containerd + nerdctl 试了下没问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
$ nerdctl -n k8s.io ps -a | grep zgz
d79ac72ea7a6 dockerhub.xxxxxxwan.cn/pub/nginx:latest "/docker-entrypoint.…" 57 seconds ago Up k8s://default/test-zgz/nginx
a2f547c59e8f dockerhub.xxxxxxwan.cn/pub/pause:3.9 "/pause" About a minute ago Up k8s://default/test-zgz
$ nerdctl -n k8s.io inspect a2f547c59e8f | grep Resolv
"ResolvConfPath": "/data/lv/lib/io.containerd.grpc.v1.cri/sandboxes/a2f547c59e8fc1d0dd1b6e5a9e35232211e9d4ce811a9101c45ed4d6bc9cf343/resolv.conf",
$ cat /data/lv/lib/io.containerd.grpc.v1.cri/sandboxes/a2f547c59e8fc1d0dd1b6e5a9e35232211e9d4ce811a9101c45ed4d6bc9cf343/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 169.254.25.10
options ndots:5
$ nerdctl -n k8s.io restart a2f547c59e8f
a2f547c59e8f
$ nerdctl -n k8s.io ps -a | grep zgz
13be2f142023 dockerhub.xxxxxxwan.cn/pub/nginx:latest "/docker-entrypoint.…" Less than a second ago Up k8s://default/test-zgz/nginx
cabe19fd2fd7 dockerhub.xxxxxxwan.cn/pub/pause:3.9 "/pause" 1 second ago Up k8s://default/test-zgz
d79ac72ea7a6 dockerhub.xxxxxxwan.cn/pub/nginx:latest "/docker-entrypoint.…" 2 minutes ago Created k8s://default/test-zgz/nginx
a2f547c59e8f dockerhub.xxxxxxwan.cn/pub/pause:3.9 "/pause" 2 minutes ago Up k8s://default/test-zgz
$ nerdctl -n k8s.io ps -a | grep zgz
13be2f142023 dockerhub.xxxxxxwan.cn/pub/nginx:latest "/docker-entrypoint.…" 9 seconds ago Up k8s://default/test-zgz/nginx
cabe19fd2fd7 dockerhub.xxxxxxwan.cn/pub/pause:3.9 "/pause" 10 seconds ago Up k8s://default/test-zgz
d79ac72ea7a6 dockerhub.xxxxxxwan.cn/pub/nginx:latest "/docker-entrypoint.…" 2 minutes ago Created k8s://default/test-zgz/nginx
a2f547c59e8f dockerhub.xxxxxxwan.cn/pub/pause:3.9 "/pause" 2 minutes ago Up k8s://default/test-zgz
$ nerdctl -n k8s.io inspect cabe19fd2fd7 | grep Resolv
"ResolvConfPath": "/data/lv/lib/io.containerd.grpc.v1.cri/sandboxes/cabe19fd2fd75677f4ce77883697a76f66c425977c7cb358a3beb7da36d9d847/resolv.conf",
$ cat /data/lv/lib/io.containerd.grpc.v1.cri/sandboxes/cabe19fd2fd75677f4ce77883697a76f66c425977c7cb358a3beb7da36d9d847/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 169.254.25.10
options ndots:5
$ nerdctl -n k8s.io inspect 13be2f142023 | grep Resolv
"ResolvConfPath": "/data/lv/lib/io.containerd.grpc.v1.cri/sandboxes/cabe19fd2fd75677f4ce77883697a76f66c425977c7cb358a3beb7da36d9d847/resolv.conf",
$ cat /data/lv/lib/io.containerd.grpc.v1.cri/sandboxes/cabe19fd2fd75677f4ce77883697a76f66c425977c7cb358a3beb7da36d9d847/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 169.254.25.10
options ndots:5

containerd 没复现

重启偶尔重建Pod

偶尔发现重启 /pause 容器被 k8s 重建了

1
2
3
4
5
6
7
8
9
10
11
12
13
$ docker restart c213ae917535
c213ae917535
$ docker ps -a | grep testpod
427dbeb17e72 reg.xxx.lan:5000/xxx/nginx "/docker-entrypoint.…" 2 seconds ago Up 1 second k8s_vulnerable-container_testpod_default_ebb8ace6-84ab-4a28-814c-109c41827908_8
612bf2b23193 reg.xxx.lan:5000/xxx/pause:3.9 "/pause" 3 seconds ago Up 1 second k8s_POD_testpod_default_ebb8ace6-84ab-4a28-814c-109c41827908_8
cbd04bc66cac reg.xxx.lan:5000/xxx/nginx "/docker-entrypoint.…" 10 minutes ago Exited (0) 2 seconds ago k8s_vulnerable-container_testpod_default_ebb8ace6-84ab-4a28-814c-109c41827908_7
51a41d9884e4 reg.xxx.lan:5000/xxx/pause:3.9 "/pause" 10 minutes ago Exited (0) 2 seconds ago k8s_POD_testpod_default_ebb8ace6-84ab-4a28-814c-109c41827908_7
c213ae917535 reg.xxx.lan:5000/xxx/pause:3.9 "/pause" 2 hours ago Exited (0) 2 seconds ago k8s_POD_testpod_default_ebb8ace6-84ab-4a28-814c-109c41827908_6
$ kubectl get event | grep testpod
3s Normal Pulling pod/testpod Pulling image "reg.xxx.lan:5000/xxx/nginx"
3s Normal Created pod/testpod Created container vulnerable-container
3s Normal Started pod/testpod Started container vulnerable-container
4s Normal SandboxChanged pod/testpod Pod sandbox changed, it will be killed and re-created.

K8S 源码里搜 Pod sandbox changed 大致看了下这块 kubelet 代码,就是刚好检测 Pod 的 sandbox 状态和重启行为重合就会启动新的容器避免了这个问题,但是这种情况发生的概率不是百分之百重合。

修复

从以上结论来看,kubelet 没问题,cri-dockerd 也没问题(它 re-write 行为和人为修改一样),只可能在 docker 方面修复了,根据上面调用链,restart 容器实际走的:

1
2
3
4
5
6
7
8
9
10
11
12
(dlv) bt
0 0x0000000002470d2f in github.com/docker/docker/libnetwork.(*Controller).NewSandbox
at ./libnetwork/controller.go:944
1 0x0000000002f855e5 in github.com/docker/docker/daemon.(*Daemon).connectToNetwork
at ./daemon/container_operations.go:762
2 0x0000000002f82825 in github.com/docker/docker/daemon.(*Daemon).allocateNetwork
at ./daemon/container_operations.go:525
3 0x0000000002f87d05 in github.com/docker/docker/daemon.(*Daemon).initializeNetworking
at ./daemon/container_operations.go:950
4 0x000000000302d26b in github.com/docker/docker/daemon.(*Daemon).containerStart
at ./daemon/start.go:117
5 0x0000000003028736 in github.com/docker/docker/daemon.(*Daemon).containerRestart

看了下就是根据 restart 的 id 获取 containerConfig,containerRestart 调用逻辑是先 stop 在 start,由于是 sandbox,所以重建了一个 sandbox,重建的关键地方在 container_operations.go 内,使用 stop 之前的 containerConfig, 创建 SandboxOption 的 slice 。

1
2
3
4
5
sbOptions, err := buildSandboxOptions(cfg, ctr)
if err != nil {
return nil, err
}
sb, err := daemon.netController.NewSandbox(ctx, ctr.ID, sbOptions...)

想法是增加一个 SandboxOption 设置 sandbox 的结构体增加一个 flag ,来判断 SandBox 是否由重启行为创建,然后写相关逻辑处理就行,pr 改动如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
diff --git a/daemon/container_operations.go b/daemon/container_operations.go
index 1ed84bb04e4a0..c00b074881d31 100644
--- a/daemon/container_operations.go
+++ b/daemon/container_operations.go
@@ -149,6 +149,10 @@ func buildSandboxOptions(cfg *config.Config, ctr *container.Container) ([]libnet

sboxOptions = append(sboxOptions, libnetwork.OptionPortMapping(publishedPorts), libnetwork.OptionExposedPorts(exposedPorts))

+ if !ctr.State.StartedAt.IsZero() && !ctr.State.FinishedAt.IsZero() {
+ sboxOptions = append(sboxOptions, libnetwork.OptionCreateByRestart())
+ }
+
return sboxOptions, nil
}

diff --git a/daemon/libnetwork/sandbox.go b/daemon/libnetwork/sandbox.go
index 4d0236f2e4628..606403e5d08f8 100644
--- a/daemon/libnetwork/sandbox.go
+++ b/daemon/libnetwork/sandbox.go
@@ -37,23 +37,24 @@ func (sb *Sandbox) processOptions(options ...SandboxOption) {
// Sandbox provides the control over the network container entity.
// It is a one to one mapping with the container.
type Sandbox struct {
- id string
- containerID string
- config containerConfig
- extDNS []extDNSEntry
- osSbox *osl.Namespace
- controller *Controller
- resolver *Resolver
- resolverOnce sync.Once
- dbIndex uint64
- dbExists bool
- isStub bool
- inDelete bool
- ingress bool
- ndotsSet bool
- oslTypes []osl.SandboxType // slice of properties of this sandbox
- loadBalancerNID string // NID that this SB is a load balancer for
- mu sync.Mutex
+ id string
+ containerID string
+ config containerConfig
+ extDNS []extDNSEntry
+ osSbox *osl.Namespace
+ controller *Controller
+ resolver *Resolver
+ resolverOnce sync.Once
+ dbIndex uint64
+ dbExists bool
+ isStub bool
+ inDelete bool
+ ingress bool
+ createdByRestart bool
+ ndotsSet bool
+ oslTypes []osl.SandboxType // slice of properties of this sandbox
+ loadBalancerNID string // NID that this SB is a load balancer for
+ mu sync.Mutex

// joinLeaveMu is required as well as mu to modify the following fields,
// acquire joinLeaveMu first, and keep it at-least until gateway changes
diff --git a/daemon/libnetwork/sandbox_dns_unix.go b/daemon/libnetwork/sandbox_dns_unix.go
index a5aac066e925b..c99b382b70894 100644
--- a/daemon/libnetwork/sandbox_dns_unix.go
+++ b/daemon/libnetwork/sandbox_dns_unix.go
@@ -264,8 +264,17 @@ func (sb *Sandbox) loadResolvConf(path string) (*resolvconf.ResolvConf, error) {
// be a copy of the host's file, with overrides for nameservers, options and search
// domains applied.
func (sb *Sandbox) setupDNS() error {
- // Make sure the directory exists.
sb.restoreResolvConfPath()
+
+ // Fixes https://github.com/moby/moby/issues/51490
+ // non-host network sandbox should check resolvconf.UserModified
+ if sb.createdByRestart && !sb.config.useDefaultSandBox {
+ if mod, err := resolvconf.UserModified(sb.config.resolvConfPath, sb.config.resolvConfHashFile); err != nil || mod {
+ return err
+ }
+ }
+
+ // Make sure the directory exists.
dir, _ := filepath.Split(sb.config.resolvConfPath)
if err := createBasePath(dir); err != nil {
return err
diff --git a/daemon/libnetwork/sandbox_dns_unix_test.go b/daemon/libnetwork/sandbox_dns_unix_test.go
index 3bb64cf5ce5b5..93700c20ced08 100644
--- a/daemon/libnetwork/sandbox_dns_unix_test.go
+++ b/daemon/libnetwork/sandbox_dns_unix_test.go
@@ -14,12 +14,17 @@ import (
is "gotest.tools/v3/assert/cmp"
)

-func getResolvConfOptions(t *testing.T, rcPath string) []string {
+func getResolvConf(t *testing.T, rcPath string) resolvconf.ResolvConf {
t.Helper()
resolv, err := os.ReadFile(rcPath)
assert.NilError(t, err)
rc, err := resolvconf.Parse(bytes.NewBuffer(resolv), "")
assert.NilError(t, err)
+ return rc
+}
+
+func getResolvConfOptions(t *testing.T, rcPath string) []string {
+ rc := getResolvConf(t, rcPath)
return rc.Options()
}

@@ -90,3 +95,69 @@ func TestDNSOptions(t *testing.T) {
dnsOptionsList = getResolvConfOptions(t, sb2.config.resolvConfPath)
assert.Check(t, is.DeepEqual([]string{"ndots:0"}, dnsOptionsList))
}
+
+func TestNonHostNetDNSRestart(t *testing.T) {
+ c, err := New(context.Background(), config.OptionDataDir(t.TempDir()))
+ assert.NilError(t, err)
+
+ // Step 1: Create initial sandbox (simulating first container start)
+ sb, err := c.NewSandbox(context.Background(), "cnt1")
+ assert.NilError(t, err)
+
+ sb.startResolver(false)
+
+ err = sb.setupDNS()
+ assert.NilError(t, err)
+ err = sb.rebuildDNS()
+ assert.NilError(t, err)
+
+ // Step 2: Simulate cri-dockerd modifying the resolv.conf for a Kubernetes pause container.
+ // This mimics the behavior where external tools (like cri-dockerd) customize DNS
+ // settings for K8s pods, which should be preserved during container restart/unpause.
+ resolvConfPath := sb.config.resolvConfPath
+ modifiedContent := []byte(`nameserver 10.96.0.10
+search default.svc.cluster.local. svc.cluster.local. cluster.local.
+options ndots:5
+`)
+ err = os.WriteFile(resolvConfPath, modifiedContent, 0644)
+ assert.NilError(t, err)
+
+ // Step 3: Delete the sandbox (simulating container stop)
+ err = sb.Delete(context.Background())
+ assert.NilError(t, err)
+
+ // Step 4: Create a new sandbox with OptionRestartOperate (simulating container restart)
+ sbRestart, err := c.NewSandbox(context.Background(), "cnt1",
+ OptionCreateByRestart(),
+ OptionResolvConfPath(resolvConfPath),
+ )
+ assert.NilError(t, err)
+ defer func() {
+ if err := sbRestart.Delete(context.Background()); err != nil {
+ t.Error(err)
+ }
+ }()
+
+ sbRestart.startResolver(false)
+
+ // Step 5: Call setupDNS on restart - should preserve external modifications
+ err = sbRestart.setupDNS()
+ assert.NilError(t, err)
+
+ // Verify that the DNS settings modified by cri-dockerd are preserved
+ rc := getResolvConf(t, sbRestart.config.resolvConfPath)
+ assert.Check(t, is.Len(rc.Options(), 1))
+ assert.Check(t, is.Equal("10.96.0.10", rc.NameServers()[0].String()))
+ assert.Check(t, is.DeepEqual([]string{"default.svc.cluster.local.", "svc.cluster.local.", "cluster.local."}, rc.Search()))
+ assert.Check(t, is.Equal("ndots:5", rc.Options()[0]))
+
+ err = sbRestart.rebuildDNS()
+ assert.NilError(t, err)
+
+ rc = getResolvConf(t, sbRestart.config.resolvConfPath)
+ assert.Check(t, is.Len(rc.Options(), 1))
+ assert.Check(t, is.Equal("10.96.0.10", rc.NameServers()[0].String()))
+ assert.Check(t, is.DeepEqual([]string{"default.svc.cluster.local.", "svc.cluster.local.", "cluster.local."}, rc.Search()))
+ assert.Check(t, is.Equal("ndots:5", rc.Options()[0]))
+
+}
diff --git a/daemon/libnetwork/sandbox_options.go b/daemon/libnetwork/sandbox_options.go
index ba05582dec270..8210d3bd6845b 100644
--- a/daemon/libnetwork/sandbox_options.go
+++ b/daemon/libnetwork/sandbox_options.go
@@ -151,3 +151,11 @@ func OptionLoadBalancer(nid string) SandboxOption {
sb.oslTypes = append(sb.oslTypes, osl.SandboxTypeLoadBalancer)
}
}
+
+// OptionCreateByRestart function returns an option setter for marking a
+// sandbox was created by restart.
+func OptionCreateByRestart() SandboxOption {
+ return func(sb *Sandbox) {
+ sb.createdByRestart = true
+ }
+}
diff --git a/integration/networking/resolvconf_test.go b/integration/networking/resolvconf_test.go
index c0119bd650be0..4a84077adbb6e 100644
--- a/integration/networking/resolvconf_test.go
+++ b/integration/networking/resolvconf_test.go
@@ -212,3 +212,47 @@ func TestNslookupWindows(t *testing.T) {
// can only be changed in daemon.json using feature flag "windows-dns-proxy".
assert.Check(t, is.Contains(res.Stdout.String(), "Addresses:"))
}
+
+// TestResolvConfPreservedOnRestart verifies that external modifications to
+// /etc/resolv.conf are preserved when a non-host network container is restarted.
+// Regression test for https://github.com/moby/moby/issues/51490
+func TestResolvConfPreservedOnRestart(t *testing.T) {
+ skip.If(t, testEnv.DaemonInfo.OSType == "windows", "No /etc/resolv.conf on Windows")
+
+ ctx := setupTest(t)
+
+ d := daemon.New(t, daemon.WithResolvConf(network.GenResolvConf("8.8.8.8")))
+ d.StartWithBusybox(ctx, t)
+ defer d.Stop(t)
+
+ c := d.NewClientT(t)
+ defer c.Close()
+
+ const ctrName = "test-resolvconf-preserved-on-restart"
+ id := container.Run(ctx, t, c,
+ container.WithName(ctrName),
+ container.WithImage("busybox:latest"),
+ container.WithCmd("top"),
+ )
+ defer c.ContainerRemove(ctx, id, client.ContainerRemoveOptions{
+ Force: true,
+ })
+
+ appendContent := `# hello`
+ res, err := container.Exec(ctx, c, ctrName, []string{
+ "sh", "-c",
+ "echo '" + appendContent + "' >> /etc/resolv.conf",
+ })
+ assert.NilError(t, err)
+ assert.Check(t, is.Equal(res.ExitCode, 0))
+
+ // Restart the container.
+ _, err = c.ContainerRestart(ctx, ctrName, client.ContainerRestartOptions{})
+ assert.Assert(t, is.Nil(err))
+
+ // Verify the modification was preserved
+ res, err = container.Exec(ctx, c, ctrName, []string{"tail", "-n", "1", "/etc/resolv.conf"})
+ assert.NilError(t, err)
+ assert.Check(t, is.Equal(res.ExitCode, 0))
+ assert.Check(t, is.Contains(res.Stdout(), appendContent))
+}

2025/11/12 提交 pr https://github.com/moby/moby/pull/51507 后,单元测试啥的都在 github action 里跑过了,然后 docker member 发现一个类似问题:

1
2
3
4
5
6
7
8
$ docker run -d --name hello nginx:alpine
2daa4fc6f6c6c708c394c9b490f80a83269108907b86059d7d472f1f735d8b34
$ docker exec hello sh -c 'echo "nameserver 1.1.1.1" > /etc/resolv.conf'
$ docker exec hello sh -c 'tail -n 1 /etc/resolv.conf'
nameserver 1.1.1.1
$ docker restart hello
$ docker exec hello sh -c 'tail -n 1 /etc/resolv.conf'
# Overrides: []

按照我修复后的代码测试了下,也可以解决这个问题,docker libnetwork 负责人 akerouanton 说我这种重启 /pause 是非标行为,但是上面这种默认桥接网络也会发生,他给我 code review了下。
reivew 评论说 createdByRestart 标志会无效,当 docker 宕机以及类似掉电情况下就无效了,让我去掉这个选项。

仔细想了下确实,意外情况下不会走正常的 restart 流程去应用上这个选项,后面就是讨论和 2025/11/26 合入了,预计 docker v29.0.5 版本带出。

CATALOG
  1. 1. 由来
  2. 2. 过程
    1. 2.1. 日志
    2. 2.2. 最小复现
    3. 2.3. 相关源码
      1. 2.3.1. 断点调试
      2. 2.3.2. 分析
      3. 2.3.3. sandbox change
      4. 2.3.4. containerd 没复现
      5. 2.3.5. 重启偶尔重建Pod
    4. 2.4. 修复