本文最初发表于作者个人网站,经原作者 Anthony N. Simon 授权,InfoQ 中文站翻译并分享。
虽然文章的标题听起来有些夸张,但我想澄清的是,我们正在讨论的是一家压力不大的个人公司,这家公司是我在德国经营的。自己花钱买的,我很喜欢慢慢来。如果我说“科技创业”,那大概和大多数人想象的不一样。
我不能在没有大量的开源软件和管理服务的情况下做到这一点。我觉得自己就是站在巨人的肩膀上,他们在我之前做过那么多艰苦的工作,我非常感谢他们。
从背景角度来说,我经营的是个人 SaaS,这是我发表的一篇关于我所使用的 技术栈 的文章的详细介绍。在听从我建议之前,先考虑一下你的情况。在技术选择上,你的背景很重要,不需要什么圣杯。
我在 AWS 上使用了 Kubernetes,但是不要陷入需要它的误区。经过一直非常耐心的团队的指导,我花了几年的时间学习了这些工具。因为这是我最擅长的,所以我的工作效率非常高,并且我能将集中精力在运输物品上。你们的目标可能不一样。
闲话少叙,言归正题。
基础设施可以同时处理多个项目,但是为了演示,我将使用 Panelbear,我最近的 SaaS,作为此类设置的一个实例。
Panelbear 中的浏览器计时图表,这是我将在本教程中使用的一个示例项目。
就技术而言,该 SaaS 每秒处理来自世界各地的大量请求,并以高效的格式存储数据,实现实时查询。
就业务而言,它仍处于起步阶段(我是半年前推出的),但它的发展比我预期的要快,特别是我最初为自己创建的 Django 应用,它是在一个小的虚拟专用服务器上使用 SQLite。对我当时的目标而言,这是非常有效的,而且我可能已经将这一模式推进了很远。
但是,我变得越来越沮丧,不得不重新使用许多我已经习惯了的工具:零停机部署、自动缩放、健康检查、自动 DNS / TLS / ingress 规则等等。Kubernetes 宠坏了我,让我习惯于在保持控制力和灵活性的情况下处理更高级抽象。
快进六个月,经历了几次迭代,虽然我目前的设置仍然是 Django 的单体版本,我现在将 Postgres 用作应用数据库,ClickHouse 用作分析数据,Redis 用作缓存。同时使用 Celery 来调度任务,使用自定义事件队列来缓冲写操作。这其中大部分都在托管的 Kubernetes 集群上运行。
架构概述
听起来似乎很复杂,但它实际上是老式的单体架构,运行在 Kubernetes 上。如果把 Django 换成 Rails 或 Laravel,你就知道我在说什么了。令人感兴趣的是,如何将所有的东西粘合在一起并自动执行:自动缩放、入口、TLS 证书、故障转移、日志、监控,等等。
值得一提的是,我在多个项目中都使用了这种设置,它帮助我降低了成本,而且很容易进行实验(编写 Dockerfile 和 git push)。因为经常有人问我这样一个问题:和你想的相反,我实际上花了很少的时间去管理基础设施,通常每个月要花大概 0~2 小时。大部分时间都用来开发功能、做客户支持,以及拓展业务。
话又说回来,这些工具我都用了好几年了,都很熟悉。虽然我的设置对它们的能力来说很简单,但我的日常工作却花了很多年才做到这一点。因此,我不会说这就是“阳光和玫瑰”。
不知道谁先说了这句话,但我这样对朋友们说:“Kubernetes 让简单的东西变得复杂,但也让复杂的东西变得简单”。
既然你已经了解了我在 AWS 上托管的 Kubernetes 集群,并且在其中运行了各种项目,那么让我们进入本文的第一站:如何将流量引入集群。
我的集群是在一个私有网络中,因此你无法从公共互联网中直接访问。有几个部分可以控制集群的访问和负载均衡流量。
基本上,我让 Cloudflare 将所有流量代理到 NLB(AWS L4 Network Load Balancer,网络负载均衡器)。该负载均衡器是公共互联网和我的私有网络之间的桥梁。当收到请求后,它将转发给其中一个 Kubernetes 集群节点。这些节点分布在 AWS 中多个可用性区域的私有子网中。所有这些都是顺便处理的,但以后还会有更多。
流量被缓存在边缘,或者转发到我运营的 AWS 区域中
ingress-nginx 就是这样做的:“Kubernetes 如何知道该将请求转发到哪个服务?”简单地说,它是一个 NGINX 集群,由 Kubernetes 管理,是集群内所有流量的入口。
在将请求发送到相应的应用程序容器之前,NIGIX 适用速度限制和其他流量形成规则。就 Panelbear 而言,应用容器是由 Uvicorn 服务的 Django。
这种方法与传统的 nginx/gunicorn/Django 的 VPS 方式没有什么不同,它带来了横向扩展和自动设置 CDN 的优点。它还可以“一次设置就忘记”,在 Terraform/Kubernetes 之间主要有一些文件,由所有已部署项目共享。
在部署新项目时,入口配置基本上只有 20 行代码,就像这样:
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
这些注释描述了我想要一个 DNS 记录,流量由 Cloudflare 代理,通过 Letsencrypt 获取 TLS 证书,并且应该根据 IP 限制每分钟的请求率,然后再将请求转发给我的应用。
Kubernetes 负责让 infra 中的这些改变反映出期望的状态。尽管有点儿啰嗦,但在实践中效果还不错。
推送新提交时发生的操作链
无论何时我想要掌握一个项目,它都会在 GitHub Actions 上启动一个 CI 管道。此管道运行一些代码库检查和端到端测试(使用 Docker compose 来设置整个环境),这些检查通过后,将创建一个新的 Docker 镜像,并将其推送到 ECR(AWS 中的 Docker 注册表)。
就应用仓库而言,新版本的应用已经过测试,可以作为 Docker 镜像部署。
panelbear/panelbear-webserver:6a54bb3
“那么接下来呢?Docker 有新的镜像,但是还没有部署?” Kubernetes 集群有一个叫做 Flux 的组件,可以自动保存集群中当前运行的内容以及我的应用最新的镜像同步。
在我的基础设施单体仓库中,Flux 自动跟踪新版本
Flux 会在 Docker 的新镜像可用时自动触发增量推出,并将这些操作记录到“基础设施单体仓库”中。
我希望有一个版本控制的基础设施,这样每当我在 Terraform 和 Kubernetes 之间的这个仓库中有新的提交时,它们就可以对 AWS、Cloudflare 和其他服务进行必要的修改,使我的仓库状态和部署的内容保持一致。
其所有版本都受版本控制,并且每次部署都有线性历史。这就是说,多年来,由于我没有在一些晦涩难懂的用户界面上通过点击配置神奇的设置,所以我需要记住的东西更少。
此单体仓库可作为可部署文档,稍后将详细讨论。
几年前,我用 Actor 并发模型在公司的多个项目中工作,并爱上了其生态系统中的许多想法。一发不可收拾,我很快开始阅读关于 Erlang 的书,以及它所阐述的让事情崩溃的哲学。
也许我已经把这个想法做了很多扩展,但是在 Kubernetes 里,我喜欢使用存活探针(liveliness probes)和自动重启来达到同样的效果。
摘自 Kubernetes 文档:“kubelet 使用存活探针来知道何时重启容器。例如,存活探针可以捕获到一个死锁,即应用程序正在运行,但无法取得进展。在这样的状态下重启容器有助于使应用更可用,尽管有 bug。”
在实践中,这对我来说很管用。容器和节点本来就是来来往往的,而 Kubernetes 则在“治疗”不健康的 pod(更像是杀掉)的同时,优雅地将流量转移到健康的 pod 上。残忍,但有效。
根据 CPU / 内存使用情况,我的应用容器会自动缩放。Kubernetes 将尽可能多的工作负载打包到每个节点上,以便最大限度地利用它。
如果集群中每个节点的 pod 过多,它将自动生成更多的服务器,以增加集群容量并减轻负载。类似地,当事情不多的时候,它就会缩减规模。
以下是 Horizontal Pod Autoscaler 的样子:
apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:
name: panelbear-api
namespace: panelbear
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: panelbear-api
minReplicas: 2
maxReplicas: 8
targetCPUUtilizationPercentage: 50
在本例中,它会根据 CPU 的使用情况自动调整panelbear-api pods
的数量,从 2 个副本开始,但上限为 8 个。
在为我的应用定义入口规则时,注释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 模型、限速计数器等)。
我利用了内存中的缓存文档置换机制 将频繁访问的对象保存在内存中,并且没有网络调用(纯 Python,不涉及 Redis),这对我有好处。
然而,大多数端点只是在集群中使用 Redis 来缓存。其速度仍然很快,并且缓存的数据可以被所有的 Django 实例共享,即使在重新部署之后,当内存中的缓存被删除时,这些数据可以可以被共享。
下面是一个实际例子:
我的定价计划是基于每月的事件分析。为实现这一目标,需要进行某种计量,以了解在当前的账单期间消耗了多少事件,并实施限制。但是,我不会在客户超过限额后立即中断服务。取而代之的是,将自动发送一封“耗尽容量”的电子邮件,并在 API 开始拒绝新数据之前为客户提供宽限期。
这样客户就有足够的时间在确保数据不丢失的情况下决定升级对他们是否有意义。举例来说,在流量高峰期,如果他们的内容被病毒式传播,或者他们只享受周末,而不去查阅邮件。若客户决定继续使用目前的计划而不进行升级,则不会有任何损失,且在使用量回到计划限制范围后,一切将恢复正常。
因此,为了实现这一功能,我使用了一个函数,应用了上面的规则,它需要多次调用数据库和 ClickHouse,但是会缓存 15 分钟,以避免在每次请求时重新计算它们。这样做很好,也很简单。值得注意的是:计划变更时缓存将失效,否则升级将在 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)。
例如:
class MySensitiveActionView(RatelimitMixin, LoginRequiredMixin):
ratelimit_key = "user_or_ip"
ratelimit_rate = "5/m"
ratelimit_method = "POST"
ratelimit_block = True
def get():
...
def post():
...
在上面的例子中,如果客户端每分钟试图向此特定端点发送 5 次以上 POST,则会以HTTP 429 Too Many Requests
状态码拒绝后续调用。
当速率受限时,会收到友好的错误消息
Django 免费为我所有的模型提供了一个管理面板。它是内置的,而且对于随时检查客户支持工作的数据非常方便。
Django 的内置管理面板对于随时提供客户支持非常有用
在用户界面上,我添加了动作来帮助我管理事情。例如,阻止对可疑账户的访问,发送公告邮件,以及请求批准完全删除账户(首先是软删除,72 小时后彻底销毁)。
安全性方面:只有员工用户能够访问面板(我),为提高安全性,我打算在所有账户上添加 2FA。
另外,每一次用户登录,我都会自动将包含新会话详情的安全邮件发送到该账户邮箱。我将在每次新登陆时发送,但将来我可能会更改此操作,以跳过已知设备。它并非很“MVP 的功能”,但是我关注安全性,并且添加它也不复杂。最起码如果有人登录了我的账户,我会收到警告。
当然,对应用的强化内容远不止这些,但这不在本文的讨论范畴。
登陆时可能收到的安全活动电子邮件示例
另外一个有趣的用例是,我在 SaaS 中运行了许多不同的计划工作。例如,为我的客户生成每日报告,每 15 分钟计算一次使用情况统计,给员工发邮件(我每天都会收到包含最重要指标的邮件)等等。
这个设置实际上很简单,我在集群中只运行了几个 Celery worker 和一个 Celery beat 调度器。它们被配置为将 Redis 用作任务队列。我花了一个下午的时间设置了一次,幸运的是,到目前为止,我还没有遇到任何问题。
当计划任务未按预期运行时,我希望通过 SMS/Slack/Email 获得通知。例如,当每周报告任务被卡住或明显延迟时。为此,我使用了 Healthchecks.io,但是你也可以看看 Cronitor 和 CronHub,因为我也听到了一些关于它们的好消息。
来自 Healthchecks.io 的 cron 作业监视指示板
我编写了一小段 Python 代码,对其 API 进行抽象,以自动创建监视程序和状态 ping:
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()
全部应用都是通过环境变量来配置的,虽然已经过时,但是具有良好的可移植性和支持性。举例来说,在 Djangosettings.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:
...
在 Kubernetesconfigmap
中,我可以覆盖以下环境变量:
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。这个组件使用非对称加密技术对我的秘密进行加密,并且只有获得授权可以访问解密密钥的集群才能对其进行解密。
举例来说,你可能会在我的基础设施仓库中发现以下内容:
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: panelbear-secrets
namespace: panelbear
spec:
encryptedData:
DATABASE_CONN_URL: AgBy3i4OJSWK+PiTySYZZA9rO43cGDEq...
SESSION_COOKIE_SECRET: oi7ySY1ZA9rO43cGDEq+ygByri4OJBlK...
集群将自动解密秘密,并将其作为环境变量传递给相应的容器:
DATABASE_CONN_URL='postgres://user:pass@my-rds-db:5432/db'
SESSION_COOKIE_SECRET='this-is-supposed-to-be-very-secret'
为了在集群中保护秘密,我使用 AWS 通过 KMS 管理的加密密钥定期轮换。当创建 Kubernetes 集群时,这是一个单独的设置,并且可以完全管理。
就操作而言,这意味着我将秘密作为环境变量写入 Kubernetes manifests,然后运行一个命令对 Kubernetes manifests 进行加密,然后在提交前推送更改。
只需几秒钟就可以部署秘密,在运行我的容器前,集群会负责自动解密。
为了进行实验,我在集群内运行一个普通的 Postgres 容器,以及一个每天备份到 S3 的 Kubernetes cronjob。这样可以帮助我节省开支,而且对于新手来说,也很简单。
不过,随着 Panelbear 等项目的发展,我会把数据库从集群中转移到 RDS,让 AWS 负责加密备份、安全更新以及所有其他无聊的事情。
为提高安全性,AWS 管理的数据库仍在私有网络中部署,因此不能通过公共互联网访问。
在 Panelbear 中,我依靠 ClickHouse 有效地存储分析数据和(软)实时查询。它是一种神奇的列式数据库,其速度惊人,并且当你的数据结构化得很好时,你可以获得高压缩比(更小的存储成本 = 更高的利润)。
当前,我在 Kubernetes 集群中自行托管一个 ClickHouse 实例。我用一个由 AWS 管理的带有加密卷密钥的 StatefulSet。我有一个 Kubernetes CronJob,它定期以高效列式格式将所有数据备份到 S3. 对于灾难恢复,我有几个脚本用于在 S3 中手动备份和恢复数据。
直到现在,ClickHouse 仍然坚不可摧,它是一款令人印象深刻的软件。当我开始使用 SaaS 时,这是我唯一不熟悉的工具,但是由于他们的文档,我能够很快上手运行。
如果想要挤出更多的性能(例如,优化字段类型以获得更好的压缩、预计算物化表以及优化实例类型), 我认为这是一个容易实现的方法,但是现在它已经足够好了。
除了 Django 之外,我还为 Redis、ClickHouse、NextJS 等运行容器。这些容器必须以某种方式相互通信,而这是通过 Kubernetes 中内置的服务发现实现的。
这很简单。我为容器定义了一个服务资源,Kubernetes 会自动管理集群内的 DNS 记录,并将流量路由到相应的服务。
举例来说,给定集群中公开的 Redis 服务:
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: weekend-project
labels:
app: redis
spec:
type: ClusterIP
ports:
- port: 6379
selector:
app: redis
通过以下 URL,我可以从集群的任何地方访问这个 Redis 实例:
redis://redis.weekend-project.svc.cluster:6379
注意,URL 包含服务名称和项目名称空间。这样,你的所有集群服务就可以轻松地彼此通信,无论它们运行在集群的哪个位置。
例如,下面是如何通过环境变量配置 Django 在集群内使用 Redis:
apiVersion: v1
kind: ConfigMap
metadata:
name: panelbear-config
namespace: panelbear
data:
CACHE_URL: "redis://redis.panelbear.svc.cluster:6379/0"
ENV: "production"
...
即使容器在自动缩放期间跨节点移动, Kubernetes 也会自动使 DNS 记录与正常 pod 保持同步。这个背后的工作方式很有趣,但是超出了本文的讨论范围。
我想要的是版本控制的、可复制的基础设施,可以使用一些简单的命令来创建和销毁它。
为了实现这一点,我在一个单体仓库中使用 Docker、Terraform 和 Kubernetes manifests,包含了所有的基础设施,甚至跨多个项目。我还将为每个应用 / 项目使用一个单独的 git repo,但是这段代码并不知道它将在什么环境中运行。
如果你熟悉 12-Factor,这种分离可能会让你想起一两件事情。从本质上讲,我的应用并不知道它将要运行的具体基础设施,而是通过环境变量来配置。
通过在 git repo 中描述我的基础设施,我就不需要在一些晦涩的用户界面中跟踪每一个小资源和配置设置。这样,当灾难恢复时,我可以使用一条命令来恢复我的整个栈。
下面是一个文件夹结构的例子,你可以在下文的单体仓库上找到:
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/
for disaster recovery, and CI Python scripts
tasks/
...
case of a fire, some help for future me In
README.md
DISASTER.md
TROUBLESHOOTING.md
这种设置的另一个好处是,所有的移动件都在同一位置描述。我可以配置和管理可重用的组件,比如集中式日志、应用监控和加密秘密等等。
我使用 Terraform 来管理大部分的基础云资源。它有助于我记录和跟踪构成基础设施的资源和配置。如果发生灾难恢复,我可以使用一个命令来启动和回滚资源。
举例来说,这里有一个 Terraform 文件,用于为 30 天后过期的加密备份创建私有 S3 存储桶:
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 manifests 也被描述在基础设施单体仓库的 YAML 文件中。我把它们分成了两个目录:cluster
和apps
。
我在cluster
群目录中描述了所有集群范围内的服务和配置,比如 nginx-ingress,加密秘密,prometheus scrapers 等等。基本上是可重用位。
而在apps
目录中,每个项目都包含一个命名空间,描述了部署它所需要的内容(入口规则、部署、秘密、卷等)。
Kubernetes 最酷的功能之一就是,你可以自定义栈中的任何东西。例如,如果我希望使用可调整大小的加密 SSD 卷,那么可以在集群中定义新的“StorageClass”。Kubernetes 以及在这种情况下,AWS 会协调和实现神奇的东西。例如:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: encrypted-ssd
provisioner: kubernetes.io/aws-ebs
parameters:
type: gp2
encrypted: "true"
reclaimPolicy: Retain
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
既然我可以继续将这种持久性存储附加到任何部署中,Kubernetes 就会为我管理请求的资源:
# Somewhere in the ClickHouse StatefulSet configuration
...
storageClassName: encrypted-ssd
resources:
requests:
storage: 250Gi
...
我用 Stripe Checkout 来保存所有工作,包括处理付款、创建结账屏幕、处理信用卡 3D 安全要求,甚至客户账单门户。
我无法接触到支付信息本身,这极大地减轻了我的负担,使我能够集中精力放在我的产品上,而非信用卡处理和反欺诈等高度敏感的问题上。
Panelbear 中的客户计费门户示例
我们只需要创建一个新的客户会话,然后将客户重定向到 Stripe 的托管页面。接着我监听 webhook,了解客户是否升级 / 降级 / 取消,并相应地更新数据库。
这其中肯定有一些重要的内容,比如确认 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}
之后,我可以将它用于任何 API 端点、cron 作业和管理任务,以确定那些限制 / 功能适合特定的客户。在BillingProfile
模型中,给定客户的当前计划是称为plan_code
的列。由于我计划在某些时候添加组织 / 团队,因此我将把BillingProfile
迁移到账户所有者 / 管理用户,所以我将用户和账单信息分离。
如果你在一家电商里提供成千上万的单品,那么这个模式是不能被扩展的,但是对于我来说,这个模式非常有效,因为 SaaS 通常只有几个计划。
无需使用任何日志代理或类似工具来检测代码。只要将日志记录到 stdout,Kubernetes 就会自动收集,然后对日志进行循环更新。还可以使用 FluentBit 自动地将这些日志发送到像 Elasticsearch/Kibana 这样的地方,但是为了简单起见,我还没有这样做。
我用 Stern 检查日志,这是用于 Kubernetes 的 一个小 CLI 工具,它可以非常轻松地跟踪多个 pod 应用日志。举例来说,stern-ningress-nginx
可以跟踪我的 nginxpods 访问日志,甚至在多个节点之间进行跟踪。
起初,我使用自托管的 Prometheus/Grafana 来自动监控集群和应用指标。但是,我觉得自托管我的监控栈并不舒服,因为如果集群中发生了什么问题,我的警报系统也会随之瘫痪(不太好)。
如果说有什么东西是绝对不能坏的,那就是你的监控系统,否则你基本上就是在没有仪器的情况下飞行。这就是为什么我把监控 / 警报系统改为托管服务(New Relic)。
所有我的服务都有一个 Prometheus 集成,能够自动记录和转发指标到兼容的后端,如 Datadog、New Relic、Grafana Cloud 或自托管的 Prometheus 实例(我曾经做过)。为了迁移到 New Relic,我需要做的就是使用他们的 Prometheus Docker 镜像,然后关闭自托管的监控栈。
New Relic 仪表盘示例,包含最重要的统计数据摘要
使用 New Relic 的探针监测世界各地的正常运行时间
从自托管的 Grafana/Loki/Prometheus 栈迁移到 New Relic,减少了我的操作面。更重要的是,即使我的 AWS 区域宕机了,我仍然会收到警报。
你也许想知道我是如何从 Django 应用中公开指标的。在我的应用中,我利用了优秀的 django-prometheus 库来简单地注册一个新的计数器 / 仪表。
from prometheus_client import Counter
EVENTS_WRITTEN = Counter(
"events_total",
"Total number of events written to the eventstore"
)
# We can increment the counter to record the number of events
# being written to the eventstore (ClickHouse)
EVENTS_WRITTEN.incr(count)
这会在服务器的/metrics
端点中公开该指标和其他指标(仅在集群内可访问)。Prometheus 每分钟都会自动抓取该端点,并将指标转发给 New Relic。
由于 Prometheus 的集成,该指标会自动显示在 New Relic 中
人人都认为在他们的应用中没有错误,除非开始错误跟踪。异常太容易在日志中丢失,或者更糟糕的是,你意识到了它,但由于缺乏上下文,无法重现问题。
用 Sentry 来汇总整个应用中的错误并通知我。检测 Django 应用非常简单:
SENTRY_DSN = env.str("SENTRY_DSN", default=None)
# Init Sentry if configured
if SENTRY_DSN:
sentry_sdk.init(
dsn=SENTRY_DSN,
integrations=[DjangoIntegration(), RedisIntegration(), CeleryIntegration()],
# Do not send user PII data to Sentry
# See also inbound rules for special patterns
send_default_pii=False,
# Only sample a small amount of performance traces
traces_sample_rate=env.float("SENTRY_TRACES_SAMPLE_RATE", default=0.008),
)
这很有用,因为它在异常发生时自动收集一堆上下文信息:
当发生异常情况时,Sentry 汇总并通知我
通过 Slack 的 #alerts 频道,我可以集中所有的警报:宕机时间、cron 作业失败、安全警报、性能下降、应用异常等等。这样做非常好,因为当多个服务在同一时间向我发出看似不相关的问题的警告时,我就能把问题关联起来。
Slack 警报示例,图为 CDN 端点在澳大利亚悉尼宕机
在需要深入研究时,我还将使用诸如 cProfile 和 snakeviz 这样的工具,以更好地了解有关应用性能的分配、调用次数和其他统计数据。这听起来很花哨,但是它们是非常容易使用的工具,而且在过去帮助我找出各种问题,这些问题使我的仪表盘因看似不相关的代码而变慢。
cProfile 和 snakeviz 是很好的工具,可以在本地对 Python 代码进行配置文件。
在本地机器上,我还使用 Django Debug Toolbar 轻松地检查视图触发的查询,在开发期间预览发送的电子邮件,以及其他一些好处。
Django 的 Debug 工具栏对于检查本地开发和预览事务性电子邮件非常有用
如果你看到这里,我希望你喜欢这篇文章。它最终比我最初计划的要长很多,因为有很多地方要涉及。
如果你还不熟悉这些工具,可以考虑先使用一个托管平台,比如 Render 或 DigitalOcean 的 App Platform(无利益相关,只是听说这两个平台很不错)。它们可以帮助你把精力集中在产品上,而且还能得到我在本文提到的好处。
“你是不是什么都用 Kubernetes?” 不是的,不同的项目有不同的需求。比如这个博客就是托管在 Vercel 上的。
有趣的是,我花在写这篇文章上的时间比实际设置我所描述的一切还要多。9000 多字,几周的工作断断续续,很显然,我是个写得慢的人。
话虽如此,我确实打算写更多的后续文章,介绍一些具体的技巧和诀窍,并分享更多一路走来的经验教训。特别是作为一名工程师,我有很多需要学习的地方。
作者介绍
Anthony N. Simon,Stylight 工程师,Panelbear 创始人。
原文链接
https://anthonynsimon.com/blog/one-man-saas-architecture/