Docker 部署的四个关键决定:数据、密钥、迁移、标签

把小项目装进 Docker,很多人当成「写个 Dockerfile 再 docker compose up」就完事。第一周确实一切正常,直到某次更新:停掉旧容器、拉新镜像、起新容器——数据库空了,用户上传的文件没了。或者反过来,一切正常运行,某天有人拉了镜像随手 docker history 一看,密钥明晃晃躺在里面。
这类事故的共同点在于:容器化不是打包技巧,而是四个必须想清楚的决定——数据放哪、密钥怎么进、数据库迁移什么时候跑、镜像叫什么名字。这篇文章把这四个决定逐个理清。
先理解一个机制:容器 = 只读镜像层 + 一层很薄的可写层。应用运行时写进容器文件系统的所有东西——SQLite 数据库文件、用户上传的图片——都落在那层可写层上,而可写层跟着容器实例走,容器一删,数据就地蒸发。注意是「删」而不是「停」:docker stop 再 docker start,数据还在;docker rm 之后再起新容器,数据就没了。
所以原则只有一句话:容器里不存任何有价值的东西,有价值的都通过挂载落到宿主机。两种挂载方式怎么选:
| 维度 | 命名卷(named volume) | 绑定挂载(bind mount) |
|---|---|---|
| 位置 | Docker 统一管理,宿主机路径不直观 | 宿主机上的明确目录 |
| 备份 | 需先定位卷的实际路径 | 直接进目录拷贝 |
| 权限坑 | 少,Docker 处理初始归属 | 可能遇到 UID 不匹配 |
| 适用 | 生产默认选择 | 需要在宿主机直接看文件时 |
以 SQLite 这类文件型数据库为例,数据库文件所在的整个目录都要挂出来,而且要挂本地磁盘(命名卷或宿主机目录都行)——SQLite 依赖文件锁,放在 NFS 之类的网络文件系统上会出现锁失效导致数据损坏,这是官方文档明确警告过的场景。如果你已经在应用层配好了 WAL 模式和超时参数,容器化本身不冲突,重点是那一对 -wal/-shm 文件和数据库文件在同一个挂载目录里即可。
一个典型的 compose 片段,数据落在命名卷、密钥从环境变量注入(下一节展开):
为什么这么写:卷独立于容器生命周期,更新镜像、重建容器,数据原地不动;镜像用具体版本号的原因在第四节。
最常见的错误写法是在 Dockerfile 里直接 ENV SECRET_KEY=xxx,或者把 .env 文件 COPY 进镜像,之后即使删掉也于事无补——镜像层是不可变的,ENV/ARG 的值会永久留在镜像配置和层元数据里,任何拿到镜像的人跑一下 docker inspect 或 docker history 就能原样取回。Docker 官方甚至为这个错误专门内置了构建检查规则(SecretsUsedInArgOrEnv),明确写着 ARG/ENV 不应用于敏感数据。
正确思路是把「构建期需要什么」和「运行期需要什么」分开:
| 场景 | 正确做法 | 说明 |
|---|---|---|
| 构建时要拉私有依赖 | BuildKit secret 挂载 | 只存在于那一步 RUN 里,不落层 |
| 运行时用 SECRET_KEY、数据库密码 | docker run -e、--env-file 或 compose 的 environment/env_file |
随容器启动注入,不进镜像 |
| 兜底 | .dockerignore 排除 .env、*.pem、密钥目录 |
防止 COPY . . 顺手把密钥打进构建上下文 |
配置层面再守一条线:Django 的 SECRET_KEY、数据库连接串这类值,settings 里一律从环境变量读,代码仓库里永远只有变量名,没有值。
Django 项目容器化时,一个高频困惑是 migrate 放在哪一步。两个位置是明确错的:
一是写在 Dockerfile 的 RUN 里。 构建镜像时数据库通常根本不存在(单机部署时数据库文件还在卷里,跟构建过程毫无关系),这一步要么报错要么跑了寂寞。更本质的原因是:镜像是构建产物,迁移是部署动作,两者生命周期不同——同一个镜像今天部署到 A 环境、明天部署到 B 环境,各自面对的数据库状态不一样,迁移不可能提前「烧」进镜像。
二是让 makemigrations 出现在任何自动化环节。 它属于开发流程:改完模型,开发者在代码库里生成迁移文件、进版本控制。放进构建或启动脚本里自动跑,容易产生不同容器各自生成、迁移文件历史不一致的问题。
正确位置是部署时、启动前:migrate 是幂等的——已应用的迁移再跑一次是无操作,不会重复执行。所以常见做法是交给 entrypoint 脚本,在启动应用服务器之前先跑一遍:
为什么这么写:首启和每次升级都能自动把库结构对齐,exec 让 gunicorn 接管 1 号进程、正确接收信号。多副本同时扩容的场景例外——多个容器并发跑迁移有竞争风险,那时应把迁移拆成独立的先导步骤,跑完再起应用容器;单机小项目没有这个顾虑。
docker run myapp 不写标签时,默认补上的就是 latest,很多人以为它指「最新发布的版本」。实际语义是:最后一次未指定标签的 push。Docker 官方博客专门澄清过这个误解——先推了 1.0.1 再有人推了个不带标签的构建,latest 指向的是后者,可能比 1.0.1 还旧。标签是可变的:任何人都可以把 latest(甚至 v1.0.0)重新指向另一个镜像,它不提供任何版本保证。
后果很具体:出问题时你说不清线上跑的到底是哪个构建;想回滚时,本地的 latest 可能已经被新镜像覆盖。生产环境的做法:
回头看,这四个决定其实是同一条原则的四个切面:镜像负责「程序」,容器负责「跑」,一切有状态的东西——数据、密钥、数据库结构——都要想清楚它存在哪、跟着谁的生命周期走。想清楚这四件事,容器化才算真正省心而不是埋雷。如果你也被其中某一坑过,欢迎转发给还没踩到的同事。
#Docker #Django #部署运维 #SQLite #容器化 #后端开发