diff --git a/docs/basic/docker.md b/docs/basic/docker.md new file mode 100644 index 00000000..4cb12c71 --- /dev/null +++ b/docs/basic/docker.md @@ -0,0 +1,527 @@ +# Docker + +### [0] 猜你想问:Docker和虚拟机有什么不同 + +Docker可以创造出一个隔离的环境,来运行某些程序。说到这里,大家一定会联想到虚拟机。这两种技术有有哪些区别呢? + +虚拟机从物理资源层面将内部系统与宿主机进行隔离。虚拟机技术可以模拟硬件,比如建立一个非常大的文件作为“虚拟硬盘”。此后,你还需要在这一系列模拟的硬件上安装操作系统。内部的操作系统就像使用真实硬件一样来使用这些虚拟硬件,只不过效率会更低。 + +而docker是在应用程序层面将内部系统和宿主机进行隔离。毫无疑问,从安全角度来说,docker的隔离性远低于虚拟机;但换取的优点是部署方便,运行速度较快,方便内外层系统进行文件交换。 + + + +### [1] Docker安装与启用 + +下面的示例统一以linux环境来介绍。对于Windows10及以上的版本,可以通过wsl安装Linux子系统 + +Linux系统的包管理工具(apt, yum, pacman等)一般都直接包含了docker的package,以使用apt的ubunu举例: + +```shell +sudo apt install docker +``` + +如果安装失败,确认网络链接正常时,可以试试先执行下列命令来更新package,然后再执行安装命令: + +``` +sudo apt update +``` + +当安装完成后,可以使用下列命令来检查是否安装成功: + +``` +docker -v +``` + +如果使用的是linux虚拟机或者物理机,可以直接跳过下面一步;如果使用的是wsl,请你务必检查自己wsl的版本,确保是wsl2,方法是在cmd中输入(注意是在windows cmd中,不是wsl linux中)下列命令: + +``` +wsl -l -v +``` + +如果VERSION显示的是1,你可以使用下列命令来转换(使用时将ubuntu替换为你用wsl安装linux时的distribution): + +``` +wsl --set-version ubuntu 2 +``` + +这个过程可能会非常慢,~~建议先去打会儿游戏~~ + +接下来,我们需要启动docker服务,现在linux大多都支持systemctl管理系统服务: + +```c +sudo systemctl status docker.service #查看docker服务状态,如是否启用 +sudo systemctl start docker.service #启动docker服务 +sudo systemctl restart docker.service #重启docker服务 +sudo systemctl enable docker.service #设置docker服务为自动启动 +``` + +如果报错System has not been booted with systemd as init system (PID 1). Can't operate,请修改wsl.conf文件,确保有下列选项: systemd=true (详情可自行google) + +再次强调:**请尽量使用wsl2**。如果坚持使用wsl1,可能出现各种问题,包括但不限于:设置了systemd=true但无法使用systemctl;尝试使用service代替systemctl但运行完后检查status仍然not running,诸如此类。 + +### [2] 拉取镜像&查看本地镜像 + +小明的Windows系统已经安装好了wsl,可以随时启动ubuntu子系统(ubuntu版本为22.04)。但是由于某些无法描述的原因,需要使用20.04上古版本的ubuntu来完成某个实验。碰巧小明有非常懒惰,不想再配置一个虚拟机;而且实验的ddl在3h后,等装完系统恐怕已经过了时辰了!有没有一种简单、快速、有效的方法来部署这样的环境呢? + +就在小明汗流浃背时,他的舍友告诉他可以使用docker,三个愿望一次满足,代价只需要一个蜜雪冰城香芋味美味甜筒。还等什么呢?让我们开始吧! + + + +**注意:执行docker命令在默认情况下需要root权限,如果出现 "Got permission denied while trying to connect to the Docker daemon socket at..."类型的报错,请自行在命令前加入sudo** + +我们先前说过(~~如果没说过的话,那就当我没说~~),docker部署环境可以跳过“安装系统”这个环节,既然如此,我们怎么部署特定系统、特定版本的环境呢?你可以理解为已经有人提前将创建这些环境所需的工具、依赖等打包成了一个个"镜像"(image),上传到了特定的网站上。当我们需要特定版本的系统时,就可以从这个网站上拉去(pull)相应的镜像,将这些镜像下载到本地,供我们使用。 + +``` +sudo docker images #查看本地镜像;刚安装好docker时本地没有任何可用的镜像 +sudo docker pull NAME:VERSION #拉取特定版本的系统镜像。VERSION部分也可以填写latest + #如sudo docker pull ubuntu:latest + #如果只填写NAME,也会默认使用latest选项 +``` + +我们首先要做的就是使用docker pull来获得我们想要的镜像,执行: + +``` +sudo docker pull ubuntu:20.04 +# 可能出现的异常:终端很长一段时间没有反应,然后报错提示无法连接: +# Error response from daemon:Get https://registry-1.docker.io/v2/: net/http:request +# canceled while waiting for connection (Client.Timeout exceeded while awaiting headers) +``` + +如果遇到无法正常pull镜像,但是确保自己网络正常的情况下,可能是docker的镜像源没有正确配置,我们需要修改/etc/docker/daemon.json文件,向其中的registry-mirrors项添加一些镜像源链接([参考博客](https://www.cnblogs.com/blogof-fusu/p/18257012)): + +```json +{ +"registry-mirrors": [ + "https://docker.m.daocloud.io", + "https://dockerproxy.com", + "https://docker.mirrors.ustc.edu.cn", + "https://docker.nju.edu.cn" + ] +} +``` + +**(如果没有daemon.json文件,那就创建一个)** + +然后重启docker服务: + +``` +sudo systemctl restart docker.service +``` + + + +接着我们就能够正常拉取镜像了。用docker images查看,可以发现本地确实增加了相应的image: + +```cmd +cnmd@DESKTOP-A6KM8RF:~$ sudo docker pull ubuntu:20.04 +20.04: Pulling from library/ubuntu +d9802f032d67: Pull complete +Digest: sha256:8e5c4f0285ecbb4ead070431d29b576a530d3166df73ec44affc1cd27555141b +Status: Downloaded newer image for ubuntu:20.04 +docker.io/library/ubuntu:20.04 +cnmd@DESKTOP-A6KM8RF:~$ sudo docker images +REPOSITORY TAG IMAGE ID CREATED SIZE +ubuntu 20.04 6013ae1a63c2 4 months ago 72.8MB +``` + +### [3] 创建容器并运行——从image到container + +第3、4部分是核心内容,字数有点多,但是坚持住,这一切都会很有趣 (确信) + +书接上回,我们成功下载了ubuntu:20.04的镜像,**但是镜像(image)是”死“的,我们不能直接运行,需要根据image来构建容器(container),然后通过运行它**。如果说image只是我们房子的“半成品”,还没有安装门窗;此基础上加装一些特殊的层次才能变成完整的房子——container,供我们使用。事实上,通过一个image,我们可以建立很多个独立的container,我们会在稍后进行实践。 + + + +我们可以使用docker run命令,来根据镜像建立容器。这个命令有很多参数,这里不再详细列出,仅仅给出最常用的形式,[猛戳我查阅](https://docs.docker.com/engine/containers/run/) + +``` +sudo docker run -it --name CONTAINER_NAME IMAGE_NAME:VERSION COMMANDLINE +``` + +其中-it选项让我们能够获得一个交互性的shell 。 + +--name CONTAINER_NAME部分填入指定的容器名称,如果不指定,容器的名称将由系统分配 + +IMAGE:VERSION就填写创建容器的依据,也就是下载到本地的镜像以及版本 + +COMMANDLINE可以指定运行的命令,如果我们想要获得shell,可以填 /bin/bash + +下面是我的运行结果: + +```cmd +cnmd@DESKTOP-A6KM8RF:~$ sudo docker run -it --name myContainer1 ubuntu:20.04 /bin/bash +root@cc27c8e1ad2c:/# +``` + +我给自己的容器命名为myContainer1,并且执行的命令是/bin/bash,加上 -it选项,我们下面立刻获得了命令提示符,表明我们正在以root身份登录这个容器。接下来,你可以随心所欲地执行命令了…… + +但你很快会发现,很多常用的命令,在这里什么都没有。因为我们构造容器的镜像是非常纯净的,没有额外为你安装常用工具,这就需要我们自己动手安装。 + +``` +root@cc27c8e1ad2c:/# gcc -v +bash: gcc: command not found +root@cc27c8e1ad2c:/# apt install gcc +Reading package lists... Done +Building dependency tree +Reading state information... Done +E: Unable to locate package gcc +``` + +但是,这时候使用apt大概率会提示无法找到相关包。我们只需要先更新一次apt package即可(因为我现在是root,所以不需要sudo了): + +```cmd +root@cc27c8e1ad2c:/# apt update +...... +Reading package lists... Done +Building dependency tree +Reading state information... Done +4 packages can be upgraded. Run 'apt list --upgradable' to see them. +root@cc27c8e1ad2c:/# apt install gcc +Reading package lists... Done +Building dependency tree +Reading state information... Done +...... +``` + +很快小明布置好了它的环境,编写了一个输出"Hello World!"的高技术力hello.c文件,编译并运行后,心满意足地通过exit命令(或者Ctrl-D)退出了容器。 + +### [4] 容器的再次运行 + +过了许久后,小明想上机修改它的hello.c程序,增添一些高级的功能,比如多输出一个换行符。可是等他再次执行sudo docker run -it --name myContainer1 ubuntu:20.04 /bin/bash命令时,却得到了报错: + +```systemverilog +docker: Error response from daemon: Conflict. The container name "/myContainer1" is already in use by container "f24f81d9d51130d38386081972b77cf59028601d095fc6381301e2e6b5810e8b". You have to remove (or rename) that container to be able to reuse that name. +See 'docker run --help'. +``` + +名为myContainer1的容器已经被创建了,不能够再创建一个同名的文件。 + +小明恍然大悟:这条命令是用来构建容器的,当容器构建好后,即使我们exit后,它仍然在我们的计算机上。所以我们想要访问它,不必重新造一座房子,只需要礼貌地敲门就可以了。这就需要另一个命令,来运行已经创建好的容器——docker exec. + +``` +sudo docker exec [OPTIONS] CONTAINER_NAME COMMANDLINE +``` + +其中OPTIONS可以像docker run中一样,填入-it来获得交互式shell + +需要注意的是,当我们exit的时候,容器并不一定会立刻stop running,在这种情况下,我们能够直接使用上面的命令,来再次进入容器。但如果容器已经停止运行,那么就需要先将其打开,再执行docker exec命令。 + +当小明输入下列命令时: + +``` +sudo docker exec -it myContainer1 /bin/bash +``` + +出现下列提示:Error response from daemon: Container xxx is not running,表明容器在退出后已经停止运行。我们需要一个命令,来将睡懒觉的容器唤醒,然后敲门,才会有人响应我们。 + + + +先介绍几个额外的docker命令 + +- 使用docker ps来检查容器: + +``` +sudo docker ps [OPTIONS] #查看当前的容器状态 +``` + +如果不加任何参数,就列出当前正在运行的容器(有时候,从容器中exit,但是容器依然会running) + +加上参数-a后会列出本地所有的容器,包括没有运行的容器。 + +- docker启动/停止容器 + + ``` + sudo docker start CONTAINER_NAME + sudo docker restart CONTAINER_NAME + sudo docker stop CONTAINER_NAME + # 分别为启动/重启/停止一个容器 + + ``` + +下面我们开始实验: + +```cmd +cnmd@DESKTOP-A6KM8RF:~$ sudo docker ps +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +cnmd@DESKTOP-A6KM8RF:~$ sudo docker ps -a +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +f24f81d9d511 ubuntu:20.04 "/bin/bash" 34 minutes ago Exited (0) 5 seconds ago myContainer1 +cnmd@DESKTOP-A6KM8RF:~$ sudo docker start myContainer1 +myContainer1 +cnmd@DESKTOP-A6KM8RF:~$ sudo docker ps +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +f24f81d9d511 ubuntu:20.04 "/bin/bash" 35 minutes ago Up 2 seconds myContainer1 +cnmd@DESKTOP-A6KM8RF:~$ sudo docker exec -it myContainer1 /bin/bash +root@f24f81d9d511:/# +``` + +当我们使用docker ps时,并没有输出我们创建的容器,因为所有的容器(虽然目前只有一个)都已经退出了,处于not running状态。 + +但是加上-a标志则表示:我想要看看本地所有的容器,不管它有没有再运行。因此会输出我们刚刚创建的那个容器。 + +当我们使用docker start重新启动myContainer1后,再次使用docker ps,即使没有加-a参数,依然输出了我们的容器。说明它已经正常运作了。 + +接着在执行docker exec命令,可以获得我们想要的终端。我们会发现上次增添的hello.c文件还乖乖地呆在原来的位置。 + + + +最后再补充两个命令: + +- docker rm可以删除一个容器 + + ```cmd + cnmd@DESKTOP-A6KM8RF:~$ sudo docker create --name HowDareYouRemoveMe ubuntu:20.04 + 7ea60417ce696499b404dd48c49b7d05ede3b77c755d86cf2038b233614f35d1 + cnmd@DESKTOP-A6KM8RF:~$ sudo docker ps -a + CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES + 7ea60417ce69 ubuntu:20.04 "/bin/bash" 3 seconds ago Created HowDareYouRemoveMe + f24f81d9d511 ubuntu:20.04 "/bin/bash" 53 minutes ago Up 18 minutes myContainer1 + cnmd@DESKTOP-A6KM8RF:~$ sudo docker rm HowDareYouRemoveMe + HowDareYouRemoveMe + cnmd@DESKTOP-A6KM8RF:~$ sudo docker ps -a + CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES + f24f81d9d511 ubuntu:20.04 "/bin/bash" 53 minutes ago Up 18 minutes myContainer1 + ``` + + 我们先通过docker create创建一个容器(和docker run的区别在于:仅仅创建容器,而不运行命令。以后需要时再用docker exec即可),名为HowDareYouRemoveMe + + 在此之后,我们通过docker ps -a可以发现,现在有2个容器了 + + 在执行docker rm后,再次查看,会发现指定的容器已被删除 + +- docker rename实现重命名,如: + + ```cmd + cnmd@DESKTOP-A6KM8RF:~$ sudo docker rename myContainer1 newName + cnmd@DESKTOP-A6KM8RF:~$ sudo docker ps -a + CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES + f24f81d9d511 ubuntu:20.04 "/bin/bash" 56 minutes ago Up 21 minutes newName + ``` + + + +### [5] 宿主机与容器内部的文件交换 + +由于docker内没有安装vscode,想要编辑hello.c这样的重量级文件对小明来说还是太困难了。于是小明想要将容器里的文件取出来,修改后再复制回去。我们的dockee cp出场了! + +``` +sudo docker cp [OPTIONS] SRC_PATH CONTAINER_NAME:DEST_PATH +sudo docker cp [OPTIONS] CONTAINER_NAME:SRC_PATH DEST_PATH +``` + +小明的操作: + +```cmd +cnmd@DESKTOP-A6KM8RF:~/workplace$ sudo docker cp myContainer1:/root/hello.c . +# 将myContainer1中的/root/hello.c复制到当前目录(.) +cnmd@DESKTOP-A6KM8RF:~/workplace$ ls +hello.c + +...... +# 经历了激烈的修改 +cnmd@DESKTOP-A6KM8RF:~/workplace$ sudo docker cp ./hello.c myContainer1:/root/modify_hello.c +cnmd@DESKTOP-A6KM8RF:~/workplace$ sudo docker exec -it myContainer1 /bin/bash +root@f24f81d9d511:/# cd +root@f24f81d9d511:~# ls +hello hello.c modify_hello.c +``` + +事实上,docker cp不仅能复制单个文件,还能够直接复制目录(即使非空也行): + +```cmd +root@f24f81d9d511:~# mkdir folder +root@f24f81d9d511:~# touch folder/file +root@f24f81d9d511:~# exit +cnmd@DESKTOP-A6KM8RF:~/workplace$ sudo docker cp myContainer1:/root/folder . +cnmd@DESKTOP-A6KM8RF:~/workplace$ ls +folder hello.c +``` + +不过,遗憾的是,docker cp并不能想普通的cp一样,同时复制多个source file。只能通过多次的docker cp,或者直接编写脚本。 + + + +### [6] 创建你自己的镜像——Dockerfile编写 + +(其实这部分docker官方文档写的已经很好了,[传送门](https://docs.docker.com/build/concepts/dockerfile/) ) + +小明发现,每次从ubuntu:20.04创建container的时候,需要使用的一系列工具都要手动编写,他想通过自制镜像解决这个问题。于是他编写了Dockerfile (像Makefile一样大小写不敏感,写成dockerfile也行): + +```dockerfile +FROM ubuntu:20.04 +MAINTAINER xiaoming +RUN apt update +RUN apt -y install gcc +CMD ["echo","xiaoming is a bad girl"] +``` + +docker build命令可以根据Dockerfile建立image,用法如下: + +``` +sudo docker build -t IMAGE_NAME:LABEL DOCKERFILE_DIR +``` + +-t表明 tag,为构建的镜像起名;DOCKERFILE_DIR是Dockerfile所在的目录 + +运行: + +```cmd +cnmd@DESKTOP-A6KM8RF:~/workplace$ sudo docker build -t wtf:v1 . +Sending build context to Docker daemon 3.072kB +Step 1/5 : FROM ubuntu:20.04 + ---> 6013ae1a63c2 +Step 2/5 : MAINTAINER xiaoming + ---> Using cache + ---> dead9c256c4a +Step 3/5 : RUN apt update + ---> Using cache + ---> 60a6e4f911da +Step 4/5 : RUN apt -y install gcc + ---> Running in b491324666a6 +...... +Removing intermediate container b491324666a6 + ---> 6cf786fbbe41 +Step 5/5 : CMD ["echo","xiaoming is a bad girl"]N + ---> Running in 8c7ca65553c0 +Removing intermediate container 8c7ca65553c0 + ---> 7d52ff217484 +Successfully built 7d52ff217484 +Successfully tagged wtf:v1 +cnmd@DESKTOP-A6KM8RF:~/workplace$ sudo docker run -it --name wtf_testcase wtf:v1 /bin/bash +root@a5781f42cedf:/# gcc -v +...... +gcc version 9.4.0 (Ubuntu 9.4.0-1ubuntu1~20.04.2) +root@a5781f42cedf:/# +``` + +docker build -t wtf:v1 . 会自动检测当前目录下的Dockerfile,根据其中的命令创建新的image,将其命名为wtf,并且赋予了一个标签'v1',此后当以新的wtf镜像来创建容器时,环境就自带gcc了。 + +下面让我们更加深入地看看这个Dockerfile到底干了什么: + +```dockerfile +FROM ubuntu:20.04 +# FROM会指定从哪一个镜像作为“地基”开始创建,我们选择了最原始的ubuntu:20.04 +MAINTAINER xiaoming +# MAINTAINER记录了维护者的信息 +RUN apt update +# RUN代表执行的命令 +RUN apt -y install gcc +# 注意看这里的-y参数,因为Dockerfile中的命令都是自动执行的,在询问”是否确认安装“时,谁来回答yes呢?我们可以通过-y选项来对所有的询问都回应yes +CMD ["echo","xiaoming is a bad girl"] +# 设置默认执行的命令。我们之前使用的都指定了/bin/bash,如果不指定,就会执行这里的命令 +``` + +Dockerfile中的命令都是在由Dockerfle生成image的过程中执行的,这个过程可能会耗费一段时间。但是只需要执行一次,一旦创建好镜像,真正的使用过程——即由image到container,则几乎是瞬间完成的。对于需要多次使用的镜像,可以说是稳赚不赔。 + + + +下面是一些常用的Dockerfile命令: + +```dockerfile +##总体## +FROM SRC + #从一个指定的镜像开始构建 +MAINTAINER INFORMATION + #Dockerfile的编写者 + +##文件操作## +ADD file_1 file_2 ... file_n dst + #可以将Docker构建目录(即Dockerfile所在的目录)中文件拷贝到容器中,也可以从url或git仓库链接中拷贝,不过一般不推荐这样做;另外会对压缩文件自动解压 +COPY file_1 file_2 ... file_n dst + #同ADD可以拷贝普通的文件,但是不会进行解压 +WORKDIR DIRECTORY + #设置工作目录(在容器内切换) + +##运行## +RUN CMDLINE + #执行CMDLINE这行命令 +ENV NAME=VALUE + #设置一个(容器内)环境变量的值 +CMD CMDLINE + #默认执行的命令,在docker run或者docker exec时执行,但是会被指定的命令覆盖 +ENTRYPOINT CMDLINE + #强制执行的命令,即使docker run和docker exec指定了执行的命令,这里还是会执行一遍 +``` + + + +### [7] Dockerfile应用赏析 + +最后,我们来赏析一个Dockerfile应用的例子,例子取自github项目diff_fuzz(https://github.com/kenballus/diff_fuzz/blob/main/Dockerfile) + +diff_fuzz项目使用python编写了一个模糊测试分析框架。这样的项目如果直接git clone到本地,还需要安装依赖,配置环境的过程很繁琐。于是作者提供了一种使用Docker快速搭建运行环境的方法。Dockerfile如下: + +```dockerfile +FROM debian:bookworm + +# Install required packages +RUN apt -y update && apt -y upgrade && apt -y install llvm-dev git make meson gcc clang wget pkg-config libglib2.0-dev neovim g++ python3 python3-pip python3-tqdm python3-venv + +WORKDIR /app + +# Download diff_fuzz +RUN git clone 'https://github.com/kenballus/diff_fuzz' + +# Download and build AFL++ with QEMU support +RUN git clone 'https://github.com/AFLplusplus/AFLplusplus' && cd AFLplusplus && git checkout 4.05c && make -j$(nproc) && cd qemu_mode && ./build_qemu_support.sh && cd .. && make install -j$(nproc) + +WORKDIR /app/diff_fuzz + +# Setup a virtual enviroment and install python dependencies +RUN ./setup.sh + +# Use the virtual enviroment +ENV VIRTUAL_ENV=fuzz_env +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +CMD ["make"] +``` + +主要完成的工作有: + +- 从debian:bookworm镜像开始构建 + +- 安装了python3,g++,make等工具与许多会用到的模块, + +- 切换到一个特定的目录下,并且将仓库源码git clone到容器内部 + +- AFL也是需要的一个工具,下载源码并编译安装 + +- 运行配置脚本 setup.sh,设置一些需要的环境变量 + + + +可以发现,所有的准备工作几乎都在构建镜像的时候完成了,这让用户不用为了配置环境而烦恼,享受一键运行的畅快感觉 + +用户只需要将该项目clone到本地: + +```cmd +cnmd@DESKTOP-A6KM8RF:~/workplace$ git clone git@github.com:kenballus/diff_fuzz.git +Cloning into 'diff_fuzz'... +remote: Enumerating objects: 508, done. +remote: Counting objects: 100% (294/294), done. +remote: Compressing objects: 100% (170/170), done. +remote: Total 508 (delta 135), reused 251 (delta 100), pack-reused 214 (from 1) +Receiving objects: 100% (508/508), 121.67 KiB | 619.00 KiB/s, done. +Resolving deltas: 100% (256/256), done. +cnmd@DESKTOP-A6KM8RF:~/workplace$ cd diff_fuzz/ +cnmd@DESKTOP-A6KM8RF:~/workplace/diff_fuzz$ ls +Dockerfile Makefile README_ANALYSIS.md config.defpy re_generate.py setup.sh +LICENSE README.md analyze.py diff_fuzz.py seeds targets +``` + +在项目目录下就存放着Dockerfile,接下来我们用它来生成镜像: + +``` +sudo docker build -t diff_fuzz . +``` + +等待漫长的生成时间后,名为diff_fuzz的镜像就做好了。 + +接下来便可以生成容器: + +``` +sudo docker exec -it --name diff_fuzz_container diff_fuzz:latest /bin/bash +``` + +进入容器后,自动进入/app/diff_fuzz目录,并且所有的所需模块、工具都已经安装完成。 diff --git a/mkdocs.yml b/mkdocs.yml index e5468f52..87fc03a4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -84,6 +84,7 @@ nav: - Git 入门: basic/git.md - Linux 入门: basic/linux.md - Markdown 入门: basic/markdown.md + - Docker入门:basic/docker.md - LaTeX 入门: basic/latex.md - Web 与 Web 应用基础: basic/web.md - 编程语言: