MastodonをGoogle Container Engine(GKE)で構築する

mastodonをGoogle Container Engine(GKE)、Google Cloud SQL、Google Cloud Storage、Redis Labsを使って構築し、pawoo.netの素晴らしいイラストを収集する方法

Last Update: 2018/05/23

Google Compute Engineで構築する方法はこちら

公式のProduction-guideDocker-Guide を参考に構築します。

Dockerイメージは、単体で全て入りですが、ここでは メディアファイルを溜め込むため、データストアにGoogle Cloud Storageを利用し、 DBにGoogle Cloud SQLでPostgreSQLを利用します。

コンテナの再起動の際の永続化が難しいので、RedisサーバにはRedis Labs のマネージドサーバを利用することとします。

ここでは、メール送信にmailgunを利用します。他のメールサービスや自前のサーバでもかまいません。

料金について

$100/mo弱となる。クラスタのnode数を下げるともう少し料金が下がる。

構築の方法

  1. mailgun

    mailgunにアクセスし、アカウントを取得します。 画面に従いドメインの登録を行います。指示に従い、お使いのDNSサーバにエントリを追加しておきます。 クレジットカードの登録をしてConcept Planに昇格させておかないと、メールが送れず困るので登録を忘れずに。 10,000emails/monthまで無料です。

    Domainsページから、登録したドメインを選択し、設定をメモします。

    Default SMTP LoginとDefault Passwordが後で必要になります。

  2. Google Cloud Storage

    Google Cloud Storageにアクセスし、バケットを作っておきます。

    ここでは、名前を"mastodon"、Regionalを指定し、ロケーションは後でサーバーを立てるリージョンを指定します。

    設定の相互運用性に行き、"新しいキーを作成"からアクセスキーと非公開を作成し、メモしておきます。

  3. Google Cloud SQL

    Google Cloud SQLにアクセスし、インスタンスを作成しておきます。

    インスタンスの作成から、データベースエンジンの選択に進み、PostgreSQLを選択します。

    次にインスタンスIDを適当に決め、リージョンをサーバーを立てるところに指定しておきます。 マシンタイプは最小の1vCPU, 3.75GBかその下のdb-pg-f1-micro(共有CPU, RAM:0.6GB)で十分です。その他はデフォルトのままでよいです。 デフォルトのユーザーpostgresのパスワードを生成して、記録しておきます。

  4. Redis Labs

    Redis Labsからアカウントを作成します。

    1. 新しい Redis サブスクリプションを作成します
    2. クラウド GCE/us-central1 を選択します
    3. 無料版を選択します
    4. リソース名とパスワードを入力します
    5. サーバ名とポート、入力したパスワードは後で必要となるので記録しておきます。

    有料のサブスクリプションもありますが、無料版で大丈夫です。無停止で有料サブスクリプションに移行することができるので 必要になったときに購入すると良いでしょう。

    有料のサブスクリプションを利用すると、In Memory ReplicationとData Persistenceを利用することができます。 100MB $6/moから

  5. Google Container Engine(GKE)

    Container Engineのコンテナは、Compute Engine上に構成されたクラスタで実行されます。

    1. Google Cloud Shellを立ち上げる

      コンテナを操作するための、コンソール環境が必要となります。 ローカルの環境にSDKを入れて操作することもできますが、ここではブラウザ上から操作できる Google Cloud Shellを利用して構築することとします。 このシェルは既にSDKがインストールされた状態ですので、立ち上げるとすぐに使用できます。

    2. コンテナクラスタの作成

      
      $ gcloud container clusters create mastodon --zone us-central1-c
      

      任意のリージョンに作成します。ここではus-central-cとします。 クラスタ名はmastodonとしておきます。

    3. クラスタへのアクセス権の取得

      
      $ gcloud container clusters get-credentials mastodon --zone us-central1-c
      
    4. Cloud SQLへのアクセス権の設定

      Google Cloud SQLへのアクセスの際に、IPアドレスで制限することが不可能なので、 サービスアカウントキーでアクセスできるようにします。

      1. 認証情報の作成

        https://console.cloud.google.com/apis/credentials にアクセスし、認証情報を作成します。

        認証情報を作成からサービスアカウントキーを選択します。

        新しいサービスアカウントを選択し、アカウント名に適当な名前を入れます。 役割に、Cloud SQL クライアントを選択します。 作成を押すと、jsonファイルで秘密鍵がダウンロードされます。

      2. ダウンロードされたjsonファイルの内容を、シェルの場所にcredentials.jsonとして保存します。

      3. credentials.jsonをクラスタに保存します。

        
        $ kubectl create secret generic service-account-token --from-file=credentials.json=$HOME/credentials.json
        
    5. ディスクの作成

      プリコンパイルした内容を保存する永続ディスクを3つ作成します。

      • /mastodon/public/system
      • /mastodon/public/assets
      • /mastodon/public/packs

      以上の3つのディレクトリにマウントされるディスクを作成します。

      
      $ gcloud compute disks create --size 1GB mastodon-disk1 --zone us-central1-c
      $ gcloud compute disks create --size 1GB mastodon-disk2 --zone us-central1-c
      $ gcloud compute disks create --size 1GB mastodon-disk3 --zone us-central1-c
      

      大きさは1GBも要りませんが、1GBからしか作成できません。名前は適宜決めてください。

    6. mastodonのdocker imageの取得

      mastodonのimageをローカルに取得します。

      
      $ docker pull gargron/mastodon:v2.4.0
      

      https://github.com/tootsuite/mastodon/releases を参照して最新のタグを指定します。masterは時折壊れているようなので、タグを指定した方が無難です。

    7. Container RegistryへのPush

      クラスターで参照できるようにContainer Registryに保存します。

      まずタグをつけます。

      
      $ docker tag gargron/mastodon:v2.4.0 gcr.io/project-id/mastodon:v2.4.0
      

      タグの確認

      
      $ docker images
      REPOSITORY                        TAG                 IMAGE ID            CREATED             SIZE
      gargron/mastodon                  v2.3.3              956d59c7c572        7 weeks ago         1.26GB
      gcr.io/project-id/mastodon        v2.3.3              956d59c7c572        7 weeks ago         1.26GB
      

      pushする

      
      gcloud docker -- push gcr.io/project-id/mastodon
      
    8. 設定ファイルの作成

      secretを作成するために、以下のコマンドを2回実行して結果を記録しておきます。

      
      $ docker run --rm -it gargron/mastodon:v2.4.0 rake secret
      

      mastodon/.env.production.sampleを参考に、yamlファイルを作成します。

      env.production.yaml

      
      apiVersion: v1
      kind: ConfigMap
      metadata:
        name: env-production
      data:
      # Service dependencies
        #Redis Labsで指定されたホスト、ポート、設定したパスワードを入れる
        REDIS_HOST: redis-xxxxx.c1.us-central1-2.gce.cloud.redislabs.com
        REDIS_PORT: '12549'
        REDIS_PASSWORD: password
       #Google Cloud SQLで設定したパスワードを入れる
        DB_HOST: db-host
        DB_USER: postgres
        DB_NAME: postgres
        DB_PASS: password
        DB_PORT: '5432'
      # Federation
        LOCAL_DOMAIN: mastodon.example.com
      # Application secrets
        SECRET_KEY_BASE: (docker run --rm -it gargron/mastodon:v2.4.0 rake secret)の結果1
        OTP_SECRET: (docker run --rm -it gargron/mastodon:v2.4.0 rake secret)の結果2
      # E-mail configuration
        SMTP_SERVER: smtp.mailgun.org
        SMTP_PORT: '2525'
        SMTP_LOGIN: 設定画面で確認できるDefault SMTP Login
        SMTP_PASSWORD: 設定画面で確認できるDefault Password
        SMTP_FROM_ADDRESS: mastodon@example.com
      # S3 (Minio Config (optional) Please check Minio instance for details)
        #Google Cloud Storageの設定
        S3_ENABLED: 'true'
        S3_BUCKET: mastodon
        AWS_ACCESS_KEY_ID: アクセスキー
        AWS_SECRET_ACCESS_KEY: シークレット
        S3_REGION: us-central1
        S3_PROTOCOLi: https
        S3_HOSTNAME: storage.googleapis.com
        S3_ENDPOINT: https://storage.googleapis.com
        S3_SIGNATURE_VERSION: s3
      # Cluster number setting for streaming API server.
        STREAMING_CLUSTER_NUM: '1'
      

      .env.production.sampleを参考に作成したenv.production.yamlを登録します。

      
      $ kubectl apply -f env.production.yaml
      
    9. sql-proxyの作成

      GKEのIPアドレスの範囲を特定することが難しいので、sql-proxyを介してGoogle Cloud SQLにアクセスするように設定します。

      backend.yaml

      
      apiVersion: extensions/v1beta1
      kind: Deployment
      metadata:
        name: db-host
      spec:
        replicas: 1
        template:
          metadata:
            labels:
              app: db-host
          spec:
            containers:
            - image: b.gcr.io/cloudsql-docker/gce-proxy
              name: db-host
              command:
              - /cloud_sql_proxy
              - -dir=/cloudsql
              - -instances=project-id:us-central1:mastodon-db=tcp:0.0.0.0:5432
              - -credential_file=/credentials/credentials.json
              ports:
              - name: mastodon-sql
                containerPort: 5432
              volumeMounts:
              - mountPath: /cloudsql
                name: cloudsql
              - mountPath: /etc/ssl/certs
                name: ssl-certs
                readOnly: true
              - mountPath: /credentials
                name: service-account-token
            volumes:
            - name: cloudsql
              emptyDir:
            - name: ssl-certs
              hostPath:
                path: "/etc/ssl/certs"
            - name: service-account-token
              secret:
                secretName: service-account-token
      ---
      apiVersion: v1
      kind: Service
      metadata:
        name: db-host
      spec:
        type: ClusterIP
        ports:
        - port: 5432
          targetPort: mastodon-sql
        selector:
          app: db-host
      

      次のコマンドで設定し、起動します。

      
      $ kubectl apply -f backend.yaml
      
    10. DBの初期化とプリコンパイル

      mastodonのDBを初期化します。 vapid_keyを生成します。

      web-init1.yaml

      
      apiVersion: batch/v1
      kind: Job
      metadata:
        name: web-init1
      spec:
        template:
          metadata:
            name: web-init1
          spec:
            containers:
            - name: web-init1
              image: gcr.io/project-id/mastodon:v2.4.0
              envFrom:
                - configMapRef:
                    name: env-production
              command: ["bash","-c","rake db:migrate && rake mastodon:webpush:generate_vapid_key"]
            restartPolicy: Never
      

      assetのプリコンパイルを行います。

      web-init2.yaml

      
      apiVersion: batch/v1
      kind: Job
      metadata:
        name: web-init2
      spec:
        template:
          metadata:
            name: web-init2
          spec:
            securityContext:
              fsGroup: 991 # container user gid
            containers:
            - name: web-init2
              image: gcr.io/project-id/mastodon:v2.4.0
              envFrom:
                - configMapRef:
                    name: env-production
              command: ["rake", "assets:precompile"]
              volumeMounts:
              - name: mastodon-disk1
                mountPath: /mastodon/public/system
              - name: mastodon-disk2
                mountPath: /mastodon/public/assets
              - name: mastodon-disk3
                mountPath: /mastodon/public/packs
            volumes:
            - name: mastodon-disk1
              gcePersistentDisk:
                pdName: mastodon-disk1
                fsType: ext4
      	  readOnly: false
            - name: mastodon-disk2
              gcePersistentDisk:
                pdName: mastodon-disk2
                fsType: ext4
      	  readOnly: false
            - name: mastodon-disk2
              gcePersistentDisk:
                pdName: mastodon-disk2
                fsType: ext4
      	  readOnly: false
            restartPolicy: Never
      

      assetのプリコンパイルは、バージョンアップの際には別のディスクを作成する必要があります。 GKEでは複数のポッドからディスクをマウントするにはリードオンリーでなければなりませんが、 起動中のポッドがロックしているので、書き込み可能で再マウントすることができません。

      次にコマンドで実行します。

      
      $ kubectl apply -f web-init1.yaml
      $ kubectl apply -f web-init2.yaml
      

      実行しているpodの名前を確認します。

      
      $ kubectl get pod -a
      NAME                       READY     STATUS    RESTARTS   AGE
      db-host-5c7c457d88-2mxxh   1/1       Running   0          6h
      web-init1-87wgs            1/1       Running   0          4s
      

      web-init1の方のpodのログを確認し、生成されたvapid_keyを確認します。

      
      $ kubectl logs -f web-init1-87wgs
      (中略)
      VAPID_PRIVATE_KEY=us1Hxx4nXwy4HCXsb50_NJaUKpQqRzFSTS1FgUyG9RI=
      VAPID_PUBLIC_KEY=BBYlb7l6lKulKPwbkrXK3SdF7LJ94jWKpLqxf2qJjQsSmELeP1791vP41aPiAEjx96bxngMD50SNEgbwWkLMvoY=
      

      vapid_keyをenv.production.yamlに追記します。

      env.production.yaml

      
        VAPID_PRIVATE_KEY: us1Hxx4nXwy4HCXsb50_NJaUKpQqRzFSTS1FgUyG9RI=
        VAPID_PUBLIC_KEY: BBYlb7l6lKulKPwbkrXK3SdF7LJ94jWKpLqxf2qJjQsSmELeP1791vP41aPiAEjx96bxngMD50SNEgbwWkLMvoY=
      この2行を追記
      

      env.production.yamlを更新します。

      
      $ kubectl apply -f env.production.yaml
      

      完了したら、消しておきます。

      
      $ kubectl delete -f web-init1.yaml
      $ kubectl delete -f web-init2.yaml
      
    11. nginx.confの設定

      nginx.confを作成します。

      nginx.conf

      
      map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
      }
      
      server {
        listen 80;
        listen [::]:80;
        server_name mastodon.example.com;
      
        access_log /dev/stdout;
      
        location /healthz {
          return 200 "healthy\n";
        }
      
        if ( $http_user_agent ~* googlehc ) {
          return 200 "healthy\n";
        }
      
        if ($http_x_forwarded_proto = "http") {
            return 301 https://$host$request_uri;
        }
      
        keepalive_timeout    70;
        sendfile             on;
        client_max_body_size 0;
      
        root /mastodon/public;
      
        gzip on;
        gzip_disable "msie6";
        gzip_vary on;
        gzip_proxied any;
        gzip_comp_level 6;
        gzip_buffers 16 8k;
        gzip_http_version 1.1;
        gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
        add_header Strict-Transport-Security "max-age=31536000";
      
        location / {
          try_files $uri @proxy;
        }
      
        location ~ ^/(emoji|packs|system/accounts/avatars|system/media_attachments/files) {
          add_header Cache-Control "public, max-age=31536000, immutable";
          try_files $uri @proxy;
        }
      
        location /sw.js {
          add_header Cache-Control "public, max-age=0";
          try_files $uri @proxy;
        }
      
        location @proxy {
          proxy_set_header Host $host;
          proxy_set_header X-Real-IP $remote_addr;
          proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
          proxy_set_header X-Forwarded-Proto https;
          proxy_set_header Proxy "";
          proxy_pass_header Server;
      
          proxy_pass http://127.0.0.1:3000;
          proxy_buffering off;
          proxy_redirect off;
          proxy_http_version 1.1;
          proxy_set_header Upgrade $http_upgrade;
          proxy_set_header Connection $connection_upgrade;
      
          tcp_nodelay on;
        }
      
        location /api/v1/streaming {
          proxy_set_header Host $host;
          proxy_set_header X-Real-IP $remote_addr;
          proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
          proxy_set_header X-Forwarded-Proto https;
          proxy_set_header Proxy "";
      
          proxy_pass http://127.0.0.1:4000;
          proxy_buffering off;
          proxy_redirect off;
          proxy_http_version 1.1;
          proxy_set_header Upgrade $http_upgrade;
          proxy_set_header Connection $connection_upgrade;
      
          tcp_nodelay on;
        }
      
        error_page 500 501 502 503 504 /500.html;
      }
      

      設定をロードします。

      
      $ kubectl create configmap nginx-config --from-file=nginx.conf
      
    12. フロントエンドの作成

      frontend.yaml

      
      apiVersion: extensions/v1beta1
      kind: Deployment
      metadata:
        name: frontend
      spec:
        replicas: 2
        template:
          metadata:
            labels:
              app: frontend
          spec:
            containers:
            - name: nginx
              image: nginx
              ports:
                - containerPort: 80
              livenessProbe:
                httpGet:
                  path: /healthz
                  port: 80
                initialDelaySeconds: 5
                timeoutSeconds: 1
              volumeMounts:
              - mountPath: /etc/nginx/conf.d
                name: nginx-config
                readOnly: true
            - name: web
              image: gcr.io/project-id/mastodon:v2.4.0
              envFrom: 
                - configMapRef:
                    name: env-production
              command:
              - bundle
              - exec
              - rails
              - s
              - -p 
              - '3000'
              - -b
              - '0.0.0.0'
              ports:
              - containerPort: 3000
              volumeMounts:
              - name: mastodon-disk1
                mountPath: /mastodon/public/system
                readOnly: true
              - name: mastodon-disk2
                mountPath: /mastodon/public/assets
                readOnly: true
              - name: mastodon-disk3
                mountPath: /mastodon/public/packs
                readOnly: true
            - name: streeming
              image: gcr.io/project-id/mastodon:v2.4.0
              envFrom: 
                - configMapRef:
                    name: env-production
              command:
              - yarn
              - start
              ports:
              - containerPort: 4000
            volumes:
            - name: mastodon-disk1
              gcePersistentDisk:
                pdName: mastodon-disk1
                fsType: ext4
                readOnly: true
            - name: mastodon-disk2
              gcePersistentDisk:
                pdName: mastodon-disk2
                fsType: ext4
                readOnly: true
            - name: mastodon-disk3
              gcePersistentDisk:
                pdName: mastodon-disk3
                fsType: ext4
                readOnly: true
            - name: nginx-config
              configMap:
                name: nginx-config
      ---
      apiVersion: v1
      kind: Service
      metadata:
        name: frontend
        labels:
          app: frontend
      spec:
        type: NodePort
        ports:
        - name: http
          port: 80
          targetPort: 80
        selector:
          app: frontend
      

      次のコマンドで起動します。

      
      $ kubectl apply -f frontend.yaml
      
    13. sidekiqの作成

      sidekiq.yaml

      
      apiVersion: extensions/v1beta1
      kind: Deployment
      metadata:
        name: sidekiq
      spec:
        replicas: 1
        template:
          metadata:
            labels:
              app: sidekiq
          spec:
            containers:
            - name: sidekiq
              image: gcr.io/project-id/mastodon:v2.4.0
              envFrom: 
                - configMapRef:
                    name: env-production
              command:
              - bundle
              - exec
              - sidekiq
              - -q
              - default
              - -q
              - mailers
              - -q
              - pull
              - -q
              - push
      

      次のコマンドで起動します。

      
      $ kubectl apply -f sidekiq.yaml
      
    14. 公開IPアドレスを確保する

      
      $ gcloud compute addresses create kubernetes-ingress --global
      $ gcloud compute addresses list
      

      DNSにセットしておきます。

    15. ingressの設定

      まずhttpでアクセスできるように設定します。

      ingress.yaml

      
      apiVersion: extensions/v1beta1
      kind: Ingress
      metadata:
        name: mastodon
        annotations:
          kubernetes.io/ingress.global-static-ip-name: kubernetes-ingress
        labels:
          app: mastodon
      spec:
        backend:
          serviceName: frontend
          servicePort: 80
      

      次のコマンドで適用します。

      
      $ kubectl apply -f ingress.yaml
      

      しばらくすると(数分)ロードバランサにIPアドレスがセットされます。 次のコマンドで確認することができます。 この状態でhttpでアクセスすると、httpsにリダイレクトされエラーで止まります。

      
      $ kubectl get ingress
      
    16. httpsの設定

      次にhttpでアクセスできるように設定します。

      1. helmのインストール

        コンソールにhelmをインストールします。 let's encryptの証明書を適切に処理してくれるcert-manager をインストールするのに必要です。

        
        $ wget https://storage.googleapis.com/kubernetes-helm/helm-v2.9.1-linux-amd64.tar.gz
        $ tar xvf helm-v2.9.1-linux-amd64.tar.gz
        $ cp linux-amd64/helm .
        

        クラスタにhelmをインストールします。

        
        $ kubectl create serviceaccount tiller --namespace kube-system
        $ kubectl create clusterrolebinding tiller --clusterrole=cluster-admin --serviceaccount=kube-system:tiller
        $ ./helm init --upgrade --service-account tiller
        
      2. cert-managerのインストール

        クラスタにcert-managerをインストールします。

        
        $ ./helm install --name cert-manager --namespace kube-system stable/cert-manager
        
      3. Let‘s Encryptの設定

        証明書の更新の連絡を受けるメールアドレスを設定します。

        
        $ export EMAIL=ahmet@example.com
        

        Issuer manifestsの登録をします。

        
        $ curl -sSL https://rawgit.com/ahmetb/gke-letsencrypt/master/yaml/letsencrypt-issuer.yaml | \
            sed -e "s/email: ''/email: $EMAIL/g" | \
            kubectl apply -f-
        
      4. 証明書の取得

        ロードバランサにIPアドレスが振られて、httpでアクセスできるようになったら、 証明書の取得を行います。

        certificate.yaml
        
        apiVersion: certmanager.k8s.io/v1alpha1
        kind: Certificate
        metadata:
          name: mastodon-tls
          namespace: default
        spec:
          secretName: mastodon-tls
          issuerRef:
            name: letsencrypt-prod
            kind: ClusterIssuer
          commonName: mastodon.example.com
          dnsNames:
          - mastodon.example.com
          acme:
            config:
            - http01:
                ingress: mastodon
              domains:
              - mastodon.example.com
        

        次のコマンドで適用します。

        
        $ kubectl apply -f certificate.yaml
        

        証明書の取得には10−20分程度の時間がかかります。コーヒーの時間です。 次のコマンドで/.well-known/acme-challenge/*が自動生成されていることが 確認できます。

        
        $ kubectl get ingress -o=yaml mastodon
        

        証明書の取得が完了すると、次のコマンドのように秘密鍵がセットされています。

        
        $ kubectl get secrets
        NAME                    TYPE                                  DATA      AGE
        default-token-spvst     kubernetes.io/service-account-token   3         15h
        mastodon-tls            kubernetes.io/tls                     2         10h
        service-account-token   Opaque                                1         9h
        
      5. ingressの設定の更新

        証明書がセットされたのでhttpsでアクセスできるように修正します。

        ingress.yaml

        
        apiVersion: extensions/v1beta1
        kind: Ingress
        metadata:
          name: mastodon
          annotations:
            kubernetes.io/ingress.global-static-ip-name: kubernetes-ingress
          labels:
            app: mastodon
        spec:
          backend:
            serviceName: frontend
            servicePort: 80
          tls:
          - secretName: mastodon-tls
            hosts:
            - mastodon.example.com
        

        次のコマンドで適用します。

        
        $ kubectl apply -f ingress.yaml
        

      ロードバランサに適用されるまで数分かかります。 完了すればドメインにアクセスするとMastodonのページが見えます。お疲れ様でした。

    17. 管理者アカウントの設定

      管理者のアカウントを作成します。 まず、frontendのpodを確認します。

      
      $ kubectl get pod
      NAME                        READY     STATUS    RESTARTS   AGE
      db-host-5c7c457d88-2mxxh    1/1       Running   0          10h
      frontend-56f7dbfbd8-58rmz   3/3       Running   0          2h
      frontend-56f7dbfbd8-pzbvs   3/3       Running   0          2h
      sidekiq-d94c6b9cd-pvfj9     1/1       Running   0          3h
      

      frontendのpodのIDを探して次のコマンドを実行します。

      
      $ kubectl exec -it frontend-56f7dbfbd8-58rmz -c web -- rails c
      

      railsのコンソールが起動するので、以下のように管理者アカウントを設定します。

      
      irb(main):001:0> password = SecureRandom.hex(16)
      => "63f7b0523db470effb7a560050cddac4" (自動生成のパスワードを記録しておきます。もしくは任意のパスワードを設定します)
      irb(main):002:0> username = 'admin'
      => "admin"
      irb(main):003:0> email = '有効なメールアドレス
      => "admin@example.com”
      irb(main):004:0> user = User.new(admin: true, email: email, password: password, confirmed_at: Time.now.utc, account_attributes: { username: username })
      => #<User email: "admin@example.com", created_at: nil, updated_at: nil, admin: true, locale: nil, encrypted_otp_secret: nil, encrypted_otp_secret_iv: nil, encrypted_otp_secret_salt: "_aRlexnt3IPpJKyWd5emjQg==\n", consumed_timestep: nil, otp_required_for_login: false, last_emailed_at: nil, otp_backup_codes: nil, filtered_languages: [], account_id: nil, id: nil, disabled: false, moderator: false, invite_id: nil, otp_secret: nil>
      irb(main):005:0> user.save(validate: false)
      => true
      irb(main):006:0> exit
      

mastodon: https://mastodon.lithium03.info/
medialist: https://mastodon.lithium03.info/medialist