0%

基于 Jenkins + Swarm 的 Web 应用不停机发布方案

前言

由于目前就职的是一家做物联网相关产品的科技公司, 为了帮助公司解决后端发布更新时导致服务不可用问题及帮助项目实现未来业务扩张后集群运行等问题, 结合公司实际情况, 探索合适的技术栈来解决这些问题。最终决定使用 Jenkins + Swarm 来进行实施. 至于采用这套技术栈的原因, 大概可以总结为以下几点:

  • 公司业务需要保证服务高度可用, 发版/更新期间服务不被中断
  • 公司规模小, 提供的云上资源也同样很少, 需要轻量化的解决方案
  • 后端框架陈旧, 资源分布混乱, 需要一套合理的项目管理方法
  • 没有专职的运维人员, 要求配置和管理尽量简单, 降低使用者学习难度曲线

综上所述, 在容器编排选型上面就直接抛弃了 k8s 而选择轻量化的 swarm. 包括镜像管理这一块, 也是直接去掉了公共的存储仓库而选择直接在目标机器操作.

技术要点

  • 项目代码通过 Gitlab 管理, 并遵循工作流方式编写和提交
  • 编译打包通过 Jenkins + Git + Maven 实现, 要求项目能够参数化构建
  • 镜像生成使用 ssh 远程编译(由于不使用镜像仓库, 所以只打包不推送)
  • 集群方案使用 Swarm + docker-compose 方式管理, 使用 compose 的目的是因为公司的资源不多, 单台4C8G的 ECS 要跑4个 J2EE 项目服务以及还有各种中间件, 所以部分有状态的中间件之类的服务通过 compose 来跑尽量节省资源
  • 反向代理使用 Traefik, 减少 nginx 配置(主要原因还是没有 k8s 那样成熟的 ingeress)
  • 容器健康检查, 实现滚动更新时保证新版本服务可用之后才 down 掉旧服务
  • 容器通信使用 Swarm 模式原生方式, 为了保证 docker-compose 部署的服务和 Swarm 的服务能够相互连接, 需要使用自定义的 Swarm 网络
  • 统一日志收集, 集群化之后日志将变得分散, 需要使用有效手段来集中管理

实施过程

docker & swarm 安装

参见 Dokcer-CE & Docker Compose 安装

为了做到足够简单, 并没有去配置 Docker 的远程API, 后面统一使用 Jenkins 远程SSH操作

Traefik部署

参见 Traefik-V2.0 & Docker-Compose 最佳实践

项目使用的是 Swarm 模式, 所以上面文章的内容需要作适量改动, 详情参照官网
https://docs.traefik.io/v2.0/providers/docker/#swarmmode

项目因为只有单节点, 所以 Traefik 只是以 compose 部署的非集群方式运行

Jenkins部署

参见Jenkins + Git + Maven持续构建部署环境搭建

这次搭建过程处理上述的3个插件之外, 还使用到一个轻量的插件 Publish Over SSH, 通过它来实现传输文件和执行命令以及管理主机(不使用 scp & ssh 的原因是这个插件可以通过密码登录, 不用修改 sshd 服务)
另外赠送一篇记录解决 Jenkins 安装插件缓慢问题的文章 Jenkins for Docker 跳过插件安装及插件加速镜像设置
另外 Jenkins 需要的 Git Maven JDK 等组件我这边都是通过 Jenkins 内部安装的, 就不细讲了

ELK部署

日志收集分析使用的业界常用的 ELK 方案(找了很久没有找到合适的轻量替代方案), 搭配阿里的 log-pilot 收集器体验非常好, 为了减少系统采销, 这里使用的是 5.6.16 版本 实际实施发现 5.x 功能太弱 7.x 消耗太高, 最终使用的 6.8.5 版本
docker-compose.yaml

version: '3.7'
services:
  # https://yq.aliyun.com/articles/674327
  log-pilot:
    image: registry.cn-hangzhou.aliyuncs.com/acs/log-pilot:0.9.7-filebeat
    restart: always
    privileged: true
    environment:
    - PILOT_TYPE=filebeat
    - LOGGING_OUTPUT=elasticsearch
    - ELASTICSEARCH_HOSTS=elasticsearch:9200
    # - ELASTICSEARCH_USER=
    # - ELASTICSEARCH_PASSWORD=
    volumes:
    - "/:/host:ro"
    - "/etc/localtime:/etc/localtime:ro"
    - "/var/run/docker.sock:/var/run/docker.sock:ro"
  # https://blog.csdn.net/qq_38906421/article/details/88644315
  # https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html
  elasticsearch:
    image: elasticsearch:6.8.5
    restart: always
    environment:
    - ES_JAVA_OPTS=-Xms256m -Xmx256m
    - xpack.security.enabled=true
    volumes:
    - "{es_data_path}:/usr/share/elasticsearch/data/:rw"
  # https://www.elastic.co/guide/en/kibana/current/docker.html
  kibana:
    image: kibana:6.8.5
    restart: always
    environment:
    - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    - XPACK_MONITORING_ENABLED=true
    - ELASTICSEARCH_USERNAME=kibana
    - ELASTICSEARCH_PASSWORD={password}
    ports:
    - 5601:5601

综合实现

上面的部分基本就是环境准备, 主要的实现还是在 Jenkins 里面的脚本, 主要方案就是打包项目之后, 将项目发送到目标机器之后, 在通过 Docker 编译, 最终使用 docker stack deploy 命令发布服务到 Swarm
简单记录一下使用到的脚本配置(下面的脚本属于和项目绑定的关系, 不适用于任何其他项目, 仅作为参考)

KEY VALUE 备注
Source files module/target/app.war 传输的文件
Remove prefix module/target/ 忽略目录
Remote directory /root/docker-build/$JOB_BASE_NAME-$BUILD_NUMBER/ 存储路径
# 包名
PN=app.war
# 镜像名字
IN=domain/project:$BUILD_NUMBER
# 工作目录
WD=/root/docker-build/$JOB_BASE_NAME-$BUILD_NUMBER/

# 准备
cd $WD

# 构建
cat << EOF > Dockerfile
FROM tomcat:8.5.47-jdk8-openjdk

RUN \
echo 'tomcat.util.http.parser.HttpParser.requestTargetAllow=|{}' >> /usr/local/tomcat/conf/catalina.properties && \
rm -rf /usr/local/tomcat/webapps/*

ENV TZ=Asia/Shanghai
ENV JAVA_OPTS="-Xmx256m -Djava.security.egd=file:/dev/urandom"

ADD $PN /usr/local/tomcat/webapps/$PN

HEALTHCHECK --interval=10s --timeout=3s --start-period=5m --retries=3 CMD curl --silent --fail http://localhost:8080/app/ || exit 1
EOF
docker build -t $IN .

# 发布
cat << EOF > docker-compose.yaml
version: '3.7'
services:
  app:
    image: $IN
    networks:
    - global
    labels:
    - "traefik.enable=true"
    - "traefik.http.middlewares.gzip.compress=true"
    - "traefik.http.middlewares.ssl.headers.sslRedirect=true"
    - "traefik.http.routers.app_http.entrypoints=http"
    - "traefik.http.routers.app_http.rule=Host(\`api.domain.org\`)"
    - "traefik.http.routers.app_http.middlewares=gzip"
    - "traefik.http.routers.app_https.entrypoints=https"
    - "traefik.http.routers.app_https.rule=Host(\`api.domain.org\`)"
    - "traefik.http.routers.app_https.middlewares=gzip"
    - "traefik.http.routers.app_https.tls.certresolver=acme-resolver"
    - "traefik.http.services.app.loadbalancer.server.port=8080"
    # index_topic 实测不能出现下划线
    - "aliyun.logs.${index_topic}=stdout"
    deploy:
      mode: replicated
      replicas: 2
      update_config:
        parallelism: 1
        order: start-first
networks:
  global:
    name: net-global
    external: true
EOF
docker stack deploy --resolve-image never --orchestrator swarm -c docker-compose.yaml $JOB_BASE_NAME

之所以没有将 labels 放到 deploy 之下, 是因为 Traefik 是以非集群模式运行的
labelsdeploy 之下时 labels服务 的标签
labelsservice 之下时 labels容器 的标签

附录

  • 用于测试Api的脚本
#!/bin/sh
while true
do
current=`date "+%Y-%m-%d %H:%M:%S"`
timeStamp=`date -d "$current" +%s`
echo $timeStamp-`curl -I -m 1 -f http://domain:port/path -o /dev/null -s -w %{http_code}`
sleep 1
done