Logicky Blog

Logickyの開発ブログです

k8sでローカル環境でイメージがあるのにpullエラーになるときはimagePullPolicyがデフォルトになっているかもしれない

今k8sとTiltを使っているのですが、結構な頻度で、再起動時にトラブルが起きます。あるPODがうまく起動しないのです。 イメージはありますが、ErrImgaePullになるのです。

ここからは実験をしていきます。

まず問題のPODのイメージを削除します。このイメージは、TiltのダミーのDeploymentの起動時にビルドされます。 なので、tiltを再起動しないで、起動しようとすると、pull errorみたいなやつになります。やってみましょう。

ErrImagePullでした。あれ、いつもとエラーが違う。いつもはImagePullBackoffみたいなやつだったような。。

ここで、ErrImagePullの意味を調べましょう。

これは勉強になります。ありがとうございます。

sysdig.jp

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を使うことで超便利になってよかったです。

作った内容(リポジトリ)

github.com

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.inisqlalchemy.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というのを使っているようです。

fastapi.tiangolo.com

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)

エディタでエラーになるのですが、実際的には問題なく動作しています。 つまり実際はエラーではないので、エラーが表示されないようにしたいです。

こちらに解決策が載っていました。

github.com

プロジェクトのルートに、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が教えてくれました。

github.com

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}"

あとはメールを受信したら、下記のコンソールで確認できます。

http://localhost:8025

macのCursorのキーボードショートカット

忘れたら困るやつをメモしておきます。

  • ターミナルの表示切り替え : ⌘J
  • サイドバーの表示切り替え : ⌘B
  • ターミナルの全画面化の切り替え : ⌘H

上記のターミナルの全画面化は、自分で設定しました。 キーボードショートカット設定の画面で、toggleMaximizedPanelと検索すると出てきます。 対応するキーが設定されていなかったので、⌘H を設定してみました。

追記

  • なんと、⌘J でもターミナル(のパネル)の表示切り替えができましたので、上記を修正しました。
  • フォーマットは、option + shift + F でした。押しづらい。
  • 下記にあるVS CodeのVimの設定をコピペしました。フォーマットも、space + f でできるようになりました。

blog.logicky.com

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

まあでもまたエラーですが。

github.com

大変そうだな。。 まあでも、これはあくまで、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');

これでできました。

日本語化する

これを使います。

github.com

使い方はこれです。

laravel-lang.com

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をつくってみよう!

こちらの続きになります。

blog.logicky.com

上記で、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です。

インストール

infyom.com

上記のとおりインストールしました。

使い方

infyom.com

リポジトリ

試した際のコードです。

github.com

感想

AI様がすごいですので、やはり衝撃は受けませんでした。また、準備までに色々なものを個別にインストールが必要ですので、CakePHPのBakeのようなお手軽感もあんまりありませんでした。といってももちろん、非常に便利で有難い機能であることは間違いございません。

AIにマイグレーションファイルを作ってもらって、php artisan migrateして、DBテーブルを読み込む形でlaravel generatorを使うのが自分的にはよいかもなあと思いました。DBテーブルを読み込む形にするとリレーションも自動でコードに反映されるようです。

実際的には自動出力したコードをそのまま使えるケースはほとんどない、という点は、CakePHPのBakeの時も同じでした。慣れてくるとBakeは全く使っていませんでした。特にView周りは、ちょっと古い感じがしました。bootstrapとjQueryを使っています。

ですので、私的には、DBテーブルをもとに、モデル、APIコントローラ、テストの自動生成位が使えるシーンがあるかもなあと思いました。Swaggerのドキュメント自動生成もあるのですが、私の好みの雰囲気ではありませんでした。NestJSっぽい感じかもなあと思いましたが、ドキュメント生成のために、色々なところに、コメントを埋め込む必要がありまして、AI様がいますので、さくっと口頭で仕様を伝えた際に、そのままSwaggerドキュメント作ってもらっちゃう方が楽だし、コードもすっきりするかもなあ、的な感想を持ちました。