应公司要求,本人最近使用 Spring Cloud 做了一个小项目,用它配合 dockerdocker-compose 搭建了一套微服务系统。作为一个很少使用 java 编写工程项目的工程师,我在其中还是遇到了不少问题的,从项目的配置到项目的部署运行,几乎每一阶段都有卡壳。公司保密制度并不允许我泄露项目相关的代码,所以这篇文章仅根据我自学 Spring Cloud 时搭建的 Hello World 项目 总结了遇到的几个主要的困难,以及一些小技巧。

Hello World 项目的整体架构

虽然这我只是在自学时搭建的 Hello World 项目,但是由于微服务系统由众多功能不同、数量不同的服务组成,Hello World 项目在架构上也不算是简简单单,即使它是根据我们的实际项目简化而来的。如图所示:

其中,

eureka 服务

eureka 服务通过 spring-cloud-netflix-eureka 搭建,它用于实现服务发现与注册功能,作为该项功能的后端服务。服务消费者和服务提供者在启动后,都访问它,并向它注册自己的服务名、网络地址等信息。服务消费者需要请求服务提供者时,需要先访问 eureka 服务获取相应服务提供者的网络地址。

服务提供者

服务提供者本质就是一个 Spring Boot 搭建的服务,它提供各种小功能。我编写的服务提供者有 image服务、时间服务、实地位置查询服务、天气查询服务。它们接受 HTTP 请求,然后根据请求中的参数返回特定数据。它们并不对外网公开端口。

服务消费者

服务消费者本质也是一个 Spring Boot 服务,只不过它使用了 openfeign 实现对服务提供者的请求。它请求特定服务提供者,从服务提供者中获取数据,然后根据业务逻辑处理数据后,返回特定的页面或json字符串等数据。它们对外网公开,监听外网端口。

Gateway 服务

gateway 服务使用 spring cloud gateway 编写并配置。gateway 服务主要是实现路由功能和过滤功能,它根据请求的url或http头信息将请求转发到服务消费者或静态文件服务器。并且它还会改写并过滤请求中的相关信息。

静态文件服务器

在本示例中,静态文件服务的重要程度很低,它由我编写的 caddy-docker 便利地部署。

官方的 pom 文件太简要

Spring Cloud 项目的依赖管理与构建工具,官方推荐使用 mavengradle ,这对于我这种常年使用 emacs 编写代码且在命令行环境下工作的工程师来说是一大幸事。不过,我的 emacs 配置没有对 java 项目进行支持,java 依赖包的导入我还是使用 eclipse 自动完成的。

Spring Cloud 系列产品的官方文档中, pom 文件中的内容很简要,且详细程度不一。比如, spring cloud 页面 的 quick start 描述的 pom 内容如下:

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
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.1.RELEASE</version>
</parent>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Finchley.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
</dependencies>

但是 spring cloud netflix 页面 的 quick start 描述地 pom 如下:

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
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-netflix</artifactId>
<version>2.0.1.BUILD-SNAPSHOT</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-eureka</artifactId>
</dependency>
</dependencies><repositories>
<repository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/libs-snapshot</url>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>

可见,下者中的 pom 内容比上者少了一个 <parent/> 标签。

这个 <parent/> 标签是必要的,因为 spring cloud 系列都是基于 spring boot 开发的。如 spring boot 中最简单的 web restful service 例子中一样, <parent/> 标签及其内容,对每一个基于 spring cloud 系列产品编写的服务都是必不可少的。

当然,同样的道理,这两个的 pom 文件对应的项目虽然在加入 <parent/> 标签后能够正常构建了,但是却无法正常运行。会提示如下错误:

1
no main manifest attribute

其中的原因是 spring boot 项目的一个必须的 plugin 没有在 pom 文件中写明:

1
2
3
4
5
6
7
8
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

该 plugin 规定了基于 spring boot 的项目的打包构建方式,当然基于 spring cloud 的项目也是需要这个的。

官方默认使用 sping cloud 的用户是具有 spring boot 经验的,就像他们默认你懂得 maven 的使用一样,这对新手来说确实不过友好。

Java 版本太高了

我在最初搭建 eureka 服务器集群的时候一直无法正常启动哪怕一条服务进程。每次都是如下错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java.lang.reflect.InvocationTargetException
at jdk.internal.reflect.NativeMethodAccessorImpl.invoke0 (Native Method)
at jdk.internal.reflect.NativeMethodAccessorImpl.invoke (NativeMethodAccessorImpl.java:62)
at jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke (DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke (Method.java:564)
at org.springframework.boot.maven.AbstractRunMojo$LaunchRunner.run (AbstractRunMojo.java:496)
at java.lang.Thread.run (Thread.java:844)
Caused by: org.springframework.context.ApplicationContextException: Unable to start web server; nested exception is org.springframework.boot.web.server.WebServerException: Unable to start embedded Tomcat
at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.onRefresh (ServletWebServerApplicationContext.java:155)
at org.springframework.context.support.AbstractApplicationContext.refresh (AbstractApplicationContext.java:544)
at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh (ServletWebServerApplicationContext.java:140)
... <省略>
Caused by: org.springframework.boot.web.server.WebServerException: Unable to start embedded Tomcat
at org.springframework.boot.web.embedded.tomcat.TomcatWebServer.initialize (TomcatWebServer.java:126)
at org.springframework.boot.web.embedded.tomcat.TomcatWebServer.<init> (TomcatWebServer.java:86)
at org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory.getTomcatWebServer (TomcatServletWebServerFactory.java:413)
... <省略>
Caused by: java.lang.IllegalStateException: StandardEngine[Tomcat].StandardHost[localhost].TomcatEmbeddedContext[] failed to start
at org.springframework.boot.web.embedded.tomcat.TomcatWebServer.rethrowDeferredStartupExceptions (TomcatWebServer.java:172)
at org.springframework.boot.web.embedded.tomcat.TomcatWebServer.initialize (TomcatWebServer.java:110)
at org.springframework.boot.web.embedded.tomcat.TomcatWebServer.<init> (TomcatWebServer.java:86)
... <省略>

由于错误信息太长,我省略很多调用栈的信息。概括一下就是,由于反射调用出现错误导致无法启动 spring boot 的内嵌 Tomcat 。而这个反射调用错误具体就是:

1
java.lang.TypeNotPresentException: Type javax.xml.bind.JAXBContext not present

JAXBContext 类型不存在。

这个错误很奇怪,因为在我的记忆中, jdk 应该自带 javax 包,且含有 JAXBContext 类的,并且 spring cloud 与 spring boot 官方也没有在 pom 的依赖中提及它。之后,我在 Stack Overflow 上查到了类似的问题,同时证明了我的记忆没有问题,但是不够具体。(具体看stack overflow 上的解答)。

java SE8 及以下版本中确实是自带 javax 包的,但是在它之后,自 java SE9 开始, javax 包就被移到了 java EE 中。而我使用我的 mbp 编写代码,通过 brew cask install java 安装 java 。问题就出在这里,这个 java 的版本是 java SE10 ,理所应当,它并没有自带 javax 包。

发现了问题所在,解决方案就很简单了,比方说直接卸载 java10 , 安装 java8 。当然,还有更简便的方法,比方说采用 Stack Overflow 上说的,将 javax 等一些列相关的包当做依赖包,写到 pom 文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
...
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.2.11</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-core</artifactId>
<version>2.2.11</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-impl</artifactId>
<version>2.2.11</version>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
...

然后在命令行中使用 mvn clean package spring-boot:run ,项目就可以正常编译并启动了。

docker 中配置Java bean参数

成功编译并在本地启动 spring boot 进程后,具体业务逻辑相关的代码就无比简单了,毕竟只是一个 Hello World 项目。如下是 image 服务提供者的主要代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
public class ImageController {
@Value("${image.serv}")
String servName;
@Autowired
DiscoveryClient discoveryClient;
@Autowired
Registration registration;

@RequestMapping("/")
public String content(@RequestParam(value = "key") String key){
JSONObject jObj = new JSONObject();

final List<ServiceInstance> instances = discoveryClient.getInstances( registration.getServiceId() );

jObj.put("host", instances.get(0).getHost()); // 写入当前服务的主机名
jObj.put("metadata",instances.get(0).getMetadata()); // 写入当前服务 Meta 数据
jObj.put("url",servName+key+".jpg"); // 生成图片的 url
return jObj.toString();
}
}

它的作用是,接收 Http 请求,获取请求的 query 中的 key 键值对的值,并根据该值和自身在 eureka 里的注册信息,生成一个 json 格式的响应体数据。

其他的各个服务也都是类似于此,没什么可说的,在此略过。

言归正传,在这里遇到的问题不是如何编写服务,而是: 如何在docker容器中正确地启动服务

基于 spring boot 的服务有许多可配置的选项,可以通过在项目根目录或则 resources 文件夹下创建 application.properties( or yml ) 文件,并在里面填写相应的参数,即可改变进程的运行配置。很方便,但是还不够灵活,如果在构建 docker 镜像时,将编译完的、包含 application.properties( or yml ) 的 jar 架包直接添加进镜像当中,那么该镜像就只能根据固定的配置启动容器了。

而在开发软件时,常有生产环境、开发环境和测试环境之分,且这三者的运行配置往往不同,因此我需要更加灵活的镜像构建与容器启动策略。我尝试了2种方案:

  • 方案一 , 为每个服务准备三个 application 文件,即 application-dev.ymlapplication-pro.ymlapplication-test.yml ,然后在启动时使用不同的参数,比如启动生产环境时使用 java -jar ./target/service.jar --spring.profile.active=pro 。同样启动开发环境时,只需要将 pro 改成 dev 即可。

相比起最初其实增加了一定的灵活性,但是也仅仅区分了三个环境,当某一环境的配置需要更改时,仍旧需要重新编译打包。所以,我想到了方案二:

  • 方案二 , 构建一个通用启动容器,该容器的启动命令是形如 java -D<key>=$VALUE -jar app.jar 的需要特定环境变量的,其中的 <key> 表示需要设置的配置。如下便是我的通用 Dockerfile :
1
2
3
4
5
6
7
8
FROM java:jdk-8-alpine

ENV EUREKA_PEERS http://localhost:8000/eureka

ARG JAR_FILE=target/test_service_caller-1.0-SNAPSHOT.jar
COPY $JAR_FILE app.jar

CMD java -Deureka.client.serviceUrl.defaultZone=$EUREKA_PEERS -jar app.jar

本试验性项目只有一个需要外部设置的变量,~eureka.client.serviceUrl.defaultZone~ 。

我使用 docker-compose 作为容器启动与编排的管理工具,所以 EUREKA_PEERS 可以直接写进 docker-compose.yml 文件中持久化。下面是我的 image 服务的 docker-compose 配置片段:

1
webimg:
    build:
        context: ./
        args:
          JAR_FILE: ./webimg/target/image-caller-1.0-SNAPSHOT.jar
    environment:
        EUREKA_PEERS: http://eureka1:8000/eureka,http://eureka2:8000/eureka,http://eureka3:8000/eureka

冗长的配置可以如此简简单单的设置,且之后的更改也只需要修改这个 docker-compose 配置片段就可以了。如果想要区分三个环境,那么就创建 docker-compose-dev.ymldocker-compose-pro.ymldocker-compose-test.yml 三个文件,使用 docker-compose 管理服务的运行环境。启动时只需要通过 “-f” 参数指定特定的配置文件,修改时只需要修改特定的配置文件。并且也支持现在的各种容器编排管理平台。

java 的 -D 参数一定要在 -jar 之前,且 -D 与参数值之间没有空格。

docker 之间的连接问题

由于我要构建的是微服务架构的系统,需要启动许多许多容器,但是每个容器不是互相隔绝的,即服务之间可能需要相互间通信。因此我需要解决一个 docker 容器间连接的问题。

曾经,我的做法都比较简单,比方说使用 Rancher 这种企业级容器管理平台,直接通过它提供的 UI 界面部署、管理容器,网络连接相关的都会自动配置,无需操心这类问题。或则更简单的方式,把所有服务全部写在同一个 docker-compose.yml 文件当中,一个命令一起启动。

当然,第二种方式在微服务架构的系统中是明显不可取的,其一是每次都要操作所有的服务,不够灵活;其二是可扩展性实在太差,且 docker-compose.yml 文件会随着服务种类的增加变得越来越复杂、不友好。

这次我选择的方式仍旧是 docker + docker-compose ,不同的是,不是将所有服务全部写到 docker-compose 当中,而是写多个 docker-compose.yml 记载不同数量的服务。比方说,一个 ./eureka_services/docker-compose.yml 记录 eureka 集群的启动配置,一个 ./gateway_services/docker-compose.yml 记录 gateway 服务的启动配置,所有的服务调用者都记录在同一个 ./microservices/docker-compose-consumer.yml 文件中,同样所有的服务提供者都记录在同一个 ./microservices/docker-compose-producer 中。简单的说就是按类划分。

那么之后的问题就是,记录在不同的 docker-compose.yml 文件中的容器该怎样通信呢?

当然是全部放到同一个网络中,并使用容器名通信啦。

首先,如果仅让与系统相关的容器处于同一个网络中,需要通过以下命令创建一个容器网络:

1
docker network create spring_cloud

然后,在每个 docker-compose 配置文件底部加入以下申明:

1
networks:
    spring_cloud_inner:
        external:
            name: spring_cloud

之所以需要 external 申明是因为 docker-compose 默认会创建新的容器网络,而 external 告诉 docker-compose 该网络已经存在,在外部的名称( name )是 spring_cloud 。可以去 networks 了解详情。

当然,每个服务也需要增加一个 networks 字段:

1
networks:
    - spring_cloud_inner

这里的 spring_cloud_inner 就是文件底部的 networks 中申明的网络名。

当微服务系统中的容器都处于同一个网络后,我需要解决的问题就是 如何让容器知道其他容器的地址 。这个问题其实很简单:如果容器处于同一个网络中,那么它们的容器名就是访问它们的主机名( host ),docker 已经为它们提供了 DNS 服务。并且,我还可以通过固定某些关键容器的容器名的方式,令容器间的寻址更加灵活、方便以及友好。如下是 ./eureka_service/docker-compose.yml 的部分代码:

1
services:
  node1:
    container_name: eureka1
    image: eureka:1.0
    ports:
      - "9001:8000"
    networks:
       - spring_cloud_inner
    environment:
        EUREKA_PEERS: http://eureka2:8000/eureka,http://eureka3:8000/eureka
  node2:
    image: eureka:1.0
    container_name: eureka2
    ports:
      - "9002:8000"
    networks:
       - spring_cloud_inner
    environment:
        EUREKA_PEERS: http://eureka1:8000/eureka,http://eureka3:8000/eureka
  node3:
    image: eureka:1.0
    container_name: eureka3
    ports:
      - "9003:8000"
    environment:
        EUREKA_PEERS: http://eureka1:8000/eureka,http://eureka2:8000/eureka
    networks:
       - spring_cloud_inner

三个 eureka 服务的容器分别取名为 eureka1eureka2eureka3 ,并且环境变量 EUREKA_PEERS 里设置的 url 的 host 都是直接使用以上三个容器名代指的。

容器花费了太多内存

随着各个服务的启动,我遇到了一个很糟糕的问题:我的远程服务器内存爆了。

由于我使用 macOS 做开发,在本地跑 docker 需要开一个虚拟机(虽然 docker 官方已经为你做了一套解决方案,但是其本质还是开虚拟机),所以 docker 镜像的构建和容器的启动这些工作,我全是在远程服务器上做的。

我远程服务器是 awz 提供的免费服务器,因此配置不高,CPU 1G Mem 1G 。在刚出现问题时,我并没有意识到是资源不足方面的问题,毕竟容器这种软虚拟占用的 CPU 和内存都是进程级别的,才启动了个位数的容器怎么可能会缺资源。

显然,我开发 java 应用的经验还不够。如下图所示,默认情况下,spring boot 应用的内存占用量居然有 500MB ,也就是 4 个容器就能把我的远程服务器爆掉。

之后,尽我所能,将启动代码修改成:

1
2
3
4
5
6
CMD java -Xms16m -Xmx48m -XX:MaxMetaspaceSize=64m \
-XX:CompressedClassSpaceSize=8m -Xss256k -Xmn8m \
-XX:InitialCodeCacheSize=4m -XX:ReservedCodeCacheSize=8m \
-XX:MaxDirectMemorySize=16m \
-Deureka.client.serviceUrl.defaultZone=$EUREKA_PEERS \
-jar app.jar

该代码将容器的内存消耗量降到了原来的 1/4 ,如下图所示:

Wonderful! , 但是这些到底是什么?有时如何将进程的内存降下来的呢?

简单地说,这些启动参数主要与 jvm 中的 GC 相关。如图所示:

在运行 java -jar 命令时,可以使用以下参数设置 Java Heap 的内存限制:

  • Xms – JVM启动时的初始堆大小
  • Xmx – 最大堆大小
  • Xmn - 年轻代的大小,其余的空间是老年代

使用以下参数设置 Java Non-Heap 部分:

  • Xss - 设置最大线程大小。
  • XX:MetaspaceSize 与 XX:MaxMetaspaceSize - 在 Metaspace 中,通过应用程序加载所有类和方法
  • XX:ReservedCodeCacheSize - 由 JIT 编译器编译为本地代码的本机代码或 Java 方法的空间
  • XX:CompressedClassSpaceSize - 设置为压缩类空间保留的最大内存

对这两部分内存空间进行限制,容器的大小自然而然就降下来了。

不过,无论怎么说,每个容器都还占大约 150MB 内存,免费 aws 服务器仍旧是支持不了的,因此我该换了一个 vultr 的服务器( CPU 2G MEM 4G )。

如何改变服务的规模

我的 Hello World 系统中共有 4 类服务,其中 gateway 和 eureka 服务的规模是固定的,因为 gateway 需要暴露到公网,而 eureka 集群的 EUREKA_PEERS 环境变量是写死的。剩余的服务调用者和服务提供者这两类中的每种服务的规模都是可以扩大的。但是该怎么做呢?不可能愚蠢地修改 docker-compose 配置文件,不断增加服务吧?

确实, docker-compose up 提供了 --scale 参数,这个参数可以告诉 docker-compose 启动复数个同类服务的容器。

1
docker-compose up --scale image=2 --scale time=3

如上命令,就是告诉 docker-compose 启动2个 image 类服务的容器、3个 time 类服务的容器。当然,这个服务名是 docker-compose.ymlservices 下定义的子项名。

由于之前,我们已经把服务调用者和服务提供者的 docker-compose 配置文件分别独立出来了,扩大它们的规模只需要根据对应的 docker-compose 配置文件使用类似的命令就可以了。

最终,在 partainer 呈现的容器状态,如下图所示: