背景

上篇博文《Garage对象存储的安装和使用》介绍了在 Oracle Cloud 的免费实例上用 Garage 搭建 S3 兼容的对象存储服务。

实际上 Oracle Cloud 的免费套餐里面还有 20GB 的 S3 兼容的对象存储额度。本着不用白不用的精神,来看看怎么利用起来。

本博客就是托管于 Cloudflare Pages,图床使用 Oracle 对象存储并使用 Cloudflare Workers 做 CDN 加速。

相比较用开源项目自行搭建的对象存储服务,云厂商的对象存储服务一般功能更多。OCI Object Storage 的一个优点是可以进行预授权,在存储桶设为私有的情况下,Web 应用程序也可以很方便地通过授权码进行访问。
(没有测试过是否支持 S3 兼容的访问策略,如果兼容的话,也可以用访问策略实现允许匿名以只读的形式访问。)

创建存储桶

进入存储桶管理页面。对于免费套餐用户,可以看到提示有 20GB 的免费存储空间额度。
picture 0

点击创建存储桶按钮,输入存储桶名称,其它选项用默认即可。我这里的名称为 bucket-oracle-free-20G
picture 1

此时该存储桶还不能被访问。注意可见性设置,如果设置为公共,那么将允许匿名用户和未通过身份验证的用户访问该存储桶中存储的数据,这样是不安全的。建议保持专用,下面会介绍通过 Cloudflare 免费 CDN 访问专用存储桶的资源。

创建访问密钥

从顶部导航栏的进入我的概要信息

从左边的侧边栏进入客户密钥

点击生成密钥并输入密钥名称,将生成密钥 (Secret key),这个只有在创建时能展示明文,需要另外保存下来。

回到密钥列表,可以看到创建的密钥,其中访问密钥Key ID,可以复制明文。

至此可以使用各种 S3 兼容的客户端和库访问存储桶了。需要注意的点:

  1. region 是当前区域,一般在网址里面会有类似于 region=ap-tokyo-1 的参数。

  2. bucket 并不是上面创建存储桶时指定的名称,而是存储桶详情页面的名称空间

  3. endpoint 参考 OCI Object Storage Dedicated Endpoints,需要在域名后加上存储桶名称。示例:axaxnpcrorw5.compat.objectstorage.ap-tokyo-1.oraclecloud.com

  4. S3 客户端得到的 URL 为 https://axaxnpcrorw5.compat.objectstorage.ap-tokyo-1.oraclecloud.com/bucket-oracle-free-20G/<key>

创建预先验证的请求

上面已经能用 Key IDSecret key 访问存储桶对象了。对通过 Web 应用访问存储桶的场景有更简单的方法,那就是创建预先验证的请求。预先验证的请求允许访问存储桶对象而不需要提供访问密钥,本质上就是预先生成访问授权码。在使用 Cloudflare Workers 实现 CDN 时会比较方便。

进入存储桶详情页面,从左边的侧边栏进入预先验证的请求

点击创建预先验证的请求。因为是用于 Cloudflare Workers,我这里起名为 Cloudflare-Workers。目标选择存储桶,类型选择允许对象读取,到期时间可以指定一个非常久远的时间以近似实现永不过期。
picture 6
之后会生成一个预先验证的请求 URL,授权码就包含在 URL 里面,这个 URL 只有在创建时能展示明文,需要另外保存下来。

Cloudflare Workers

Cloudflare 为普通用户提供免费 CDN 服务,非常良心。
基本上要使用 Cloudflare 服务,需要在 Cloudflare 托管域名。如何购买和托管域名参见网上其它文章。本博客所使用的域名 ijoy.store 就托管于 Cloudflare。

常规的 CDN 模式是:用户 -> Cloudflare 服务器 -> Source 服务器。这种方式只需要在 Cloudflare 托管域名,使用 CNAME 并开启代理(点亮小云朵)即可。
然而对于 Oracle 对象存储,无论可见性是公共还是专用,都无法实现代理。这是因为 Oracle 对象存储的域名里面包含存储桶的 bucketregion 信息,Cloudflare 访问源服务器时并不会使用源服务器域名,而是使用 Cloudflare 托管的域名,这样一来 Oracle 服务器就不能正常处理请求了。(企业版订阅用户可以通过规则重写 Host HTTP Header。Cloudflare 解释说普通用户不允许是出于安全考虑。)
那就只能使用 Cloudflare Workers + Cache 实现 CDN 效果了。

添加二级域名

进入到域名管理页面,Home -> Websites -> ijoy.store -> DNS -> Records。

添加二级域名,类型为 CNAME,名字为 oss,目标为 <namespace>.compat.objectstorage.<region>.oraclecloud.com,默认开启代理。之后 oss.ijoy.store 的流量会接入到 Cloudflare。
注意:这里的目标没有实际作用,后续会把流量指向 Worker。

创建 Worker

进入到 Worker 创建页面,Home -> Workers & Pages。

点击 Create application -> Create Worker,输入 Worker 名字,我这里用跟域名相同的名字 oss

进入到创建的 Worker 管理页面,点击 Edit Code 进入在线编辑器,用以下代码替换。需要修改 OOS_BUCKET_PREAUTH_URL 为你为存储桶创建的预先验证的请求 URL。为防止上传文件时未指定 Content-Type,这段代码使用文件扩展名进行映射。

const FileMimeType = {
  'audio/x-mpeg': ['mpega'],
  'video/3gpp': ['3gpp', '3gp'],
  'application/postscript': ['ps', 'eps', 'ai'],
  'audio/x-aiff': ['aiff'],
  'application/x-aim': ['aim'],
  'image/x-jg': ['art'],
  'video/x-ms-asf': ['asx', 'asf'],
  'audio/basic': ['ulw'],
  'video/x-msvideo': ['avi'],
  'video/x-rad-screenplay': ['avx'],
  'application/x-bcpio': ['bcpio'],
  'image/bmp': ['dib'],
  'text/html': ['html', 'htm', 'shtml'],
  'application/x-cdf': ['cdf'],
  'application/pkix-cert': ['cer'],
  'application/java': ['class'],
  'application/x-cpio': ['cpio'],
  'application/x-csh': ['csh'],
  'text/css': ['css'],
  'application/msword': ['doc'],
  'application/xml-dtd': ['dtd'],
  'video/x-dv': ['dv'],
  'application/x-dvi': ['dvi'],
  'application/vnd.ms-fontobject': ['eot'],
  'text/x-setext': ['etx'],
  'image/gif': ['gif'],
  'application/x-gtar': ['gtar'],
  'application/x-gzip': ['gz'],
  'application/x-hdf': ['hdf'],
  'application/mac-binhex40': ['hqx'],
  'text/x-component': ['htc'],
  'image/ief': ['ief'],
  'text/vnd.sun.j2me.app-descriptor': ['jad'],
  'application/java-archive': ['jar', 'war', 'ear'],
  'text/x-java-source': ['java'],
  'application/x-java-jnlp-file': ['jnlp'],
  'image/jpeg': ['jpg', 'jpeg'],
  'application/javascript': ['js'],
  'text/plain': ['txt'],
  'application/json': ['json'],
  'audio/midi': ['midi', 'mid', 'kar'],
  'application/x-latex': ['latex'],
  'audio/x-mpegurl': ['m3u'],
  'image/x-macpaint': ['pnt'],
  'text/troff': ['tr'],
  'application/mathml+xml': ['mathml'],
  'application/x-mif': ['mif'],
  'video/quicktime': ['qt'],
  'video/x-sgi-movie': ['movie'],
  'audio/mpeg': ['mpa'],
  'video/mp4': ['mp4', 'm4v'],
  'video/mpeg': ['mpg', 'mpeg'],
  'video/mpeg2': ['mpv2'],
  'application/x-wais-source': ['src'],
  'application/x-netcdf': ['nc'],
  'application/oda': ['oda'],
  'application/vnd.oasis.opendocument.database': ['odb'],
  'application/vnd.oasis.opendocument.chart': ['odc'],
  'application/vnd.oasis.opendocument.formula': ['odf'],
  'application/vnd.oasis.opendocument.graphics': ['odg'],
  'application/vnd.oasis.opendocument.image': ['odi'],
  'application/vnd.oasis.opendocument.text-master': ['odm'],
  'application/vnd.oasis.opendocument.presentation': ['odp'],
  'application/vnd.oasis.opendocument.spreadsheet': ['ods'],
  'application/vnd.oasis.opendocument.text': ['odt'],
  'application/vnd.oasis.opendocument.graphics-template': ['otg'],
  'application/vnd.oasis.opendocument.text-web': ['oth'],
  'application/vnd.oasis.opendocument.presentation-template': ['otp'],
  'application/vnd.oasis.opendocument.spreadsheet-template': ['ots'],
  'application/vnd.oasis.opendocument.text-template': ['ott'],
  'application/ogg': ['ogx'],
  'video/ogg': ['ogv'],
  'audio/ogg': ['spx'],
  'application/x-font-opentype': ['otf'],
  'audio/flac': ['flac'],
  'application/annodex': ['anx'],
  'audio/annodex': ['axa'],
  'video/annodex': ['axv'],
  'application/xspf+xml': ['xspf'],
  'image/x-portable-bitmap': ['pbm'],
  'image/pict': ['pict'],
  'application/pdf': ['pdf'],
  'image/x-portable-graymap': ['pgm'],
  'audio/x-scpls': ['pls'],
  'image/png': ['png'],
  'image/x-portable-anymap': ['pnm'],
  'image/x-portable-pixmap': ['ppm'],
  'application/vnd.ms-powerpoint': ['pps'],
  'image/vnd.adobe.photoshop': ['psd'],
  'image/x-quicktime': ['qtif'],
  'image/x-cmu-raster': ['ras'],
  'application/rdf+xml': ['rdf'],
  'image/x-rgb': ['rgb'],
  'application/vnd.rn-realmedia': ['rm'],
  'application/rtf': ['rtf'],
  'text/richtext': ['rtx'],
  'application/font-sfnt': ['sfnt'],
  'application/x-sh': ['sh'],
  'application/x-shar': ['shar'],
  'application/x-stuffit': ['sit'],
  'application/x-sv4cpio': ['sv4cpio'],
  'application/x-sv4crc': ['sv4crc'],
  'image/svg+xml': ['svgz'],
  'application/x-shockwave-flash': ['swf'],
  'application/x-tar': ['tar'],
  'application/x-tcl': ['tcl', 'tk'],
  'application/x-tex': ['tex'],
  'application/x-texinfo': ['texinfo'],
  'image/tiff': ['tiff', 'tif'],
  'text/tab-separated-values': ['tsv'],
  'application/x-font-ttf': ['ttf'],
  'application/x-ustar': ['ustar'],
  'application/voicexml+xml': ['vxml'],
  'image/x-xbitmap': ['xbm'],
  'application/xhtml+xml': ['xhtml'],
  'application/vnd.ms-excel': ['xls'],
  'application/xml': ['xsl'],
  'image/x-xpixmap': ['xpm'],
  'application/xslt+xml': ['xslt'],
  'application/vnd.mozilla.xul+xml': ['xul'],
  'image/x-xwindowdump': ['xwd'],
  'application/vnd.visio': ['vsd'],
  'audio/x-wav': ['wav'],
  'image/vnd.wap.wbmp': ['wbmp'],
  'text/vnd.wap.wml': ['wml'],
  'application/vnd.wap.wmlc': ['wmlc'],
  'text/vnd.wap.wmlsc': ['wmls'],
  'application/vnd.wap.wmlscriptc': ['wmlscriptc'],
  'video/x-ms-wmv': ['wmv'],
  'application/font-woff': ['woff'],
  'application/font-woff2': ['woff2'],
  'model/vrml': ['wrl'],
  'application/wspolicy+xml': ['wspolicy'],
  'application/x-compress': ['z'],
  'application/zip': ['zip'],
};

const fileMimeType = {}
for (let k in FileMimeType) {
  FileMimeType[k].forEach(ext => {
    fileMimeType[ext] = k;
  });
}

// 预先验证的请求 URL
const OOS_BUCKET_PREAUTH_URL = '<你创建的预先验证的请求 URL>';

const preAuthURL = new URL(OOS_BUCKET_PREAUTH_URL);

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const host = url.host
    url.host = preAuthURL.host;
    url.pathname = preAuthURL.pathname + url.pathname.substring(1);

    const newRequest = new Request(url, request);
    newRequest.headers.set('X-Forwarded-Host', host)
    const response = await fetch(newRequest);

    let contentType = response.headers.get('Content-Type');
    if (contentType && contentType != 'application/octet-stream') {
      return response
    }

    const newResponse = new Response(response.body, {
      headers: response.headers,
      status: response.status,
      statusText: response.statusText,
    });
    const ext = url.pathname.split('.').pop();
    contentType = fileMimeType[ext];
    if (contentType) {
      newResponse.headers.set('Content-Type', contentType);
    }
    return newResponse
  },
};

将二级域名指向 Worker

进入到创建的 Worker 管理页面,Settings -> Add route。

Route 填 oss.ijoy.store/*,Zone 选 ijoy.store。这样就把 oss.ijoy.store 的所有请求路由到当前 Worker。

开启缓存

进入到规则管理页面,Home -> Websites -> ijoy.store -> Rules -> Page Rules。

点击 Create Page Rule。



URL 填 oss.ijoy.store/*, Pick a Setting 选 Cache Level,Select Cache Level 选 Cache Everything。这样所有 oss.ijoy.store 请求的响应都会被缓存,就达到 CDN 的效果了。

使用方法

至此,所有的准备工作就完成了,对于上传至存储桶的文件,可以通过 https://oss.ijoy.store/<key> 访问到了。

对于使用 VS Code 写 Markdown 的用户,推荐 Markdown Image 插件。该插件以粘贴的形式自动上传至云服务并生成可访问链接。详情可参见插件说明

Markdown Image 插件实际使用配置如下:

    "markdown-image.base.uploadMethod": "S3",
    "markdown-image.s3.endpoint": "https://https://axaxnpcrorw5.compat.objectstorage.us-phoenix-1.oraclecloud.com",
    "markdown-image.s3.region": "ap-tokyo-1",
    "markdown-image.s3.bucketName": "<名称空间>",
    "markdown-image.s3.accessKeyId": "<Key ID>",
    "markdown-image.s3.secretAccessKey": "<Secret key>",
    "markdown-image.s3.cdn": "https://oss.ijoy.store/${filepath}",
    "markdown-image.s3.config": {
        "forcePathStyle": false,
    }

总结

对象存储一般要搭配 CDN 一起使用。为了免费使用 Cloudflare CDN,上述步骤略显复杂。
其实如果已经在使用 Cloudflare Pages 托管博客了,那不妨直接使用 Cloudflare R2 对象存储:有 10G 的免费存储额度,配置简单,自带 CDN 支持,个人博客用户基本够用。