k8sでローカル環境でイメージがあるのにpullエラーになるときはimagePullPolicyがデフォルトになっているかもしれない
今k8sとTiltを使っているのですが、結構な頻度で、再起動時にトラブルが起きます。あるPODがうまく起動しないのです。 イメージはありますが、ErrImgaePullになるのです。
ここからは実験をしていきます。
まず問題のPODのイメージを削除します。このイメージは、TiltのダミーのDeploymentの起動時にビルドされます。 なので、tiltを再起動しないで、起動しようとすると、pull errorみたいなやつになります。やってみましょう。
ErrImagePullでした。あれ、いつもとエラーが違う。いつもはImagePullBackoffみたいなやつだったような。。
ここで、ErrImagePullの意味を調べましょう。
これは勉強になります。ありがとうございます。
KubernetesがPod内のコンテナ用のイメージをプルしようとすると、うまくいかないことがあります。ErrImagePull ステータスは、 kubelet が Pod 内のコンテナを起動しようとしたが、Pod、デプロイ、または ReplicaSet マニフェストに指定されたイメージに何か問題があった場合に表示されます。
ではログを見てみます。
> kubectl logs hoge-pod Error from server (BadRequest): container "hoge" in pod "hoge-podl" is waiting to start: trying and failing to pull image
これはPod の起動時にイメージの pull (取得) に失敗していることを示しているそうです。まあ、イメージないですからね。
ImagePullBackOff は Kubernetes における Pod のステータスの一つで、コンテナイメージの pull (取得) に失敗し、再試行している状態 を示します
なるほど。これはGeminiさんの発言ですが、要するに再試行中の状態ってことですね。どっちにしてもイメージのpullに失敗したことが分かるわけですね。 さて、では、イメージをビルドしましょう。
私はTiltを使っており、tilt upで自動的にビルドするようになっています。やってみます。
> minikube image ls imageないです > tilt up > minikube lmage ls imageありました
さあこれでpull自体はできるはずですね。もう一度POD起動してみましょう。
結果は、ErrImagePullでした。これはつまりイメージが壊れている、エラーが出ているってことですかね。 ログを見てみましょう。
あれ、同じログですね。。。おわた。。 つまり、minikube image lsで存在が確認できているのに、イメージのpullに失敗するケースがあるってことでしょうか?
describeというのをやってみます。
> kubectl describe po hoge-pod .... Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal Scheduled 10m default-scheduler Successfully assigned default/hoge-pod to minikube Normal Pulling 8m56s (x4 over 10m) kubelet Pulling image "hoge/hoge-image:latest" Warning Failed 8m53s (x4 over 10m) kubelet Failed to pull image "hoge/hoge-image:latest": Error response from daemon: pull access denied for hoge/hoge-image, repository does not exist or may require 'docker login': denied: requested access to the resource is denied Warning Failed 8m53s (x4 over 10m) kubelet Error: ErrImagePull Warning Failed 8m39s (x6 over 10m) kubelet Error: ImagePullBackOff Normal BackOff 22s (x41 over 10m) kubelet Back-off pulling image "hoge/hoge-image"
上記について、Claude3.7様によりますと
ここで重要なポイントは: "repository does not exist or may require 'docker login'" - これは Docker Hub などの外部レジストリを探しに行っている証拠です "pull access denied" - 外部レジストリへのアクセス権がないか、そもそもそのリポジトリが存在しないことを示しています "requested access to the resource is denied" - 外部リソースへのアクセスが拒否されています つまり、Kubernetes は minikube のローカルイメージを見に行くのではなく、Docker Hub などの外部レジストリから hoge/hoge-image を pull しようとして失敗しているんです 🔍
だそうです。なるほど。。。 ここで既に3回目位なことに気づきました。
self.k8s.create_deployment(
name=deployment_name,
namespace="default",
labels=labels,
container_image=container_image,
container_name=container_name,
container_port=container_port,
env_vars=env_vars,
image_pull_policy=image_pull_policy,
)
上記のimage_pull_policyを設定しておりませんでした。その結果デフォルトのAlwaysになっていて、これは毎回外部をチェックする的な意味のようです。 そこでimage_pull_policyを追加して、値は「Never」にしました。これはローカルイメージしか見ないそうです。
はて、これで再度試したら、正常起動(Running)しました。
一応、image_pull_policyを「IfNotPresent」にして、試してみます。 これでもいけました。これなら本番感動時もよさげなので、こっちにしておきます。
まとめ
多分結構な頻度でエラーになっているのは、tiltの再起動をせずにpod起動をしようとして純粋にイメージがない場合と、上記のようにコードを修正した結果imagePullPolicyが消えて外部を見に行っているのと、イメージにエラーがあって起動できないのと、があるのかなあと思った。全てにおいて kubectl get po からの kubectl logs {pod name} と kubectl describe po {pod name} によって、明確になるはずなので、これをps1ファイルにしておこうと思いました。
Tiltを使ってみる
k8sを使うとローカルでの開発がめんどくさくなりました。毎回ビルドしたりdeploymentを削除したりしないといけないからです。HotReload機能的なものは標準ではついていないそうです。 そこで、Tiltを使うと差分チェックして、最小限のビルド処理を自動で実行してくれて、ログとかもブラウザでいい感じに見られるようになるらしいので、使ってみたいと思います。
TiltをScoopでインストール
> scoop bucket add tilt-dev https://github.com/tilt-dev/scoop-bucket > scoop install tilt > tilt version v0.33.22, built 2025-01-03
FastAPIのプロジェクトを作る
ためしにFastAPIで簡易的なプロジェクトを作ります。
> mkdir tilt-1 > cd tilt-1 > poetry init > poetry add fastapi uvicorn > mkdir app > touch app/main.py
main.py
from fastapi import FastAPI app = FastAPI() @app.get("/") def read_root(): return {"message": "Hello, Tilt + FastAPI!"} @app.get("/ping") def ping(): return {"pong": True}
Dockerfileを作成
> touch Dockerfile
Dockerfile
# ベースイメージ
FROM python:3.12-slim
# Poetryをインストールするために必要なパッケージ等を追加
RUN apt-get update && apt-get install -y curl git
# Poetryのインストール (最新版の公式推奨スクリプト)
# バージョンは必要に応じて固定しても良い
RUN curl -sSL https://install.python-poetry.org | python3 -
# Poetry がインストールされたパスを通す
ENV PATH="/root/.local/bin:${PATH}"
# 作業ディレクトリ作成
WORKDIR /app
# ホスト側の pyproject.toml / poetry.lock をコピー
COPY pyproject.toml poetry.lock /app/
# Poetryで依存関係をインストール (--no-root でプロジェクト本体はインストールしない)
RUN poetry install --no-root
# ソースコードをコピー
COPY . /app
# プロジェクト本体をインストール (必要に応じて)
# RUN poetry install
# FastAPIを起動するコマンド
CMD ["poetry", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
k8sのDeployment, Serviceのyamlファイルを作成
touch deployment.yaml touch service.yaml
deployment.yaml
apiVersion: apps/v1 kind: Deployment metadata: name: my-tilt-app spec: replicas: 1 selector: matchLabels: app: my-tilt-app template: metadata: labels: app: my-tilt-app spec: containers: - name: my-tilt-app image: my-tilt-app-image:latest ports: - containerPort: 8000
service.yaml
apiVersion: v1 kind: Service metadata: name: my-tilt-app-service spec: selector: app: my-tilt-app ports: - port: 80 targetPort: 8000 protocol: TCP type: NodePort
Tiltflieを作る
# Tiltfile
# 1. Dockerイメージのビルド設定
docker_build(
'my-tilt-app-image', # イメージ名。deployment.yaml で参照
'.',
dockerfile='Dockerfile',
live_update=[
sync('.', '/app'), # ホストの現在ディレクトリをコンテナの /app に同期
run('poetry install --no-root', trigger=['pyproject.toml', 'poetry.lock'])
],
)
# 2. k8s マニフェストの読み込み
k8s_yaml('k8s/deployment.yaml')
k8s_yaml('k8s/service.yaml')
# 3. ポートフォワード設定(Service を利用しない場合や補助的に使う場合)
# Service: NodePort でアクセス可能なら必須ではありませんが、補助的に設定する例
k8s_resource(
'my-tilt-app',
port_forwards=8000 # 例:ローカルホストの 8000 番を Pod の 8000 番にフォワード
)
# 4. 推奨: Tilt が実行されたら Minikube コンテキストを自動的に使用する
# 事前に `kubectl config use-context minikube` しているなら省略可。
# 参考: https://docs.tilt.dev/api.html#tilt_set_team (最新版のAPIをご確認ください)
tilt upする
> tilt up Tilt started on http://localhost:10350/ v0.33.22, built 2025-01-03 (space) to open the browser (s) to stream logs (--stream=true) (t) to open legacy terminal mode (--legacy=true) (ctrl-c) to exit Opening browser: http://localhost:10350/
tiltのコンソールを開く
上記のhttp://localhost:10350を開くと下記のような画面になりました。

http://localhost:8000でFastAPIのエンドポイントにアクセスできました! しかも、main.pyを変更すると、即座に変更が反映されました!
感想
超便利。Tiltを使わない場合、docker buildしてもminikubeで使えなくてminikube loadしないといけなかったり、色々deployment側で設定を追加していないとminikubeのリポジトリを見てくれなかったりしました。また、修正したらビルドし直しで、差分チェックとかしない場合、再ビルドにものすごく時間がかかります。あと、Windows11の場合だけかもしれませんが、NodePortで30080とか指定しても、直接アクセスできず、minikube service xxxxみたいなやつを使わないとアクセス可能なurlが取得できませんでした。これを使うとportがランダムになったりしました。ということで、何しろ結構めんどくさかったです。Tiltを使うと、Tiltfileにビルド内容を書くだけで、tilt upでビルドしてくてれて、自動的にMinikubeのイメージに入りました。port_forwards設定をすると、minikube service xxxxとかやらずに、直接localhost:8000でアクセスできました。しかも、main.py修正すると一瞬でHot Reloadされます!まあ、Tiltを使わない場合でも、もっと便利なやり方はあるとは思いますが、Tiltを使うことで超便利になってよかったです。
作った内容(リポジトリ)
Windows11のDocker DesktopでビルドしたイメージをMinikubeのDeploymentで使う
Docker DesktopでDockerfileをビルドして作成されたイメージは、Minikubeそのままだと使えないようです。
> minikube start --driver=docker > docker bild -t hoge/hoge:latest -f Dockerfile.hoge . > kubectl apply -f hoge-deployment.yaml > kubectl get po
これで、podのSTATUSは"ErrImagePull"や"ImagePullBackOff"というものになっています。 イメージのpullに失敗しているということです。 Docker Desktopでビルドした場合それをMinikubeにloadする必要があるようです。
> minikube image load hoge/hoge:latest > minikube image ls
あと、Deploymentの設定で、imagePullPolicy: IfNotPresent というのを設定する必要があるようでした。
これを設定すると、Minikubeのローカルのイメージを優先的に使ってくれるようです。
これでloadできました。
Windows11でMinikubeを使ってみる
環境
- Windows11
- PowerShell
- Scoop
MinikubeをScoopでインストール
> scoop install minikube > minikube version minikube version: v1.34.0
kubectlをScoopでインストール
kubectlというのも必要らしいのでインストールします。
> scoop install kubectl > kubectl version Client Version: v1.29.2
Minikubeはk8sの1ノードを簡単に立ち上げられるアプリだそうです。 k8sのほとんどの機能を網羅しているけどノードは1つに限定されていて、インストールも超簡単なので学習・開発に使われるそうです。 kubectlはk8sの各種操作を実行できるCLIのアプリです。
Minikubeをスタートしてみる
Dockerドライバというのでスタートしてみます。
> minikube start --driver=docker
😄 Microsoft Windows 11 Pro 10.0.22631.4602 Build 22631.4602 上の minikube v1.34.0
✨ ユーザーの設定に基づいて docker ドライバーを使用します
📌 root 権限を持つ Docker Desktop ドライバーを使用
👍 Starting "minikube" primary control-plane node in "minikube" cluster
🚜 Pulling base image v0.0.45 ...
💾 ロード済み Kubernetes v1.31.0 をダウンロードしています...
> preloaded-images-k8s-v18-v1...: 326.69 MiB / 326.69 MiB 100.00% 22.72 M
> gcr.io/k8s-minikube/kicbase...: 487.90 MiB / 487.90 MiB 100.00% 12.99 M
🔥 Creating docker container (CPUs=2, Memory=32700MB) ...
❗ Failing to connect to https://registry.k8s.io/ from inside the minikube container
💡 外部イメージを取得するためには、プロキシーを設定する必要があるかも知れません: https://minikube.sigs.k8s.io/docs/reference/networking/proxy/
🐳 Docker 27.2.0 で Kubernetes v1.31.0 を準備しています...
▪ 証明書と鍵を作成しています...
▪ コントロールプレーンを起動しています...
▪ RBAC のルールを設定中です...
🔗 bridge CNI (コンテナーネットワークインターフェース) を設定中です...
🔎 Kubernetes コンポーネントを検証しています...
▪ gcr.io/k8s-minikube/storage-provisioner:v5 イメージを使用しています
🌟 有効なアドオン: storage-provisioner, default-storageclass
❗ C:\Program Files\Docker\Docker\resources\bin\kubectl.exe のバージョンは 1.29.2 で、Kubernetes 1.31.0 と互換性がない かもしれません。
▪ kubectl v1.31.0 が必要ですか? 'minikube kubectl -- get pods -A' を試してみてください
🏄 終了しました!kubectl がデフォルトで「minikube」クラスターと「default」ネームスペースを使用するよう設定されました
kubectlが古いかもよみたいなメッセージがありますね。とりあえず何も分かってないので次に進みます。
kubectlでMinikubeが動いてるか確認してみる
> kubectl get nodes NAME STATUS ROLES AGE VERSION minikube Ready control-plane 5m44s v1.31.0
とりあえず動いているようです。
Deploymentを作る
apiVersion: apps/v1 kind: Deployment metadata: name: hello-deployment spec: replicas: 2 selector: matchLabels: app: hello template: metadata: labels: app: hello spec: containers: - name: hello-container image: nginx:latest ports: - containerPort: 80
Serviceを作る
apiVersion: v1 kind: Service metadata: name: hello-service spec: selector: app: hello type: NodePort ports: - protocol: TCP port: 80 targetPort: 80 nodePort: 30080
DeploymentはどんなコンテナのPodを何個作るかみたいな設定だそうです。 ServiceはPodのネットワーク接続に関する設定だそうです。
Deployment, Serviceを適用する
kubectl apply -f deployment.yaml kubectl apply -f service.yaml
ファイルの設定を適用する場合 -f オプションを付ける必要があり、-f はファイル名のことだそうです。
色々確認する
> kubectl get pods
NAME READY STATUS RESTARTS AGE
hello-deployment-6c9c7b86b9-2k8cj 1/1 Running 0 5m5s
hello-deployment-6c9c7b86b9-2tgv5 1/1 Running 0 5m5s
> kubectl get nodes
NAME STATUS ROLES AGE VERSION
minikube Ready control-plane 82m v1.31.0
> kubectl get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
hello-service NodePort 10.105.1.202 <none> 80:30080/TCP 5m11s
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 82m
> minikube ip
192.168.49.2
> wget http://192.168.49.2:30080
wget : リモート サーバーに接続できません。
発生場所 行:1 文字:1
+ wget
+ ~~~~
+ CategoryInfo : InvalidOperation: (System.Net.HttpWebRequest:HttpWebRequest) [Invoke-WebRequest]、WebException
+ FullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand
> minikube service hello-service
|-----------|---------------|-------------|---------------------------|
| NAMESPACE | NAME | TARGET PORT | URL |
|-----------|---------------|-------------|---------------------------|
| default | hello-service | 80 | http://192.168.49.2:30080 |
|-----------|---------------|-------------|---------------------------|
🏃 hello-service サービス用のトンネルを起動しています。
|-----------|---------------|-------------|------------------------|
| NAMESPACE | NAME | TARGET PORT | URL |
|-----------|---------------|-------------|------------------------|
| default | hello-service | | http://127.0.0.1:61074 |
|-----------|---------------|-------------|------------------------|
🎉 デフォルトブラウザーで default/hello-service サービスを開いています...
❗ Docker ドライバーを windows 上で使用しているため、実行するにはターミナルを開く必要があります。
上記のhttp://127.0.0.1:61074がブラウザで開き、Nginxの画面が表示されました。
スケールアップしてみる
> kubectl scale deployment hello-deployment --replicas=5 deployment.apps/hello-deployment scaled > kubectl get po NAME READY STATUS RESTARTS AGE hello-deployment-6c9c7b86b9-2k8cj 1/1 Running 0 21h hello-deployment-6c9c7b86b9-2tgv5 1/1 Running 0 21h hello-deployment-6c9c7b86b9-ckkhk 1/1 Running 0 9s hello-deployment-6c9c7b86b9-g56kr 1/1 Running 0 9s hello-deployment-6c9c7b86b9-s5v4n 1/1 Running 0 9s
Jobを作ってみる
Jobは一時的な処理を実行するPodを作ることが出きるそうです。
apiVersion: batch/v1 kind: Job metadata: name: test-job spec: template: spec: containers: - name: test-job-container image: busybox command: ["echo", "Hello from Job"] restartPolicy: Never
Jobを適用・実行してみる
> kubectl apply -f job.yml job.batch/test-job created > kubectl get pod NAME READY STATUS RESTARTS AGE hello-deployment-6c9c7b86b9-2k8cj 1/1 Running 0 21h hello-deployment-6c9c7b86b9-2tgv5 1/1 Running 0 21h test-job-hb8lq 0/1 Completed 0 27s > kubectl get jobs NAME STATUS COMPLETIONS DURATION AGE test-job Complete 1/1 9s 35s > kubectl logs test-job-hb8lq Hello from Job
FastAPIを使ってみる

FastAPI, poetry, uvicorn, alembicを使ってみます。
- FastAPIはWEBフレームワークですね。API作ると、SwaggerUIを勝手に作ってくれるらしいです。
- poetryはnpmみたいなやつでライブラリを簡単に追加して依存関係を管理してくれる新しい便利なやつらしいです。
- uvicornはWEBブラウザなんですかね?
- alembicはマイグレーションツールなんですかね?
FastAPI
mkdir fastapi-1 cd fastapi-1 git init poetry init poetry add fastapi poetry add uvicorn[standard]
main.py
from fastapi import FastAPI app = FastAPI() @app.get("/") def read_root(): return {"Hello": "World"} @app.get("/items/{item_id}") def read_item(item_id: int, q: str = None): return {"item_id": item_id, "q": q}
uvicorn main:app --reload
これで起動して、SwaggerUIまで表示できる。便利そう。
FastAPIでDB接続、マイグレーション、ORMなど
今自分のローカル環境には、PostgreSQLが入っていて既に動いています。
マイグレーション
poetry add sqlalchemy alembic psycopg2-binary alembic init alembic alembic revision -m "create users table"
alembicはsqlalchemyと連動するマイグレーションツールとのことです。 上記でalembicの初期化と最初のマイグレーションファイルを作成しました。 このやり方だと、マイグレーションファイルは手動で作成する必要があります。
"""create users table Revision ID: de05c422a1ac Revises: Create Date: 2024-12-13 10:48:01.319333 """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = "de05c422a1ac" down_revision: Union[str, None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.create_table( "users", sa.Column("id", sa.Integer, primary_key=True), sa.Column("name", sa.String(50), nullable=False), sa.Column("email", sa.String(255), nullable=False), sa.Column("password", sa.String(255), nullable=False), sa.Column("created_at", sa.DateTime, nullable=False), sa.Column("updated_at", sa.DateTime, nullable=False), ) def downgrade() -> None: op.drop_table("users")
マイグレーション実行時のDB接続については、alembic.ini の sqlalchemy.url を環境に合わせて更新します。
sqlalchemy.url = postgresql://postgres:password@localhost:5432/hoge
これでマイグレーション実行可能になりましたので、下記で実行します。
alembic upgrade head
色々なコマンドがここに書いてあります。
FastAPIでDB接続・ORM
touch .env mkdir app mv main.py app cd app touch config.py touch database.py touch models.py touch schemas.py
configy.pyで.envの環境変数を読み込み、他のファイルではconfigy.pyを読み込んで使います。 database.pyでDBと接続します。 models.pyにテーブルの定義を書きます。 schemas.pyにリクエストやレスポンス時の型を定義します。 みたいな感じっぽいです。
config.py
from dotenv import load_dotenv
import os
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL")
database.py
from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from .config import DATABASE_URL engine = create_engine(DATABASE_URL) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() # DBセッション依存性 def get_db(): db = SessionLocal() try: yield db finally: db.close()
models.py
from sqlalchemy import Column, Integer, String, DateTime from sqlalchemy.sql import func from .database import Base class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True, index=True) name = Column(String) email = Column(String, unique=True, index=True) password = Column(String, nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
schemas.py
from pydantic import BaseModel class UserBase(BaseModel): name: str email: str class UserCreate(UserBase): password: str class User(UserBase): id: int class Config: from_attributes = True
main.py
from fastapi import FastAPI, Depends from sqlalchemy.orm import Session from . import models, schemas from .database import engine, get_db models.Base.metadata.create_all(bind=engine) app = FastAPI() @app.get("/") def read_root(): return {"Hello": "World"} @app.get("/items/{item_id}") def read_item(item_id: int, q: str = None): return {"item_id": item_id, "q": q} @app.post("/users/", response_model=schemas.User) def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)): db_user = models.User(**user.model_dump()) db.add(db_user) db.commit() db.refresh(db_user) return db_user @app.get("/users/", response_model=list[schemas.User]) def read_users(db: Session = Depends(get_db)): return db.query(models.User).all()
main.pyにある、models.Base.metadata.create_all(bind=engine) は、存在しないテーブルやカラムを勝手に作成してくれるそうです。
ですので、1にで開発中は、テーブル全部削除して、 uvicorn app.main:app --reload をすればテーブルが最新状態で作成されます。
これは便利かも。まあでも、毎回テーブル削除するスクリプトとかは必要かも。
claude先生によるとdb.commit()した時点で、idをdb_userオブジェクトに入れてくれるそうです。 ですので、commit()した時点でidはdb_user.idで取得可能になるそうじゃ。 db.refresh(db_user)はselect文でこのユーザを改めて取得しているようなもんだそうじゃ。 例えば、created_atとかは、DBが勝手に保存するので、そのような値が必要な場合に使えるそうじゃ。 逆に言うと、idだけ返せばよいなら、refreshは不要なんだそうじゃ。 まあ試してないど、多分本当のことじゃろう。便利じゃな。
SQLModelを使って簡略化
下記を見ると、SQLAlchemyではなく、SQLModelというのを使っているようです。
SQLModel は FastAPI の作者である Tiangolo が作った、以下の2つのライブラリを組み合わせたものです: - SQLAlchemy (ORM) - Pydantic (データバリデーション) つまり、SQLModel は SQLAlchemy の機能を全て継承しつつ、FastAPI との相性を良くするために Pydantic との統合を強化したライブラリなんです。
だそうです。つまり、SQLAlchemyを使っていて問題はないのですが、よりスッキリかけるやつということのようです。 これを使って書き直してみましょう。
poetry add sqlmodel
models.py
from datetime import datetime, UTC from typing import Optional from sqlmodel import SQLModel, Field, create_engine, Session from .config import DATABASE_URL # データベース設定 engine = create_engine(DATABASE_URL) # モデル定義(schemas.pyとmodels.pyが統合される) class UserBase(SQLModel): name: str email: str = Field(unique=True, index=True) class User(UserBase, table=True): __tablename__ = "users" id: Optional[int] = Field(default=None, primary_key=True) password: str created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) updated_at: datetime = Field( default_factory=lambda: datetime.now(UTC), sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)}, ) class UserCreate(UserBase): password: str # DBセッション依存性 def get_db(): with Session(engine) as session: yield session # アプリ起動時にテーブルを作成 def create_db_and_tables(): SQLModel.metadata.create_all(engine)
main.py
from contextlib import asynccontextmanager from fastapi import FastAPI, Depends from sqlmodel import Session, select from . import models @asynccontextmanager async def lifespan(app: FastAPI): models.create_db_and_tables() yield app = FastAPI(lifespan=lifespan) @app.get("/") def read_root(): return {"Hello": "World"} @app.get("/items/{item_id}") def read_item(item_id: int, q: str = None): return {"item_id": item_id, "q": q} @app.post("/users/", response_model=models.User) def create_user(user: models.UserCreate, db: Session = Depends(models.get_db)): db_user = models.User.model_validate(user) db.add(db_user) db.commit() db.refresh(db_user) return db_user @app.get("/users/", response_model=list[models.User]) def read_users(db: Session = Depends(models.get_db)): return db.exec(select(models.User)).all()
こんな感じでできました。
バリデーションチェック
バリデーションルールもmodelsに書けるようです。
models.py
from datetime import datetime, UTC from typing import Optional from sqlmodel import SQLModel, Field, create_engine, Session from pydantic import EmailStr from .config import DATABASE_URL # データベース設定 engine = create_engine(DATABASE_URL) # モデル定義(schemas.pyとmodels.pyが統合される) class UserBase(SQLModel): name: str = Field( min_length=1, max_length=50, description="ユーザー名は1-50文字で入力してください" ) email: EmailStr = Field( unique=True, index=True, description="有効なメールアドレスを入力してください" ) class User(UserBase, table=True): __tablename__ = "users" id: Optional[int] = Field(default=None, primary_key=True) password: str created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) updated_at: datetime = Field( default_factory=lambda: datetime.now(UTC), sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)}, ) class UserCreate(UserBase): password: str = Field( min_length=8, max_length=100, description="パスワードは8文字以上で入力してください" ) # DBセッション依存性 def get_db(): with Session(engine) as session: yield session # アプリ起動時にテーブルを作成 def create_db_and_tables(): SQLModel.metadata.create_all(engine)
MacでPostgreSQLがうまく自動起動しなくなった
OSを新しくしたせいなのか、突然PostgreSQLの自動起動でエラーが出るようになりました。 一度直ったのですが、再度、再起動時に同じエラーが出ましたので、メモします。
エラー内容
❯ php artisan serve
Illuminate\Database\QueryException
SQLSTATE[08006] [7] connection to server at "127.0.0.1", port 5432 failed: Connection refused
Is the server running on that host and accepting TCP/IP connections? (Connection: pgsql, SQL:
select * from "settings" limit 1)
❯ brew services list Name Status User File mailpit none mysql none php none postgresql@16 error 256 dev ~/Library/LaunchAgents/homebrew.mxcl.postgresql@16.plist redis started dev ~/Library/LaunchAgents/homebrew.mxcl.redis.plist
下記のように再起動しても変わらず上記のままです。
brew services stop postgresql@16 brew services start postgresql@16
plistの内容は下記でした。
❯ cat ~/Library/LaunchAgents/homebrew.mxcl.postgresql@16.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>EnvironmentVariables</key>
<dict>
<key>LC_ALL</key>
<string>C</string>
</dict>
<key>KeepAlive</key>
<true/>
<key>Label</key>
<string>homebrew.mxcl.postgresql@16</string>
<key>LimitLoadToSessionType</key>
<array>
<string>Aqua</string>
<string>Background</string>
<string>LoginWindow</string>
<string>StandardIO</string>
<string>System</string>
</array>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/opt/postgresql@16/bin/postgres</string>
<string>-D</string>
<string>/opt/homebrew/var/postgresql@16</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StandardErrorPath</key>
<string>/opt/homebrew/var/log/postgresql@16.log</string>
<key>StandardOutPath</key>
<string>/opt/homebrew/var/log/postgresql@16.log</string>
<key>WorkingDirectory</key>
<string>/opt/homebrew</string>
</dict>
</plist>
ログを見てみます。
❯ tail -f /opt/homebrew/var/log/postgresql@16.log 2024-10-15 11:19:30.964 JST [8382] FATAL: lock file "postmaster.pid" already exists 2024-10-15 11:19:30.964 JST [8382] HINT: Is another postmaster (PID 754) running in data directory "/opt/homebrew/var/postgresql@16"?
ここに同じエラーについて書かれていました。postgresの二重起動を防止するもので、何らかの原因で残ってしまうものが、よく残るようになってしまったのでしょうか? www.fujitsu.com
とりあえず、消してみます。
rm /opt/homebrew/var/postgresql@16/postmaster.pid
おー直りました。
❯ brew services list Name Status User File mailpit none mysql none php none postgresql@16 started dev ~/Library/LaunchAgents/homebrew.mxcl.postgresql@16.plist redis started dev ~/Library/LaunchAgents/homebrew.mxcl.redis.plist
これが続くのは嫌ですが、とりあえず、postmaster.pidを削除したら良いのかなと思いました。
Livewire3のrenderのview関数でエラーが出る
Livewire3のrenderのview関数で、下記のような感じでlayoutやtitleを使うと、エラーが出ます。
public function render()
{
return view('livewire.pages.toppage')
->layout('components.layouts.main')
->title($this->pageTitle);
}
エラー
Undefined method 'layout'.intelephense(P1013)
エディタでエラーになるのですが、実際的には問題なく動作しています。 つまり実際はエラーではないので、エラーが表示されないようにしたいです。
こちらに解決策が載っていました。
プロジェクトのルートに、intelephense_helper.php というファイルを作成して、中身を下記にします。
<?php namespace Illuminate\Contracts\View; use Illuminate\Contracts\Support\Renderable; interface View extends Renderable { /** @return static */ public function extends(); public function layoutData(); public function layout(); public function title(); }
これでエラーがで消えました。
tmuxでLaravelの開発環境を簡単に立ち上げられるようにする
Laravelの開発環境を立ち上げる時にいくつかやることがあります。 私は主に下記です。PostgreSQLとRedisはPC起動時に自動で起動するようにしています。
php artisan serve npm run dev minio server ~/minio-storage --console-address :9001 mailpit
これを毎回実行するのはめんどくさいですし、忘れてしまいますので、tmuxで自動起動するようにしました。 これをプロジェクトのlocal/start.shとして保存します。
#!/bin/bash # セッション名を設定 SESSION_NAME="laravel-dev" # 既存のセッションを終了(オプション) tmux kill-session -t $SESSION_NAME 2>/dev/null # 新しいセッションを作成 tmux new-session -d -s $SESSION_NAME # ウィンドウを分割 tmux split-window -h tmux split-window -v tmux select-pane -t 0 tmux split-window -v # コマンドを各ペインで実行 tmux select-pane -t 0 tmux send-keys "php artisan serve" C-m tmux select-pane -t 1 tmux send-keys "npm run dev" C-m tmux select-pane -t 2 tmux send-keys "mailpit" C-m tmux select-pane -t 3 tmux send-keys "minio-start" C-m # セッションにアタッチ tmux attach-session -t $SESSION_NAME
あとはプロジェクトのルートに移動して、下記を実行します。
sh local/start.sh
これで簡単に環境の立ち上げができるようになりました。

超シンプルなローカル環境用のメール受信テストツール - Mailpit
Mailhogのようなやつを探していたら、MailpitをClaudeが教えてくれました。
brew install axllent/apps/mailpit mailpit
これだけで起動しました。 Laravelの.envの設定は下記のような感じになります。
MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
あとはメールを受信したら、下記のコンソールで確認できます。

macのCursorのキーボードショートカット
忘れたら困るやつをメモしておきます。
- ターミナルの表示切り替え :
⌘J - サイドバーの表示切り替え :
⌘B - ターミナルの全画面化の切り替え :
⌘H
上記のターミナルの全画面化は、自分で設定しました。
キーボードショートカット設定の画面で、toggleMaximizedPanelと検索すると出てきます。
対応するキーが設定されていなかったので、⌘H を設定してみました。
追記
- なんと、
⌘Jでもターミナル(のパネル)の表示切り替えができましたので、上記を修正しました。 - フォーマットは、
option + shift + Fでした。押しづらい。 - 下記にあるVS CodeのVimの設定をコピペしました。フォーマットも、
space + fでできるようになりました。
Laravel Herdが動かなくなったのでWindows11にWSLのUbuntuを設定してみて結果的にMacを使うことにした
ずっとWSL2のArch Linuxを使っており、最近、Windows11 Powershellでの開発に切り替えたところでしたが、どうもやっぱり変えたいです。 きっかけは、Laravel Herdが自動アップデートされたら、Laravel Herdが起動しなくなったことです。。Laravel Herdが必要だった理由は、WindowsでRedisがすんなり入らなかったからです。 Linuxなら何も問題ありません。しかし、WSLの場合、Cursorのcodeコマンドがうまく使えなかったり、Windowsでしか動かないアプリとの連携がめんどくさかったりしたので、AIとか3Dとか考えたらどっちにしてもWindowsで開発することになるから、いっそのこと、WindowsでWSL使わずに開発しようと思いました。
しかし、最近またLaravel中心の開発が増えてきており、Laravelだと逆にWindowsで動かないライブラリなども結構ありました。それに、LaravelならWindowsである必要は全くありません。そこで、WSLのUbuntuでもしかしたら、codeコマンドとか色々うまくいくかもしれへん、と思い、セットアップしてみようと思ったのです。ダメなら、コンパクトなPCかノートPCかマックで、Laravel開発どこでもできるマンになるべく、セットアップをしていく所存です。MacのノートはM1 Macというやつを持っていますが、何故か外部ディスプレイは1つしか繋げられないし、画面が小さすぎるので、あんまり好きではありません。せっかくならLinuxマシンにしたいところです。まあでもLaravelならMacで全く問題ないはずですし、なにせ、Macは持っていますので、Macを使う可能性が高いです。メモリが16GBというところが、ちょっとあれですが、まあ開発だけなら問題ないと思います。
最近のコンパクトPCに折り畳みみたいな分割キーボードをくっつけて、折り畳みみたいなディスプレイを2つもっていれば、ホテルやらでもよい感じの開発環境が作れるのでは、ともちょっと思っております。折り畳みディスプレイが現実的に入手可能なのかなどは全然分かっておりませんが、Xで見たことがあります。ほしいです。
ひとまず、自宅のWindowsPCにおきましては、とりあえず、Ubuntu設定をしてみます。
Ubuntuをインストール

インストールしたいもの
買い物リストみたいな感じで書いていきます。大文字小文字は適当とさせていただきます。 php, redis, postgreSQL, minIO, node, npm, cursor, tmux, nvim, python, aws シェルはzshを使っていて、その後、starshipを使っていましたが、何か別のを使ってみようかなと思います。
Cursorの動作チェック
まずはやはり、Cursorのcodeコマンドが動くかどうかを確認してみたいと思います。 シェルでcodeを実行すると、何故かcodeコマンドを認識していました。でもエラーになっております。
dev@WIN:~$ code Unable to determine app path from symlink : /mnt/c/Users/dev/AppData/Local/Programs/cursor/resources/app/bin/code
PowershellでCursorの場所を確認しました。
PS C:\Users\dev> Get-Process | Where-Object {$.Name -like "cursor"} | Select-Object Name, Path
PS C:\Users\dev> Get-ChildItem "C:\Users\$env:USERNAME\AppData\Local\Programs\cursor" -Recurse | Where-Object {$.Name -eq "cursor.exe"}
ディレクトリ: C:\Users\dev\AppData\Local\Programs\cursor
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2024/09/05 12:13 176270600 Cursor.exe
.bashrcにエイリアスを設定しました。
alias code='/mnt/c/Users/dev/AppData/Local/Programs/cursor/Cursor.exe'
これでなんと、code hoge でcursorが起動しました!!
と思ったら、cursorが起動したものの、Ubuntuに接続できておらず、全然使えませんでした。。
既に、前回のArch Linux時にごにょごにょしたせいで、使えなくなっているのかもしれません。
なぜかArch Linuxにも接続できませんね。。
UbuntuとCursorの問題というよりは、WSL2とCursorの問題を調査・解明する必要がありそうです。。
PS C:\Users\dev> wsl --status 既定のディストリビューション: arch 既定のバージョン: 2 PS C:\Users\dev> wsl --list --verbose NAME STATE VERSION * arch Running 2 docker-desktop Stopped 2 Ubuntu Running 2 dev Stopped 2 docker-desktop-data Stopped 2 PS C:\Users\dev> wsl --update 更新プログラムを確認しています。 Linux 用 Windows サブシステムの最新バージョンは既にインストールされています。 PS C:\Users\dev> wsl --shutdown PS C:\Users\dev> wsl -d Ubuntu To run a command as administrator (user "root"), use "sudo <command>". See "man sudo_root" for details. dev@WIN:/mnt/c/Users/dev$
一回wslをシャットダウンして、Ubuntuをもう一度起動してみましたがダメでした。 Windows11自体を再起動しても変わりませんでした。
CursorにWSLを接続しようとすると下記が出てきます。Cursorのターミナルで下記が表示されます。
[2024-09-20 02:00:55.460] Extension version: 0.81.8 [2024-09-20 02:00:55.460] L10N bundle: file:///c%3A/Users/dev/.cursor/extensions/ms-vscode-remote.remote-wsl-0.81.8/l10n/bundle.l10n.ja.json [2024-09-20 02:00:55.475] authorityHierarchy: wsl+Ubuntu [2024-09-20 02:00:55.475] WSL extension activating for a local WSL instance [2024-09-20 02:00:55.490] Resolving wsl+Ubuntu, resolveAttempt: 1 [2024-09-20 02:00:55.491] NodeExecServer run: C:\WINDOWS\System32\wsl.exe --status [2024-09-20 02:00:55.538] WSL feature installed: true (wsl --status) [2024-09-20 02:00:55.538] NodeExecServer run: C:\WINDOWS\System32\wsl.exe --list --verbose [2024-09-20 02:00:55.581] 5 distros found [2024-09-20 02:00:55.582] Starting VS Code Server inside WSL (wsl2) [2024-09-20 02:00:55.582] Windows build: 22631. Multi distro support: available. WSL path support: enabled [2024-09-20 02:00:55.582] Scriptless setup: false [2024-09-20 02:00:55.583] No shell environment set or found for current distro. [2024-09-20 02:00:55.677] スタートアップの問題のヘルプについては、https://code.visualstudio.com/docs/remote/troubleshooting#_wsl-tips にアクセスしてください
あとは、Ubuntu側で、codeを使ってCursorを起動して、WSLに接続しようとした際の出力です。
dev@WIN:~$ code .
[main 2024-09-20T02:00:45.341Z] update#setState disabled
[main 2024-09-20T02:00:45.342Z] update#ctor - updates are disabled as there is no update URL
[main 2024-09-20T02:00:45.409Z] [storage state.vscdb] error checking size of src.vs.platform.reactivestorage.browser.reactiveStorageServiceImpl.persistentStorage.workspaceUser: Error: Unexpected number of rows - 0
[main 2024-09-20T02:00:54.082Z] Extension host with pid 10696 exited with code: 0, signal: unknown.
[main 2024-09-20T02:00:54.115Z] Extension host with pid 7752 exited with code: 0, signal: unknown.
いやーなんかめんどくさいな。全然分からないし。これはなんだ。。。
update#setState disabled update#ctor - updates are disabled as there is no update URL error checking size of src.vs.platform.reactivestorage.browser.reactiveStorageServiceImpl.persistentStorage.workspaceUser: Error: Unexpected number of rows - 0 Extension host with pid 10696 exited with code: 0, signal: unknown. Extension host with pid 7752 exited with code: 0, signal: unknown.
ここに同じエラーの人いるな、でもコメントきてない。
ここにもある。
スタートアップ問題のヘルプを見てみるか。。
sudo apt-get update && sudo apt-get install wget ca-certificates
まだエラー出るから、一旦Cursorを完全削除してみるか。。
dev@WIN:~$ code hoge Installing VS Code Server for Linux x64 (f1e16e1e6214d7c44d078b1f0607b2388f29d729) Downloading: 100% Unpacking: 100% Unpacked 1707 files and folders to /home/dev/.vscode-server/bin/f1e16e1e6214d7c44d078b1f0607b2388f29d729. Looking for compatibility check script at /home/dev/.vscode-server/bin/f1e16e1e6214d7c44d078b1f0607b2388f29d729/bin/helpers/check-requirements.sh Running compatibility check script Compatibility check successful (0)
おーVS Codeはできた。うーむ。なんかこれでCursorを再度入れたら出来そうな気がする。。 だめだ。Cursorはだめだった。
cursorコマンドなんてあるのか。。
dev@WIN:~$ cursor hoge To use Cursor with the Windows Subsystem for Linux, please install Cursor in Windows and uninstall the Linux version in WSL. You can then use the `cursor` command in a WSL terminal just as you would in a normal command prompt. Do you want to continue anyway? [y/N] y To no longer see this prompt, start Cursor with the environment variable DONT_PROMPT_WSL_INSTALL defined. /mnt/c/Users/dev/AppData/Local/Programs/cursor/resources/app/bin/cursor: 62: /mnt/c/Users/dev/AppData/Local/Programs/cursor/resources/app/bin/../cursor: not found
まあでもまたエラーですが。
大変そうだな。。 まあでも、これはあくまで、cursorコマンドで起動させたいという話で、別にCursorからWSLに接続しようとしてエラーが出る話とは違うんだよな。 VS Codeだと接続できるしなあ。うーむ。
Macの設定でもするか。。。 macから失礼いたします。
macだと簡単にCursorも起動しますし、Laravel開発に必要なものはDocker使わずとも簡単にインストール可能です。 そして、windowsだと使えなかったLaravelの便利ツールもいくつかありました。 そもそもLaravel HerdのドメインはGoogleログインの動作確認などでちょっと不便でした。 ということで、Laravel Herdの利用をやめて、macを使っていきたいと思います。 macであれば、外出先でも同じ環境なので、どこでも開発ができます。 放浪しながら開発をし続けたいと思います。
Laravel11でJetstreamのルートや言語やプロフィール画像の保存ロジックを変更する
環境
- windows11
- powershell
- php8.3
- Laravel11.22.0
- Livewire3
- jetstream5.2.0
ちなみに、下記でバージョンを表示できました。
composer show laravel/jetstream
jetstreamのプロフィール編集画面のルートを変えたい
routes/web.phpがある場所には、jetstreamのルート設定はありません。vendor内にありました。
vendor/laravel/jetstream/routes/livewire.php があって、内容は下記でした。
/user/profileに対するルート設定が書いてあります。
<?php use Illuminate\Support\Facades\Route; use Laravel\Jetstream\Http\Controllers\CurrentTeamController; use Laravel\Jetstream\Http\Controllers\Livewire\ApiTokenController; use Laravel\Jetstream\Http\Controllers\Livewire\PrivacyPolicyController; use Laravel\Jetstream\Http\Controllers\Livewire\TeamController; use Laravel\Jetstream\Http\Controllers\Livewire\TermsOfServiceController; use Laravel\Jetstream\Http\Controllers\Livewire\UserProfileController; use Laravel\Jetstream\Http\Controllers\TeamInvitationController; use Laravel\Jetstream\Jetstream; Route::group(['middleware' => config('jetstream.middleware', ['web'])], function () { if (Jetstream::hasTermsAndPrivacyPolicyFeature()) { Route::get('/terms-of-service', [TermsOfServiceController::class, 'show'])->name('terms.show'); Route::get('/privacy-policy', [PrivacyPolicyController::class, 'show'])->name('policy.show'); } $authMiddleware = config('jetstream.guard') ? 'auth:'.config('jetstream.guard') : 'auth'; $authSessionMiddleware = config('jetstream.auth_session', false) ? config('jetstream.auth_session') : null; Route::group(['middleware' => array_values(array_filter([$authMiddleware, $authSessionMiddleware]))], function () { // User & Profile... Route::get('/user/profile', [UserProfileController::class, 'show'])->name('profile.show'); Route::group(['middleware' => 'verified'], function () { // API... if (Jetstream::hasApiFeatures()) { Route::get('/user/api-tokens', [ApiTokenController::class, 'index'])->name('api-tokens.index'); } // Teams... if (Jetstream::hasTeamFeatures()) { Route::get('/teams/create', [TeamController::class, 'create'])->name('teams.create'); Route::get('/teams/{team}', [TeamController::class, 'show'])->name('teams.show'); Route::put('/current-team', [CurrentTeamController::class, 'update'])->name('current-team.update'); Route::get('/team-invitations/{invitation}', [TeamInvitationController::class, 'accept']) ->middleware(['signed']) ->name('team-invitations.accept'); } }); }); });
web.phpで設定を上書きする
例えば、jetstreamのプロフィール編集画面を/user/accountにアクセスしたときに表示させて、/user/profileにアクセスした場合は、全く別のページを表示させたいとします。
<?php use Laravel\Jetstream\Http\Controllers\Livewire\UserProfileController; Route::get('/user/profile', App\Livewire\Pages\User\Profile::class)->name('profile.show'); Route::get('/user/account', [UserProfileController::class, 'show'])->name('user-account');
これでできました。
日本語化する
これを使います。
使い方はこれです。
composer require --dev laravel-lang/lang php artisan lang:update
これでできました。
プロフィール画像をS3に保存させる
プロフィール画像をリサイズしてS3に保存できるかやってみます。
app/Actions/Fortify/UpdateUserProfileInfomation.php にプロフィール更新関連のコードがあります。
プロフィール画像が設定されている場合、updateProfilePhoto を実行しています。
これは、 vendor/laravel/jetstream/src/HasProfilePhoto.php内にあります。これを上書きしてみます。
app/Traits/HasCustomProfilePhoto.php を作成します。
中身は下記です。
<?php namespace App\Traits; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; use Laravel\Jetstream\Features; use Illuminate\Support\Str; use Intervention\Image\ImageManager; use Intervention\Image\Drivers\Gd\Driver; trait HasCustomProfilePhoto { use \Laravel\Jetstream\HasProfilePhoto; /** * Update the user's profile photo. * * @param \Illuminate\Http\UploadedFile $photo * @return void */ public function updateProfilePhoto(UploadedFile $photo) { $this->deleteProfilePhoto(); $path = $this->getProfilePhotoPath(); $filename = pathinfo($path, PATHINFO_FILENAME); $manager = new ImageManager(new Driver()); $image = $manager->read($photo); $original = $image->toWebp(); $resized500 = $image->cover(500, 500)->toWebp(); $resized40 = $image->cover(40, 40)->toWebp(); $disk = $this->profilePhotoDisk(); $disk->put($path, $original, 'public'); $disk->put($this->getResizedPhotoPath($filename, 500), $resized500, 'public'); $disk->put($this->getResizedPhotoPath($filename, 40), $resized40, 'public'); $this->forceFill([ 'profile_photo_path' => $path, ])->save(); } /** * Get the URL to the user's profile photo. * * @return string */ public function profilePhotoUrl(): Attribute { return Attribute::get(function (): string { return $this->getProfilePhotoUrl(40); }); } /** * Get the URL to the user's large profile photo. * * @return string */ public function largeProfilePhotoUrl(): Attribute { return Attribute::get(function (): string { return $this->getProfilePhotoUrl(500); }); } /** * Get the URL to the user's profile photo. * * @param int $size * @return string */ public function getProfilePhotoUrl($size = 40): string { if ($this->profile_photo_path) { $filename = pathinfo($this->profile_photo_path, PATHINFO_FILENAME); return env('R2_PUBLIC_URL') . '/' . $this->getResizedPhotoPath($filename, $size); } return $this->defaultProfilePhotoUrl(); } /** * Process the profile photo. * * @param \Illuminate\Http\UploadedFile $photo * @return string */ protected function processProfilePhoto(UploadedFile $photo) { $manager = new ImageManager(new Driver()); $image = $manager->read($photo); $image->cover(500, 500); return $image->toWebp(); } /** * Get the path for the profile photo. * * @return string */ protected function getProfilePhotoPath() { return 'user/' . $this->id . '/profile-image/' . Str::random(40) . '.webp'; } /** * Get the resized photo path. * * @param string $filename * @param int $size * @return string */ protected function getResizedPhotoPath($filename, $size) { return 'user/' . $this->id . '/profile-image/' . $filename . "_{$size}x{$size}.webp"; } /** * Get the disk that profile photos should be stored on. * * @return \Illuminate\Contracts\Filesystem\Filesystem */ protected function profilePhotoDisk() { return Storage::disk('r2'); } /** * Delete the user's profile photo. * * @return void */ public function deleteProfilePhoto() { if (! Features::managesProfilePhotos() || is_null($this->profile_photo_path)) { return; } $this->profilePhotoDisk()->deleteDirectory('user/' . $this->id . '/profile-image'); $this->forceFill([ 'profile_photo_path' => null, ])->save(); } }
そして、Userモデルで利用するTraitを、HasCustomProfilePhotoに変更します。
上記はCloudflare R2に保存しており、diskの名前もS3ではなく、r2になっております。
アップロード画像を500x500と40x40にリサイズして、R2に保存しています。
$user->profile_photo_urlとやると、40x40の画像のURLが返ります。
$user->large_profile_photo_urlとやると、500x500のURLが返ります。

RemixでdaisyUIを使ってnavbarとかfooterをつくってみよう!
こちらの続きになります。
上記で、daisyUIを入れましたので、これを使って、navbarとfooterを作ったりしてみましょう!
navbarはここにあります。
footerはここにあります。
まずは、appにcomponentsというディレクトリを作りましょう。 そして、その中に、navbar.tsxとfooter.tsxを作りましょう。
navbar.tsxを下記のようにします。
import { Link } from '@remix-run/react'; export default function Navbar() { return ( <div className="navbar bg-base-100"> <div className="navbar-start"> <div className="dropdown"> <button id="mobile-menu-button" aria-label="Open mobile menu" className="btn btn-ghost lg:hidden" > <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h8m-8 6h16" /> </svg> </button> <ul aria-labelledby="mobile-menu-button" className="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52" > <li> <Link to="/">Item 1</Link> </li> <li> <Link to="/">Parent</Link> <ul className="p-2"> <li> <Link to="/">Submenu 1</Link> </li> <li> <Link to="/">Submenu 2</Link> </li> </ul> </li> <li> <Link to="/">Item 3</Link> </li> </ul> </div> <Link to="/" className="btn btn-ghost text-xl"> daisyUI </Link> </div> <div className="navbar-center hidden lg:flex"> <ul className="menu menu-horizontal px-1"> <li> <Link to="/">Item 1</Link> </li> <li> <details> <summary>Parent</summary> <ul className="p-2"> <li> <Link to="/">Submenu 1</Link> </li> <li> <Link to="/">Submenu 2</Link> </li> </ul> </details> </li> <li> <Link to="/">Item 3</Link> </li> </ul> </div> <div className="navbar-end"> <button className="btn">Button</button> </div> </div> ); }
footer.tsxを下記のようにします。
import { Link } from '@remix-run/react'; export default function Footer() { return ( <footer className="footer bg-base-300 text-base-content p-10"> <nav> <h6 className="footer-title">Services</h6> <Link to="/" className="link link-hover"> Branding </Link> <Link to="/" className="link link-hover"> Design </Link> <Link to="/" className="link link-hover"> Marketing </Link> <Link to="/" className="link link-hover"> Advertisement </Link> </nav> <nav> <h6 className="footer-title">Company</h6> <Link to="/" className="link link-hover"> About us </Link> <Link to="/" className="link link-hover"> Contact </Link> <Link to="/" className="link link-hover"> Jobs </Link> <Link to="/" className="link link-hover"> Press kit </Link> </nav> <nav> <h6 className="footer-title">Social</h6> <div className="grid grid-flow-col gap-4"> <Link to="/"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className="fill-current" > <path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z"></path> </svg> </Link> <Link to="/"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className="fill-current" > <path d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 3.993-8 4.007z"></path> </svg> </Link> <Link to="/"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className="fill-current" > <path d="M9 8h-3v4h3v12h5v-12h3.642l.358-4h-4v-1.667c0-.955.192-1.333 1.115-1.333h2.885v-5h-3.808c-3.596 0-5.192 1.583-5.192 4.615v3.385z"></path> </svg> </Link> </div> </nav> </footer> ); }
app/root.tsxを下記のようにして、navbar, footerを追加します。childrenにflex-growを設定して、常にfooterが画面下になるようにしてみました。
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react'; import Navbar from './components/navbar'; import Footer from './components/footer'; import './tailwind.css'; export function Layout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <head> <meta charSet="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <Meta /> <Links /> </head> <body className="flex flex-col min-h-screen"> <Navbar /> <main className="flex-grow">{children}</main> <ScrollRestoration /> <Scripts /> <Footer /> </body> </html> ); } export default function App() { return <Outlet />; }

RemixでexpressをサーバにしたりDaisyUIを入れたりする
Remixiの新規プロジェクト作成
下記で新規プロジェクトを作成します。プロジェクト名はhogeとします。
npx create-remix@latest cd hoge
サーバをexpressにする
expressを入れて、server.tsを追加します。
npm i express @remix-run/express cross-env @types/express touch server.ts
server.tsを下記にします。
import { createRequestHandler } from "@remix-run/express"; import express from "express"; import type { ViteDevServer } from "vite"; import type { ServerBuild } from "@remix-run/node"; const viteDevServer: ViteDevServer | null = process.env.NODE_ENV === "production" ? null : await import("vite").then((vite) => vite.createServer({ server: { middlewareMode: true }, }) ); const app = express(); app.use( viteDevServer ? viteDevServer.middlewares : express.static("build/client") ); const build: ServerBuild | (() => Promise<ServerBuild>) = viteDevServer ? () => viteDevServer.ssrLoadModule("virtual:remix/server-build") as Promise<ServerBuild> : (await import("./build/server/index.js")) as ServerBuild; app.all("*", createRequestHandler({ build })); app.listen(3000, () => { console.log(process.env.NODE_ENV); console.log("App listening on http://localhost:3000"); });
上記のserver.tsの、./build/server/index.jsというパスの部分でエディタでエラーが出るので、一度ビルドしておきます。
npm run build
vite.config.tsを下記にします。
import { vitePlugin as remix } from "@remix-run/dev"; import { defineConfig, loadEnv } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig(({ mode }) => { // 環境変数をロード const env = loadEnv(mode, process.cwd(), ''); return { plugins: [ remix({ future: { v3_fetcherPersist: true, v3_relativeSplatPath: true, v3_throwAbortReason: true, }, }), tsconfigPaths(), ], define: { 'process.env': env } }; });
package.jsonのscriptを下記にします。下記2つ以外は変更する必要はありません。
"scripts": { "dev": "tsx ./server.ts", "start": "cross-env NODE_ENV=production node ./server.ts" },
tsxをインストールします。
npm i -D tsx
これでサーバがexpressになって、開発環境だとHot Reloadされるようになりました。
フォーマッターを入れる
次はBiomeを入れてみたいと思います。
npm install --save-dev --save-exact @biomejs/biome npx @biomejs/biome init
VSCodeの設定をやってみます。 とりあえず、ワークスペースのsettings.jsonに下記を追加しました。
{ "[javascript]": { "editor.defaultFormatter": "biomejs.biome" } }
うまくいかない。下記のようなエラーが出る。

biome.jsonの初期状態じゃだめなのかな。 よく分からないからprettierにしよう。
npm i -D prettier eslint-config-prettier touch .prettierrc
.prettierrcを下記にします。
{
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"semi": true,
"printWidth": 100
}
daisyUIを入れる
次はdaisyUIを入れます。
npm i -D daisyui@latest
tailwind.config.tsを修正します。puginにdaisyuiを追加します。 darkModeがデフォルトでONになるので、OFFになるようにしてオリジナルの色を設定してみます。
import type { Config } from 'tailwindcss'; export default { content: ['./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}'], theme: { extend: {}, }, plugins: [require('daisyui')], darkMode: 'class', daisyui: { themes: [ { logicky: { primary: '#663399', secondary: '#F000B8', accent: '#37CDBE', neutral: '#3D4451', info: '#3ABFF8', success: '#36D399', warning: '#FBBD23', error: '#F87272', 'base-100': '#FFFFFF', 'base-200': '#fcf6ff', 'base-300': '#f6f0f9', }, }, ], darkTheme: 'logicky', }, } satisfies Config;
daisyUIが反映されました。

Laravel Generatorを使ってみる
CakePHPのBakeみたいなやつですね。php artisanでモデルを作っても中身は空っぽなわけですが、中身があるやつが、laravel-generatorですね。AIがあるので必要性は下がっているかもしれませんが、jsonファイルでよい感じにスキーマを指定したら、migrationファイル、モデルファイル、そしてコントローラ、Viewファイルまでとりあえず作ってくれるので、LLMにお願いすることが減ってよいかなと思っております。
環境
windows11, PowerShellです。PHP8.3, Laravel10.48.17, Node22です。
インストール
上記のとおりインストールしました。
使い方
リポジトリ
試した際のコードです。
感想
AI様がすごいですので、やはり衝撃は受けませんでした。また、準備までに色々なものを個別にインストールが必要ですので、CakePHPのBakeのようなお手軽感もあんまりありませんでした。といってももちろん、非常に便利で有難い機能であることは間違いございません。
AIにマイグレーションファイルを作ってもらって、php artisan migrateして、DBテーブルを読み込む形でlaravel generatorを使うのが自分的にはよいかもなあと思いました。DBテーブルを読み込む形にするとリレーションも自動でコードに反映されるようです。
実際的には自動出力したコードをそのまま使えるケースはほとんどない、という点は、CakePHPのBakeの時も同じでした。慣れてくるとBakeは全く使っていませんでした。特にView周りは、ちょっと古い感じがしました。bootstrapとjQueryを使っています。
ですので、私的には、DBテーブルをもとに、モデル、APIコントローラ、テストの自動生成位が使えるシーンがあるかもなあと思いました。Swaggerのドキュメント自動生成もあるのですが、私の好みの雰囲気ではありませんでした。NestJSっぽい感じかもなあと思いましたが、ドキュメント生成のために、色々なところに、コメントを埋め込む必要がありまして、AI様がいますので、さくっと口頭で仕様を伝えた際に、そのままSwaggerドキュメント作ってもらっちゃう方が楽だし、コードもすっきりするかもなあ、的な感想を持ちました。