1. 为什么选择在Ubuntu上学习Docker1.1 Ubuntu与Docker的天然亲和力我最早接触容器技术是在一个云服务器上当时跑的是CentOS 7折腾了整整一个下午才把Docker环境弄利索。后来换到Ubuntu之后发现Docker官方对Ubuntu的支持几乎可以称得上是“亲儿子”级别内核版本新、依赖库齐全、社区文档完备遇到问题一搜基本都有答案。如果你正准备入门容器技术我强烈建议直接从Ubuntu开始不管是实体机、虚拟机还是云服务器这套流程都通用。选Ubuntu作为学习Docker的载体背后有几个很实际的原因。首先是内核版本够新Docker依赖的cgroup、namespaces等内核特性在Ubuntu 20.04以上的默认内核中都得到了很好的支持不需要额外编译内核模块。其次是软件包管理体系成熟apt源里的依赖处理非常干净安装Docker时不会遇到那种“缺了libxxx又要手动编译”的尴尬局面。还有一个容易被忽略的点是Ubuntu的用户基数大你在学习过程中踩到的坑大概率已经被别人踩过并且发到了Stack Overflow上。我自己这几年的经验是学习容器技术的过程中环境安装环节往往是劝退新手的第一道坎。Windows上装Docker要处理Hyper-V或WSL2的兼容性问题macOS上要面对Apple Silicon的架构适配问题而Ubuntu上的安装路径相对平直很多。这也是为什么很多Docker官方教程和书籍都默认以Ubuntu作为演示环境的原因。1.2 学习Docker之前需要理解的几个核心概念在动手敲命令之前我建议你先花半小时把几个核心概念弄明白。不是说你要背诵每个底层实现细节而是至少搞清楚它们之间的关系否则后面操作的时候会产生很多困惑。Docker本质上是一个容器运行时管理工具它利用Linux内核的namespace做资源隔离、用cgroup做资源限制再通过联合文件系统UnionFS实现镜像的分层存储。这一整套机制使得容器看起来像一个轻量级的虚拟机但相比虚拟机少了对硬件资源的模拟所以启动速度可以达到毫秒级单台机器上能跑几十上百个容器。镜像Image和容器Container的关系可以理解为“类”与“实例”的关系。镜像是只读的模板里面包含了运行某个应用所需的一切内容代码、运行时环境、系统库、配置等。当你用docker run命令运行一个镜像时Docker会在镜像层之上添加一个可写层这个运行中的实例就是容器。你对容器做的所有修改都只存在于这个可写层中一旦容器被删除这些修改也就随之消失。数据卷Volume是容器持久化数据的方式。因为镜像层是只读的容器删除后数据就会丢失所以你需要把容器内的重要目录挂载到宿主机上。这个在后面实战部分我会详细演示。网络方面Docker默认提供bridge、host、none三种网络模式默认的bridge模式会让容器通过一个虚拟网桥与宿主机通信从外部访问容器内的服务则需要做端口映射。理解这些概念你就能读懂绝大部分Docker命令的用途了。2. Ubuntu上安装Docker的三种实战方式2.1 方式一使用官方APT仓库安装推荐这是我最推荐的一种方式也是生产环境中用得最多的安装方式。相比直接apt install docker.io添加Docker官方APT源能够确保你安装的是最新稳定版而且之后的升级也只需要apt upgrade就能搞定。先说明一下很多人会在教程里看到apt install docker.io这种写法这是Ubuntu官方仓库内置的Docker版本。问题在于这个版本的更新往往滞后可能落后官方好几个大版本。在学习和使用过程中你可能会遇到一些新特性不生效的情况所以还是建议走官方源。安装前先更新一下软件包索引然后安装一些必要的依赖sudo apt update sudo apt install -y apt-transport-https ca-certificates curl gnupg lsb-release接下来添加Docker官方的GPG密钥和APT仓库curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg echo deb [arch$(dpkg --print-architecture) signed-by/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable | sudo tee /etc/apt/sources.list.d/docker.list /dev/null注意上面命令中的$(lsb_release -cs)会自动获取你当前Ubuntu的版本代号比如focal、jammy、noble。如果你在较老的Ubuntu版本上执行可能会提示lsb_release命令不存在那就先执行sudo apt install lsb-release装一下。最后执行安装sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin这里我额外安装了docker-compose-plugin它提供了docker compose子命令注意是带空格的子命令跟独立的docker-compose可执行文件不同。后面编排多容器应用时会用到建议一起装好。安装完成后启动Docker服务并设置开机自启sudo systemctl enable docker --now用sudo systemctl status docker或者sudo docker version验证一下安装结果。看到Client和Server两段版本信息都正常输出就说明Docker已经跑起来了。2.2 方式二使用官方脚本快速安装如果你只是想快速搭个环境做实验不太在意版本管理的精细度可以使用Docker官方提供的自动化安装脚本curl -fsSL https://get.docker.com -o get-docker.sh sudo sh get-docker.sh这个脚本会检测你的操作系统版本自动配置好APT源并安装所有依赖。整个过程基本不需要人工干预几分钟就能装完。脚本执行结束后同样用sudo systemctl enable docker --now启动服务。不过坦白讲我不太推荐在生产环境或正式服务器上直接用脚本安装因为你不知道脚本具体做了什么操作、改写了哪些配置文件。在虚拟机或者自己折腾的测试机上用脚本无所谓出了问题重装系统就行。生产环境还是老老实实按方式一来每一步都在自己掌控中。2.3 方式三离线环境下的二进制安装有些时候你的Ubuntu机器处于内网环境无法访问外网下载软件包。这种场景在政企项目里很常见。这时候可以在一台能联网的机器上下载Docker的静态二进制包然后拷贝到内网机器上解压使用。Docker官方提供了静态二进制包下载地址是https://download.docker.com/linux/static/stable/x86_64/选择合适版本比如docker-24.0.7.tgz下载后解压tar xzvf docker-24.0.7.tgz sudo cp docker/* /usr/bin/然后编写一个简单的systemd服务文件/etc/systemd/system/docker.service内容大致是[Unit] DescriptionDocker Daemon Afternetwork-online.target [Service] Typenotify ExecStart/usr/bin/dockerd ExecReload/bin/kill -s HUP $MAINPID LimitNOFILE1048576 LimitNPROCinfinity LimitCOREinfinity [Install] WantedBymulti-user.target保存后执行sudo systemctl daemon-reload sudo systemctl enable docker --now这种方式适合特殊场景对新手来说了解即可大部分情况下用前两种方式就够了。2.4 安装后的环境检查与用户权限配置安装完成不等于万事大吉我习惯性会做两个额外操作。第一个是验证Docker能否正常运行一个测试容器sudo docker run hello-world如果看到Hello from Docker!的输出说明整个链路是通的。这个命令会从Docker Hub拉取一个极小的测试镜像并在容器中运行是验证安装是否成功最直接的方式。第二个操作是配置用户权限。Docker守护进程默认绑定在/var/run/docker.sock这个Unix套接字上只有root用户和docker组的成员才能访问。每次敲命令都带sudo很烦把自己加入docker组可以免去这一步sudo usermod -aG docker $USER newgrp docker退出重新登录后你就可以直接执行docker ps而不需要加sudo了。注意把用户加入docker组实际上等同于授予该用户root权限因为可以通过挂载宿主机的/var/run/docker.sock来操控宿主机。个人开发机这么做没问题但团队共享的服务器上要慎重。3. Docker核心操作构建、运行与排错3.1 镜像管理拉取、查看、删除与导出镜像在Docker中的地位就像虚拟机中的模板文件。Docker Hub是官方的镜像仓库里面存放着海量的公共镜像比如Ubuntu、Nginx、MySQL、Redis等。拉取一个镜像的命令是docker pull ubuntu:22.04这里ubuntu是镜像名22.04是标签Tag用来区分不同版本。如果不写标签默认拉取latest标签的镜像。查看本地已有的镜像列表docker images这个命令会显示镜像的仓库名、标签、镜像ID、创建时间、大小。镜像ID是一个64位的十六进制字符串通常我们在命令中可以只用前几位来指代。删除镜像的命令是docker rmi ubuntu:22.04需要注意的是如果某个镜像还有正在运行或已退出但未删除的容器引用删除会失败。这时候要么先删除对应容器要么用docker rmi -f强制删除不建议容易留下悬空镜像。镜像导出和导入也是经常用到的操作尤其是离线环境下传输镜像时docker save -o ubuntu.tar ubuntu:22.04 docker load -i ubuntu.tarsave和load是用来打包镜像的而export和import则是针对容器的两者不要搞混。还有一个小命令docker search可以用来在命令行搜索Docker Hub上的镜像。实际使用中我倒是很少用因为直接在网页端搜索看排名和描述更直观还能看到官方认证和star数量。3.2 容器生命周期管理容器管理是日常操作最频繁的部分。首先是运行一个容器docker run -it --name my_ubuntu ubuntu:22.04 /bin/bash这个命令拆开来看-it是以交互模式并分配一个伪终端--name给容器起名字ubuntu:22.04指定镜像/bin/bash是容器启动后要执行的命令。执行后你会直接进入容器的shell可以像操作一台新装的Ubuntu系统一样敲命令。退出容器时如果直接输入exit容器会停止运行因为它的主进程是bashbash退出后容器也就没有存在的意义了。如果只想退出终端但让容器继续在后台运行可以按CtrlP再按CtrlQ。对于已经存在的容器有几个常用操作docker ps # 查看正在运行的容器 docker ps -a # 查看所有容器包括已停止的 docker start my_ubuntu # 启动已停止的容器 docker stop my_ubuntu # 停止运行的容器 docker restart my_ubuntu # 重启容器 docker rm my_ubuntu # 删除已停止的容器如果你希望容器在后台保持运行又不直接进入交互终端docker run -d --name nginx_demo -p 8080:80 nginx-d参数表示daemon模式容器在后台运行命令执行完只会返回一个容器ID。-p 8080:80的意思是把宿主机的8080端口映射到容器内的80端口。这样你在浏览器访问http://宿主机IP:8080就能看到Nginx的欢迎页面了。查看容器日志是排查问题最常用的手段docker logs -f nginx_demo-f参数表示实时跟随日志输出类似tail -f。加--tail 200能只看最近200行日志量大时有用的。3.3 数据持久化数据卷与绑定挂载前面提到过容器删除后数据就没了。在真实场景中数据库的数据文件、应用生成的日志都不应该存在容器可写层里必须做持久化。Docker提供了三种挂载方式但日常用到的其实就两种数据卷Volume和绑定挂载Bind Mount。数据卷是Docker自己管理的一块存储区域使用方式docker volume create mydata docker run -d --name mysql_demo -v mydata:/var/lib/mysql mysql:8.0这里-v mydata:/var/lib/mysql的意思是把名为mydata的卷挂载到容器内的/var/lib/mysql目录。删除容器时卷不会自动删除下次再创建一个容器挂载同一个卷数据还在。绑定挂载则是直接把宿主机的某个目录映射到容器里docker run -d --name nginx_demo -p 8080:80 -v /home/user/html:/usr/share/nginx/html nginx这样你直接在宿主机上修改/home/user/html里的文件容器内的Nginx就会立刻响应新的内容非常适合开发调试场景。注意绑定挂载时如果宿主机目录不存在Docker会自动创建但目录的所有者可能是root。如果你遇到“Permission denied”之类的权限问题多半就是目录所有者不对用sudo chown -R 用户:组 目录调整一下即可。3.4 网络模式与端口映射Docker的网络模型对新手来说有一点门槛但理解之后排查问题会顺畅很多。默认的bridge模式下Docker会在宿主机上创建一个名为docker0的虚拟网桥并为每个容器分配一个内网IP通常是172.17.0.x段。容器与容器之间可以通过这个内网IP互相通信但容器无法被宿主机外部直接访问必须通过端口映射。端口映射的格式是宿主机IP:宿主机端口:容器端口通常写宿主机端口:容器端口就行宿主机IP默认绑定0.0.0.0即所有网卡docker run -d --name web -p 8080:80 nginx这个命令将容器的80端口映射到宿主机的8080端口。外部用户访问http://宿主机IP:8080请求会被Docker的iptables规则转发到容器的80端口。host网络模式下容器不会做网络隔离直接使用宿主机的网络栈。这种模式性能损耗最低但容器内端口会直接占用宿主机端口且不支持端口映射。还有一个在开发中很有用的自定义网络docker network create mynet docker run -d --name db --network mynet mysql:8.0 docker run -d --name app --network mynet myapp同一个自定义网络里的容器可以通过容器名直接互相访问。比如在app容器里连接数据库完全不用关心数据库容器的IP是多少直接用db:3306就能连上。这个特性在多容器编排时特别实用。4. 常用Docker实践从单容器到多容器4.1 实战在Docker中运行MySQL光说理论记不住还是得动手跑几个服务。我拿MySQL举例因为这是大家最熟悉的数据库而且踩坑点比较有代表性。运行一个MySQL 8.0容器的命令docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD123456 \ -e MYSQL_DATABASEtestdb \ -v mysql_data:/var/lib/mysql \ mysql:8.0-e参数是传递环境变量MySQL镜像会根据这些变量来初始化root密码和创建的默认数据库。-v mysql_data:/var/lib/mysql挂载数据卷保证容器删除重建后数据不丢。等待几秒让MySQL完成初始化然后查看日志确认启动成功docker logs mysql8看到ready for connections就说明已经起来了。用容器内的MySQL客户端连上去测一下docker exec -it mysql8 mysql -uroot -p这里docker exec是在运行中的容器内执行命令。输完密码进入MySQL交互界面后show databases;如果能看到testdb说明一切正常。有两个坑我想提醒一下。第一个是MySQL 8.0的默认认证插件是caching_sha2_password老客户端比如Python的mysqlclient库、旧版Navicat会连不上报Authentication plugin caching_sha2_password cannot be loaded。解决办法是在运行容器时加一个参数docker run -d --name mysql8 -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD123456 \ -v mysql_data:/var/lib/mysql \ mysql:8.0 --default-authentication-pluginmysql_native_password第二个坑是容器内的MySQL默认只监听3306端口并且root用户只允许localhost登录。如果你用宿主机或者外部工具连不上先检查docker ps确认端口映射生效没再进容器里看看MySQL用户配置SELECT user, host FROM mysql.user;如果发现root的host不是%需要手动创建远程用户或者改host。4.2 实战构建自己的第一个镜像拉别人的镜像只是入门学会构建自己的镜像才是真正的转折点。Dockerfile是描述镜像构建步骤的文本文件在里面写好一系列指令Docker会按顺序执行并生成一个镜像。看一个最简单的Python Web服务DockerfileFROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple COPY . . EXPOSE 8000 CMD [python, app.py]逐行解释一下FROM指定基础镜像这里用的是Python 3.11的slim版本精简版体积更小。WORKDIR设置工作目录后续的COPY、RUN、CMD都会在这个目录下执行。COPY把宿主机上的文件复制到镜像里。要注意的是COPY的源路径是相对于构建上下文的所以一会儿构建时指定的目录很关键。RUN在镜像构建过程中执行的命令通常用来安装依赖。EXPOSE声明容器运行时监听的端口这只是文档性质的声明并不代表真的会映射端口。CMD指定容器启动时执行的命令。一个Dockerfile只能有一个有效的CMD指令如果有多个只有最后一个生效。写好后在Dockerfile所在目录执行构建docker build -t my-python-app:v1 .注意命令最后有一个点这个点代表构建上下文是当前目录。构建上下文里的文件都会被发送到Docker守护进程所以尽量避免把不需要的大文件放在构建目录里否则构建会变慢。我见过有人把整个node_modules目录放在项目根目录然后直接docker build .构建过程卡到怀疑人生。正确做法是用.dockerignore文件排除掉这些目录写法类似.gitignore。构建完成后用docker run my-python-app:v1启动访问一下就能看到你的服务跑在容器里了。这里有个技巧本地调试时可以不用-d直接前台运行方便看日志输出。4.3 使用Docker Compose编排多容器应用当应用开始依赖多个服务时比如一个Web项目同时用到Nginx、后端应用和数据库逐个docker run就变得很不现实。这时候就用到了Docker Compose。Docker Compose的核心是一个docker-compose.yml文件用YAML格式描述整个应用包含哪些服务、每个服务用什么镜像、端口如何映射、环境变量如何配置、依赖关系如何定义。我举个例子一个简单的Web应用需要MySQL和Redisversion: 3.8 services: db: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: 123456 MYSQL_DATABASE: appdb volumes: - db_data:/var/lib/mysql ports: - 3306:3306 redis: image: redis:7-alpine app: build: . depends_on: - db - redis environment: DB_HOST: db REDIS_HOST: redis ports: - 8000:8000写了这个文件之后在同一个目录下执行docker compose up -dDocker就会按照配置把三个服务都拉起来。-d后台运行--build参数表示启动前先构建镜像。查看所有服务状态用docker compose ps查看某个服务日志用docker compose logs app停止并删除所有资源用docker compose down。注意depends_on只控制启动顺序不保证依赖服务已经就绪。比如app依赖db但db可能还要十几秒才能初始化完成。如果你在app里一启动就要连数据库可能会连接失败。解决办法是应用层面做重试或者使用healthcheck机制让Compose等数据库健康后再启动app。这个属于中高级玩法新手先了解有这回事就行。Compose还有一个我觉得特别实用的功能是docker compose exec它让你在运行中的服务容器里执行命令比如docker compose exec app python manage.py migrate不用记容器ID直接用服务名就可以了。5. 常见问题与排查技巧实录5.1 权限问题排查Docker相关的权限问题我遇到最多的就是permission denied while trying to connect to the Docker daemon socket。这个报错的字面意思是“尝试连接Docker守护进程套接字时权限被拒绝”。遇到这个报错先做两件事。第一确认当前用户是不是在docker组里groups命令能查看第二确认docker.sock的文件权限ls -l /var/run/docker.sock看属主和属组。解决办法就是把用户加入docker组然后重新登录。有些教程会让你执行sudo chmod 666 /var/run/docker.sock来开放权限这个做法千万别学等于把Docker的完全控制权开放给所有用户安全风险非常大。5.2 容器网络不通容器启动后宿主机访问不了服务这是新手最容易卡住的点之一。排查思路按照从外到内的顺序来先看端口映射是否生效docker ps看PORTS那一列如果是0.0.0.0:8080-80/tcp说明映射成功了。如果只有80/tcp说明没有映射端口需要删掉重建容器或者在启动时加上-p参数。再看容器内部的服务是否正常docker exec -it 容器名 /bin/bash curl localhost:80容器内能通但外部不能通问题出在防火墙。检查一下系统防火墙是否拦截了对应端口。Ubuntu默认防火墙是ufw可能没有开放端口sudo ufw status sudo ufw allow 8080/tcp如果这些都没问题最后检查一下监听的IP地址。有些服务默认只监听127.0.0.1比如Redis默认配置。在容器内再怎么配外部都访问不了必须在启动命令或配置文件里改成0.0.0.0。5.3 磁盘空间被镜像占满Docker用久了会发现磁盘空间越来越小这是镜像、容器、数据卷和构建缓存共同膨胀的结果。几个常用命令帮你清理docker system df这条命令会列出镜像、容器、卷和构建缓存分别占用了多少磁盘。接下来针对性清理docker system prune这个命令会删除所有已停止的容器、所有未被使用的网络、所有悬空镜像没有标签且没有被容器引用的镜像和构建缓存。加-a参数会更激进所有未被容器使用的镜像都会被删除包括那些你想保留下来的。我的习惯是先不带-a跑一次过一段时间看情况再彻底清理。如果只是清理构建过程中的临时缓存用docker builder prune这个指令删除的是BuildKit构建缓存有些镜像构建过程会产生非常多的中间层缓存占几个GB都有可能。5.4 与Ubuntu版本相关的坑Ubuntu版本不同踩的坑确实有差异。有一个经典的坑Ubuntu 22.04的iptables默认是nftables架构Docker在设置端口转发时偶尔会报类似iptables: No chain/target/match by that name的错误。解决办法是切换成传统的iptablessudo update-alternatives --set iptables /usr/sbin/iptables-legacy sudo update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy sudo systemctl restart docker还有一个坑是在旧版Ubuntu18.04及以下上安装新版Docker时可能会遇到libcgroup或者overlayfs相关的依赖问题。旧系统的内核可能不支持Docker新版要求的overlay2存储驱动这时候要么升级系统要么在/etc/docker/daemon.json里显式指定存储驱动为vfs不推荐性能差很多。6. 个人学习建议与后续进阶方向6.1 我的学习路径建议学Docker这件事我觉得最忌讳的就是只看教程不实操。很多概念光看文字觉得理解了一上手操作就原形毕露。我建议你拿到这篇文章后照着从安装到运行的流程完整走一遍别跳步。等基础命令都熟悉了可以尝试自己搭建一个简单的个人博客或者API服务用Docker跑起来这个过程中你自然就会遇到数据持久化、环境变量配置这些真实问题。一个比较靠谱的学习路径是先把docker run、docker ps、docker exec、docker logs这几个命令用到熟练然后学习Dockerfile的编写再去了解数据卷和网络最后上Compose编排。不要一上来就去研究Kubernetes、Service Mesh这些万丈高楼从地起容器基础没有打牢就去追热点很容易学得一头雾水。另外我强烈建议在本地装一个Ubuntu虚拟机来练习特别是只用Windows的同学。WSL2其实也可以跑Docker但它在systemd支持和网络处理上跟原生Linux有些细微差别。如果你遇到“怎么跟教程里不太一样”的情况检查一下是不是运行环境的差异导致的。6.2 几个值得深入的方向如果基础操作已经熟练了接下来可以往这几个方向深入第一个是Dockerfile优化。比如使用多阶段构建来减小最终镜像体积理解层缓存机制来加速构建合理使用COPY和RUN顺序来充分利用缓存。一个几百MB的镜像优化到几十MB是常态这对部署速度和存储成本的影响是非常明显的。第二个是容器的资源限制。用--memory和--cpus参数限制容器可以使用的内存和CPU避免某一个容器把整台机器吃垮。在docker-compose.yml里也有对应的配置项deploy.resources.limits。第三个是容器化应用的日志管理。默认情况下容器日志会输出到宿主机的/var/lib/docker/containers/容器ID/目录下。可以配置json-file日志驱动的轮转策略或者把日志收集到集中式日志平台。第四个是Docker与CI/CD的结合。现在主流的GitLab CI、GitHub Actions都提供了Docker相关的Runner和Action学会用Docker镜像来封装构建环境能让持续集成的流水线更干净、更可复现。最后再分享一个小技巧写Dockerfile的时候记得把COPY的文件尽可能缩小到最小范围然后利用好层缓存。我见过很多人在改了源码之后重新build因为Dockerfile里写的是COPY . .导致每次都把整个上下文重新打进镜像。正确的做法是把依赖文件和源码分开拷贝这样改源码的时候不会把已经装好的依赖层全部推翻重来。这个小习惯能帮你省下大量的构建时间和网络流量。