阅读视图

发现新文章,点击刷新页面。
☑️ ⭐

ComfyUI简介

ComfyUI 是一个基于节点工作流的现代化 Stable Diffusion 图形用户界面。与传统的WebUI不同,ComfyUI采用节点连接的方式来构建图像生成工作流,让用户能够更精确地控制整个生成过程。

Stable Diffusion 是一款开源的 AI 图像生成技术,基于扩散模型构建。用户可以通过 Stable Diffusion WebUIComfyUI 等开源工具来运行它,只需下载相应的模型文件(通常为 .ckpt.safetensors 格式)即可开始使用。

核心概念

ComfyUI中的图像生成涉及三个关键组件,在CheckpointLoader中进行设置:

  • CLIP:将文本提示转化为主模型可以理解的向量形式
  • 主模型(Main MODEL):执行实际的图像生成计算
  • VAE(变分自编码器):将主模型的潜在空间格式转化为最终可视的图片

安装和配置

1. 下载和安装

GitHub 下载对应版本,解压后运行:

  • run_nvidia_gpu.bat(推荐NVIDIA GPU用户)
  • ./python_embeded/python -s ComfyUI/main.py --windows-standalone-build

2. 安装管理器和插件

下载 ComfyUI-Manager 放到 ComfyUI/custom_nodes 文件夹,然后在Manager的Custom Node Manager中安装所需插件(需要科学上网)。

3. 下载模型

将模型文件放置到 ComfyUI/models 文件夹中:

  • Checkpoint模型checkpoints 文件夹
  • LoRA模型loras 文件夹
  • VAE模型vae 文件夹

推荐入门模型:SD 1.5

模型资源网站:

4. 网络配置

如果遇到网络连接问题,可以使用SwitchHosts添加以下配置:

1
2
3
4
185.199.108.133 raw.githubusercontent.com
185.199.108.133 user-images.githubusercontent.com
185.199.108.133 avatars2.githubusercontent.com
185.199.108.133 avatars1.githubusercontent.com

扩展功能

ComfyUI_StoryDiffusion

通过Custom Node Manager安装,然后执行以下命令安装依赖:

1
2
./python_embeded/python.exe -m pip install -r ../ComfyUI_windows_portable/ComfyUI/custom_nodes/ComfyUI_StoryDiffusion/requirements.txt
./python_embeded/python.exe -m pip install opencv-python

API操作

HTTP API方式

  1. 启用开发者选项
  2. 将设置好的Workflow导出为API格式
  3. 创建任务:
    1
    2
    3
    curl -X POST 'http://127.0.0.1:8188/prompt' \
    -H 'Content-Type: application/json' \
    -d '{"prompt": API文件的内容}'
  4. 查询结果:curl -X GET 'http://127.0.0.1:8188/history/{prompt_id}'
  5. 获取图片:http://127.0.0.1:8188/view?filename=ComfyUI_00003_.png&subfolder&type=output

WebSocket方式

更简单的实时通信方式:

1
2
3
4
5
// 建立连接
ws://127.0.0.1:8188/ws?clientId=23333

// 提交任务
{"client_id": "23333", "prompt": "API文件的内容"}

提示:如果不想折腾本地环境,可以考虑使用腾讯云等平台提供的按时计费ComfyUI服务。

参考资料

官方资源

学习教程

🔲 ⭐

利用whisper为视频自动生成字幕

whisper是一个由openai开发的通用语言识别模型,我们可以使用它来为视频自动创建字幕。

环境安装

为了加速,我们需要使用GPU来进行计算,因此需要安装基于CUDA的pytorch。首先我们需要安装Miniconda,这里安装的时候直接点击下一步即可。

安装完毕之后,我们需要创建一个新的环境,这里我们创建一个名为whisper的环境:

conda create -n whisper python=3.8conda activate whisper

1. 安装CUDA

安装好了Miniconda之后,我们需要安装CUDA,执行nvidia-smi

$ nvidia-smiThu Jan  2 11:49:53 2025+-----------------------------------------------------------------------------------------+| NVIDIA-SMI 560.94                 Driver Version: 560.94         CUDA Version: 12.6     ||-----------------------------------------+------------------------+----------------------+| GPU  Name                  Driver-Model | Bus-Id          Disp.A | Volatile Uncorr. ECC || Fan  Temp   Perf          Pwr:Usage/Cap |           Memory-Usage | GPU-Util  Compute M. ||                                         |                        |               MIG M. ||=========================================+========================+======================||   0  NVIDIA GeForce GTX 1060 6GB  WDDM  |   00000000:01:00.0  On |                  N/A ||  0%   39C    P8             10W /  120W |     505MiB /   6144MiB |      0%      Default ||                                         |                        |                  N/A |+-----------------------------------------+------------------------+----------------------+

通过这个命令可以看到Driver Version: 560.94CUDA Version: 12.6,因此我们需要安装12.6版本的CUDA,更加详细的版本对照表在这里。在安装的时候可以选择自定义安装选项,一般来说只要勾选CUDA下的 Development和Runtime即可。

安装完毕之后执行命令nvcc -V查看CUDA版本:

$ nvcc -Vnvcc: NVIDIA (R) Cuda compiler driverCopyright (c) 2005-2024 NVIDIA CorporationBuilt on Thu_Sep_12_02:55:00_Pacific_Daylight_Time_2024Cuda compilation tools, release 12.6, V12.6.77Build cuda_12.6.r12.6/compiler.34841621_0

2. 安装cuDNN

根据自己下载的CUDA来选择对应版本的cuDNN,下载地址在这里。下载完毕之后解压到CUDA的安装目录下,一般来说是C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA{版本号},如果有重名的文件直接替换即可。

之后进入extras\demo_suite目录,执行如下命令:

bandwidthTest.exedeviceQuery.exe

如果出现了PASS的字样,说明安装成功。

3. 安装pytorch

切换到我们之前创建的whisper环境,使用如下命令安装CUDA版本的pytorch:

pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118

安装之后执行python命令进入python环境,执行如下代码:

1
2
import torch
torch.cuda.is_available()

如果显示True则说明CUDA版本的pytorch安装成功。

4. 安装whisper

切换到我们之前创建的whisper环境,执行如下命令安装whisper:

pip install -U openai-whisperpip install setuptools-rust

安装完毕之后执行如下命令就可以使用whisper了:

whisper 'C:/Users/raymond/Desktop/voice.aac' --language zh --model turbo

如上命令表示对C:/Users/raymond/Desktop/voice.aac文件进行中文语言的识别,使用turbo模型。第一次执行该命令会下载模型文件,模型文件较大,下载时请确保网络通畅。执行结果如下

[00:00.000 --> 00:03.060] 提到肉毒毒素[00:03.060 --> 00:04.540] 你会想到什么[00:04.540 --> 00:10.820] 你真的了解它吗[00:10.820 --> 00:12.540] 2017年[00:12.540 --> 00:14.180] 肉毒毒素以万能药标签[00:14.180 --> 00:15.500] 登上时代周刊方面[00:15.500 --> 00:17.280] 目前它在全球[00:17.280 --> 00:18.960] 已被应用于几十种适应症[00:18.960 --> 00:20.560] 仅在2019年[00:20.560 --> 00:23.000] 接受注射的就已超过620万例[00:23.000 --> 00:24.880] 但不要忘了[00:24.880 --> 00:26.780] 肉毒毒素更是一种神经毒素[00:26.780 --> 00:29.000] 还曾被当作生化武器使用... 省略 ...

生成字幕

我们可以使用ffmpeg将音频从视频中提取出来,然后使用whisper生成字幕,最后使用ffmpeg将字幕添加到视频中。

使用如下命令提取音频:

ffmpeg -i input.mp4 -vn -acodec copy output.aac

然后使用whisper生成字幕,我们先在pycharm中创建一个test-whisper项目,并且把python解释器设置为Miniconda创建的whisper环境。创建一个main.py文件,写入如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import whisper
from whisper.utils import get_writer

root = 'E:/'

# 使用turbo模型
model = whisper.load_model('turbo')
prompt = '如果使用了中文,请使用简体中文来表示文本内容'

# 选择声音文件,识别中文,并且打印详细信息
result = model.transcribe(root + 'output.aac', language='zh', initial_prompt=prompt, verbose=True)
print(result['text'])

# 保存字幕文件
writer = get_writer('srt', root)
writer(result, 'output.srt')

如上代码表示使用turbo模型,识别中文,打印详细信息,并且保存字幕文件。执行完毕之后我们可以在E:/目录下看到生成的字幕文件。

最后我们使用ffmpeg将字幕添加到视频中:

ffmpeg -i input.mp4 -i output.srt -c:s mov_text -c:v copy -c:a copy output.mp4

之后我们在播放这个视频的时候就会有字幕了。

参考

video-subtitle-generator
基于Anaconda的pytorch-cuda
CUDA与cuDNN的安装与配置
ffmpeg视频合并、格式转换、截图

🔲 ⭐

ffmpeg笔记

合并一个文件夹内的所有视频
1
2
3
4
5
find *.mp4 | sed 's:\ :\\\ :g'| sed 's/^/file /' > fl.txt
ffmpeg -f concat -i fl.txt -c copy output.mp4
// 忽略错误信息
ffmpeg -safe 0 -f concat -i fl.txt -c copy output.mp4
rm fl.txt

参考资源

视频压缩
1
2
3
4
5
6
// 视频使用h.264编码,声音使用aac编码
ffmpeg -i input.mp4 -vcodec h264 -acodec aac output.mp4
// 视频使用h.265编码,压缩到更小文档
ffmpeg -i input.mp4 -vcodec libx265 -crf 28 output.mp4
// 视频使用h.264编码,保留更好的质量
ffmpeg -i input.mp4 -vcodec libx264 -crf 20 output.mp4

crf越小,视频质量越高;crf越大,视频文件越小

编码参数也可以简写,从-vcodec-acodec改为-c:v-c:a

1
2
3
ffmpeg -i input.mp4 -c:v libx264 -crf 23 output.mp4
ffmpeg -i input.mp4 -c:v libx265 -crf 28 output.mp4
ffmpeg -i input.mp4 -c:v libvpx-vp9 -crf 31 -b:v 0 output.mkv

参考资源

其中AVC/H264HEVC/H265都是软件编码,速度很慢。可以选择英伟达的硬件编码:hevc_nvenc与h264_nvenc,它们使用硬件加速,速度很快。

参考资源

使用英伟达显卡进行编码:

1
ffmpeg -i video.mp4 -c:v hevc_nvenc -crf 28 output.mp4

将视频从H.264转码到H.265,花了55分钟,视频体积从3.8GB减小到430MB,效果立竿见影。转码命令:ffmpeg -i 1.mp4 -c:v libx265 -vtag hvc1 -c:a copy 1_hevc.mp4

在win10可以用scoop安装ffmpeg,更新Windows上面通过scoop安装的所有程序
scoop list | foreach { scoop update $_.Name }

将视频以同样的编码,按照指定时间进行裁剪

1
ffmpeg -ss 00:05 -to 08:53.500 -i ./input.mp4 -c copy video.mp4

利用ffmpeg快速剪辑视频

1
ffmpeg -ss 07:18 -to 13:45 -i ./aaa.mkv -c copy bbb.mkv
  • -ss表示开始时间
  • -to表示结束时间
  • -i是输入文档
  • -c表示使用被剪辑视频一样的编码
  • bbb是输出文档的名称

合并视频和声音,视频使用原始编码,声音改为aac编码

1
ffmpeg -i 1.mp4 -i 1.opus -c:v copy -c:a aac output.mp4

将PNG格式图片转为JPG格式图片

1
ffmpeg -i image.png -preset ultrafast image.jpg

修改图片的尺寸

1
2
ffmpeg -i image.jpeg -vf scale=413:626 2寸.jpeg
ffmpeg -i image.jpeg -vf scale=390:567 1寸.jpeg

将一个音频重复10次

1
ffmpeg -stream_loop 10 -i input.m4a -c copy output.m4a
☑️ ⭐

如何参与Apache顶级开源项目

我们在日常工作中经常会使用到很多的开源项目,开源也是一个在工作和学习中都离不开的内容。一般来说,开源项目可以选择直接开源,也可以选择捐赠给某些基金会,例如Linux FoundationCNCFASF等等。以ASF为例,如果一个项目想要成为顶级项目,则需要先通过孵化器孵化,孵化结束毕业才能成为顶级项目。最近我因为一些原因参与了云原生网关APISIX开源项目,这里做一下介绍。

搭建环境

首先我们需要下载源代码并且构建开发流程,根据官网介绍,我们把项目代码fork到自己的仓库并clone到本地,随后在本地仓库中将原始的项目设置为上游upstream,之后新建分支进行开发即可

~ git clone git@github.com:RitterHou/apisix.git~ cd apisix~ git remote -v~ git remote add upstream https://github.com/apache/apisix.git~ git config --global user.name "derobukal"~ git config --global user.email "derobukal@gmail.com"~ git fetch upstream~ git checkout master~ git rebase upstream/master~ git push origin master~ git checkout -b issue-10484~ cd ..

根据官方教程,我们还需要安装APISIX的开发环境注1

~ export https_proxy="http://192.168.65.100:7890"~ export http_proxy="http://192.168.65.100:7890"~ wget https://raw.githubusercontent.com/apache/apisix/master/utils/install-dependencies.sh~ APISIX_RUNTIME='1.1.1' bash install-dependencies.sh~ cd apisix~ make deps~ sudo mkdir /usr/local/apisix~ sudo mkdir /usr/local/apisix/logs~ sudo chown raymond:raymond /usr/local/apisix -R~ sudo make install

以及安装和启动etcd

~ ETCD_VERSION='3.4.18' wget https://github.com/etcd-io/etcd/releases/download/v${ETCD_VERSION}/etcd-v${ETCD_VERSION}-linux-amd64.tar.gz && tar -xvf etcd-v${ETCD_VERSION}-linux-amd64.tar.gz && cd etcd-v${ETCD_VERSION}-linux-amd64 && sudo cp -a etcd etcdctl /usr/bin/~ cd ..~ etcd

最简单的PR

搭建好了环境之后,就可以修改代码并提交PR了。一般来说开源项目都会有多种类型的改动,以apisix为例,它限制了PR可以为固定的几种类型

featfixdocsstylerefactorperftestbuildcichorerevertchange

包含了文档修改、新功能、bug修复等等,我们选择比较简单的文档修改作为第一个PR。我在使用APISIX的过程中,发现它证书相关的文档中存在一个错误

在上图中,这里的地址不应该是/hello而应该是/get,因此我们可以在本地仓库新建一个分支并对这个问题作出修改。修改后我们将这次改动提交(为了使提交更加的清晰和安全,建议合并commit并对commit进行签名)并push到我们自己的远程仓库,随后在GitHub上面创建一个针对原始仓库的Pull Request。一般项目在创建一个PR的时候都会有模板信息,照着模板信息进行填写即可,填写完毕就可以提交PR并等待项目的成员进行处理了

修复issue中的问题

在APISIX中有着很多的ISSUE

我们可以关注这些ISSUE看有没有自己能解决的问题。一般来说,带有good first issue标签的都是比较简单并且适合新人的,我们可以优先从这些issue中寻找自己能解决的问题

在确定了issue之后,我们可以请求管理员将这个issue分配给自己,防止别人也会去解决这个问题从而浪费时间。当然,有的时候issue即使已经被分配给别人了,但是如果他一直没有解决这个问题,我们仍然可以请求将这个issue分配给自己

搭建测试环境

对于issue-10484,因为涉及到代码更改,因此需要执行测试。APISIX使用了test-nginx框架来执行测试,官方有关于如何进行测试的介绍

我们需要安装测试框架注2

sudo cpan Test::Nginx

并下载依赖测试模块

➜  apisix git:(issue-10484) git clone https://github.com/api7/test-toolkit/ t/toolkit

修改完代码之后可以执行相关的测试

~ PATH=/usr/local/openresty/nginx/sbin:/usr/bin PERL5LIB=.:$PERL5LIB FLUSH_ETCD=1 prove -Itest-nginx/lib -r t/admin/ssl2.tt/admin/ssl2.t .. ok    All tests successful.Files=1, Tests=52, 11 wallclock secs ( 0.06 usr  0.01 sys +  3.61 cusr  1.05 csys =  4.73 CPU)Result: PASS

如果测试没有问题就可以提交PR了。

通过CI的测试

apisix使用GitHub的workflow进行CI执行测试,以redhat-ci.yaml为例,它会在多个平台上执行多个文件夹内的测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
jobs:
test_apisix:
name: run ci on redhat ubi
runs-on: ubuntu-20.04
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
events_module:
- lua-resty-worker-events
- lua-resty-events
test_dir:
- t/plugin/[a-k]*
- t/plugin/[l-z]*
- t/admin t/cli t/config-center-yaml t/control t/core t/debug t/discovery t/error_page t/misc
- t/node t/pubsub t/router t/script t/secret t/stream-node t/utils t/xds-library

如上所示,GitHub action中的matrix默认会对其下面的的多个选项进行合并,例如redhat-ci.yamlevents_moduletest_dir组合起来就会构成8个执行脚本

lua-resty-worker-events t/plugin/[a-k]*lua-resty-worker-events t/plugin/[l-z]*lua-resty-worker-events t/admin t/cli t/config-center-yaml t/control t/core t/debug t/discovery t/error_page t/misclua-resty-worker-events t/node t/pubsub t/router t/script t/secret t/stream-node t/utils t/xds-librarylua-resty-events t/plugin/[a-k]*lua-resty-events t/plugin/[l-z]*lua-resty-events t/admin t/cli t/config-center-yaml t/control t/core t/debug t/discovery t/error_page t/misclua-resty-events t/node t/pubsub t/router t/script t/secret t/stream-node t/utils t/xds-library

github的CI会对这些所有的任务执行测试。如果测试执行失败了,需要重点关注失败的日志,并在本地调试直到可以通过测试。如果遇到数据不对的问题,可以重新对数据进行初始化

apisix quit && apisix init && apisix init_etcd && apisix start

社区交流

在代码提交并创建了PR之后,我们可能会收到一些反馈,这时候我们就需要针对这些反馈作出回应。以#10771为例

我在提交了代码之后,管理员认为需要将aes_encrypt_pkeyaes_decrypt_pkey方法中的field字段去掉。我一开始在写代码的时候就发现这个字段去掉会导致数据加解密过程中ssldata的行为不一致,可能导致错误。不过管理员让我放心删,没问题,本着信任的态度我就把参数删除掉了

但是删除掉了之后测试却怎么也跑不过,我因为非常信任社区管理员也没有怀疑是因为ssldata逻辑不一致导致的,而是从其它地方入手进行排查。最终花了大量的时间经过了很多**的排查之后,发现其实就是因为ssldata的行为逻辑不一致导致的。我随后询问了管理员,并且把field参数加了回来

field参数加回来之后,所有的test cases就都能跑过了

所有测试用例通过,一段时间之后PR被review没有问题,就会被合并到master分支了

参考

Revolution OS
S04E00-吴晟:开源项目进入 Apache 孵化器意味着什么
Apache 是如何运作的?
新手如何快速参与开源项目
如何从小白成长为 Apache Committer?

  1. 在执行install-dependencies.sh脚本的时候,会下载golang的依赖,比如gRPC-Go,这里需要保证网络能够顺畅访问golang的官方仓库。

  2. 为了保证test-nginx正常安装,需要网络顺畅,能够正常访问相关资源。

☑️ ⭐

Kubernetes的安装和使用(三)

目录

Kubernetes的安装和使用(一)
Kubernetes的安装和使用(二)
Kubernetes的安装和使用(三)

k8s使用Service

在前面的例子中我们使用port-forward进行端口转发,这样会存在两个问题:

  1. pod重启后ip地址发生了变化怎么办
  2. 如何进行负载均衡

k8s的Service就是用来解决这个问题的,它包含ClusterIP、NodePort和Headless等模块。

ClusterIP

ClusterIP就是将多个pod用一个ip进行访问的服务,这个ip只能在集群内访问,我们创建如下的程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"fmt"
"io"
"net/http"
"os"
)

func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
host, _ := os.Hostname()
io.WriteString(w, fmt.Sprintf("[v3] Hello, Kubernetes!, From host: %s\n", host))
})
http.ListenAndServe(":3000", nil)
}

我们将如上程序打包成镜像

docker build . -t derobukal/hellok8s:v3_hostnamedocker push derobukal/hellok8s:v3_hostname

然后启动k8s的deployment,可以得到3个运行的pod

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: apps/v1
kind: Deployment
metadata:
# deployment 唯一名称
name: hellok8s-go-http
spec:
replicas: 3 # 副本数量
selector:
matchLabels:
app: hellok8s # 管理template下所有 app=hellok8s的pod,(要求和template.metadata.labels完全一致!!!否则无法部署deployment)
template: # template 定义一组容器
metadata:
labels:
app: hellok8s
spec:
containers:
- image: derobukal/hellok8s:v3_hostname
name: hellok8s

之后我们创建service-clusterip.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
apiVersion: v1
kind: Service
metadata:
name: service-hellok8s-clusterip
spec:
type: ClusterIP # 这行是默认的,可省略
# sessionAffinity: ClientIP # or None, 设置会话亲和性(ClientIP表示同一客户端ip的请求会路由到同个Pod)
# sessionAffinityConfig:
# clientIP:
# timeoutSeconds: 3600 # 范围 0~86400,默认10800(3h)
selector:
app: hellok8s # 通过selector关联pod组
ports:
- port: 3000 # service端口
targetPort: 3000 # 后端pod端口

随后启动service,并查看service所生成的clusterIp的值,以及clusterIp后面的endpoints的详细信息

~ kc apply -f service-clusterip.yaml service/service-hellok8s-clusterip created~ kc get svc                        NAME                         TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)    AGEkubernetes                   ClusterIP   10.96.0.1        <none>        443/TCP    7d1hservice-hellok8s-clusterip   ClusterIP   10.111.240.227   <none>        3000/TCP   10s~ kc get endpointsNAME                         ENDPOINTS                                            AGEkubernetes                   192.168.49.2:8443                                    7d1hservice-hellok8s-clusterip   10.244.0.67:3000,10.244.0.68:3000,10.244.0.69:3000   16s

有了service之后,我们就可以很方便的通过clusterIp的地址访问服务了。因为我们使用的是minikube,为了能正常访问clusterIp,我们先创建一个nginx的pod

1
2
3
4
5
6
7
8
9
10
apiVersion: v1
kind: Pod
metadata:
name: nginx
labels:
app: nginx
spec:
containers:
- name: nginx-container
image: nginx

之后我们进入nginx的pod测试clusterIp

~ kc apply -f pod_nginx.yaml        pod/nginx created~ kc get podsNAME                                READY   STATUS    RESTARTS   AGEhellok8s-go-http-6f5d68bc64-6xrdx   1/1     Running   0          23mhellok8s-go-http-6f5d68bc64-b7wq2   1/1     Running   0          23mhellok8s-go-http-6f5d68bc64-rk59k   1/1     Running   0          23mnginx                               1/1     Running   0          98s~ kc exec nginx -it -- bash                            root@nginx:/# curl 10.111.240.227:3000[v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-b7wq2root@nginx:/# curl 10.111.240.227:3000[v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-rk59kroot@nginx:/# curl 10.111.240.227:3000[v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-rk59kroot@nginx:/# curl 10.111.240.227:3000[v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-b7wq2root@nginx:/# curl 10.111.240.227:3000[v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-6xrdxroot@nginx:/# curl 10.111.240.227:3000[v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-b7wq2root@nginx:/# curl 10.111.240.227:3000[v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-rk59kroot@nginx:/# 

上面通过curl测试service,可以看到每次访问的后端pod都是不一样的。接下来我们增加pod的数量,可以看到endpoint的数量也发生了变化,同时测试请求也打到了新的pod上面

~ kc scale deployment/hellok8s-go-http --replicas=10deployment.apps/hellok8s-go-http scaled~ kc get endpoints                                                             NAME                         ENDPOINTS                                                        AGEkubernetes                   192.168.49.2:8443                                                7d1hservice-hellok8s-clusterip   10.244.0.67:3000,10.244.0.68:3000,10.244.0.69:3000 + 7 more...   22m~ kc get pods     NAME                                READY   STATUS    RESTARTS   AGEhellok8s-go-http-6f5d68bc64-5lxvs   1/1     Running   0          27shellok8s-go-http-6f5d68bc64-6xrdx   1/1     Running   0          29mhellok8s-go-http-6f5d68bc64-b7wq2   1/1     Running   0          29mhellok8s-go-http-6f5d68bc64-cl56f   1/1     Running   0          27shellok8s-go-http-6f5d68bc64-gbn9v   1/1     Running   0          27shellok8s-go-http-6f5d68bc64-k7db4   1/1     Running   0          27shellok8s-go-http-6f5d68bc64-m4h5s   1/1     Running   0          27shellok8s-go-http-6f5d68bc64-rk59k   1/1     Running   0          29mhellok8s-go-http-6f5d68bc64-whpht   1/1     Running   0          27shellok8s-go-http-6f5d68bc64-xnvk2   1/1     Running   0          27snginx                               1/1     Running   0          8m13s~ kc exec nginx -it -- bashroot@nginx:/# curl 10.111.240.227:3000                                 [v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-6xrdxroot@nginx:/# curl 10.111.240.227:3000[v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-b7wq2root@nginx:/# curl 10.111.240.227:3000[v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-m4h5sroot@nginx:/# curl 10.111.240.227:3000[v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-whphtroot@nginx:/# curl 10.111.240.227:3000[v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-whphtroot@nginx:/# curl 10.111.240.227:3000[v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-cl56froot@nginx:/# curl 10.111.240.227:3000[v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-xnvk2root@nginx:/# curl 10.111.240.227:3000[v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-cl56froot@nginx:/# curl 10.111.240.227:3000[v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-cl56froot@nginx:/# curl 10.111.240.227:3000[v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-m4h5sroot@nginx:/# curl 10.111.240.227:3000[v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-rk59kroot@nginx:/# curl 10.111.240.227:3000[v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-whphtroot@nginx:/# curl 10.111.240.227:3000[v3] Hello, Kubernetes!, From host: hellok8s-go-http-6f5d68bc64-k7db4root@nginx:/# 

NodePort

clusterIp只能在集群内部进行访问,NodePort在clusterIp的基础上,还支持了通过k8s集群的节点进行访问。有如下的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
apiVersion: v1
kind: Service
metadata:
name: service-hellok8s-nodeport
spec:
type: NodePort
selector:
app: hellok8s
ports:
- port: 3000 # pod端口
nodePort: 30000 # 节点固定端口。在NodePort类型中,k8s要求在 30000-32767 范围内,否则apply报错
# 若需要暴露多个端口,则按下面形式
# - name: http
# protocol: TCP
# port: 80
# targetPort: 9376
# - name: https
# protocol: TCP
# port: 443
# targetPort: 9377

之后启动nodeport的服务,就可以在集群的节点上访问服务了

~ kc apply -f service-nodeport.yaml service/service-hellok8s-nodeport created~ kc get svc                       NAME                         TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGEkubernetes                   ClusterIP   10.96.0.1        <none>        443/TCP          7d1hservice-hellok8s-clusterip   ClusterIP   10.111.240.227   <none>        3000/TCP         28mservice-hellok8s-nodeport    NodePort    10.106.138.169   <none>        3000:30000/TCP   17s

因为使用minikube创建服务,而非创建了k8s的集群,因此暂时不太方便测试该功能。

☑️ ⭐

Kubernetes的安装和使用(二)

目录

Kubernetes的安装和使用(一)
Kubernetes的安装和使用(二)
Kubernetes的安装和使用(三)

k8s的使用

构建和运行镜像

编写一个go程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"io"
"log"
"net/http"
)

func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "[v1] Hello, Kubernetes!")
})

log.Printf("v1 access http://localhost:3000\n")
panic(http.ListenAndServe(":3000", nil))
}

编写Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 引入golang的环境,并设置别名
FROM golang:1.20-alpine AS builder
# 工作目录
WORKDIR /project
# 把当前文件夹的内容都添加到镜像的/project文件夹下
ADD . .
# 编译golang源代码
# 设置代理、操作系统、CPU架构、禁用cgo编译
# 设置mod为自动模式,防止因为没有设置go.mod而编译报错
RUN GOPROXY=https://goproxy.cn,direct GOOS=linux GOARCH=amd64 CGO_ENABLED=0 GO111MODULE=auto go build -o main -ldflags "-w -extldflags -static"

# 引入alpine系统环境
FROM alpine as prod
# 从上面的build镜像复制编译好的文件/project/main到当前目录
COPY --from=builder /project/main .
# 暴露3000端口
EXPOSE 3000
# 启动main程序
ENTRYPOINT ["/main"]

打包镜像

docker build . -t derobukal/hellok8s:v1

执行镜像,并把3000端口暴露出来

docker run --rm -p 3000:3000 derobukal/hellok8s:v1

之后可以用curl访问3000端口,结果正常显示了

~ curl 127.0.0.1:3000[v1] Hello, Kubernetes!%   

之后可以把这个镜像推送到镜像仓库

docker logindocker push derobukal/hellok8s:v1

使用Pod

编写pod.yaml

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: Pod # 资源类型为pod
metadata:
name: go-http # 名称,需要在当前命名空间中唯一
labels:
app: go
version: v1
spec:
containers: # pod内的容器组
- name: go-http
image: derobukal/hellok8s:v1 # 镜像默认来源 DockerHub

之后创建pod

~ kc apply -f pod.yamlpod/go-http created~ kc get podsNAME      READY   STATUS    RESTARTS   AGEgo-http   1/1     Running   0          65s

然后临时开启端口转发,就可以访问相应的服务了

~ kc port-forward go-http 3000:3000Forwarding from 127.0.0.1:3000 -> 3000Forwarding from [::1]:3000 -> 3000~ curl http://127.0.0.1:3000[v1] Hello, Kubernetes!%

在进行以上测试的时候,如果出现pod启动不了的情况,可能是因为防火墙的原因,可以开启网络代理之后再试。

使用Deployment

一般来说,pod不会被直接的使用,而是用Deployment来进行相关的操作。

编写deployment.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: apps/v1
kind: Deployment
metadata:
# deployment 唯一名称
name: hellok8s-go-http
spec:
replicas: 2 # 副本数量
selector:
matchLabels:
app: hellok8s # 管理template下所有 app=hellok8s的pod,(要求和template.metadata.labels完全一致!!!否则无法部署deployment)
template: # template 定义一组容器
metadata:
labels:
app: hellok8s
spec:
containers:
- image: derobukal/hellok8s:v1
name: hellok8s

部署deployment

~ kc apply -f deployment.yaml deployment.apps/hellok8s-go-http created~ kc get deploymentsNAME               READY   UP-TO-DATE   AVAILABLE   AGEhellok8s-go-http   2/2     2            2           79s

kc get pod -o wide可以查看更详细的pod信息。我们可以把配置中的副本数改为3,之后重新执行部署命令,然后查看详细的pod信息如下,可以看到又新增了一个pod

NAME                                READY   STATUS    RESTARTS   AGE     IP           NODE       NOMINATED NODE   READINESS GATESgo-http                             1/1     Running   0          14m     10.244.0.6   minikube   <none>           <none>hellok8s-go-http-6db476b8cb-4n2nx   1/1     Running   0          4m35s   10.244.0.7   minikube   <none>           <none>hellok8s-go-http-6db476b8cb-s76jr   1/1     Running   0          4m35s   10.244.0.8   minikube   <none>           <none>hellok8s-go-http-6db476b8cb-tjqs9   1/1     Running   0          8s      10.244.0.9   minikube   <none>           <none>

我们选取任意一个pod进行端口转发,随后请求可以看到结果正常

~ kc port-forward hellok8s-go-http-6db476b8cb-4n2nx 3000:3000 Forwarding from 127.0.0.1:3000 -> 3000Forwarding from [::1]:3000 -> 3000Handling connection for 3000

更新deployment

我们将golang程序中的v1修改为v2,之后打包新版本镜像并上传

docker build . -t derobukal/hellok8s:v2docker push derobukal/hellok8s:v2

之后我们修改deployment.yaml中的镜像为derobukal/hellok8s:v2,然后更新部署

~ kc apply -f deployment.yaml deployment.apps/hellok8s-go-http configured

可以看到pod都重新部署了

~ kc get pod -o wide                                         NAME                               READY   STATUS    RESTARTS   AGE   IP            NODE       NOMINATED NODE   READINESS GATESgo-http                            1/1     Running   0          25m   10.244.0.6    minikube   <none>           <none>hellok8s-go-http-8b44d58c5-5bmx7   1/1     Running   0          34s   10.244.0.12   minikube   <none>           <none>hellok8s-go-http-8b44d58c5-djfcd   1/1     Running   0          35s   10.244.0.11   minikube   <none>           <none>hellok8s-go-http-8b44d58c5-rt29z   1/1     Running   0          58s   10.244.0.10   minikube   <none>           <none>

之后选取一个Pod进行端口转发,并请求发现返回结果发生了变化

~ kc port-forward hellok8s-go-http-8b44d58c5-5bmx7 3000:3000 Forwarding from 127.0.0.1:3000 -> 3000Forwarding from [::1]:3000 -> 3000Handling connection for 3000~ curl http://127.0.0.1:3000[v2] Hello, Kubernetes!%

回滚deployment

查看版本信息

~ kc rollout history deployment/hellok8s-go-httpdeployment.apps/hellok8s-go-http REVISION  CHANGE-CAUSE1         <none>2         <none>

查看具体版本2

~ kc rollout history deployment/hellok8s-go-http --revision=2deployment.apps/hellok8s-go-http with revision #2Pod Template:Labels:app=hellok8s    pod-template-hash=8b44d58c5Containers:hellok8s:    Image:derobukal/hellok8s:v2    Port:<none>    Host Port:<none>    Environment:<none>    Mounts:<none>Volumes:<none>

查看具体版本1

~ kc rollout history deployment/hellok8s-go-http --revision=1deployment.apps/hellok8s-go-http with revision #1Pod Template:Labels:app=hellok8s    pod-template-hash=6db476b8cbContainers:hellok8s:    Image:derobukal/hellok8s:v1    Port:<none>    Host Port:<none>    Environment:<none>    Mounts:<none>Volumes:<none>

回退到版本1

~ kc rollout undo deployment/hellok8s-go-http --to-revision=1deployment.apps/hellok8s-go-http rolled back

此时查看pod可以发现pod已经发生了改变,访问服务返回的也是v1版本信息了。

部署失败

我们构建一个如下的程序

1
2
3
4
5
package main

func main() {
panic("something went wrong")
}

之后打包一个新版本镜像:docker build . -t derobukal/hellok8s:v_error
并对这个镜像进行push:docker push derobukal/hellok8s:v_error

之后可以简单地使用命令而不修改deployment.yaml文件来重新部署deployment,通过命令修改镜像:

~ kc set image deployment/hellok8s-go-http hellok8s=derobukal/hellok8s:v2_error  deployment.apps/hellok8s-go-http image updated~ kc get podsNAME                                READY   STATUS              RESTARTS        AGEgo-http                             1/1     Running             1 (7m34s ago)   54mhellok8s-go-http-55669566cb-l69hx   0/1     ContainerCreating   0               41shellok8s-go-http-6db476b8cb-dlr2z   1/1     Running             1 (7m34s ago)   22mhellok8s-go-http-6db476b8cb-glx7h   1/1     Running             1 (7m34s ago)   22mhellok8s-go-http-6db476b8cb-sfxnr   1/1     Running             1 (7m34s ago)   22m

重新部署之后我们查看pod可以发现,之前的pod仍然在正常运行,而新启动的pod则处于ContainerCreating状态。我们可以直接回退到上一个版本

~ kc rollout undo deployment/hellok8s-go-http --to-revision=2deployment.apps/hellok8s-go-http rolled back~ kc get podsNAME                                READY   STATUS        RESTARTS      AGEgo-http                             1/1     Running       1 (12m ago)   58mhellok8s-go-http-55669566cb-l69hx   0/1     Terminating   0             5m17shellok8s-go-http-8b44d58c5-74mhw    1/1     Running       0             22shellok8s-go-http-8b44d58c5-hfpjj    1/1     Running       0             20shellok8s-go-http-8b44d58c5-lzwfm    1/1     Running       0             19s

存活探针

存活探针顾名思义就是用来检查进程的存活情况的,它支持多种方式,下面是一个例子

flat
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
"fmt"
"io"
"net/http"
"time"
)

func main() {
started := time.Now()
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
duration := time.Since(started)
if duration.Seconds() > 15 {
w.WriteHeader(500)
w.Write([]byte(fmt.Sprintf("error: %v", duration.Seconds())))
} else {
w.WriteHeader(200)
w.Write([]byte("ok"))
}
})

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "[v2] Hello, Kubernetes!")
})
http.ListenAndServe(":3000", nil)
}

这个程序的/healthz接口会在启动15秒钟后开始持续报错,我们使用这个代码创建镜像derobukal/hellok8s:liveness。我们先使用正常的镜像创建depolyment,并设置存活探针配置

flat
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
apiVersion: apps/v1
kind: Deployment
metadata:
# deployment唯一名称
name: hellok8s-go-http
spec:
replicas: 2 # 副本数量
selector:
matchLabels:
app: hellok8s # 管理template下所有 app=hellok8s的pod,(要求和template.metadata.labels完全一致!!!否则无法部署deployment)
template: # template 定义一组pod
metadata:
labels:
app: hellok8s
spec:
containers:
- image: derobukal/hellok8s:v1
name: hellok8s
# 存活探针
livenessProbe:
# http get 探测指定pod提供HTTP服务的路径和端口
httpGet:
path: /healthz
port: 3000
# 3s后开始探测
initialDelaySeconds: 3
# 每3s探测一次
periodSeconds: 3

livenessProbe中我们设置使用httpGet的方式来检查程序的存活状态,并且接口为healthz。启动这个deployment,一切正常

~ kc get podsNAME                               READY   STATUS    RESTARTS   AGEhellok8s-go-http-cf86fc9d6-qjt57   1/1     Running   0          8shellok8s-go-http-cf86fc9d6-tnf9r   1/1     Running   0          8s

之后我们切换镜像为我们刚刚创建的derobukal/hellok8s:liveness镜像

kc set image deployment/hellok8s-go-http hellok8s=derobukal/hellok8s:liveness

然后可以看到pod会每隔一会儿重启一下

~ kc get podsNAME                                READY   STATUS    RESTARTS      AGEhellok8s-go-http-78fd694d74-j2kr6   1/1     Running   3 (23s ago)   2m41shellok8s-go-http-78fd694d74-j5d5x   1/1     Running   3 (19s ago)   91s

如果我们这时候再把镜像设置为v1,很快就可以看到pod恢复正常了。

就绪探针

就绪探针是用来检测程序是否已经成功启动的,例如程序是否可以接受前端的流量、是否已经可以执行相关的任务了等等。我们编写如下程序并创建derobukal/hellok8s:readiness镜像

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
"io"
"net/http"
)

func hello(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "[v2] Hello, Kubernetes!")
}

func main() {
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
})

http.HandleFunc("/", hello)
http.ListenAndServe(":3000", nil)
}

使用如下的配置创建deployment

flat
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
apiVersion: apps/v1
kind: Deployment
metadata:
# deployment唯一名称
name: hellok8s-go-http
spec:
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
replicas: 3 # 副本数量
selector:
matchLabels:
app: hellok8s # 管理template下所有 app=hellok8s的pod,(要求和template.metadata.labels完全一致!!!否则无法部署deployment)
template: # template 定义一组pod
metadata:
labels:
app: hellok8s
spec:
containers:
- image: derobukal/hellok8s:v1
name: hellok8s
# 就绪探针
readinessProbe:
# http get 探测pod提供HTTP服务的路径和端口
httpGet:
path: /healthz
port: 3000
initialDelaySeconds: 1 # 1s后开始探测
periodSeconds: 5 # 每5s探测一次
timeoutSeconds: 1 # 单次探测超时,默认1
failureThreshold: 3 # 探测失败时,k8s的重试次数,默认3,达到这个次数后 停止探测,并打上未就绪的标签

还是使用/healthz接口进行检测,启动deployment后发现pod正常启动

~ kc get podsNAME                                READY   STATUS    RESTARTS   AGEhellok8s-go-http-7b48477987-2x4ll   1/1     Running   0          12shellok8s-go-http-7b48477987-hl4kt   1/1     Running   0          14shellok8s-go-http-7b48477987-xdtvl   1/1     Running   0          14s

之后修改镜像kc set image deployment/hellok8s-go-http hellok8s=derobukal/hellok8s:readiness

之后发现有两个pod一直无法进入ready状态

~ kc get podsNAME                                READY   STATUS    RESTARTS   AGEhellok8s-go-http-56dd6754c7-6qltj   0/1     Running   0          20shellok8s-go-http-56dd6754c7-kmzb2   0/1     Running   0          20shellok8s-go-http-7856db556-5d9vr    1/1     Running   0          31shellok8s-go-http-7856db556-jctgt    1/1     Running   0          31s

任务

任务用于执行一些job,例如如下的任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
apiVersion: batch/v1
kind: Job
metadata:
name: pods-job
spec:
# completions: 3 # 启用它表示串行执行3次
# parallelism: 3 # 启动它表示并发数,由completions指定总次数
# backoffLimit: 3 # 限制重试次数,默认6,超过次数则不再启动新pod
# activeDeadlineSeconds: 10 # 限制job执行时间,超时还不终止则强制终止,并且稍后执行自动删除(若设置),且不受restartPolicy字段影响
ttlSecondsAfterFinished: 10 # 多少秒后自动删除执行成功的job,避免太多不再需要的job累积
template:
spec:
restartPolicy: Never # or OnFailure, 不能是其他值;推荐Never,因为这个策略下控制会启动新的pod,不会删除失败的pod,有助于排查问题;OnFailure是不断重启旧的pod
containers:
- command: ['sh', '-c', 'echo "Start Job!"; sleep 30; echo "Job Done!"']
image: busybox
name: pods-job-container

如上的任务只是简单的进行了信息的打印

~ kc apply -f job.yaml       job.batch/pods-job created~ kc get pods         NAME             READY   STATUS              RESTARTS   AGEpods-job-nthvl   0/1     ContainerCreating   0          7s~ kc get podsNAME             READY   STATUS    RESTARTS   AGEpods-job-nthvl   1/1     Running   0          23s~ kc get podsNAME             READY   STATUS    RESTARTS   AGEpods-job-nthvl   1/1     Running   0          48s~ kc get podsNAME             READY   STATUS      RESTARTS   AGEpods-job-nthvl   0/1     Completed   0          56s

定时任务

定时任务CronJob用于执行定时任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
apiVersion: batch/v1
kind: CronJob
metadata:
name: pods-cronjob
spec:
schedule: "*/1 * * * *" # 最小到min级别,这表示每分钟1次
startingDeadlineSeconds: 3 # 最大启动时间,超时后变成失败
concurrencyPolicy: Forbid # Allow/Forbid/Replace,上个周期的Job未执行结束时,是否允许下个周期的Job开始执行,默认Allow
suspend: false # 是否暂停cronjob的执行,一般通过kubectl edit修改
successfulJobsHistoryLimit: 3 # 保留多少条执行成功的Job记录,默认3
failedJobsHistoryLimit: 1 # 保留多少条执行失败的Job记录,默认1
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- command: [ 'sh', '-c', 'echo "Start Job!"; sleep 30; echo "Job Done!"' ]
image: busybox
name: pods-cronjob-container

如上这个任务会每分钟执行一次,详情如下

~ kc apply -f job.yamlcronjob.batch/pods-cronjob created~ kc get cronjobNAME           SCHEDULE      SUSPEND   ACTIVE   LAST SCHEDULE   AGEpods-cronjob   */1 * * * *   False     0        <none>          8s~ kc get podsNAME                          READY   STATUS              RESTARTS   AGEpods-cronjob-28410113-fs4hr   0/1     ContainerCreating   0          0s~ kc get job NAME                    COMPLETIONS   DURATION   AGEpods-cronjob-28410113   0/1           6s         6s~ kc logs pods-cronjob-28410113-fs4hrStart Job!Job Done!~ kc delete cronjob pods-cronjob       cronjob.batch "pods-cronjob" deleted
☑️ ⭐

Kubernetes的安装和使用(一)

目录

Kubernetes的安装和使用(一)
Kubernetes的安装和使用(二)
Kubernetes的安装和使用(三)

k8s的介绍

k8s是一种可以实现容器集群的自动化部署、自动扩缩容、维护等功能的服务。Docker解决了应用运行时环境的问题,而k8s则可以用来构建大量应用服务,它能方便的管理海量应用容器。它拥有自动包装、自我修复、横向缩放、服务发现、负载均衡、自动部署、升级回滚、存储编排等特性。

k8s的节点分为master和node,它的架构如下

Master:官方叫做控制平面(Control Plane),它用于负责整个集群的管控。master由4个部分组成

  1. API Server进程,负责任何资源的管理和操作
  2. etcd,用于保存集群状态,只有apiServer可以读写
  3. 调度器(Scheduler),用于调度Pod资源
  4. 控制器管理器(kube-controller-manager)

Node:数据平面,是实际的工作节点,直接负责对容器的资源控制。node由3个部分组成

  1. kubelet,运行在每个节点上面的代理进程
  2. kube-proxy,负责每个节点的网络服务
  3. 容器运行时,例如docker

k8s还定义了一些内核抽象

1. Pod

Pod是k8s调度的基本单元,它封装了一个或多个容器。Pod中的容器会作为一个整体被k8s调度到一个Node上运行。同一个Pod内的容器可以互相操作对方的文件,这些容器就好像运行在同一个操作系统上的不同进程一样。

2. 控制器

一般来说,用户不会直接创建Pod,而是创建控制器来管理Pod,因为控制器能够更细粒度的控制Pod的运行方式,比如副本数量、部署位置等。 控制器包含下面几种:

  • Replication控制器(以及ReplicaSet控制器):负责保证Pod副本数量符合预期(涉及对Pod的启动、停止等操作)
  • Deployment控制器:是高于Replication控制器的对象,也是最常用的控制器,用于管理Pod的发布、更新、回滚等
  • StatefulSet控制器:与Deployment同级,提供排序和唯一性保证的特殊Pod控制器。用于管理有状态服务,比如数据库等
  • DaemonSet控制器:与Deployment同级,用于在集群中的每个Node上运行单个Pod,多用于日志收集和转发、监控等功能的服务。并且它可以绕过常规Pod无法调度到Master运行的限制
  • Job控制器:与Deployment同级,用于管理一次性任务,比如批处理任务
  • CronJob控制器:与Deployment同级,在Job控制器基础上增加了时间调度,用于执行定时任务

3. Service、Ingress和Storage

Service是对一组Pod的抽象,它定义了Pod的逻辑集合以及访问该集合的策略。前面的Deployment等控制器只定义了Pod运行数量和生命周期, 并没有定义如何访问这些Pod,由于Pod重启后IP会发生变化,没有固定IP和端口提供服务。
Service对象就是为了解决这个问题。Service可以自动跟踪并绑定后端控制器管理的多个Pod,即使发生重启、扩容等事件也能自动处理, 同时提供统一IP供前端访问,所以通过Service就可以获得服务发现的能力,部署微服务时就无需单独部署注册中心组件。
Ingress不是一种服务类型,而是一个路由规则集合,通过Ingress规则定义的规则,可以将多个Service组合成一个虚拟服务(如前端页面+后端API)。 它可实现业务网关的作用,类似Nginx的用法,可以实现负载均衡、SSL卸载、流量转发、流量控制等功能。
Storage是Pod中用于存储的抽象,它定义了Pod的存储卷,包括本地存储和网络存储;它的生命周期独立于Pod之外,可进行单独控制。

4. 资源划分

命名空间(Namespace):k8s通过namespace对同一台物理机上的k8s资源进行逻辑隔离。
标签(Labels):是一种语义化标记,可以附加到Pod、Node等对象之上,然后更高级的对象可以基于标签对它们进行筛选和调用, 例如Service可以将请求只路由到指定标签的Pod,或者Deployment可以将Pod只调度到指定标签的Node。
注解(Annotations):也是键值对数据,但更灵活,它的value允许包含结构化数据。一般用于元数据配置,不用于筛选。 例如Ingress中通过注解为nginx控制器配置禁用ssl重定向。

k8s的安装

k8s的安装比较复杂,需要涉及到很多的Linux、网络、存储等设置。为了简单起见,我们先学习使用minikube安装单机的k8s环境,等学习并熟悉了k8s的使用之后,再去搭建k8s的集群环境。

安装kubectl

kubectl是k8s的客户端,我们可以通过它和k8s的服务进行交互,我们直接从k8s的官网上下载它并将其安装到/usr/local/bin目录下

# 下载kubectl客户端,这里使用了代理curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" -x http://192.168.65.100:7890# 将kubectl客户端安装到指定的bin目录下sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl

为了方便后面的使用,可以将kc设置为kubectl的别名,将如下配置添加到~/.zshrc文件中

alias kc="kubectl"

之后我们就可以查看kubectl的版本了

~ kc version --client --output=json{    "clientVersion": {        "major": "1",        "minor": "29",        "gitVersion": "v1.29.0",        "gitCommit": "3f7a50f38688eb332e2a1b013678c6435d539ae6",        "gitTreeState": "clean",        "buildDate": "2023-12-13T08:51:44Z",        "goVersion": "go1.21.5",        "compiler": "gc",        "platform": "linux/amd64"    },    "kustomizeVersion": "v5.0.4-0.20230601165947-6ce0bf390ce3"}

安装Docker

Docker的安装参考了官方文档,具体步骤如下

添加Docker的官方GPG秘钥

sudo apt-get updatesudo apt-get install ca-certificates curl gnupgsudo install -m 0755 -d /etc/apt/keyringscurl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpgsudo chmod a+r /etc/apt/keyrings/docker.gpg

把仓库添加到apt的资源列表中

echo \"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \sudo tee /etc/apt/sources.list.d/docker.list > /dev/nullsudo apt-get update

安装相关的程序并进行权限设置

# 安装程序sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin# 设置文件权限,并把当前用户添加到docker组中sudo chmod 666 /var/run/docker.socksudo usermod -aG docker $USER

安装好了Docker并设置完权限之后,可以执行Docker的hello-world查看是否安装成功了

docker run hello-world

安装成功的输出如下

Hello from Docker!This message shows that your installation appears to be working correctly.To generate this message, Docker took the following steps:    1. The Docker client contacted the Docker daemon.    2. The Docker daemon pulled the "hello-world" image from the Docker Hub.        (amd64)    3. The Docker daemon created a new container from that image which runs the        executable that produces the output you are currently reading.    4. The Docker daemon streamed that output to the Docker client, which sent it        to your terminal.To try something more ambitious, you can run an Ubuntu container with:    $ docker run -it ubuntu bashShare images, automate workflows, and more with a free Docker ID:    https://hub.docker.com/For more examples and ideas, visit:    https://docs.docker.com/get-started/

安装minikube

与kubectl的安装类似,我们还是使用下载并安装的方式安装minikube

# 下载curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64# 安装到指定目录下面sudo install minikube-linux-amd64 /usr/local/bin/minikube

安装之后,我们就可以启动minikube了。因为网络原因,直接使用minikube start命令有的时候无法正常启动,因此我们可以使用代理

~ minikube start http_proxy=http://192.168.65.100:7890 https_proxy=http://192.168.65.100:7890😄  minikube v1.32.0 on Ubuntu 22.04✨  Using the docker driver based on existing profile👍  Starting control plane node minikube in cluster minikube🚜  Pulling base image ...🔄  Restarting existing docker container for "minikube" ...🐳  Preparing Kubernetes v1.28.3 on Docker 24.0.7 ...🔗  Configuring bridge CNI (Container Networking Interface) ...🔎  Verifying Kubernetes components...    ▪ Using image gcr.io/k8s-minikube/storage-provisioner:v5🌟  Enabled addons: default-storageclass, storage-provisioner🏄  Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default

看到以上内容,则说明k8s已经启动好了。接下来我们就可以使用kubectl来管理k8s了

查看版本信息

~ kc versionClient Version: v1.29.0Kustomize Version: v5.0.4-0.20230601165947-6ce0bf390ce3Server Version: v1.28.3

查看k8s集群信息

~ kc cluster-infoKubernetes control plane is running at https://192.168.49.2:8443CoreDNS is running at https://192.168.49.2:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxyTo further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.

查看节点信息

~ kc get nodesNAME       STATUS   ROLES           AGE     VERSIONminikube   Ready    control-plane   4h48m   v1.28.3

参考

Kubernetes 安装小记
Kubernetes 使用小记
Kubernetes 基础教程
Docker 入门教程

🔲 ⭐

自己动手实现一个可以运行在JVM上的编程语言

众所周知,JVM虚拟机被设计为可以执行栈式指令的机器。因此任何一个语言只要编译之后得到的字节码符合JVM的标准,就可以在JVM上执行,例如Kotlin、Groovy、Scala、Clojure。

我们自己设计一款语言,并命名为Jinx,它支持类定义、变量定义、变量打印。它的语法解析逻辑如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
grammar Jinx;

@header {
package com.nosuchfield.jinx.code;
}

jinx: CLASS ID LEFT_BR classBody RIGHT_BR EOF;
classBody: (variable | print)*;
variable: VARIABLE ID EQUALS value;
print: PRINT ID;
value: STRING | INT | DOUBLE;

LEFT_BR: '{';
RIGHT_BR: '}';
CLASS: 'class';
VARIABLE: 'var';
PRINT: 'print';
EQUALS: '=';
STRING: '"' ('\\"' | ~'"')+ '"';
DOUBLE: [0-9]+ '.' [0-9]+;
INT: [0-9]+;
// 这个ID不能放在前面,不然会被提前解析,导致print等字符串被解析为ID
ID: [a-zA-Z] [a-zA-Z0-9]*;

WS: [\n\r\t ]+ -> skip;

Jinx的最外层是类class,class的内部可以包含变量的定义和打印,变量的值支持字符串、整数和小数。有了ANTLR4的解析逻辑之后,我们就可以处理程序的语法树了,语法树的解析如下

flat
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public class Loader extends JinxBaseListener {

/**
* 变量表,以变量名为key,包括:变量索引idx、变量类型
*/
private final Map<String, ImmutablePair<Integer, Integer>> variables = new HashMap<>();

/**
* 指令列表
*/
private final List<Instruction> instructions = new ArrayList<>();

private String className;

@Override
public void enterJinx(JinxParser.JinxContext ctx) {
className = ctx.ID().getText();
}

@Override
public void exitVariable(JinxParser.VariableContext ctx) {
// 变量名
String name = ctx.ID().getText();
JinxParser.ValueContext variable = ctx.value();
// 变量值
String text = variable.getText();
// 变量类型
int type = variable.getStart().getType();
// 变量索引(在局部变量表中这是第几个变量)
int idx = variables.size();

// 把这个变量保存在内存,方便后面知道这个变量的索引和类型
variables.put(name, ImmutablePair.of(idx, type));
// 创建保存这个变量的指令
instructions.add(new VariableInstruction(idx, type, text));
}

@Override
public void exitPrint(JinxParser.PrintContext ctx) {
String name = ctx.ID().getText();
if (!variables.containsKey(name)) {
System.err.printf("variable %s not exist\n", name);
System.exit(1);
}
int idx = variables.get(name).getLeft();
int type = variables.get(name).getRight();
// 创建打印的指令
instructions.add(new PrintInstruction(idx, type));
}

public List<Instruction> getInstructions() {
return instructions;
}

public String getClassName() {
return className;
}

}

在上面的语法树解析中,我们会解析每一个变量的定义语法和打印语法。

变量定义

我们会在定义每个变量的时候记录下变量的类型和索引,并把记录的数据关联到这个变量的名字上。此外,我们还会针对这个变量的类型、索引和值生成JVM保存变量的指令。

变量打印

在打印程序的解析中,我们会先通过变量的名称从关联表中取出变量的类型和索引(如果不存在就报错),之后根据变量的类型和索引创建JVM打印的指令。

上面的语法树解析最终生成了一个指令列表instructions,我们接下来根据这个指令列表生成JVM所需要的字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private byte[] generateBytecode(List<Instruction> instructions, String className) {
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
classWriter.visit(V1_8, ACC_PUBLIC + ACC_SUPER, className, null, "java/lang/Object", null);
// main方法
MethodVisitor methodVisitor = classWriter.visitMethod(ACC_PUBLIC + ACC_STATIC, "main",
"([Ljava/lang/String;)V", null, null);
for (Instruction instruction : instructions) {
instruction.apply(methodVisitor);
}
methodVisitor.visitInsn(RETURN);
methodVisitor.visitMaxs(0, 0); // 设置COMPUTE_FRAMES后会自动计算,但是此处设置不能省略
methodVisitor.visitEnd();
classWriter.visitEnd();
return classWriter.toByteArray();
}

如上我们根据指令和类名使用ASM生成了字节码数据,它生成了一个包含main方法的类,并且把我们的指令放在main方法中。每个指令都调用了其apply方法,接下来我们具体看一下变量定义和变量打印的apply方法是如何实现的。

变量定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void apply(MethodVisitor mv) {
switch (type) {
case JinxLexer.DOUBLE -> {
double val = Double.parseDouble(value);
// 常量池的数据推到栈顶
mv.visitLdcInsn(val);
// 栈顶double值存入本地局部变量,idx代表索引
mv.visitVarInsn(DSTORE, idx);
}
case JinxLexer.INT -> {
int val = Integer.parseInt(value);
mv.visitLdcInsn(val);
mv.visitVarInsn(ISTORE, idx);
}
case JinxLexer.STRING -> {
mv.visitLdcInsn(Utils.removeFirstAndLastChar(value));
mv.visitVarInsn(ASTORE, idx);
}
}

变量的定义很简单,都是先把变量的值从常量池取出,然后推到操作数栈的顶部。之后从操作数栈顶取数据,根据变量的idx把变量保存到局部变量表的指定索引位置。区别在于浮点型的保存指令是DSTORE,整型是ISTORE,字符串是ASTORE

变量打印

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void apply(MethodVisitor mv) {
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
switch (type) {
case JinxLexer.INT -> {
mv.visitVarInsn(ILOAD, idx);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(I)V", false);
}
case JinxLexer.DOUBLE -> {
mv.visitVarInsn(DLOAD, idx);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(D)V", false);
}
case JinxLexer.STRING -> {
mv.visitVarInsn(ALOAD, idx);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
}
}
}

变量的打印会先使用System.out变量,之后从局部变量表中根据变量的idx取出变量的值,然后执行println方法,入参分别为整型、浮点型和字符串。

有了以上这些指令,我们就可以正常生成字节码了,我们进行语法分析生成instructions,并使用instructions最终生成字节码文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void compile0(String file) throws IOException {
// 词法分析
JinxLexer lexer = new JinxLexer(CharStreams.fromFileName(file));
CommonTokenStream tokens = new CommonTokenStream(lexer);
// 语法分析
JinxParser parser = new JinxParser(tokens);
parser.removeErrorListeners();
parser.addErrorListener(new ErrorHandler()); // 语法分析错误处理
ParseTree tree = parser.jinx();
// 语法树遍历
ParseTreeWalker parseTreeWalker = new ParseTreeWalker();
Loader loader = new Loader();
parseTreeWalker.walk(loader, tree);
// 遍历语法树生成Java指令
List<Instruction> instructions = loader.getInstructions();
// 生成Java.class文件
String className = loader.getClassName();
String classFile = Paths.get(new File(file).getParent(), className + ".class").toString();
writeByteArrayToFile(classFile, generateBytecode(instructions, className));
}

上面代码的最后一行就是根据指令列表和类名生成字节码,并把字节码保存到文件中。我们创建一个源代码

class Test {    var name = "Mike"    var salary = 2370    print name    print salary    var number = 1.1    print number}

使用编译器解析如上代码并最终生成一个字节码文件Test.class,运行这个字节码文件可以打印出变量的值

$ java TestMike23701.1

我们也可以查看字节码的信息如下

$ javap -verbose TestClassfile /src/main/resources/jinx/Test.classLast modified Jan 3, 2023; size 342 bytesMD5 checksum fff7d9ac9c044299ffd5a6194c452502public class Testminor version: 0major version: 52flags: ACC_PUBLIC, ACC_SUPERConstant pool:#1 = Utf8               Test#2 = Class              #1             // Test#3 = Utf8               java/lang/Object#4 = Class              #3             // java/lang/Object#5 = Utf8               main#6 = Utf8               ([Ljava/lang/String;)V#7 = Utf8               Mike#8 = String             #7             // Mike#9 = Integer            2370#10 = Utf8               java/lang/System#11 = Class              #10            // java/lang/System#12 = Utf8               out#13 = Utf8               Ljava/io/PrintStream;#14 = NameAndType        #12:#13        // out:Ljava/io/PrintStream;#15 = Fieldref           #11.#14        // java/lang/System.out:Ljava/io/PrintStream;#16 = Utf8               java/io/PrintStream#17 = Class              #16            // java/io/PrintStream#18 = Utf8               println#19 = Utf8               (Ljava/lang/String;)V#20 = NameAndType        #18:#19        // println:(Ljava/lang/String;)V#21 = Methodref          #17.#20        // java/io/PrintStream.println:(Ljava/lang/String;)V#22 = Utf8               (I)V#23 = NameAndType        #18:#22        // println:(I)V#24 = Methodref          #17.#23        // java/io/PrintStream.println:(I)V#25 = Double             1.1d#27 = Utf8               (D)V#28 = NameAndType        #18:#27        // println:(D)V#29 = Methodref          #17.#28        // java/io/PrintStream.println:(D)V#30 = Utf8               Code{public static void main(java.lang.String[]);    descriptor: ([Ljava/lang/String;)V    flags: ACC_PUBLIC, ACC_STATIC    Code:    stack=3, locals=4, args_size=1        0: ldc           #8                  // String Mike        2: astore_0        3: ldc           #9                  // int 2370        5: istore_1        6: getstatic     #15                 // Field java/lang/System.out:Ljava/io/PrintStream;        9: aload_0        10: invokevirtual #21                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V        13: getstatic     #15                 // Field java/lang/System.out:Ljava/io/PrintStream;        16: iload_1        17: invokevirtual #24                 // Method java/io/PrintStream.println:(I)V        20: ldc2_w        #25                 // double 1.1d        23: dstore_2        24: getstatic     #15                 // Field java/lang/System.out:Ljava/io/PrintStream;        27: dload_2        28: invokevirtual #29                 // Method java/io/PrintStream.println:(D)V        31: return}

参考

ANTLR4表达式
Java代码
Java ASM系列
Enkel-JVM-language

☑️ ⭐

Kubernetes的安装和使用(二)

k8s的使用

构建和运行镜像

编写一个go程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"io"
"log"
"net/http"
)

func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "[v1] Hello, Kubernetes!")
})

log.Printf("v1 access http://localhost:3000\n")
panic(http.ListenAndServe(":3000", nil))
}

编写Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 引入golang的环境,并设置别名
FROM golang:1.20-alpine AS builder
# 工作目录
WORKDIR /project
# 把当前文件夹的内容都添加到镜像的/project文件夹下
ADD . .
# 编译golang源代码
# 设置代理、操作系统、CPU架构、禁用cgo编译
# 设置mod为自动模式,防止因为没有设置go.mod而编译报错
RUN GOPROXY=https://goproxy.cn,direct GOOS=linux GOARCH=amd64 CGO_ENABLED=0 GO111MODULE=auto go build -o main -ldflags "-w -extldflags -static"

# 引入alpine系统环境
FROM alpine as prod
# 从上面的build镜像复制编译好的文件/project/main到当前目录
COPY --from=builder /project/main .
# 暴露3000端口
EXPOSE 3000
# 启动main程序
ENTRYPOINT ["/main"]

打包镜像

docker build . -t derobukal/hellok8s:v1

执行镜像,并把3000端口暴露出来

docker run --rm -p 3000:3000 derobukal/hellok8s:v1

之后可以用curl访问3000端口,结果正常显示了

~ curl 127.0.0.1:3000[v1] Hello, Kubernetes!%   

之后可以把这个镜像推送到镜像仓库

docker logindocker push derobukal/hellok8s:v1

使用Pod

编写pod.yaml

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: Pod # 资源类型为pod
metadata:
name: go-http # 名称,需要在当前命名空间中唯一
labels:
app: go
version: v1
spec:
containers: # pod内的容器组
- name: go-http
image: derobukal/hellok8s:v1 # 镜像默认来源 DockerHub

之后创建pod

~ kc apply -f pod.yamlpod/go-http created~ kc get podsNAME      READY   STATUS    RESTARTS   AGEgo-http   1/1     Running   0          65s

然后临时开启端口转发,就可以访问相应的服务了

~ kc port-forward go-http 3000:3000Forwarding from 127.0.0.1:3000 -> 3000Forwarding from [::1]:3000 -> 3000~ curl http://127.0.0.1:3000[v1] Hello, Kubernetes!%

在进行以上测试的时候,如果出现pod启动不了的情况,可能是因为防火墙的原因,可以开启网络代理之后再试。

使用Deployment

一般来说,pod不会被直接的使用,而是用Deployment来进行相关的操作。

编写deployment.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: apps/v1
kind: Deployment
metadata:
# deployment 唯一名称
name: hellok8s-go-http
spec:
replicas: 2 # 副本数量
selector:
matchLabels:
app: hellok8s # 管理template下所有 app=hellok8s的pod,(要求和template.metadata.labels完全一致!!!否则无法部署deployment)
template: # template 定义一组容器
metadata:
labels:
app: hellok8s
spec:
containers:
- image: derobukal/hellok8s:v1
name: hellok8s

部署deployment

~ kc apply -f deployment.yaml deployment.apps/hellok8s-go-http created~ kc get deploymentsNAME               READY   UP-TO-DATE   AVAILABLE   AGEhellok8s-go-http   2/2     2            2           79s

kc get pod -o wide可以查看更详细的pod信息。我们可以把配置中的副本数改为3,之后重新执行部署命令,然后查看详细的pod信息如下,可以看到又新增了一个pod

NAME                                READY   STATUS    RESTARTS   AGE     IP           NODE       NOMINATED NODE   READINESS GATESgo-http                             1/1     Running   0          14m     10.244.0.6   minikube   <none>           <none>hellok8s-go-http-6db476b8cb-4n2nx   1/1     Running   0          4m35s   10.244.0.7   minikube   <none>           <none>hellok8s-go-http-6db476b8cb-s76jr   1/1     Running   0          4m35s   10.244.0.8   minikube   <none>           <none>hellok8s-go-http-6db476b8cb-tjqs9   1/1     Running   0          8s      10.244.0.9   minikube   <none>           <none>

我们选取任意一个pod进行端口转发,随后请求可以看到结果正常

~ kc port-forward hellok8s-go-http-6db476b8cb-4n2nx 3000:3000 Forwarding from 127.0.0.1:3000 -> 3000Forwarding from [::1]:3000 -> 3000Handling connection for 3000

更新deployment

我们将golang程序中的v1修改为v2,之后打包新版本镜像并上传

docker build . -t derobukal/hellok8s:v2docker push derobukal/hellok8s:v2

之后我们修改deployment.yaml中的镜像为derobukal/hellok8s:v2,然后更新部署

~ kc apply -f deployment.yaml deployment.apps/hellok8s-go-http configured

可以看到pod都重新部署了

~ kc get pod -o wide                                         NAME                               READY   STATUS    RESTARTS   AGE   IP            NODE       NOMINATED NODE   READINESS GATESgo-http                            1/1     Running   0          25m   10.244.0.6    minikube   <none>           <none>hellok8s-go-http-8b44d58c5-5bmx7   1/1     Running   0          34s   10.244.0.12   minikube   <none>           <none>hellok8s-go-http-8b44d58c5-djfcd   1/1     Running   0          35s   10.244.0.11   minikube   <none>           <none>hellok8s-go-http-8b44d58c5-rt29z   1/1     Running   0          58s   10.244.0.10   minikube   <none>           <none>

之后选取一个Pod进行端口转发,并请求发现返回结果发生了变化

~ kc port-forward hellok8s-go-http-8b44d58c5-5bmx7 3000:3000 Forwarding from 127.0.0.1:3000 -> 3000Forwarding from [::1]:3000 -> 3000Handling connection for 3000~ curl http://127.0.0.1:3000[v2] Hello, Kubernetes!%

回滚deployment

查看版本信息

~ kc rollout history deployment/hellok8s-go-httpdeployment.apps/hellok8s-go-http REVISION  CHANGE-CAUSE1         <none>2         <none>

查看具体版本2

~ kc rollout history deployment/hellok8s-go-http --revision=2deployment.apps/hellok8s-go-http with revision #2Pod Template:Labels:app=hellok8s    pod-template-hash=8b44d58c5Containers:hellok8s:    Image:derobukal/hellok8s:v2    Port:<none>    Host Port:<none>    Environment:<none>    Mounts:<none>Volumes:<none>

查看具体版本1

~ kc rollout history deployment/hellok8s-go-http --revision=1deployment.apps/hellok8s-go-http with revision #1Pod Template:Labels:app=hellok8s    pod-template-hash=6db476b8cbContainers:hellok8s:    Image:derobukal/hellok8s:v1    Port:<none>    Host Port:<none>    Environment:<none>    Mounts:<none>Volumes:<none>

回退到版本1

~ kc rollout undo deployment/hellok8s-go-http --to-revision=1deployment.apps/hellok8s-go-http rolled back

此时查看pod可以发现pod已经发生了改变,访问服务返回的也是v1版本信息了。

部署失败

我们构建一个如下的程序

1
2
3
4
5
package main

func main() {
panic("something went wrong")
}

之后打包一个新版本镜像:docker build . -t derobukal/hellok8s:v_error
并对这个镜像进行push:docker push derobukal/hellok8s:v_error

之后可以简单地使用命令而不修改deployment.yaml文件来重新部署deployment,通过命令修改镜像:

~ kc set image deployment/hellok8s-go-http hellok8s=derobukal/hellok8s:v2_error  deployment.apps/hellok8s-go-http image updated~ kc get podsNAME                                READY   STATUS              RESTARTS        AGEgo-http                             1/1     Running             1 (7m34s ago)   54mhellok8s-go-http-55669566cb-l69hx   0/1     ContainerCreating   0               41shellok8s-go-http-6db476b8cb-dlr2z   1/1     Running             1 (7m34s ago)   22mhellok8s-go-http-6db476b8cb-glx7h   1/1     Running             1 (7m34s ago)   22mhellok8s-go-http-6db476b8cb-sfxnr   1/1     Running             1 (7m34s ago)   22m

重新部署之后我们查看pod可以发现,之前的pod仍然在正常运行,而新启动的pod则处于ContainerCreating状态。我们可以直接回退到上一个版本

~ kc rollout undo deployment/hellok8s-go-http --to-revision=2deployment.apps/hellok8s-go-http rolled back~ kc get podsNAME                                READY   STATUS        RESTARTS      AGEgo-http                             1/1     Running       1 (12m ago)   58mhellok8s-go-http-55669566cb-l69hx   0/1     Terminating   0             5m17shellok8s-go-http-8b44d58c5-74mhw    1/1     Running       0             22shellok8s-go-http-8b44d58c5-hfpjj    1/1     Running       0             20shellok8s-go-http-8b44d58c5-lzwfm    1/1     Running       0             19s
☑️ ⭐

Kubernetes的安装和使用(一)

k8s是一种可以实现容器集群的自动化部署、自动扩缩容、维护等功能的服务。Docker解决了应用运行时环境的问题,而k8s则可以用来构建大量应用服务,它能方便的管理海量应用容器。它拥有自动包装、自我修复、横向缩放、服务发现、负载均衡、自动部署、升级回滚、存储编排等特性。

k8s的节点分为master和node,它的架构如下

Master:官方叫做控制平面(Control Plane),它用于负责整个集群的管控。master由4个部分组成

  1. API Server进程,负责任何资源的管理和操作
  2. etcd,用于保存集群状态,只有apiServer可以读写
  3. 调度器(Scheduler),用于调度Pod资源
  4. 控制器管理器(kube-controller-manager)

Node:数据平面,是实际的工作节点,直接负责对容器的资源控制。node由3个部分组成

  1. kubelet,运行在每个节点上面的代理进程
  2. kube-proxy,负责每个节点的网络服务
  3. 容器运行时,例如docker

k8s还定义了一些内核抽象

1. Pod

Pod是k8s调度的基本单元,它封装了一个或多个容器。Pod中的容器会作为一个整体被k8s调度到一个Node上运行。同一个Pod内的容器可以互相操作对方的文件,这些容器就好像运行在同一个操作系统上的不同进程一样。

2. 控制器

一般来说,用户不会直接创建Pod,而是创建控制器来管理Pod,因为控制器能够更细粒度的控制Pod的运行方式,比如副本数量、部署位置等。 控制器包含下面几种:

  • Replication控制器(以及ReplicaSet控制器):负责保证Pod副本数量符合预期(涉及对Pod的启动、停止等操作)
  • Deployment控制器:是高于Replication控制器的对象,也是最常用的控制器,用于管理Pod的发布、更新、回滚等
  • StatefulSet控制器:与Deployment同级,提供排序和唯一性保证的特殊Pod控制器。用于管理有状态服务,比如数据库等
  • DaemonSet控制器:与Deployment同级,用于在集群中的每个Node上运行单个Pod,多用于日志收集和转发、监控等功能的服务。并且它可以绕过常规Pod无法调度到Master运行的限制
  • Job控制器:与Deployment同级,用于管理一次性任务,比如批处理任务
  • CronJob控制器:与Deployment同级,在Job控制器基础上增加了时间调度,用于执行定时任务

3. Service、Ingress和Storage

Service是对一组Pod的抽象,它定义了Pod的逻辑集合以及访问该集合的策略。前面的Deployment等控制器只定义了Pod运行数量和生命周期, 并没有定义如何访问这些Pod,由于Pod重启后IP会发生变化,没有固定IP和端口提供服务。
Service对象就是为了解决这个问题。Service可以自动跟踪并绑定后端控制器管理的多个Pod,即使发生重启、扩容等事件也能自动处理, 同时提供统一IP供前端访问,所以通过Service就可以获得服务发现的能力,部署微服务时就无需单独部署注册中心组件。
Ingress不是一种服务类型,而是一个路由规则集合,通过Ingress规则定义的规则,可以将多个Service组合成一个虚拟服务(如前端页面+后端API)。 它可实现业务网关的作用,类似Nginx的用法,可以实现负载均衡、SSL卸载、流量转发、流量控制等功能。
Storage是Pod中用于存储的抽象,它定义了Pod的存储卷,包括本地存储和网络存储;它的生命周期独立于Pod之外,可进行单独控制。

4. 资源划分

命名空间(Namespace):k8s通过namespace对同一台物理机上的k8s资源进行逻辑隔离。
标签(Labels):是一种语义化标记,可以附加到Pod、Node等对象之上,然后更高级的对象可以基于标签对它们进行筛选和调用, 例如Service可以将请求只路由到指定标签的Pod,或者Deployment可以将Pod只调度到指定标签的Node。
注解(Annotations):也是键值对数据,但更灵活,它的value允许包含结构化数据。一般用于元数据配置,不用于筛选。 例如Ingress中通过注解为nginx控制器配置禁用ssl重定向。

k8s的安装

k8s的安装比较复杂,需要涉及到很多的Linux、网络、存储等设置。为了简单起见,我们先学习使用minikube安装单机的k8s环境,等学习并熟悉了k8s的使用之后,再去搭建k8s的集群环境。

安装kubectl

kubectl是k8s的客户端,我们可以通过它和k8s的服务进行交互,我们直接从k8s的官网上下载它并将其安装到/usr/local/bin目录下

# 下载kubectl客户端,这里使用了代理curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" -x http://192.168.65.100:7890# 将kubectl客户端安装到指定的bin目录下sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl

为了方便后面的使用,可以将kc设置为kubectl的别名,将如下配置添加到~/.zshrc文件中

alias kc="kubectl"

之后我们就可以查看kubectl的版本了

~ kc version --client --output=json{    "clientVersion": {        "major": "1",        "minor": "29",        "gitVersion": "v1.29.0",        "gitCommit": "3f7a50f38688eb332e2a1b013678c6435d539ae6",        "gitTreeState": "clean",        "buildDate": "2023-12-13T08:51:44Z",        "goVersion": "go1.21.5",        "compiler": "gc",        "platform": "linux/amd64"    },    "kustomizeVersion": "v5.0.4-0.20230601165947-6ce0bf390ce3"}

安装Docker

Docker的安装参考了官方文档,具体步骤如下

添加Docker的官方GPG秘钥

sudo apt-get updatesudo apt-get install ca-certificates curl gnupgsudo install -m 0755 -d /etc/apt/keyringscurl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpgsudo chmod a+r /etc/apt/keyrings/docker.gpg

把仓库添加到apt的资源列表中

echo \"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \sudo tee /etc/apt/sources.list.d/docker.list > /dev/nullsudo apt-get update

安装相关的程序并进行权限设置

# 安装程序sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin# 设置文件权限,并把当前用户添加到docker组中sudo chmod 666 /var/run/docker.socksudo usermod -aG docker $USER

安装好了Docker并设置完权限之后,可以执行Docker的hello-world查看是否安装成功了

docker run hello-world

安装成功的输出如下

Hello from Docker!This message shows that your installation appears to be working correctly.To generate this message, Docker took the following steps:    1. The Docker client contacted the Docker daemon.    2. The Docker daemon pulled the "hello-world" image from the Docker Hub.        (amd64)    3. The Docker daemon created a new container from that image which runs the        executable that produces the output you are currently reading.    4. The Docker daemon streamed that output to the Docker client, which sent it        to your terminal.To try something more ambitious, you can run an Ubuntu container with:    $ docker run -it ubuntu bashShare images, automate workflows, and more with a free Docker ID:    https://hub.docker.com/For more examples and ideas, visit:    https://docs.docker.com/get-started/

安装minikube

与kubectl的安装类似,我们还是使用下载并安装的方式安装minikube

# 下载curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64# 安装到指定目录下面sudo install minikube-linux-amd64 /usr/local/bin/minikube

安装之后,我们就可以启动minikube了。因为网络原因,直接使用minikube start命令有的时候无法正常启动,因此我们可以使用代理

~ minikube start http_proxy=http://192.168.65.100:7890 https_proxy=http://192.168.65.100:7890😄  minikube v1.32.0 on Ubuntu 22.04✨  Using the docker driver based on existing profile👍  Starting control plane node minikube in cluster minikube🚜  Pulling base image ...🔄  Restarting existing docker container for "minikube" ...🐳  Preparing Kubernetes v1.28.3 on Docker 24.0.7 ...🔗  Configuring bridge CNI (Container Networking Interface) ...🔎  Verifying Kubernetes components...    ▪ Using image gcr.io/k8s-minikube/storage-provisioner:v5🌟  Enabled addons: default-storageclass, storage-provisioner🏄  Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default

看到以上内容,则说明k8s已经启动好了。接下来我们就可以使用kubectl来管理k8s了

查看版本信息

~ kc versionClient Version: v1.29.0Kustomize Version: v5.0.4-0.20230601165947-6ce0bf390ce3Server Version: v1.28.3

查看k8s集群信息

~ kc cluster-infoKubernetes control plane is running at https://192.168.49.2:8443CoreDNS is running at https://192.168.49.2:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxyTo further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.

查看节点信息

~ kc get nodesNAME       STATUS   ROLES           AGE     VERSIONminikube   Ready    control-plane   4h48m   v1.28.3

参考

Kubernetes 安装小记
Kubernetes 使用小记
Kubernetes 基础教程
Docker 入门教程

🔲 ⭐

Kubernetes的安装和使用(二)

k8s的使用

构建和运行镜像

编写一个go程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"io"
"log"
"net/http"
)

func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "[v1] Hello, Kubernetes!")
})

log.Printf("v1 access http://localhost:3000\n")
panic(http.ListenAndServe(":3000", nil))
}

编写Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 引入golang的环境,并设置别名
FROM golang:1.20-alpine AS builder
# 工作目录
WORKDIR /project
# 把当前文件夹的内容都添加到镜像的/project文件夹下
ADD . .
# 编译golang源代码
# 设置代理、操作系统、CPU架构、禁用cgo编译
# 设置mod为自动模式,防止因为没有设置go.mod而编译报错
RUN GOPROXY=https://goproxy.cn,direct GOOS=linux GOARCH=amd64 CGO_ENABLED=0 GO111MODULE=auto go build -o main -ldflags "-w -extldflags -static"

# 引入alpine系统环境
FROM alpine as prod
# 从上面的build镜像复制编译好的文件/project/main到当前目录
COPY --from=builder /project/main .
# 暴露3000端口
EXPOSE 3000
# 启动main程序
ENTRYPOINT ["/main"]

打包镜像

docker build . -t derobukal/hellok8s:v1

执行镜像,并把3000端口暴露出来

docker run --rm -p 3000:3000 derobukal/hellok8s:v1

之后可以用curl访问3000端口,结果正常显示了

~ curl 127.0.0.1:3000[v1] Hello, Kubernetes!%   

之后可以把这个镜像推送到镜像仓库

docker logindocker push derobukal/hellok8s:v1

使用Pod

编写pod.yaml

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: Pod # 资源类型为pod
metadata:
name: go-http # 名称,需要在当前命名空间中唯一
labels:
app: go
version: v1
spec:
containers: # pod内的容器组
- name: go-http
image: derobukal/hellok8s:v1 # 镜像默认来源 DockerHub

之后创建pod

~ kc apply -f pod.yamlpod/go-http created~ kc get podsNAME      READY   STATUS    RESTARTS   AGEgo-http   1/1     Running   0          65s

然后临时开启端口转发,就可以访问相应的服务了

~ kc port-forward go-http 3000:3000Forwarding from 127.0.0.1:3000 -> 3000Forwarding from [::1]:3000 -> 3000~ curl http://127.0.0.1:3000[v1] Hello, Kubernetes!%

在进行以上测试的时候,如果出现pod启动不了的情况,可能是因为防火墙的原因,可以开启网络代理之后再试。

使用Deployment

一般来说,pod不会被直接的使用,而是用Deployment来进行相关的操作。

编写deployment.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: apps/v1
kind: Deployment
metadata:
# deployment 唯一名称
name: hellok8s-go-http
spec:
replicas: 2 # 副本数量
selector:
matchLabels:
app: hellok8s # 管理template下所有 app=hellok8s的pod,(要求和template.metadata.labels完全一致!!!否则无法部署deployment)
template: # template 定义一组容器
metadata:
labels:
app: hellok8s
spec:
containers:
- image: derobukal/hellok8s:v1
name: hellok8s

部署deployment

~ kc apply -f deployment.yaml deployment.apps/hellok8s-go-http created~ kc get deploymentsNAME               READY   UP-TO-DATE   AVAILABLE   AGEhellok8s-go-http   2/2     2            2           79s

kc get pod -o wide可以查看更详细的pod信息。我们可以把配置中的副本数改为3,之后重新执行部署命令,然后查看详细的pod信息如下,可以看到又新增了一个pod

NAME                                READY   STATUS    RESTARTS   AGE     IP           NODE       NOMINATED NODE   READINESS GATESgo-http                             1/1     Running   0          14m     10.244.0.6   minikube   <none>           <none>hellok8s-go-http-6db476b8cb-4n2nx   1/1     Running   0          4m35s   10.244.0.7   minikube   <none>           <none>hellok8s-go-http-6db476b8cb-s76jr   1/1     Running   0          4m35s   10.244.0.8   minikube   <none>           <none>hellok8s-go-http-6db476b8cb-tjqs9   1/1     Running   0          8s      10.244.0.9   minikube   <none>           <none>

我们选取任意一个pod进行端口转发,随后请求可以看到结果正常

~ kc port-forward hellok8s-go-http-6db476b8cb-4n2nx 3000:3000 Forwarding from 127.0.0.1:3000 -> 3000Forwarding from [::1]:3000 -> 3000Handling connection for 3000

更新deployment

我们将golang程序中的v1修改为v2,之后打包新版本镜像并上传

docker build . -t derobukal/hellok8s:v2docker push derobukal/hellok8s:v2

之后我们修改deployment.yaml中的镜像为derobukal/hellok8s:v2,然后更新部署

~ kc apply -f deployment.yaml deployment.apps/hellok8s-go-http configured

可以看到pod都重新部署了

~ kc get pod -o wide                                         NAME                               READY   STATUS    RESTARTS   AGE   IP            NODE       NOMINATED NODE   READINESS GATESgo-http                            1/1     Running   0          25m   10.244.0.6    minikube   <none>           <none>hellok8s-go-http-8b44d58c5-5bmx7   1/1     Running   0          34s   10.244.0.12   minikube   <none>           <none>hellok8s-go-http-8b44d58c5-djfcd   1/1     Running   0          35s   10.244.0.11   minikube   <none>           <none>hellok8s-go-http-8b44d58c5-rt29z   1/1     Running   0          58s   10.244.0.10   minikube   <none>           <none>

之后选取一个Pod进行端口转发,并请求发现返回结果发生了变化

~ kc port-forward hellok8s-go-http-8b44d58c5-5bmx7 3000:3000 Forwarding from 127.0.0.1:3000 -> 3000Forwarding from [::1]:3000 -> 3000Handling connection for 3000~ curl http://127.0.0.1:3000[v2] Hello, Kubernetes!%

回滚deployment

查看版本信息

~ kc rollout history deployment/hellok8s-go-httpdeployment.apps/hellok8s-go-http REVISION  CHANGE-CAUSE1         <none>2         <none>

查看具体版本2

~ kc rollout history deployment/hellok8s-go-http --revision=2deployment.apps/hellok8s-go-http with revision #2Pod Template:Labels:app=hellok8s    pod-template-hash=8b44d58c5Containers:hellok8s:    Image:derobukal/hellok8s:v2    Port:<none>    Host Port:<none>    Environment:<none>    Mounts:<none>Volumes:<none>

查看具体版本1

~ kc rollout history deployment/hellok8s-go-http --revision=1deployment.apps/hellok8s-go-http with revision #1Pod Template:Labels:app=hellok8s    pod-template-hash=6db476b8cbContainers:hellok8s:    Image:derobukal/hellok8s:v1    Port:<none>    Host Port:<none>    Environment:<none>    Mounts:<none>Volumes:<none>

回退到版本1

~ kc rollout undo deployment/hellok8s-go-http --to-revision=1deployment.apps/hellok8s-go-http rolled back

此时查看pod可以发现pod已经发生了改变,访问服务返回的也是v1版本信息了。

部署失败

我们构建一个如下的程序

1
2
3
4
5
package main

func main() {
panic("something went wrong")
}

之后打包一个新版本镜像:docker build . -t derobukal/hellok8s:v_error
并对这个镜像进行push:docker push derobukal/hellok8s:v_error

之后可以简单地使用命令而不修改deployment.yaml文件来重新部署deployment,通过命令修改镜像:

~ kc set image deployment/hellok8s-go-http hellok8s=derobukal/hellok8s:v2_error  deployment.apps/hellok8s-go-http image updated~ kc get podsNAME                                READY   STATUS              RESTARTS        AGEgo-http                             1/1     Running             1 (7m34s ago)   54mhellok8s-go-http-55669566cb-l69hx   0/1     ContainerCreating   0               41shellok8s-go-http-6db476b8cb-dlr2z   1/1     Running             1 (7m34s ago)   22mhellok8s-go-http-6db476b8cb-glx7h   1/1     Running             1 (7m34s ago)   22mhellok8s-go-http-6db476b8cb-sfxnr   1/1     Running             1 (7m34s ago)   22m

重新部署之后我们查看pod可以发现,之前的pod仍然在正常运行,而新启动的pod则处于ContainerCreating状态。我们可以直接回退到上一个版本

~ kc rollout undo deployment/hellok8s-go-http --to-revision=2deployment.apps/hellok8s-go-http rolled back~ kc get podsNAME                                READY   STATUS        RESTARTS      AGEgo-http                             1/1     Running       1 (12m ago)   58mhellok8s-go-http-55669566cb-l69hx   0/1     Terminating   0             5m17shellok8s-go-http-8b44d58c5-74mhw    1/1     Running       0             22shellok8s-go-http-8b44d58c5-hfpjj    1/1     Running       0             20shellok8s-go-http-8b44d58c5-lzwfm    1/1     Running       0             19s
☑️ ⭐

购房贷款中等额本金与等额本息的计算方式

在我们买房子的时候,如果手上的现金不够,可以选择通过向银行借钱的方式来得到缺少部分的购房现金。一般来说,我们以房子本身作为抵押来向银行借钱,借到钱之后我们就可以用手上的钱向开发商购买房屋,之后只需要按月向银行还钱即可。如果我们单方面对银行进行债务违约,则银行有权利收回借贷抵押物即该套房屋。

当然天下没有免费的午餐,在我们向银行借钱的时候,是需要向银行支付利息的。以中国人民银行2020年12月21日的5年期以上全国银行间同业拆借中心LPR公布价为例,该值为4.65%,其决定了2021年一整年的房贷利率。当然,商业银行在真正进行贷款发放时可能会对利率进行一定的上浮或打折。

基准利率与LPR

LRP是央行在最近几年刚刚推出的一个新的利率计算方式,在以前,购房的利率主要参考的是央行基准利率,基准利率的当前值为4.9%。如果你之前的利率是锚定在基准利率上的,根据中国人民银行公告〔2019〕第30号中规定的商业性个人住房贷款的加点数值应等于原合同最近的执行利率水平与2019年12月发布的相应期限LPR的差值,那么将购房贷款利率的锚从基准利率改为LPR的计算方式如下(已知2019年12月发布的LPR的值为4.8%)

实际购房的房贷利率 - 4.8% + 当前LPR利率

例如你在2015年买房,但是当前的利率是有折扣,我们假设是9折,则贷款的真实利率为 4.9% x 0.9 = 4.41%,即最终的贷款利率是4.41%。那么你的利率转化为LPR之后的值则为 4.41% - 4.8% + 4.65% = 4.26%

央行之所以在基准利率之外又推出了一个LPR,本质上是因为基准利率是由央行直接制定的,而LPR则还会参考一些商业银行的意见。商业银行直接面对了贷款人,对市场行情的了解显然比央行更加及时,所以LPR相比于基准利率也更加能代表市场的真实利率。就好像汽车厂商一般都会给4S店一定的汽车售价变更权利,最终的汽车成交价会在厂商的销售指导价周围产生一定的上升或下降。因为4S店比汽车厂商更加了解市场的真实需求,如果车子好卖就会提升价格获得更多的收益,如果车子不好卖那么也会在一定程度上降低车的售价。LPR就好像是除了给4S店修改售价的权利之外,厂商还会每个月抽取一些4S店来参与产商的报价,最终决定当月的汽车厂商指导价。LPR是每月进行一次报价,而房贷利率是每年一变并且只决定于前一年12月份的LPR报价。LPR的变化不仅影响新增的贷款,也影响已经存在的贷款。

LPR的推出表明我国在近一步的深入市场化改革,摒弃计划经济的一些遗留问题。至于LPR以后会上升还是会下降的问题,这个我认为目前还没有人能够预测。前面扯了一堆LPR的相关问题,只是为了告诉读者LPR是如何出现、如何决定的,在有了贷款利率之后,下面我我们就来了解一下银行是如何根据利率和贷款本金计算贷款人的月供的。这就涉及到两种月供计算方式:等额本金和等额本息。我们以100万贷款为例,假设借贷人借款20年且利率为当前的LPR值即4.65%。

等额本金

等额本金的计算方式十分简单,就是每个月还的本金是等额的。以100万为例,借贷20年就是240个月,那么每个月需要还的本金就是 1000000 ÷ 240 = 4166.6666666667,即每个月需要还本金4166.67块钱。

计算完本金我们再计算利息,因为我们每个月还一次本金,那么每个月的利息其实都是不一样的。每个月需要还的利息计算公式如下

借贷本金 x 年利率 ÷ 12

对于第一个月,需要还的利息为 1000000 x 4.65% ÷ 12 = 3875,那么第一个月要还的本金和利息加起来就是 4166.67 + 3875 = 8041.67

对月第二个月,因为我们已经还了一个月的本金了,所以需要还的利息为 (1000000 - 4166.67) x 4.65% ÷ 12 = 3858.85,那么第二月的月供就为 4166.67 + 3858.85 = 8025.52

通过以上的规律我们就能算出每一个的月供了,我们也可以观察到,等额本金这种方式一开始每个月的月供是比较高的,但是随着本金的降低,利息也会逐月降低,那么月供也是同样会逐月降低的。

具体每个月的月供我们可以通过如下的代码计算得到

1
2
3
4
5
for i in range(240):
principal = 4166.67 # 本金
interest = (1000000 - 4166.67 * i) * 0.0465 / 12 # 利息
total = principal + interest # 月供
print('{:>3}月 {:.2f}(元)'.format(i + 1, total))

以上代码得到结果如下

  1月 8041.67(元)  2月 8025.52(元)  3月 8009.38(元)  4月 7993.23(元)  5月 7977.09(元)  6月 7960.94(元)  7月 7944.79(元)  8月 7928.65(元)  9月 7912.50(元) 10月 7896.36(元) 11月 7880.21(元) 12月 7864.07(元) 13月 7847.92(元) 14月 7831.77(元) 15月 7815.63(元) 16月 7799.48(元) 17月 7783.34(元) 18月 7767.19(元) 19月 7751.04(元) 20月 7734.90(元) 21月 7718.75(元) 22月 7702.61(元) 23月 7686.46(元) 24月 7670.32(元) 25月 7654.17(元) 26月 7638.02(元) 27月 7621.88(元) 28月 7605.73(元) 29月 7589.59(元) 30月 7573.44(元) 31月 7557.29(元) 32月 7541.15(元) 33月 7525.00(元) 34月 7508.86(元) 35月 7492.71(元) 36月 7476.57(元) 37月 7460.42(元) 38月 7444.27(元) 39月 7428.13(元) 40月 7411.98(元) 41月 7395.84(元) 42月 7379.69(元) 43月 7363.54(元) 44月 7347.40(元) 45月 7331.25(元) 46月 7315.11(元) 47月 7298.96(元) 48月 7282.82(元) 49月 7266.67(元) 50月 7250.52(元) 51月 7234.38(元) 52月 7218.23(元) 53月 7202.09(元) 54月 7185.94(元) 55月 7169.79(元) 56月 7153.65(元) 57月 7137.50(元) 58月 7121.36(元) 59月 7105.21(元) 60月 7089.07(元) 61月 7072.92(元) 62月 7056.77(元) 63月 7040.63(元) 64月 7024.48(元) 65月 7008.34(元) 66月 6992.19(元) 67月 6976.04(元) 68月 6959.90(元) 69月 6943.75(元) 70月 6927.61(元) 71月 6911.46(元) 72月 6895.31(元) 73月 6879.17(元) 74月 6863.02(元) 75月 6846.88(元) 76月 6830.73(元) 77月 6814.59(元) 78月 6798.44(元) 79月 6782.29(元) 80月 6766.15(元) 81月 6750.00(元) 82月 6733.86(元) 83月 6717.71(元) 84月 6701.56(元) 85月 6685.42(元) 86月 6669.27(元) 87月 6653.13(元) 88月 6636.98(元) 89月 6620.84(元) 90月 6604.69(元) 91月 6588.54(元) 92月 6572.40(元) 93月 6556.25(元) 94月 6540.11(元) 95月 6523.96(元) 96月 6507.81(元) 97月 6491.67(元) 98月 6475.52(元) 99月 6459.38(元)100月 6443.23(元)101月 6427.09(元)102月 6410.94(元)103月 6394.79(元)104月 6378.65(元)105月 6362.50(元)106月 6346.36(元)107月 6330.21(元)108月 6314.06(元)109月 6297.92(元)110月 6281.77(元)111月 6265.63(元)112月 6249.48(元)113月 6233.34(元)114月 6217.19(元)115月 6201.04(元)116月 6184.90(元)117月 6168.75(元)118月 6152.61(元)119月 6136.46(元)120月 6120.31(元)121月 6104.17(元)122月 6088.02(元)123月 6071.88(元)124月 6055.73(元)125月 6039.59(元)126月 6023.44(元)127月 6007.29(元)128月 5991.15(元)129月 5975.00(元)130月 5958.86(元)131月 5942.71(元)132月 5926.56(元)133月 5910.42(元)134月 5894.27(元)135月 5878.13(元)136月 5861.98(元)137月 5845.83(元)138月 5829.69(元)139月 5813.54(元)140月 5797.40(元)141月 5781.25(元)142月 5765.11(元)143月 5748.96(元)144月 5732.81(元)145月 5716.67(元)146月 5700.52(元)147月 5684.38(元)148月 5668.23(元)149月 5652.08(元)150月 5635.94(元)151月 5619.79(元)152月 5603.65(元)153月 5587.50(元)154月 5571.36(元)155月 5555.21(元)156月 5539.06(元)157月 5522.92(元)158月 5506.77(元)159月 5490.63(元)160月 5474.48(元)161月 5458.33(元)162月 5442.19(元)163月 5426.04(元)164月 5409.90(元)165月 5393.75(元)166月 5377.61(元)167月 5361.46(元)168月 5345.31(元)169月 5329.17(元)170月 5313.02(元)171月 5296.88(元)172月 5280.73(元)173月 5264.58(元)174月 5248.44(元)175月 5232.29(元)176月 5216.15(元)177月 5200.00(元)178月 5183.86(元)179月 5167.71(元)180月 5151.56(元)181月 5135.42(元)182月 5119.27(元)183月 5103.13(元)184月 5086.98(元)185月 5070.83(元)186月 5054.69(元)187月 5038.54(元)188月 5022.40(元)189月 5006.25(元)190月 4990.11(元)191月 4973.96(元)192月 4957.81(元)193月 4941.67(元)194月 4925.52(元)195月 4909.38(元)196月 4893.23(元)197月 4877.08(元)198月 4860.94(元)199月 4844.79(元)200月 4828.65(元)201月 4812.50(元)202月 4796.35(元)203月 4780.21(元)204月 4764.06(元)205月 4747.92(元)206月 4731.77(元)207月 4715.63(元)208月 4699.48(元)209月 4683.33(元)210月 4667.19(元)211月 4651.04(元)212月 4634.90(元)213月 4618.75(元)214月 4602.60(元)215月 4586.46(元)216月 4570.31(元)217月 4554.17(元)218月 4538.02(元)219月 4521.88(元)220月 4505.73(元)221月 4489.58(元)222月 4473.44(元)223月 4457.29(元)224月 4441.15(元)225月 4425.00(元)226月 4408.85(元)227月 4392.71(元)228月 4376.56(元)229月 4360.42(元)230月 4344.27(元)231月 4328.13(元)232月 4311.98(元)233月 4295.83(元)234月 4279.69(元)235月 4263.54(元)236月 4247.40(元)237月 4231.25(元)238月 4215.10(元)239月 4198.96(元)240月 4182.81(元)

我们也可以计算总利息

1
2
3
4
5
interest_total = 0
for i in range(240):
interest = (1000000 - 4166.67 * i) * 0.0465 / 12 # 利息
interest_total += interest
print(interest_total)

得到总利息:466937.13

等额本息

等额本息的计算方式要稍微复杂一些,等额本息就是每个月还的本金+利息的和是等额的,也就是每个月的月供是不变的。

我们先假设我们每个月的月供是A,其中 A = 本金 + 利息。我们假设我们每个月还完月供之后还欠银行的本金an,则有a1a2a3…a240。其中a0=1000000,a240=0。

并且我们知道,下一个月所欠的本金 = 这个月所欠的本金 + 这个月本金产生的利息 - 这个月的月供,即如下公式

an = an-1 + an-1 x (0.0465 ÷ 12) - A

因此我们可以知道如下等式,其中N的值为 (0.0465 ÷ 12) + 1 = 1.003875,即利率

a1 = N x a0 - A
a2 = N x a1 - A = N x (N x a0 - A) - A = N2 x a0 - (N + 1) x A
a3 = N x a2 - A = N x (N2 x a0 - (N + 1) x A) - A = N3 x a0 - (N2 + N + 1) x A

根据以上的的规律,我们可以总结得到一个表达式

am = Nm x a0 - (Nm-1 + Nm-2 + … + N + 1) x A

通过观察可以发现,Nm-1 + Nm-2 + … + N + 1 是一个等比数列,回忆一下我们高中就已经学过的等比数列的求和公式如下:

其中a为首项,n为项数,r为公比且r不等于1(求和公式取自维基百科)。那么我们把上面的式子带入求和公式就可以得到

am = Nm x a0 - ( ( 1 - Nm ) ÷ ( 1 - N ) ) x A

令m=240且将a0=1000000、a240=0、N=1.003875带入等式可以得到

0 = 1.003875240 x 1000000 - ( ( 1 - 1.003875240 ) ÷ ( 1 - 1.003875 ) ) x A

求解如上公式可以得到A的值为:6407.75,即等额本息每个月需要还6407.75元。

总结

在等额本金中,我们最终还的总金额是1000000 + 466937.13 = 1466937.13;而等额本息最终的还款金额是6407.75 x 240 = 1537860.0。可见等额本息的方式总还款会稍微多一些,这也很容易理解,因为等额本息一开始还的本金更少,那么最终产生的利息也就会更多一些。

参考

https://www.youtube.com/watch?v=T6FBfNpiBYw
等额本息计算公式推导

https://zhuanlan.zhihu.com/p/36677027

☑️ ⭐

Elasticsearch新增节点引发CircuitBreakingException错误

那是一个平静的上午,我因为业务需要决定给我们的Elasticsearch(以下简称ES)集群添加几个节点,当前集群ES的版本为7.5.2,而待添加的节点都是从之前的1.5.2版本的ES集群中移出来的。当前的7.5.2集群已经有了6个数据节点,我这次的工作就是准备再添加5个新的数据节点上去。

因为这5个节点都是之前1.5.2版本的ES集群的数据节点,并且在之前的集群中稳定的运行了好几年也没有出现过任何问题,所以我对它们的配置都是很放心的,这种大意的态度就为接下来的悲剧埋下了伏笔。因为这些节点在之前的集群中都没什么问题,所以我就一次性把这5个节点全部添加到了ES7集群中成为了数据节点。

出现错误

节点添加之后,ES集群开始进行分片的重新平衡,整个集群开始进行分片的搬迁和复制操作,我们的工作似乎很快就要完成了。很快,这种平静就被手机短信的铃声所打破了,短信提示业务突然开始出现了大量的读写失败错误!同时企业微信的告警群也开始大量告警,告警信息显示的都是CircuitBreakingException类型的异常,具体的报错信息我摘录部分如下

[parent] Data too large, data for [<transport_request>] would be [15634611356/14.5gb], which is larger than the limit of [15300820992/14.2gb], real usage: [15634605168/14.5gb], new bytes reserved: [6188/6kb], usages [request=0/0b, fielddata=0/0b, in_flight_requests=6188/6kb, accounting=18747688/17.8mb]

其实报错的原因非常简单,就是当前内存已经触发了parent级别的circuit breaker,导致transport_request无法继续进行。因为如果继续进行transport_request则可能导致ES产生OutOfMemory错误,ES为了避免OOM会设置一些circuit breaker (断路器),这些断路器的作用就是在内存不够的时候主动拒绝接下来的操作,而不是进一步的分配内存最终产生OutOfMemoryError,断路器的作用就是保护整个进程不至于挂掉。

我们已经知道了报错的原因是Java进程的堆内存不够了,那么到底是什么原因导致了内存会不够呢?此时此刻我暂时没有心思考虑这些问题,新增加的5个节点都在频繁报内存不够的问题,这导致了大量的线上读写失败,我当前的首要目标就是解决这些报错。这就是我之前所说的伏笔了,因为我一次性把5个节点全部加到了集群中去,所以此时某个索引的某个分片的主分片和所有副本分片可能全部分布在这些我新添加的节点上,因而我不能一次性把这些节点全部停掉,因为这样会导致这些分片的数据彻底丢失从而使得整个集群变成红色。

事实上我当时因为告警太多太紧张了所以干了这种事情,即停止了多个节点,集群状态立即变成了红色。好在这些分片还存在于停止节点的磁盘上,集群变红之后我赶紧又把这些节点起了起来,集群才又脱离红色状态。之后我只能一边忍受着告警,一边默默地等待分片的复制,只有确认一个节点上不存在某一个分片的唯一分片数据之后,我才能把这个节点停掉。

Anyway,我一边忍受着告警一边停止节点,经过一段时间之后总算是把这5个节点都停掉了,内存不足导致的读写告警终于停止了。坑爹的是此时整个集群出现了一些unassigned的分片,即这些分片未能成功分配。我们使用如下命令找到所有还没有分配的分片(unassigned shards),并且解释这些分片没能分配的原因

GET /_cluster/allocation/explain

得到错误原因如下

shard has exceeded the maximum number of retries

也就是说之前的内存错误导致了这些分片的分配失败,并且多次失败达到了最大的重试次数,此时ES放弃了对这些分片的分配操作。这种情况下我们只需要执行如下命令来手动开始进行重新分配分片

POST /_cluster/reroute?retry_failed=true

之后集群会开始对这些未分配的分片进行分配,等待一段时间的分片分配和复制之后,整个集群终于重新恢复绿色了。

出错原因

首先我们想到的就是因为GC的问题导致内存没能及时的回收掉,剩余内存不够导致了错误。我们观察了G1垃圾收集器的GC日志,G1的日志大致分为如下三个部分

# 正常的YoungGCPause Young (Normal) (G1 Evacuation Pause)# 伴随着YoungGC会有多次标记操作Pause Young (Concurrent Start) (G1 Humongous Allocation)Concurrent Cycle# MixedGCPause Young (Prepare Mixed) (G1 Evacuation Pause)Pause Young (Mixed) (G1 Evacuation Pause)

在观察了GC日志之后我们发现堆内存每每在已经达到了很高的占用率之后才会触发GC,这种情况就很有可能导致内存无法及时回收以及剩余的内存不足。如果我们能让GC更早的发生,那么就能够降低剩余内存不够的概率(虽然这样会因为GC的更加频繁而降低整个系统的吞吐量)。通过搜索我们在ES源码的Pull requests中发现了如下的GC配置

-XX:G1ReservePercent=25-XX:InitiatingHeapOccupancyPercent=30

G1垃圾收集器的官方文档中对这两个参数的解释如下

-XX:G1ReservePercent=10Sets the percentage of reserve memory to keep free so as to reduce the risk of to-space overflows. The default is 10 percent. When you increase or decrease the percentage, make sure to adjust the total Java heap by the same amount. This setting is not available in Java HotSpot VM, build 23.-XX:InitiatingHeapOccupancyPercent=45Sets the Java heap occupancy threshold that triggers a marking cycle. The default occupancy is 45 percent of the entire Java heap.

简单来说-XX:G1ReservePercent是保留的内存空间百分比,其目的是避免内存不够而导致的错误,默认值是10,我们将其提升到了25。-XX:InitiatingHeapOccupancyPercent是触发一次marking cycle的内存占用阈值百分比,默认是45,我们将其减小到了30。修改了这两个虚拟机参数之后,虚拟机就能够更早的进行GC,这样就会大大降低内存不够错误出现的概率。

修改完参数重新启动节点,这次我们一台一台的起,启动一台之后等待几个小时确认没有问题之后再启动下一台。此外,我们在新增节点之前先把cluster.routing.allocation.enable参数设置为none,等待节点确认启动完毕之后再把其设置为all,这样就可以手动控制分片分配的开始和停止。改完参数之后,节点已经不会频繁的发生内存不够的错误了,可见修改配置使得GC时间提前确实降低了GC过慢导致的内存不足问题。虽然一般的情况下节点内存已经不存在压力,但是此时还有另一个问题,就是在加入节点后搬迁分片时还是有几率触发节点内存不够的错误,此时我们只需要减慢分片搬迁的速度即可。

我们将cluster.routing.allocation.node_concurrent_recoveries的值从默认的2修改为1,这样可以降低同一时刻节点上搬迁的分片的数量。此外,我们通过设置indices.recovery.max_bytes_per_sec将每个节点分片搬迁速度从40mb/s降低为20mb/s,这也能减少分片搬迁时节点的内存压力。改完了这些配置之后,节点就再也没有出现过内存不够的错误了。

其实综上我们可以总结出这次出现问题的原因如下

  1. gc速度过慢
  2. 内存增长过快

解决办法就相应地如下

  1. 减小GC触发阈值,提升GC频率
  2. 减少数据同步速度,降低内存增加速度

参考

PB级大规模Elasticsearch集群运维与调优实践
JVM调优实战:G1中的to-space exhausted问题

另外多扯一句,ES分片复制的时候,对于从开始复制到复制结束这段时间产生的数据,是由target的translog负责记录的,之后target会对复制来的数据和自己的translog数据进行合并,得到最终数据。target的数据来源如下:

复制开始前复制过程中复制结束后
复制source分片的数据复制过程中写入的本地translog数据普通的分片本地写入数据
🔲 ⭐

一次Elasticsearch慢查询问题的排查

最近对Elasticsearch(以下简称ES)进行了升级,升级之后把部分数据从之前的ES-1.5集群同步到了现在的ES-7.5集群,之后在新集群中进行数据查询的时候发现会有偶发性的查询非常慢的情况。新集群的大部分查询耗时都在10ms以内,但是偶尔却会出现800ms左右的超高查询耗时,本文记录了该问题的排查过程。

GC的原因?

首先我们怀疑是ES的GC导致了偶发性的慢查询,我们知道JVM的GC会导致Stop The World现象,在GC时节点无法处理任何逻辑导致查询行为被阻塞最终导致超长的查询耗时,而GC这种行为本身也是会偶发的,所以和我们观察到的偶尔出现查询耗时非常高的现象非常吻合。

我们观察ES运行的GC日志,并未看到有延迟特别高的GC行为,而且也没有看到任何Old GC的动作,因此这些慢查询应该并不是因为JVM的GC行为导致的。

顺便介绍一下,我们使用的是ES-7.5.2自带的bundled JDK,它的版本为13.0.1

~ java -versionopenjdk version "13.0.1" 2019-10-15OpenJDK Runtime Environment AdoptOpenJDK (build 13.0.1+9)OpenJDK 64-Bit Server VM AdoptOpenJDK (build 13.0.1+9, mixed mode, sharing)

在该版本的Java中已经废弃了以前的GC日志打印配置方式,新版本的Java使用了一种新的叫做JVM Unified Logging Framework的方式来控制GC日志的打印,它通过 -Xlog 这个属性来进行设置。

我们在当前的ES配置jvm.options中添加GC相关配置如下

## JDK 8 GC logging8:-XX:+PrintGCDetails8:-XX:+PrintGCDateStamps8:-XX:+PrintTenuringDistribution8:-XX:+PrintGCApplicationStoppedTime8:-Xloggc:logs/gc.log8:-XX:+UseGCLogFileRotation8:-XX:NumberOfGCLogFiles=328:-XX:GCLogFileSize=64m# JDK 9+ GC logging9-:-Xlog:gc*,gc+age=trace,safepoint:file=logs/gc.log:utctime,pid,tags:filecount=32,filesize=64m

上面我们设置了两种GC日志配置,一种是针对Java8的配置,还有一种是针对大于或者等于Java9的配置,ES会根据JVM的版本选择合适的GC日志配置。

因为我们使用的是Java13,它的版本号是大于9的,因此ES会自动使用

-Xlog:gc*,gc+age=trace,safepoint:file=logs/gc.log:utctime,pid,tags:filecount=32,filesize=64m

这个Xlog的配置来设置GC LOG的打印方式。Xlog属性使用冒号 ( : ) 把它的内容分割为了四个部分:

  1. JVM中哪些tag的日志需要被打印,多个tag之间用逗号 ( , ) 分割
  2. 日志打印的目标位置,可以是标准输出或文件等等
  3. 打印日志时同时一起打印的附加属性,例如时间、进程号、tag名称等等,属性之间用逗号 ( , ) 分割
  4. 一些打印日志时会用到的其它属性

我们再了解一下上面ES使用的-Xlog属性的各个部分的具体含义,这些属性被分为了四大类

属性含义
(tags)
gc*打印JVM中tag以gc开头的日志,级别为默认值info
gc+age=trace打印JVM中tag为gc+age的日志,级别为trace
safepoint打印JVM的safepoint日志,级别为默认值info
(日志打印位置)
file=logs/gc.logGC日志所保存的文件名
(需要被打印的其它一些属性)
utctime打印GC的时间点
pidJVM的进程号
tags打印对应的tags信息
(控制日志打印的一些其它属性)
filecount=32,filesize=64m当日志文件达到64m是进行切割,保存32个切割文件

上面的Xlog可以得到日志如下,包含了gc*、gc+age和safepoint等tag的一些日志

[2020-08-01T09:54:10.551+0000][1836][gc,heap] Heap region size: 4M[2020-08-01T09:54:14.339+0000][1836][gc     ] Using G1[2020-08-01T09:54:14.339+0000][1836][gc,heap,coops] Heap address: 0x0000000600000000, size: 8192 MB, Compressed Oops mode: Zero based, Oop shift amount: 3[2020-08-01T09:54:14.339+0000][1836][gc,cds       ] Mark closed archive regions in map: [0x00000007ffc00000, 0x00000007ffc77ff8][2020-08-01T09:54:14.339+0000][1836][gc,cds       ] Mark open archive regions in map: [0x00000007ffb00000, 0x00000007ffb47ff8][2020-08-01T09:54:14.355+0000][1836][gc           ] Periodic GC disabled[2020-08-01T09:54:14.376+0000][1836][safepoint    ] Safepoint "EnableBiasedLocking", Time since last: 26126542 ns, Reaching safepoint: 711439 ns, At safepoint: 88387 ns, Total: 799826 ns[2020-08-01T09:54:14.381+0000][1836][safepoint    ] Safepoint "RevokeBias", Time since last: 4864660 ns, Reaching safepoint: 286521 ns, At safepoint: 99576 ns, Total: 386097 ns...省略部分日志...[2020-08-01T09:54:17.374+0000][1836][safepoint    ] Safepoint "RevokeBias", Time since last: 743562 ns, Reaching safepoint: 193154 ns, At safepoint: 51118 ns, Total: 244272 ns[2020-08-01T09:54:17.374+0000][1836][safepoint    ] Safepoint "RevokeBias", Time since last: 198778 ns, Reaching safepoint: 375962 ns, At safepoint: 58373 ns, Total: 434335 ns[2020-08-01T09:54:17.410+0000][1836][gc,start     ] GC(0) Pause Young (Normal) (G1 Evacuation Pause)[2020-08-01T09:54:17.412+0000][1836][gc,task      ] GC(0) Using 13 workers of 13 for evacuation[2020-08-01T09:54:17.413+0000][1836][gc,age       ] GC(0) Desired survivor size 27262976 bytes, new threshold 15 (max threshold 15)[2020-08-01T09:54:17.452+0000][1836][gc,age       ] GC(0) Age table with threshold 15 (max threshold 15)[2020-08-01T09:54:17.452+0000][1836][gc,age       ] GC(0) - age   1:   13104680 bytes,   13104680 total[2020-08-01T09:54:17.452+0000][1836][gc,phases    ] GC(0)   Pre Evacuate Collection Set: 1.8ms[2020-08-01T09:54:17.452+0000][1836][gc,phases    ] GC(0)   Evacuate Collection Set: 25.5ms[2020-08-01T09:54:17.452+0000][1836][gc,phases    ] GC(0)   Post Evacuate Collection Set: 2.3ms[2020-08-01T09:54:17.452+0000][1836][gc,phases    ] GC(0)   Other: 12.1ms[2020-08-01T09:54:17.452+0000][1836][gc,heap      ] GC(0) Eden regions: 102->0(98)[2020-08-01T09:54:17.452+0000][1836][gc,heap      ] GC(0) Survivor regions: 0->4(13)[2020-08-01T09:54:17.452+0000][1836][gc,heap      ] GC(0) Old regions: 0->0[2020-08-01T09:54:17.452+0000][1836][gc,heap      ] GC(0) Archive regions: 2->2[2020-08-01T09:54:17.452+0000][1836][gc,heap      ] GC(0) Humongous regions: 0->0[2020-08-01T09:54:17.452+0000][1836][gc,metaspace ] GC(0) Metaspace: 20335K->20335K(1067008K)[2020-08-01T09:54:17.452+0000][1836][gc           ] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 411M->16M(8192M) 41.802ms[2020-08-01T09:54:17.452+0000][1836][gc,cpu       ] GC(0) User=0.17s Sys=0.14s Real=0.04s[2020-08-01T09:54:17.452+0000][1836][safepoint    ] Safepoint "G1CollectForAllocation", Time since last: 35223271 ns, Reaching safepoint: 235074 ns, At safepoint: 42013632 ns, Total: 42248706 ns[2020-08-01T09:54:17.452+0000][1836][safepoint    ] Safepoint "RevokeBias", Time since last: 274881 ns, Reaching safepoint: 134481 ns, At safepoint: 40119 ns, Total: 174600 ns[2020-08-01T09:54:17.452+0000][1836][safepoint    ] Safepoint "RevokeBias", Time since last: 43491 ns, Reaching safepoint: 140798 ns, At safepoint: 26232 ns, Total: 167030 ns[2020-08-01T09:54:17.453+0000][1836][safepoint    ] Safepoint "RevokeBias", Time since last: 64481 ns, Reaching safepoint: 129088 ns, At safepoint: 25018 ns, Total: 154106 ns[2020-08-01T09:54:17.453+0000][1836][safepoint    ] Safepoint "RevokeBias", Time since last: 35003 ns, Reaching safepoint: 129551 ns, At safepoint: 28893 ns, Total: 158444 ns[2020-08-01T09:54:17.453+0000][1836][safepoint    ] Safepoint "RevokeBias", Time since last: 111722 ns, Reaching safepoint: 113937 ns, At safepoint: 37371 ns, Total: 151308 ns[2020-08-01T09:54:17.453+0000][1836][safepoint    ] Safepoint "RevokeBias", Time since last: 36233 ns, Reaching safepoint: 128605 ns, At safepoint: 30396 ns, Total: 159001 ns[2020-08-01T09:54:17.454+0000][1836][safepoint    ] Safepoint "RevokeBias", Time since last: 44610 ns, Reaching safepoint: 129399 ns, At safepoint: 28918 ns, Total: 158317 ns[2020-08-01T09:54:17.456+0000][1836][gc,start     ] GC(1) Pause Young (Concurrent Start) (Metadata GC Threshold)[2020-08-01T09:54:17.456+0000][1836][gc,task      ] GC(1) Using 13 workers of 13 for evacuation[2020-08-01T09:54:17.456+0000][1836][gc,age       ] GC(1) Desired survivor size 27262976 bytes, new threshold 15 (max threshold 15)[2020-08-01T09:54:17.473+0000][1836][gc,age       ] GC(1) Age table with threshold 15 (max threshold 15)[2020-08-01T09:54:17.473+0000][1836][gc,age       ] GC(1) - age   1:      29256 bytes,      29256 total[2020-08-01T09:54:17.473+0000][1836][gc,age       ] GC(1) - age   2:   13077648 bytes,   13106904 total[2020-08-01T09:54:17.473+0000][1836][gc,phases    ] GC(1)   Pre Evacuate Collection Set: 0.4ms[2020-08-01T09:54:17.473+0000][1836][gc,phases    ] GC(1)   Evacuate Collection Set: 15.3ms[2020-08-01T09:54:17.473+0000][1836][gc,phases    ] GC(1)   Post Evacuate Collection Set: 0.9ms[2020-08-01T09:54:17.473+0000][1836][gc,phases    ] GC(1)   Other: 0.6ms[2020-08-01T09:54:17.473+0000][1836][gc,heap      ] GC(1) Eden regions: 1->0(98)[2020-08-01T09:54:17.473+0000][1836][gc,heap      ] GC(1) Survivor regions: 4->4(13)[2020-08-01T09:54:17.473+0000][1836][gc,heap      ] GC(1) Old regions: 0->0[2020-08-01T09:54:17.473+0000][1836][gc,heap      ] GC(1) Archive regions: 2->2[2020-08-01T09:54:17.473+0000][1836][gc,heap      ] GC(1) Humongous regions: 0->0[2020-08-01T09:54:17.473+0000][1836][gc,metaspace ] GC(1) Metaspace: 20366K->20366K(1069056K)[2020-08-01T09:54:17.473+0000][1836][gc           ] GC(1) Pause Young (Concurrent Start) (Metadata GC Threshold) 20M->16M(8192M) 17.263ms[2020-08-01T09:54:17.473+0000][1836][gc,cpu       ] GC(1) User=0.06s Sys=0.03s Real=0.02s[2020-08-01T09:54:17.473+0000][1836][gc           ] GC(2) Concurrent Cycle[2020-08-01T09:54:17.473+0000][1836][gc,marking   ] GC(2) Concurrent Clear Claimed Marks[2020-08-01T09:54:17.473+0000][1836][gc,marking   ] GC(2) Concurrent Clear Claimed Marks 0.039ms[2020-08-01T09:54:17.473+0000][1836][gc,marking   ] GC(2) Concurrent Scan Root Regions[2020-08-01T09:54:17.473+0000][1836][safepoint    ] Safepoint "RevokeBias", Time since last: 93450 ns, Reaching safepoint: 129146 ns, At safepoint: 17510480 ns, Total: 17639626 ns[2020-08-01T09:54:17.474+0000][1836][safepoint    ] Safepoint "RevokeBias", Time since last: 59519 ns, Reaching safepoint: 157428 ns, At safepoint: 40661 ns, Total: 198089 ns

关于Xlog的更加详细的信息可以查看**参考文档**。有的时候我们只想看到GC的日志而不在意其它的日志信息,此时可以只设置gc日志的tag而移除其它日志的tag,同时我们停止打印tags信息并且将打印时间修改为ISO-8601格式。那么根据参考文档,具体设置如下

-Xlog:gc:file=logs/gc.log:t,pid:filecount=32,filesize=64m

改完之后得到的日志如下

[2020-08-07T14:41:42.809+0800][25593] Using G1[2020-08-07T14:41:42.827+0800][25593] Periodic GC disabled[2020-08-07T14:41:54.322+0800][25593] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 411M->16M(8192M) 32.843ms[2020-08-07T14:41:54.339+0800][25593] GC(1) Pause Young (Concurrent Start) (Metadata GC Threshold) 20M->16M(8192M) 12.395ms[2020-08-07T14:41:54.339+0800][25593] GC(2) Concurrent Cycle[2020-08-07T14:41:54.357+0800][25593] GC(2) Pause Remark 24M->24M(8192M) 2.347ms[2020-08-07T14:41:54.359+0800][25593] GC(2) Pause Cleanup 24M->24M(8192M) 0.409ms[2020-08-07T14:41:54.393+0800][25593] GC(2) Concurrent Cycle 53.919ms[2020-08-07T14:41:54.889+0800][25593] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 408M->19M(8192M) 16.746ms[2020-08-07T14:41:55.335+0800][25593] GC(4) Pause Young (Normal) (G1 Evacuation Pause) 411M->22M(8192M) 13.978ms[2020-08-07T14:41:56.946+0800][25593] GC(5) Pause Young (Normal) (G1 Evacuation Pause) 426M->42M(8192M) 38.684ms[2020-08-07T14:41:57.545+0800][25593] GC(6) Pause Young (Concurrent Start) (Metadata GC Threshold) 104M->50M(8192M) 106.594ms[2020-08-07T14:41:57.545+0800][25593] GC(7) Concurrent Cycle[2020-08-07T14:41:57.566+0800][25593] GC(7) Pause Remark 52M->52M(8192M) 3.391ms[2020-08-07T14:41:57.571+0800][25593] GC(7) Pause Cleanup 52M->52M(8192M) 0.689ms[2020-08-07T14:41:57.599+0800][25593] GC(7) Concurrent Cycle 54.224ms[2020-08-07T14:42:00.515+0800][25593] GC(8) Pause Young (Concurrent Start) (Metadata GC Threshold) 364M->59M(8192M) 135.170ms[2020-08-07T14:42:00.515+0800][25593] GC(9) Concurrent Cycle[2020-08-07T14:42:00.544+0800][25593] GC(9) Pause Remark 63M->63M(8192M) 3.422ms[2020-08-07T14:42:00.560+0800][25593] GC(9) Pause Cleanup 65M->65M(8192M) 0.432ms[2020-08-07T14:42:00.591+0800][25593] GC(9) Concurrent Cycle 75.614ms[2020-08-07T14:42:01.937+0800][25593] GC(10) Pause Young (Normal) (G1 Evacuation Pause) 447M->84M(8192M) 33.470ms[2020-08-07T14:42:02.786+0800][25593] GC(11) Pause Young (Normal) (G1 Evacuation Pause) 448M->117M(8192M) 42.285ms[2020-08-07T14:42:09.939+0800][25593] GC(12) Pause Young (Normal) (G1 Evacuation Pause) 1088M->730M(8192M) 60.019ms[2020-08-07T14:42:19.286+0800][25593] GC(13) Pause Young (Normal) (G1 Evacuation Pause) 1854M->769M(8192M) 51.429ms[2020-08-07T14:43:19.996+0800][25593] GC(14) Pause Young (Normal) (G1 Evacuation Pause) 4924M->770M(8192M) 43.029ms[2020-08-07T14:44:50.846+0800][25593] GC(15) Pause Young (Normal) (G1 Evacuation Pause) 6717M->771M(8192M) 50.404ms[2020-08-07T14:46:32.710+0800][25593] GC(16) Pause Young (Normal) (G1 Evacuation Pause) 7767M->774M(8192M) 41.461ms[2020-08-07T14:47:38.164+0800][25593] GC(17) Pause Young (Normal) (G1 Evacuation Pause) 5766M->855M(8192M) 48.741ms[2020-08-07T14:47:53.993+0800][25593] GC(18) Pause Young (Normal) (G1 Evacuation Pause) 5667M->809M(8192M) 40.445ms

和我们之前看到的详细日志相比,现在的日志已经显得清晰多了。对于线上ES的JVM而言,这些日志一般已经足够我们在出现问题时进行相应的排查了。当然,如果你需要了解JVM的详细工作情况,那么也可以将尽可能多tag的日志都打印出来以方便进行分析,具体需要哪些日志还是根据实际情况进行考虑。

最后再放一些其它的关于JVM GC LOG打印的参考文档

https://stackoverflow.com/q/54144713/4614538
GC logging - Elasticsearch
Setting JVM options - Elasticsearch
JEP 158: Unified JVM Logging

ES本身的Cache?

排除了GC的问题之后,我们又考虑到可能是ES本身的缓存失效导致的慢查询。验证方式非常简单,针对指定的索引我们调用ES清除缓存的接口清掉其缓存

POST /index-name/_cache/clear

清除掉缓存之后我们立即进行一次查询,发现该次查询耗时80ms左右。虽然清除缓存在一定程度上降低了查询速度,但是也并没有降低到800ms那么慢,可见ES缓存失效也不是偶发性慢查询的真正原因。因为如果是ES缓存失效导致的慢查询,那么在清除掉ES缓存之后查询速度也应该降低到800ms才对。

Page cache

我们知道在Linux操作系统中,内核是按页来管理内存的。如果想要访问磁盘上的一段数据,操作系统会分配一页(一般是4K)物理内存,之后把这些数据读取到这一页内存中以进行后续的操作。读取流程如下参考

  1. 进程调用库函数read发起读取文件请求
  2. 内核检查已打开的文件列表,调用文件系统提供的read接口
  3. 找到文件对应的inode,然后计算出要读取的具体的页
  4. 通过inode查找对应的页缓存
    1. 如果页缓存节点命中,则直接返回文件内容
    2. 如果没有对应的页缓存,则会产生一个缺页异常(page fault)
    3. 操作系统创建新的空的页缓存并从磁盘中读取文件内容,更新页缓存,然后重复第4步
  5. 读取文件返回

页缓存,也叫做文件缓存或磁盘缓存,它对于ES的核心部件Lucene十分重要。Lucene的读写文件十分依赖操作系统的页缓存来提高访问速度,以至于ES在官方文档中都提到ES的JVM进程只应该占用操作系统的一半物理内存,而把剩下的一半物理内存留给Lucene用作读写文件的页缓存。空闲内存越多,操作系统可用于Page Cache的内存也就越多,Lucene也就可以缓存越多的数据在内存中,这样就可以大大的提高Lucene的读写速度。当物理内存不足时,操作系统也会让部分缓存失效以空出内存空间。

需要注意的是,Page Cache是完全由操作系统控制的,程序无法干预。在程序读写文件时操作系统会自动的创建Page Cache来提高访问速度,但是在程序看来它只是进行了一次文件读写操作,而并不知道在读写操作背后操作系统具体是如何完成这次读写的、以及操作系统是否使用了缓存。

背景知识介绍到此。我们怀疑ES的偶发性慢查询是否是因为Page Cache的失效导致数据查询无法通过缓存获得,因此从磁盘上获取数据导致了比平时更长的查询时间呢?通过对慢查询发生时机器状态的监控我们发现,在发生慢查询时对应查询较慢的分片所在的机器节点CPU负载很低、内存空闲资源充足,并不存在内存资源不足的情况。因此应该并不是内存资源不足导致Page Cache失效而引发的慢查询。

index.search.idle.after

以上两个因素都不是偶发慢查询的真正原因,我们继续在Google上搜索相关的问题,最后我们发现了这个帖子。帖子里面提到了一个叫做 index.search.idle.after 的属性,它是在ES-7.0中新增的一个配置属性

按照官方文档的说法,只要一个分片在 index.search.idle.after 时间段(默认30s)没有能够收到任何请求,就会进入search idle状态。

但是!!!按照官方文档的说法是即使进入了search idle状态,只要 index.refresh_interval 设置了刷新间隔,分片依旧会刷新,**这部分好像与事实不符**。

事实上,一旦分片进入了search idle状态,该分片就会停止refresh以节省资源(即使设置了index.refresh_interval)。等到后面再有search请求在该分片发生时,分片首先需要进行一次refresh,refresh完成之后才会执行真正的search。所以一旦分片进入search idle之后再次查询时就会比平时消耗更多的时间。

至此我们偶发性的慢查询问题就找到原因了。因为我们新的集群刚刚搭建不久,还处于测试阶段,所以我们只切了很少的一部分查询流量到新的集群,那么每个分片在两次查询之间的间隔就有可能会大于30s。而一个分片一旦30s都没有任何查询就会进入search idle状态,那么下一次的查询自然就会比普通的查询慢很多。

知道了原因之后我们就开始着手解决这个问题,目前有两个解决方案。

1. 修改index.search.idle.after的值

我们可以把 index.search.idle.after 值改大一些,避免分片频繁的进入search idle,例如我们可以把其从默认值30秒修改为5分钟

PUT /index-name/_settings{    "index.search.idle.after": "5m"}

修改完之后我们可以看下修改是否生效

GET /index-name/_settings

得到结果如下,可见该值已经被修改5m了

{    "index-name": {        "settings": {            "index": {                "search": {                    "idle": {                        "after": "5m"                    }                },                "number_of_shards": "2",                ...省略部分内容...            }        }    }}

当然我们也可以恢复该值的默认设置

PUT /index-name/_settings{    "index.search.idle.after": null}

2. 增加请求频率

上面我们介绍了第一个解决方案是增加进入idle的时间,还有一个办法是我们把更多的查询流量切到新的集群中去,这样因为查询之间的间隔变低,也就不会进入idle状态了。

考虑到我们已经测试了一段时间的新集群了,所以我们选择第二种方案把所有的查询流量都切到新集群中,查询频率增加后也没有再出现偶尔查询很慢的情况了。

🔲 ⭐

ANTLR4从入门到实践

ANTLR(ANother Tool for Language Recognition)是一个强大的解析器生成器,用于读取、处理、执行或翻译结构化文本或二进制文档。它被广泛用于构建语言、工具和框架。ANTLR根据语法定义生成解析器,解析器可以构建和遍历解析树。

安装

以Linux系统为例,我们首先安装Java17

~ java -versionjava version "17.0.6" 2023-01-17 LTSJava(TM) SE Runtime Environment (build 17.0.6+9-LTS-190)Java HotSpot(TM) 64-Bit Server VM (build 17.0.6+9-LTS-190, mixed mode, sharing)

随后我们下载antlr4的完整依赖包

wget https://www.antlr.org/download/antlr-4.13.0-complete.jar

并把依赖包添加到Java的CLASSPATH中,将以下命令添加到~/.zshrc文件中

export CLASSPATH="/home/raymond/Desktop/antlr4/antlr-4.13.0-complete.jar:$CLASSPATH"

之后我们就可以使用antlr4的Tool和TestRig了

~ java org.antlr.v4.ToolANTLR Parser Generator  Version 4.13.0-o ___              specify output directory where all output is generated-lib ___            specify location of grammars, tokens files-atn                generate rule augmented transition network diagrams-encoding ___       specify grammar file encoding; e.g., euc-jp-message-format ___ specify output style for messages in antlr, gnu, vs2005-long-messages      show exception details when available for errors and warnings-listener           generate parse tree listener (default)-no-listener        don't generate parse tree listener-visitor            generate parse tree visitor-no-visitor         don't generate parse tree visitor (default)-package ___        specify a package/namespace for the generated code-depend             generate file dependencies-D<option>=value    set/override a grammar-level option-Werror             treat warnings as errors-XdbgST             launch StringTemplate visualizer on generated code-XdbgSTWait         wait for STViz to close before continuing-Xforce-atn         use the ATN simulator for all predictions-Xlog               dump lots of logging info to antlr-timestamp.log-Xexact-output-dir  all output goes into -o dir regardless of paths/package~ java org.antlr.v4.gui.TestRigjava org.antlr.v4.gui.TestRig GrammarName startRuleName[-tokens] [-tree] [-gui] [-ps file.ps] [-encoding encodingname][-trace] [-diagnostics] [-SLL][input-filename(s)]Use startRuleName='tokens' if GrammarName is a lexer grammar.Omitting input-filename makes rig read from stdin.

可以在~/.zshrc中添加如下别名

alias antlr4='java org.antlr.v4.Tool'alias grun='java org.antlr.v4.gui.TestRig'

后面就可以直接使用antlr4和grun命令了

一个简单的例子

我们从一个最简单的例子来看antlr4,创建一个名为Hello.g4的文件并输入如下内容

1
2
3
4
5
grammar Hello;              // 语法名称,必须要和文件名称一样

r : 'hello' ID ; // 表示匹配字符串hello和ID这个token,语法名称用小写字母定义
ID : [a-z]+ ; // ID这个token的定义只允许小写字母,词法名称用大写字母定义
WS : [ \t\r\n]+ -> skip ; // 忽略一些字符

随后执行antlr4 Hello.g4 -o code命令将语法文件转化为Java的代码,具体生成的文件如下

HelloBaseListener.javaHello.interpHelloLexer.interpHelloLexer.javaHelloLexer.tokensHelloListener.javaHelloParser.javaHello.tokens

之后执行命令javac *.java将所有的Java代码进行编译,编译完了之后执行命令grun Hello r -tree并输入相关文本内容,之后输入EOF(Linux上面是Ctrl + D)可以得到解析结果

➜  grun Hello r -treehello antlr<EOF>(r hello antlr)

其中Hello是语法文件的名称,r则是语法的名称,-tree表示以lisp语法展示语法,我们也可以使用-gui选项展示语法树。

Visual Studio Code提供了antlr4的插件,可以方便的进行语法高亮和格式化等操作。IntelliJ Idea也提供了插件,具有快速生成代码、设置生成代码的参数以及查看语法树等功能。

使用antlr4构建一个计算器

首先我们创建一个Calc.g4文件,具体内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
grammar Calc;       // 语法的名称,要和文件名称一致

calc: (expr)* EOF; // 一个或多个表达式

expr:
BRACKET_L expr BRACKET_R // 圆括号
| (ADD | SUB)? (NUMBER | PERCENT_NUMBER) // 正负数字和百分数
| expr (MUL | DIV) expr // 乘除法
| expr (ADD | SUB) expr; // 加减法

PERCENT_NUMBER: NUMBER PERCENT; // 百分数
NUMBER: DIGIT (POINT DIGIT)?; // 小数

DIGIT: [0-9]+; // 数字
BRACKET_L: '('; // 左括号
BRACKET_R: ')'; // 右括号
ADD: '+';
SUB: '-';
MUL: '*';
DIV: '/';
PERCENT: '%';
POINT: '.';

WS: [ \t\r\n]+ -> skip; // 跳过空格换行等字符

执行命令antlr4 Calc.g4 -o code来生成代码,并将生成的代码放到code文件夹中。进入code文件夹,执行javac *.java命令编译代码。编译完代码之后,就可以执行测试程序了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
➜ grun Calc calc -tree
1 + 2 * (3 + 4) - 5 / 6
(calc (expr (expr (expr 1) + (expr (expr 2) * (expr ( (expr (expr 3) + (expr 4)) )))) - (expr (expr 5) / (expr 6))) <EOF>)

➜ grun Calc calc -tokens
1 + 2 * (3 + 4) - 5 / 6
[@0,0:0='1',<NUMBER>,1:0]
[@1,2:2='+',<'+'>,1:2]
[@2,4:4='2',<NUMBER>,1:4]
[@3,6:6='*',<'*'>,1:6]
[@4,8:8='(',<'('>,1:8]
[@5,9:9='3',<NUMBER>,1:9]
[@6,11:11='+',<'+'>,1:11]
[@7,13:13='4',<NUMBER>,1:13]
[@8,14:14=')',<')'>,1:14]
[@9,16:16='-',<'-'>,1:16]
[@10,18:18='5',<NUMBER>,1:18]
[@11,20:20='/',<'/'>,1:20]
[@12,22:22='6',<NUMBER>,1:22]
[@13,31:30='<EOF>',<EOF>,2:0]

➜ grun Calc calc -gui
1 + 2 * (3 + 4) - 5 / 6

第一个命令是生成Lisp风格的语法树,第二个命令是查看相应的token,第三个命令生成的语法树如下所示

通过Java代码调用生成的Lexer和Parser

还是以上面的例子为例,这次我们把词法分析和语法分析的内容分开来,分别创建CalcLexerRules.g4Calc.g4文件,它们的内容分别如下

CalcLexerRules.g4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
lexer grammar CalcLexerRules;

PERCENT_NUMBER: NUMBER PERCENT;
NUMBER: DIGIT (POINT DIGIT)?;

DIGIT: [0-9]+;
BRACKET_L: '(';
BRACKET_R: ')';
ADD: '+';
SUB: '-';
MUL: '*';
DIV: '/';
PERCENT: '%';
POINT: '.';

WS: [ \t\r\n]+ -> skip;

Calc.g4

1
2
3
4
5
6
7
8
9
10
grammar Calc;
import CalcLexerRules; // 引入CalcLexerRules的词法规则

calc: (expr)* EOF;

expr:
BRACKET_L expr BRACKET_R
| (ADD | SUB)? (NUMBER | PERCENT_NUMBER)
| expr (MUL | DIV) expr
| expr (ADD | SUB) expr;

创建这两个文件之后,执行命令antlr4 Calc.g4 -o code生成代码,antlr会自动把CalcLexerRules.g4的内容引入进来。在生成代码的code文件夹下创建Java文件CalcTest.java,并使用Java代码调用生成的Lexer和Parser类中的方法

CalcTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
import org.antlr.v4.runtime.CharStreams;
import org.antlr.v4.runtime.CommonTokenStream;
import org.antlr.v4.runtime.tree.ParseTree;

public class CalcTest {
public static void main(String[] args) throws Exception {
CalcLexer lexer = new CalcLexer(CharStreams.fromString("1 + 2 * (3 + 4) - 5 / 6"));
CommonTokenStream tokens = new CommonTokenStream(lexer);
CalcParser parser = new CalcParser(tokens);
ParseTree tree = parser.calc();
System.out.println(tree.toStringTree(parser));
}
}

添加了如上的类之后,执行命令javac *.java编译源码文件,之后执行命令java CalcTest来运行Java代码,得到结果如下

(calc (expr (expr (expr 1) + (expr (expr 2) * (expr ( (expr (expr 3) + (expr 4)) )))) - (expr (expr 5) / (expr 6))) <EOF>)

运行结果和上面的grun的测试结果是一致的。

通过Visitor访问代码

上面我们使用Java代码调用了CalcLexer和CalcParser类,接下来我们实现一个Visitor,通过Visitor来访问我们所需要访问的AST节点,并执行计算器的计算功能。

这里我们使用Idea的ANTLR v4插件来生成代码,上面的词法文件CalcLexerRules.g4不需要任何改变,而语法文件Calc.g4修改如下

1
2
3
4
5
6
7
8
9
10
11
12
13
grammar Calc;
@header {
package com.nosuchfield.calc.code;
}
import CalcLexerRules; // 引入词法分析文件

calc: (expr)* EOF # calculationBlock;

expr:
BRACKET_L expr BRACKET_R # expressionWithBr
| sign = (ADD | SUB)? num = (NUMBER | PERCENT_NUMBER) # expressionNumeric
| expr op = (MUL | DIV) expr # expressionMulOrDiv
| expr op = (ADD | SUB) expr # expressionAddOrSub;

这里我们添加了@header标记,表示在生成代码的时候在代码头部生成我们所需要的内容,如上就是在代码头部放上了类的package声明。

我们还在每个语法后面使用井号#设置了一个标记名称,这个名称在生成Visitor代码的时候会生成相应名称的方法。此外我们还给表达式的参数设置了名称,例如sign、num和op,这样当生成代码的时候,我们就可以用参数num取到NUMBER或者PERCENT_NUMBER的值。

我们在Calc.g4文件上右击并选择Configure ANTLR选项

之后设置代码的生成目录为src/main/java/com/nosuchfield/calc/code,并且去掉生成listener的选项,同时选择生成visitor的选项

设置好了之后我们右击Calc.g4文件并右击选择Generate ANTLR Recognizer选项,即可在com/nosuchfield/calc/code文件夹下生成相关的代码

接下来我们自定义一个继承自CalcBaseVisitor类的CalculateVisitor,具体如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
package com.nosuchfield.calc;

import com.nosuchfield.calc.code.CalcBaseVisitor;
import com.nosuchfield.calc.code.CalcLexer;
import com.nosuchfield.calc.code.CalcParser;
import org.antlr.v4.runtime.Token;

import java.math.BigDecimal;
import java.math.MathContext;
import java.util.Objects;

public class CalculateVisitor extends CalcBaseVisitor<BigDecimal> {

/**
* 用于设置BigDecimal的计算精度
*/
private static final MathContext MATH_CONTEXT = MathContext.DECIMAL128;

/**
* calc语法,包含了多个expr,返回最后一个expr的结果
*/
@Override
public BigDecimal visitCalculationBlock(CalcParser.CalculationBlockContext ctx) {
BigDecimal calcResult = null;
for (CalcParser.ExprContext expr : ctx.expr()) {
calcResult = visit(expr);
}
return calcResult;
}

/**
* 左右括号,取出括号中的表达式
*/
@Override
public BigDecimal visitExpressionWithBr(CalcParser.ExpressionWithBrContext ctx) {
return visit(ctx.expr());
}

/**
* 乘除法,返回左右两个元素的计算结果
* 其中op属性是在语法文件中自定义的
*/
@Override
public BigDecimal visitExpressionMulOrDiv(CalcParser.ExpressionMulOrDivContext ctx) {
BigDecimal left = visit(ctx.expr(0));
BigDecimal right = visit(ctx.expr(1));
switch (ctx.op.getType()) {
case CalcParser.MUL:
return left.multiply(right, MATH_CONTEXT);
case CalcParser.DIV:
return left.divide(right, MATH_CONTEXT);
default:
throw new RuntimeException("unsupported operator type");
}
}

/**
* 加减法,返回左右两个元素的计算结果
* 其中op属性是在语法文件中自定义的
*/
@Override
public BigDecimal visitExpressionAddOrSub(CalcParser.ExpressionAddOrSubContext ctx) {
BigDecimal left = visit(ctx.expr(0));
BigDecimal right = visit(ctx.expr(1));
switch (ctx.op.getType()) {
case CalcParser.ADD:
return left.add(right, MATH_CONTEXT);
case CalcParser.SUB:
return left.subtract(right, MATH_CONTEXT);
default:
throw new RuntimeException("unsupported operator type");
}
}

/**
* 获取数值,num属性是在语法文件中定义的
* 如果数值前有负号就取负值
*/
@Override
public BigDecimal visitExpressionNumeric(CalcParser.ExpressionNumericContext ctx) {
BigDecimal numeric = numberOrPercent(ctx.num);
if (Objects.nonNull(ctx.sign) && ctx.sign.getType() == CalcLexer.SUB) {
return numeric.negate();
}
return numeric;
}

/**
* 将文本内容转化为BigDecimal,包含数字和百分数
*/
private BigDecimal numberOrPercent(Token num) {
String numberStr = num.getText();
switch (num.getType()) {
case CalcLexer.NUMBER:
return new BigDecimal(numberStr);
case CalcLexer.PERCENT_NUMBER:
return new BigDecimal(numberStr.substring(0, numberStr.length() - 1).trim())
.divide(BigDecimal.valueOf(100), MATH_CONTEXT);
default:
throw new RuntimeException("unsupported number type");
}
}

}

在自定义的Visitor中我们实现了计算逻辑,可以看到,这里重写了类CalcBaseVisitor的5个方法,分别对应了语法文件中的5个标记以及它们定义的名称,而属性的定义如num则对应了方法中入参的属性。以expressionNumeric语法为例,它对应的了方法visitExpressionNumeric,我们可以通过方法入参ExpressionNumericContext取到sign和num属性,之后通过这两个属性来定义数字的值。而expressionMulOrDiv语法就是通过op取到运算符,之后对两边的数字根据运算符来进行相应的计算。

有了visitor之后,我们用一个测试类来测试计算结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package com.nosuchfield.calc;

import com.nosuchfield.calc.code.CalcLexer;
import com.nosuchfield.calc.code.CalcParser;
import org.antlr.v4.runtime.CharStream;
import org.antlr.v4.runtime.CharStreams;
import org.antlr.v4.runtime.CommonTokenStream;
import org.junit.Test;

import java.math.BigDecimal;

import static junit.framework.TestCase.assertEquals;

public class TestCalculate {

@Test
public void testCalculate() {
String[][] sources = new String[][]{
{"1 + 2", "3"},
{"3 - 2", "1"},
{"2 * 3", "6"},
{"6 / 3", "2"},
{"6 / (1 + 2)", "2"},
{"50%", "0.5"},
{"100 * 30%", "30.0"},
{"1 + 2 * (3 - 4) / 5", "0.6"},
{"-8 + 8 * 2 - 8", "0"}
};
for (String[] source : sources) {
String input = source[0].trim();
BigDecimal result = new BigDecimal(source[1].trim());
assertEquals(calculate(input), result);
}
}

/**
* 计算表达式
*
* @param expression 表达式
* @return 计算的结果
*/
private BigDecimal calculate(String expression) {
CharStream cs = CharStreams.fromString(expression);
CalcLexer lexer = new CalcLexer(cs);
CommonTokenStream tokens = new CommonTokenStream(lexer);
CalcParser parser = new CalcParser(tokens);
CalcParser.CalcContext context = parser.calc();
CalculateVisitor visitor = new CalculateVisitor();
return visitor.visit(context);
}

}

可以看到,我们构建的计算器已经成功的计算出了正确结果。

ANTLR4的工作流程

在上面的例子中我们已经了解到,使用antlr4的一般流程如下

  1. 书写antlr4的词法和文法规则
  2. 使用antlr4的生成工具处理写好的规则,以生成指定语言的Lexer和Parser代码
  3. 调用生成的Lexer和Parser类,书写相应的逻辑代码,将原始输入文本转化为一个抽象语法树
  4. 使用antlr4的visitor来解析语法树,实现各种功能

实际上,除了visitor之外,antlr4还提供了另一种解析语法树方式,叫做Listener。Listener是antlr4默认解析语法树的方式,它和visitor一样都可以实现对ParseTree的解析。如果开启了visitor或listener,那么antlr4除了会生成Lexer和Parser代码,还会生成相应的Visitor和Listener代码。

Listener和Visitor区别如下

ListenerVisitor
是否访问所有节点访问所有节点只访问手动指定的节点
访问节点方式通过enter和exit方法通过visit方法
方法是否有返回值没有返回值有返回值

了解了Listener和Visitor的区别之后,我们可以总结出antlr4的大致工作流程如下

如上左边的点线流程代表了通过ANTLR4,将原始的.g4规则转化为Lexer、Parser、Listener和Visitor。右边的虚线流程代表了将原始的输入流通过Lexer转化为Tokens,再将Tokens通过Parser转化为语法树,最后通过Listener或Visitor遍历ParseTree得到最终结果。

解析CSV文件

我们已经使用Visitor构建过一个计算器,接下来我们使用Listener实现对CSV的解析。
Comma-separated values (CSV)文件是一种使用英文逗号 , 来分割字段的文件格式。文件分为多行,每行又被逗号分割为多列,第一行的内容可以当作字段的名称。下面是一个例子

省份,城市,区县,描述江苏,南京,雨花台,外包大道浙江,杭州,西湖,太美丽啦!西湖,上海,黄浦,"as it says: ""hello, shanghai"""

分析这个格式,首先是一行头部,之后跟着多行数据,因此可以很容易的得出如下的语法规则

csv: hdr row*;

而头部也是一样的数据格式,因此有如下规则

hdr: row;

数据是一些由逗号分割的字段,因此可以定义数据如下。其中\n是Mac和Linux的换行符,\r\n则是Windows下的换行符,因此\r是可选的

row: field (',' field)* '\r'? '\n';

接下来只需要定义field的词法即可,因为换行和逗号都是CSV中的格式符号,不允许在字符中存在。因此可以很容易的得到

field: ~[\n,\r]+;

~代表取反,也就是除了换行和逗号之外的其它多个字符。

有了上面这个规则还不够,因为CSV标准规定了,如果有特殊字符,可以用双引号包起来。例如一个逗号如果被包含在双引号里面,那么就是一个字段的组成部分而不是字段的分隔符。如果双引号包裹的内容中又有双引号,那么需要将这个字段内部的双引号用两个双引号进行替代。
因此我们还需要一个规则

field: '"' ('""' | ~'"')* '"';

如上规则表示用双引号包裹的内容,可以是两个双引号或者除了单个双引号之外的其它任意内容。

CSV还允许空字段

field: ;

整理如上规则,并添加包配置和相关的标记

1
2
3
4
5
6
7
8
9
10
11
12
13
grammar Csv;

@header {
package com.nosuchfield.csv.code;
}

csv: hdr row*;
hdr: row;
row: field (',' field)* '\r'? '\n';
field: TEXT # text | STRING # string | # empty;

TEXT: ~[\n,\r]+;
STRING: '"' ('""' | ~'"')* '"';

之后配置代码生成目录为com/nosuchfield/csv/code,并去掉生成Visitor的选项,勾选生成Listener的选项,使用antlr4生成代码,生成的Java代码如下

CsvBaseListener.javaCsvLexer.javaCsvListener.javaCsvParser.java

可以看到除了Lexer和Parser,还生成了相应的Listener代码。我们创建一个继承自CsvBaseListener的类如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
package com.nosuchfield.csv;

import com.nosuchfield.csv.code.CsvBaseListener;
import com.nosuchfield.csv.code.CsvParser;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class CsvListener extends CsvBaseListener {

/**
* CSV的多行数据
*/
private final List<Map<String, String>> rows = new ArrayList<>();

/**
* CSV的头部
*/
private List<String> header;

/**
* 一行CSV数据
*/
private List<String> row;

/**
* 进入一行
*/
@Override
public void enterRow(CsvParser.RowContext ctx) {
// 创建一个list用来保存这一行的数据
row = new ArrayList<>();
}

/**
* 离开TEXT
*/
@Override
public void exitText(CsvParser.TextContext ctx) {
// 添加这一列的数据
row.add(ctx.TEXT().getText());
}

@Override
public void exitString(CsvParser.StringContext ctx) {
// 获取字符
String field = ctx.STRING().getText();
// 移除头部和尾部的双引号
field = field.substring(1, field.length() - 2);
// 因为CSV在双引号中用两个双引号代表单引号,这里转回来
field = field.replaceAll("\"\"", "\"");
row.add(field);
}

@Override
public void exitEmpty(CsvParser.EmptyContext ctx) {
// 添加空字符串
row.add("");
}

/**
* 离开某一行
*/
@Override
public void exitRow(CsvParser.RowContext ctx) {
if (ctx.getParent() instanceof CsvParser.HdrContext) {
// 如果某一行的父节点是header头部
// 那么就把header的值设置成这一行的数据
header = row;
return;
}
Map<String, String> data = new HashMap<>();
// 某一行已经遍历完毕,将这一行的数据和header组合起来,构成一个map
for (int i = 0; i < row.size(); i++) {
data.put(header.get(i), row.get(i));
}
// 将这一行数据添加到数据集中
rows.add(data);
}

public List<Map<String, String>> getRows() {
return rows;
}

}

如上的Listener在进入行的时候初始化容器,在退出字段的时候将字段的数据保存到容器中,并在退出行的时候最终保存所有的字段。我们通过Lexer和Parser来解析上面的CSV数据,最终生成一个ParseTree,并调用Listener遍历ParseTree来解析生成的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void testCsv() throws IOException {
// 从文件中取得CSV数据流,并生成lexer
CsvLexer lexer = new CsvLexer(CharStreams.fromFileName("src/main/resources/csv/city.csv"));
// 根据lexer生成token
CommonTokenStream tokens = new CommonTokenStream(lexer);
// 将token交给parser
CsvParser parser = new CsvParser(tokens);
// 生成语法树
ParseTree tree = parser.csv();
// 打印语法树
System.out.println(tree.toStringTree(parser));

// 构建语法树遍历器
ParseTreeWalker parseTreeWalker = new ParseTreeWalker();
// 语法树监听器
CsvListener listener = new CsvListener();
// 遍历语法树
parseTreeWalker.walk(listener, tree);
// 打印生成的结果
System.out.println(listener.getRows());
}

执行上面的代码得到结果如下,可以看到完整的打印出了CSV的数据

(csv (hdr (row (field 省份) , (field 城市) , (field 区县) , (field 描述) \r \n)) (row (field 江苏) , (field 南京) , (field 雨花台) , (field 外包大道) \r \n) (row (field 浙江) , (field 杭州) , (field 西湖) , (field 太美丽啦!西湖) \r \n) (row field , (field 上海) , (field 黄浦) , (field "as it says: ""hello, shanghai""") \r \n))[{省份=江苏, 描述=外包大道, 城市=南京, 区县=雨花台}, {省份=浙江, 描述=太美丽啦!西湖, 城市=杭州, 区县=西湖}, {省份=, 描述=as it says: "hello, shanghai", 城市=上海, 区县=黄浦}]

通过Listener构建一个计算器

在上面的例子中,我们已经使用了Visitor实现了一个计算器,实际上通过Listener也可以实现相同的功能。在Visitor中我们通过方法的返回值来存储计算结果,在Listener中方法没有返回值,那我们就需要通过另一种方式来进行计算并存储计算结果 —— 栈。

还是使用上面的词法分析和语法分析规则,这次我们勾选生成Listener选项,之后再次生成代码,这次会生成CalcListener接口和CalcBaseListener类,我们实现一个继承自CalcBaseListener的类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
public class CalculateListener extends CalcBaseListener {

private static final MathContext MATH_CONTEXT = MathContext.DECIMAL128;

private Stack<BigDecimal> stack;

private BigDecimal result;

@Override
public void enterCalculationBlock(CalcParser.CalculationBlockContext ctx) {
// 创建新的栈
stack = new Stack<>();
}

@Override
public void exitCalculationBlock(CalcParser.CalculationBlockContext ctx) {
// 取出栈顶元素作为结果
result = stack.pop();
}

@Override
public void exitExpressionMulOrDiv(CalcParser.ExpressionMulOrDivContext ctx) {
// 将栈顶的两个元素取出来做乘除法,将结果压回栈
BigDecimal x = stack.pop();
BigDecimal y = stack.pop();
BigDecimal z;
switch (ctx.op.getType()) {
case CalcLexer.MUL:
z = y.multiply(x, MATH_CONTEXT);
break;
case CalcLexer.DIV:
z = y.divide(x, MATH_CONTEXT);
break;
default:
throw new RuntimeException("unsupported operator type");
}
stack.push(z);
}

@Override
public void exitExpressionAddOrSub(CalcParser.ExpressionAddOrSubContext ctx) {
// 将栈顶两个元素取出来做加减法,将结果压回栈
BigDecimal x = stack.pop();
BigDecimal y = stack.pop();
BigDecimal z;
switch (ctx.op.getType()) {
case CalcLexer.ADD:
z = y.add(x, MATH_CONTEXT);
break;
case CalcLexer.SUB:
z = y.subtract(x, MATH_CONTEXT);
break;
default:
throw new RuntimeException("unsupported operator type");
}
stack.push(z);
}

@Override
public void exitExpressionNumeric(CalcParser.ExpressionNumericContext ctx) {
// 计算数字
BigDecimal numeric = numberOrPercent(ctx.num);
if (Objects.nonNull(ctx.sign) && ctx.sign.getType() == CalcLexer.SUB) {
numeric = numeric.negate();
}
stack.push(numeric);
}

private BigDecimal numberOrPercent(Token num) {
String numberStr = num.getText();
switch (num.getType()) {
case CalcLexer.NUMBER:
return new BigDecimal(numberStr);
case CalcLexer.PERCENT_NUMBER:
return new BigDecimal(numberStr.substring(0, numberStr.length() - 1).trim())
.divide(BigDecimal.valueOf(100), MATH_CONTEXT);
default:
throw new RuntimeException("unsupported number type");
}
}

/**
* 获取计算结果
*
* @return 计算结果
*/
public BigDecimal getResult() {
return result;
}

}

上面的代码和Visitor非常相似,区别在于针对加减法和乘除法的计算,Visitor是直接拿方法参数计算,并将结果作为返回值返回。而Listener是从栈的顶部取出两个元素进行计算,并将计算结果压回栈。

如果你了解方法调用的一般方式,就应该知道其实方法调用的一般方式也是通过栈来存储方法的入参和出参的。在方法调用前,将方法入参的值压入栈中,之后运行方法,如果方法中还有方法调用,继续将入参压入栈。当方法开始执行时,将方法的入参弹出,等到方法执行完毕,将执行完毕的方法返回值压入栈,如此往复就形成了方法调用。

因此我们可以知道,计算Visitor和Listener的逻辑基本一致,都是使用栈来存储计算的数值和计算的结果。区别在于Visitor的值是存储在当前运行线程的栈上的,如果值过多,可能因为栈空间不够导致StackOverflow错误。而Listener的值是保存在我们自定义的位于堆内存的栈数据结构上的,可以存储更多的数据内容。

完整的代码位于https://github.com/RitterHou/test-antlr4

参考

ANTLR 4权威指南
语法解析器ANTLR4从入门到实践
从一个小例子理解Antlr4
Antlr4系列(二):实现一个计算器
ANTLR 使用——以表达式语法为例
Antlr4教程

❌