AGRISMILE DEV BLOG

株式会社AGRI SMILEの開発ブログです。

"gem rails" をタイプできるキーボードを作る

こんにちは。AGRISMILEプロダクト開発部新田です。

弊社は(一部expressを除けば)ほとんどのプロダクトのバックエンドをRuby on Railsで開発しています。
また年に数件の新規プロダクト開発があり、Gemfile に gem "rails" を記載する機会が比較的多い会社です。

source "https://rubygems.org"

gem "rails"

この手間を最小化するため、 gem "rails" を速やかにタイピングできるキーボードを開発したのでそのビルドログを以下に残しておきます。

回路図の設計

今回は 3x3 の単純なマトリクスで設計します。 コントローラにpro microを使い、row は 0, 1, 2ピン、 col は 3, 4, 5 ピンを使います。

ケースの印刷

今回は手配線で作ってしまうので、PCB設計はスキップしてケースを作ってしまいます。
キースイッチはだいたい14mm x 14mmの穴を空けておけばちょうど嵌まるということだけを念頭にケースを作ります。
(通常、キーキャップを含めたピッチを考慮する必要がありますが、今回はキーキャップもピッチにあわせて作るので考えなくて良いものとします)

CADでこういうケースをつくって、印刷

配線

先ほど作ったケースにプッシュスイッチを配置してダイオードの足を巻き付けつつ配線します。
銅色の線はポリウレタン線で、はんだごてで被膜を焼き切るだけで導通するようになるのでとても便利。

ProMicroを固定する方法は考え忘れていたので接着剤で貼り付けました。
また、回路図どおり配線するのが面倒になって7, 8, 9 10, 16, 14ピンを使っています。

配線を終えたらケースをネジで留めます。

プログラム

自作キーボードのファームウェアづくりにはQMK, ZMKを始め様々な便利ツールがありますが、味気無いのでarduino IDEでプログラムを書いてみることにします。

#include "Keyboard.h"

const uint8_t scan_port[] = {1, 0, 2};
const uint8_t data_port[] = {3, 4, 5};

// キーマップの定義
const char keys[3][3] = {
    {'A', 'G', 'R'},
    {'I', 'S', 'M'},
    {'I', 'L', 'E'}
};

bool pushed_state[3][3] = {
    {false, false, false},
    {false, false, false},
    {false, false, false}
};

void scanKeys() {
    for (int column = 0; column < 3; column++) {
        digitalWrite(scan_port[column], LOW);
        delay(3); // チャタリング対策

        for (int row = 0; row < 3; row++) {
            if (digitalRead(data_port[row]) == LOW) {
                if (!pushed_state[row][column]) {
                    // 初めて押されたときのみpressを送信
                    Keyboard.press(keys[row][column]);
                    pushed_state[row][column] = true;
                }
            } else {
                if (pushed_state[row][column]) {
                    Keyboard.release(keys[row][column]);
                    pushed_state[row][column] = false;
                }
            }
        }

        digitalWrite(scan_port[column], HIGH);
    }
}

void setup() {
    Keyboard.begin();
    for (int i = 0; i < 3; i++) {
        pinMode(scan_port[i], OUTPUT);
        digitalWrite(scan_port[i], HIGH); // プルアップ抵抗を有効にする
    }

    for (int i = 0; i < 3; i++) {
        pinMode(data_port[i], INPUT_PULLUP);
    }
}

void loop() {
    scanKeys();
}

Arduino IDEで上のコードを書き込んで、正常に動作することが確認できたらOKです。
押して無いキーが反応したり、押しても反応しないキーがあればテスターを持ち出して導通チェック・はんだし直しをします。。。

キーキャップづくり

GEM RAILS それぞれのキーのキーキャップを作ります。

3Dプリンターでいい感じに印刷します。 FDM方式の場合、精度を出すために0.2mmのノズルを使うのと、積層方向を強くするためにノズル温度を高めに設定するとよいとおもいます。

これをレジンで着色するとこのような形に。

完成

ありがとうございました。

Redisのダンプ/リストアを2コマンドで行う

こんにちは。新田です。
今回はRedisのデータのバックアップ/リストアを2コマンドで行う方法について説明します。
ただバックアップ/リストアするだけであれば、 redis-cli bgsave して、 redis-cli lastdumprdbファイルのパスを特定し、 そのファイルをなんとか取り出して 正しいパスに配置し、Redisを再起動する方法が正攻法です。

しかし今回バックアップ対象のRedisサーバーがマネージドサービス下で、ダンプファイルを取り出したりファイルを配置することができません。つまり

そのファイルをなんとか取り出して

これができない場合の方法について書きます。

DUMP と PTTL と RESTOREコマンド

Redisのコマンドには

があります。

DUMPは、指定したkeyの(任意の型の)データをシリアライズして文字列として返してくれます。このとき、そのキーに設定されたTTL(有効期限)データは含まれません。
PTTLは、指定したkeyの有効期限(までの残り時間)をミリ秒単位で返してくれます。
RESTOREは、keyとTTLデータとDUMPデータから値を復元してくれます。

ということはすべてのキーについてDUMPとPTTLをコールして、返り値をRESTOREすると、バックアップ&リストアできます。
ただ、この方法を愚直にやると通信が沢山発生してしまいます。

# 疑似コード
client = RedisClient.new
keys = client.keys('*')
backup_data = []
keys.each do |key|
  dump = client.dump(key)
  ttlms = client.pttl(key)
  backup_data.push([key, ttlms, dump])
end
backup_data.each do |payload|
  redis.restore(payload[0], payload[1] <= 0 ? 0 : payload[1], payload[2])
end

MULTI

いちいち通信発生させたくない人向けに MULTI というコマンドが用意されています。

MULTIの挙動についてはこちらの記事の図解が大変にわかりやすいのでご参照ください。

christina04.hatenablog.com

(対話的でなく)一方的に沢山コマンドを送りつけるようなユースケースでは、MULTIが便利です。
本件においても MULTI でやってしまうのがよいでしょう。

# 疑似コード
client = RedisClient.new
keys = client.keys('*')
backup_data = []
redis.multi do
  keys.each do |key|
    dump = client.dump(key)
    ttlms = client.pttl(key)
    backup_data.push([key, ttlms, dump])
  end
end
redis.multi do
  backup_data.each do |payload|
    redis.restore(payload[0], payload[1] <= 0 ? 0 : payload[1], payload[2])
  end
end

が、当初私が勘違いで「MULTIはコマンドを打つたびに通信が発生する」と思い込んでいたため、もう1通りソリューションを考えていて、捨てるのももったいないので共有します。

EVAL

EVAL という、任意のluaスクリプトを実行できるコマンドがあります。

これを利用して、ダンプ用のコマンド1コール、リストア用のコマンド1コールで行ってしまうコードが以下です。

import Log from '../middlewares/Log';
const Redis = require('ioredis');

class RedisService {
  // https://stackoverflow.com/questions/34618946/lua-base64-encode から借用
  static Base64LuaScript: string = `
    local b='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
    local function enc(data)
        return ((data:gsub('.', function(x)
            local r,b='',x:byte();
            for i=8,1,-1 do r=r..(b%2^i-b%2^(i-1)>0 and '1' or '0') end
            return r;
        end)..'0000'):gsub('%d%d%d?%d?%d?%d?', function(x)
            if (#x < 6) then return '' end
            local c=0;
            for i=1,6 do c=c+(x:sub(i,i)=='1' and 2^(6-i) or 0) end
            return b:sub(c+1,c+1);
        end)..({ '', '==', '=' })[#data%3+1]);
    end

    local function dec(data)
        data = string.gsub(data, '[^'..b..'=]', '');
        return (data:gsub('.', function(x)
            if (x == '=') then return '' end
            local r,f='',(b:find(x)-1);
            for i=6,1,-1 do r=r..(f%2^i-f%2^(i-1)>0 and '1' or '0') end
            return r;
        end):gsub('%d%d%d?%d?%d?%d?%d?%d?', function(x)
            if (#x ~= 8) then return '' end;
            local c=0;
            for i=1,8 do c=c+(x:sub(i,i)=='1' and 2^(8-i) or 0) end
            return string.char(c);
        end));
    end;
  `;

  public static async getAllDump(redisUrl: string): Promise<string> {
    const redis = Redis(redisUrl);
    try {
      const backupLuaScript = `
      ${this.Base64LuaScript}
      local keys = redis.call('keys', '*');
      local ret = {};
      for i, key in ipairs(keys) do
        ret[i] = { enc(key), redis.call('PTTL', key), enc(redis.call('DUMP', key))};
      end;
      return ret;
    `.replace(/\n/g, ' ');
      const dumpData: [string, number, string][] = await redis.eval(backupLuaScript, 0);
      return dumpData.map((r) => r.map(String).join(',')).join('#');
    } catch (e) {
      Log.error(e.message);
      throw e;
    } finally {
      redis.disconnect();
    }
  }

  public static async restoreDump(redisUrl: string, dumpStr): Promise<string> {
    const redis = Redis(redisUrl);

    try {
      const restoreLuaScript = `
    ${this.Base64LuaScript}
      local input_str = "${dumpStr}";
      local function split_string(str, delimiter)
          local result = {};
          for match in (str..delimiter):gmatch("(.-)"..delimiter) do
              table.insert(result, match);
          end
          return result;
      end

      local lines = split_string(input_str, "#");

      for i, line in ipairs(lines) do
          local elements = split_string(line, ",")
          if #elements ~= 3 then
              return redis.error_reply("3要素以上ある行があります");
          end

          if tonumber(elements[2]) <= 0 then
              elements[2] = 0;
          end

          redis.call("RESTORE", dec(elements[1]), elements[2], dec(elements[3]))
      end

      return "OK"
    `.replace(/\n/g, ' ');
      return (await redis.eval(restoreLuaScript, 0)) as string;
    } catch (e) {
      Log.error(e.message);
      throw e;
    } finally {
      redis.disconnect();
    }
  }
}

const redisUrl = 'rediss://~~';
const targetRedisUrl = 'rediss://~~';
const dumpData = await RedisService.getAllDump(redisUrl);
await RedisService.restoreDump(targetRedisUrl, dumpData);

コードの内容を日本語で説明するなら、
luaでkeyをすべて取得しそれぞれについて [keyN, pttl, dump] を取得。 key1,pttl,dump#key2,pttl,dump#key3,pttl,dump... の独自形式で保管。それをリストア用luaスクリプトにそのまま投げて、luaでパースし各個RESTOREを行う。このとき、独自形式はコンマとシャープ記号で区切っているので、コンマとシャープ記号が入りうるkeyとdumpについてはエスケープのためBase64変換して扱う。
というものです。

MULTIを使う場合は3コマンド(keys + dump + restore)になりますが、この方法だと2コマンドで行けて便利と言うことができるかもしれません。
もう一つメリットを強いて挙げるなら、クライアントサイドでの処理は文字列のやり取りぐらいで済み、キーごとにループを回すような処理をRedisサーバー側に押し付けることができるというのがあります。

単純なGETのケースで調べてみると

# multi version
redis.multi do |pipe|
  100000.times do |i|
    pipe.get('somekey')
  end
end

# eval version
script = <<~LUA
  local ret = {}
  for i = 1, 100000 do
    redis.call('GET', 'somekey')
  end
  return ret
LUA

redis.eval(script)

通信回数はあまり変わりませんが、送信するデータ量を考えると、また実行速度を見てみると後者のほうが優れていますし、この差はキーが増えるごとに差が開くことになります。 とはいえコードの保守性の観点からは前者のほうが遥かに優れていると言えるはずです。

おわりに

MULTIで充分ということに記事を書き始めてから気づいて萎えてしまったので雑な記事になってしまいましたが以上です。
システム開発はロジックを考えるパートやコードを書くパートよりも、周辺環境やドメインについてよく調べよく知ることが重要です。

axiosのエラーをカスタマイズして数秒を節約する

プロダクト開発部新田です。

取り立てて困っているわけではないし、解消したとて1回あたり数秒しか改善しないが遭遇頻度を考えるとまあやってもよいのではないか、程度の改善を紹介します。

改善したいこと

開発環境におけるaxiosのエラー画面にもう少し情報を載せたい。

react-refreshを使っていると、開発環境でフロントエンドでErrorがthrowされた際にこういった画面が表示されます。
エラーに気づけて便利ではありますが、404エラーが発生していることだけがわかり、一方でどのエンドポイントで404になっているかまでは一見してわかりません。

1画面で複数のAPIリクエストが飛んでいるような状況だと特にどのAPIについてのエラー直感で絞りきれない場合があります。
このとき、従来ChromeのNetworkタブを見に行くかバックエンドのログを見に行くことで特定していますが、ちょっと煩わしく思えてきたのでエラー画面にどのエンドポイントが404なのか教えてくれるようにしたいなあと思った次第です。

解決策

エラー画面のカスタマイズはreact-refresh自体に手を入れなければならなそうなので、 Errorの方に手を入れてみます。

ErrorもReactならば ErrorBoundary でキャッチする方法もありますが、今回は一般のエラーについて触る必要はなくaxiosのエラーについてだけ手を入れたいので、axiosのオプションで実現できそうです。

調べてみると、 interceptor という仕組みを利用することで目的を達成できました。

if (process.env.NODE_ENV === 'development') {
  axios.interceptors.response.use(
    (response) => response,
    (error) => {
      if (
        isAxiosError(error)
      ) {
        const newError = new AxiosError(
          `${error.message}: ${error.config.url}`,
          error.code,
          error.config,
          error.request,
          error.response
        );
        return Promise.reject(newError);
      }
      return Promise.reject(error);
    }
  );
}

axiosがレスポンスを受け取ってErrorが発生した際、そのAxiosErrorの内容を受け継ぎつつmessageに error.config.url を追加して新しいErrorに変えて再度投げるという内容のコードをどこかに書いてしまえばこうなります↓

数秒の改善でも積もり積もれば大きな時間になるはずなので。

farmer-motionでログイン失敗時にわかりやすくフィードバックする

こんにちは。プロダクト開発部新田です。
今回は小さなスニペットの紹介です。

弊社ではフロントエンドにReact + Typescriptを使用しており、
CSS-in-JSのフレームワークとしてChakra UIを採用することが非常に多いです。
ので以下のコードスニペットは上記の技術構成を前提にしたものです。

ログイン失敗したときにわかりやすくログイン拒否されたことを通知する

ログインエラーをユーザーに通知する典型的な方法いくつか挙げると、

window.alert

単に window.alert でエラーを表示する方法は実装がかなり容易ですが、荒削りな印象を与えてしまうと思います。

ログインボタン付近に目立つ文字で「ログインエラー」を表示する

この方法は一般的ですが、二度三度ログイン失敗したときのためににすこし気をつけて実装する必要があります。

toast でエラーを表示する

Toast - Chakra UI

toast は Androidとかマテリアルデザイン文脈由来のような気がしますが[要出典]、
ポップアップでメッセージを出せて、時間が経つことで/能動的にクリックすることで消えるので便利なシーンが多い印象です。  

今回提案する方法

framer-motion でログインボタンを揺らす、という方法とその実装を提案します。
(「首をふる動作に似ているため直感的にエラーがあったことがわかり〜」など、見た目に面白い以上の意味づけをしてもいいんですが、本当の理由は見た目に面白いからです。)

Web上でのアニメーションに慣れていない場合、実装が面倒そうで忌避してしまいがちですが、framer-motionを使うと簡単に実装ができます。

import { chakra } from '@chakra-ui/react';
import { useToast } from '@chakra-ui/toast';
import axios from 'axios';
import { motion, useAnimation } from 'framer-motion';
import React, { useState } from 'react';

export function LoginPageContent() {
  const [isRequesting, setIsRequesting] = useState(false);
  const [formState, setFormState] = useState<LoginFormState>(initState);
  const controls = useAnimation();
  const shake = {
    x: [0, -10, 10, -10, 10, 0],
    transition: { duration: 0.2 },
  };
  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setIsRequesting(true);
    axios
      .post('/api/sessions/login', formState)
      .then(() => {
        // ログインできた時の処理
      })
      .catch((e) => {
        console.error(e);
        controls.start(shake);
      })
      .finally(() => {
        setIsRequesting(false);
      });
  };
  return (
    <chakra.div paddingTop="32px" w="100%">
      {isRequesting && <LoadingCover />}
      <chakra.div
        bgColor="white"
        borderRadius="8px"
        boxShadow="0px 1px 2px 0px rgba(0, 0, 0, 0.06), 0px 1px 3px 0px rgba(0, 0, 0, 0.10);"
        margin="auto"
        maxW="90vw"
        padding="24px"
        w="750px"
      >
        <chakra.h2 color="asBlack" fontSize="24px" fontWeight="bold" textAlign="center">
          ログイン画面
        </chakra.h2>
        <chakra.div h="32px" />
        <chakra.form margin="auto" maxW="80%" w="300px" onSubmit={onSubmit}>
          <input id="ログインID" />
          <input id="パスワード" />
          <chakra.div
            animate={controls}
            as={motion.div}
            display="flex"
            justifyContent="center"
          >
            <RadiusButton kind="blue" type="submit">
              ログイン
            </RadiusButton>
          </chakra.div>
        </chakra.form>
      </chakra.div>
    </chakra.div>
  );
}

こうです。

要は

  const controls = useAnimation();
  const shake = {
    x: [0, -10, 10, -10, 10, 0],
    transition: { duration: 0.2 },
  };

          <chakra.div
            animate={controls}
            as={motion.div}
          >

で実装できてしまう、という話でした。

おわりに

エンジニアは正常系に集中してプロダクト開発しがちですが、
実際に計測をしてみると(特にパスワード管理ツールを使っている人には信じがたいほど)ログイン失敗は頻繁に起こっており、
そういった異常系の体験にも気を配るとちょっと熟れたプロダクトに見えるかなと思います。

rubyXLでエクセル上の画像を差し替える

プロダクト開発部新田です。
エンジニアはみなxlsxよりもcsvが好きですが、現実にはシステムでエクセルファイルを出力するという実装をする必要に迫られることがあります。
またそのエクセルファイルに画像を貼る必要があることもあります。

rubyでエクセルに画像を貼りたくて苦心したので、今後同じように困る誰かの一助になることを願って、rubyXLでエクセルファイルの画像を差し替える方法について書きます。

今回やりたいこと

この画像のようなエクセルファイルをシステムから出力する必要があります。

なお、名前と電子印影はユーザーごとに変える必要があります。

また、実際に生成するエクセルファイルは複雑なレイアウトであり、
プログラムでスクラッチからレイアウトを再現するのはとてもつらく、人の手によって作られたエクセルファイルをテンプレートとしてその中の一部を書き換える方法で実現するのがよさそうな状況です。

参考情報・先行研究

qiita.com

エクセルをRubyで扱う場合に、要件に合わせてどのライブラリを選べばよいかについてまとめられています。
画像を持ったxlsxを作るには caxlsx が良さそうですが、caxlsxはエクセルファイルを読み込んで編集することはできず、新規に作る場合に画像を載せることが得意なライブラリであって、今回の要件に合わず。
一方でrubyXLはエクセルファイルを読み込んで内容を変更することができますが、画像に関するサポートはありません

qiita.com

rubyXLで一般に使われるものより低レベルなAPIを用いて画像を貼り付ける方法について書いてあります。
コピペすると確かにワークしますが、これを保守し続けられるかというと疑問符がつき、採用を見送りました。(たとえば、ランタイムの都合でrubyXLのメジャーバージョンを上げなくてはならなくなり、一部のAPIが提供されなくなる等起きた場合を想定して; rubyXLはバージョンごとにAPIがごりっと変わることがあります)

どう解決したか

.xlsx ファイルは、xmlとアセットを持ったzip形式のファイルです。
(このことは、 unzipコマンドで.xlsxを解凍することで確かめられます; mac標準の解凍では何故か失敗するのでunzipを使います)

unzipして見てみると、エクセル上に貼り付けた画像がそのままのファイル形式で保存されている事がわかります。
では、この画像ファイルを差し替えて再度zipしてみると画像の差し替えができるのでは?と思いやってみたらできました。

これをRubyでどうやるかというと、プレースホルダーとして適当なpngファイルを貼りつけたエクセルファイルを用意して、

  require 'rubyXL'
  require 'zip'

  workbook = RubyXL::Parser.parse(Rails.root.join('path', 'to', 'the_file.xlsx').to_s)
  tempfile = Tempfile.new(['filename', '.xlsx'])
  # テキストを書き換える等の処理をここで行う
  workbook.write(tempfile.path)

  Zip::File.open(tempfile.path) do |zipfile|
    # 画像は xl/media/* に格納されている。名前は image1 等エクセルによってリネームされるので(決め打ちでも構わないが)globで探す。
    zipfile.glob('xl/media/*.png').each do |entry|
      zipfile.get_output_stream(entry.name) do |f|
        f.write user_info.stamp_image.file.read
      end
    end
  end
  record.file_column = tempfile # carrierwaveでmount_uploaderしているカラム
  record.save!
  tempfile.close!

こうです。 方法はややハッキーですがコードとしてはシンプルで(直接的に外部コマンドを呼んだりしていないので)ギリギリrubyの世界から逸脱せずにすみました。

おわりに

書いてみてよく考えたら「rubyXLで」というより「rubyzipで」という内容でした。
rubyXLを使っていても画像を扱う方法はありますよ、ということでご容赦ください。

今回紹介した方法はユースケースがかなり限定的なのでより柔軟に(画像サイズを変更したいとか、画像の枚数を増やしたり減らしたりだとか)操作したい場合は、 Pythonに便利なライブラリがあるので、この手の操作はRuby固執せずにPythonで書いてLambda等にそれ用のファンクションを用意してしまうのが良いかもしれません。

HerokuのOne-off dynoを踏み台サーバーとしてデータベースのダンプを取得する

こんにちは。プロダクト開発部の新田です。
AGRI SMILEで、複数プロダクトをフロントエンド・バックエンドに亘って開発する業務をしています。

AGRI SMILEでのHerokuのユースケース

AGRI SMILEでは、オンラインで学術集会を開催できるプラットフォーム、ONLINE CONFを提供しています。
このシステムはHerokuを利用してホスティングされています。
なぜAWSGCP等のクラウドインフラでなくPaaSであるHerokuを利用しているかというと、WEBアプリケーションエンジニアであっても容易にインフラをコントロールできるのが一つの理由ですが、なにより学術集会の性質との親和性が高いからです。
具体的には、学術集会はシステムの観点から見ると、

  • 半年から1年程度の間、準備のために定常的に低頻度のアクセスが発生する
  • 開催当日は一気に同時アクセス数がスパイクする
  • スパイクのタイミングは予測でき、その際の負荷の上限も(参加者数などからある程度)事前に予測できる
  • イベント(や後日配信等)が終了すると、システムが利用されなくなる

という特性があります。
Herokuは、WebUIまたはコマンドラインで簡単に立ち上げ・スケールさせ・クローズさせることができ、学術集会のこれらの性質とかなりマッチしていると考えています。

また別プロダクトでは Salesforceとのデータ連携に Heroku Connectを使っているところもあり、AGRI SMILEはHerokuをよく使っています。
そういう背景もあって、今回EC2やECSでなく踏み台サーバーとしてHerokuを使う話について書いてみます。

この記事で説明することと背景

Heroku の One-off dyno を使って us-east にある RDS から速やかにダンプを取得し、それを Tailscale を通じてローカルに転送する手順を説明します。

インフラとして完全にHerokuを利用していれば heroku pg:backups:download -a <app_name>Heroku Postgres からダンプファイルを得られますが、
弊社では一部でAWS RDSやAurora を利用しており、この便利機能が使えません。

そこで、通常はローカルにダンプファイルが欲しい場合、

$ pg_dump -d postgres://user:pass@xxx.com:5432/xx  -F custom -f latest.dump -v

を実行して直接DBからダンプを取得することがあります。

ただ、(Herokuはサーバーの物理的な所在地がアメリカ、というか us-east にある関係で)AWSのDBもアメリカのリージョンのものを調達しています。
pg_dump は頻繁に通信を行うため、通信距離が大きいとバックアップの取得時間も長くなってしまいます。

ステージング用のディスク使用量数MB程度の小さなDBを使って実際に計測してみると、Heroku One-off dynoでバックアップを取得すると1秒、手元のマシンでバックアップを取得すると1分40秒かかりました。
(time コマンドを使って数回計測。 手元のマシンはM2 MBP, Herokuのdynoはstandard 1X で、マシン性能の差ではないことを念のため付記しておきます。)

より大きなDBではこの差が更に大きくなり、Heroku dyno上のダンプファイルをローカルに転送する時間を考えてもお釣りがくるようになります。

しかしHeroku dynoは通常scpできませんから、dyno上のファイルをローカルに持ってくるには工夫が必要です。
方法はいくつか考えられ、 file.ioやtransfer.shを利用して一度外部サーバーにアップロードしてダウンロードするのが最も手軽ですがDBのダンプを他所に預けるのはセキュリティ上の問題があり、S3にアップロードするのは面倒です。
そこで、Tailscaleをインストールした踏み台dynoをHerokuに用意しておいて、そこでダンプを取ってローカルとTailscale経由で通信してファイルを持ってくる方法を今回紹介します。

TailscaleがインストールされたHerokuアプリケーションを用意する

アプリケーションのセットアップ

適当な Herokuアプリケーションを新規に登録します。
Heroku には環境一式をインストールできるbuildpackという便利な仕組みがあり、 有志がTailscaleが使えるbuildpackを作って提供してくれているのでこちらを利用します。

github.com

$ heroku buildpacks:set https://github.com/aspiredu/heroku-tailscale-buildpack -a <app_name>

buildpack は次回デプロイ時に反映されるので、適当なProcfileを作ってgitリポジトリを作り、アプリケーションとしてデプロイします。

# Procfile
web: echo a

今回はOne-off dynoを利用したいだけで、webサーバーとしての働きを期待しないのでProcfileは適当でよいです。
また、リポジトリの中身もProcfile以外なにも置かなくて良いです。

認証キーの設定

先程のbuildpack は環境変数 TAILSCALE_AUTH_KEY にtailscaleの認証キーを要求します。

認証キーの取得方法はこちらのStep1の記載の通りです。

tailscale.com

tailscale 認証キー発行モーダル

キーが取得できたら、Heroku アプリケーションの環境変数にセットします。 heroku config:set TAILSCALE_AUTH_KEY=<キー> -a <app_name>

反映を待って heroku run /bin/bash -a <app_name> で正しく設定できてそうかチェックします。

-----> Skipping Tailscale

と出たら設定が完了していません。

-----> Starting Tailscale

に続いて標準出力がやかましくなったら設定完了です。
このとき、Tailscaleのダッシュボード上でもオンラインのデバイスが増えていることが確認できます。

tailscale devices ダッシュボード

dynoからローカルにファイルを送る

pg_dump 等で目的のファイルをdyno上に生成したら、Taildrop という機能でリモートからローカルにファイルを送ります。

tailscale.com

$ tailscale file cp ./latest.dump <ローカルマシン名>:

すると、(私の環境では)~/Downloads ディレクトリに latest.dump が送り込まれてきます。

まとめ

  • pg_dump は通信速度の影響を強く受けるらしい
  • Herokuは一時的な踏み台サーバーとしてとても便利
  • Tailscale はHerokuのdynoと通信するのにも使えて便利だしセキュア