patorashのブログ

方向性はまだない

sidekiqとactiverecord-session_storeを使っている場合はWeb UIが使えない

バージョン

何が起きたのか

ActiveJobのアダプターにresqueを使っていたプロジェクトで、アダプターをresqueに変えようとしていました。そこで、諸所の設定を修正後、ActiveJobにキューイングしても、うんともすんとも言わない…。sidekiqは起動しているにも関わらず、全く何も起きない…。

調査開始

qiitaの記事を見かけました。

qiita.com

これによると、rack 2.0.7から2.0.8になると、セッション関係でエラーが起きるようになるとか…。そして、2.0.9で直った、とあります。

うちのrackのバージョンは…2.2.3。あれ?

セッションを無効にしてみる

なにはともあれ、上記の記事を参考にセッションを無効にしてみます。

require 'sidekiq/web'
Sidekiq::Web.set :sessions, false # <= セッションを無効化

Rails.application.routes.draw do
  # 他は省略
  mount Sidekiq::Web => '/sidekiq' if Rails.env.development?
end 

すると、今までキューイングされていたジョブが一斉に処理され始めました…。ActiveJobのキューの実行がSidekiq::Webのセッションに依存してるのか??? しかも別のプロジェクトではSidekiq::Webを見ても普通に見れましたし、その際にセッションを無効にする必要などありませんでした。

activerecord-session_storeのPRを発見

セッションを保存している場所に問題があるかと思い、検索キーワードを変更してみたところ、こういうPull Requestを発見。

github.com

まだOpenのままで取り込まれていない模様…。issueを見るとactiverecord-session_storeとrackのバージョンのものがチラホラと…。あまり開発が進んでいないのでしょうか?

対応

とりあえず、Sidekiq::Webさえ使わなければActiveJob自体はちゃんと動いたので、Web UIをひとまず諦めることとしました。

sidekiqでDBへのコネクションプールを使い切らないようにする

ActiveJobに大量に仕事を依頼するようにしたら、以下のようなログが出るようになりました。

could not obtain a database connection within 5.000 seconds

早速調査。

ActiveJobでsidekiqを使う場合、connection_poolの値はconcurrency + 1以上にしよう - repl.info

つまりは、ActiveJobがDBへのコネクションプールの数以上に起動して、コネクションプールが空くのを待っている間にタイムアウトしてしまう、というやつです。 そして、sidekiq起動時にDBに接続を試みるので、コネクションプールは並列数+1にしましょうということでした。

ActiveJobは、アダプタにsidekiqを使っています。

修正前

特にパラメータの指定もなく、以下のように起動。

bundle exec sidekiq

問題点

デフォルトだと、sidekiqの並列数(concurrency)は10です。しかし、DBへのコネクションプールの設定はというと…。database.ymlを見てみると…。

default: &default
  adapter: postgresql
  encoding: unicode
  username: <%= ENV.fetch("DOCKER_POSTGRES_USER") { "postgres" } %>
  port: <%= ENV.fetch("DOCKER_POSTGRES_PORT") { 5432 } %>
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> # <= ここ!
  host: localhost

development:
  <<: *default
  database: example_development

test:
  <<: *default
  database: example_test

production:
  <<: *default
  database: example_production

特に指定がなければ5になっています。並列数10に対してコネクションプールが5なのでそりゃ落ちますわ…。

修正していく

ということで、先の参照先のリンクの情報を元に、並列数が10の場合はコネクションプールを11にすればOK。

env RAILS_MAX_THREADS=11 bundle exec sidekiq -c 10

これで大量にActiveJobでキューイングしてみたところ、問題なく全ての処理が終了することを確認。

Herokuに対応する

本番環境はHerokuなので、herokuの記事も参考にしました。

devcenter.heroku.com

環境変数 SIDEKIQ_RAILS_MAX_THREADSSIDEKIQ_CONCURRENCY を定義して、それで調整します。

接続数の上限を意識する

heroku-postgresのstandard-0を使っている場合は、最大接続数は100なので、結構大きなアプリケーションでなければ使い切ることはありません。しかし、hobby-devやhobby-basicの場合の最大接続数は20です。

今のdatabase.ymlのままだと、WebDynoはpumaのworker毎に5の接続数を使うので、worker数を2と想定した場合、残りの接続数は10。 じゃあWorkerDynoのsidekiqは残りの10を使っていいのか?というと、heroku run rails consoleで接続することもあるし、heroku schedulerを使うこともあり得るので、3〜4接続数くらいは残しておかないと詰みます。

そのため、sidekiqの並列数は5として、コネクションプールは6とします。そうすると、接続数が4つ残る計算に。

worker: RAILS_MAX_THREADS=${SIDEKIQ_RAILS_MAX_THREADS:-6} bundle exec sidekiq -c ${SIDEKIQ_CONCURRENCY:-5}

これで、herokuに作ったステージング環境で動作検証したところ、並列数を5に制御しているため、エラー起きずにちゃんと処理が終わりました。 接続数とコネクションプールの関係を考えると、デフォルトにしておくのは危険なので設定しておきましょう!

ActiveJobのコールバックは2系統に分かれている

ActiveJobの処理が時々コケることがあったので調査した。

Active Job の基礎 - Railsガイド を参照したところ、ActiveJobのコールバックは以下のようになる。

  • before_enqueue
  • around_enqueue
  • after_enqueue
  • before_perform
  • around_perform
  • after_perform

問題のあったコード

そして、元々のコードはこういう感じのことをやっていた。TaskLogモデルがあったとする。

  1. キューに入った時点でTaskLogモデルを作成する
  2. TaskJob実行直前に、ジョブIDからタスクログを取得してステータスを処理中に変更
  3. TaskJobが終了したら、ジョブIDからタスクログを取得してステータスを終了に変更
class TaskJob < ApplicationJob
  
  after_enqueue do |job|
    TaskLog.create!(job_id: job.id, status: :queued)
  end

  before_perform do |job|
    task = TaskLog.find_by! job_id: job.id
    task.status_processing!
  end

  after_perform do |job|
    task = TaskLog.find_by! job_id: job.id
    task.status_finished!
  end

  def perform
    # do something...
  end

end

しかし、いざ実行すると、たまに失敗するときがあった。before_performのTaskLog.find_by!で落ちていた。ここで落ちるということは、TaskLogが作られていないということだ。

実験

after_enqueueが終わる前にbefore_performが実行されることがある、ということだと判断したので、実験用のコードを書いた。

class TestJob < ApplicationJob

  after_enqueue do |job|
    puts "enqueued TestJob. sleep 10..."
    sleep 10
    puts "enqueued TestJob. end"
  end

  before_perform do |job|
    puts "before_perform TestJob. sleep 2"
    sleep 2
    puts "before_perform TestJob. end"
  end

  after_perform do |job|
    puts "after_perform TestJob."
  end

  def perform
    puts "perform!!!"
  end
end

これをrails cで実行した。sleepしているので10秒後にenquequd TestJob. endと出ます。

pry(main) > TestJob.perform_later
enqueued TestJob. sleep 10...
enqueued TestJob. end
Enqueued TestJob (Job ID: 0234b71e-aa4a-4644-b86e-dcf67b40d550) to Sidekiq(default)
=> #<TestJob:0x00007ff599493630
 @arguments=[],
 @executions=0,
 @job_id="0234b71e-aa4a-4644-b86e-dcf67b40d550",
 @priority=nil,
 @provider_job_id="3f7bda026dc435a015d6f81c",
 @queue_name="default">

その頃、sidekiqを実行中のターミナルでは…

2020-10-21T01:36:32.006Z 51089 TID-our2bxzcp TestJob JID-f427b7a69e619b9e45c68371 INFO: start
before_perform TestJob. sleep 2
before_perform TestJob. end
perform!!!
after_perform TestJob.
2020-10-21T01:36:34.027Z 51089 TID-our2bxzcp TestJob JID-f427b7a69e619b9e45c68371 INFO: done: 2.021 sec

早々にbefore_performが実行され、performも呼ばれ、after_enqueueが終わる前に処理が終わった。

ということは、ActiveJobのコールバックのenqueue系とperform系は完全に別々の処理と考える必要がある。

直したコード

before_enqueueでTaskLogモデルを作るのをやめればいい。

class TaskJob < ApplicationJob
  
  before_perform do |job|
    TaskLog.create!(job_id: job.id, status: :processing)
  end

  after_perform do |job|
    task = TaskLog.find_by! job_id: job.id
    task.status_finished!
  end

  def perform
    # do something...
  end

end

まとめ

enqueue系のコールバックは、使うにしても「予約しました」とか、そういう通知系をやるくらいに留めておく。perform系の処理で参照したいような前処理をenqueue系でやるべきではない!

Elasticsearchを5.6から7.9にバージョンアップ

過去にElasticsearchのバージョンアップをしたのだけれど、そのバージョンも既にEOLとなり、早く最新のバージョンにしたいと思いつつも、なかなかできずにいた。 ようやくできるタイミングがきたので、取り組んでみた。まだステージング環境では検証が終わっていないけれど、CIが通ったので修正点を一旦まとめる。

やったこと

Elasticsearch 7.9.1のDockerイメージを作成

kuromojiプラグインを入れたDockerイメージをDockerHubに作った。

https://hub.docker.com/repository/docker/patorash/elasticsearch-kuromoji

docker-compose.ymlを修正

Elasticsearchの公式のページにあるdocker-composeの設定を参考にした。

www.elastic.co

しかし、CircleCIでテストをする際にdocker-composeで行っているのだが、その際にElasticsearchのコンテナが起動しなかった。 起動しなかったときのエラーはこれ。

elasticsearch_1 | ERROR: [1] bootstrap checks failed elasticsearch_1 | [1]: max virtual memory areas vm.max_map_count [65530] is too low, increase to at least [262144]

max_map_countの値が低すぎるから増やせという話だけれど、CircleCIのVMカーネルの値は変更できないということだった。

Running Elasticsearch 5 - Build Environment - CircleCI Discuss

このフォーラムで紹介されていた設定を追加した。environmentのところの"transport.host=localhost""bootstrap.system_call_filter=false"だ。 これで、CircleCIのdocker-composeでElasticsearchが起動するようになった。

version: '3.3'

services:
  elasticsearch:
    image: patorash/elasticsearch-kuromoji:7.9.1
    expose:
      - "9200"
      - "9300"
    volumes:
      - elasticsearch_data:/usr/share/elasticsearch/data
    environment:
      - node.name=es01
      - cluster.name=es-docker-cluster
      - cluster.initial_master_nodes=es01
      - bootstrap.memory_lock=true
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
      - "TZ=/usr/share/zoneinfo/Asia/Tokyo"
      - "transport.host=localhost"
      - "bootstrap.system_call_filter=false"
    ulimits:
      memlock:
        soft: -1
        hard: -1
      nofile:
        soft: 65536
        hard: 65536
    healthcheck:
      test: ["CMD", "curl --silent --fail localhost:9200/_cluster/health || exit 1"]
      interval: 60s
      timeout: 30s
      retries: 3

関連gemの更新

  • elasticsearch-rails
  • elasitcsearch-model

この2つを更新した。

コードの修正

クラウドワークスさんのところの記事を参考にした。

engineer.crowdworks.jp

ここに書かれていた通り、_allが使えなくなったので削除したのと、copy_toを使うようにした。copy_toは6系で追加されたらしい。1つのキーワード検索用のフィールドを作り、そこに対して各フィールドをcopy_toで集める。今までは検索対象のフィールドをいくつも指定していたため、効率が悪かったけれど、これを使うことで1つのフィールドだけを検索対象とできるので効率がよくなるそう。

あとは、ngramの設定を変更した。以前はmin_gramを1に、max_gramを20に指定していたのだが、起動時にエラーとなった。max_ngram_diffを19に設定する必要があった(デフォルトで1のため)。

www.elastic.co

これで大体のテストは通ったのだが、ソートのテストがうまくいかなかった。

ソート順がElasticsearchへの指定順にならない

なぜかソート順がおかしいのでgemのコードを調べたところ、Elasticsearchから取得したデータをActiveRecordにするところでorderが定義されているとElasticsearchのソート条件よりもActiveRecordのソート条件のほうが優先されるように変更されていた…。

github.com

default_scopeでorderを定義していたため、elasticsearch-railsrecordsメソッドを呼んだ時点でもうダメだった。default_scopeでのorderを止めるのは変更点が多すぎて厳しいので、elasticsearch-railsから呼ばれた場合のみ、default_scopeを無効にしたいと考え、caller_locationsを使って対処した。

caller_locationsは処理が呼び出された場所を取得するので、そのパス情報の中にelasticsearch/model/adapters/active_record.rbが含まれていたらorderを無視するようにした。ただ毎回この処理が実行されるわけなので、オーバーヘッドがどれくらいになるのかが気になる…が、今はこれで。これで、Elasticsearchのソート順でデータが取れるようになった。

default_scope do
  paths = caller_locations.map(&:path)
  order(name: :asc) unless paths.any? { |path| path.end_with?('elasticsearch/model/adapters/active_record.rb') }
end

とりあえず、これでCIのテストが通るようになった👍

5にバージョンアップしたときと比べると比較的楽にバージョンアップできそう。

タブレット型Chromebookは実質Androidタブレットだった

先日、ふとChromebookが気になり始めてググっていたら、LenovoChromebookSurfaceみたいにタブレットにもなるタイプのやつを売っているのを見つけました。タブレットにもなるし、普通にキーボードも付いてくるのに4万円ちょいだったので、ボーナスが入ってから特に何も買ってなかったので思い切って買ってみました!(ここまで書いて、ボーナスが入ったから在宅勤務多いのでエアコンを買ったのを思い出した…)

買った機種は、Lenovo IdeaPad Duet Chromebook です。

www.lenovo.com

注文して3日程度で届きました。やはり新しいPCが来るとワクワクします!😁

開封の儀

f:id:patorash:20200926131139j:plain

chromebookの文字があります。

f:id:patorash:20200926131330j:plain

開けたところ。使い方のパターンが印刷されていました。

f:id:patorash:20200926131415j:plain

色はアイスブルー+アイアングレー。でも普段は立てるためにカバーつけることになるので色はあまり意識しなさそう。

f:id:patorash:20200926131628j:plain

立たせたところです。こんな感じか。カッコいい。

f:id:patorash:20200926131817j:plain

付属品は、充電用のUSB Type Cと、USBをイヤフォンジャックに変換するケーブル。インターフェースがUSB Type Cの1つのみ。 これは外部ディスプレイとか繋げないのかな?と思ってググってみたら、繋げられるらしい!アダプター買おうかな!

blog.itokoichi.com

f:id:patorash:20200926131945j:plain

そして、いよいよ起動…。うーん、速い!さすがChromebook

設定していく

ログインはPIN CODEでできるように

ログインには、当たり前だけれどGoogleアカウントが必要なので、長いパスワードとか設定していると入力が大変…。しかし、1度やってしまえば、あとはPIN CODEの設定ができるので、ログインのために長いパスワードを入力しなくてよくなります。昔にChromebook使おうとしたときはこれが辛かったので進歩してる!

Androidアプリが使える

正直、これが購入する判断に至ったものなのですが、めっちゃ便利。タブレットKindle Fire HD 8を使っていたのですが、Kindle以外にも他の会社の電子書籍サービスを使っているので、それが全部1つの端末で読めるようになりました。Kindleebook japanもGoogle Booksも読めるぞー!

また、AuthyやLastPassも入れられるので、いろんなサービスへのログインも簡単。設定もサクサクと終わらせられました。

ディスプレイの解像度を変更

初期表示だと文字がかなり大きいので、解像度を1440x900にしました。それ以上にすると、ちょっと文字が小さすぎて読みづらい…。

レビュー

半日くらいしか使っていませんが、とりあえずレビューします。

軽い

キーボードとかスタンドがあるとまぁまぁな重量がするけれど(それでも1kgは切る)、本体だけにすると450gらしいのでかなり軽いです。読書するには軽いは正義!

動画が綺麗

Chromebookなので、YouTubeは当然として、AndroidアプリでHuluやNetflixも入れられます。私はHuluを契約しているので、Google Play StoreからHuluをインストール。 10インチながら、1920x1200のWUXGAなので、フルハイビジョンで見ることができます。

縦持ちできるのがGood

キーボードを切り離したら縦持ちできるので、大きな本を読むのに便利です。また、横持ちならば漫画を見開きで見ても見やすいサイズなのでいいですね。

動作はキビキビ

CPUは8コアあるからか、まったくモッサリという印象はないです。また、ファンレスなので静か。

キーボードがやや打ちづらい

10インチに合わせるために、ホームポジション付近のよく使うキーに関してはまぁまぁいいサイズになっているのですが、記号系のキーとエンターキーが小さいです。特にエンターキーが細長くて見た目は微妙…。でも押しにくいというほどでもない…。あと、Ctrlキーがあってほしいところに検索キーがあります。WindowsでいうCapslockキーの位置です。ですが、設定からキーバインドは柔軟に変えられるので、検索キーとCtrlキーを入れ替えておきました。

Linux(Beta)が付いてた

WSLみたいな感じなのか、Linuxをインストールすることができます。Androidアプリの開発とかで使えるらしい…。ただ、コピーペーストがうまくいかないみたいで今のところの印象だと使いづらい。調べてきたコマンドがコピペできんの辛い。

メモリが少ない(4GB)ので、開発のメインマシンにはならないけれど、VSCode Onlineとかが出てきているので、今後に期待できるかも。

総評

カスタマイズで遊ぶというよりは、エンタメ系とか、執筆とかに気軽に使える端末としてすごく優秀だと思う。いわゆるノートPC型のChromebookと違ってタブレットとして使えるのがいい!なんかよく巷で「最近Androidタブレットが出ない〜。iPadは出てるのに〜!」というのをtwitterとかで見かけるが、もしかしたら、Androidタブレットは諦めてChromebookに統合していくつもりではなかろうか?Androidタブレットを探している人は、IdeaPad Duetを候補にするのがいいと思う。

なお、私はLenovo Storeで購入したが、AmazonでもAmazon限定モデルで販売していた(後で気づいた…)。違いは、Lenovo Storeだとディスク容量が128GBで、Amazonのモデルだと64GBというところ。ただし、容量が少ない分、定価はAmazonのほうが4千円くらい安い。

ストレージにGoogle Driveを使うのであれば、本体の容量は64GBでも十分かなぁと思えるので、Amazonモデルでもいいと思う。安いし。そして今さらに気づいたのだが、Amazonのモデル、9/30までの期間限定で10%オフクーポン付いてた。4万円くらいなので、3万6千円で買えるらしい!グヌヌ…!この情報を最初に知っていれば!!

CircleCIで使う処理をShellScriptにしていっている

CircleCIで時々思わぬところでライブラリ系のキャッシュがされずにエラーになってしまうことがあった。 処理を速くするために複雑なキャッシュをするようになっていたので、それが原因である…。久々に自分で見てみても複雑だなぁ〜と思う。

patorash.hatenablog.com

もっとシンプルにしたいなぁと思っていたら、たまたま以下の記事を見かけた。

qiita.com

これには感銘を受けた。makeはイマイチよくわかっていないので今のところは採用しないが、.circleci/config.ymlで使っているcommand類はShellScriptにすることが可能だ。ShellScriptにすることで、ローカル環境で試しやすくなる。config.ymlに直書きだと、試しづらい。当たり前だけれどなんだか目から鱗だった。

遅くても必ず完走させるようにしたい

キャッシュの不備が原因でテストがコケるのは本当にダサい…。しかも何が原因でライブラリの更新漏れやキャッシュし忘れが発生するのかを探すのが大変になってくると本末転倒…。

それならばもうシンプルに毎度毎度、ちゃんとライブラリの更新処理とキャッシュ処理をするようにした。これでもう予期しないエラーにはならないはず!😀

ただし、このせいで6分くらい遅くなっている…😫

workspaceを渡すのが遅い

CircleCIでは、workspaceを保存して次のジョブに渡すことができる。これをすると、次のジョブで環境構築する手間が省けるので速…そうなのだが、この受け渡し処理が遅い…。dockerイメージを含めているせいで容量も大きいというのはあるのだが、3分以上かかる。受け取るのも1分近くかかる。

1コンテナで環境構築を行い、テストを流す際は4コンテナにそれを渡して処理させているので、料金的には多少安いはずなのだが、時間的には直列になるので頂けない。

コマンドのShellScript化

これはローカルで実験がしやすくなったのでよかった。

エラーを見逃してしまう

ただ、CircleCIのconfig.ymlではステップ毎に定義していたものを1つのShellScriptにまとめたので、エラーの検知がうまくいっていなかった。エラーが起きているのに次の処理に行ってしまい、最後の処理が成功しているので終了コードが0になり、CircleCI上で成功したことになってしまっていた。

エラー対策

ShellScriptでもtry-catchみたいなことができれば…と思い、調べてみたら、trap関数を使えばできることがわかった。

zuqqhi2.com

手元にあるシェルスクリプトコマンドブックにも書いてあった。とにかく、これを使えばtry-catch-finallyができる。 エラーが起きようが起きまいが、docker-compose downはさせたかったので、finally的にやるようにした。

docker_compose_down() {
  docker-compose down
}

catch_error() {
  printf "エラーが発生しました"
  exit 1
}

trap docker_compose_down EXIT
trap catch_error ERR

上記の定義してあるファイル(common.sh)を、全てのShellScriptで読み込むように修正した。

#!/bin/bash

SCRIPT_DIR=$(cd $(dirname $0); pwd)
. $SCRIPT_DIR/common.sh

一旦これで様子見する。

高速化するには?

スタディストさんの開発ブログにCircleCIの高速化に効きそうな記事があったので、これらについて今後試していきたい。

medium.com

medium.com

それにしてもやはりdocker-composeを使ってテストをするのは速くなりにくいようだ…。 machineだと、resource_class: smallが選択不可能なので、smallにして並列数を2倍に増やす作戦も失敗に終わったし、打ち手に限りがある。

1台でキャッシュ用ファイルを作る戦略だったが、そろそろ見直したほうがいいかもなぁ…と考えている。 まぁdocker-composeを使っているせいなので(キャッシュしたいファイルがdocker volumeにあるから引っ張り出すのが遅い)もうちょい、いい方法がないか考える。まぁ速度よりも安定性をまずは重視する。

tmuxに慣れようとしている最中

便利なツールにも慣れていきたいなと思って、最近まで放置していたものにも挑戦していこうと思い、tmuxに再チャレンジしている。再チャレンジというのは、昔に使おうとして面倒になって断念していたからだ。

tmuxはターミナルの多重接続ソフトウェアである。詳しくはWikipediaを。

tmux - Wikipedia

いいところは、接続を終了せずにデタッチという形でセッションを残しておけるところ。普通のターミナルだと、終了したらセッションも終了するので、続きから作業ができないが、tmuxなら会社のPCでセッションをデタッチしておいて、家に帰ってから家のPCでセッションをアタッチして続きを作業する、というような使い方できる(そんな使い方はまだしたことはないが)

あとは、複数ウィンドウを立ち上げたり、ペインを分けて1画面で複数使えるようにしたりできるのが便利。。。なのだが、それはiTermやWindows Terminalでもできたから、そのために慣れきれなくて断念してきていた。

しかし、SSHした先で複数ウィンドウを使ったりペインを分けたりできるのは便利だろうし、なによりデタッチ・アタッチを使いたい機会がいつかくるだろうという想いもあった。

私は習うより慣れろ派ではなくてひとまず知識をザックリ入れてから慣れたい派なので、tmuxの記事を探そうとした。そうしたら、Kindle Unlimitedで読めるtmuxの本があったので、それを読んだ。

ターミナルマルチプレクサ tmux 入門

ターミナルマルチプレクサ tmux 入門

知りたいことも一通り書いてあったし、ザックリと知識が入ってきたことで若干苦手意識が薄れた。まずウィンドウに慣れようと思って、ここ数日はiTermでタブを使わずにtmuxでウィンドウを作って作業していた。 本を読んで、ペインの使い方もなんとなくわかったので、とりあえず素直に分割して使うところまではやれている。幅の調整とかはまだ。 慣れないのが、コピーモード。まだ全然慣れないのだけれど、慣れたら便利そうなので、少しずつやっていく。

本はざっくりと知れるのに便利なのだが、なんだかんだでチートシートも欲しかったのでqiitaにあるものを参照させてもらった。

qiita.com

こちらにあるものから、.tmux.confを作って、マウス操作である程度できるようにした。

そして今はWindowsでブログを書いているのだが、tmuxの操作の検証のためにWindows Terminalを立ち上げているが、やはりCtrlキーはCapsLockキーの位置にあってほしいなと強く感じている…。あまりキーバインドは弄っていないのだが、いじったほうがいいかなぁと感じ始めている。でもどのキーがどれになるかわからなくなるので、キーボードのキーの上に上書きできるシールが欲しい…。

【追記】

Microsoft製のCtrl2CapというCtrlキーとCapsLockキーを入れ替えるソフトの存在を知ったのでインストールしといた。便利。

docs.microsoft.com

でもCtrlキーにCapsLockキーが割り当てられてないような…😅