1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ゲゲーッ! minioがArchの公式から外れてる…

1
Last updated at Posted at 2026-01-03

どうも、零細Misskey鯖缶です。
いや、新年早々アップデートチェックしてたらminioがArchの公式から外れてんじゃん。

乗り換えよう

まず、要件。

  • ファイルシステムにファイルを直接保存する形式がいい。 ・・・ いちいち開発元の動向に左右されたくない。
  • 配信はnginx + cloudflaredにする。

→ s3proxyをdocker-composeで組むことにしました。

docker-compose.yml

services:
  s3proxy:
    image: andrewgaul/s3proxy
    environment:
      - LOG_LEVEL=debug
      - S3PROXY_ENDPOINT=http://0.0.0.0:9000
      - S3PROXY_AUTHORIZATION=aws-v4
      - S3PROXY_IDENTITY=admin
      - S3PROXY_CREDENTIAL=shimomi-yukino-sekino-to
      - JCLOUDS_PROVIDER=filesystem-nio2
      - JCLOUDS_FILESYSTEM_BASEDIR=/data
    volumes:
      - ./volumes/s3proxy:/data
    user: "101:101"
    network_mode: "service:tailscale"
    restart: unless-stopped

  tailscale:
    image: tailscale/tailscale:latest
    restart: unless-stopped
    networks:
      - s3-net
    cap_add:
      - NET_ADMIN
      - NET_RAW
    environment:
      - TS_STATE_DIR=/var/lib/tailscale
      - TS_EXTRA_ARGS=--advertise-exit-node=false
    volumes:
      - ./tailscale_state:/var/lib/tailscale
      - /dev/net/tun:/dev/net/tun

  nginx:
    image: nginx:alpine
    volumes:
      - ./volumes/s3proxy:/data:ro
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    networks:
      - public-net
    restart: unless-stopped

  cloudflared:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    networks:
      - public-net
    command: tunnel run --token とてもとーくん

networks:
  public-net:
    driver: bridge
  s3-net:
    driver: bridge

ポイント

  • Misskeyとの通信はTailscaleでやるので、s3proxyにはtailscaleコンテナのネットワークに便乗させる。
  • nginxはroでデータディレクトリをマウントする。
  • aws-v4 じゃないとMisskeyから使えないみたい。地味ハマりポイント。
  • filesystem じゃなくて filesystem-nio2 を使えとWarningされたのでそうした。
  • nginx.confは /data をサーブするように適当に書く。
  • Cloudflare Tunnelは http://nginx をリバースするように適当に設定する。

まとめ

新年から疲れた。
minioからrcloneでファイルを吸い出すのが地味に面倒だったので、やはりデータは汎用的な形式で持つべき。

追補

APIのmime-typeが反映されないじゃんって突っ込まれたので、調べた。

$ getfattr -d /mnt/s3proxy/volumes/s3proxy/nekono/files/4785ccce-f10e-4f58-bc5b-025b60b523f8.webp 
getfattr: Removing leading '/' from absolute path names
# file: mnt/s3proxy/volumes/s3proxy/nekono/files/4785ccce-f10e-4f58-bc5b-025b60b523f8.webp
user.user.cache-control="max-age=31536000, immutable"
user.user.content-disposition="inline; filename=\"f7e033fc707168467fcf99f6450c17fb_6022322677774841781.webp\"; filename*=UTF-8''f7e033fc707168467fcf99f6450c17fb_6022322677774841781.webp"
user.user.content-type="image/webp"
user.user.storage-tier="STANDARD"

というわけで、これを読んで適切に返すコードを書けば良さそう。

書いた。

READMEはGitHub Copilotに書かせて何もチェックしてないのでよろしく。

追補2

s3proxyを通じて上がったファイルにはxattrが書き込まれるけど、rcloneでファイルシステムに引き上げたファイルにはxattrがついていない。
というわけで、xattrを書き込む手順が必要。

$ docker compose exec db psql -U misskey -d mk1 -c "\copy (SELECT name, uri, type FROM drive_file) TO '/dump/drive_file_dump.csv' WITH CSV HEADER"

というわけでまずはこんな感じで雑にdrive_fileテーブルを引き上げる。

import csv
import os
import urllib.parse
from pathlib import Path

# --- 設定項目 ---
DRY_RUN = True
CSV_FILE = "/tmp/drive_file_dump.csv"
STORAGE_ROOT = Path("/mnt/s3proxy/volumes/s3proxy/nekono")

def encode_content_disposition(original_name, is_thumb=False):
    """
    S3Proxyの出力に合わせたContent-Dispositionヘッダー値を生成する
    """
    name = f"thumb-{original_name}" if is_thumb else original_name
    
    # 日本語などをURLエンコード
    encoded_name = urllib.parse.quote(name)
    
    # filename= の部分はASCII以外をアンダースコアに置換(S3Proxyの挙動を再現)
    ascii_name = "".join([c if ord(c) < 128 else "_" for c in name])
    
    return f'inline; filename="{ascii_name}"; filename*=UTF-8\'\'{encoded_name}'

def set_xattr_safe(file_path, attrs, storage_label):
    if not file_path.exists():
        return False

    if DRY_RUN:
        print(f"[Dry-run] ({storage_label}) Updating: {file_path.relative_to(STORAGE_ROOT)}")
        for k, v in attrs.items():
            print(f"    {k}: {v}")
    else:
        try:
            for k, v in attrs.items():
                os.setxattr(str(file_path), k, v.encode('utf-8'))
        except Exception as e:
            print(f"[Error] ({storage_label}) {file_path.name}: {e}")
            return False
    return True

def apply_xattr_from_csv():
    if not os.path.exists(CSV_FILE):
        print(f"Error: CSV file not found at {CSV_FILE}")
        return

    with open(CSV_FILE, mode='r', encoding='utf-8') as f:
        reader = csv.DictReader(f)
        stats = {"original": 0, "thumbnail": 0, "webpublic": 0, "nf": 0}

        for row in reader:
            uri = row.get('uri', '').strip()
            stored_internal = row.get('storedInternal', '').lower()
            
            if uri == "" and stored_internal in ['f', 'false']:
                original_name = row.get('name', 'file')
                mime_type = row.get('type', 'application/octet-stream')
                
                # 1. オリジナル
                if key := row.get('accessKey'):
                    attrs = {
                        "user.user.content-type": mime_type,
                        "user.user.cache-control": "max-age=31536000, immutable",
                        "user.user.content-disposition": encode_content_disposition(original_name),
                        "user.user.storage-tier": "STANDARD"
                    }
                    if set_xattr_safe(STORAGE_ROOT / key, attrs, "Original"): stats["original"] += 1
                    else: stats["nf"] += 1

                # 2. サムネイル
                if key := row.get('thumbnailAccessKey'):
                    attrs = {
                        "user.user.content-type": "image/webp",
                        "user.user.cache-control": "max-age=31536000, immutable",
                        "user.user.content-disposition": encode_content_disposition(original_name, is_thumb=True),
                        "user.user.storage-tier": "STANDARD"
                    }
                    if set_xattr_safe(STORAGE_ROOT / key, attrs, "Thumbnail"): stats["thumbnail"] += 1
                    else: stats["nf"] += 1

                # 3. Webpublic
                if key := row.get('webpublicAccessKey'):
                    webpub_type = row.get('webpublicType') or mime_type
                    attrs = {
                        "user.user.content-type": webpub_type,
                        "user.user.cache-control": "max-age=31536000, immutable",
                        "user.user.content-disposition": encode_content_disposition(original_name),
                        "user.user.storage-tier": "STANDARD"
                    }
                    if set_xattr_safe(STORAGE_ROOT / key, attrs, "Webpublic"): stats["webpublic"] += 1
                    else: stats["nf"] += 1

    print(f"\n--- 統計 ({'DRY RUN' if DRY_RUN else '実行完了'}) ---")
    print(f"Original: {stats['original']} | Thumb: {stats['thumbnail']} | Webpub: {stats['webpublic']} | NotFound: {stats['nf']}")

if __name__ == "__main__":
    apply_xattr_from_csv()

こんな感じでxattrを書き込む。

追補3

tarバックアップ取るときの引数

# tar --xattrs --xattrs-include='*' -c -I 'zstd' -v s3proxy/
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?