Kubernetes 是一款开源软件,你可以利用它大规模地部署和管理容器化应用程序。Kubernetes 管理 Amazon EC2 计算实例集群,并在这些实例上运行容器以及执行部署、维护与扩展进程。借助 Kubernetes,你可以在本地和云上使用相同的工具集运行任何类型的容器化应用程序。AWS 利用可扩展并且高度可用的虚拟机基础设施、具有社区支持的服务集成,以及经过认证的 Kubernetes 托管服务 Amazon Elastic Kubernetes Service (EKS),大幅简化了在云上运行 Kubernetes 的过程。来自哥斯达黎加的软件工程师 Anthony Najjar Simon(下文中均以作者代替)向我们分享了一个人如何经营运作一家公司。该公司建立在作者在德国的公寓里,完全由自己出资。他主要介绍了在 AWS 上使用 Kubernetes,从负载平衡到 cron 作业监控,再到支付和订阅,实现了一人公司的顺利运行。基础设施可以同时处理多个项目,但为了说明问题,作者使用 SaaS Panelbear 作为这种设置的实际示例。作者表示,从技术角度来看,这种 SaaS 每秒需要处理来自世界各地的大量请求,以高效的格式存储数据,以便进行实时查询;从业务角度来看,它还处于初级阶段(六个月前才开始推进),但发展速度很快,在预期范围内。然而,令人沮丧的是,作者不得不重新实现以前非常熟悉的工具:零停机部署、弹性伸缩、安全检查、自动 DNS / TLS / ingress 规则等。作者以前使用 Kubernetes 来处理更高层级的抽象概念,同时进行监控和保持灵活性。六个月过去了,历经几次迭代,目前的设置仍然是 Django monolith。作者现在使用 Postgres 作为应用程序数据库,ClickHouse 用于分析数据,Redis 用于缓存。作者还将 Celery 用于预期任务,并使用自定义事件队列缓冲数据写入,并在一个托管 Kubernetes 集群(EKS)上运行这些东西。上述内容听起来可能很复杂,但实际上是一个在 Kubernetes 上运行的老式整体架构,并且用 Rails 或 Laravel 替换 Django。有趣的部分是如何将所有内容复合在一起并进行自动化,包括弹性伸缩、ingress、TLS 协议、失效转移、日志记录、监视等。值得注意的是,作者在多个项目中使用了这个设置,这有助于降低成本并非常轻松地启动实验(编写 Dockerfile 和 git push)。可能有人会问,这需要花费很多的时间,但实际上作者花很少的时间管理基础设施,通常每月只需花费 2 个小时以内的时间。其余大部分时间都花在开发特性、做客户支持和发展业务上。作者经常告诉朋友的一句话是:「Kubernetes 让简单的东西变得复杂,但它也让复杂的东西变得简单。」作者在 AWS 上有一个托管 Kubernetes 集群,并在其中运行了各种项目。接下来开始本教程的第一站:如何将流量引入集群。该集群在一个私有网络中,无法从公共互联网直接访问它。但是,Kubernetes 如何知道将请求转发到哪个服务呢?这就是 ingress-nginx 的作用所在。简而言之:它是一个由 Kubernetes 管理的 NGINX 集群,是集群内所有流量的入口点。NGINX 在将请求发送到相应的 app 容器之前,会应用速率限制和其他流量整形规则。在 Panelbear 的例子中,app 容器是由 Uvicorn 提供服务的 Django。它与 VPS 方法中的 nginx/gunicorn/Django 没有太大的不同,具有额外的横向缩放优势和自动 CDN 设置。大多数是 Terraform/Kubernetes 之间的一些文件,所有部署的项目都共享它。apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
namespace: example
name: example-api
annotations:
kubernetes.io/ingress.class: "nginx"
nginx.ingress.kubernetes.io/limit-rpm: "5000"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
external-dns.alpha.kubernetes.io/cloudflare-proxied: "true"
spec:
tls:
- hosts:
- api.example.com
secretName: example-api-tls
rules:
- host: api.example.com
http:
paths:
- path: /
backend:
serviceName: example-api
servicePort: http
就 application repo 而言,该 app 新版本已测试过,并准备作为 Docker 镜像部署:panelbear/panelbear-webserver:6a54bb3
接下来怎么做?有了新的 Docker 镜像,但没有部署?Kubernetes 集群有一个叫做 flux 的组件,它会自动同步集群中当前运行的内容和 app 的最新图像。Flux 自动跟踪基础架构 monorepo 中的新版本。
当有了新的 Docker 镜像可用时,Flux 会自动触发增量卷展栏(incremental rollout),并在 Infrastructure Monrepo 中记录这些操作。可以将此 monorepo 视为可部署的文档,但稍后将详细介绍。该 app 容器基于 CPU / 内存使用进行自动扩展。Kubernetes 尝试在每个节点上打包尽可能多的工作负载,以充分利用它。如果集群中每个节点有太多的 pod,则将自动生成更多的服务器以增加集群容量并减轻负载。类似地,当没有太多事情发生时,它也会缩小。在本例中,它将根据 CPU 使用情况自动调整 panelbear api pod 的数量,从 2 个副本开始,但上限为 8 个。在为 app 定义 ingress 规则时,标注「cloudflare-proxied: "true"」通知 Kubernetes 使用 cloudflare 进行 DNS,并通过 CDN 和 DDoS 保护代理所有请求。之后在使用中,只需在应用程序中设置标准的 HTTP 缓存头,以指定可以缓存哪些请求以及缓存多长时间。# Cache this response for 5 minutes
response["Cache-Control"] = "public, max-age=300"
Cloudflare 将使用这些响应头来控制边缘服务器上的缓存行为。对于这样一个简单的设置,它工作得非常好。作者使用 Whitenoise 直接从应用程序容器提供静态文件。这样就避免了每次部署都需要将静态文件上传到 Nginx/Cloudfront/S3。到目前为止,它工作得非常好,大多数请求在被填满时都会被 CDN 缓存。它的性能,并保持简单的事情。作者还将 NextJS 用于一些静态网站,例如 Panelbear 的登录页。可以通过 Cloudfront/S3 甚至 Netlify 或 Vercel 提供服务,但是在集群中作为一个容器运行它并让 Cloudflare 缓存请求的静态资产是很容易的。这样做没有额外的成本,而且可以重用所有工具进行部署、日志记录和监视。除静态文件缓存之外,作者还需要应用程序数据缓存(如繁重计算的结果、Django 模型、速率限制计数器等)。作者的定价计划基于每月的分析事件。为此,有必要进行某种计量,以了解在当前计费周期内消耗了多少事件,并强制执行限制。不过,作者不会在顾客超限时立即中断服务。相反,系统会自动发送一封容量耗尽的电子邮件,并在 API 开始拒绝新数据之前给客户一个宽限期。因此,对于这个特性,有一个应用上述规则的函数,它需要对 DB 和 ClickHouse 进行多次调用,但需要缓存 15 分钟,以避免每次请求都重新计算。优点是足够好和简单。值得注意的是:计划更改时缓存会失效,升级也可能需要 15 分钟才能生效。@cache(ttl=60 * 15)
def has_enough_capacity(site: Site) -> bool:
"""
Returns True if a Site has enough capacity to accept incoming events,
or False if it already went over the plan limits, and the grace period is over.
"""
虽然作者在 Kubernetes 上的 nginx-ingress 强制执行全局速率限制,但同时希望在每个端点 / 方法的基础上实施更具体的限制。为此,作者使用 Django Ratelimit 库来轻松地声明每个 Django 视图的限制,使用 Redis 作为后端来跟踪向每个端点发出请求的客户端(其存储基于客户端密钥的哈希,而不是基于 IP)。例如:在上面的示例中,如果客户端试图每分钟向这个特定的端点 POST 超过 5 次,那么后续的调用将使用 HTTP 429 Too Many Requests 状态码拒绝。Django 可以为所有模型免费提供了一个管理面板。它是内置的,非常方便用于检查数据以进行客户支持工作。作者添加了一些操作来管理来自 UI 的东西,比如阻止访问可疑账户、发送公告邮件等。安全方面:只有员工用户可以访问面板,并为所有账户计划添加 2FA 作为额外安全保障。此外,每次用户登录时,作者都会自动向帐户的电子邮件发送一封安全电子邮件,其中包含新会话的详细信息。现在作者在每次新登录时都会发送它,但将来可能会更改它以跳过已知设备。另一个有趣的用例是,作为 SaaS 的一部分,作者运行了许多不同的调度作业。这些工作包括为客户生成每日报告、每 15 分钟计算一次使用情况、发送员工电子邮件等。这个设置实际上很简单,只需要几个 Celery workers 和一个 Celery beat scheduler 在集群中运行。它们被配置为使用 Redis 作为任务队列。当计划任务未按预期运行时,作者希望通过 SMS/Slack/Email 获得通知。例如,当每周报告任务被卡住或严重延迟时,可以使用 Healthchecks.io,但同时也检查 Cronitor 和 CronHub。来自 Healthchecks.io 的 cron 作业监控仪表板。
为了抽象 API,作者写了一个 Python 代码片段来自动创建监控器和状态提示:def some_hourly_job():
# Task logic
...
# Ping monitoring service once task completes
TaskMonitor(
name="send_quota_depleted_email",
expected_schedule=timedelta(hours=1),
grace_period=timedelta(hours=2),
).ping()
所有应用程序都是通过环境变量配置的,虽然老式但很便携,而且具有良好支持。例如,在 Django settings.py 中,作者会用一个默认值设置一个变量:INVITE_ONLY = env.str("INVITE_ONLY", default=False)
from django.conf import settings
# If invite-only, then disable account creation endpoints
if settings.INVITE_ONLY:
...
可以重写 Kubernetes configmap 中的环境变量:apiVersion: v1
kind: ConfigMap
metadata:
namespace: panelbear
name: panelbear-webserver-config
data:
INVITE_ONLY: "True"
DEFAULT_FROM_EMAIL: "The Panelbear Team <support@panelbear.com>"
SESSION_COOKIE_SECURE: "True"
SECURE_HSTS_PRELOAD: "True"
SECURE_SSL_REDIRECT: "True"
作者使用 Kubernetes 中的 kubeseal 组件,它使用非对称加密来加密,只有授权访问解密密钥的集群才能解密。如下代码所示:集群将自动解密,并将其作为环境变量传递给相应的容器:DATABASE_CONN_URL='postgres://user:pass@my-rds-db:5432/db'
SESSION_COOKIE_SECRET='this-is-supposed-to-be-very-secret'
为了保护集群中的隐私,作者通过 KMS 使用 AWS 管理的加密密钥。在创建 Kubernetes 集群时,这是一个单独的设置,并且它是完全受管理的。对于实验,作者在集群中运行原版 Postgres 容器,并运行每日备份到 S3 的 Kubernetes cronjob。随着项目进展,对于 Panelbear 等,作者将数据库从集群转移到 RDS 中,让 AWS 负责加密备份、安全更新等操作。为了增加安全性,AWS 管理的数据库仍然部署在作者的专用网络中,因此它们无法通过公共互联网访问。作者依靠 ClickHouse 对 Panelbear 中的分析数据进行高效存储和实时查询。这是一个非常棒的列式数据库,速度非常快,当将数据组织得很好时,你可以获得高压缩比(存储成本越低 = 利润率越高)。目前,作者在 Kubernetes 集群中自托管了一个 ClickHouse 实例。作者有一个 Kubernetes CronJob,它定期地将所有数据以高效的列格式备份到 S3。在灾难恢复(disaster recovery)的情况下,作者使用几个脚本来手动备份和恢复 S3 中的数据。除了 Django,作者还运行 Redis、ClickHouse、NextJS 等容器。这些容器必须以某种方式相互通信,并通过 Kubernetes 中的内置服务发现(service discovery)来实现。很简单:作者为容器定义了一个服务资源,Kubernetes 自动管理集群中的 DNS 记录,将流量路由到相应的服务。例如,给定集群中公开的 Redis 服务:可以通过以下 URL 从集群的任何位置访问此 Redis 实例:redis://redis.weekend-project.svc.cluster:6379
注意:服务名称和项目命名空间是 URL 的一部分。这使得所有集群服务都可以很容易地实现互通信。下图展示了作者如何通过环境变量配置 Django,用来使用集群中的 Redis:Kubernetes 将自动保持 DNS 记录与 pod 同步,即使容器在自动伸缩期间跨节点移动。作者希望通过一些简单的命令来创建和销毁版本控制、可复制的基础架构。为了实现这一点,作者在 monorepo(包含 all-things 架构) 中使用 Docker、Terraform 和 Kubernetes manifests,甚至在跨项目中也如此。对于每个应用程序 / 项目,作者都使用一个单独的 git repo。作者通过在 git repo 中描述基础架构,不需要跟踪某些 obscure UI 中的每个小资源和配置设置。这样能够在灾难恢复时使用一个命令还原整个堆栈。下面是一个示例文件夹结构,在 infra monorepo 上可能找到的内容:# Cloud resources
terraform/
aws/
rds.tf
ecr.tf
eks.tf
lambda.tf
s3.tf
roles.tf
vpc.tf
cloudflare/
projects.tf
# Kubernetes manifests
manifests/
cluster/
ingress-nginx/
external-dns/
certmanager/
monitoring/
apps/
panelbear/
webserver.yaml
celery-scheduler.yaml
celery-workers.yaml
secrets.encrypted.yaml
ingress.yaml
redis.yaml
clickhouse.yaml
another-saas/
my-weekend-project/
some-ghost-blog/
# Python scripts for disaster recovery, and CI
tasks/
...
# In case of a fire, some help for future me
README.md
DISASTER.md
TROUBLESHOOTING.md
这种设置的另一种优势是,所有的移动部件都在同一个地方描述。作者可以配置和管理可重用的组件,如集中式日志记录、应用程序监控和加密机密等。作者采用 Terraform 来管理大多数底层云资源,这可以帮助记录和跟踪组成基础设施的资源和配置。在错误恢复时,作者可以使用单个命令启动和回滚资源。例如,如下是作者的 Terraform 文件之一,用于为加密备份创建一个私有 S3 bucket,该 bucket 在 30 天后过期:resource "aws_s3_bucket" "panelbear_app" {
bucket = "panelbear-app"
acl = "private"
tags = {
Name = "panelbear-app"
Environment = "production"
}
lifecycle_rule {
id = "backups"
enabled = true
prefix = "backups/"
expiration {
days = 30
}
}
server_side_encryption_configuration {
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
}
类似地,作者所有的 Kubernetes 清单都在基础设施 monorepo 中的 YAML 文件中描述,并将它们分为两个目录 cluster 和 apps。在 cluster 目录中,作者描述了所有集群范围的服务和配置,如 nginx-ingress、encrypted secrets、prometheus scrapers 等。这些基本上是可重用的比特。apps 目录在每个项目中包含一个命名空间,描述部署所需的内容,如 ingress rules、deployments、secrets、volumes 等。Kubernetes 的一个很酷的地方是:你可以定制几乎所有关于堆栈的东西。因此,如果你想使用可调整大小的加密 SSD volumes,则可以在集群中定义一个新的 StorageClass。Kubernetes 和 AWS 将协调产生作用,如下所示:现在,作者可以为任何部署附加这种类型的持久存储,Kubernetes 管理请求的资源:作者采用 Stripe Checkout 来保存付款、创建结账屏幕、处理信用卡 3D 安全要求、甚至客户账单门户的所有工作。这些工作没有访问支付信息本身,这是一个巨大的解脱,可以专注于产品,而不是高度敏感的话题,如信用卡处理和欺诈预防。现在需要做的就是创建一个新的客户会话,并将客户重定向到 Stripe 托管页面之一。然后,监听客户是否升级 / 降级 / 取消的网络钩子(webhook),并相应地更新数据库。当然,有一些重要的部分,比如验证网络钩子是否真的来自 Stripe。不过,Stripe 的文档很好地涵盖了所有要点。作者可以非常容易地在代码库中进行管理,如下所示:# Plan constants
FREE = Plan(
code='free',
display_name='Free Plan',
features={'abc', 'xyz'},
monthly_usage_limit=5e3,
max_alerts=1,
stripe_price_id='...',
)
BASIC = Plan(
code='basic',
display_name='Basic Plan',
features={'abc', 'xyz'},
monthly_usage_limit=50e3,
max_alerts=5,
stripe_price_id='...',
)
PREMIUM = Plan(
code='premium',
display_name='Premium Plan',
features={'abc', 'xyz', 'special-feature'},
monthly_usage_limit=250e3,
max_alerts=25,
stripe_price_id='...',
)
# Helpers for easy access
ALL_PLANS = [FREE, BASIC, PREMIUM]
PLANS_BY_CODE = {p.code: p for p in ALL_PLANS}
作者将 Stripe 应用在 API 端点、cron job 和管理任务中,以确定哪些限制 / 特性适用于特定的客户,当前计划用的是 BillingProfile 模型上的 plan_code。作者还将用户与帐单信息分开,因为计划在某个时间添加组织 / 团队,这样就可以轻松地将帐单配置文件迁移到帐户所有者 / 管理员用户。当然,如果你在电子商务商店中提供数千种单独的产品,这种模式是无法扩展的,但它对作者来说非常有效,因为 SaaS 通常只有几个计划。作者不需要 logging agen 之类的东西测试代码,只需登录 stdout、Kubernetes,即可自动收集 log。你也可以使用 FluentBit 自动将这些 log 发送到 Elasticsearch/Kibana 之类的应用上,但为了保持简单,作者还没有这么做。为了检查 log,作者使用了 stern,这是一个用于 Kubernetes 的小型 CLI 工具,可以非常容易地跨多个 pod 跟踪应用程序 log。例如,stern -n ingress-nginx 会跟踪 nginx pod 的访问 log,甚至跨越多个节点。最开始,作者采用一个自托管 Prometheus/Grafana 来自动监控集群和应用指标。然而,作者不喜欢自托管监控堆栈,因为在集群中一旦出现错误,那么告警系统也会随之崩溃。作者所有的服务都有 Prometheus 集成,该集成可自动记录指标并将指标转发到兼容的后端,例如 Datadog、New Relic、Grafana Cloud 或自托管的 Prometheus 实例。如果你想迁移到 New Relic,需要使用 Prometheus Docker 映像,并关闭自托管监控堆栈。New Relic 仪表盘示例汇总了最重要的统计数据。
使用 New Relic 探测器监测世界各地运行时间。
从自托管的 Grafana/Loki/Prometheus 堆栈迁移到 New Relic 简化了操作界面。更重要的是,即使 AWS 区域关闭,使用者仍然会收到警报。至于如何从 Django app 中公开指标,作者利用 django prometheus 库,只需在应用程序中注册一个新的计数器 / 仪表:这一指标和其他指标将在服务器的 / metrics 端点中公开。Prometheus 每分钟都会自动抓取这个端点,将指标发送至 New Relic。由于 Prometheus 整合,这个指标会自动出现在 New Relic 中。
每个人都认为自己的应用程序没有错误,直到进行错误跟踪时才发现错误。异常很容易在日志中丢失,更糟的是,你知道异常的存在,但由于缺少上下文而无法复现问题。作者采用 Sentry 来聚合应用程序中的错误。检测 Django app 非常简单,如下所示Sentry 非常有帮助,因为它自动收集了一堆关于异常发生时出现何种异常的上下文信息:异常发生时,Sentry 会聚集异常并通知使用者。
作者使用 Slack #alerts 通道来集中所有的警告,包括停机、cron job 失败、安全警告、性能退化、应用程序异常等。这样做的好处是当多个服务同时进行 ping 操作时,可以将问题关联起来,并处理看似不相关的问题。澳大利亚悉尼 CDN 端点下降导致的 Slack 警告。
在进行深入研究时,作者还使用 cProfile 和 snakeviz 之类的工具来更好地了解分配、调用次数以及有关 app 性能的其他统计信息。 cProfile 和 snakeviz 是可用于分析本地 Python 代码的工具。
作者还使用本地计算机上的 Django debug toolbar 来方便地检查视图触发的查询,预览开发期间发送的电子邮件。Django 的 Debug 工具栏非常适合在本地开发中检查内容、以及预览事务性邮件。
原文链接:https://anthonynsimon.com/blog/one-man-saas-architecture/