最近我通过 Google Container Engine 把我的博客从 Google App Engine 迁移到了运行在 Kubernetes 上的 Docker 容器中。
我之所以这么做的原因是一直以来我把折腾作为一种学习的方式。在这之前,我的博客一直被用来尝试各种不同的技术,理解各不同的架构的优缺点。我的博客经历过各种技术栈:PHP/Apache/MySQL,Python/SQLite,用Bash脚本生成静态文件,用Python生成静态文件,Perl CGI,以及其他将文字变成网页的方式。在最近,我的博客是已经成了一个基于 Clay框架 的Python应用,运行在App Engine之上,使用Google Cloud Database作为数据库。这个配置十分的稳定,几乎不需要我去做什么改动。不需要管理什么服务器,不需要关心什么扩展。成本也十分低廉,流量几乎很少超过App Engine的免费范围,尤其是在有 Cloudflare 的缓存在前面起很大作用的情况下。
这个项目的代码十分简单,也不是很优雅。每一个文章包含一个 title
,一个 slug
, created_date
, modified_date
,和一个是二进制大对象的 content
,所有这些都保存在数据存储的一个键下面。文章会通过 slug
或者 modified_date
进行查询,这取决于请求使用的URL方案。返回的文章列表传进一个 Jinja2 模板,该模板使用很多自定义的过滤器,最终处理的HTML结果将作为响应准备发送出去。所有的响应在发送给客户端之前都会缓存到memcache。请求的响应尽量从memcache中获取,以减少数据存储查询和花在模板渲染上的时间消耗。并且因为博客文章的内容几乎很少更改,这保证了特别高的缓存命中率。
唯一App Engine特有的部分是一些使用app.yaml安装的依赖库,和 google.appengine.ext.db 模块,这个模块提供了一个简单的ORM用来和数据存储进行交互。 db 模块已经被废弃了好一阵子了,取而代之的是 ndb ,这个模块提供了更多的功能和更简洁的API。我本来想把所有的部分都换成 ndb ,但是很惊讶的发现在App Engine之外不能使用ndb。虽然有一个公共可访问的数据存储API。这个公共API使用的是 gcloud.datastore 库,没有与 google.appengine.ext.ndb 共用的代码,并且离一个ORM还差得远。 gcloud.database 差不多是一个数据存储的protobuf接口封装。好像 有人正努力 将ndb的功能放进 gcloud.database 中,但是仍然在等待将一些方法添加到公共的数据存储API中。
这个博客是是数据存储非常简单的应用场景,因此我决定继续折腾,将对数据存储的查询从ORM的变成GQL接口。当我在测试这些更改的时候,在查询数据存储的时候遇到了身份认证的问题。gcloud提供了三个认的方法:显式地指定密码,通过文件提供密码,或者通过一个服务账号。服务账号是Google Cloud的一个十分好的特性,能让让你项目运行的Computer Engine实例中访问其他Google服务。我发现我的开发实例在创建的时候没有正确启用的作用域来访问数据库,所有我不得不删除这个实例(仍保留磁盘)然后重新创建,并勾选auth服务的单选框。在花了几个小时调试一个开发实例上启动脚本的被搞坏的无关问题之后,我再次试着用服务账号来访问数据存储,仍然得到的是未认证的错误。我做了很多次测试,观察来回的HTTP请求响应,确认gcloud库通过服务账号获得了OAUTH的token,并且将它放在了请求头里面传给了数据存储服务,但是问题仍然没有解决。我开始怀疑gclound库可能有bug或者公共的数据存储服务阻止了服务账号正确的认证。我在Cloud Console里面生成了一个新的服务账号,导出成一个json文件,然后将这个传给 gcloud.datastore.Client.from_service_account_json
类方法,来认证我的app。这在第一次尝试的时候就成功了。可糟糕的是,现在我有一个放了各种秘密信息的文件需要考虑考虑...
下一步是让我的应用在Docker容器中跑起来。这十分简单,因为我的应用将自己暴露成一个WSGI的callable,因此我可以直接用uwsgi来运行它。需要经过一些反复尝试才能让所有依赖正确的构建并安装好。下面是我最终得到的Dockerfile:
FROM debian:jessie
COPY config/sources.list /etc/apt/sources.list
RUN apt-get update && apt-get install -yf /
dnsutils python-dev python-pip python-yaml python-memcache /
python-openssl python-crypto python-cryptography /
python-jinja2 && rm -rf /var/cache/apt/archives/*.deb
COPY . /app
RUN pip install -r /app/requirements.txt
ENV CLAY_CONFIG=/app/config/production.json
CMD /usr/local/bin/uwsgi /
--http-socket :8080 /
--wsgi-file application.py /
--callable application /
--pythonpath lib /
--chdir /app /
--static-map /static=static /
--static-map /robots.txt=static/robots.txt /
--enable-threads
EXPOSE 8080
我将几个依赖以debian包的形式安装来避免从源码中编译这些库,从而不用担心开发头文件。将apt的归档文件删除能让layer更加轻量一点点。 static-map
的参数是用来复制在App Engine app.yaml
中的一些静态路由的功能。
一旦我有了一个可以工作的Docker容器,我给其打上标签,然后推到Google Container Rgistry。这是一个十分方便的私有Docker registry,可以使用服务账号让你项目中的每一个实例都能使用。
在经过几次调试和测试后,我使用Container Engine启动了一个Kubernetes集群。这特别简单:
gcloud container clusters create testing
当我在等待集群启动的时候(这需要几分钟),我浏览了一下Kubernetes的文档然后编写service和pod的定义文件。Kubernetes中的pod简单说来就是一系列同时运行在相同主机上的容器。在最简单的情形下,一个pod可以只运行一个容器,然而我想每一个uwgsi容器都同时与一个memcached容器一并运行,这样看起来更加平衡一些。这样,不管任何时候我向上伸缩更多的uwsgi的实例的时候,我同时也启动了更多的memcache实例。尽管目前缓存的性能很可能不会成为一个很大的问题,在以后的日子可能会是,而且往前计划一点也没有什么害处。如果遇到了内存的瓶颈,我可以将memcached分离出来,放到一个单独的pod中,然后对其单独进行伸缩。 Replication Controller 能保证不管在什么时候一个给定的pod都有一定数量的副本运行着。也可以让pod没有任何副本运行,但是如果当它意外的终止的时候可能无法自动重启。
apiVersion: v1
kind: ReplicationController
metadata:
name: steel-v7
spec:
replicas: 2
template:
metadata:
labels:
app: steel
version: v7
spec:
containers:
- name: memcache128
image: "memcached:latest"
command: ["/usr/local/bin/memcached", "-m", "128", "-v"]
ports:
- containerPort: 11211
- name: uwsgi
image: "gcr.io/projectname/steel:v7"
ports:
- containerPort: 8080
为了让uwsgi容器能连接到memcache容器,我需要把memcache定义成一个 service 。 service 会将端口映射到pod中,并且在每一个容器中设置好环境变量来告诉你的应用该如何连接每一个 service 。另外,Kubernetes的一个好处是,Container Engine会自动的启动,SkyDNS,会自动的创建DNS记录以让服务发现更加方便。在我的应用中,我可以通过调用 memcache.Client(['memcache:11211'])
然后客户端就会连接到一个半随机的memcache实例。尽管这不如将所有的memcache分片以一个地址列表传进去方便,在这种情况下可以运行的足够好。
apiVersion: v1
kind: Service
metadata:
name: memcache
labels:
app: steel
spec:
type: NodePort
selector:
app: steel
ports:
- port: 11211
protocol: TCP
name: memcache
/---
apiVersion: v1
kind: Service
metadata:
name: http
labels:
app: steel
spec:
type: LoadBalancer
selector:
app: steel
ports:
- port: 80
targetPort: 8080
protocol: TCP
name: http
memcache这个service是通过NodePort类型来定义的,这意味着集群内的所有容器都能访问到这个端口,但这个端口不会暴露在集群之外的网络上。http service使用的是LoadBalance类型,在一个最简单的Kubernetes集群中会挑选一个可用的公共接口然后为你均衡负载。当运行在Container Engine中的时候,Kubernetes会创建一个Google Load Banlancer实例然后配置它用来在pod间做负载均衡。这些是简单的四层负载均衡器,因此不要期待有多么的复杂功能,特别是在客户端在将连接进行流水线控制的时候。
更新的Kubernetes的版本有Ingress resouce(入口资源),其位于Service resource之前,提供了一些七层的负载均衡的功能。我还没有尝试过Ingress resource,并且我的应用也不需要这种功能,但是一旦Ingress支持SSL终止和IPv6,对我的吸引力还是很大的。
启动应用现在十分简单,只需要将这两个文件传给kubectl create命令,过一到两分钟,所有的容器layer都已下载好并且在容器的节点中启动,并且一个有一个外部IP地址的负载均衡器会创建好。你可以使用 kubectl get events -w
和 kubectl describe servcie http
来进行追踪。
关于selector说两句:selector是service知道该把流量发往何处的途径。在我这个场景中,每一个pod都有一个app=steel的选择器都同时运行memcache和uwsgi,所以这是我唯一需要的选择器。如果我讲memcacahe拆分到了一个单独的pod中间,我把它的选择器改成app=memcache然后相应的更新service selector。
到现在为止,仅仅在kubectl上已经花了不少的时间。还有很多有趣的事情你可以尝试,如滚动更新来将用一个容器的新版本的来替换容器的老版本,通过监控pod的CPU使用情况来自动伸缩或者在需要的情况启动的新的副本,并且添加或者移除集群的节点来看看集群是如何响应下面的实例消失的情况的。仔细观察你会发现这些很有趣!
那么,现在我的应用运行在了Container Engine之上了,我的应用的功能与在App Engine之上的功能一样了。性能的差别可以忽略,大概比在App Engine上提高了2ms,在之前我可以在App Engine的免费范围内使用,但是现在我要花费大约$15一个月来运行一个g1大小的Kubernetes节点。从某一方面来说这实际上不算更好了,除了我不再受限于App Engine的API,并且我可以很快的向上伸缩。正如我在开头说的,这主要是一个学习的经历,但是整体上这种运行app的方式更加的灵活。我可以轻松的预见将我的其他几个项目移植到Kubernetes并且把他们运行在同一个集群上,或者引入一些其他Kubernetes已经开发的service,例如Vites来提供额外的service。在某一个时候,我计划将相同的app部署到AWS上的Kubernetes来看看整个过程会有什么不同。对于认证服务的问题,我已经发了一个反馈报告,希望它能很快修复。如果不行的话,我我需要把我的服务账号的密码移到一个Secret resource中,这是Kubernetes提供的一个很好的抽象来将密码到挂载到每一个容器命名空间中的内存磁盘中。
( 原文链接: Migrating from App Engine to Kubernetes )