>docker run -d \ --name web \ -p 8080:80 \ --memory=512m \ --cpus=1 \ --restart=unless-stopped \ nginx--restart=unless-stopped常被我称作"基础保命策略":进程崩了,Docker 自动拉起,但手动 stop 的容器不会复活。--memory和--cpus是硬限制,配合docker stats观察资源水位,才算是对容器有了掌控力。
3. Dockerfile 是一份可追溯的构建清单,优化点决定交付质量用docker commit把一个改好的容器提交成镜像,这种做法在校验阶段偶尔用可以,但绝不能成为常规手段。镜像交付必须是声明式的、可复现的,Dockerfile 就是把环境构建的每一步变成显式记录。 3.1 基础镜像不是越全越好选基础镜像的原则很简单:够用就好。同样的应用需要,直接写FROM ubuntu没问题,但更推荐从alpine、debian:slim这类精简镜像入手。不要觉得"系统越全越保险",容器里没有 sshd、没有 vim、没有一堆工具,恰恰说明攻击面小、交付体积小。 同时,避免使用latest。latest是移动靶,这次构建和三个月后构建拉到的可能完全是两个镜像。我习惯写明确版本:alpine:3.20、debian:12-slim,甚至锁定 digest。看起来是小事,但在复现问题、回滚版本时,它能帮你省下大量时间。 3.2 指令顺序与层缓存机制:把变动频率低的放在前面Docker 构建时会逐层检查缓存:如果当前指令和上一层内容都没变,就会复用层缓存,而不是重新执行。利用这个机制,我把依赖安装放在源码拷贝之前: FROM node:20-alpine WORKDIR /app # 先拷贝依赖清单,命中缓存时无需重复安装依赖 COPY package.json package-lock.json ./ RUN npm ci # 最后拷贝源码,源码改动不会破坏前面的缓存 COPY src ./src CMD ["node", "src/index.js"]
如果反着写,每条COPY src都会导致后续所有层缓存失效,每次构建都得重新npm ci,构建时间直接从秒级变成分钟级。这里需要理解一个反直觉的点:指令不会因为上一个指令内容完全没变而自动跳过,它必须结合层缓存一起判断;如果你心里没底,就记住"先稳定后变动"的排列顺序。 3.3 多阶段构建:把编译环境和运行环境分开多阶段构建的核心价值是:构建过程中需要的工具链(编译器、依赖包)很大,但运行期根本用不上。与其费劲在一个镜像里删删减减,不如把"构建"和"运行"分成两个阶段。 以 Go 服务为例: # 构建阶段 FROM golang:1.22-alpine AS builder WORKDIR /build COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o app ./cmd/server # 运行阶段 FROM alpine:3.20 WORKDIR /app COPY --from=builder /build/app ./app RUN addgroup -S appuser && adduser -S appuser -G appuser USER appuser EXPOSE 8080 CMD ["./app"]
最终镜像里只有编译好的二进制和运行时依赖,没有一丁点编译源码或中间产物。很多人觉得多阶段构建复杂,实际操作一遍,看到体积对比和启动体验后,就再也不想用单阶段方案了。 3.4 ENTRYPOINT 和 CMD 的关系,以及 PID 1 的信号问题这两者的关系是:命令行参数会覆盖 CMD,但不会覆盖 ENTRYPOINT。最稳妥的用法是ENTRYPOINT 写固定的主程序,CMD 写默认参数,比如: ENTRYPOINT ["/usr/local/bin/app"] CMD ["--config=/etc/app/config.yaml"]
用户通过docker run追加参数时,可以轻松替换 CMD 部分,而不影响主程序。 容器里的 PID 1 还有一个经典问题:信号转发。默认情况下,发往容器的 SIGTERM 信号不会自动转发给 PID 1,如果主进程没有处理信号,docker stop就会变成SIGKILL,进程可能丢失未落盘的数据。小应用可以挂tini或dumb-init这类轻量 init 进程,把这层信号处理补上。这不影响正常开发,但对"优雅停机"有要求的服务是必须考虑的。 4. 数据与网络:容器重启不可怕,数据丢了才可怕容器是"一次性"的,这句话的潜台词是:删除容器时,容器可写层里的数据会一起消失。对无状态服务来说,删了重建是好事;但对数据库、应用上传文件、日志这种有状态数据,必须主动管理。 4.1 三种挂载方式:匿名卷、命名卷、bind mountdocker run -v /data会创建一个匿名卷,但容器删掉后匿名卷还存在,只是很难再和业务对应起来,容易变成僵尸卷。我推荐至少使用命名卷:
docker volume create pgdata docker run -d -v pgdata:/var/lib/postgresql/data postgres:16
命名卷放在/var/lib/docker/volumes下,容器可以换,数据始终在卷里。备份时可以docker run --rm -v pgdata:/data -v $(pwd):/backup alpine tar czf /backup/pgdata.tar.gz /data,直接在数据层做封存。 还有一种 bind mount,把宿主机目录直接映射进容器,比如开发时热加载源码、把宿主机日志目录挂给容器。好处是看得见、改得方便,坏处是宿主机目录的权限问题容易引发容器内读取异常。我的原则是:开发调试用 bind mount,生产数据用命名卷。 4.2 卷权限问题:postgres 起不来的常见元凶一个非常典型的问题:docker run postgres直接报权限错误,因为 postgres 镜像默认用postgres用户写数据目录,而 bind mount 目录的所有权可能是 root。解决办法不是chmod 777(这是给权限问题打补丁,启动后还有隐患),而是在创建卷时指定正确的属主,或者用支持 user namespace remap 的方式。排查时先看日志,报 permission denied 就检查目录属主和 SELinux/AppArmor 策略。 4.3 网络模型不止 bridge:容器之间怎么通信默认的 bridge 网络里,容器之间通过 IP 可以互通,但 IP 会变化。想让容器通过服务名稳定访问,最好的办法是创建自定义网络: docker network create app-net docker run -d --name web --network app-net nginx docker run -d --name api --network app-net my-api
在自定义网络里,api容器可以直接用web这个名字访问web容器的端口,Docker 内置 DNS 做了服务名解析。这个特性在 compose 项目里尤其重要。 host 模式适合对网络性能极度敏感的场景(比如部分网络转发服务),但缺点很明显:没有隔离网络栈,不能用-p做端口映射。none 模式则完全没有网络,适合拿来跑一些只处理本机文件的临时任务。生产环境默认还是优先自定义 bridge。 4.4 网络排查三板斧遇到容器之间访问不通,我一般按这个顺序排查: docker network inspect app-net,确认两个容器确实在同一网络;- 进入任意容器
docker exec -it api sh,用getent hosts web看能不能解析到服务名; - 确认目标容器监听端口、防火墙规则(容器内和宿主机都要看)。
很多"网络不通"最后都栽在第 1 步:容器明明在同一宿主机,但一个在自定义网络、一个在默认 bridge,IP 互通规则完全不同。先查网络归属,比盲改防火墙靠谱。 5. Compose 把"一堆 docker run"收拢成一份声明式配置,但细节决定成败当服务数量超过五六个,逐个敲docker run已经不可接受了。Compose 用一份 YAML 文件把镜像、端口、卷、网络、环境变量全部定义好,一条docker compose up -d全部拉起。但"声明式"不代表"自动正确",细节仍然要抠。 5.1 文件骨架与常用指令一个最简骨架: services: web: image: nginx:1.25 ports: - "8080:80" networks: - app-net volumes: - ./html:/usr/share/nginx/html:ro depends_on: - api api: image: my-api:1.0.0 environment: - DB_HOST=db networks: - app-net networks: app-net: {} volumes: dbdata: {}
注意我在这里加了一个networks段。很多初学者的 compose 文件根本没定义网络,所有服务会被塞进默认网络,服务名解析在现代版本里依然可用,但显式定义网络后,网络参数、IP 规划、通行策略都更可控,我是建议从一开始就写明白的。 5.2 depends_on 不等于"服务已经就绪",健康检查才是depends_on只是控制启动顺序,它并不能保证依赖服务已经可接受流量。最经典的坑:api 容器启动时连接数据库,数据库容器虽然启动了,但 PostgreSQL 初始化还没完成,api 重试几次失败就崩溃退出了。
解决办法是加健康检查: services: db: image: postgres:16 healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s retries: 10 start_period: 10s api: image: my-api:1.0.0 depends_on: db: condition: service_healthy
这一条配置能救很多人的命。start_period更是关键:它给容器一段"启动宽限期",期间健康检查失败不计入 retries,避免数据库还在高负荷启动就误判不健康。 5.3 环境变量与配置文件分离Compose 写死环境变量是安全反模式。正确做法是用${VAR:-default}语法引用宿主机环境变量,或者用env_file加载独立的变量文件,并把这个文件加入版本忽略列表,不提交到代码仓库。我的习惯: compose.yaml:声明结构,不含密钥;.env:本地方便覆盖的变量(如端口);secrets/:敏感信息单独管理,生产环境走密钥服务。
docker compose config这个命令很多人不用,但它能把你写的 YAML 插值解析成最终配置,调试变量覆盖问题一流。
6. 生产环境里最容易翻车的五个场景,我把排查链路写出来了标题里那句"拿捏的死死的",最终要落到生产环境中能冷静处理问题。下面五个翻车场景,是按出镜频率排的,每一个我都真实见过或者踩过。 6.1 容器启动后立即退出现象:docker ps -a显示容器状态Exited (1)。 排查链路: docker logs 容器名,看最后几条日志。大多数时候错误信息已经写出来了;- 检查启动命令是否是"前台运行"。很多基础镜像需要
nginx -g 'daemon off;',如果你通过docker run ... nginx直接跑默认 CMD,它可能进入了后台模式然后立即退出; - 检查 ENTRYPOINT/CMD 拼接是否正确,命令路径是否存在、权限是否够;
- 若日志为空,尝试临时覆盖
entrypoint: ["/bin/sh"]进容器检查环境变量和挂载目录。
这个场景八成的根因是"没有前台运行"。 6.2 端口冲突与诡异访问不通现象:docker run -p 8080:80报 bind: address already in use,或者容器起来了但访问不了。 排查链路: ss -lntp | grep 8080查宿主机端口占用;被其他进程占着就换端口;docker port 容器名查看实际映射,有些编排工具会改写端口规则;- 如果外部访问不通但容器内
curl 127.0.0.1:80正常,检查宿主机的防火墙和云安全组规则。这个属于把 Docker 跑通了,但网络策略没跟上,属于高频遗漏点。
6.3 日志文件撑爆磁盘现象:磁盘告警,看du发现/var/lib/docker/containers/*/*-json.log堆到几十 GB。 根因:默认json-file日志驱动不设轮转,容器标准输出持续堆积。 解决: { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "5" } }
改完 daemon.json 重启后,对存量容器需要重建才会生效。更严重的设计问题是:大量应用把日志打到 stdout,没有结构化日志链路;我建议在 daemon 层面做好轮转兜底,应用层面再按规范输出到指定文件或直接送日志中心,两层都不能省。 6.4 时区与编码不对现象:容器内date的时间和宿主机差了 8 小时,或者日志中文乱码。 排查链路: - 基础镜像默认 UTC 时区,设置环境变量
TZ=Asia/Shanghai是最省事的方式; - 涉及 Java 应用注意
user.timezone之类的参数覆盖; - 编码问题通常与缺失 locale 有关,Alpine 镜像里尽量补
musl-locales或改用 debian slim 系; - 不要盲目去挂载宿主机的
/etc/localtime,在容器里挂宿主机文件会引入意外耦合,优先用环境变量解决。
6.5 docker system prune 不能无脑跑docker system prune -a会清理所有未使用的镜像和停止的容器,看起来是磁盘救星,但它会连带删除所有未被容器使用的命名卷吗?新版本里,默认不会删卷,但如果你手滑加了--volumes或用了某些图形工具的一键清理,后果可能是数据库持久化数据全没了。
我的建议:生产环境的清理操作必须单独写命令。只清理悬空镜像用docker image prune;清理停止容器用docker container prune;卷清理必须逐个人工确认。宁可磁盘多占用一点,也不要拿数据开玩笑。 7. 从"会用命令"到"拿捏原理":namespace 和 cgroup 的关键认知到这一步,Docker 的命令、文件、网络、Compose 都通了,再往前走一步就是理解它"凭什么能隔离"。这部分不用深入内核源码,只需要建立两个关键认知。 7.1 namespace:让进程以为自己是"唯一"Linux namespace 是容器隔离的基础。进程创建时,Docker 会为它分配新的 namespace: - PID namespace:容器里的进程看到自己是 PID 1,看不到宿主机其他进程;
- Network namespace:容器有独立的网卡、路由表、防火墙规则;
- Mount namespace:容器只能看到自己挂载的文件系统视图;
- UTS namespace:容器有自己的 hostname;
- IPC namespace:隔离进程间通信资源。
类比一下:镜像分层是"文件系统视图的叠加",namespace 是"进程视图的隔离"。容器不是另一个机器,而是同一台 Linux 里一个自成体系的进程组。 7.2 cgroup:限制和统计资源光隔离还不够,还要防一个容器把整台机器吃垮,这就是 cgroup 的职责。--memory=512m --cpus=1最终就是写入 cgroup 对应的控制文件里,由内核强制生效。 理解 cgroup 对排查有很大帮助:容器内存超限被 OOM kill 时,dmesg里能看到 cgroup 相关记录;磁盘统计异常时,/sys/fs/cgroup下的数据比 docker stats 更底细。遇到资源问题不要只会docker stats,切换到底层视角看,往往能更准确判断是容器内问题还是宿主机竞争。 7.3 安全边界:容器不等于安全堡垒共享内核意味着容器逃逸一旦发生,危害范围就是整个宿主机。所以生产环境我会建议做几件事: - 用
USER指定非 root 运行,避免容器内 root 直接对应宿主机 root 权限; - 按需关闭容器 Capabilities,比如
--cap-drop ALL --cap-add NET_BIND_SERVICE; - 文件系统设为只读
--read-only,必要时再挂 tmpfs 到/tmp; - 镜像里不要留调试工具和生产私钥。
这些点不是危言耸听,而是在真实环境里反复验证过的底线。Docker 的便利性容易让人忽略它仍然是共享内核的进程隔离,把权限收得越紧,出事的概率就越低。
这篇文章写到这里,基本把 Docker 从镜像模型、命令执行、Dockerfile 构建、数据网络、Compose 编排到生产排错和底层原理都过了一遍。我在实际使用中一个很深的体会是:学 Docker 不需要把每个命令都背下来,真正要建立的是三个模型——镜像分层的存储模型、容器即进程的隔离模型、声明式配置的编排模型。这三个模型在脑子里立住了,遇到没见过的问题也能顺着日志和原理推出答案。 最后分享一个小技巧:日常调试时可以设置全局的环境别名,比如把docker ps别名成dps,把docker compose logs -f --tail 200别名成dcl。工具效率提升不大,但调试时少敲几个字符,心情确实会好一点。希望这篇长总结能帮你把 Docker 这一块真正拿捏住。 RELATED READING 延伸阅读 更多一线实战笔记与深度复盘,助您持续精进 |