我将数据管道部署到AWS。然后所有"本地"的东西都崩溃了。

将Docker化管道从笔记本电脑迁移所学到的关于容器、网络和隐藏假设的经验。

来源:走向数据科学

在过去的几个月里,我一直在我的Windows机器上构建一个小型数据管道。RSS摄取在WSL2中运行,Postgres在Docker中运行,Kestra编排整个过程,dbt在数据之上进行转换。每个部分都能正常工作。我有了一个完整的工作谱系图、通过的测试,以及一个定时获取文章并保存到数据库的调度流程。

一切正常运转,因为所有东西都在同一个地方——我的笔记本电脑上。

本文讲述的是当我试图将其从笔记本电脑迁移到真实服务器上时发生的事情,全程仅使用命令行。我原以为困难会出在AWS本身:账户、实例、网络。但并非如此。困难的是那些我未曾察觉的、建立在“所有东西都在同一台机器上运行”这一假设之上的东西。这个假设所做的隐形工作比我意识到的要多,而一旦失去它,问题就逐个暴露出来,让我学到的东西比最初构建时还要多。

搭建服务器

老实说,让服务器运行起来是最简单的部分。

我创建了一个AWS账户,使用较新的$100额度免费套餐,设置了一个用于命令行操作的IAM用户而不是使用根账户(一个小好习惯,多次帮我避免麻烦),然后启动了一个运行Ubuntu 22.04的t3.small EC2实例。

过程中出现了一些小意外。都不算大,但足以提醒我,云服务器并不只是放在不同位置的我的电脑。

默认的8GB磁盘在我还没开始之前就太小了。调整大小意味着运行growpart和resize2fs,而我在网上找到的说明一直引用一个叫xvda的设备。我的实例没有这个。较新的实例类型使用Nitro系统,它将驱动器命名为像`nvme0n1`这样的名称。命名上的小差异,但正是这类事情让你开始怀疑你接下来要输入的一切,因为如果第一条命令都找不到目标,还有什么会悄无声息地不生效呢。

我还看到实例一度进入“受损”状态,当时Kestra正在首次拉取Docker镜像。我最好的猜测是内存耗尽了。我添加了一个1GB的交换文件作为一点额外的缓冲空间,之后再也没有发生过这种情况。基本上,它为系统提供了在内存满时将数据卸载到磁盘上的地方,使用磁盘空间作为较慢的备份。这是一个简单的修复,但它也是我第一次意识到租用的服务器没有我笔记本电脑上习惯的那种余量。

我附加了一个弹性IP,这样服务器的地址不会在我每次停止和启动时改变,并锁定了安全组,只允许我自己的IP访问22端口(SSH)、8080端口(Kestra的UI)和5432端口(Postgres)。最后一部分变成了一个次要的重复性任务:我家里的网络不给我固定IP地址,所以大多数会话都以我在加载任何东西之前重新授权当前地址开始。

这些都不难。只是关于这种特定计算机的大量具体细节,而我本地的设置从未强迫我去学习。

迁移管道

我安装了Docker,使用rsync而不是git将我的项目复制过来,因为我的仓库是公开的,而我的.env文件包含真实的密钥,然后运行了docker compose up -d。Postgres正常启动。Kestra也启动了。

这里有一件事让我措手不及:Kestra的流程并不存在于你复制项目时自动同步的文件中。它们存在于Kestra自己的内部数据库中。我的一个流程fetch_rss在服务器上根本不存在,直到我亲自将它的YAML粘贴到UI中。这是一件小事,但它是整个过程中反复出现的一个模式的最初迹象:那些感觉像是“我的项目”的东西,实际上被分割在两种不同的状态之间——我拥有并复制移动的文件,和只存在于运行系统内部的状态。

我点击了执行。立刻失败了。

缺失的工具箱

我的流程使用了一个Process任务运行器来执行实际工作。它会构建Python虚拟环境、安装依赖项并运行我的ETL脚本。这正是我在本地的设置方式,从未给我带来任何问题。

错误是关于缺失的软件包:python3.12-venv。深入调查后,我学到了一些我确实没有考虑过的东西。Process任务运行器并不是在EC2主机上运行的。它运行在Kestra自己的容器内部。Kestra的容器有Python,但没有构建虚拟环境所需的部分。

我通过在容器内部直接运行apt-get install -y python3.12-venv临时修复了它。它起作用了,但我知道它不会在重启后保留,因为更改不是镜像本身的一部分。我解决了眼前的问题,但没有解决底层设置。

这就是我不得不做出真正决定的地方,这也是整个项目中第一个真正有用的教训:管理者和工作者不应该是一回事。Kestra的工作是决定何时运行并跟踪它们。它不应该同时也是手动构建Python环境和安装软件包的东西。每次任务需要新东西时,我都得重建Kestra自己的镜像以适应它,这相当于给你的编排器分配了一个奇怪的责任范围。

与其继续修补Kestra本身,我将实际工作移出了它,并将任务切换为Kestra的Docker任务运行器。这个想法相当简单:Kestra会为Python作业专门启动一个独立的容器,在那里运行ETL脚本,并在作业完成时销毁该容器。这更接近我想要的设置方式,尽管我即将遇到更多问题。

给管理器一把它不知道需要的钥匙

为了让Kestra启动独立的worker容器,它需要能够与Docker本身对话,即管理主机上容器的实际守护进程。授予此权限的方式是将Docker套接字挂载到Kestra自己的容器中:

kestra:volumes:- /var/run/docker.sock:/var/run/docker.sock

这值得花点时间思考,因为它不是一个中性的决定。任何能访问该套接字的东西都可以有效地要求Docker在主机上做任何事情,包括启动具有远超应有权限的容器。在本地,在我的笔记本电脑上,这从未被视为真正的权衡。在暴露在开放互联网上的服务器上,这是一个真正的权衡。我决定,对于一个运行单一低风险流程的学习项目来说,这是可以接受的,但这正是那种需要有意为之的决定,而不是在调试过程中三步之后才偶然发现的。

挂载套接字后,我更新了流程以使用Docker任务运行器,并将其指向python:3.12-slim镜像。第一次真正测试:Permission denied。

事实证明,仅仅挂载套接字是不够的。Kestra在容器内的进程并非以root身份运行,而那扇门只为root打开。拥有钥匙和获准使用它不是一回事。

向Kestra的服务定义添加user: "0:0"修复了它,不过在此之前我经历了一次令人困惑的往返,发现Docker Compose并不会仅仅因为你修改了文件中的一行就总是重建容器。docker compose up -d --force-recreate kestra是实际使更改生效的命令。单独使用up -d会悄悄决定没有重要更改。

实际上不存在的卷

权限问题解决后,流程运行得更远了,但遇到了新的障碍:无法打开requirements文件:没有那个文件或目录。

我已经将我的项目文件夹挂载到worker容器中,以便它能查看脚本及其依赖项。我可以通过在Kestra之外手动运行完全相同的docker run命令来证明挂载是有效的,而且它完美运行。无论Kestra在做什么,都不是同一回事。

这最终成为整个项目中最长的弯路,老实说,回想起来可能也是让我最恼火的一个。最糟糕的是它如何悄无声息地失败。我首先尝试了一个叫volume-enabled的设置,但我把它放在了配置的错误位置。然后我再次尝试了正确的属性名volumeEnabled——没有破折号——并把它放在了正确的位置,放在plugins.configurations下,通过完整的类名定位Docker任务运行器插件。仍然没有任何效果。

没有错误。没有警告。什么都没有。它只是继续静默忽略该设置。

最终,我找到了真正的原因:Docker任务运行器的主机文件夹挂载似乎需要Kestra的企业版。我一直在追寻一个在我使用的免费版本上根本不起作用的设置。这不是拼写错误或配置错误。我基本上是在试图打开一扇我甚至不知道锁着的门。令人沮丧的是Kestra从未明确说明这一点——它只是静默忽略该设置,让我一直困惑自己到底哪里做错了。

我想特别指出这一点,供任何走类似路线的人参考:当一个设置无论你写得多正确似乎都不起作用时,停止假设你的语法有误。检查该功能在你实际运行的版本中是否存在。

实际应该工作的方式

解决办法不是强行让挂载工作。而是完全停止尝试挂载任何东西。

Kestra有一个名为命名空间文件的功能,本质上是一个位于Kestra内部的小型文件存储。你一次性将项目的文件上传到其中,Kestra会在运行时自动将它们交给worker容器,无需主机文件夹访问。这就是我试图用卷拼凑的方法的完全受支持版本。

从服务器上传它们变成了一连串小错误的连锁反应,在经历了一天的折腾后,这感觉几乎是预料之中的。我首先尝试了一个PUT请求;Kestra需要一个POST,并将文件包装为multipart form data。我弄对了,却只得到沉默,没有确认,没有错误,我一开始还以为成功了。其实并没有。

一个curl -i标志,实际向我显示响应头,揭示了真正的问题:401 Unauthorized。基本认证一直开着,我的浏览器只是从未告诉我,因为它已经登录了。curl没有那样的记忆。在每个请求中添加-u username:password修复了它。

一旦文件开始上传,我养成了立即检查每一个的习惯,因为有一次批量尝试不知怎么把错误的文件大小和错误的文件名配对了。更慢,但意味着每一个不匹配都在发生的那一刻就被发现,而不是三步之后以神秘脚本错误的形式浮出水面。

在确认所有六个文件都正确后,我重写了流程,使用`namespaceFiles: enabled: true`而不是卷挂载,并将文件路径从绝对路径(`/workspace/python/...`)切换为相对路径(`python/...`),因为命名空间文件直接落在容器的默认工作目录中,而不是我挂载的任何地方。

这一次,它确实运行了脚本。获取了25篇文章。解析了它们。然后:

psycopg.OperationalError:连接失败:连接到“127.0.0.1”,端口5432的服务器失败:连接被拒绝

最后一个假设:localhost不是一个地方

这是当天最小、也是最诚实的修复,从某种意义上说,它暴露了一个我从未有过理由质疑的假设。

我的数据库配置默认设置了DB_HOST=localhost。在本地,这完全没有问题。一切都在同一台机器上运行,所以“数据库就在这里”实际上是正确的。

但在AWS上,Kestra的worker在自己的独立容器中运行。所以当它尝试连接到localhost时,它基本上是在自己的小容器内部寻找,然后问“数据库在哪里?”Postgres位于另一个容器中,可以通过共享的Docker网络使用服务名postgres访问它。

我将实际的连接细节作为环境变量传递给worker容器,与我们之前设置的容器网络匹配,管道终于从头到尾运行了。获取文章,保存,完成。

紧接着我修复的最后一件事情是,同样的配置中以明文形式存在的密码。Kestra有一个专门为此设计的KV存储,一个可以存储一次值并从流程中引用它的地方({{ kv('DB_PASSWORD') }}),而不是在任何地方写出它。小事,但感觉是一个恰当的结尾:让东西运行起来,然后立即问自己,这个运行方式是否是我愿意让别人看到的方式。

真正出了什么问题,以及为什么它很重要

退一步看,这个过程中的每一次失败都可以追溯到两个原因之一:

在本地工作但在跨容器时失效的事情

“数据库在localhost上”,“我手动设置的Python环境还在这里。”在本地,在同一台机器上为你做了很多隐形工作。你不会认为那些是要求,因为你从未需要它们。它们就在那里,一切正常。一旦你转移到独立的容器中,这些假设就消失了,突然你意识到你有多依赖它们而从未察觉。

静默失败而不是大声报错的事情。

一个打错的配置属性。一个在你运行的版本中不存在的功能。一个返回空而不是错误信息的未认证请求。这些都不会崩溃任何东西。它们只是悄悄地少做了我要求的事情,这比实际崩溃更难调试,因为没有堆栈跟踪指向那个缺口。你只能注意到有些事情没有发生。

如果我要把这一切压缩成一条给第一次做这件事的人的建议:当你在本地构建的东西迁移到真正的服务器时,把每一个关于“东西在同一个地方”的假设都当作一个需要重新证明的主张,而不是一个你可以保留的事实。而且,当一个修复似乎完全不起作用时,无论你检查了多少遍语法,都要考虑到它可能真的什么都没做,因为这个功能一开始对你就是不可用的。

下一步是什么

管道本身已经完成、正在运行,并且实际上正在将真实数据保存到云数据库中。这是这篇文章的目标,在经历了那样的日子之后,能够打出这句话真的很令人满足。

我一直在反复思考接下来做什么。我有一部分想完成整个循环,将rss-transform——我的dbt项目——连接到这个新的云数据库,将RSS管道作为一个完整的端到端故事收尾,从原始信息源到干净、经过测试的数据。那种完成感有一种吸引力。

但老实说,这个项目已经教会了我它原本打算教会我的大部分东西。我从“Docker容器到底是什么”到在真实服务器上调试Docker套接字权限、静默配置失败和容器网络,而且当我弄错时会产生真实的后果。这正是当初选择这个项目的全部意义,而在某个点上,从同一个管道中挤出更多教训开始感觉像是一个已经真正成长之后还停留不走。

所以我是真的不确定,我认为这是一个诚实的结尾点。也许下一篇文章是dbt到云的连接,恰当地结束这个系列。也许完全是新的东西,一个新的项目,一套不同的技能,一个以新方式再次感觉像初学者的理由。我还不确定,我宁愿坦白地说出来,而不是编造一个我实际上并不致力于的整洁路线图。

无论如何,我都会以同样的方式记录它:诚实地,包括错误。

这是我正在进行的系列文章的一部分,记录我从系统分析师向数据工程师的转变。如果你一直在关注,谢谢你。

在LinkedIn、YouTube和Twitter上与我联系。