Ruby on Rails文件存储整合S3 API

存储对应用透明,存储升级扩容更灵活

Posted by Archfish on 2018-08-14

在项目初期我们将一台服务器插满硬盘并提供NFS服务挂载到各业务服务器上。业务产生的文件分为两类,一类是涉及隐私的敏感文件,另一类是可公开访问的普通文件。普通文件需要对接CDN以降低服务器带宽压力。敏感文件只能在相应业务使用到时才能访问,通常是先加载到内存然后再嵌入页面或Base64编码后通过JSON返回给前端。

在业务系统中使用CarrierWave作为文件上传管理组件,部分图片相关业务需要生成缩略图或保存多版本文件。

files

对外URL只能处理可公开访问的文件,对于隐私文件只能使用文件操作读取到内存。下面是uploader封装统一URL的例子,其他的uploader继承该class即可,使用时直接使用external_url

class BaseUploader < CarrierWave::Uploader::Base
  include CarrierWave::MiniMagick
  storage :file

  def external_url
    return if self.blank?

    return if private_file?

    # 使用最后更新时间作为CDN自动过期方案
    s = (self.model.try(:updated_at) || self.model.try(:created_at) || Time.current).tv_sec
    s = "?t=#{s}"

    wrap_cdn(url.sub(File.expand_path(Rails.root), '') + s)
  end

  def wrap_cdn(file_path)
    if fit_cdn_condition?
      File.join(cdn_domain, file_path)
    else
      File.join(backend_domain, file_path)
    end
  end
end

aws

我们是托管服务器,综合对比了几款S3兼容存储软件后决定选择Ceph作为存储服务,考虑到后期可能需要块存储Ceph是个不错的选择。CarrierWave配合carrierwave-aws可以很方便使用aws-sdk-ruby,假设RGW服务地址为http://ceph:10086则可以通过下面的方式配置s3

# config/initializers/carrierwave.rb

CarrierWave.configure do |config|
  config.storage    = :aws
  config.aws_bucket = :mybucket
  config.aws_acl    = 'public-read'

  # 该参数用于设置图片访问域名
  # config.asset_host = File.join(endpoint, bucket)

  # 隐私文件开放时间,过期则返回403
  config.aws_authenticated_url_expiration = 30.minute.to_i

  config.aws_attributes = {
    cache_control: 'max-age=604800'
  }

  config.aws_credentials = {
    access_key_id:     :access_key,
    secret_access_key: :secret_key,
    force_path_style:  true,
    endpoint:          'http://ceph:10086',
    region:            'us-east-1',
    stub_responses:    Rails.env.test? # Optional, avoid hitting S3 actual during tests
  }
end
# base_uploader.rb

class BaseUploader < CarrierWave::Uploader::Base
  include CarrierWave::MiniMagick
  storage :aws

  # 获取图片外部访问地址,有CDN可用时使用CDN
  def external_url
    return if self.blank?

    # 私有文件会带上一些鉴权参数,不能对其进行修改
    return url unless public_file?

    s = (self.model.try(:updated_at) || self.model.try(:created_at) || Time.current).tv_sec
    s = "?t=#{s}"

    url_ = self.url
    # 自定义 asset_host 时不做替换
    if self.asset_host.blank?
      url_ = url_.sub(backend_domain, cdn_domain)
    end
    url_ + s
  end

  # 根据文件类型设置文件的访问权限
  # CarrierWave::Uploader::Base::ACCEPTED_ACL
  def aws_acl
    public_file? ? 'public-read' : 'private'
  end

  # 用于处理不同时期不同存放路径问题,无此需求忽略这个方法
  def dir_exists?(dir)
    # 检查S3存储目录是否存在,其他存储可能不适用
    # NOTE S3存储中,如果目录没有文件则父目录也不存在
    if self._storage == CarrierWave::Storage::AWS
      bucket = Aws::S3::Bucket.new(self.aws_bucket, self.aws_credentials)
      bucket.objects(prefix: dir).limit(1).any?
    else
      Dir.exist?(dir)
    end
  end
end

经过上面的修改之后相同的接口就能直接适配S3存储,同时隐私文件夹通过设置合理的expires即可限制资源的访问。

生产环境

通过Nginx将内部服务暴露到公网中,这时你会遇到一个bug,还有这个bug。所以在业务需要处理1XX状态码时不宜使用nginx作为反向代理。

整个流程搞定以后我也进行了反思:

  • 对于文件上传,通过nginx会增加不必要的代理层;
  • 在这个需求中我只希望通过外网可以访问文件而不需要外网(通过Nginx代理)直接上传文件;
  • 文件上传使用内部网络(IP+端口方式)可以减少DNS环节,虽然可以设置内部DNS为内部地址,提高稳定性;

于是改变思路,默认endpoint还是内网的IP+PORT方式,在获取URL时,创建一个新的client用于对文件URL进行签名,最终的代码如下:

# config/initializers/carrierwave.rb

CarrierWave.configure do |config|

  ...

  # 设置CDN域名+bucket名
  config.asset_host = File.join(CDN_DOMAIN, bucket)

  ...
end
# base_uploader.rb

# 获取图片外部访问地址,有CDN可用时使用CDN
def external_url
  return if self.blank?

  # 私有文件会带上一些鉴权参数,不能对其进行修改
  return authenticated_url unless public_file?

  s = (self.model.try(:updated_at) || self.model.try(:created_at) || Time.current).tv_sec
  s = "?t=#{s}"

  url_ = self.url
  # 自定义 asset_host 时不做替换
  if self.asset_host.blank?
    url_ = url_.sub(backend_domain, cdn_domain)
  end
  url_ + s
end

# 用于修改aws endpoint到图片服务器
def authenticated_url
  # 不能修改 self.aws_credentials 的内容,否则会影响之后的文件上传操作
  options = self.aws_credentials.merge(endpoint: APP_CONFIG['ceph_image_gateway'])
  # 新建一个bucket客户端,用于访问对象
  bucket = Aws::S3::Bucket.new(self.aws_bucket, options)
  # 预签名URL,加上验签信息
  bucket.object(path).presigned_url(:get, self.file.aws_options.expiration_options)
end

CORS

为了正常嵌入其它域名的页面中,可能需要配置bucket允许跨域请求,这里提供一个模板,在bucket上配置以后就不需要在nginx上配置了,否则可能会出现错误。在给配置方案时,运维同学理解出现偏差,除了对S3 bucket进行操作,还同时配置了nginx,导致我花了一个小时去排查这个问题。

<!-- cors.xml -->
<CORSConfiguration>
 <CORSRule>
   <AllowedOrigin>*</AllowedOrigin>
   <AllowedMethod>GET</AllowedMethod>
 </CORSRule>
</CORSConfiguration>
s3cmd setcors cors.xml s3://reocar/

附录

附上我排查bug的流程:

  • 打开ceph rgw的debug 20模式;
  • 关闭osd日志(这里有很多集群消息);
  • 使用s3cmd工具上传文件并将日志拷贝出来(s3cmd不使用100状态码,所以经过nginx以后还是能正常上传文件);
  • 使用s3 ruby sdk上传文件,并将日志拷贝出来
  • 比对两次日志即可发现sdk里使用的是100状态实现首次请求不带任何body只验证权限和路径等信息,参考S3实现
# s3cmd

2018-08-20 11:43:05.426 7f789febc700 20 CONTENT_LENGTH=392424
2018-08-20 11:43:05.426 7f789febc700 20 CONTENT_TYPE=image/jpeg
2018-08-20 11:43:05.426 7f789febc700 20 HTTP_ACCEPT_ENCODING=identity
2018-08-20 11:43:05.426 7f789febc700 20 HTTP_AUTHORIZATION=AWS4-HMAC-SHA256 Credential=QZPWZQQ4PCUVXKZA1CK4/20180820/us-east-1/s3/aws4_request,SignedHeaders=content-length;content-type;host;x-amz-content-sha256;x-amz-date;x-amz-meta-s3cmd-attrs;x-amz-storage-class,Signature=7dad3193636d4d3c66b2a06877c135d50834fbb9f7a94c26c3c8a198cd81a14e
2018-08-20 11:43:05.426 7f789febc700 20 HTTP_HOST=my.local.lan
2018-08-20 11:43:05.426 7f789febc700 20 HTTP_VERSION=1.1
2018-08-20 11:43:05.426 7f789febc700 20 HTTP_X_AMZ_CONTENT_SHA256=867a5e890d8e8b156d16d33d8bbc135fd4d3b8f73844fb2d2e69df668fc4447c
2018-08-20 11:43:05.426 7f789febc700 20 HTTP_X_AMZ_DATE=20180820T034305Z
2018-08-20 11:43:05.426 7f789febc700 20 HTTP_X_AMZ_META_S3CMD_ATTRS=atime:1534736585/ctime:1532484245/gid:20/gname:staff/md5:cfef3b9dac46df5d717e32ca33d3e7da/mode:33188/mtime:1532412861/uid:501/uname:weihl
2018-08-20 11:43:05.426 7f789febc700 20 HTTP_X_AMZ_STORAGE_CLASS=STANDARD
2018-08-20 11:43:05.426 7f789febc700 20 HTTP_X_FORWARDED_BY=127.0.0.1:80
2018-08-20 11:43:05.426 7f789febc700 20 HTTP_X_FORWARDED_FOR=127.0.0.1
2018-08-20 11:43:05.426 7f789febc700 20 HTTP_X_FORWARDED_PROTO=http
2018-08-20 11:43:05.426 7f789febc700 20 HTTP_X_REAL_IP=127.0.0.1
2018-08-20 11:43:05.426 7f789febc700 20 REMOTE_ADDR=192.168.15.58
2018-08-20 11:43:05.426 7f789febc700 20 REQUEST_METHOD=PUT
2018-08-20 11:43:05.426 7f789febc700 20 REQUEST_URI=/reocar/reocar.jpg
2018-08-20 11:43:05.426 7f789febc700 20 SCRIPT_URI=/reocar/reocar.jpg
2018-08-20 11:43:05.426 7f789febc700 20 SERVER_PORT=7480
2018-08-20 11:43:05.426 7f789febc700  1 ====== starting new request req=0x7f789feb3830 =====
...
# ruby sdk

2018-08-20 11:45:14.781 7f789febc700 20 CONTENT_LENGTH=392424
2018-08-20 11:45:14.781 7f789febc700 20 CONTENT_TYPE=
2018-08-20 11:45:14.781 7f789febc700 20 HTTP_ACCEPT=*/*
2018-08-20 11:45:14.781 7f789febc700 20 HTTP_ACCEPT_ENCODING=
2018-08-20 11:45:14.781 7f789febc700 20 HTTP_AUTHORIZATION=AWS4-HMAC-SHA256 Credential=QZPWZQQ4PCUVXKZA1CK4/20180820/us-east-1/s3/aws4_request, SignedHeaders=content-md5;expect;host;user-agent;x-amz-content-sha256;x-amz-date, Signature=6186f7dd9c9a33be40a91afd1ce0a9c4ce5cb8b830962764cebef6143d4913a3
2018-08-20 11:45:14.781 7f789febc700 20 HTTP_CONTENT_MD5=z+87naxG311xfjLKM9Pn2g==
2018-08-20 11:45:14.781 7f789febc700 20 HTTP_EXPECT=100-continue
2018-08-20 11:45:14.781 7f789febc700 20 HTTP_HOST=my.local.lan
2018-08-20 11:45:14.781 7f789febc700 20 HTTP_USER_AGENT=aws-sdk-ruby3/3.23.0 ruby/2.1.9 x86_64-darwin17.0 aws-sdk-s3/1.17.0
2018-08-20 11:45:14.781 7f789febc700 20 HTTP_VERSION=1.1
2018-08-20 11:45:14.781 7f789febc700 20 HTTP_X_AMZ_CONTENT_SHA256=867a5e890d8e8b156d16d33d8bbc135fd4d3b8f73844fb2d2e69df668fc4447c
2018-08-20 11:45:14.781 7f789febc700 20 HTTP_X_AMZ_DATE=20180820T034514Z
2018-08-20 11:45:14.781 7f789febc700 20 HTTP_X_FORWARDED_BY=127.0.0.1:80
2018-08-20 11:45:14.781 7f789febc700 20 HTTP_X_FORWARDED_FOR=127.0.0.1
2018-08-20 11:45:14.781 7f789febc700 20 HTTP_X_FORWARDED_PROTO=http
2018-08-20 11:45:14.781 7f789febc700 20 HTTP_X_REAL_IP=127.0.0.1
2018-08-20 11:45:14.781 7f789febc700 20 REMOTE_ADDR=192.168.15.58
2018-08-20 11:45:14.781 7f789febc700 20 REQUEST_METHOD=PUT
2018-08-20 11:45:14.781 7f789febc700 20 REQUEST_URI=/reocar/reocar.jpg
2018-08-20 11:45:14.781 7f789febc700 20 SCRIPT_URI=/reocar/reocar.jpg
2018-08-20 11:45:14.781 7f789febc700 20 SERVER_PORT=7480
2018-08-20 11:45:14.781 7f789febc700  1 ====== starting new request req=0x7f789feb3830 =====
...

欢迎跟我交流 Archfish