刚上手那会儿,我创建容器基本只有一个姿势:docker run。网上一搜 Nginx 就复制一条docker run -d -p 8080:80 nginx,端口起了、页面能开了,收工。直到后来要一次性准备一堆容器、想在真正启动前先核对一遍网络配置、或者在 CI 里预创建容器而不急着跑进程,才发现 Docker 里负责“创建容器”的命令并不是只有docker run这一条,还有个容易忽略的docker create,而参数之间的搭配和运行模式选择,才是能不能稳定把容器跑起来的关键。
这篇笔记是我实际用下来整理出来的创建容器命令完整梳理,涵盖了docker create和docker run的关系、常用参数拆解、不同场景的命令组织方式、创建后的状态检查与排查思路,最后补了一点 compose 声明式创建容器的经验。适合已经把镜像拉取玩明白、但被各种参数和网络模式搞得眼花缭乱的读者,也适合每天要部署多个容器但不想反复敲长命令的运维。
1. docker create和docker run:创建容器命令的两个入口,很多人只用了半个
1.1 先弄清“创建容器”在 Docker 里到底完成了什么
Docker 里所谓的“创建容器”,本质上是基于一个镜像实例化出一个独立的运行环境。这个过程中,Docker daemon 会为容器分配一个唯一的容器 ID、创建一层可写的容器文件系统、初始化网络命名空间和网络接口、设置 hostname,然后把这些配置固化到容器的元数据里。整套动作做完,容器对象就存在了,但主进程还没有被拉起。
形象一点理解:镜像是模板,容器是拿模板做出来的实例。创建容器相当于把模板裁剪成一台有自己配置的“虚拟小机器”,但还没通电开机。这个“没通电”的状态,在docker ps -a里能看到,状态栏显示Created。而docker ps看不到它,因为默认只显示正在运行的容器。
这个细节很关键。很多人排查问题时只看docker ps,发现容器不在了就以为镜像有问题,其实容器可能一直好端端地躺在Created状态,只是没启动而已。
1.2 docker run 只是 create 加 start 的复合命令
docker run这条命令,实际等于docker create加docker start,如果再带上交互参数,还会自动 attach 到容器主进程上。所以很多时候你想把“创建容器”和“启动容器”两个动作拆开,就得用docker create先做出容器,再手动docker start。
我比较常用的一种调试方式是:
docker create --name debug-nginx -p 8081:80 nginx:1.27 docker inspect debug-nginx | grep -A5 NetworkSettings docker start debug-nginx这样做的好处是:在还没有真正运行之前,你可以用docker inspect完整检查容器创建时的配置,比如端口映射有没有写错、环境变量有没有传全、网络模式是不是自己想要的。发现问题直接docker rm删掉重来,比docker run起来之后发现不对劲再停掉、删掉要干净。
如果哪天你创建了很多容器,但没有启动,也可以统一批量处理:
docker start $(docker ps -a --filter "status=created" -q)这条命令会把所有处于created状态的容器一次性启动,适合批量准备开发环境的场景。
1.3 创建命令的语法结构,以及 IMAGE 后面那个 COMMAND 是干什么的
无论docker create还是docker run,语法结构都一致:
docker create [OPTIONS] IMAGE [COMMAND] [ARG...] docker run [OPTIONS] IMAGE [COMMAND] [ARG...]中括号里的 COMMAND 和 ARG 很多人会忽略,其实它们是用来覆盖镜像默认 CMD 的。比如镜像默认启动的是 Nginx,但你偏要让这个容器启动后去执行一条 shell 命令做测试,就可以这样:
docker create --name pingtest alpine ping 8.8.8.8这种情况下容器的主进程是ping而不是 Alpine 的默认 shell。以后你想复用某个镜像但当工具用,又不想改镜像本身,这个位置就是入口。
需要注意:这个 COMMAND 是容器启动后内部执行的命令,不是在宿主机上执行的命令。它会被直接当成容器的主进程(PID 1)来运行,主进程退出了,容器也就退出了。这是容器和虚拟机最大的运行差异之一。
2. 创建容器时真正要记牢的参数清单
2.1 端口映射:-p 的几种写法,以及一个容易忽略的绑定地址
默认情况下,Docker 创建容器使用的是 bridge 网络,容器有自己独立的 IP(通常在 172.17.0.0/16 网段),容器内的服务只能在容器网络里被访问。宿主机想访问容器里的服务,就得做端口映射,也就是把宿主机某个端口转发到容器的某个端口。
最常用的写法是:
docker run -d --name web -p 8080:80 nginx这里的8080是宿主机端口,80是容器端口。如果想限制只允许本机访问,可以加绑定地址:
docker run -d --name web -p 127.0.0.1:8080:80 nginx这样外部机器无法通过宿主机 IP 的 8080 端口访问,只有宿主机自己能访问到。很多安全要求比较严格的内网服务,我会建议用这种方式,避免服务直接暴露在整张网络上。
多个端口就写多个-p:
docker run -d --name app -p 8080:8080 -p 9090:9090 myapp:latest如果你懒得指定端口,只想让系统随机分配宿主机端口,可以用大写-P,它会把镜像里EXPOSE的端口全部映射到宿主机随机端口。配合docker port命令查看实际映射结果。
这里有个实操经验:创建后的容器端口映射不能直接修改。想换端口,唯一的办法是删掉容器重新创建。所以创建前一定规划好端口,尤其多个项目共用一台宿主机时,端口规划混乱是运维事故的高发源头。
2.2 数据卷挂载:-v 与 --mount,数据不能只留在容器里
容器本身是可写的,但容器层是临时的,容器一删,所有没挂载持久化的数据全部没了。所以数据库、日志、上传文件这类数据,必须挂载到宿主机目录或 Docker 命名卷里。
常用写法:
docker run -d --name web \ -v /srv/www:/usr/share/nginx/html:ro \ nginx-v /srv/www:/usr/share/nginx/html:ro中的前半段是宿主机目录,后半段是容器目录,后置的:ro表示只读挂载。对于 Web 静态文件这种只需要读的场景,只读挂载可以避免容器内进程意外修改宿主机文件。
另一种挂载类型是 Docker 管理的卷:
docker run -d --name mysql8 \ -v mysql-data:/var/lib/mysql \ mysql:8.0这里mysql-data是 Docker 卷名,由 Docker 自动管理,数据存放在 Docker 的数据目录下。好处是备份、迁移、权限管理都更规范,不用关心卷到底落在宿主机哪个目录。
如果你用较新的语法,更推荐--mount:
docker run -d --name web \ --mount type=bind,source=/srv/www,target=/usr/share/nginx/html,readonly \ nginx--mount的语义更清晰,键值对形式不容易写错位置。不过要注意一个坑:--mount type=bind要求宿主机 source 目录必须存在,否则直接创建失败;而-v在某些情况下会自动帮你创建目录。我实际踩过几次,所以脚本里如果用的是--mount,会在前面先mkdir -p。
2.3 环境变量与启动命令:-e、--env-file 和覆盖命令
很多镜像在创建容器时需要通过环境变量来初始化配置。典型的就是 MySQL:
docker run -d --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=root123 \ -e MYSQL_DATABASE=blog \ -v mysql-data:/var/lib/mysql \ mysql:8.0MYSQL_ROOT_PASSWORD是 root 密码,MYSQL_DATABASE是初始化时自动创建的数据库名。漏传任何一个必需变量,MySQL 容器启动后可能反复重启,日志里全是初始化失败信息。
变量多了之后,命令行会变得很长很乱。这时可以用--env-file:
docker run -d --name app --env-file .env myapp:latest.env文件的格式很简单,一行一个KEY=VALUE,不需要加引号。这样做还有个好处:避免密码直接出现在 shell 历史记录里。
覆盖启动命令的场景也很常见。比如你想让一个容器临时去执行某个脚本,但镜像默认的 CMD 不是这个脚本,就可以在镜像名后面直接跟命令:
docker run --rm -v "$PWD":/app -w /app alpine sh -c "echo hello && cat config.txt"-w /app是设置工作目录,相当于在容器内先执行了一次cd /app。这个组合在做一次性数据处理任务时特别方便。
2.4 资源限制:别让一个容器吃垮整台机器
容器共享宿主机内核,默认情况下一个容器能使用宿主机全部 CPU 和内存。这在单机部署多个容器时是灾难级的风险:某个容器内存泄漏,可能把整机拖垮,其他容器全被 OOM。
所以创建容器时,资源限制我几乎必加:
docker run -d --name app \ --memory=1g \ --cpus=0.5 \ --pids-limit=128 \ myapp:latest--memory=1g限制容器最多使用 1GB 内存;--cpus=0.5限制容器最多使用 0.5 个 CPU 核心;--pids-limit=128限制容器内进程数,能有效防止 fork 炸弹一类的问题。
注意一个细节:--memory-swap默认情况下会比--memory大一倍,如果你不想要 swap,建议显式设置跟--memory相同,或者直接关闭 swap。否则容器实际可用的内存可能比你预期的大很多。我见过不止一次,内存明明限了 1GB,容器却跑出了远超 1GB 的用量,一查发现 swap 没限制。
如果内存超限,内核会直接掐死容器的主进程,表现就是容器状态变成Exited(137)或 OOMKilled。这种情况用docker inspect能看到OOMKilled: true。
2.5 命名、网络和重启策略,创建时一次定好
--name给容器起名字,不指定的话 Docker 会随机生成一个名字。生产环境强烈建议显式命名,否则日志和监控里看docker ps的输出全是随机单词,定位问题很痛苦。
--restart是容器退出后的重启策略,可选值有:
| 策略 | 行为 |
|---|---|
no | 容器退出后不自动重启(默认) |
on-failure[:次数] | 非正常退出时自动重启,可限制次数 |
always | 总是自动重启,包括崩溃后、Docker daemon 启动后 |
unless-stopped | 自动重启,但如果手动 stop 过,就不在 daemon 启动时拉起 |
生产环境的守护型服务我一般用--restart=unless-stopped。它和always的区别在于:always在 Docker 服务重启后,即使是手动 stop 过的容器也会被拉起来,这有时会让你莫名其妙地发现某个容器又活了;unless-stopped则尊重手动 stop 的状态。
还需要清楚一点:重启策略只针对容器进程异常退出和 Docker daemon 重启这两种情况,不涵盖docker stop手动停止的容器。手动停了就是停了,除非你手动docker start。
--network决定容器加入哪种网络,这个后面专门讲,但创建时一定要想清楚。
3. 按场景挑命令:后台服务、一次性任务、交互调试与网络模式选择
3.1 后台服务容器:-d 与端口、卷、环境变量、重启策略的组合
最典型的场景是跑一个常驻后台服务,Nginx、MySQL、Redis 这些都属于这种。组织命令时我一般遵循一个顺序:名字、资源限制、重启策略、端口映射、环境变量、数据卷、镜像和 tag。
docker run -d \ --name nginx-web \ --restart=unless-stopped \ --memory=256m --cpus=0.5 \ -p 8080:80 \ -v /srv/www:/usr/share/nginx/html:ro \ nginx:1.27-alpine-d是后台运行模式。没有-d,容器主进程会占据当前终端,输出全部打到屏幕上,关掉终端容器也跟着停。加了-d,Docker 把标准输出接到日志系统,终端释放出来干别的。
MySQL 容器除了端口和卷,还必须注意环境变量:
docker run -d \ --name mysql8 \ --restart=unless-stopped \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=root123 \ -e MYSQL_DATABASE=blog \ -e TZ=Asia/Shanghai \ -v mysql-data:/var/lib/mysql \ mysql:8.0注意时区环境变量TZ=Asia/Shanghai,很多镜像默认时区是 UTC,日志时间比本地时间差 8 小时,排查问题时容易造成错觉。
3.2 一次性任务容器:--rm 和命令覆盖
有些容器是用来跑一次性任务的,比如 CI 里的构建、批量数据处理、临时测试。这种容器跑完就该消失,留着只会占用磁盘空间。用--rm就行:
docker run --rm \ -v "$PWD":/work \ -w /work \ alpine sh -c "echo done > result.txt"--rm的效果是:容器主进程退出时,自动把容器删除。注意,--rm和-d不能同时用,而且它会在容器停止后先执行删除,所以如果你还想docker logs查看输出,--rm会让你没有机会看日志。需要看日志时,就不要加--rm。
3.3 交互式调试容器:-it 与 exec、attach 的区别
调试环境是另一个高频场景。拉一个 Ubuntu 或 Alpine 容器,进去装工具、看文件、跑测试:
docker run -it --name devbox ubuntu:22.04 bash这里的-i是保持标准输入打开,-t是分配一个伪终端。很多人记不清为什么要两个一起写——没有-i,你敲键盘的输入容器收不到;没有-t,命令没有终端语义,连输出颜色和交互控制都没有。所以“进入容器敲命令”的场景,基本就是-it成对出现。
容器已经在后台运行了,想进去执行命令,用docker exec:
docker exec -it nginx-web bashdocker exec是在运行中的容器里额外启动一个新进程,不会影响容器主进程。而docker attach是连接容器主进程的标准输入输出,用起来像是直接坐在容器终端前,但一个不小心按了 Ctrl+C,会把信号发给主进程,容器可能直接退出。生产环境我基本只用exec,很少用attach。
3.4 四种网络模式怎么选,以及 hot 场景下的 host 网络需求
创建容器时,--network决定了容器接入什么样的网络:
| 网络模式 | 说明 | 适用场景 |
|---|---|---|
bridge | 默认模式,容器有独立 IP,通过端口映射对外 | 绝大多数服务容器 |
host | 容器直接使用宿主机网络栈,无独立 IP,-p失效 | 性能敏感、需要大量端口 |
none | 容器没有网络接口 | 离线任务、安全要求极高的场景 |
container:<容器名> | 新容器共享另一个容器的网络栈 | sidecar、服务网格模式 |
host模式经常被误解成“更简单、更高效”。确实它性能高,也不需要端口映射,宿主机能直接访问容器端口。但它的代价是容器失去网络隔离,端口占用直接冲突,创建多个用同端口的服务根本跑不起来。除非明确知道自己在做什么,否则默认还是用 bridge。
有些人在部署 Web 服务时发现 bridge 模式下外部访问不到容器,于是改成--network=host,问题立刻消失。这确实是一种绕开端口映射问题的手段,但属于拆东墙补西墙,更稳妥的做法是检查 bridge 网络的双向访问:
docker network inspect bridge看端口映射和网关配置是否正常,然后再考虑是不是真的需要 host 模式。
4. 容器已经创建了:启动、检查与状态管理
4.1 docker ps 与 docker ps -a:创建结果在哪里看
容器创建完但没有启动时,在docker ps里是看不到的,它只显示running状态的容器。必须用:
docker ps -a才能看到所有容器,包括Created、Exited、Up等各种状态。我排查问题时第一步永远是docker ps -a,先把“容器到底存不存在、处于什么状态”搞清楚,再往下查。
4.2 docker inspect 查看创建时的详细配置
docker inspect是最重要的排障工具之一,它能输出容器的完整 JSON 配置,包括网络、挂载、环境变量、Entrypoint、CMD、重启策略等。想看指定字段时,配合grep或者jq:
docker inspect nginx-web --format '{{.HostConfig.PortBindings}}' docker inspect nginx-web --format '{{json .Config.Env}}' docker inspect nginx-web --format '{{.HostConfig.RestartPolicy.Name}}'使用--format而不是直接看一大坨 JSON,效率高很多。创建阶段如果怀疑参数没生效,用docker inspect一眼就能确认。
4.3 start、restart、stop、rm 对已创建容器的影响
docker start <容器>:启动一个已创建但未运行的容器。docker restart <容器>:停止再启动,适合改了外部配置后让容器重新加载。docker stop <容器>:先给主进程发 SIGTERM,等待宽限期,超时后发 SIGKILL。默认宽限期 10 秒,可用-t调整。docker rm <容器>:删除容器。默认不能删除运行中的容器,需要-f强制;加了--rm创建的容器在停止后会自动删除。
这里有个操作习惯:修改容器配置(端口、环境变量、卷)时,我从来不会去想“改一下正在运行的容器”,因为 Docker 根本不允许。唯一路径是删掉后重新创建。所以重要服务的启动命令我都会整理成脚本或 compose 文件,而不是随手打在命令行里。
4.4 从日志判断容器创建后的运行状态
容器启动失败但状态停在那里时,日志是最直接的证据:
docker logs mysql8 docker logs -f --tail 50 nginx-web-f是跟随输出,--tail 50是只看最后 50 行。创建容器后如果发现有异常,先别删,先用logs看看主进程到底输出了什么。很多时候端口冲突、环境变量缺失、数据目录权限错误,都会直接打印在日志里。
5. 创建容器失败的常见原因与完整排查链路
5.1 容器创建后立即退出:先看状态再查日志
遇到容器创建后立刻退出,不要急着重新docker run。先看状态:
docker ps -a | grep <容器名>如果状态是Exited(1),一般是应用本身报错,比如配置文件缺失、初始化失败。如果状态是Exited(137),基本可以断定是被 OOM 杀了。如果状态是Exited(0),说明主进程正常退出而不是崩溃——比如你可能不小心在镜像名后面跟了一条普通命令,命令执行完进程退出,容器也就结束了。
然后看日志:
docker logs <容器名>日志里会直接告诉你失败原因。MySQL 我见到的典型报错是mysqld: Can't create/write to file,多半是挂载目录权限问题;Nginx 报directory index则多半是 Web 根目录没挂对。
5.2 端口冲突:Bind for 0.0.0.0:8080 failed
这条报错应该是每个用过 Docker 的人都见过的:
docker: Error response from daemon: driver failed programming external connectivity on endpoint web: Bind for 0.0.0.0:8080 failed: port is already allocated.原因很简单:宿主机 8080 端口已经被人占了。排查分两步,先看 Docker 自己有没有占用:
docker ps -a --format "table {{.Names}}\t{{.Ports}}"如果 Docker 里没有占用,再用系统命令看宿主机进程:
ss -ltnp | grep 8080找到占用进程后,要么停掉它,要么换端口重新创建容器。注意:如果你不想让外部访问该服务,完全可以不映射端口,让容器只在 Docker 内网里被其他容器访问。
5.3 镜像问题:Unable to find image 和 tag 漂移
创建容器时报Unable to find image 'nginx:latest' locally是正常的,Docker 发现本地没有就会去拉取。拉取失败时常见原因是镜像名拼错、tag 不存在,或者网络原因导致拉取超时。
这里有个值得养成的习惯:创建容器时指定具体 tag,不用latest。latest是漂移的,今天部署的mysql:latest和三个月后部署的mysql:latest可能完全是两个版本,升级造成的不兼容问题很难排查。我个人在创建命令里一律写死 tag,比如mysql:8.0.36、nginx:1.27-alpine。
5.4 挂载目录权限和平台相关的特殊问题
挂载目录权限问题非常经典。容器内进程如果以非 root 用户运行,而挂载进去的宿主机目录属于 root,那容器内进程就没有写权限。典型报错是 MySQL 提示无法写入数据目录。
解决思路通常是:修改宿主机目录属主,让容器内用户 UID 和宿主机目录属主对应;或者干脆用 Docker 命名卷,它的权限由 Docker 管理,通常不会出现这类权限错位。
Windows 上的 Docker Desktop 也有自己独特的坑。有时候创建容器时网络配置无法生效,报错信息里会出现类似0x8007273f的错误码,这是 Windows 网络子系统层面的异常,常见于 WSL2 网络状态异常或 Docker Desktop 的虚拟网络适配器出问题。我遇到这种情况的基本处理顺序是:先重启 Docker Desktop,再不行就在管理员终端里执行:
netsh winsock reset然后重启系统。这个操作会重置 Windows 的网络编程接口层,很多容器网络创建失败的问题能靠它解决。注意这是一步比较重的操作,会影响宿主机上所有依赖 Winsock 的程序,执行前要确认时机合适。
5.5 资源限制与 OOM 导致的启动失败
最后一种常见失败是创建容器时给了太小的内存限制。容器能启动,但只要业务稍微跑起来就立刻死掉。排查时先用:
docker inspect <容器名> --format '{{.State.OOMKilled}}'如果输出true,说明容器主进程是被内核 OOM 杀死的。这时调整--memory或者--memory-swap限制,把值调大,再重建容器。如果你不想在容量上猜来猜去,先看一段时间docker stats的实时使用量,再确定合理的资源限制。
6. 把创建命令交给 compose:声明式创建多个容器
6.1 为什么容器一多就用 compose 而不是继续敲 docker run
当你要同时跑 Web、数据库、缓存三个服务时,三条docker run也算勉强能维护。但服务多到十几个,或者环境从开发、测试到生产都要重复部署时,命令行方式就变得不可维护了。参数散落在 shell 历史里,新人接手根本不知道当时为什么是这样创建的。
Docker Compose 的解决思路是把容器的创建参数写在一个 YAML 文件里,用一条命令创建和启动整个服务组。文件可以提交到 Git,变更可评审、可回滚,这是命令行方式做不到的。
6.2 一个 compose.yml 的创建实例
以一个简单的 Web 加数据库为例:
services: web: image: nginx:1.27-alpine container_name: nginx-web restart: unless-stopped ports: - "8080:80" volumes: - ./html:/usr/share/nginx/html:ro environment: - TZ=Asia/Shanghai mem_limit: 256m db: image: mysql:8.0 container_name: mysql8 restart: unless-stopped ports: - "3306:3306" environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: blog volumes: - db_data:/var/lib/mysql mem_limit: 1g volumes: db_data:然后在项目目录下执行:
docker compose up -dCompose 会自动创建并启动web和db两个容器,同时创建它们所属的默认网络和声明的db_data卷。docker compose ps可以查看这个服务组的状态,docker compose logs -f可以同时跟踪所有服务日志。
注意一个细节:up -d的语义是“按声明创建并启动”,如果已经启动了,再执行一次会根据文件变更做增量更新。创建阶段如果只是误操作改了个环境变量,重新up -d后容器会按新配置重建。
6.3 compose 与 docker run 的字段对照,以及各自局限
| docker run 写法 | compose 字段 | 说明 |
|---|---|---|
--name | container_name | 容器名 |
-p 8080:80 | ports: - "8080:80" | 端口映射 |
-e KEY=VALUE | environment: KEY=VALUE | 环境变量 |
-v /host:/container | volumes: - /host:/container | 卷挂载 |
--restart=unless-stopped | restart: unless-stopped | 重启策略 |
--memory=1g | mem_limit: 1g | 内存限制 |
--network=xxx | networks: [xxx] | 指定网络 |
Compose 很适合标准化部署单元,但也有局限。一次性的临时命令、需要交互式进去调试的场景、复杂 shell 拼接的场景,直接用docker run更顺手。我的习惯是:正式服务的创建参数一律写成 compose 文件进仓库,本地临时调试随意用docker run --rm。
6.4 一个容易踩的坑:compose down 会不会删数据卷
docker compose down默认会删除服务组对应的容器和网络,但不会删除数据卷,这本来是一个很贴心的设计。但如果你手滑加了-v:
docker compose down -v它会连带把所有声明在该 compose 文件里的卷一起删掉。数据库数据如果只存在卷里,这一步操作就是事故现场。我给自己定的规矩是:任何 compose 环境里,执行down -v之前必须确认已经做好了数据备份,拿不准就不加-v,数据卷留着也不占多少空间。
最后分享一个我自己用下来的小习惯:创建容器前先把镜像 tag 写死拉一遍,docker pull nginx:1.27-alpine,再创建容器。这样能把“拉取超时”和“容器创建失败”两个问题隔离开,排查链路会清晰很多。容器创建这种操作,看着简单,真正稳定地跑起来,靠的还是这些细节上的较真。