Madogiwa Blog

主に技術系の学習メモに使っていきます。

pnpmのバージョン管理をCorepackから`manage-package-manager-versions`に移行する📝

個人のサービスでpnpmのバージョン管理にCorepackを利用していたのですが、Corepackは以下の通りNode.jsから削除されてしまうようです 😢

zenn.dev

そのためpnpmのv9.7で追加されたmanage-package-manager-versionsを使ってpnpmのバージョン管理をCorepackと同様にpackage.json内ので行うpackageManagerで行うようにしたので対応したことをメモ📝

github.com

manage-package-manager-versions Added in: v9.7.0 Default: false Type: Boolean When enabled, pnpm will automatically download and run the version of pnpm specified in the packageManager field of package.json. This is the same field used by Corepack.

Settings (.npmrc) | pnpm

やり方は簡単でpnpmのinstallをnpmでglobal installするように変更し、

- corepack enable pnpm
+ npm install -g pnpm 

※node.jsはinstallする必要があるのでスタンドアローンは採用せず普通にnpmでインストールするようにしました。 link

.npmrcを作成して以下の設定を追加するだけでした🙆

manage-package-manager-versions=true

これであとはCorepackと同様に以下のpackage.jsonpackageManagerの指定に基づいたバージョンを自動的に利用してくれます✨

  "packageManager": "pnpm@9.10.0"

CorepackがNode.jsの標準添付で無くなってしまうのは残念ですが、pnpmを利用しているのであればmanage-package-manager-versionsを利用すると、Corepack相当の機能を利用できるので便利ですね!!!(ありがたや🙏)

参考

qiita.com

Vue.js: さらに読み込む的なボタンクリック方式のシンプルなページャーを作るメモ📝

Vue.jsを使って以下のようなボタンクリック方式のシンプルなページャーを作成したのでメモ📝

以下のような感じでボタンクリックの度にサーバー側に/feeds?page=1のようなリクエストを送信して結果を元に処理を行うことができます。

<template>
  <div class="collection">
    <p v-for="feed in feeds">{{ feed.title }}</p>
    <div class="button-pager">
      <span v-if="status === 'complete'" class="button-pager__loader-info">全て読み込みました</span>
      <span v-else-if="status === 'no-result'" class="button-pager__loader-info">結果が取得できませんでした</span>
      <span v-else-if="status === 'loading'" class="button-pager__loader-info">読み込み中です</span>
      <span v-else-if="status === 'error'" class="button-pager__loader-info">エラーが発生しました</span>
      <button v-else class="button-pager__button" @click="feedPagerHandler">
        <span>次のページ</span>
      </button>
    </div>
  </div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from "vue";
import FeedCardCollection from "@js/components/molecules/feed/FeedCardCollection.vue";
import { getFeeds } from "@js/services/FeedService";
import type { Feed } from "@js/types/types";
import { usePager } from "@js/composables/Pager";
import CommonButtonPager from "@js/components/molecules/CommonButtonPager.vue";

const feeds = ref<Feed[]>([]);
const resetFeedList = () => feeds.value.splice(0);
const updateFeedList = (target: Feed[]) => feeds.value.push(...target);
const { page, pagerHandler, status } = usePager();
const feedPagerHandler = async () => {
  await pagerHandler(
    () => getFeeds({ page: page.value }),
    (data: Feed[]) => updateFeedList(data),
  ).catch(error) { console.error(error) };
};
onMounted(() => feedPagerHandler());
</script>

ページャー周りの処理は以下のようなusePagerを返すcomposableを用意して、ロード中や結果なし、全件読み込み完了等のステータス制御をしつつ引数で渡された処理を実行して実行の度にページをインクリメントしてます。今回はさらに読み込む的なUIを想定しているためボタンクリックで実行するようにしてますが、IntersectionObserver等を使って要素の可視性を監視して実行するようにすると無限スクロールみたいな実装にも使えると思います。

import { computed, ref } from "vue";

export type PagerStatus = "initial" | "loading" | "loaded" | "complete" | "error" | "no-result";
export const usePager = () => {
  const page = ref<number>(1);
  const status = ref<PagerStatus>("initial");
  const isInitial = computed(() => status.value === "initial");
  const isLoading = computed(() => status.value === "loading");
  const isLoaded = computed(() => status.value === "loaded");
  const isNoResult = computed(() => status.value === "no-result");
  const isComplete = computed(() => status.value === "complete");
  const isError = computed(() => status.value === "error");
  const isNoNeededProcess = computed(() => isLoading.value || isComplete.value || isNoResult.value);

  const resetStatus = () => {
    page.value = 1;
    status.value = "initial";
  };

  const calcSuccessStatus = (resultLength: number, pageNumver: number): PagerStatus => {
    if (resultLength < 1 && pageNumver <= 1) return "no-result";
    if (resultLength < 1 && pageNumver > 1) return "complete";
    return "loaded";
  };

  const pagerHandler = async <T>(process: () => Promise<T[]>, callback: (data: T[]) => void) => {
    if (isNoNeededProcess.value) return;
    try {
      status.value = "loading";
      const result = await process();
      callback(result);
      status.value = calcSuccessStatus(result.length, page.value);
      if (isLoaded.value) page.value += 1;
    } catch (error) {
      status.value = "error";
      throw error;
    }
  };

  return {
    isInitial,
    isLoading,
    isLoaded,
    isComplete,
    isError,
    isNoResult,
    status,
    page,
    pagerHandler,
    resetStatus,
  };
};

おしまい。

HerokuにGitHub ActionでImage Buildしてデプロイする

Herokuにはheroku.ymlを使って簡単にpush時に自動デプロイする機能がありますが、

devcenter.heroku.com

簡単にできる反面、以下のようにRuntimeで設定される環境変数は利用できず、動的な値や秘匿値をセキュアにビルド時に設定するようなことはできません。

Variables set in this section don’t create runtime config vars. Also runtime config vars, such as those config vars set with heroku config:set, aren’t available at build time. Building Docker Images with heroku.yml | Heroku Dev Center

そのようなことをやりたい時にはContainer Registry & Runtime (Docker Deploys)を使うことで自身でDockerfileをビルドしてデプロイすることができます。

devcenter.heroku.com

以下がGitHub Actionでデプロイするようにしてみたworkflowです。

name: Deploy

on:
  workflow_dispatch:
  push:
    branches: [master]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  SOURCE_VERSION: ${{ github.sha }}

jobs:
  deploy:
    timeout-minutes: 20
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Build, Push and Release a Docker container to Heroku. 
        uses: gonuit/heroku-docker-deploy@v1.3.3
        with:
          email: your.email@example.com
          heroku_api_key: ${{ secrets.HEROKU_API_KEY }}
          heroku_app_name: your_app_name
          docker_options: "--build-arg SOURCE_VERSION=${{ env.SOURCE_VERSION }}"
          process_type: web

gonuit/heroku-docker-deployを使うと簡単に実現できますね、ありがたや🙏✨

github.com

参考

qiita.com

Viteでビルド時にRollbarにSourcemapをアップロードするプラグインを自作したのでメモ📝

Viteを使っていてRollbarにSourceMapをアップロードする際には下記の通りですが、

Vite plugin There is a community-maintained Rollbar Sourcemap Plugin for Vite. Please check the Readme doc for details on the project and usage instructions. https://docs.rollbar.com/docs/source-maps#vite-plugin

以下のコミュニティメンテのPluginの利用が推奨されてますが、Vite v5に対応してなさそうだったので参考にして自作してみたのでメモ📝

github.com

以下がそのプラグインです。(基本はコミュニティメンテのPluginのロジックを踏襲しつつTypeScriptで書き直しつつ、メソッドを整理したりエラーハンドリング周りを簡素化しただけ)

/* eslint-disable no-console */
import type { Plugin } from "vite";
import { existsSync, readFileSync } from "fs";
import { resolve } from "path";
import { glob } from "glob";
const ROLLBAR_ENDPOINT = "https://api.rollbar.com/api/1/sourcemap";

type rollbarSourcemapsOptions = {
  accessToken: string;
  version: string;
  baseUrl: string;
  silent?: boolean;
  ignoreUploadErrors?: boolean;
  base?: string;
  outputDir?: string;
};
export default function rollbarSourcemaps({
  accessToken,
  version,
  baseUrl,
  silent = false,
  ignoreUploadErrors = true,
  base = "/",
  outputDir = "dist",
}: rollbarSourcemapsOptions): Plugin {
  return {
    name: "vite-plugin-rollbar",
    async writeBundle() {
      const files = await glob("./**/*.map", { cwd: outputDir });
      const sourcemaps: RollbarSourceMap[] = files
        .map((file) => {
          const sourcePath = calcSourcePath({ sourcemap: file, outputDir });
          if (sourcePath === null) {
            console.error(`No corresponding source found for '${file}'`, true);
            return null;
          }
          const sourcemapLocation = resolve(outputDir, file);
          const sourcemap = buildRollbarSourcemap({ base, sourcePath, sourcemapLocation });
          if (sourcemap === null) console.error(`Error reading sourcemap file ${sourcemapLocation}`, true);
          return sourcemap;
        })
        .filter((sourcemap) => sourcemap !== null);

      if (!sourcemaps.length) return;

      try {
        await Promise.all(
          sourcemaps.map((asset) => {
            const form = buildPostFormData({ accessToken, version, baseUrl, asset });
            return uploadSourcemap(form, { filename: asset.original_file, silent });
          }),
        );
      } catch (error) {
        if (ignoreUploadErrors) {
          console.error("Uploading sourcemaps to Rollbar failed: ", error);
          return;
        }
        throw error;
      }
    },
  };
}

async function postRollbarSourcemap(body: FormData): Promise<Response> {
  const res = await fetch(ROLLBAR_ENDPOINT, { method: "POST", body });
  if (!res.ok) throw new Error(`Failed to pots sourcemap to Rollbar: ${res.statusText}`);
  return res;
}

async function uploadSourcemap(form: FormData, { filename, silent }: { filename: string; silent: boolean }) {
  let res;
  try {
    res = await postRollbarSourcemap(form);
  } catch (err: unknown) {
    const error = err as Error;
    const errMessage = `Failed to upload ${filename} to Rollbar: ${error.message}`;
    throw new Error(errMessage);
  }

  if (res.ok || !silent) console.info(`Uploaded ${filename} to Rollbar`);
}

type RollbarSourceMap = {
  content: string;
  sourcemap_url: string;
  original_file: string;
};
function buildRollbarSourcemap({
  base,
  sourcePath,
  sourcemapLocation,
}: {
  base: string;
  sourcePath: string;
  sourcemapLocation: string;
}): RollbarSourceMap | null {
  try {
    return {
      content: readFileSync(sourcemapLocation, "utf8"),
      sourcemap_url: sourcemapLocation,
      original_file: `${base}${sourcePath}`,
    };
  } catch (_error) {
    return null;
  }
}

function buildPostFormData({
  accessToken,
  version,
  baseUrl,
  asset,
}: {
  accessToken: string;
  version: string;
  baseUrl: string;
  asset: RollbarSourceMap;
}) {
  const form = new FormData();

  form.set("access_token", accessToken);
  form.set("version", version);
  form.set("minified_url", `${baseUrl}${asset.original_file}`);
  form.set("source_map", new Blob([asset.content]), asset.original_file);
  return form;
}

function calcSourcePath({ sourcemap, outputDir }: { sourcemap: string; outputDir: string }): string | null {
  const sourcePath = sourcemap.replace(/\.map$/, "");
  const sourceFilename = resolve(outputDir, sourcePath);
  if (!existsSync(sourceFilename)) return null;
  return sourcePath;
}

以下のような感じで利用することができます。

const setupRollbarPlugin = () => {
  const rollbarConfig = {
    accessToken: process.env.ROLLBAR_POST_SERVER_ITEM_ACCESS_TOKEN || "",
    version: process.env.SOURCE_VERSION || "unknown",
    baseUrl: APP_ASSETS_HOST_URL,
    ignoreUploadErrors: true,
    outputDir: OUTPUT_DIR,
    silent: false,
  };
  return viteRollbar(rollbarConfig);
};

Ruby: RSSのパースが`This is not well formed XML\nentity expansion has grown too large`で落ちた時の対処法メモ📝

結論「REXML::Security.entity_expansion_text_limitに任意の値を設定する。」

結論としては上記の通りなのですが、個人で運営しているサービスで、Ruby標準ライブラリのRSSによるパース時にThis is not well formed XML\nentity expansion has grown too largeが発生するようになりました。

理由としては、以下のRexmlで対応されたセキュリティFIXによってバイトサイズのチェックがされるようになったようでした🙏(感謝)

github.com

そのため、デフォルト値である10240 bytesを超える場合には明示的にリミットを調整する必要があります。

docs.ruby-lang.org

私はRuby on Railsで構築したサービス内で利用していたのでRails v7.1で追加されたObject#withを使って以下のようにParseする時だけリミットを上書きするようにしました。

    def parse!
      # NOTE: 大きめのRSSをパース可能にするために、entity_expansion_text_limitを設定
      REXML::Security.with(entity_expansion_text_limit: text_limit) do
        RSS::Parser.parse(resource)
      rescue RSS::InvalidRSSError
        RSS::Parser.parse(resource, false)
      end
    end

www.shakacode.com

ちなみにREXML::Document.entity_expansion_text_limitは、非推奨なのでご注意ください。

REXML::Document.entity_expansion_text_limit このメソッドは Ruby 2.1 から deprecated になりました。 REXML::Security.entity_expansion_text_limit を使ってください。 REXML::Document.entity_expansion_text_limit (Ruby 3.3 リファレンスマニュアル)

参考)

obel.hatenablog.jp

stackoverflow.com

🚃Rails newで生成される本番用Dockerfileを読んでみる🐳

以下の通り、Rails v7.1 から新規アプリケーション生成時にDockerfileが生成されるようになりました。

2.1 新規RailsアプリケーションでDockerfileが生成されるようになった 新規Railsアプリケーションでは、デフォルトでDockerがサポートされるようになりました(#46762)。 新しいアプリケーションを生成すると、そのアプリケーションにDocker関連ファイルも含まれます。 https://railsguides.jp/7_1_release_notes.html#%E6%96%B0%E8%A6%8Frails%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%81%A7dockerfile%E3%81%8C%E7%94%9F%E6%88%90%E3%81%95%E3%82%8C%E3%82%8B%E3%82%88%E3%81%86%E3%81%AB%E3%81%AA%E3%81%A3%E3%81%9F

今回は先日リリースされたRails v7.2で生成されるDockerfileがどんな感じなのか読んでみたいと思います。

edgeguides.rubyonrails.org

Rails v7.2で生成されるDockerfile

Rails v7.2で生成されるDockerfileは以下のTemplateを元に生成されます。

github.com

例えば以下のコマンエドRails newすると

$ rails -v
Rails 7.2.0
$ rails new myapp

以下のようなDockerfileが生成されます。

# syntax = docker/dockerfile:1

# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
# docker build -t my-app .
# docker run -d -p 80:80 -p 443:443 --name my-app -e RAILS_MASTER_KEY=<value from config/master.key> my-app

# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.3.1
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base

# Rails app lives here
WORKDIR /rails

# Install base packages
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y curl libjemalloc2 libsqlite3-0 libvips && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Set production environment
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"

# Throw-away build stage to reduce size of final image
FROM base AS build

# Install packages needed to build gems
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y build-essential git pkg-config && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
    bundle exec bootsnap precompile --gemfile

# Copy application code
COPY . .

# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/

# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile




# Final stage for app image
FROM base

# Copy built artifacts: gems, application
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails

# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
    useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
    chown -R rails:rails db log storage tmp
USER 1000:1000

# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

# Start the server by default, this can be overwritten at runtime
EXPOSE 3000
CMD ["./bin/rails", "server"]

RailsのDockerfileのtemplateを読んでみる

github.com

RailsのDockerfileのtemplateは以下のようなマルチステージビルドを行うような構成になっています。

  • base : イメージの指定と環境変数、ベースとなるライブラリのinstall
  • build : gem/npmのinstall及びビルド
  • final stage : ビルド成果物のコピーとカスタムユーザーへの権限追加、起動コマンドの指定

それぞれサラッとみていきます。

base

base部分のDockerfileは以下の通りです。

ARG RUBY_VERSION=<%= gem_ruby_version %>
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base

# Rails app lives here
WORKDIR /rails

# Install base packages
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y <%= dockerfile_base_packages.join(" ") %> && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Set production environment
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"

イメージにはruby:<version>-slimRubyのイメージが利用されます。Rubyに実行に必要な最低限のパッケージのみが含まれているためイメージサイズを小さくすることができます。

ruby:<version>-slim
This image does not contain the common packages contained in the default tag and only contains the minimal packages needed to run ruby. Unless you are working in an environment where only the ruby image will be deployed and you have space constraints, we highly recommend using the default image of this repository.

https://hub.docker.com/_/ruby

イメージに利用するRubyのバージョンはARGでビルド時に指定可能になっていますが、 デフォルトではgem_ruby_versionが実行されRails newした際に利用しているRubyのバージョンが設定されます。

apt-getでinstallされるライブラリはdockerfile_base_packagesで設定され、基本はcurllibjemalloc2がインストールされ、あとはActiveRecordを使う場合にはdatabase関連の必要なライブラリ、ActiveStrageを利用するならlibvipsがインストールされます。

その後は環境変数としてRAILS_ENV及びBubdler周りの設定用の環境変数が設定されます。

build

build部分のDockerfileは以下の通りです。gemのinstallやアセットのビルド等を行い最終的なイメージに必要となる成果物を作成します。

# Throw-away build stage to reduce size of final image
FROM base AS build

# Install packages needed to build gems<%= using_node? ? " and node modules" : "" %>
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y <%= dockerfile_build_packages.join(" ") %> && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

<% if using_node? -%>
# Install JavaScript dependencies
ARG NODE_VERSION=<%= node_version %>
ARG YARN_VERSION=<%= dockerfile_yarn_version %>
ENV PATH=/usr/local/node/bin:$PATH
RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
    /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
    npm install -g yarn@$YARN_VERSION && \
    rm -rf /tmp/node-build-master

<% end -%>
<% if using_bun? -%>
ENV BUN_INSTALL=/usr/local/bun
ENV PATH=/usr/local/bun/bin:$PATH
ARG BUN_VERSION=<%= dockerfile_bun_version %>
RUN curl -fsSL https://bun.sh/install | bash -s -- "bun-v${BUN_VERSION}"

<% end -%>
# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git<% if depend_on_bootsnap? -%> && \
    bundle exec bootsnap precompile --gemfile<% end %>

<% if using_node? -%>
# Install node modules
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

<% end -%>
<% if using_bun? -%>
# Install node modules
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile

<% end -%>
# Copy application code
COPY . .

<% if depend_on_bootsnap? -%>
# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/

<% end -%>
<% unless dockerfile_binfile_fixups.empty? -%>
# Adjust binfiles to be executable on Linux
<%= "RUN " + dockerfile_binfile_fixups.join(" && \\\n    ") %>

<% end -%>
<% unless options.api? || skip_asset_pipeline? -%>
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile

<% end -%>

<% if using_node? -%>
RUN rm -rf node_modules
<% end %>

まず先ほどのbaseをベースイメージとしてbuildと名前をつけます。 そしてdockerfile_build_packagesで必要となるライブラリをインストールします。

基本はnative拡張等のgemをinstallするためのbuild-essential,git,pkg-configがinstallされますが、Node.jsが必要となる場合にはnpmのnative modulesをinstallするためのnode-gyppython-is-python3もインストールされます。

Node.jsが必要となるかどうかは、using_node?で判定されており、実態はimportmapを利用せずにJavaScriptを利用するかで判定しています。

Node.jsを使った環境構築を行う場合にはnpm packageのinstallを行うために必要なpackage.jsonyarn.lockをホストからコピーしyarn installを行います。(bunを使う場合にはbun installが行われる)

その後COPY . .でホストからすべてのファイルをコピーし、bootsnapに依存していればbootsnap precompileを行いapp,limのcacheを生成します。

dockerfile_binfile_fixupsでの処理は、OS間でのbin系ファイルの互換性を保持するようにしているようです。

https://github.com/rails/rails/pull/46944

その後Asset Pipelineを利用している場合にはSECRET_KEY_BASE_DUMMY=1を指定して、bin/rails assets:precompileを実行して必要なアセット類のビルドを行います。

ENV["SECRET_KEY_BASE_DUMMY"]を設定すると、一時ファイルに保存されるランダム生成のsecret_key_baseが使われるようになります。これは、ビルド中にproduction用のsecretsにアクセスせずに、production用のアセットをプリコンパイルしたい場合に便利です。 https://railsguides.jp/asset_pipeline.html

最後にRUN rm -rf node_modulesを実行してビルドも終わり不要になったnode_modulesを削除しています。(最終的ステージでCOPY --from=build /rails /railsをした際にnode_modulesが最終イメージに含まれないようにするため)

final stage

final stageのDockerfileは以下の通りです。buildで生成した成果物を取得し、カスタムユーザーに必要な権限を与え、起動コマンドを設定しています。

# Final stage for app image
FROM base

# Copy built artifacts: gems, application
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails

# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
    useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
    chown -R rails:rails <%= dockerfile_chown_directories.join(" ") %>
USER 1000:1000

# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

# Start the server by default, this can be overwritten at runtime
EXPOSE 3000
CMD ["./bin/rails", "server"]

COPY --from=buildでbundle installしたgemとprojectのrootディレクトリである/railsをbuidステージから取得します。(ここで実行時に必要なファイルだけ取得してるので最終的なイメージサイズを削減できます)

その後、カスタムユーザーrailsを作成しchowndockerfile_chown_directoriesで取得したディレクトリの所有者を変更し権限を付与し、実行時のユーザーとしてrailsを設定します。

カスタムユーザー(non-root user)での実行は以下のPRによって導入されており、

github.com

Docker公式でもセキュリティ面で推奨されているようです。

docs.docker.com

ENTRYPOINT ["/rails/bin/docker-entrypoint"]が設定されイメージからコンテナが起動された際にdocker-entrypointが実行され、デフォルトではjemallocの有効化とbin/rails db:prepareが行われます。

最後に3000番ポート公開し、デフォルトの実行コマンドとしてRailsの起動コマンドであるbin/rails serverを設定します。

おわりに

RailsのDockerfileを見てみましたが、今までおまじない的にインストールしていたライブラリ群をRails公式が整理してくれたことにより自信を持てるようになったのと、並列実行のためにbundle installとnpmはステージを分けたりとカスタマイズのしがいはありそうですが、マルチステージビルドでいい感じに最終的なイメージサイズを削減してくれていたり初期の段階では十分そうな印象を持ちました。

また、Railsで本番デプロイする際のDockerfileがテンプレートとして管理されるようになったことによって、今まで各社で個別に行われていたDockerfile周りの知見が集まっていくのは非常にありがたいなと思いました🙏

おわり

`openapi-fetch`でサーバー側のレスポンスがエラー相当の場合にデフォルトで例外を発生させる方法メモ

openapi-fetchを使っていてサーバー側のレスポンスがエラー相当の場合にデフォルトで例外を発生させる方法メモしておきます。

openapi-ts.dev

以下の記事で記載の通り、openapi-fetchでは例外がthrowされません。

レスポンスエラーと思われるようなステータス(400系、500系)が返されてもthrowされない点は注意です。 openapi-typescript + openapi-fetchを使ってOpenAPIのスキーマから型情報とクライアントを自動生成するメモ - Madogiwa Blog

毎回response.statusを確認して例外をthrowするのは手間なのでMiddlewareを使ってresponse.statusを確認して例外を発生させるようにしました。

以下がサンプルコードです。

import createClient, { type Middleware } from "openapi-fetch";

export class FetchError extends Error {
  response: Response;

  constructor(message: string, response: Response) {
    super(message);
    this.message = message;
    this.response = response;
    this.name = "FetchError";
  }
}

const fetchResponseRaiseErrorMiddleware: Middleware = {
  onResponse({ response }) {
    if (response.status >= 400) throw new FetchError("request failed", response);
    return response;
  },
};

export const openApiFetchClient = createClient<paths>({});
openApiFetchClient.use(fetchResponseRaiseErrorMiddleware);

openapi-fetch、機能的にはシンプルですがこういったMiddlewareを使って色々と拡張できるの便利ですね👍