iOSアプリでファイル取得処理にRepositoryパターンを活用してPreviewする

年末にやっていた個人アプリ開発でアプリでのTipsの1つです。
他に良いやり方もあるかもしれませんが、Swift触り始めて4日目ぐらいの感想になります。

TL;DR

  • Previewでドキュメントディレクトリにアクセスするとエラーが出る、もしくはクラッシュして2度と起動しなくなる
  • ファイルの取得処理はRepositoryに任せることで差し替えしやすくする
  • Previewではテスト用に実装されたRepositoryに差し替えることでエラーも出ずに確認できる

なぜ取得処理をRepositoryパターンとして実装しようとしたのか?

Repositoryパターンでも問題はないんですが、取得処理をViewから剥がすことが重要でした。
SwiftUIではとても便利なPreviewという機能があるんですが、アプリのドキュメントディレクトリにあるファイルを取得して表示するようなViewなどの場合、 このアクセスがきっかけでエラーで表示できないことや、Previewがクラッシュして同じ端末では2度と起動できないという現象に遭遇しました。

これらを防ぐために取得処理自体を別の層に移し、Previewでは実際にファイルアクセスをしないようなダミーの実装クラスを用意したかったのが経緯になります。

ファイルの取得処理をRepositoryパターンで実装する

今回はサンプルとしてドキュメントディレクトリに保存されているPDFファイルを一覧で表示するViewを題材にします。

表示後のイメージは以下のような形です。

ファイルの取得処理を実装する

はじめから実装の話になりますが、まずはファイルの取得処理のためのProtocolを定義します。

protocol FileRepository {
    func fetchDocuments() -> [PDFDocument]
}

URLではなくPDFDocumentを返しているのは、その方がView側での使い勝手が良いためと、
ファイルアクセスが入るとPreviewでエラーもしくはクラッシュする可能性が高まるためです。

そして、次にアプリとして動作させるための本実装をします。
特筆することはありませんが、PDF拡張子であるファイルのURLを元にPDFDocumentを生成しています。

class FileRepositoryImpl: FileRepository {
    private let fileManager = FileManager.default

    func fetchDocuments() -> [PDFDocument] {
        guard let path = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return [] }
        
        do {
            let urls = try fileManager.contentsOfDirectory(at: path, includingPropertiesForKeys: nil)
            return urls
                .filter { $0.pathExtension == "pdf" }
                .map { PDFDocument(url: $0)! }
        } catch {
            print("Error while enumerating files \(path.path): \(error.localizedDescription)")
            return []
        }
    }
}

Viewを実装する

テスト用の実装の前に先にViewを作ります。

struct ContentView: View {
    var fileRepository: FileRepository = FileRepositoryImpl()
    private var documents: [PDFDocument] {
        fileRepository.fetchDocuments()
    }
    private var columns: [GridItem] {
        Array(repeating: .init(.flexible()), count: 2)
    }

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 20) {
                ForEach(documents, id: \.documentURL?.lastPathComponent) { document in
                    VStack {
                        Image(uiImage: generateThumbnail(document: document)!)
                            .resizable()
                            .aspectRatio(contentMode: .fit)
                            .cornerRadius(10)
                        
                        Text(document.documentURL!.lastPathComponent)
                            .font(.headline)
                            .frame(minHeight: 2 * UIFont.systemFontSize * 1.5, alignment: .topLeading)
                            .lineLimit(2)
                    }
                    .shadow(radius: 5)
                    .padding()
                }
            }
        }
    }
    
    private func generateThumbnail(document: PDFDocument) -> UIImage? {
        let scale = 1.0
        guard let page = document.page(at: 0) else { return nil }
        let pageSize = page.bounds(for: .mediaBox)
        let thumbnailSize = CGSize(width: pageSize.width * scale, height: pageSize.height * scale)
        return page.thumbnail(of: thumbnailSize, for: .mediaBox)
    }
}

サンプルとしては雑な実装となっていますが、以下のようにfileRepositoryをプロパティとして定義し、 ContentViewの初期化時に上書きできるような定義をしておきます。

    // デフォルトではFileRepositoryImpleを使うが
    // ContentView(fileRepository: Dummy())のような宣言ができるようにする
    var fileRepository: FileRepository = FileRepositoryImpl()

PreviewのためのRepositoryを実装する

さて下準備はできたのでPreviewのための実装をします。
前提条件としては以下をおさらいしておきます。

  • Previewでドキュメントディレクトリにアクセスするとエラーもしくはクラッシュするのでアクセスさせない
  • FileRepositoryではPDFDocumentの配列として返す
  • documentURLのlastPathComponentにアクセスしている
  • サムネイルを生成して表示しているので何かが表示されているデータになっているとPreviewで確認しやすい

これができるように実装を進めていきます。

class PreviewFileRepositoryImpl: FileRepository {
    class DummyPDFDocument: PDFDocument {
        private let url: URL
        init(url: URL, data: Data) {
            self.url = url
            super.init(data: data)!
        }
        
        override var documentURL: URL? { return url }
    }
    
    private let words: [String] = [
        "Spring", "Summer", "Autumn", "Winter",
        "January", "February", "March", "April", "May", "June",
        "July", "August", "September", "October", "November", "December",
        "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday",
        "Day", "Night", "Morning", "Evening", "Noon", "Midnight",
        "Sun", "Moon", "Star", "Sky", "Cloud"
    ]
    
    func fetchDocuments() -> [PDFDocument] {
        return words.map { makePDF(name: $0)! }
    }
    
    func makePDF(name: String) -> DummyPDFDocument? {
        let data = NSMutableData()
        let pdfRect = CGRect(x: 0, y: 0, width: 612, height: 792)
        UIGraphicsBeginPDFContextToData(data, pdfRect, nil)

        UIGraphicsBeginPDFPage() // 描画開始
        if UIGraphicsGetCurrentContext() != nil {
            let style = NSMutableParagraphStyle()
            style.alignment = .center
            let attributes: [NSAttributedString.Key: Any] = [
                .font: UIFont.systemFont(ofSize: 64),
                .paragraphStyle: style
            ]
            
            let size = name.size(withAttributes: attributes)
            let rect = CGRect(
                x: (pdfRect.width - size.width) / 2,
                y: (pdfRect.height - size.height) / 2,
                width: size.width,
                height: size.height
            )

            name.draw(
                in: rect,
                withAttributes: attributes
            )
        }
        UIGraphicsEndPDFContext() // 描画終了
        
        let url = URL(fileURLWithPath: "/path/to/\(name).pdf")
        return DummyPDFDocument(url: url, data: data as Data)
    }
}

さてまずはDummyPDFDocumentについてです。
PDFDocument(data: data) だけでもPDFデータは生成できるのですが、この定義の場合documentURLnilになってしまいます。
ここに依存している処理がViewでは存在しているため、DummyPDFDocumentクラスを用意して、初期化時に渡されたURLを返すようにoverrideしておきます。
こうすることで、URLやパスの取得処理が走る場合には対応できるケースが多いです。

    class DummyPDFDocument: PDFDocument {
        private let url: URL
        init(url: URL, data: Data) {
            self.url = url
            super.init(data: data)!
        }
        
        override var documentURL: URL? { return url }
    }

次にファイルの取得処理についてです。
ここでは、事前に定義されているwordsごとにDummyPDFDocumentを生成して返すような実装になっています。

    func fetchDocuments() -> [PDFDocument] {
        return words.map { makePDF(name: $0)! }
    }
    
    func makePDF(name: String) -> DummyPDFDocument? {
        let data = NSMutableData()
        let pdfRect = CGRect(x: 0, y: 0, width: 612, height: 792)
        UIGraphicsBeginPDFContextToData(data, pdfRect, nil)

        UIGraphicsBeginPDFPage() // 描画開始
        if UIGraphicsGetCurrentContext() != nil {
            let style = NSMutableParagraphStyle()
            style.alignment = .center
            let attributes: [NSAttributedString.Key: Any] = [
                .font: UIFont.systemFont(ofSize: 64),
                .paragraphStyle: style
            ]
            
            let size = name.size(withAttributes: attributes)
            let rect = CGRect(
                x: (pdfRect.width - size.width) / 2,
                y: (pdfRect.height - size.height) / 2,
                width: size.width,
                height: size.height
            )

            name.draw(
                in: rect,
                withAttributes: attributes
            )
        }
        UIGraphicsEndPDFContext() // 描画終了
        
        let url = URL(fileURLWithPath: "/path/to/\(name).pdf")
        return DummyPDFDocument(url: url, data: data as Data)
    }

makePDFは、DummyPDFDocumentを作成しますが、サムネイルとして表示するためのページを作成したいので、UIGraphicsを使って気合いで描画しています。

Previewする

最後にPreviewですが、先程実装したPreview用のFileRepositoryを初期化時に渡してあげることでうまく動作することが確認できるかと思います。

#Preview {
    ContentView(fileRepository: PreviewFileRepositoryImpl())
}

こうすることで最初に提示したイメージ通りにPreviewが表示されることが確認できました。

まだ触って5日目程度で書いた記事なので、別の良い実装があれば気軽にコメントいただければと。

2023年買ってよかったもの

2023年に買ってよかったものを書いておきます。
2023年も色々細かい買い物してたんですが、かなり印象に残ってるのは1つしかないので、それを書いておきます。

Cleer Arc 2

イヤホンは外耳炎になりがちなのでヘッドホンを使っていたんですが、これに切り替えてからは外出先でも家でも気軽に使えるようになって、めちゃくちゃはかどっています。
耳をふさがないので外音も通常通り聞こえますし、運動中でも重宝しています。

www.cleeraudio.jp

SwiftDataで動的なキーワード検索を実装する

最近は自分向けのPDFリーダーアプリを開発しています。
このアプリではSwiftDataを使って読書履歴をモデル化して管理しているのですが、これを踏まえてちょっと自分のメモがてらにTODOアプリを例にしたメモを残しておきます。

Swiftを触り始めて3日目ぐらいなので、書き間違えや嘘などがあるかもしれません。
その場合はコメントなどで教えてもらえると助かります。

TL;DR

  • SwiftDataの@Queryで動的な条件は付与できない
  • Computed Propertiesを使って@Queryで定義したフィルターを利用する

基本的な使い方

まず今回のサンプルとなるタスクを一覧表示するViewと、Modelを定義します。
以下のような仕様で作成しておきます。

  • Taskモデル
    • nameをプロパティとして持つ
  • TodoView
    • Taskを一覧表示する
    • 画面上部にはキーワード検索のためのTextFieldを設定
import SwiftUI
import SwiftData

@Model
final class Task {
    var name: String
    
    init(name: String) {
        self.name = name
    }
}

struct TodoView: View {
    @Environment(\.modelContext) private var context
    @State private var keyword: String = ""
    @Query(sort: \Task.name) private var tasks: [Task]

    var body: some View {
        VStack {
            HStack {
                TextField("検索...", text: $keyword)
                    .padding()
                    .background(primaryColor)
                    .cornerRadius(5)
            }
            .buttonStyle(PlainButtonStyle())
            .padding(.horizontal)
            
            List(tasks) { task in
                Text(task.name)
            }
        }
    }
}

// Preview
struct TodoView_Previews: PreviewProvider {
    static var words: [String] = [
        "Summery", "Country", "Perk", "Relief", "Virus",
        "Hunter", "Photocopy", "Liberal", "Sugar", "Practice",
    ]

    static var container: some ModelContainer {
        let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try! ModelContainer(for: Task.self, configurations: configuration)
        
        // プレビュー用データを作成
        words.forEach { word in
            let task = Task(name: word)
            container.mainContext.insert(task)
        }
        
        return container
    }
    
    static var previews: some View {
        TodoView()
            .modelContainer(container)
    }
}

この段階では、検索用のTextFieldがありますが絞り込むロジックを書いていません。
そのため絞り込むためのロジックを書いていきます。

キーワードでタスクを絞り込む 

キーワード検索を絞り込むためにはどういったやり方があるでしょうか?
最初は@Queryのfilterに絞り込むためのロジックを書けばいけると思っていたのですが、ここではkeyword自体を参照することができませんでした。

// エラー
@Query(filter: #Predicate<Task> { $0.name.contains(keyword) }, sort: \Task.name) private var tasks: [Task]

そして小一時間ほど公式ドキュメントを眺めたりしながら試行錯誤した結果、Computed Propertiesなプロパティを定義することで実現することができました。

// 成功
@Query(sort: \Task.name) private var tasks: [Task]
private var filteredTasks: [Task] {
    guard !keyword.isEmpty else { return tasks }
    return tasks.filter { $0.name.contains(keyword) }
}
  • キーワードが空文字列の場合はtasksをそのまま返す
  • それ以外の場合、タスク名にキーワードが含まれている文字列を返す

というプロパティを定義することで動的なキーワード検索を比較的楽に実現することができました。

もし同じように悩んでいる方がいれば参考にしてみてください。

Argo CD, Helm, Renovateを使ったコンポーネントの継続的アップデート

皆さん、こんにちは!
この記事はSRE Advent Calendar 2023の16日目の記事になります。

今回の話は、k8sクラスタでのコンポーネントの継続的アップデートを行うための工夫についての紹介です。
SREとして信頼性や安定性を担保するためにアップデートの話は必要不可欠化と思うので今回の話にしました。

紹介例がおうちクラスタマニフェストになっていますが、実際の業務にも転用できるので参考になれば幸いです。

TL;DR

  • Kubernetes環境でのセキュリティ、機能性、パフォーマンスの維持には継続的なアップデートが必要不可欠
  • Argo CD, Helm Charts, Renovateの組み合わせを組み合わせることでアップデートの自動化、設定管理、依存関係の更新を迅速かつ安全に行う

継続的アップデートの重要性

システムの信頼性や安定性を担保するためにもアプリケーションやサービス、ツールの継続的なアップデートは重要です。
アップデートを行うことで、セキュリティの向上、バグ修正や機能改善、パフォーマンスや安定性の向上が見込めます。

そのため、Kubernetesクラスタでは、クラスタ自体のバージョンアップや、各種コンポーネントのアップデートはこまめに行うことが重要です。

継続的にアップデートを行う場合の課題

Kubernetesコンポーネントで継続的にアップデートを行う場合には以下のような課題が挙げられます。

  • マニフェスト更新の複雑化
  • APIバージョンやリソース変更への対応
  • 依存関係の解決

今回は、Argo CD, Helm, Renovateを使って課題を解消・低減する方法について紹介します。

Argo CD, Helm, Renovateの組み合わせ

Argo CD, Helm Charts, Renovateといったツールを組み合わせることで解消・低減することができます。
これらのツールは、異なる側面からアプローチし、Kubernetesの運用を簡素化し、自動化します。

Argo CD+Helm

Argo CDは、GitOpsなCDツールでリポジトリにあるマニフェストの状態を監視し、クラスタへ手動もしくは自動でリソースの作成や変更を反映してくれます。
そして、Argo CDはKustomizeやHelmといったツールもサポートしており、それらのマニフェストを指定することもできます。

argo-cd.readthedocs.io

この組み合わせでコンポーネントを管理すれば、Argo CDで自動デプロイする仕組みを構築することが可能です。
例として、実際のおうちクラスタで管理しているWordPressのArgo CD Applicationを張っておきます。

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: wordpress
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  sources:
    - ref: home
      repoURL: https://github.com/corrupt952/home
    - path: manifests/wordpress/base
      repoURL: https://github.com/corrupt952/home
    - chart: mariadb
      repoURL: https://charts.bitnami.com/bitnami
      targetRevision: 14.1.4
      helm:
        releaseName: mariadb
        valueFiles:
          - $home/manifests/values/mariadb.yaml
    - chart: wordpress
      repoURL: https://charts.bitnami.com/bitnami
      targetRevision: 18.1.27
      helm:
        releaseName: wordpress
        valueFiles:
          - $home/manifests/values/wordpress.yaml
  destination:
    server: "https://kubernetes.default.svc"
    namespace: wordpress
  syncPolicy:
    syncOptions:
      - CreateNamespace=true
    automated:
      prune: true
      selfHeal: true

アップデートは、Helm Chartsのバージョン(例だとtargetRevision)を上げるだけで比較的容易に行うことができます。

この組み合わせの時点で、挙げた課題についてはほぼ解消・低減できていますが、アップデート作業も楽にしたいのでRenovateを組み合わせます。

Renovate

Argo CD+Helmで管理されているリポジトリにRenovateを追加することで、アップデートをRenovateが自動化してくれます。

以下の例では、Argo CDやKubernetesマニフェストを読み取って各種コンポーネントをアップデートするための設定です。

{
  $schema: "https://docs.renovatebot.com/renovate-schema.json",
  extends: [
    "github>corrupt952/.github:default.json5",
  ],

  argocd: {
    fileMatch: ["manifests/argocd-config/base/.*\\.yaml"],
  },
  kubernetes: {
    fileMatch: ["manifests/.*/base/.*\\.yaml"],
  }
}

さらにRenvoateのAutomergeをパッチやマイナーバージョンにのみ適用することで、Pull Requestを手動でマージしなくてもクラスタへ反映することができるようになります。

おうちクラスタでの実践例

実際のマニフェストや設定は以下のリポジトリにまとめてあります。
参考にする場合は以下を見てみてください。

github.com

ただし、このレポはモノレポ運用しているため、AnsibleのPlaybookが混じっていることに注意してください。

まとめ

Argo CD, Helm, Renovateを組み合わせてコンポーネントの継続的なアップデートを行う方法を紹介しました。

より楽により安全にアップデートするためにも今回紹介した方法が参考になれば幸いです。

それではよい週末・年末を :)

おうちクラスタにCDIをデプロイしてUbuntuを動かす

前回の続きでContainerized Data Importer(以下、CDI)を導入してUbuntu仮想マシンを動かします。

khasegawa.hatenablog.com

マニフェストを用意する

マニフェストの取得コマンドは省きしますが、マニフェストをkustomization.yamlから参照するように定義しておきます。

---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: cdi
resources:
  - https://github.com/kubevirt/containerized-data-importer/releases/download/v1.57.0/cdi-operator.yaml
  - https://github.com/kubevirt/containerized-data-importer/releases/download/v1.57.0/cdi-cr.yaml

クラスタにデプロイする

前回同様にArgo CD Applicationを定義して、CDIをデプロイします。
マニフェストは以下のようになります。

---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: cdi
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  sources:
    - path: manifests/cdi/base
      repoURL: https://github.com/corrupt952/home
  destination:
    server: "https://kubernetes.default.svc"
    namespace: cdi
  syncPolicy:
    syncOptions:
      - CreateNamespace=true
    automated:
      prune: true
      selfHeal: true

ボリュームの定義をする

PVCを定義する方法もありますが、今回はDataVolumeを定義してUbuntuのイメージをベースとしたデータボリュームを定義しておきます。
今回はUbuntu 22.04を使うので以下のような書き方にしました。
urlstorageClassNameは環境に合わせて変更してください。

---
apiVersion: cdi.kubevirt.io/v1beta1
kind: DataVolume
metadata:
  name: ubuntu-volume
spec:
  source:
    http:
      url: "https://cloud-images.ubuntu.com/jammy/20231027/jammy-server-cloudimg-amd64.img"
  pvc:
    storageClassName: nfs
    accessModes:
      - ReadWriteOnce
    resources:
      requests:
        storage: 8Gi

Argo CDでデプロイしても良いのですが、動作確認のためだけなのでkubectl applyを直接実行して反映します。
この方法でもPVCは作成されるので作成されたかどうかは、以下のコマンドで確認できます。

# DataVolumeの確認
kubectl get dv

# PersistentVolumeClaimの確認
kubectl get pvc

DataVolumeの方でSuccessedと出ていれば問題ありません。
PVCがPendingのままの場合は、storageClassNameクラスタ内で利用可能なStorageClass`になっているのかは確認しましょう。

仮想マシンを定義する

VirtualMachineを定義します。
DataVolumeであるubuntu-volumedisk0という名前でマウントして使います。
また今回はbeestrapというISUCON向けのBootstrappingレポの動作確認も行いたいので Serviceを定義してCockpitやNetdataのポートを解放しておきました。

---
apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
  name: ubuntu
  labels:
    kubevirt.io/os: linux
spec:
  running: true
  template:
    metadata:
      labels:
        kubevirt.io/size: small
        kubevirt.io/domain: ubuntu
    spec:
      domain:
        cpu:
          cores: 2
        devices:
          disks:
            - name: disk0
              disk:
                bus: virtio
            - name: cloudinitdisk
              cdrom:
                bus: sata
                readonly: true
          interfaces:
            - name: default
              masquerade: {}
        machine:
          type: q35
        resources:
          requests:
            memory: 2048M
      networks:
        - name: default
          pod: {}
      volumes:
        - name: disk0
          persistentVolumeClaim:
            claimName: ubuntu-volume
        - name: cloudinitdisk
          cloudInitNoCloud:
            userData: |
              #cloud-config
              hostname: ubuntu
              ssh_pwauth: true
              password: ubuntu
              chpasswd:
                expire: false

---
apiVersion: v1
kind: Service
metadata:
  name: ubuntu
  labels:
    kubevirt.io/os: linux
spec:
  ports:
    - name: ssh
      port: 22
      targetPort: 22
    - name: cockpit
      port: 9090
      targetPort: 9090
    - name: netdata
      port: 19999
      targetPort: 19999
  selector:
    kubevirt.io/domain: ubuntu

こちらもDataVolumeと同様にkubectl applyで直接反映します。
1分ほど起動に時間がかかるかもしれませんが、起動できればsshで接続することもできます。

kubectl virt ssh --local-ssh ubuntu@ubuntu 

またVNCで接続したい場合はプロキシーとしてコマンドを実行するか、VNCクライアントを直接起動することもできます。
以下の例では、tigervncのクライアントがインストールされているWSL2の環境でクライアントを起動します。

kubectl virt vnc ubuntu

これで諸々の動作確認が終わりました。 Serviceで定義したポートに関しては仮想マシン内にCockpitなどをインストール後にport-forwardなどで動作確認すると接続できます。

おうちクラスタにKubeVirtをデプロイする

おうちクラスタにKubeVirtをデプロイした時の話を書いておきます。

実際に変更したコミットは以下にあるので参考にしてください。

github.com

マニフェストを用意する

公式のInstalling KubeVirt on Kubernetesを元に必要なファイルを書いていきます。 まずは以下のコマンドをkustomization.yamlに書き換えてArgo CDでデプロイできるようにします。

# Point at latest release
$ export RELEASE=$(curl https://storage.googleapis.com/kubevirt-prow/release/kubevirt/kubevirt/stable.txt)
# Deploy the KubeVirt operator
$ kubectl apply -f https://github.com/kubevirt/kubevirt/releases/download/${RELEASE}/kubevirt-operator.yaml
# Create the KubeVirt CR (instance deployment request) which triggers the actual installation
$ kubectl apply -f https://github.com/kubevirt/kubevirt/releases/download/${RELEASE}/kubevirt-cr.yaml
# wait until all KubeVirt components are up
$ kubectl -n kubevirt wait kv kubevirt --for condition=Available

↓ kustomization.yamlに書き換える

---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: kubevirt
resources:
  - https://github.com/kubevirt/kubevirt/releases/download/v1.1.0/kubevirt-operator.yaml
  - https://github.com/kubevirt/kubevirt/releases/download/v1.1.0/kubevirt-cr.yaml

クラスタにデプロイする

おうちクラスタではArgo CDを使ってマニフェストを反映しています。 なので、Argo CD Applicationを作成してデプロイします。

---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: kubevirt
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  sources:
    - path: manifests/kubevirt/base
      repoURL: https://github.com/corrupt952/home
  destination:
    server: "https://kubernetes.default.svc"
    namespace: kubevirt
  syncPolicy:
    syncOptions:
      - CreateNamespace=true
    automated:
      prune: true
      selfHeal: true

後はデフォルトブランチにプッシュすれば、弊宅では反映されます。

これで諸々の作業は完了です。 krew経由でvirtctlをインストールしておくと楽なのでオススメしておきます。

余談

弊宅のクラスタネットワークはサブネットが異なるため、ただのsshだけでは接続ができないのでvirtctlを使ってsshをしています。

kubectl virt ssh cirros@testvm

似たようなネットワーク構成の人がいれば参考にまでにどうぞ

Rails 7.0.8でdatetimeなカラムの精度が修正された件について

はじめに

少し前にRails 7.0.8がリリースされましたが、その前のバージョンである7.0.7.2でマイグレーションした結果のdb/schema.rbが異なるということについて質問されたので、金曜夜に解説した内容を文章にまとめたものになります。

前提

  • Rails 7.0.7.2 -> 7.0.8へアップグレードした時に起きた現象
  • DBはMySQL
  • 発生する現象は、6.1なマイグレーションファイルでchange_columnしているdatetimeなカラムのprecisionがdb/schema.rbについたりつかなかったりする

TL; DR

カラムの精度に関する変更

7.0.8からマイグレーションファイルのバージョンが6.1の場合、datetimeなカラムの精度は指定なしでマイグレーションされるように修正されました。

github.com

これにより、Rails 7.0.7.2と7.0.8の間でカラムの定義に違いが生じる場合があります。

7.0.7.2以前の7系と7.0.8とでdatetimeなカラムの作成方法が異なります。

  • Rails 7.0.7.2 ... datetime(6) で作成
  • Rails 7.0.8 ... datetime で作成

そのため、7.0.8の修正が入る前の7系のRailsと、7.0.8のRailsのどちらのタイミングでマイグレーションしたかによって、カラムの定義が異なる可能性があります。

以下のようなマイグレーションファイルが定義されている場合、それぞれのバージョンでスキーマにどういった影響があるのかを確認してみます。

class Hoge< ActiveRecord::Migration[6.1]
  def change
    change_column :users, :inactive_at, :datetime, default: '2023-10-27 14:50:04'
  end
end

それぞれのバージョンでマイグレーションした場合のテーブル定義は以下のようになっており、inactive_atのdatetimeの精度指定が異なることが分かります。

# Rails 7.0.7.2でマイグレーションした場合のdatetimeなカラムの定義
>  show create table users \G;
*************************** 1. row ***************************
       Table: users
Create Table: CREATE TABLE `users` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL,
  `email` varchar(255) DEFAULT NULL,
  `inactive_at` datetime(6) DEFAULT '2023-10-27 14:50:04.000000',
  `created_at` datetime(6) NOT NULL,
  `updated_at` datetime(6) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

# Rails 7.0.8でマイグレーションした場合のdatetimeなカラムの定義
>  show create table users \G;
*************************** 1. row ***************************
       Table: users
Create Table: CREATE TABLE `users` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL,
  `email` varchar(255) DEFAULT NULL,
  `inactive_at` datetime DEFAULT '2023-10-27 14:50:04',
  `created_at` datetime(6) NOT NULL,
  `updated_at` datetime(6) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
1 row in set (0.000 sec)

カラム定義の違いの影響

このカラム定義の違いが、実際のアプリケーションにどのような影響を与えるのでしょうか?
例えば、7.0.7.2でマイグレーションしていたAさんの環境と、7.0.8にアップグレードした後にデータベースを作り直したBさんの環境とでは、rails db:schema:dumpの結果が異なります。
この挙動によっては、ローカルだけではなく特定のテスト環境や場合によっては本番環境を含めて、書く環境ごとにdatetimeの精度が異なるという可能性がありえます。

おわりに

本記事では、Rails 7.0.8と7.0.7.2の間でdatetimeなカラムのschema.rbが異なることについて解説しました。
アプリケーション開発やマイグレーションを行う際には、Railsのバージョンによるカラム定義の違いに注意する必要があります。
正確なカラムの定義を把握し、適切にマイグレーションを行うことで、問題なくアプリケーションを運用することができます。