Docker基础知识
容器
基本概念
- 容器是一种计算机虚拟化技术,是云原生体系中的重要组成部分,其代表性的应用包括:Docker、Podman等
- 容器是轻量的、可执行的独立软件包,其包含软件运行所需的所有内容:代码、运行时环境、系统工具、系统库和设置
- 容器赋予了软件独立性(具有很好的可移植性),使其免受外在环境差异(例如,开发和测试环境的差异)的影响,通俗的说就是使得软件可以带环境安装
虚拟机
虚拟机可以实现与容器类似的功能,可以在一种操作系统中运行另外的操作系统,虚拟机不受宿主机环境的影响,虚拟机对于宿主机来说也仅仅是普通的文件,对宿主机的其余部分无影响,但是虚拟机技术有以下的缺点:
- 资源占用多
- 体积臃肿
- 启动慢
- 以Docker为代表的Linux容器技术(Linux Containers,缩写为LXC)解决了上述问题
容器虚拟机对比
容器虚拟化的是操作系统而不是硬件,容器之间是共享同一套操作系统资源的,一个运行中的容器本质上是一个特殊的进程
- 容器是一个应用层抽象,用于将代码和依赖资源打包在一起。多个容器可以在同一台机器上运行,共享操作系统内核,但各自作为独立的进程在用户空间中运行。与虚拟机相比,容器占用的空间较少(容器镜像大小通常只有几十兆),瞬间就能完成启动
虚拟机技术则是虚拟出一套硬件后,在其上运行一个完整操作系统。相对来说容器的隔离级别会稍低一些
- 虚拟机 (VM) 是一个物理硬件层抽象,用于将一台服务器变成多台服务器。管理程序允许多个VM在一台机器上运行。每个VM都包含一整套操作系统、一个或多个应用、必要的二进制文件和库资源,因此占用大量空间,消耗大量资源,启动也十分缓慢
容器与虚拟机是可以共存的,不是取代的关系
两者有不同的使用场景。虚拟机更擅长于彻底隔离整个运行环境(隔离更彻底)。例如,云服务提供商通常采用虚拟机技术隔离不同的用户。而 Docker通常用于隔离不同的应用 ,例如前端,后端以及数据库
<img src="https://images.demoli.xyz/image-20210808191333415.png" alt="image-20210808191333415" style="zoom:67%;" />
容器优势
- 相对虚拟机来说,容器更加轻量级,消耗资源少,可以快速启动
可移植性强,在软件开发、测试,运维的各个时期都能一致的运行
因此支持敏捷的DevOps开发,缩短生产周期
所谓的DevOps就是是两个传统角色Dev(Development)和Ops(Operations)的结合,Dev负责开发,Ops负责部署上线,说白了就是要有一个了解Dev的人能把Ops的事干了
- 相对强的隔离性,一个容器出现故障并不会影响其他容器的运行连续性
容器劣势
安全性问题,由于容器的隔离性只是进程级别的隔离,相比较传统虚拟机,容器潜在的安全风险更高。它们需要多种级别的安全措施
- 最常说的一个缺点就是在容器内部执行top命令可以看到宿主机的运行状态
跟Namespace情况类似,Cgoups对资源的限制能力也有很多不完善的地方,其中被提及最多的是/proc文件系统的问题。/proc目录存储着当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件,查看系统以及当前正在运行的进程的信息,比如CPU使用情况、内存占用率等,这些文件也是top指令查看系统信息的主要数据来源。
但是,如果你在容器里执行top指令,就会发现,它显示的信息居然还是宿主机的CPU和内存数据。这是因为/proc文件系统并不知道用户通过Cgroups给这个容器做了什么样的资源限制,所以它返回的还是整个宿主机的。那么这个问题会导致,容器内的应用程序读取到的CPU核数、可用内存等信息还是宿主机的,而不是做了限制之后的。这就是容器相比较于虚拟机另一个不尽如人意的地方
当然,为了解决上面的那个问题。直观的做法就是容器不挂载宿主机的该目录就可以了,可以通过lxcfs来实现隔离,lxcfs在宿主机上维护进程组的信息,然后容器启动的时候将lxcfs维护的进程组信息所在的目录挂载到容器的/proc目录,在容器中获取/proc信息时,实际上获取的是宿主机上对该容器的进程组信息
- 容器对有状态应用,例如数据存储应用是不友好的,一般需要将数据与应用分离,容器一旦关机,其中的数据可能会永久消失
- 容器资源监控,云环境异常复杂,因此需要深度监控安全问题
Docker
基础定义
- Docker是代表性的容器技术,其使用Google公司推出的Go语言进行开发实现,基于Linux内核提供的CGroups功能和Namespace,以及UnionFS等技术实现
- Docker技术最初完全基于传统的LXC构建,但是后续又添加了容器构建,分层镜像以及镜像仓库等功能,这使得容器技术真正流行了起来
Docker优势
- Docker容器基于分层镜像运行,分层镜像可以实现文件共享,可以尽量降低磁盘用量,更快地实现镜像传输以及容器构建
- Docker容器基于开放式标准,能够在所有主流Linux版本、Microsoft Windows以及包括VM、裸机服务器和云在内的任何基础设施上运行
- Docker赋予应用的隔离性不仅限于彼此隔离,还独立于底层的基础设施。Docker默认提供最强的隔离,因此应用出现问题,也只是单个容器的问题,而不会波及到整台机器
- Docker提供了丰富的API,不仅可以在Shell环境下执行操作,也可以使用多种语言进行控制,比如docker-java、go-dockerclient等
Docker劣势
- 进程管理与传统的Linux容器不同,例如终止子进程之后,需要清理孙进程,而对于这类事情,传统 Linux 容器会自行处理。Docker的使用者可以在开始时更改配置文件和设置功能,从而消除这些顾虑
- 容器化下,容器与宿主机共享内核,带来安全隐患
- 在Docker中有些其它Linux子系统和设备未指定命名空间。比如SELinux、Cgroups以及/dev/sd*设备。这意味着,如果攻击者控制了这些子系统,主机也将不保
Docker守护进程也可能成为安全隐患。为使用和运行Docker容器,需要使用Docker守护进程,来为容器提供持续运行时环境。而Docker守护进程需要root权限,Docker守护进程有被劫持攻击的风险
Docker守护程序绑定到Unix套接字而不是TCP端口。默认情况下,Unix套接字由root拥有,而非用户只能通过sudo使用它。如果要以非root用户使用Docker,可参考非Root用户使用Docker
- Podman就不需要使用root权限的守护进程,因此相对Docker更安全一些
Docker使用场景
- 面向产品:产品交付
- 面向开发:简化环境配置
- 面向测试:多版本测试
- 面向运维:环境一致性、DevOps
- 面向架构:自动化扩容(微服务)
Docker与Podman
- 自从Kubernetes宣布不再支持Docker作为容器运行时后,Docker将要被Podman或者Containerd取代的声音就甚嚣尘上
Podman可以管理和运行任何符合OCI(Open Container Initiative)规范的容器和容器镜像。Podman提供了一个与Docker兼容的命令行前端来管理Docker镜像
Podman的命令行工具与Docker类似,比如构建镜像、启停容器等。甚至可以通过
alias docker=podman
可以进行替换。因此,即便使用了Podman,仍然可以使用Docker.io
作为镜像仓库,这也是兼容性最关键的部分二者的区别如下:
- Docker需要一个以root执行的守护进程维持运行,带来了安全隐患,Podman不需要守护程序,也不需要root用户运行,相对安全
在Docker的容器管理体系中,需要多个daemon才能调用到runC(下文有描述),而Podman直接调用runC,通过common这个守护进程作为容器进程的管理工具(不需要root权限)
在Podman体系中,有个称之为common的守护进程,其运行路径通常是/usr/libexec/podman/conmon,它是各个容器进程的父进程,每个容器各有一个,common的父进程则通常是1号进程。Podman中的common其实相当于Docker体系中的containerd-shim
Docker基本概念
镜像
- Docker镜像是一个特殊的
文件系统
,除了提供容器运行时所需的程序、库、资源、配置等文件外,还包含了一些为运行时准备的一些配置参数(如匿名卷、环境变量、用户等) 镜像的设计充分利用联合文件系统的技术,镜像的构建过程就是一层一层叠加文件系统的过程,最顶层的则是镜像运行时,容器内部可以看到的文件内容是镜像的分层文件系统层层叠加的效果
- 联合文件系统的具体实现包括
UnionFS
、aufs
、OverlayFS
等等
- 联合文件系统的具体实现包括
镜像构建时,会一层层构建,前一层是后一层的基础。每一层构建完就不会再发生改变,后一层上的任何改变只发生在自己这一层。
- 比如,删除前一层文件的操作,实际不是真的删除前一层的文件,而是仅在当前层标记为该文件已删除。在最终容器运行的时候,虽然不会看到这个文件,但是实际上该文件会一直跟随镜像。因此,在构建镜像的时候,需要额外小心,每一层尽量只包含该层需要添加的东西,任何额外的东西应该在该层构建结束前清理掉。
- 用户构建镜像(Dockerfile)的每一步操作都会构建一个新的层,因此支持在使用Dockerfile进行容器镜像构建时执行构建回滚,因此也能很好地支持使用Docker执行CI/CD时执行快速版本回退
为什么使用联合文件系统
- 使用联合文件系统的目的之一是使得镜像的构建可以复用之前的文件,增快构建的速度,同时可以支持镜像的自定义改造,即在原有镜像的基础之上加更多的层,实现镜像的自由定制构建
- 多个容器可以共享同一个镜像的文件,减少了容器的启动时间与空间消耗
- 可以对最顶层的可读写层(即容器运行时)的状态进行保存(docker commit产生容器的快照镜像),以实现复用
- 关于镜像的构建,参考[Docker镜像构建]()
容器
- 镜像和容器在逻辑上的关系,就像是面向对象程序设计中的类和实例一样,镜像是静态的定义,容器是镜像运行时的实体
- 容器的实质是宿主机上的进程,但与直接在宿主执行的进程不同,容器进程运行于属于自己的独立的命名空间
- 容器在镜像的基础之上的运行本质上可以理解为在镜像的分层只读文件系统上添加了一个可写层,可以称之为
容器的存储层
,容器存储层的生存周期和容器一样,容器消亡时,容器存储层也随之消亡。因此,任何保存于容器存储层的信息都会随容器删除而丢失 在容器内对镜像文件的读写,是基于copy-on-write技术的
- 所谓的记录删除操作实际上就是对应的文件在容器层就不可见了
- 越顶层的层优先级越高,这也就是为什么在当前层的修改,会覆盖下边的层的修改的以及所有的增删改查都要放到当前最上层进行操作的原因
按照 Docker 最佳实践的要求,容器不应该向其存储层内写入任何数据 ,容器存储层要保持无状态化。所有的文件写入操作,都应该使用数据卷(Volume)、或者绑定宿主目录,在这些位置的读写会跳过容器存储层,直接对宿主(或网络存储---比如ceph的rbd插件)发生读写,其性能和稳定性更高。数据卷的生存周期独立于容器,容器消亡,数据卷不会消亡。因此,使用数据卷后,容器可以随意删除,数据却不会丢失
- 虽然容器的可读写层的生命周期比较短,但是可以在使用容器时使用 docker commit 和 push 指令,保存这个被修改过的可读写层,并上传到 Docker Hub上,供其他人使用
- 参考[容器的持久化方案]()
除了顶层的可读可写层以及底层的只读镜像层之外,还有一个特殊的层--init层,需要被读写,但是又不想被commit提交做增量修改的一个层就是init层(本身应该是只读的镜像层的一部分),专门用来存放/etc/hosts等信息(启动容器时写入hostname)
容器的生命周期
- created:初建状态
docker create
running:运行状态
docker start
或者是docker run
容器启动时的后台逻辑是怎样的?
- docker客户端执行命令,命令通过Unix套接字或者tcp链接的方式请求到docker daemon(也就是dockerd进程)
- dockerd进程通过gRpc向containerd这个标准层发出请求,请求创建一个容器
- containerd首先判断本地仓库是否有对应的镜像,如果有直接用该镜像创建容器,没有的话,需要先拉取镜像
创建一个dockerd-shim进程,并以此进程为父进程创建runc进程运行一个容器进程
- 容器进程使用namespace进行资源命名空间的限制;使用cgroup实现资源的分配与审计
- 容器进程需要挂载对应的联合文件系统(privot_root),也就是镜像层上面加一层可写层
- 如果使用的默认的桥接网络的话,会从网桥的子网IP中选择未使用的IP进行分配
stopped:停止状态
docker stop
- 通过 docker stop 停止容器,其原理是给运行中的容器发 sigterm 信号,如果容器为1号进程接受并处理sigterm,则等待1号进程处理完毕后就退出,如果等待一段时间后还是没有处理,则会通过发送sigkill命令强制终止容器
- paused:暂停状态
docker pause
- deleted:删除状态
docker rm
- created:初建状态
仓库
- 一个集中的存储、分发镜像的服务,Docker Registry 、Docker Hub就是这样的服务
- 使用镜像的标签(tag)进行镜像的版本管理
- 可以使用官方的镜像仓库Docker Hub,或者国内的一些同步的镜像仓库,也可以自己搭建Docker Registry服务,可以结合Docker Auth与keycloak等来执行访问控制与用户控制,参考私有仓库部署
Docker的底层逻辑
虚拟化技术
虚拟化技术是一种资源管理技术,是将计算机的各种实体资源)(CPU、内存、磁盘空间、网络适配器等),予以抽象、转换后呈现出来并可供分割、组合为一个或多个电脑配置环境。由此,打破实体结构间的不可切割的障碍,使用户可以比原本的配置更好的方式来应用这些电脑硬件资源。这些资源的新虚拟部分是不受现有资源的架设方式,地域或物理配置所限制。一般所指的虚拟化资源包括计算能力和数据存储
- 虚拟化的技术是通过硬件的抽象实现更好的分配组合硬件资源,实现资源的最大化利用
LXC虚拟化技术
- LXC,其名称来自Linux 软件容器(Linux Containers)的缩写,一种操作系统层虚拟化(Operating system–level virtualization)技术,实际上是Linux内核容器功能的一个用户空间接口。它将应用软件系统打包成一个软件容器(Container),内含应用软件本身的代码,以及所需要的操作系统核心和库。通过统一的名字空间和共用API来分配不同软件容器的可用硬件资源,创造出应用程序的独立沙箱运行环境,使得 Linux用户可以容易的创建和管理系统或应用容器
与传统虚拟化技术相比,LXC的优势在于:
- 与宿主机使用同一个内核,性能损耗小
- 不需要指令级模拟
- 不需要即时(Just-in-time)编译
- 容器可以在CPU核心的本地运行指令,不需要任何专门的解释机制
- 避免了准虚拟化和系统调用替换中的复杂性
- 轻量级隔离,在隔离的同时还提供共享机制,以实现容器与宿主机的资源共享
Docker并不是完全依赖LXC虚拟化技术,比如镜像的构建与以及容器的构建过程并不是依赖LXC的(Docker在LXC的基础上提供了更加丰富的功能)
- 传统的Linux容器使用init系统来管理多种进程。这意味着,所有应用程序都作为一个整体运行。与此相反,Docker技术鼓励应用程序各自独立运行其进程,并提供相应工具以实现这一功能
namespace
namespace是Linux内核用来隔离内核资源的方式
- 通过namespace可以让一些进程只能看到与自己相关的一部分资源,而另外一些进程也只能看到与它们自己相关的资源(实现资源的限制与隔离),这两拨进程根本就感觉不到对方的存在。具体的实现方式是把一个或多个进程的相关资源指定在同一个namespace中
- Linux namespaces是对全局系统资源的一种封装隔离,使得处于不同 namespace 的进程拥有独立的全局系统资源,改变一个namespace中的系统资源只会影响当前namespace里的进程,对其他namespace 中的进程没有影响
namespace分为以下几种:
PID ns
- 进程编号的隔离,所以容器中的应用的pid为1
Net ns
- 每个net名字空间有独立的网络设备,IP 地址,路由表,/proc/net 目录
User ns
- 每个名字空间可以有不同的用户和组id, 也就是说可以在名字空间内用内部的用户执行程序而非主机上的用户
UTS ns
- UTS(“UNIX Time-sharing System”) 名字空间允许每个名字空间拥有独立的hostname和 domain name, 使其在网络上可以被视作一个独立的节点而非主机上的一个进程
Mount ns
- 类似chroot,将一个进程放到一个特定的目录执行。mnt名字空间允许不同名字空间的进程看到的文件结构不同,这样每个名字空间中的进程所看到的文件目录就被隔离开了。同chroot不同,每个名字空间中的容器在/proc/mounts的信息只包含所在名字空间的mount point
IPC ns
- 不同名字空间的交互采用Linux常见的进程间交互方法(interprocess communication - IPC),包括信号量、消息队列和共享内存等
cgroup
- cgroup是Control Groups的缩写,是Linux内核提供的一种可以限制、分配、记录(统计)、隔离进程组 (process groups)所使用的物理资源(如cpu、memory、i/o等等)的机制
有了namespace之后,为什么还需要cgroup呢:
- namespace 是为了隔离进程组之间的资源,而 cgroup 是为了对一组进程进行统一的资源监控和限制
- 使用了namespace之后,容器对应的进程只能看到当前进程对应的命名空间中的资源,但是从宿主机的角度来看,这只是一个普通的进程,可以和其他进程一起竞争资源,可以使用全部的宿主机资源,这显然不符合容器的概念,因此引入cgroup对容器的资源进行限制与审计
- 在Linux中,cgroup给用户暴露出来的操作接口是文件系统(其实就是通过写配置文件来配置cgroup),即操作接口是以文件和目录的方式组织在操作系统的
/sys/fs/cgroup
路径下
- cgroup的缺陷在于前文说的top命令的问题(隔离不彻底)以及只能限制资源消耗的最大值,而不能隔绝其他程序占用自己的资源
chroot
chroot用来为容器环境挂载特定的根文件系统,chroot(change root filesystem)的作用是指定特定进程的根路径为指定位置,Docker使用此命令指定容器的根目录的位置,同时还会在该目录挂载一个完整的操作系统的文件系统(即使用联合文件系统,共享的镜像层文件)
- chroot只改变当前进程的/,pivot_root改变当前mount namespace的/。pivot_root可以认为是 chroot的改良版
Docker架构
- Docker实际上是一个client/server的架构,如下图所示
<img src="https://images.demoli.xyz/image-20210810233212064.png" alt="image-20210810233212064" style="zoom:80%;" />
客户端
- 平常使用的docker指令就是客户端的一部分,Docker组件向服务端发送请求后,服务端根据请求执行具体的动作将结果返回给Docker,Docker解析服务端的返回结果,并将结果通过命令行标准输出展示给用户。这样一次完整的客户端服务端请求就完成了
客户端与服务端的通信方式
客户端和服务端可以通过Unix套接字通信
- 默认的dockerd(Docker服务端)生成的socket文件存放在
/var/run/docker.sock
,此文件只能由root用户或具有sudo权限的用户访问
- 默认的dockerd(Docker服务端)生成的socket文件存放在
- 采用TCP的方式与服务端通信,配置格式为:
tcp://host:por
,为了保证安全,通常还需要使用TLS认证 - 通过fd文件描述符的方式,配置格式为:
fd://
,这种格式一般用于systemd管理的系统中 - 还可以使用各种语言的sdk与docker的服务端交互,比如docker-java、go-dockerclient等
服务端
服务端实际上是一个叫做
dockerd
的后台进程(也就是上图中的docker daemon
)- 相对来说
dockerd
更多的就是一个server
,用来接收请求和解析请求,真正的容器镜像管理由containerd
维护
- 相对来说
runc
- 实际上是Docker的
libcontainer
这个容器运行时库的改进,是一个用来运行容器的轻量级工具,实现了OCI标准,是利用LXC技术创建容器进程的底层工具
containerd
contained通过contained-shim启动并管理runc,可以说contained是真正管理容器的生命周期的组件,因此该组件也被拆分出来作为单独的项目维护,K8s不再支持Docker后,可以无缝迁移到使用containerd作为容器运行时,containerd可以完成以下任务:
- 镜像管理
- 管理存储相关资源
- 管理网络资源
接受dockerd的请求
- dockerd通过gRPC与containerd通信,由于dockerd与底层的容器运行时runC中间有了 containerd这一OCI标准层,使得dockerd可以确保接口向下兼容
containerd-shim
containerd-shim的主要作用是将containerd和真正的容器进程解耦,使用containerd-shim作为容器进程的父进程,从而实现重启Docker时不影响已经启动的容器进程
**-shim
的意思是垫片,类似于拧螺丝时夹在螺丝和螺母之间的垫片,相当于是一个中间件的功能,类似的组件还有早期版本K8s中的docker-shim
- 当有需求去替换runc运行时工具库时,例如替换为安全容器kata container或Google研发的 gViser,则需要增加对应的shim(kata-shim等),以上两者均有自己实现的shim
- containerd和shim并不是父子进程关系,
runc
启动完容器后本身会直接退出,containerd-shim则会成为容器进程的父进程,负责收集容器进程的状态,上报给containerd,并在容器中pid为1的进程退出后接管容器中的子进程进行清理,确保不会出现僵尸进程
docker-init
在运行容器时可以指定--init参数,向容器中引入docker-init进程,作为1号进程来管理所有的进程(namespace下的所有子进程),例如回收僵尸进程等等
- container-shim只在容器中的pid为1的进程退出后才会发挥作用
ctr
- ctr实际上是containerd-ctr,它是containerd的客户端,主要用来开发和调试,在没有dockerd的环境中,ctr可以充当docker客户端的部分角色,直接向containerd守护进程发送操作容器的请求
docker-proxy
- 容器端口映射的一些iptables配置,由docker-proxy进程完成
容器和镜像标准接口OCI
OCI是Docker公司与CoreOS和Google共同创建的,该接口提供了运行时规范(描述如何运行
filesystem bundle
)与镜像规范(指定了镜像的格式与相关的镜像操作)filesystem bundle(文件系统束): 定义了一种将容器编码为文件系统束的格式,即以某种方式组织的一组文件,并包含所有符合要求的运行时对其执行所有标准操作的必要数据和元数据,即config.json 与 根文件系统
Runc是OCI的参考实现,是Docker的一部分,也是K8s的Kernel API标准
K8s的三层抽象:
Orchestration API -> Container API -> Kernel API
- 资源调度与编排的标准就是k8s
- 容器标准就是CRI
- 容器底层标准就是OCI
容器运行时接口CRI
- CRI是一组K8s定义的对于容器和镜像操作的gRPC接口
Docker本身(借助dockershim)、containerd(cri-plugin)、cri-o就是这个接口的实现
- dockershim显然也是一个垫片,用来实现OCI到CRI的兼容,早期版本的k8s使用dockershim实现对Docker的支持
- containerd本身是从Docker中分离出来的,默认是实现OCI接口的,但是在用到K8S中后,通过加入CRI-Plugin,使其支持CRI接口
- 此接口标准是K8S中的容器接口标准
容器技术的发展展望
- 随着互联网的发展到万物智联,5G、AIoT 等新技术的涌现,随处可见的计算需求已经成为现实。针对不同计算场景,容器运行时会有不同需求。KataContainer、Firecracker、gVisor、Unikernel 等新的容器运行时技术层出不穷,分别解决
安全隔离性
、执行效率
和通用性
三个不同维度的要求。OCI(Open Container Initiative)标准的出现,使不同技术采用一致的方式进行容器生命周期管理,进一步促进了容器引擎技术的持续创新 - 基于MicroVM的安全容器占比将逐渐增加,提供更强的安全隔离能力。虚拟化和容器技术的融合,已成为未来 重要趋势。在公共云上,阿里云容器服务已经提供了对基于KataContainer的阿里云的袋鼠容器引擎支持,可以运行不可信的工作负载,实现安全的多租隔离
- 基于软硬一体设计的
机密计算容器
开始展露头角。比如阿里云安全、系统软件、容器服务团队以及蚂蚁金服可信原生团队共同推出了面向机密计算场景的开源容器运行时技术栈inclavare-containers,支持基于 Intel SGX 机密计算技术的机密容器实现,如蚂蚁金服的Occlum、开源社区的Graphene等 Libary OS。它极大降低了机密计算的技术门槛,简化了可信应用的开发、交付和管理 WebAssembly
作为新一代可移植、轻量化、应用虚拟机,在 IoT,边缘计算,区块链等场景会有广泛的应用前景。 WASM/WASI将会成为一个跨平台容器实现技术。近期Solo.io推出的WebAssembly Hub就将 WASM 应用通过OCI镜像标准进行统一管理和分发,从而更好地应用在Istio服务网格生态中
补充
- 分享一篇有意思的文章:如何区分当前正在使用的shell环境是物理机、虚拟机还是容器
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。