本篇文章是使用 Mastodon 搭建个人信息平台的第二篇内容,我将聊聊在容器环境中搭建 Mastodon 后的一些应用调整和问题修复。

这篇文章或许同样是你能够找到的为数不多的关于如何在容器环境中搭建和优化 Mastodon 服务的内容。

写在前面

本篇内容需要有上一篇内容的基础,所以如果你还未阅读上一篇内容,可以考虑移步过去,阅读了解 《使用 Mastodon 搭建个人信息平台:前篇》

在上篇文章结束后,我们已经可以通过手机应用进行登录和发帖记录信息了,但是在 Web 端使用的话,还是会遇到一些影响体验的小问题,同时,应用运行时使用的资源也会相对浪费,所以本篇内容就来解决这些问题。

为了照顾新人,解决问题的顺序按照从简到难,先从基础的服务配置开始吧。

如何启用 ES 全文搜索

在登录账号之后,在侧边栏选择“首选项”,打开应用后台页面。在后台页面的侧边栏中选择“管理”,就可以看到展示应用当前运行状况的信息面板啦。

Mastodon 默认运行状况

在图片中我们可以看到“服务器配置”中的“全文搜索”目前是关闭着的。

为了让服务正常使用,我们需要在前文中提到的配置文件 .env.production 中添加一些内容:

ES_ENABLED=true
ES_HOST=es
ES_PORT=9200

接着使用 docker-compose down && docker-compose up -d 重启服务,稍等服务运行就绪之后,我们就能够看到“全文搜索”已经启用啦。

Mastodon 开启 ES 全文搜索

加载字体资源报错的问题

在应用控制台中,我们会看到一条刺眼的报错。

Refused to load the font 'data:application/font-woff2;base64,...' because it violates the following Content Security Policy directive: "font-src 'self' https://hub-assets.lab.com".

这是由于 config/initializers/content_security_policy.rb 中的设置比较严格导致:

Rails.application.config.content_security_policy do |p|
  p.base_uri        :none
  p.default_src     :none
  p.frame_ancestors :none
  p.font_src        :self, assets_host
  p.img_src         :self, :https, :data, :blob, assets_host
  p.style_src       :self, assets_host
  p.media_src       :self, :https, :data, assets_host
  p.frame_src       :self, :https
  p.manifest_src    :self, assets_host

  if Rails.env.development?
...
  else
    p.connect_src :self, :data, :blob, assets_host, media_host, Rails.configuration.x.streaming_api_base_url
    p.script_src  :self, assets_host
    p.child_src   :self, :blob, assets_host
    p.worker_src  :self, :blob, assets_host
  end
end

解决这个问题很简单,只需要在 font 资源的安全策略中允许 data: 类型的资源即可:

...
  p.font_src        :self, :data, assets_host
...

因为我们使用的是容器中的 Mastodon,为了保证“打补丁”的程序和运行中的一致,可以从运行容器中将所需要的文件复制到本地。

docker cp app-web-1:/opt/mastodon/config/initializers/content_security_policy.rb .

在调整之后,可以使用文件挂载的方式将文件映射回容器。

web:
  image: tootsuite/mastodon:v3.4.4
  restart: always
  env_file: .env.production
  environment:
    - "RAILS_ENV=production"
  command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
  networks:
    - mastodon_networks
  healthcheck:
    test: ["CMD-SHELL", "wget -q --spider --proxy=off localhost:3000/health || exit 1"]
    interval: 15s
    retries: 12
  volumes:
    - /etc/localtime:/etc/localtime:ro
    - /etc/timezone:/etc/timezone:ro
    - ./content_security_policy.rb:/opt/mastodon/config/initializers/content_security_policy.rb:ro

接着使用 docker-compose down && docker-compose up -d 重启服务,稍等服务运行就绪之后,我们就能够看到这条错误已经消失啦。

解决页面中图片不展示的问题

虽然我们在上篇文章中,将 Mastodon 使用的资源文件都使用 MinIO 进行了存储,在上传过程中也能够正确的进行文件上传和存储。

但是在使用的过程中,不出意外,会遇到下面两个警告和错误提示,而导致页面无法显示图片。

浏览器中的 FloC 状态

Mixed Content: The page at 'https://hub.lab.com/web/timelines/home' was loaded over HTTPS, but requested an insecure element 'http://hub-res.lab.com/accounts/avatars/107/676/580/770/436/146/original/c80b9f6855890db4.png'. This request was automatically upgraded to HTTPS, For more information see https://blog.chromium.org/2019/10/no-more-mixed-messages-about-https.html

Refused to load the image 'http://hub-res.lab.com/media_attachments/files/107/677/586/354/795/556/original/08697ec4a7c4a362.jpeg' because it violates the following Content Security Policy directive: "img-src 'self' https: data: blob: https://hub-assets.lab.com".

其中一个问题的解决方案和上文中解决“加载字体资源报错”相同,需要调整 content_security_policy.rb 中的规则来解决问题。

p.img_src         :self, :https, :data, :blob, assets_host

我们在上面的规则中添加静态资源服务器地址即可:

p.img_src         :self, :https, :data, :blob, assets_host, 'http://hub-res.lab.com', 'https://hub-res.lab.com'

在和上文使用相同操作,将修改后的文件映射回容器后,重启应用,就可以看到浏览器拒绝加载资源的错误已经消失了。但是混合资源加载的警告则升级成为了错误。

所以接下来我们要解决一个新的问题:加载资源并未使用 HTTPS。

解决S3资源未使用 HTTPS的问题

页面资源之所以会使用 HTTP 方式加载,主要的原因是 Mastodon 使用的一个依赖库 https://github.com/thoughtbot/paperclip,在处理资源上传和资源展示的逻辑上处理的比较死板。如果你在上传资源的时候使用的是 HTTP 协议,那么在请求资源的时候,也会默认使用相同的协议。

而在上一篇文章里,我们有提到在同机部署的状况下,在相同容器网络中,可以直接使用 HTTP 进行服务间调用(省略掉为容器和系统安装自签名证书的麻烦)。

一般情况下,我会考虑直接对这类直接产生作用的库进行调整,然而这个存在了十年之久的库,已经被作者宣告废弃:“Paperclip is deprecated”。并且推荐我们进行工具迁移,或许在接下来的版本中,Mastodon 或许会因此进行部分功能的调整或者重构。所以在解决这个问题的时候,我们有两个选择,一个是将补丁打在应用本身,另外一个则是把补丁打在 PaperClip 上。

将补丁打在依赖库上

先来聊聊副作用最小的方式,将补丁打在依赖库上,仅在输出 S3 资源的时候调整资源使用的协议。

经过简单的调用追踪,可以看到负责输出 S3 静态资源的逻辑在 https://github.com/thoughtbot/paperclip/blob/main/lib/paperclip/storage/s3.rb 这个文件中。

Paperclip.interpolates(:s3_alias_url) do |attachment, style|
  protocol = attachment.s3_protocol(style, true)
  host = attachment.s3_host_alias
  path = attachment.path(style).
    split("/")[attachment.s3_prefixes_in_alias..-1].
    join("/").
    sub(%r{\A/}, "".freeze)
  "#{protocol}//#{host}/#{path}"
end unless Paperclip::Interpolations.respond_to? :s3_alias_url

解决的方式很简单,只需将 protocol 调整为我们所需要的值即可(使用 ENV、参数传递、或者 HardCode 都可以),比如:

protocol = "https:"

和上文一样,使用命令将文件拷贝出来:

docker cp app-web-1:/opt/mastodon/vendor/bundle/ruby/2.7.0/gems/paperclip-6.0.0/lib/paperclip/storage/s3.rb .

在修改完毕之后,将文件挂载回容器,再重启容器,你会发现问题就解决啦。

将补丁打在应用程序上

我们也可以将补丁打在应用本身,一劳永逸的解决问题,不过相比较前者,在性能上会有一丢丢的损失。

经过简单的调用追踪,我们可以看到在页面中输出媒体资源的逻辑在 app/serializers/rest/media_attachment_serializer.rb 这个文件中:

def url
  if object.not_processed?
    nil
  elsif object.needs_redownload?
    media_proxy_url(object.id, :original)
  else
    full_asset_url(object.file.url(:original))
  end
end

def remote_url
  object.remote_url.presence
end

def preview_url
  if object.needs_redownload?
    media_proxy_url(object.id, :small)
  elsif object.thumbnail.present?
    full_asset_url(object.thumbnail.url(:original))
  elsif object.file.styles.key?(:small)
    full_asset_url(object.file.url(:small))
  end
end

其中 full_asset_url 这个函数就是我们要尝试打补丁的“家伙”。在 https://github.com/mastodon/mastodon/blob/main/app/helpers/routing_helper.rb 可以找到这个函数的真身:

def full_asset_url(source, **options)
  source = ActionController::Base.helpers.asset_url(source, **options) unless use_storage?

  URI.join(root_url, source).to_s
end

...

private

def use_storage?
  Rails.configuration.x.use_s3 || Rails.configuration.x.use_swift
end

同样的,我们使用命令将容器中的程序文件拷贝到本地:

docker cp app-web-1:/opt/mastodon/app/helpers/routing_helper.rb .

因为我们只需要在处理 S3 相关资源的时候打补丁,所以可以使用下面的方式对程序进行调整:

def full_asset_url(source, **options)
  source = ActionController::Base.helpers.asset_url(source, **options).gsub("http://", "https://") unless use_storage?
  URI.join(root_url, source).to_s
end

在修改完毕文件之后,将文件挂载回容器中,接着重启容器,问题也就解决了。

解决前端资源使用错误协议

不论你使用上面哪一种方案,在问题解决后,你会发现哪怕页面 meta 信息、接口响应字段中都是 https 协议的主机地址,Mastodon Web 端在渲染界面中图片的时,始终会触发两次元素绘制,第一次明明还是正确的结果,到了第二次就变成了内容一样,但是资源地址以 http 的结果了…

坦白说 Mastodon 前端实现比较乱(主线版本和稳定版本目录结构差异也比较大),管理方式也比较奇怪(类似 Flarum,用主要技术栈来管理前端资源和构建),我就不做深入的动态调试了。

所以我选择直接在输出渲染的地方进行全局协议替换,毕竟我们的 Mastodon 是运行在 HTTPS 协议下,并开启了严格 CSP 规则。这样的场景下是不可能再引入 HTTP 的页面资源的。

简单定位,可以看到页面中输出资源的逻辑在 https://github.com/mastodon/mastodon/blob/main/app/javascript/mastodon/components/media_gallery.js 中进行处理:

const previewUrl   = attachment.get('preview_url');
const previewWidth = attachment.getIn(['meta', 'small', 'width']);

const originalUrl   = attachment.get('url');
const originalWidth = attachment.getIn(['meta', 'original', 'width']);

const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';

const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
const sizes  = hasSize && (displayWidth > 0) ? `${displayWidth * (width / 100)}px` : null;

const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
const x      = ((focusX /  2) + .5) * 100;
const y      = ((focusY / -2) + .5) * 100;

thumbnail = (
  <a
    className='media-gallery__item-thumbnail'
    href={attachment.get('remote_url') || originalUrl}
    onClick={this.handleClick}
    target='_blank'
    rel='noopener noreferrer'
  >

经过动态调试,可以看到页面的资源是什么,能否正确展示(有正确的协议头)其实多数场景下都是由 originalUrl 这个变量来决定的,所以我们针对它做一个字符串替换就行了。当然,为了保险,可以将另外一个有类似功能,但是经常数值为空的变量 previewUrl 也做相同处理:

const previewUrl   = attachment.get('preview_url').replace(/^http:/,'https:');
const previewWidth = attachment.getIn(['meta', 'small', 'width']);

const originalUrl   = attachment.get('url').replace(/^http:/,'https:');
const originalWidth = attachment.getIn(['meta', 'original', 'width']);

...

和上面不同的是,我们除了需要将源文件拷贝出来进行修改之外,还需要将代码进行重新构建,才能够使用。

docker cp app-web-1:/opt/mastodon/app/javascript/mastodon/components/media_gallery.js .

参考前文中剥离 Mastodon 静态资源和主应用的容器,将打补丁后的程序进行重新编译,然后更新资源镜像:

FROM tootsuite/mastodon:v3.4.4 AS Builder
ENV RAILS_ENV="production"
ENV NODE_ENV="production"
COPY media_gallery.js /opt/mastodon/app/javascript/mastodon/components/media_gallery.js
RUN cd ~ && \
	OTP_SECRET=precompile_placeholder SECRET_KEY_BASE=precompile_placeholder rails assets:precompile && \
	yarn cache clean

FROM nginx:1.21.4-alpine
COPY --from=Builder /opt/mastodon/public /usr/share/nginx/html

等待镜像构建完毕,重启服务,并彻底清除页面缓存(尤其是 Worker)后,再次尝试发一个带有图片的内容,你会发现一切都正常啦。

一切就绪的 Mastodon

去除 FloC 隐私沙盒警告

在应用的 Web 控制台中,我们能够看到一条有趣的错误提示。

Error with Permissions-Policy header: Unrecognized feature: 'interest-cohort'.

Mastodon 默认会在 config/environments/production.rb 文件中声明 Permissions-Policy 响应头的内容为 interest-cohort=(),来禁止浏览器对我们进行追踪和分析。

  config.action_dispatch.default_headers = {
    'Server'                 => 'Mastodon',
    'X-Frame-Options'        => 'DENY',
    'X-Content-Type-Options' => 'nosniff',
    'X-XSS-Protection'       => '0',
    'Permissions-Policy'     => 'interest-cohort=()',
  }

上面这条警告的来源是 FloC 功能尚未启用,浏览器无法根据服务端输出的响应头 Permissions-Policy 来执行对应的操作。

在 Chrome 浏览器中打开 chrome://settings/privacySandbox,可以看到当前用户是否打开或关闭了 FloC 功能。关于 FloC 的更多资料,可以从 https://web.dev/floc/ 了解。

浏览器中的 FloC 状态

如果想清除掉这条警告,只需要修改上面提到的文件,将该响应字段删除即可。

减少应用资源占用

因为我的目的是个人使用,所以我期望这套服务可以尽可能的“绿色环保”。尽量少使用一些资源,为其他应用留一些 Buffer。

减少 Streaming 服务资源使用量

影响 Streaming 服务的资源使用量主要因素有两个因素:是否开启了生产模式、是否限制了 Worker 的数量。前者不光是印象 Streaming 的行为,同时会影响它引入的各种外部框架和软件包的行为;后者则默认会根据你运行环境的 CPU 数量来做一个资源分配,对于个人用户而言,有一个 Worker 就足够了。

通过阅读代码,我们可以看到,控制这两个因素的变量和具体代码实现:

...
const env = process.env.NODE_ENV || 'development';

...
const numWorkers = +process.env.STREAMING_CLUSTER_NUM || (env === 'development' ? 1 : Math.max(os.cpus().length - 1, 1));

了解了是哪个变量控制服务,那么将变量配置到容器编排文件中即可:

  streaming:
    ...
    environment:
      - "NODE_ENV=production"
      - "STREAMING_CLUSTER_NUM=1"

重启应用,我们能够看到日志输出中就只会有一个 Worker 了。

WARN Starting streaming API server master with 1 workers 
WARN Starting worker 1 
WARN Worker 1 now listening on 0.0.0.0:4000 
verb Subscribe timeline:access_token:4 
verb 01214813-cadf-4e28-855a-c96ccca243c6 Starting stream from timeline:107676580770436146 for 107676580770436146
verb Subscribe timeline:107676580770436146 

减少 Web 应用资源占用

Mastodon 使用的 Web 服务是 Puma,默认启动后,查看日志我们可以看到进程的使用情况:

[9] Puma starting in cluster mode...
[9] * Puma version: 5.3.2 (ruby 2.7.2-p137) ("Sweetnighter")
[9] *  Min threads: 5
[9] *  Max threads: 5
[9] *  Environment: production
[9] *   Master PID: 9
[9] *      Workers: 2
[9] *     Restarts: (✔) hot (✖) phased
[9] * Preloading application
[9] * Listening on http://0.0.0.0:3000
[9] Use Ctrl-C to stop
[9] - Worker 0 (PID: 20) booted in 0.02s, phase: 0
[9] - Worker 1 (PID: 24) booted in 0.01s, phase: 0

这里对于个人使用而言,比较浪费,我们也做一个数值下调。并且因为我们已经用 Nginx 剥离了静态资源,所以还可以设置不使用 Puma 来提供静态资源服务。

  web:
...
    environment:
      - "RAILS_ENV=production"
      - "MAX_THREADS=2"
      - "WEB_CONCURRENCY=1"
      - "RAILS_SERVE_STATIC_FILES=false"
...

重启服务,可以看到配置已经生效了。

[9] Puma starting in cluster mode...
[9] * Puma version: 5.3.2 (ruby 2.7.2-p137) ("Sweetnighter")
[9] *  Min threads: 2
[9] *  Max threads: 2
[9] *  Environment: production
[9] *   Master PID: 9
[9] *      Workers: 1
[9] *     Restarts: (✔) hot (✖) phased
[9] * Preloading application
[9] * Listening on http://0.0.0.0:3000
[9] Use Ctrl-C to stop
[9] ! WARNING: Detected running cluster mode with 1 worker.
[9] ! Running Puma in cluster mode with a single worker is often a misconfiguration.
[9] ! Consider running Puma in single-mode (workers = 0) in order to reduce memory overhead.
[9] ! Set the `silence_single_worker_warning` option to silence this warning message.
[9] - Worker 0 (PID: 20) booted in 0.0s, phase: 0

让 Sidekiq 运行的更有安全感

Sidekiq 负责处理所有的异步任务和计划任务,对于这类组件,一般建议是在资源冗余的情况下,尽快的让任务计算完毕,避免堆积,最终造成服务雪崩。

所以并不建议对其进行设置,将任务并发处理量减少。如果你实在介意默认的并发数量,可以在 mastodon/config/sidekiq.yml 配置文件中调整数值到你期望的程度(默认资源占用其实也不高)。

不过 Mastodon 官方也好,社区也罢,并没有针对 Mastodon 做服务运行状况检查,所以这里我们针对 Sidekiq 做一个简单的健康检查,保障服务能够在极端情况下自动恢复即可。

  sidekiq:
...
    healthcheck:
      test: ["CMD-SHELL", "ps aux | grep '[s]idekiq\ 6' || false"]
      interval: 15s
      retries: 12

至于进程匹配命令为何会这样写呢,感兴趣的同学可以阅读 How does this [t]ricky bracket expression in grep work?,来了解一个冷知识。

应用资源使用概览

一通操作下来,在使用一阵 Mastodon 后,我们可以看到各个容器对资源的具体使用情况,除了两个 Ruby 大户比较吃资源外,可以看到其他的应用的内存消耗都在 100MB (多数远远低于这个数值),CPU 用量更是低到可以忽略不计,基本上到了可以接受的范围内。

NAME                                CPU %     MEM USAGE / LIMIT     MEM %
mastodon-api                        0.02%     308.8MiB / 7.369GiB   4.09%
nginx-mastodon                      0.00%     3.449MiB / 7.369GiB   0.05%
streaming                           0.01%     30.85MiB / 7.369GiB   0.41%
sidekiq                             0.43%     259.7MiB / 7.369GiB   3.44%
nginx-assets                        0.00%     3.898MiB / 7.369GiB   0.05%
redis                               0.47%     4.137MiB / 7.369GiB   0.05%
postgres                            0.00%     38.64MiB / 7.369GiB   0.51%
nginx-minio                         0.00%     7.289MiB / 7.369GiB   0.10%
minio                               0.00%     79.41MiB / 7.369GiB   1.05%

关于 ES 的选择和资源使用问题,在上篇文章中已经提到了,故不重复展开描述。

其他

如果你希望更深入的调整和优化这个 Ruby 项目,可以参考我之前的一篇文章进行操作:《Ruby 应用容器封装踩坑记录(Lobsters)》

最后

写到这里,本篇文章的目的就达到了。

本文中相关的代码,可以在 GitHub 上的开源仓库中找到,也欢迎提供更好的方案。

下一篇文章中,我将聊聊如何快速开发和集成机器人,让作为个人信息平台的 Mastodon 的信息流变的更有价值,交互方式更有趣。后续也将陆续整理和分享一些在知识管理、知识库建设过程中的小经验,希望能帮助到同样对这个领域感兴趣、充满好奇心的你。

–EOF