本文主要讲述如何让Rails应用配合Kubernetes来完成生产环境的部署,原文站点:https://kubernetes-rails.com/ 。本文的翻译已得到作者Marco Colli的同意。


要部署Rails应用有很多种方式:其中一种则是利用Docker容器化技术,并结合Kubernetes作容器编排。这篇教程会为您展示Kubernetes对比于其他部署解决方案的优势所在,并会详细描述如何借助Kubernetes,把Rails应用部署到生产环境。这里会把重点放在生产环境中的容器应用,开发环境的容器应用并不会花费太多的笔墨,毕竟我们觉得简单的解决方案更具价值。此教程会涵盖在生产环境上运行Rails应用所需要关注的方方面面,这其中就包括Web应用的部署与持续交付,域名及负载均衡的配置,环境变量和隐私安全,静态资源的编译,数据库表的变更,日志和监控,后台作业还有定时任务,当然不能少了要如何实施维护性的任务及系统更新。

这篇文章的目录结构如下

  • Kubernetes的历史及其他可选方案
  • 预备知识
  • Rails应用
  • Git仓库
  • Docker镜像
  • Kubernetes集群
  • 域名与SSL(Security Socket Layer加密套接字协议层)
  • 环境变量
  • 隐私安全
  • 日志
  • 后台作业
  • 定时任务
  • 命令行控制台
  • Rake任务
  • 数据库变更
  • 持续交付
  • 监控
  • 安全更新
  • 总结

Kubernetes的历史及其他可选方案

部署Rails应用最简便的方式可能是借助PaaS服务,比方说Heroku,这些服务让部署和扩容变得异常简单,让你几近忘记服务本身,然而:

  • 当应用需要扩容的时候,账单上的花销会高到让你怀疑人生。
  • 你对应用并不具备完整的控制权,都是第三方服务在帮你管理,在应用运作的过程中可能会给你增添些许担忧。
  • 你会受到来自于平台方的约束。
  • 你的应用可能会跟特定的平台绑定,在可移植性方面会有些麻烦。

有个更经济的解决方案是使用IaaS服务,比方说DigitalOcean(可以对标国内的阿里云ECS/轻量级服务器)。最开始,可以用单机的方式来部署应用服务。一般来说,你需要至少一个负载均衡器(比方说HAProxy),及多个基于Puma与Nginx的Web服务,当然还有数据库(通常是可能以集群方式运行的PostgreSQL和Redis)。哦,还需要额外的服务用于后台作业(比方说Sidekiq)。当你需要横向扩容应用的时候,通常只需要针对单一服务创建快照,然后便能得到它相应的副本。你当然也可以通过pssh或者类似于Chef这样的配置管理工具来管理或是变更多台服务器,紧接着便能够使用Capistrano完成部署工作。创建并配置一堆服务器并非难事,然而:

  • 初始化服务器设置需要时间且运维者要有相关技能的知识储备。
  • 要给多台服务器做变更会比较痛苦。
  • 要是给一小撮服务器发送了错误的指令,回滚将是一件较为困难的事情。
  • 你必须要确保所有的服务器都应用了同一份配置。
  • 扩容时需要大量的手动操作。

Kubernetes提供了PaaS的优点,并且用户只需要承担IaaS的费用,因此它是一个不错的折中方案,你应该考虑一下。得益于它是一项开源技术,大多数的云服务商都已经提供了托管的Kubernetes集群服务(比方说阿里云的ACK)。

接下来我们一起来看看如何借助Kubernetes来把一个Rails应用部署到生产环境。

预备知识

这篇教程假设你已经具备Web开发的基本常识。

我们当然也认定你已经拥有一台开发用的机器,并且机器上已经安装好所有必备软件,其中包括Ruby(你可以使用rbenv来安装),Ruby On Rails, Git, Docker等等。

你起码要有一个Docker Hub的账号,要尝试Kubernetes的相关功能DigitalOcean账号也是需要的(当然你也可以选择自己喜欢的替代品,比如说阿里云,腾讯云等等)。

Rails应用

你可以使用一个已经存在的Rails应用,或者说用下面的命令创建一个新的Rails应用案例

> rails new kubernetes-rails-example

然后往示例应用中添加一个简单的页面

config/routes.rb

Rails.application.routes.draw do
  root 'pages#home'
end

app/controllers/pages_controller.rb

class PagesController < ApplicationController
  def home
  end
end

app/views/pages/home.html.erb

<h1>Hello, world!</h1>

Git仓库

接下来我们把改动保存在本地的Git仓库,该仓库在Rails项目初始化的时候就存在了。

git add .
git commit -m "Initial commit"

还需要在线上准备一个Git仓库。你可以去Github创建一个新的仓库,并把远端仓库跟本地仓库进行关联,然后把改动推送到远端仓库:

git remote add origin https://github.com/username/kubernetes-rails-example.git
git push -u origin master

然而Docker或Kubernetes并不严格依赖于Git仓库,我特意在这里提到它是因为绝大多数的CI/CD工具(包括Docker Hub),都可以跟你的Git仓库进行关联。关联仓库之后,就可以做到每当你往远程Git仓库提交改动的时候都可以自动构建Docker镜像。

Docker镜像

容器化的第一步就是创建Docker镜像。一个Docker镜像其实就是一个简单的软件包,里面包含了我们的应用,以及该应用所依赖的第三方软件包和系统库。

在你的Rails应用项目的根目录中添加这个文件

Dockerfile

FROM ruby:2.5

RUN apt-get update && apt-get install -y nodejs yarn postgresql-client

RUN mkdir /app
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN gem install bundler
RUN bundle install
COPY . .

RUN rake assets:precompile

EXPOSE 3000
CMD ["rails", "server", "-b", "0.0.0.0"]

首先使用FROM指令,告诉Docker要下载一个公共镜像,接下来我们会基于这个基础镜像来自定义服务镜像。事实上,我们所使用的镜像包含了特定版本的Ruby。

接下来,使用RUN指令,主要用于在镜像构建的过程中执行系统命令。实际是,我们会借助Linux的apt-get命令安装一些软件包。请记住,在默认的Ubuntu软件仓库中可用的软件包通常都非常陈旧:如果你想要获取最新版本,则需要更新软件仓库中的列表并且告知APT要直接到对应软件维护者的仓库中去下载软件包。实际操作的时候,我们可以往apt-get命令之前添加下面这些命令,借此来更新Node.js以及Yarn的软件仓库

RUN curl https://deb.nodesource.com/setup_12.x | bash
RUN curl https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list

在下一个代码块中,我们会把Rails应用拷贝到镜像中并使用Bundler安装所有必备的Gem包。GemfileGemfile.lock文件会比项目中的其他代码文件更早拷贝到镜像中。那是因为在Gemfile没有任何改动的情况下,Docker可以利用缓存机制来加速镜像的构建。

我们还需要执行任务来预编译静态资源(stylesheet,script等等)。

最后,我们会配置一个默认的命令,它将在镜像中执行。

在构建镜像之前,我们还要确认哪些文件不必被包含到镜像中去:安全角度考虑,把隐私相关的文件排除出去十分关键。当然也要排除一些不必要的目录,比方说tmp.git,这些目录只会浪费系统的存储资源。为了实现这一点,需要在Rails项目根目录创建.dockerignore文件:你可以从项目中的.gitignore文件中获取一些灵感,它们的目的跟语法都十分相似。

现在是时候构建镜像了:

docker build -t username/kubernetes-rails-example:latest .

-t选项,以及跟在后头的参数都是可选的:我们使用它是为了给新的镜像命名并附上一个标签。这为后期查找镜像带来便利。:前面的部分是镜像名,后面的部分是标签。需要注意的是,latest这个标签字眼是可以去掉的,当你没有指定任何标签的时候,latest就会是默认的标签。最后的一个.是必要参数,它指出Dockerfile所在的目录。

构建完成之后,目标镜像在你的机器上就处于可用状态:

docker image ls

你可以使用镜像ID或是镜像名来运行镜像(镜像的运行时被称作容器):

docker run -p 3000:3000 username/kubernetes-rails-example:latest

需要注意,我们把机器的3000端口(左边)跟容器的3000端口(右边)做了映射。你当然也可以使用你喜欢的其他端口:然而,如果你更改了容器的端口,还需要把镜像也一并更新,以保证Rails服务监听了正确的端口,在这里你需要通过EXPOSE指令把端口号暴露出去。

现在你可以通过http://localhost:3000访问你的站点了。

这一步之后,我们便可以把镜像推送到远端的镜像仓库。首先你需要在Docker Hub上注册账号,当然也可以使用其他的镜像仓库服务(考虑到镜像隐私以国内网络问题,建议使用国内云服务商的私有镜像仓库服务),并为服务镜像创建专门的镜像仓库。然后,你就可以把本地镜像推送到远端仓库了:

docker push username/kubernetes-rails-example:latest

Kubernetes集群

接下来就可以为我们的生产环境创建Kubernetes集群了。可以先去到你喜欢的Kubernetes供应商站点,并利用它们所提供的操控面板创建集群:这篇教程中我们用的是DigitalOcean。

一旦集群创建完毕,你需要把相关的证书凭证以及集群配置下载到本地机器,然后就可以连接到远端集群了。举个例子,你可以把配置文件放在这个路径下~/.kube/kubernetes-rails-example-kubeconfig.yaml,并通过kubectl命令的--kubeconfig可选参数指定该配置文件,又或者通过设置环境变量的方式来指定

export KUBECONFIG=~/.kube/kubernetes-rails-example-kubeconfig.yaml

接下来,你需要确认本地机器上的Kubernetes工具套件可以连接到远端集群。运行这条命令即可

kubectl version

应该会看到命令行工具的对应版本号以及远端Kubernetes集群的版本。

你当然也可以干点别的事情,比方说用这条命令:

kubectl get nodes

每个node(节点)都可以看作是Kubernetes管理的简化服务器。Kubernetes可以根据我们的配置信息在每一个node上创建一些虚拟机器,这些机器俗称为pod。这些pod会被Kubernetes自动分发到可用的节点上,如果一个节点挂掉了,Kubernetes会把该节点上的pod移动到其他节点,一个pod通常会包含一个的容器。不过pod也可以包含多个互相关联的容器,而这些容器一般都有共享某些资源的需求。

下一步就是基于我们的Docker镜像调度出一些pod。Docker镜像可能会被托管在私有仓库中,这种情况我们需要给Kubernetes提供Docker镜像所在仓库的证书凭证,这样Kubernetes才能从仓库下载对应的镜像。运行这些命令即可

kubectl create secret docker-registry my-docker-secret --docker-server=DOCKER_REGISTRY_SERVER --docker-username=DOCKER_USER --docker-password=DOCKER_PASSWORD --docker-email=DOCKER_EMAIL

kubectl edit serviceaccounts default

并把下面的文本放在Secrets:后面

imagePullSecrets:
- name: my-docker-secret

然后你可以开始定义Kubernetes的配置信息:在Rails根目录创建一个名为config/kube的子目录。

我们先为Rails应用创建一个部署的配置信息吧

config/kube/deployment.yml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: kubernetes-rails-example-deployment
spec:
  replicas: 4
  selector:
    matchLabels:
      app: rails-app
  template:
    metadata:
      labels:
        app: rails-app
    spec:
      containers:
      - name: rails-app
        image: username/kubernetes-rails-example:latest
        ports:
        - containerPort: 3000

上面的配置信息是一个最小化的部署

  • apiVersion设定应用该配置所需要的API版本;
  • kind设定配置文件的类型;
  • metadata用于设定该部署任务的名称;
  • replicas告知Kubernetes需要创建pod的数量;
  • selector告知Kubernetes基于哪个模板来生成pod;
  • template用于定义pod的模板。
  • spec可以指定运行pod所依赖的Docker镜像,并包含一些其他的配置,比方说容器所需暴露的端口号。

好了,现在我们需要把HTTP请求分发给这些pod。那么就需要创建一个负载均衡器:

config/kube/load_balancer.yml

apiVersion: v1
kind: Service
metadata:
  name: kubernetes-rails-example-load-balancer
spec:
  type: LoadBalancer
  selector:
    app: rails-app
  ports:
    - protocol: TCP
      port: 80
      targetPort: 3000
      name: http

本质上来说,我们告知了负载均衡器以下信息:

  • 需要监听的默认端口是80端口;
  • 把请求转发到打了rails-app标签,并监听了3000端口的pod中;

现在我们可以采用声明式的管理方式,把配置应用到Kubernetes集群中:

kubectl apply -f config/kube

验证以下这些pod有没有正常运行:

  1. kubectl get pods可以列出所有的pod,并附带他们的状态信息;
  2. 所有pod的都应该是Running这个状态;
  3. 如果你看到错误信息ImagePullBackOff,很可能你没有配置合适的镜像仓库证书凭证,以至于Kubernetes无法从私有仓库下载镜像;
  4. 你可以通过命令kubectl describe pod pod-name来获取更多的错误信息;
  5. 你可以修复配置,并通过命令kubectl delete --all pods来重新生成所有的pod;

你还可以通过下面的命令来得知负载均衡器的IP地址及其他信息

kubectl get services
kubectl describe service service-name

实际上你需要的是LoadBalancer Ingress或者EXTERNAL-IP列中的IP地址信息,并把它输入到浏览器的地址栏中:这样我们的网站就启动完成了,现在看来运行良好!

域名与SSL(Security Socket Layer加密套接字协议层)

绝大多数情况下,你的用户群都不会用IP地址来访问站点,因此你需要为服务配置一个域名。可以在DNS解析配置中添加一条记录:

example.com.   A   192.0.2.0

很显然,这里你需要换成自己的域名信息,并把IP地址替换成负载均衡器的公网IP地址(通过命令kubectl get services可以得知)。

SSL证书可以通过恰当的YAML配置添加到你的网站中去,不过如果你用DigitalOcean,最简单的方式还是去到它的控制面板,到那里去配置证书(可能是负载均衡的设置页面)。

环境变量

如果要存储环境变量,其实有很多种不同的解决方案

  • 在Rails的配置中定义环境变量。
  • 在Dockerfile中定义环境变量。
  • 在Kubernetes中定义环境变量。

我建议你使用Kubernetes来管理生产环境中的环境变量:这样系统变更会比较简单,也不需要每次做变更都从头构建一次镜像。当然,Kubernetes针对环境变量的存取有许多可行做法,它甚至可以动态为你设置一些环境变量值(比方说,它会把pod的名称或者IP设置到某个变量中)。最终你会选择采用一个隔离度更好的解决方案,当你要部署多个服务(比方说Puma以及Sidekiq)的时候,就可以为不同的部署场景配置不同的环境变量了。

往你的容器定义文件config/kube/deployment.yml中设置下面的参数(可以放在image属性后面):

env:
- name: EXAMPLE
  value: This env variable is defined by Kubernetes.

然后,可以运行这条命令来对集群做变更

kubectl apply -f config/kube

如果你想知道环境变量有没有设置成功,其实可以去到app的内部,把环境变量打印出来验证一下。

在开发与测试环境,你均可以使用一个名为dotenv的Gem包来简单定义一些环境变量。

而在生产环境,则可以使用Kubernetes的ConfigMaps来定义环境变量。这种做法的优势在于,环境变量只需定义一次,然后把它们应用到不同的部署任务或者说pod中。举个简单的例子,在一个Rails应用中,通常需要配置下面这些环境变量。

config/kube/env.yml

apiVersion: v1
kind: ConfigMap
metadata:
  name: env
data:
  RAILS_ENV: production
  RAILS_LOG_TO_STDOUT: enabled
  RAILS_SERVE_STATIC_FILES: enabled
  DATABASE_URL: postgresql://example.com/mydb
  REDIS_URL: redis://redis.default.svc.cluster.local:6379/

然后把下面的配置添加到容器的定制文件中(可以放在image属性后面):

config/kube/deployment.yml

envFrom:
- configMapRef:
    name: env

你要记住,用此做法来存放像SECRET_KEY_BASE这种敏感信息并不安全,因为这个文件会被纳入Git仓库中被管理起来!下一个小节,我们会一起看看要怎么活用Rails的credentials机制来安全存放你的隐私信息。

隐私安全

你既可以把隐私信息存放在Rails的配置中,也可以利用Kubernetes的隐私安全功能(Kubernetes Secrets)来存放这些信息。这里,我建议你使用Rails的credentials机制来存放隐私信息:我们只会用Kubernetes的隐私安全功能来存放master key。本质上来说,所有的隐私信息都跟应用的源代码一起存放在Git仓库中,不过它们是安全的,因为我们使用了master key来对隐私信息进行了加密,以后就可以利用master key来访问所有的隐私信息了(master key不会托管到Git仓库中)。

config/environments/production.rb配置文件中开启相应选项

config.require_master_key = true

然后运行该命令来编辑你的隐私信息

EDITOR="vi" rails credentials:edit

把下面的行添加到文件之后就保存并退出

example_secret: foobar

然后你可以尝试在应用中访问该隐私信息

app/views/pages/home.html.erb

<%= Rails.application.credentials.example_secret %>

需要清楚的是,从Rails 6开始,你甚至可以区分环境来保存各环境不同的隐私信息。接下来如果你在本地启动网站,访问对应的页面便能看到隐私信息了。最后一件需要做的事情是把master key用Kubernetes的隐私安全功能保存起来,以便日后访问。

kubectl create secret generic rails-secrets --from-literal=rails_master_key='example'

通常你master key的内容会存放在文件config/master.key中,只是现在内容会存放于Kubernetes系统中了。

最后,我们要把Kubernetes系统存放的master key信息,以环境变量的形式提供给容器使用。在你的config/kube/deployment.yml配置文件中的env属性下添加这个环境变量

- name: RAILS_MASTER_KEY
  valueFrom:
    secretKeyRef:
      name: rails-secrets
      key: rails_master_key

要测试是否能够正常工作,你可以重新构建镜像并使用新的配置来部署应用:你将会看到示例中的隐私信息(并非真实的隐私信息)显示在网站首页了。

日志

日志的记录通常有两种不同的策略

  • 把Rails应用的日志信息直接发送到一个中心化的日志服务中去。
  • 把日志直接写入到标准输出(stdout),然后让Docker或者Kubernetes从对应的节点(更准确来说应该是容器)收集日志。

第一种策略十分简单,就是可能有些低效,且无法收集到Kubernetes集群本身的日志信息。要是你选择这种方式,都可以使用类似于logstash-logger的Gem来实现相关功能。

如果你选择第二种策略,那么你可以启用Rails应用中的配置项,使得日志信息直接被写入到标准输出(stdout)。直接把环境变量RAILS_LOG_TO_STDOUT设置成enabled就可以了。接着你就可以使用下面的命令查看日志信息:

kubectl logs -l app=rails-app

本质上来说,当你运行命令的时候,Kubernetes的主节点会从其他的节点收集最新的日志信息(只从标记了rails-app的pod中)并展示出来。这是一个很好的开端,不过呢,这些日志信息是没有被持久化的,你需要想想办法,让它们能够被方便地检索。要达成这个目的,你需要把日志发送到一个中心化的日志服务中去:我们以Logz.io为例,它提供了便于管理的ELK stack。这里会使用Fluentd来把日志信息发送到ELK服务中去,它是一个Ruby语言编写的日志收集工具,也是一个从CNCF毕业的项目。

以下是日志记录的工作流程:

  • 你的Rails应用及其他的Kubernetes组件都会把日志写入到标准输出(stdout)中;
  • Kubernetes收集日志,并把日志存放在节点上;
  • 在每个不同的节点,你都可以使用Kubernetes DaemonSet来运行一个Fluentd的pod;
  • Fluentd会读取节点中的日志信息,并发送到日志服务中心。
  • 你可以到日志服务中心去查看日志信息,同时你也可以对日志信息进行可视化及检索。

可以用下面的命令,简单来为集群安装Fluentd:

kubectl create secret generic logzio-logs-secret --from-literal=logzio-log-shipping-token='MY_LOGZIO_TOKEN' --from-literal=logzio-log-listener='MY_LOGZIO_URL' -n kube-system

kubectl apply -f https://raw.githubusercontent.com/logzio/logzio-k8s/master/logzio-daemonset-rbac.yaml

如果你需要对配置进行一些定制,可以在运行kubectl apply之前先把配置文件先下载下来并编辑它。需要记住,即便不使用Logz.io,而是其他类似的服务,策略也是非常相似的。你还可以在fluent/fluentd-kubernetes-daemonset这个Git仓库中找到许多配置案例。

现在你可以去访问一下网站,并查看日志信息,来验证日志策略是否已经生效了。

后台作业

为了让一些后台作业可以正常工作,现在我们来看看如何在Kubernetes上运行Sidekiq。

首先,你需要把Sidekiq添加到Rails应用中去:

Gemfile

gem 'sidekiq'

运行bundle install进行安装,接下来就可以创建一个后台任务作为示例:

app/jobs/hard_worker.rb

class HardWorker
  include Sidekiq::Worker

  def perform(*args)
    # Do something
    Rails.logger.info 'It works!'
  end
end

最后在你的PagesController#home方法中添加下面的行,这样每次有请求进来都会创建一个后台作业。

HardWorker.perform_async

接下来到有趣的部分了:我们需要为Kubernetes添加新的部署配置,让Sidekiq可以在集群中运行。对比我们前面已经完成的Web应用部署,这次的部署配置十分简单:只是,这次在容器中运行的主进程并非Puma,我们想要运行的是Sidekiq。配置如下

config/kube/sidekiq.yml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: sidekiq
spec:
  replicas: 2
  selector:
    matchLabels:
      app: sidekiq
  template:
    metadata:
      labels:
        app: sidekiq
    spec:
      containers:
      - name: sidekiq
        image: username/kubernetes-rails-example:latest
        command: ["sidekiq"]
        env:
        - name: REDIS_URL
          value: redis://redis.default.svc.cluster.local:6379/

本质上来说,我们新定义的部署配置会生成两个pod:每个pod都是基于先前定制的Rails应用标准镜像运行起来的。这里面最有趣的地方在于,我们可以通过设置command来覆写掉Docker镜像里面的默认命令。你当然也可以通过args选项来为Sidekiq传递一些参数。

要注意到我们定义了REDIS_URL这个变量,这样,Sidekiq才可以连接到对应的Redis服务,进而可以读取并执行一些后台作业。当然,也需要在Web应用的部署配置里设置相同的变量,这样Rails应用才能连接到同一个Redis服务,并对一些后台作业进行调度。至于Redis服务本身,既可以借助Kubernetes StatefulSets,也可以把它安装到自己的服务器上甚至采用第三方托管的解决方案:虽说,管理单点的Redis服务十分简单,然而要对Redis集群进行扩容却不是一件手到擒来的事情。如果你对可伸缩以及可靠性有一定的要求,需要考虑第三方托管的解决方案。

不可避免的是,你还需要通过命令kubectl apply -f config/kube把新的配置应用到Kubernetes集群中。

一切都搞定之后,便可以尝试访问你的站点,检测后台作业有没有正常工作:当加载首页之后,示例后台作业将会被调度,你应该可以通过日志记录看到它的工作效果。

定时任务

当把一切都部署到Kubernetes之后,你其实有很多种不同的方案来为其创建定时作业:

  • 使用Kubernetes自带的CronJob,周期性地运行一个容器。
  • 借用Ruby生态的后台作业进程来调度并运行相关任务。

第一种策略的问题在于,你还需要为每个不同的定时任务定义对应的Kubernetes配置。如果,你喜欢这个解决方案,你可以把Kubernetes CronJobs搭配rake tasks或者rails runner一起使用。

而如果你选择第二种解决方案,你可以用Ruby更方便地去调度各种定时任务。从本质上来说,你需要一个长时间运行在后台的Ruby进程,在当前时间能够与Cron定时任务的配置记录相匹配的时候,它可以创建并调度特定任务。

举个具体的例子,你可以安装rufus-scheduler这个Gem,然后为这个Gem启动一个专门的容器来支持定时任务的调度:只不过这样的话你会有单点失败的风险,并且,如果这个pod被重新调度,那么当前pod中的任务也将丢失。如果你需要一个分布式且可靠的环境,我们需要使用像sidekiq-cron这种Gem:它会基于每一个Sidekiq服务进程来运行一个调度线程,同时它也会依赖Redis来规避同一个任务被调度多次的情况。举个例子,假设你的Sidekiq服务有N个进程,那么这N个进程每分钟都会去检查调度表(Cron配置),查看当前时间是否能够跟Cron配置上的记录相匹配,并尝试获取一个Redis锁:如果其中一个线程获得了锁,那么在那个时间点,它将会承担起调度Sidekiq任务的责任,否则的话,什么都不用做。最后,这个Sidekiq任务会以另一种后台作业的身份正常工作。故而,基于已有的pod,这种方式要做横向扩容十分简便,并且还可以通过重试机制来保证运作的可靠性。

我们需要把这个Gem添加到Rails应用中:

Gemfile

gem 'sidekiq-cron'

然后运行bundle install并创建一个启动器配置

config/initializers/sidekiq.rb

Sidekiq::Cron::Job.load_from_hash YAML.load_file('config/schedule.yml') if Sidekiq.server?

最后定义一个调度计划:

config/schedule.yml

my_first_job:
  cron: "* * * * *"
  class: "HardWorker"

只要启动了Sidekiq服务,你会发现,不管有多少个pod正在运行着,这个任务每分钟会被执行一次。

命令行控制台

你可以通过下面这条命令来连接到一个pod:

kubectl exec -it my-pod-name bash

本质上来说,我们会在容器内部启动一个bash进程。不仅如此,我们还加入了参数-it,这样在启动了bash之后就可以通过命令行与容器进行一些常规的交互。

如果你需要列出所有pod的名字,可以使用kubectl get pods这个命令。

虽说你可以直接连接到任何一个运行中的pod来完成日常的维护工作,但是我发现一种更为实用的做法,就是专门为这些维护工作另外调度一个名为terminal的pod。创建以下文件,并执行kubectl apply -f kube/config命令:

apiVersion: v1
kind: Pod
metadata:
  name: terminal
spec:
  containers:
  - name: terminal
    image: username/kubernetes-rails-example:latest
    command: ['sleep']
    args: ['infinity']
    env:
    - name: EXAMPLE
      value: This env variable is defined by Kubernetes.

所有的容器都必须有一个主进程,否则他们将会退出运行,然后Kubernetes则会认为容器已经销毁。然而如果要运行镜像中的默认命令(就是Rails服务),则会有点浪费资源,毕竟这种维护类型的pod并不需要连接到负载均衡器:我们用sleep infinity命令来取代原来的默认命令,这本质上是一个no-op的命令,相对来说消耗资源更少且可以保持容器的持续运作。

一旦你以bash的方式连接上这个pod,你就可以很轻易地运行各种命令了。然而如果你只想要运行单一命令,也可以在连接的时候直接指定目标命令。举个例子,如果想要在一个名为terminal的pod里面运行rails console,可以这样做:

kubectl exec -it terminal rails console

如果你还想要为进程赋予额外的命令行参数,则可以使用--来对Kubernetes的参数目标命令以及它的参数两者之间做分割。举个例子:

kubectl exec -it terminal -- rails console -e production

PS: 不加--的写法已经废弃了,kubectl exec [POD] [COMMAND] is DEPRECATED and will be removed in a future version. Use kubectl exec [POD] -- [COMMAND] instead.,做分割尽量都使用--

Rake任务

要在Kubernetes中运行Rake任务有两种不同的方式:

  • 可以创建一个Kubernetes Job然后让Rake任务可以在一个专门容器中运行。
  • 可以连接到一个已经存在的pod,并在上面运行Rake任务。

简单起见,我更喜欢第二种做法。

运行下面的命令来列出所有可用的pod:

kubectl get pods

你可以用下面的命令来运行一个Rake任务

kubectl exec my-pod-name rake task-name

官方会建议写成

kubectl exec my-pod-name -- rake task-name

需要注意,kubectl exec命令会返回Rake任务执行结果的状态码(比方说如果Rake任务运行成功,就会返回0)。

数据库变更

每当你部署最新版本Rails应用的时候,要在不停机的状态下对它所依赖的数据库做变更并非易事。问题的根源大多在于,不管是把最新的代码部署到所有的pod上还是利用Rails的Migration机制对数据库做变更都是耗时任务,它们都需要一定的时间才能执行完毕。从本质上来说,这些操作既非瞬时亦非原子,在部署完成之前,你至少需要面对以下两种场景中的一种。

  • 老代码运行在最新的数据表模式上。
  • 新代码运行在老的数据表模式上。

让我们更细致地去分析一些策略:

  • 停机:如果你能够接受变更过程中的停机,那么只消简单地把pod的数量降到0,并执行一个用于数据表变更的Kubernetes Job,当变更完毕之后再把pod的数量升上去即可。优点:简单,你的应用代码不需要有额外的依赖;也不存在部署过程中因为代码运行在不匹配的数据表模式中而造成的运行时错误。缺点:得停机一段时间。

  • 先部署新代码,然后做数据表变更:这是在Heroku上实施变更的传统做法。举个实际点的例子,你会先把代码部署出去,所有的pod都会依照最新的镜像进行更新。当一切都完成之后,你再去实施数据库的变更。咋一看,这种做法十分完美,前提是你可以让代码适应老的数据表模式。然而,当最新数据表模式被更新完毕之后,你依旧需要处理掉Ruby进程中ActiveRecord中老数据表模式的缓存(因此需要做额外的重启动作)。如果你选择这种策略,可以先部署新代码,完成之后简单连接到其中一个pod,并运行rake db:migrate优点:不需要停机;部署起来非常简单。缺点:要让代码向后兼容十分困难;而且在数据库变更完毕之后你可能还需要额外的重启动作。

  • 先做数据库变更,然后再部署新代码:这是一个比较通用的手段,Capistrano便是采用了这种方案,Heroku Release Phase包括其他一些CI/CD工具也是。这种做法的问题在于,滚动更新多个pod会需要点时间,并且在这个时间段内,可能会有些老的代码在新的数据表模式上运行。为了避免这个短暂间隔中发生的异常,你需要让新的数据表模式向后兼容,换句话说就是让它能够跟新/老代码一同运行:然而要编写出这种在不停机情况下的数据表变更并非易事,且里面会有许多陷阱。为了规避掉一些额外的问题,你还应该为每个版本的镜像都打上不同的标签(不仅仅只是用latest),此外,镜像的调度操作是原子性的,应该要在数据表变更完成之前就把镜像下载好。如果你选择了这个策略,需要创建一个Kubernetes Job,并基于最新的镜像来调度出一个pod,在里面实施数据表变更。在变更完毕之后,再用最新的镜像去更新其他所有的pod。优点:这是一个比较常用的策略,可靠性方面有一定保障。缺点:如果你并不能编写向后兼容的数据库变更,部署最新代码的过程中可能会引发一些错误。如果你还想规避掉新代码运行在老数据表模式这种意外场景,可能需要为不同版本的Docker镜像都打上不同的标签。

如果我们选择最后一种解决方案,那我们必须要定义一个像下面这样的任务:

config/kube/migrate.yml

apiVersion: batch/v1
kind: Job
metadata:
  name: migrate
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: username/kubernetes-rails-example:latest
          command: ['rails']
          args: ['db:migrate']
          env: …

接着你可以使用以下的命令来实施数据表变更

kubectl apply -f config/kube/migrate.yml

然后,便可以看到数据表变更的状态

kubectl describe job migrate

上面的命令会显示出执行数据表变更任务所在pod的名字。你也可以通过命令看到相关的日志

kubectl logs pod-name

一旦迁移任务完毕,就可以把它删除了,借此,可以节省出一些资源,而且在未来有需要的时候还能够重新运行一遍:

kubectl delete job migrate

持续部署

在前面的各个小节中,我们已经配置好Kubernetes集群并且手动把代码部署出去了。然而,如果能把这一切封装成一个简单的命令,让你随时都能部署,那将十分便利。

创建下面的文件并赋予可执行权限(使用chmod +x deploy.sh)

deploy.sh

#!/bin/sh -ex
export KUBECONFIG=~/.kube/kubernetes-rails-example-kubeconfig.yaml
docker build -t username/kubernetes-rails-example:latest .
docker push username/kubernetes-rails-example:latest
kubectl create -f config/kube/migrate.yml
kubectl wait --for=condition=complete --timeout=600s job/migrate
kubectl delete job migrate
kubectl delete pods -l app=rails-app
kubectl delete pods -l app=sidekiq
# For Kubernetes >= 1.15 replace the last two lines with the following
# in order to have rolling restarts without downtime
# kubectl rollout restart deployment/kubernetes-rails-example-deployment
# kubectl rollout restart deployment/sidekiq

然后你就可以通过这条命令简单地发布新版本了

./deploy.sh

上面的命令会执行以下步骤:

  1. 使用sh作为解释器,设置可选项来打印每一条命令,并在出错的时候自动退出运行。
  2. 本地构建并推送Docker镜像到远端镜像仓库。
  3. 实施数据表变更,等变更完毕之后把任务删除。
  4. 最后发布新代码/镜像。

当然,你还要记得,每次对Kubernetes的配置做了修改,都需要用这条命令来应用最新的配置:

kubectl apply -f kube/config

监控

出于一些原因,你还需要对Kubernetes集群做一些监控,比方说:

  • 了解资源的使用情况,并对相关的集群进行扩容。
  • 检查资源的使用情况是否正常,比如,会不会出现pod占用了大量系统资源的场景。
  • 核查是否所有的pod都运行正常,有没有运行失败的实例。

一般情况下,Kubernetes服务的供应商会提供了一个控制面板,面板里面会包含许多有用的状态信息,所有节点的CPU使用率,平均负载,内存使用率,磁盘使用率,带宽使用情况等等。通常我们会通过一个Kubernetes DaemonSet来收集状态相关的信息,该行为跟先前描述的日志收集行为十分相似。根据你的喜好,当然也可以在每一个节点上安装可定制化的监控代理。你可以使用诸如Prometheus这样的开源产品,又或者是类似于Datadog这样的第三方服务。

也有其他可用于监控应用性能的手段:

  • 在集群上安装Kubernetes的metrics-server,它会把状态信息存放在内存中,然后你可以使用kubectl top nodes/pods这样的命令来查看信息。
  • 直接使用记录在负载均衡器中的状态信息。
  • 通过外部服务往应用程序中发送ad-hoc请求,根据响应情况,收集并合成对应的度量信息。比方说,要在外部的观测点度量某请求的响应时间。
  • 使用特定的Gem直接从Rails应用程序收集状态信息,借此来完成对应用的性能监控,比如说Datadog APM以及New Relic APM(笔者公司正在用它)。

安全更新

在使用容器的过程中,人们会有一个思想误区,觉得可以忘掉安全更新这回事。即便Docker镜像以及容器都增添了额外的层级用作隔离,安全更新依旧重要。事实上,不管是新的虚拟主机---可以理解成pod,或是其他的容器,它们的存在都是短暂的。从安全的角度来看,这是很好的,然而为了避免应用服务被攻击,我们依旧需要对它们进行升级。请记住,如果安全漏洞处在更底层,那么应用服务遭受攻击依旧是可能的,比方说,安全漏洞处在操作系统层又或者是基础镜像所包含的代码库中的时候。

如果你在Kubernetes上运行Rails应用,那么请记住要对以下层级进行更新:

  • Kubernetes集群及节点:大多数的Kubernetes服务提供商都会为你的底层节点以及Kubernetes服务套件实施更新,故而你可以把需要着手更新该层级的事情抛诸脑后。然而,你可能要开启自动更新的选项:假设你是使用DigitalOcean,请记得要去到Kubernetes的设置面板,并启动自动更新开关。
  • Docker镜像以及容器:你需要让容器始终保持最新版本。实际操作中,请确保你的基础镜像使用的是最新版。如果你用Ruby官方镜像作为基础镜像,应该使用2.5这样的标签,而不是2.5.1,这样,当有新安全补丁(patch)的时候,容器也会自动应用最新的版本,你也不需要一直提醒自己要记得去提升补丁版本号。然而,这样还不够:每当有操作系统新补丁的时候,Ruby的维护者会根据最新的操作系统发布新的Ruby镜像,却使用了相同的标签(比方说,都使用了2.5作为标签的Ruby镜像,底层系统并不总是一样的)。这意味着,你需要经常到Docker Hub上查看,检查基础镜像是否有接收到一些更新(或者订阅Ruby,Ubuntu的官方安全邮件列表):如果有可用更新,重新构建并发布你的镜像。
  • Rails应用和相关依赖:请记得要升级你的Ruby以及Rails的版本。这当然也包括应用所依赖的Gem包跟Yarn包,也包括其他类型的软件包。
  • 其他:你当然也需要升级Kubernetes之外的数据库及其他第三方服务。通常来说,使用第三方托管的数据库服务会比较便于管理。服务提供商会自动为对应服务实施安全更新,你可以忘掉对他们的更新工作。

基本上,如果你使用的是第三方托管的服务(不管是Kubernetes还是数据库),并频繁地把自己的应用部署到上面,一般不需要特意去做什么事情:只需要让你的Rails应用保持最新版本即可。然而,如果你发布应用的频率并不高,记得要定期重新构建镜像,不要让你的应用几个月都运行在一个早已过期的基础镜像上。

总结

这篇文章,基本覆盖了投产时把Ruby On Rails应用部署到Kubernetes集群上所需要的各方面知识。

在数百个节点上实施应用扩容或是配置变更,在现如今是一件十分简单的事情,只需要一个运维层面的操作就能够实现。这要感谢Kubernetes的强大。对比于像Heroku这样的PaaS服务,它更具性价比,且移植性更好。

请记住,要达到一定的高可用性,并实现世界范围内的扩容,你需要规避掉一些瓶颈情况以及单点错误,在实际操作中会包括:

  • 负载均衡,如果它是一个单一的服务,可能会成为瓶颈所在;在条件允许的情况下,你可以使用更好的硬件设备,不过最后你可能还是得使用Round-Robin DNS把客户请求分发到不同的负载均衡器或部署节点来提高吞吐能力;如果你使用类似CloudFlare这样的全局网络服务,它们甚至可以对你的负载均衡器实行健康检查,使其免受DDoS攻击并缓存大多数的请求。
  • 数据库,如果是单点的数据库服务,便有可能会成为瓶颈所在;你可以考虑使用更好的硬件设施和热备份服务。只是到最后,可能还得迁移到一些支持分片的数据库管理系统(DBMS)上,分片意味着数据会自动分发到不同的数据库节点上,每个节点都管理着某个范围的key;数据库的客户端(比方说Rails应用内部的数据库适配器)首次请求数据库集群是要了解当前集群的配置信息,下一次则会到正确的数据库实例中去做进一步的查询,因此可以规避掉一些瓶颈。此外,分片节点之间互为复制集,这是为了在某些节点硬件发生异常的时候保护数据免受损害,故而它们会被称为分片复制集(Sharded Replica);MongoDB和Redis Cluster提供的案例就与我们所描述的策略类似,市面上也存在着许多第三方托管的解决方案。

DSCF0410.jpg