Richard

My personal blog

0%

Recently I deployed a TeslaMate on an Azure virtual machine. Since TeslaMate doesn’t support any authentication, and to apply defense by depth, I’m trying to make the whole network traffic from browser to Azure VM is encrypted.

Cloudflare to Azure

Cloudflare authenticate my VM

On Cloudflare dashboard, it can set SSL/TLS encryption mode:

Full mode requires a trusted CA certificate or a certificate generated by Cloudflare. I choose the later one as it has 20 years validation date, so do not need to use ACME every 3 months (like LetsEncrypt).

> It's possible to automatically request and deploy certificate, some webservers like Caddy has built-in mechanism to refresh server certificate.

On Cloudflare dashboard, create a new origin server certificate here

Then deploy certificate and key on the server (Never share certificate private key on the internet) In Caddyfile, enable TLS with certificate and key: tls /cert/cloudflare_origin.pem /cert/cloudflare_origin.key

VM authenticate requests from Cloudflare

In the virtual machine, I would like all requests are sent from Cloudflare. Their are few ways to do so, like restrict IP range, sign the network request etc… But for Cloudflare free version, the recommended way is “Authenticated Original Pulls“. “Origin Pulls” are the requests from Cloudflare to user’s origin server (In my case, the Virtual Machine on Azure). Cloudflare will send TLS client certificate to origin server, on the VM we can check the cert and block other requests directly sent from user. On Cloudflare dashboard, enable “Authenticated Origin Pulls”:

On VM, we need to download Cloudflare CA certificate first, then enable TLS client auth in Caddy:

1
2
3
4
client_auth {
mode require_and_verify
trusted_ca_cert_file /cert/cloudflare_client.pem
}

Browser to Cloudflare

To archive only authenticated user (myself explicitly) can visit that site, we can add authentication on the VM or Cloudflare side. Such as HTTP basic auth, or signin (first party or third party). Cloudflare supports TLS client certificate on the Edge server, which gives Cloudflare ability to cryptographically verify it’s myself. This requires user to install certificate on device first:

Then we need to set Cloudflare firewall to block requests without Client certificate, just follow guide on Cloudflare dashboard.

上一篇博客介绍了如何在 Kubernetes 上部署 Nginx 并从 80 端口 http 访问后端服务,本文将介绍如何使用 cert manager 配置自动证书签发。

安装

cert-manager 的安装比较复杂,有较多的配置文件,因此我们使用 helm 安装。 对于从前文介绍的 stackpoint 安装的集群,已经自动在集群上部署了 tiller,可以从本地远程连接部署。 安装好 helm 并配置了 kubeconfig 的环境变量指向配置文件后,可以简单的使用一行命令安装 cert-manager:

1
helm install --name cert-manager --namespace kube-system stable/cert-manager

配置证书

1
2
3
4
5
6
7
8
9
10
11
apiVersion: certmanager.k8s.io/v1alpha1
kind: ClusterIssuer
metadata:
name: submodule-checker-ssl-cert
spec:
acme:
email: [email protected]
http01: {}
privateKeySecretRef:
name: submodule-checker-ssl-cert
server: https://acme-v01.api.letsencrypt.org/directory

这份配置非常好理解,从 Let’s encrypt 安装了证书,保存到了 secret 中。

从 stackpoint 的左侧可以直接链接到当前集群的 dashboard 界面,选择 secret 中的 submodule-checker-ssl-cert 可以看到自动签发的证书:

配置 Ingress

证书申请好后,需要在 Ingress 中配置启用 TLS 并指定证书:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: submodule-checker-ingress
annotations:
certmanager.k8s.io/cluster-issuer: submodule-checker-ssl-cert
spec:
tls:
- hosts:
- submodule-checker.hlyue.com
secretName: submodule-checker-ssl-cert
rules:
- host: submodule-checker.hlyue.com
http:
paths:
- backend:
serviceName: submodule-checker-service
servicePort: 7777

默认情况下 Nginx Ingress Controller 在有 TLS 的情况下启用了 HSTS 头和 http 自动重定向,如果想要关闭这些功能请参考文档。

参考链接

  1. https://medium.com/containerum/how-to-launch-nginx-ingress-and-cert-manager-in-kubernetes-55b182a80c8f
  2. helm: https://helm.sh/
  3. cert-manager: http://cert-manager.readthedocs.io/en/latest/getting-started/index.html

上一篇博客介绍了如何在 Kubernetes 上部署一个无状态容器,但最后将 Service 暴露成 NodePort 时, 由于 k8s 的限制,默认情况下端口只能是 30000-32767,基本无法对外提供服务。

所幸的是除了 Service 提供的外部负载均衡外,还可以使用 Ingress 控制器从处理流量。 官方提供了 Nginx Ingress Controller,可以较为方便的部署 Nginx 作为入流量控制器,部署过程可以参考官方文档,但我对官方的配置做了一些修改。

Nginx Ingress Controller

官方文档参考:https://kubernetes.github.io/ingress-nginx/deploy/

首先部署命名空间,默认后端(处理 404等),配置等,与官方一致。

1
2
3
4
5
6
7
8
curl https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/namespace.yaml \
| kubectl apply -f -

curl https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/default-backend.yaml \
| kubectl apply -f -

curl https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/configmap.yaml \
| kubectl apply -f -

接着需要为 RBAC 配置角色和权限:

1
2
curl https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/rbac.yaml \
| kubectl apply -f -

部署 Nginx

官方的配置文件使用 Deployment 的方式部署单副本到任意一台 worker 机器,但我修改了一些配置,改变了以下行为:

  1. Nginx 部署在 master 机器上,使用 master 的入口 ip 提供服务
  2. 官方文档部署完后仍然需要使用 Service 做转发,在没有 ELB 的情况下仍需使用 NodePort 方式暴露在高端口上
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
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: nginx-ingress-controller
namespace: ingress-nginx
spec:
selector:
matchLabels:
app: ingress-nginx
template:
metadata:
labels:
app: ingress-nginx
annotations:
prometheus.io/port: '10254'
prometheus.io/scrape: 'true'
spec:
serviceAccountName: nginx-ingress-serviceaccount
hostNetwork: true
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-role.kubernetes.io/master
operator: Exists
tolerations:
- key: node-role.kubernetes.io/master
operator: Exists
effect: NoSchedule
containers:
- name: nginx-ingress-controller
image: quay.io/kubernetes-ingress-controller/nginx-ingress-controller:0.14.0
args:
- /nginx-ingress-controller
- --default-backend-service=$(POD_NAMESPACE)/default-http-backend
- --configmap=$(POD_NAMESPACE)/nginx-configuration
- --annotations-prefix=nginx.ingress.kubernetes.io
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
ports:
- name: http
containerPort: 80
- name: https
containerPort: 443
livenessProbe:
failureThreshold: 3
httpGet:
path: /healthz
port: 10254
scheme: HTTP
initialDelaySeconds: 10
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 1
readinessProbe:
failureThreshold: 3
httpGet:
path: /healthz
port: 10254
scheme: HTTP
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 1
securityContext:
runAsNonRoot: false

对官方配置的修改主要有以下几处:

  1. 使用 hostNetwork 配置将服务暴露在外网接口:L19
  2. 使用亲和性配置限制服务只能部署在 master 上:L20-26
  3. 使用 tolerations 配置允许在 master 上部署此服务:L27-30
  4. 删除了对 tcp 和 udp 直接转发的配置,目前还用不到

Tolerations

默认情况下 Kubernetes 不在 master 上部署各种服务,使用了 taint 的机制限制某个 node 的能力,可以查看一下 master 上的 taint:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 列出所有节点
kubectl --kubeconfig=./kubeconfig get nodes

NAME STATUS ROLES AGE VERSION
spc1d17xmk-master-1 Ready master 2d v1.10.2
spc1d17xmk-worker-1 Ready <none> 2d v1.10.2
spc1d17xmk-worker-2 Ready <none> 2d v1.10.2

// 查看 master 节点
kubectl --kubeconfig=./kubeconfig describe node spc1d17xmk-master-1

Name: spc1d17xmk-master-1
Roles: master
// Omitted
Taints: node-role.kubernetes.io/master:NoSchedule
// Omitted

可以看到 master 节点上的 key 为 node-role.kubernetes.io/master 指定了 NoSchedule 限制,可以阻止其它 pod 被部署到这个节点上。 因此如果想要让 pod 部署在这里,需要在 pod 上指定 tolerations 配置,表示某个 pod 可以容忍被配置了这个 taint 的节点。 Taints 和 Tolerations 是一组非常精巧的设计,组合使用时可以允许某些 pod 被部署在当前节点,但阻止其它 pod 的部署。

配置 Ingress

官方的 Ingress 文档已经非常清楚的描述了如何配置。

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: submodule-checker-ingress
spec:
rules:
- host: YOUR.HOST.NAME
http:
paths:
- backend:
serviceName: submodule-checker-service
servicePort: 7777

配置完成后将域名解析到 k8s 集群的主节点上,即可访问服务内的 pod。

参考链接

  1. NodePort: https://kubernetes.io/docs/concepts/services-networking/service/#type-nodeport
  2. Affinity: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#affinity-and-anti-affinity
  3. Tolerations: https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/
  4. Ingress: https://kubernetes.io/docs/concepts/services-networking/ingress/

开 kubernetes 集群

如何开一个 kubernetes 集群较为复杂,我使用了一个支持 digital ocean 的第三方服务,进行 API 授权后即可替你在 DO 上还可以集群:https://stackpoint.io/ 。 具体创建集群的教程请参考 DO 的文章:https://www.digitalocean.com/community/tutorials/webinar-series-getting-started-with-kubernetes

开好以后 stackpoint 会提供一个 kubeconfig 文件,用这个带证书的配置文件可以使用 kubectl 工具远程连接集群进行管理,不需要 ssh 上去。

查看集群信息

使用如下命令查看集群信息:

1
kubectl --kubeconfig=./kubeconfig get cluster-info

会有类似如下的输出:

1
2
3
Kubernetes master is running at https://YOURIP:6443
Heapster is running at https://YOURIP:6443/api/v1/namespaces/kube-system/services/heapster/proxy
KubeDNS is running at https://YOURIP:6443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

查看所有节点信息:

1
kubectl --kubeconfig=./kubeconfig get nodes

得到如下输出:

1
2
3
4
NAME                  STATUS    ROLES     AGE       VERSION
spc1d17xmk-master-1 Ready master 1d v1.10.2
spc1d17xmk-worker-1 Ready <none> 1d v1.10.2
spc1d17xmk-worker-2 Ready <none> 1d v1.10.2

创建一个 webapp 的部署

为了方便管理部署的配置,我们使用 yaml 文件描述所要部署的应用,本文以 submodule-checker 一个 Node 开发的 web app 为例。

首先我们创建一个最简单的部署:

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
apiVersion: apps/v1
kind: Deployment
metadata:
name: submodule-checker-deployment
spec:
replicas: 2
selector:
matchLabels:
app: submodule-checker
template:
metadata:
labels:
app: submodule-checker
spec:
containers:
- name: submodule-checker
image: richard1122/submodule-checker:fe51cb0
ports:
- containerPort: 7777
livenessProbe:
httpGet:
path: /healthz
port: 7777
initialDelaySeconds: 3
periodSeconds: 3
env:
- name: APPID
valueFrom:
secretKeyRef:
name: submodule-checker-key
key: appid
- name: APPSECRET
valueFrom:
secretKeyRef:
name: submodule-checker-key
key: appsecret
volumeMounts:
- name: key
mountPath: /app/keys
readOnly: true
volumes:
- name: key
secret:
secretName: submodule-checker-key
items:
- key: key.pem
path: key.pem

使用 kubectl 工具将这份配置文件发给服务器,其它类型的配置文件都可以用这个命令:

1
kubectl --kubeconfig=./kubeconfig apply -f ./submodule-checker/submodule-checker.yaml

这个配置非常容易理解,它非常类似 docker-compose 配置,部署了两个副本,容器内部端口是 7777,目前还不需要分配外部端口。

k8s 自带了健康检查功能,我们使用 httpGet 的方式访问容器内部的 /healthz 地址,返回成功的 http 状态吗就会被认为服务仍然健康,否则 k8s 会认为服务已经坏掉了,把这个容器重启了。

env 是两个从 secret 中取来的环境变量,k8s 可以将配置的 secret 独立部署上去,再需要的地方读取,传递给应用的环境变量、文件等。

volumes 则也是 secret 的一个内容,将对应可以为 key.pem 的 secret 写在文件中,映射到 /app/keys/key.pem 文件。

部署 secret

secret 可以与应用分开配置,一个样例的文件如下:

1
2
3
4
5
6
7
8
apiVersion: v1
kind: Secret
metadata:
name: submodule-checker-key
data:
appid: MTE5MzQ=
appsecret: xxx
key.pem: xxx

这里每个字段对应的都是一个 base64 后的内容

  1. 字符串可以使用 echo -n '123' | base64 生成
  2. 文件则需要输出出来,并不带换行的转换为 base64:cat ./key.pem | base64 -w0

查看部署

以上两个配置应用完成后,即可查看 submodule-checker 的部署情况:

1
kubectl  --kubeconfig=./kubeconfig describe -f ./submodule-checker/submodule-checker.yaml
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
Name:                   submodule-checker-deployment
Namespace: default
CreationTimestamp: Thu, 10 May 2018 01:13:43 +0800
Labels: <none>
Annotations: deployment.kubernetes.io/revision=4
kubectl.kubernetes.io/last-applied-configuration={"apiVersion":"apps/v1","kind":"Deployment","metadata":{"annotations":{},"name":"submodule-checker-deployment","namespace":"default"},"spec":{"replicas...
Selector: app=submodule-checker
Replicas: 2 desired | 2 updated | 2 total | 2 available | 0 unavailable
StrategyType: RollingUpdate
MinReadySeconds: 0
RollingUpdateStrategy: 25% max unavailable, 25% max surge
Pod Template:
Labels: app=submodule-checker
Containers:
submodule-checker:
Image: richard1122/submodule-checker:fe51cb0
Port: 7777/TCP
Liveness: http-get http://:7777/healthz delay=3s timeout=1s period=3s #success=1 #failure=3
Environment:
APPID: <set to the key 'appid' in secret 'submodule-checker-key'> Optional: false
APPSECRET: <set to the key 'appsecret' in secret 'submodule-checker-key'> Optional: false
Mounts:
/app/keys from key (ro)
Volumes:
key:
Type: Secret (a volume populated by a Secret)
SecretName: submodule-checker-key
Optional: false
Conditions:
Type Status Reason
---- ------ ------
Available True MinimumReplicasAvailable
Progressing True NewReplicaSetAvailable
OldReplicaSets: <none>
NewReplicaSet: submodule-checker-deployment-6bbcf79db8 (2/2 replicas created)
Events: <none>

可以看到这份部署(Deployment)的详细状态,更具体的每个 pod 也可以查看:

1
kubectl  --kubeconfig=./kubeconfig get pods
1
2
3
NAME                                            READY     STATUS    RESTARTS   AGE
submodule-checker-deployment-6bbcf79db8-7w9f7 1/1 Running 0 7h
submodule-checker-deployment-6bbcf79db8-ft2hs 1/1 Running 0 7h

对于每个 pod 可能处于各种不同状态,如崩溃重启等,此时可以使用 logs 命令查看某个 pod 的日志:

1
kubectl  --kubeconfig=./kubeconfig logs submodule-checker-deployment-6bbcf79db8-7w9f7

从外部访问

截至目前部署的容器都只是内部的,需要定义一个 service 暴露端口从外部访问

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
kind: Service
metadata:
name: submodule-checker-service
labels:
app: submodule-checker
spec:
ports:
- port: 7777
targetPort: 7777
type: NodePort
selector:
app: submodule-checker

部署后可以使用 describe 命令查看 service 对外具体暴露的端口,同时 service 也有不同 type,可以仅仅对内使用

1
kubectl  --kubeconfig=./kubeconfig describe -f ./submodule-checker/submodule-checker-service.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Name:                     submodule-checker-service
Namespace: default
Labels: app=submodule-checker
Annotations: kubectl.kubernetes.io/last-applied-configuration={"apiVersion":"v1","kind":"Service","metadata":{"annotations":{},"labels":{"app":"submodule-checker"},"name":"submodule-checker-service","namespace":"d...
Selector: app=submodule-checker
Type: NodePort
IP: 10.3.0.200
Port: <unset> 7777/TCP
TargetPort: 7777/TCP
NodePort: <unset> 30989/TCP
Endpoints: 10.2.1.15:7777,10.2.2.16:7777
Session Affinity: None
External Traffic Policy: Cluster
Events: <none>

其中 NodePort 就是对外的端口,直接从外网 IP 访问到。

总结

这篇文章只是简单介绍了如何部署一个多副本的,无状态的 webapp,并把它暴露在外网上。

但想要更好的提供服务,还需要配置 SSL,Nginx 等。

本文涉及到的内容请参考以下文档:

  1. https://kubernetes.io/docs/reference/kubectl/overview/
  2. https://kubernetes.io/docs/concepts/workloads/controllers/deployment/
  3. https://kubernetes.io/docs/concepts/services-networking/service/#defining-a-service
  4. https://kubernetes.io/docs/concepts/configuration/secret/

背景

为了让多个服务互相通讯,拥有强类型的消息类型定义、服务接口定义非常有必要。 同时我们需要将一份定义配置(如 Protobuf,GRPC)在多个项目中使用,最终选择了用 Git Submodule

在比较标准的 git 协作流程中,我们希望所有合并到 master 分支的提交,submodule 也是指向子项目的 master 分支的。 同时我们 pr 使用 squash 合并,分支中的所有提交都被压成一个提交进入 master。

假设我们有 parent 和 child 两个项目,其中 child 是 parent 的一个子模块,两个项目都需要使用 pr 做协作。

考虑如下的工作流程:

  1. 修改 child 项目,提出 pr,review 后合并至 master
  2. 更新 parent 项目,指向最新的 child 提交,开发 parent 项目提 pr 并 review

在这种两个项目没有同时开发的过程中,此流程没有明显问题,但事实上 child 作为一份消息格式定义,不可避免的要在开发过程中修改定义, 如果每次修改都先等待 child 发 pr 合并会浪费很多时间,并且让提交日志混乱。因此一般都会在 child 改一些内容后,先开发 parent,在过程中同时修改两个项目,最后一起发 pr。也就是如下的工作流程:

  1. child 开新分支,做出初步的修改
  2. parent 开新分支,根据新的定义写代码,同时将 child 的子模块指向新分支的新提交
  3. 两边同时推上去,等 CI,根据测试结果、覆盖率情况修改
  4. 随着开发的过程修改 child 和 parent,分别提交到各自分支
  5. 在 child 项目开 pr 等后合入主分支
  6. 在 parent 中将 child 指向刚刚合进去的新分支,开 pr

如果在以上过程最后两步中,没有将 parent 指向 child,最终合并后 parent 就指向了一个在分支上的提交,等 child 项目提交被 squash,分支删除后,parent 的主分支将处于找不到 submodule commit 的尴尬境地。

设计与实现

所有项目的主分支都保证了严格向前,只需要保证 parent 对 child 的指针一定是在一个 child 的主分支上的提交即可,每个 PR 在合并前都检查是否已经将 submodule 指向了 master 分支。

因此我开发了一个用来检查这一状态,并会设置 github commit status 的小程序:https://github.com/richard1122/submodule-checker

首先需要配置 parent 项目中都有哪些子项目,分别位于那个路径中,程序会分别用 Github API 查看这个路径的内容。 Github 对于 Submodule 已经做了处理,可以得到其项目地址等信息,再使用 Github 比较提交 API, 获取 Submodule 的指针与默认分支的比较结果,Github 会返回对应的状态,如 ahead,identical 等,根据这个状态就可以知道当前提交和某个分支的头指针的关系了。

使用方式请参考项目连接,目前已经配置了 Github APP,可以在此安装:https://github.com/apps/submodule-checker

反射在 Entity 上的异常

前段时间在线上的 Hibernate 查询出来的 Entity 中反射调用其中 getId method 时,发生了 java.lang.IllegalArgumentException: object is not an instance of declaring class 异常。 由于是在查询出的一个 List 上依次进行反射,提前将反射的方法缓存了下来,却在某个对象上发生了这种异常。 以下是一份非常简单的复现代码(用 Kotlin 写,但 Java 类似,跟语言无关):

1
2
3
4
5
6
7
8
9
10
11
@Entity
class Post(
@get:ManyToOne(fetch = FetchType.LAZY, optional = false)
@get:JoinColumn(nullable = false, insertable = true, updatable = false, foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT))
var post: Post?
) {
@Transient
@get:Id
@get:GeneratedValue(strategy = GenerationType.AUTO)
var id: Long = 0L
}

每个 Post 只有 id 和另一个 Post 的对象,在表中也就是主键 id 和一个 post_id 字段。

表中的数据如下:

id post_id
1 2
2 2

复现代码:

1
2
3
4
5
6
7
@Transactional
fun insertAndList() {
val post1 = postRepo.findOne(1L)
val post2 = postRepo.findOne(2L)
val method2 = post2::class.java.getMethod("getId")
val id1 = method2.invoke(post1) // Exception!!
}

调试

首先自然想到是 post1post2 的确属于两个不同的类,链接调试器后发现 post1 的类是 Post,而 post2 的类是 Post_$$_jvst38d_0,很明显这是一个被 Hibernate 子类化的类,除了原有的成员变量外,还多了个 handler: JavassistLazyInitializer 变量。

我们知道以下两个行为导致了这个行为:

  1. Hibernate 会对 LazyFetch 的对象生成代理对象,在 getId 以外的方法上才会真的去数据库中执行查询,因此代理对象必须是一个生成的类,原始类的成员显然无法做到动态进行查询。(注意这一行为仅限 Property-based access,可参考扩展阅读)
  2. Hibernate 在一个 session 中,被管理的 Entity 总是同一个 Java 对象,不论是被 findOnefindAll,或者被其它 Entity 关联的实体。

第一个 findOne 导致 post1 所关联的 id 为 2 的 Post 是一个代理对象,但是为了满足条件 2,Hibernate 必须在第二次 findOne 时返回那个代理对象。由于 post2 的类型是 post1 的子类,显然无法用 post2 的方法在 post1 上反射调用。

扩展阅读

从 16 年 12 月我们在 SpringBoot 的后端使用 Kotlin 开发以来,遇到了各种各样的坑。尽管 Jetbrains 宣称 Kotlin 对 Java 的互操作性是语言设计的一大优势,但由于 SpringBoot 和 Spring 严重依赖了 JVM 平台的各种特性,有时 Kotlin 并不能编译出足够符合行为的字节码,在一些依赖 Spring 特性的地方会遇到各种奇怪问题。

本文总结了使用 Kotlin 开发 SpringBoot 后端项目的过程中遇到的各种坑,其中有些可能逐渐被 Kotlin 官方文档提醒或解决。

首先介绍一个可以方便查看 Kotlin 编译后代码具体行为的方式,以 Intellij 为例,假设以下代码:

1
2
3
4
5
6
7
8
9
10
@Entity
class User(
@get:Column
var name: String
) {
@Transient
@get:Id
@get:GeneratedValue(strategy = GenerationType.AUTO)
var id: Long = 0L
}

在 Intellij 中双击 Shift 键,选择 Show kotlin bytecode,再选择 Decompile 可以查看编译后代码再逆回 Java 的样子,如上面这段 Data Class 会生成很多的方法,节选如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Entity
@Metadata(/* xxx */)
public final class User {
@Transient
private long id;
@NotNull
private String name;
@Id
@GeneratedValue(
strategy = GenerationType.AUTO
)
public final long getId() {
return this.id;
}

public final void setId(long var1) {
this.id = var1;
}
// xxxxxxx
}

可以看到每个字段都生成的 Getter 和 Setter,并且 @get:id 可以将 annotation 直接加在 Getter 上。 这种方式可以非常具体的查看 Kotlin 编译器到底为我们生成了怎样的代码,对于熟悉 Java 打算试试 Kotlin 的人来说非常方便,不会被内部复杂的细节困扰。

No default constructor for entity (实体缺少默认构造)

从数据库中查询 entity 时,Hibernate 会首先调用默认构造(无参构造函数)初始化对象,之后将各个字段调用 Setter 设置进来。以开头的那段代码为例,使用 JpaRepository 查询出对象时,会报这个错误。查看一下 Java 代码可以发现构造函数只有一个,接受的是 name:string 字段。

开发时我们会希望将构造一个实体时需要的参数都放在构造函数中,增强静态检查能力,同时给每个对象都设置默认初值不够方便,因此无法手写一个无参构造给 Hibernate 调用。

此时可以使用 jpa-support 这个编译插件来为 @entity 注解的 class 生成无参构造。

此时再查看生成的代码,会看到在最下面多了个无参构造,没有做任何事情,但再次用 Hibernate 已经没问题了。

Could not locate setter method for property (找不到 Setter)

1
2
3
4
5
@Entity
data class User(
@get:Column(updatable = false)
val name: String
)

比如 name 字段我们只想在构造时设置,之后不能修改,在 kotlin 中自然的选择用 val,但运行时 hibernate 会提示找不到这个字段的 setter 方法。可以给 name 加上 annotation,在自己的代码中调用会报错,但 hibernate 反射调用却不会有问题。

1
2
3
@get:Column(updatable = false)
@set:Deprecated("deprecated", level = DeprecationLevel.HIDDEN)
var name: String

Transactional 不生效

Kotlin 默认的类是 final 的,不可继承,Spring 也无法代理其中的方法,可以手动将某些类变成 open class,方法变成 open fun,也可以使用 spring-support,会自动把一些 annotation 注解的类变成 open class。

Lazy Fetch 不生效

实体类默认全部被生成了 final class,且 spring-support 插件没有将 @entity 注解的类变为 open class,需要手动应用 kotlin-allopen 并在 gradle 中配置对 @entity 注解的 allopen。

1
2
3
4
apply plugin: "kotlin-allopen"
allOpen {
annotation("javax.persistence.Entity")
}

类内方法不能被代理

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
class Runner(private val userRepo: UserRepo,
private val postRepo: PostRepo) {
@EventListener(ApplicationReadyEvent::class)
fun test() {
queryPost()
}
@Transactional
fun queryPost() {
val post = postRepo.findOne(1)
println(post.user.name)
}
}

在这个类中 queryPost 应该运行在事务中,但执行时会发生异常 could not initialize proxy - no Session,没能开启事务。这是由于 spring-aop 是包裹你的方法,对于从 this 调用的方法不能代理掉。可以注入一个自己类的实例,调用该对象的方法。

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
class Runner(private val userRepo: UserRepo,
private val postRepo: PostRepo) {

@Autowired
private lateinit var runner: Runner

@EventListener(ApplicationReadyEvent::class)
fun test() {
runner.queryPost()
}
}

项目地址:richard1122/GithubReleaseHook

项目背景

目前的项目部署方式基本使用 Git Hook 部署源码,本地编译后运行的方式。但有些项目编译依赖复杂,如果在服务器上编译成本较高。

可以选择使用 CI 服务(如 Travis-CI)做编译,生成 Github Release 后直接由服务器运行,服务器不负责编译工作。

技术细节

本工具使用 .NET Core 跨平台运行,在 Windows 上开发,同时提供 Linux 下所需的 Docker 环境。

在 Windows 上使用 Git Bash 作为脚本的运行环境。推荐安装 Git For Windows 并将里面的 sh.exe 加入 Path 环境变量。

工作流程

GithubReleaseHook 开启一个 HTTP 服务器,收到 Github Release Hook 的 event 通知后,将下载 Release 附带的资源文件,执行自行配置的脚本进行部署、重启服务等操作。

Repo.yml

以博客为例, Github Release 上包含一份 Release.tar.gz 文件,是博客编译生成的全部静态文件。

1
2
3
4
5
6
7
repo: richard1122/blog.hlyue.com
file:
- release.tar.gz
script:
- tar xavf $f0
secret: sec
workingDir: /blog

其中 file 段接受多个文件,将会自动下载 Release 中对应的文件。

在 script 段中使用 $f[0-9] 如 $f0 表示前面 file 中的第一个文件,在运行脚本前会自动替换为下载的本地临时文件地址。

secret 是 Github Webhook 中填写的 secret key, Github 会在每个 event 通知中使用这个 secret 对请求 payload 做 HMAC-SHA1 签名,程序通过检查签名来判定这个请求来自 Github,因此需要妥善保管 Secret Key。

workingDir 是脚本的工作路径,如对于本博客来说会将 tar 自动解压缩到 /blog 目录

使用样例

本博客使用了 GithubReleaseHook 做自动部署操作。

博客使用 Hexo 静态博客生成器生成器,因此需要将源码(markdown等)编译到对应的 html 文件。

编译

使用 Travis-CI 做 hexo 编译工作,并且自动在编译完成后将编译后的全部网站文件提交 Github Release。 具体脚本可以参考 .travis.yml

Webhook

/richard1122/blog.hlyue.com 项目中添加 Webhook, Content-Type 选择 application/json, secret 务必使用唯一、随机生成的安全的 key ,以防止别人伪造 http 请求部署服务。 Webhook 类型选择 Release。

配置

由于博客本身是纯静态内容,使用主机中的 Nginx 做服务,因此博客自身的 Docker 只需要运行 Release Hook 这一个服务。

配置文件如下:

1
2
3
4
5
6
7
repo: richard1122/blog.hlyue.com
file:
- release.tar.gz
script:
- tar xavf $f0
secret: udih6n0lhYfaggtA
workingDir: /blog

Dockerfile:

1
2
3
4
FROM richard1122/githubreleasehook
RUN mkdir /blog
VOLUME /blog
COPY repo.yml /usr/src/app/

/usr/src/app 是项目的代码路径, /blog 是博客的静态文件路径。

将 repo.yml 文件复制到代码目录,docker 启动时会自动启动 Hook 解析配置文件。

release.tar.gz 是某个release 中由 travis 编译生成的静态网页文件。

因此脚本只需要将网页内容解压缩到对应的 Docker Volume 位置即可。

启动 Docker

docker run -p 8081:8081 --name blog -v /usr/share/nginx/blog:/blog blog

这里将 8081 端口映射了进去,因此在上面创建 Github Webhook 时候也需要使用 8081 端口

这是一个浙江大学嵌入式课程作业,将两个开关与几个LED灯接入树莓派的GPIO口。开关控制游戏的开停,LED灯则被轮流点亮。在这次作业里面我用了中断。

实物图(其实毛线都看不清楚)

用fritzing画的图(第一次画丑了一些)

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

#include <wiringPi.h>
#include <stdio.h>
#define BTN1 5
#define BTN2 4

int running = 0, currentLed = 0;
const int leds[] = { 1, 0, 2, 3 };

void btn1Up() {
delay(20);
if (digitalRead(BTN1)) {
printf("Game Start\n");
running = 1;
}
}

void btn2Up() {
delay(20);
if (digitalRead(BTN2)) {
printf("Game Stop\n");
running = 0;
}
}

void shutdownAll() {
for (int i = 0; i != sizeof(leds) / sizeof(int); ++i)
digitalWrite(leds[i], LOW);
}

int main (void)
{
wiringPiSetup();
pinMode(BTN1, INPUT);
pinMode(BTN2, INPUT);
pullUpDnControl(BTN1, PUD_DOWN);
pullUpDnControl(BTN2, PUD_DOWN);
wiringPiISR(BTN1, INT_EDGE_RISING, &btn1Up);
wiringPiISR(BTN2, INT_EDGE_RISING, &btn2Up);
for (int i = 0; i != sizeof(leds) / sizeof(int); ++i) {
pinMode(leds[i], OUTPUT);
pullUpDnControl(leds[i], PUD_UP);
}

for( ; ;) {
delay(100);
currentLed = (currentLed + 1) % (sizeof(leds) / sizeof(int));
shutdownAll();
if (running)
digitalWrite(leds[currentLed], HIGH);
}
return 0 ;
}

源代码位于:https://github.com/richard1122/FQDNS

注:由于浙江大学网络与Linode 日本节点的UDP包丢包率过高,本项目目前暂时无法使用,记录下来只为能够留下一些有用的信息。

项目背景

在今年(2015)寒假的时候,我有了开发一个新的翻墙软件的想法,但寒假因为种种原因(主要是拖延),连Github项目都创建好了,却一直没有开始。

近期由于GFW多次升级,特别是DNS污染相关的:

  • 过去,只会返回黑名单内IP的随机一个,因此通过黑名单配合延迟解析 (据说GFW会首先返回一个假的结果,但是真实结果在返回时并不会被drop),可以从一定程度解决这个问题。
  • 现在,从2015年起,DNS开始随机污染,会返回国外的随机IP,或一个非常大的IP列表,且这些IP地址很多都是真实有web服务在运行的。因此无法很好地判断这是一个假的地址了。

因此我决定开发一个同时需要客户端和服务端的DNS程序,将本地的dns请求加密发送到远程服务器,并在解析完成后将结果以加密方式返回,为了保证解析速度,我选择使用UDP。

工作原理

如上文所述,这个的工作原理非常简单,首先在本地监听一个UDP端口,设置下游dns服务器(如DNSMASQ)使用它来解析,然后配置服务器和客户端共享的加密密钥,服务器地址等参数,即可开始运行。

解析阶段

  • 客户端收到一个DNS请求后,将其加密,然后根据地址发送到对应的服务器地址
  • 服务器收到请求后,将数据包解密,然后把raw udp package直接转发给某个DNS服务器(如8.8.8.8)

回复阶段

  • 服务器收到从DNS服务器返回的解析结果后,将其加密,然后发送给来源的客户端
  • 客户端收到来服务器的请求,将其解密,然后转发给来源的请求地址

识别DNS请求

根据rfc1035,DNS请求和返回包的前16位是一个ID,DNS服务器会拷贝请求包的ID来使我们知道这个返回包对应哪个请求,因此我们通过代码

1
2
(msg) ->
return msg.readUIntBE 0, 2

从一个Buffer中读取前2个字节,客户端和服务器分别开一个循环队列,记录下最近的 X 个请求,如果找不到对应的则丢弃

测试结果

从原理上这个程序可以完全防止GFW的DNS投毒攻击,但是如开头所述,UDP包丢包率非常高,甚至有可能发送40,50个包后服务器端一个都收不到的情况,因此目前还无法使用,下一步可能考虑预先打开TCP连接等等方式,测试下延迟是否有明显增加。

推荐阅读

如果对GFW工作原理等内容有兴趣的话,推荐阅读以下一些文章,本人也从中得到了大量的知识与灵感,在此一并感谢。

  1. 翻墙路由器的原理与实现
  2. Chinadns