The first car in my life - Fabia - Skoda
目标
k8s 环境下,在不停止或重启 container 的情况下,重启应用进程(pid:1),甚至重新加载运行新版本的应用。本文以 gdb 作为工具,调用应用容器自带的 libc 的 close & exec 函数,去实现这个目标。
背景
K8s 显然已经由兴起转向成熟。大潮过后,是时候思考一下,当初吹过的牛有哪些是真的,哪些是还未对现的。不可否认,k8s 变革了运维的工作方式,这基本是进步的。但对于开发,特别是障碍问题定位、程序调试方法,显然难度是增加了。
在应用的障碍问题定位、程序调试时,我们时常希望能在相同的环境下重复重启应用,去观察我们对配置的修改,或者程序的更新是否真正解决了问题。要实现这个目标,通常需要:
- 修改应用代码,跑 CI pipeline 重新打包 docker image,上传。—— 费时费力 😱
- 想办法重启容器。—— 环境破坏了,问题可能重现不了 😱
如果容器启动脚本设计时就支持重启,当然没问题,但大部分情况下,均是不直接支持的,很多时候,应用主进程就直接是 container 的主进程 pid:1 了。
我研究过三个方法去替换主进程:
- gdb 调用 libc 的 execl 。 这是本文要说的方法。
- gdb 调用 syscall execve 。 这个方法比较复杂,但也更通用,还在研究。
kill -STOP
挂起主进程的父进程- gdb 主进程的父进程,让它收不到
SIGCHLD
警告
由于本文使用了 gdb attach 和非常规方法关闭文件描述符(close(fd))和替换进程执行文件(exec),潜在比较大的未知风险,请不要在生产环境中使用。我也未充分验证这个方法的可靠性,和副作用。包括 close 和 exec 是否能干净清理前任的问题。所以,使用有风险。
思路
- gdb attach 进程
- 调用 close fd ,特别是 socket 相关的,特别是 listen tcp port 的。
- 调用 exec 执行相同的 elf 文件或不同的 elf 文件
步骤
搭建实验目标环境
环境说明:
node: 192.168.122.55
- Ubuntu 22.04.2 LTS
- kernel: 5.4.0-152-generic
- hostname: worknode5
- gdb 9.2
- docker image repo: 192.168.122.1:5000
我使用 tomcat 作为例子:
docker pull tomcat:9.0.76-jre17
docker tag tomcat:9.0.76-jre17 localhost:5000/tomcat:9.0.76-jre17
docker push localhost:5000/tomcat:9.0.76-jre17
运行 pod:
kubectl delete StatefulSet tomcat-worknode5
kubectl apply -f - <<"EOF"
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: tomcat-worknode5
labels:
app: tomcat-worknode5
spec:
replicas: 1
selector:
matchLabels:
app: tomcat-worknode5
template:
metadata:
labels:
app.kubernetes.io/name: tomcat-worknode5
app: tomcat-worknode5
annotations:
sidecar.istio.io/inject: "false"
spec:
restartPolicy: Always
imagePullSecrets:
- name: docker-registry-key
containers:
- name: main-app
image: 192.168.122.1:5000/tomcat:9.0.76-jre17
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
protocol: TCP
name: http
affinity: #make sure runing at worknode5(192.168.122.55)
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: "kubernetes.io/hostname"
operator: In
values:
- "worknode5"
EOF
查看 tomcat 进程
ssh labile@192.168.122.55
# get the pid of tomcat
export POD="tomcat-worknode5-0"
PIDS=$(pgrep java)
while IFS= read -r TPID; do
HN=$(sudo nsenter -u -t $TPID hostname)
if [[ "$HN" = "$POD" ]]; then # space between = is important
sudo nsenter -u -t $TPID hostname
export POD_PID=$TPID
fi
done <<< "$PIDS"
echo $POD_PID
export PID=$POD_PID
# 进程的状态:
➜ ~ ps -f $PID | cat
UID PID PPID C STIME TTY STAT TIME CMD
root 38123 37929 2 07:06 ? Ssl 0:04 /opt/java/openjdk/bin/java -Djava.util.logging.config.file=/usr/local/tomcat/conf/logging.properties -Djava.util.logging.manager=org.apache.juli.ClassLoaderLogManager -Djdk.tls.ephemeralDHKeySize=2048 -Djava.protocol.handler.pkgs=org.apache.catalina.webresources -Dorg.apache.catalina.security.SecurityListener.UMASK=0027 -Dignore.endorsed.dirs= -classpath /usr/local/tomcat/bin/bootstrap.jar:/usr/local/tomcat/bin/tomcat-juli.jar -Dcatalina.base=/usr/local/tomcat -Dcatalina.home=/usr/local/tomcat -Djava.io.tmpdir=/usr/local/tomcat/temp org.apache.catalina.startup.Bootstrap start
# 进程树结构:
➜ ~ ps -ef --forest | grep -B2 $PID
root 37929 1 0 07:06 ? 00:00:00 /usr/bin/containerd-shim-runc-v2 -namespace k8s.io -id 9a43e5d86f29e64eb67ccf2ef19d442e4a06af69def716e181fa52694ec9f43b -address /run/containerd/containerd.sock
65535 37963 37929 0 07:06 ? 00:00:00 \_ /pause
root 38123 37929 1 07:06 ? 00:00:05 \_ /opt/java/openjdk/bin/java -Djava.util.logging.config.file=/usr/local/tomcat/conf/logging.properties -Djava.util.logging.manager=org.apac....local/tomcat -Djava.io.tmpdir=/usr/local/tomcat/temp org.apache.catalina.startup.Bootstrap start
# 可见 socket fd
➜ ~ sudo ls -l /proc/$PID/fd
total 0
lrwx------ 1 root root 64 Jun 18 07:06 0 -> /dev/null
l-wx------ 1 root root 64 Jun 18 07:06 1 -> 'pipe:[181879]'
l-wx------ 1 root root 64 Jun 18 07:07 10 -> /usr/local/tomcat/logs/host-manager.2023-06-18.log
...
lr-x------ 1 root root 64 Jun 18 07:07 19 -> /usr/local/tomcat/lib/tomcat-i18n-es.jar
l-wx------ 1 root root 64 Jun 18 07:06 2 -> 'pipe:[181880]'
lr-x------ 1 root root 64 Jun 18 07:07 20 -> /usr/local/tomcat/lib/tomcat-i18n-cs.jar
...
lrwx------ 1 root root 64 Jun 18 07:07 43 -> 'socket:[182049]'
lrwx------ 1 root root 64 Jun 18 07:07 44 -> 'socket:[182552]'
l-wx------ 1 root root 64 Jun 18 07:07 45 -> /usr/local/tomcat/logs/localhost_access_log.2023-06-18.txt
lrwx------ 1 root root 64 Jun 18 07:07 46 -> 'anon_inode:[eventpoll]'
lrwx------ 1 root root 64 Jun 18 07:07 47 -> 'anon_inode:[eventfd]'
lrwx------ 1 root root 64 Jun 18 07:07 49 -> 'socket:[182059]'
...
l-wx------ 1 root root 64 Jun 18 07:07 9 -> /usr/local/tomcat/logs/manager.2023-06-18.log
➜ ~ sudo strings /proc/$PID/environ
KUBERNETES_SERVICE_PORT_HTTPS=443
KUBERNETES_SERVICE_PORT=443
HOSTNAME=tomcat-worknode5-0
LANGUAGE=en_US:en
JAVA_HOME=/opt/java/openjdk
GPG_KEYS=48F8E69F6390C9F25CFEDCD268248959359E722B A9C5DF4D22E99998D9875A5110C01C5A2F6059E7 DCFD35E0BF8CA7344752DE8B6FB21E8933C60243
HTTPBIN_PORT_80_TCP_PORT=80
PWD=/usr/local/tomcat
TOMCAT_SHA512=028163cbe15367f0ab60e086b0ebc8d774e62d126d82ae9152f863d4680e280b11c9503e3b51ee7089ca9bea1bfa5b535b244a727a3021e5fa72dd7e9569af9a
TOMCAT_MAJOR=9
HOME=/root
LANG=en_US.UTF-8
KUBERNETES_PORT_443_TCP=tcp://10.96.0.1:443
HTTPBIN_SERVICE_PORT=80
TOMCAT_NATIVE_LIBDIR=/usr/local/tomcat/native-jni-lib
CATALINA_HOME=/usr/local/tomcat
SHLVL=0
KUBERNETES_PORT_443_TCP_PROTO=tcp
JDK_JAVA_OPTIONS= --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.rmi/sun.rmi.transport=ALL-UNNAMED
FORTIO_SERVER_SERVICE_PORT_HTTP=8080
KUBERNETES_PORT_443_TCP_ADDR=10.96.0.1
LD_LIBRARY_PATH=/usr/local/tomcat/native-jni-lib
KUBERNETES_SERVICE_HOST=10.96.0.1
LC_ALL=en_US.UTF-8
KUBERNETES_PORT=tcp://10.96.0.1:443
KUBERNETES_PORT_443_TCP_PORT=443
PATH=/usr/local/tomcat/bin:/opt/java/openjdk/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
TOMCAT_VERSION=9.0.76
JAVA_VERSION=jdk-17.0.7+7
➜ ~ sudo nsenter -n -t $PID ss -tpna | grep $PID
# 可见 fd 与 listen port 的对应关系
LISTEN 0 1 [::ffff:127.0.0.1]:8005 *:* users:(("java",pid=38123,fd=49))
LISTEN 0 100 *:8080 *:* users:(("java",pid=38123,fd=43))
上面已经写得比较明细了,其它补充如下:
- 进程的状态:ps 的 STAT 显示
Ssl
。说明见 man ps
D uninterruptible sleep (usually IO)
I Idle kernel thread
R running or runnable (on run queue)
S interruptible sleep (waiting for an event to
complete)
T stopped by job control signal
t stopped by debugger during the tracing
W paging (not valid since the 2.6.xx kernel)
X dead (should never be seen)
Z defunct ("zombie") process, terminated but not
reaped by its parent
For BSD formats and when the stat keyword is used, additional
characters may be displayed:
< high-priority (not nice to other users)
N low-priority (nice to other users)
L has pages locked into memory (for real-time and
custom IO)
s is a session leader
l is multi-threaded (using CLONE_THREAD, like NPTL
pthreads do)
+ is in the foreground process group
- socket listen:
➜ ~ sudo nsenter -n -t $PID ss -tpna | grep $PID
LISTEN 0 1 [::ffff:127.0.0.1]:8005 *:* users:(("java",pid=38123,fd=49))
LISTEN 0 100 *:8080 *:* users:(("java",pid=38123,fd=43))
gdb attach
➜ ~ sudo gdb -p $PID
...
# 下面可以看出,java 进程使用了 libc
0x00007ffff7dea197 in ?? () from target:/lib/x86_64-linux-gnu/libc.so.6
➜ ~ ps -f $PID
# STAT 变为 `tsl`
UID PID PPID C STIME TTY STAT TIME CMD
root 38123 37929 0 07:06 ? tsl 0:06 /opt/java/openjdk/bin/java -Djava.util.logging.config.file=/usr/local/tomcat/conf/logging.properties -Dj
close fd
# fd 0 到 2 作为 stdin/stdout/stderr 不关闭,其它,从 3 开始关闭。
# 上面的 ls -l /proc/$PID/fd 看到,最大 fd 号是 49
(gdb) set $max=49
(gdb) set $current=3
(gdb) while ($current <= $max)
> p (int) close($current++)
>end
# 在我的环境,需要多次执行上面的循环,才真正关闭了所有 fd,不知道为何。
➜ ~ sudo ls -l /proc/$PID/fd
total 0
# socket fd 已经被关闭了
lrwx------ 1 root root 64 Jun 18 07:06 0 -> /dev/null
l-wx------ 1 root root 64 Jun 18 07:06 1 -> 'pipe:[181879]'
l-wx------ 1 root root 64 Jun 18 07:06 2 -> 'pipe:[181880]'
调用 libc 的 execl,替换进程
函数说明:man exec
# 调用的参数来源于之前 ps -f $PID 的输出。注意:函数调用的第一个参数是可执行文件(ELF) 的路径,第二个参数是进程名,不是我们一般命令行的参数,最后一个参数需要补 0 。
# 为验证方便,我加了一个参数:-DgdbRestarted=true
p (int) execl("/opt/java/openjdk/bin/java", "java", "-DgdbRestarted=true", "-Djava.util.logging.config.file=/usr/local/tomcat/conf/logging.properties", "-Djava.util.logging.manager=org.apache.juli.ClassLoaderLogManager", "-Djdk.tls.ephemeralDHKeySize=2048", "-Djava.protocol.handler.pkgs=org.apache.catalina.webresources", "-Dorg.apache.catalina.security.SecurityListener.UMASK=0027", "-Dignore.endorsed.dirs=", "-classpath", "/usr/local/tomcat/bin/bootstrap.jar:/usr/local/tomcat/bin/tomcat-juli.jar", "-Dcatalina.base=/usr/local/tomcat", "-Dcatalina.home=/usr/local/tomcat", "-Djava.io.tmpdir=/usr/local/tomcat/temp", "org.apache.catalina.startup.Bootstrap", "start",0)
检查
➜ ~ ps -f $PID | cat
UID PID PPID C STIME TTY STAT TIME CMD
root 38123 37929 0 07:06 ? ts 0:07 java -DgdbRestarted=true -Djava.util.logging.config.file=/usr/local/tomcat/conf/logging.properties -Djava.util.logging.manager=org.apache.juli.ClassLoaderLogManager -Djdk.tls.ephemeralDHKeySize=2048 -Djava.protocol.handler.pkgs=org.apache.catalina.webresources -Dorg.apache.catalina.security.SecurityListener.UMASK=0027 -Dignore.endorsed.dirs= -classpath /usr/local/tomcat/bin/bootstrap.jar:/usr/local/tomcat/bin/tomcat-juli.jar -Dcatalina.base=/usr/local/tomcat -Dcatalina.home=/usr/local/tomcat -Djava.io.tmpdir=/usr/local/tomcat/temp org.apache.catalina.startup.Bootstrap start
gdb detach
(gdb) detach
替换进程后的检查
➜ ~ ps -f $PID | cat
UID PID PPID C STIME TTY STAT TIME CMD
root 38123 37929 0 07:06 ? Ssl 0:10 java -DgdbRestarted=true -Djava.util.logging.config.file=/usr/local/tomcat/conf/logging.properties -Djava.util.logging.manager=org.apache.juli.ClassLoaderLogManager -Djdk.tls.ephemeralDHKeySize=2048 -Djava.protocol.handler.pkgs=org.apache.catalina.webresources -Dorg.apache.catalina.security.SecurityListener.UMASK=0027 -Dignore.endorsed.dirs= -classpath /usr/local/tomcat/bin/bootstrap.jar:/usr/local/tomcat/bin/tomcat-juli.jar -Dcatalina.base=/usr/local/tomcat -Dcatalina.home=/usr/local/tomcat -Djava.io.tmpdir=/usr/local/tomcat/temp org.apache.catalina.startup.Bootstrap start
➜ ~ sudo ls -l /proc/$PID/fd
total 0
lrwx------ 1 root root 64 Jun 18 07:46 0 -> /dev/null
l-wx------ 1 root root 64 Jun 18 07:46 1 -> 'pipe:[181879]'
l-wx------ 1 root root 64 Jun 18 07:50 10 -> /usr/local/tomcat/logs/host-manager.2023-06-18.log
lr-x------ 1 root root 64 Jun 18 07:50 11 -> /usr/local/tomcat/lib/jaspic-api.jar
...
lr-x------ 1 root root 64 Jun 18 07:50 17 -> /usr/local/tomcat/lib/el-api.jar
lr-x------ 1 root root 64 Jun 18 07:50 18 -> /usr/local/tomcat/lib/ecj-4.20.jar
lr-x------ 1 root root 64 Jun 18 07:50 19 -> /usr/local/tomcat/lib/tomcat-i18n-es.jar
l-wx------ 1 root root 64 Jun 18 07:46 2 -> 'pipe:[181880]'
lr-x------ 1 root root 64 Jun 18 07:50 20 -> /usr/local/tomcat/lib/tomcat-i18n-cs.jar
lr-x------ 1 root root 64 Jun 18 07:50 21 -> /usr/local/tomcat/lib/annotations-api.jar
lr-x------ 1 root root 64 Jun 18 07:50 22 -> /usr/local/tomcat/lib/servlet-api.jar
...
lr-x------ 1 root root 64 Jun 18 07:50 42 -> /usr/local/tomcat/lib/tomcat-i18n-ru.jar
lrwx------ 1 root root 64 Jun 18 07:50 43 -> 'socket:[282010]'
lrwx------ 1 root root 64 Jun 18 07:50 44 -> 'socket:[281414]'
l-wx------ 1 root root 64 Jun 18 07:50 45 -> /usr/local/tomcat/logs/localhost_access_log.2023-06-18.txt
lrwx------ 1 root root 64 Jun 18 07:50 46 -> 'anon_inode:[eventpoll]'
lrwx------ 1 root root 64 Jun 18 07:50 47 -> 'anon_inode:[eventfd]'
lrwx------ 1 root root 64 Jun 18 07:50 49 -> 'socket:[282022]'
lr-x------ 1 root root 64 Jun 18 07:50 5 -> /usr/local/tomcat/bin/bootstrap.jar
lr-x------ 1 root root 64 Jun 18 07:50 6 -> /usr/local/tomcat/bin/commons-daemon.jar
l-wx------ 1 root root 64 Jun 18 07:50 7 -> /usr/local/tomcat/logs/catalina.2023-06-18.log
l-wx------ 1 root root 64 Jun 18 07:50 8 -> /usr/local/tomcat/logs/localhost.2023-06-18.log
l-wx------ 1 root root 64 Jun 18 07:50 9 -> /usr/local/tomcat/logs/manager.2023-06-18.log
➜ ~ sudo nsenter -n -t $PID ss -tpna | grep $PID
LISTEN 0 1 [::ffff:127.0.0.1]:8005 *:* users:(("java",pid=38123,fd=49))
LISTEN 0 100 *:8080 *:* users:(("java",pid=38123,fd=43))
kubectl logs tomcat-worknode5-0 | less
NOTE: Picked up JDK_JAVA_OPTIONS: --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.rmi/sun.rmi.transport=ALL-UNNAMED
18-Jun-2023 07:06:57.361 INFO [main] org.apache.catalina.startup.VersionLoggerListener.log Server version name: Apache Tomcat/9.0.76
18-Jun-2023 07:06:57.388 INFO [main] org.apache.catalina.startup.VersionLoggerListener.log Server built: Jun 5 2023 07:17:04 UTC
18-Jun-2023 07:06:57.389 INFO [main] org.apache.catalina.startup.VersionLoggerListener.log Server version number: 9.0.76.0
18-Jun-2023 07:06:57.389 INFO [main] org.apache.catalina.startup.VersionLoggerListener.log OS Name: Linux
18-Jun-2023 07:06:57.390 INFO [main] org.apache.catalina.startup.VersionLoggerListener.log OS Version: 5.4.0-152-generic
18-Jun-2023 07:06:57.390 INFO [main] org.apache.catalina.startup.VersionLoggerListener.log Architecture: amd64
18-Jun-2023 07:06:57.390 INFO [main] org.apache.catalina.startup.VersionLoggerListener.log Java Home: /opt/java/openjdk
18-Jun-2023 07:06:57.390 INFO [main] org.apache.catalina.startup.VersionLoggerListener.log JVM Version: 17.0.7+7
...
18-Jun-2023 07:06:59.137 INFO [main] org.apache.coyote.AbstractProtocol.init Initializing ProtocolHandler ["http-nio-8080"]
18-Jun-2023 07:06:59.354 INFO [main] org.apache.catalina.startup.Catalina.load Server initialization in [2996] milliseconds
18-Jun-2023 07:06:59.688 INFO [main] org.apache.catalina.core.StandardService.startInternal Starting service [Catalina]
18-Jun-2023 07:06:59.689 INFO [main] org.apache.catalina.core.StandardEngine.startInternal Starting Servlet engine: [Apache Tomcat/9.0.76]
18-Jun-2023 07:06:59.771 INFO [main] org.apache.coyote.AbstractProtocol.start Starting ProtocolHandler ["http-nio-8080"]
18-Jun-2023 07:06:59.845 INFO [main] org.apache.catalina.startup.Catalina.start Server startup in [490] milliseconds
------------- 替换后: --------------
NOTE: Picked up JDK_JAVA_OPTIONS: --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.rmi/sun.rmi.transport=ALL-UNNAMED
18-Jun-2023 07:49:57.887 INFO [main] org.apache.catalina.startup.VersionLoggerListener.log Server version name: Apache Tomcat/9.0.76
18-Jun-2023 07:49:57.894 INFO [main] org.apache.catalina.startup.VersionLoggerListener.log Server built: Jun 5 2023 07:17:04 UTC
18-Jun-2023 07:49:57.894 INFO [main] org.apache.catalina.startup.VersionLoggerListener.log Server version number: 9.0.76.0
18-Jun-2023 07:49:57.895 INFO [main] org.apache.catalina.startup.VersionLoggerListener.log OS Name: Linux
18-Jun-2023 07:49:57.898 INFO [main] org.apache.catalina.startup.VersionLoggerListener.log OS Version: 5.4.0-152-generic
18-Jun-2023 07:49:57.899 INFO [main] org.apache.catalina.startup.VersionLoggerListener.log Architecture: amd64
18-Jun-2023 07:49:57.899 INFO [main] org.apache.catalina.startup.VersionLoggerListener.log Java Home: /opt/java/openjdk
18-Jun-2023 07:49:57.900 INFO [main] org.apache.catalina.startup.VersionLoggerListener.log JVM Version: 17.0.7+7
...
18-Jun-2023 07:49:58.777 INFO [main] org.apache.coyote.AbstractProtocol.init Initializing ProtocolHandler ["http-nio-8080"]
18-Jun-2023 07:49:58.853 INFO [main] org.apache.catalina.startup.Catalina.load Server initialization in [1340] milliseconds
18-Jun-2023 07:49:58.944 INFO [main] org.apache.catalina.core.StandardService.startInternal Starting service [Catalina]
18-Jun-2023 07:49:58.944 INFO [main] org.apache.catalina.core.StandardEngine.startInternal Starting Servlet engine: [Apache Tomcat/9.0.76]
18-Jun-2023 07:49:58.976 INFO [main] org.apache.coyote.AbstractProtocol.start Starting ProtocolHandler ["http-nio-8080"]
18-Jun-2023 07:49:59.009 INFO [main] org.apache.catalina.startup.Catalina.start Server startup in [155] milliseconds
一些使用限制
由于这个方法使用了 libc ,要求目标进程使用了 libc。大多数执行文件会使用 libc 的:
➜ ~ sudo ldd /proc/$PID/root/opt/java/openjdk/bin/java
linux-vdso.so.1 (0x00007ffff7fce000)
libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007ffff7f9e000)
libjli.so => /proc/38123/root/opt/java/openjdk/bin/../lib/libjli.so (0x00007ffff7f8b000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007ffff7f68000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007ffff7f62000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ffff7d70000)
/lib64/ld-linux-x86-64.so.2 (0x00007ffff7fcf000)
但也有一些例外,起码包括:
- Alpine Linux docker image 的 libc 比较特别,我的 gdb 无法识别
- Golang 静态 link 了 libc 的情况
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。