บทเรียน 138 · Rails · Day 4 Part 1

ไฟล์ไม่ควรอยู่ในแอป — object storage, MinIO และ Active Storage

~30 นาที (มีขั้นติดตั้งหลายขั้น) · ควรผ่าน บทเรียน 137 (Action Text) และ บทเรียน 120 (docker compose) มาก่อน

บทนี้เราจะให้บทความมีรูปปก · คำถามแรกไม่ใช่ "เขียนโค้ดยังไง" แต่เป็น "ไฟล์ควรไปอยู่ที่ไหน"
อ่านจบบทนี้ คุณจะอธิบายได้ว่าทำไมไฟล์ไม่ควรเก็บไว้ในเครื่องที่รันแอป · รู้จัก object storage กับ MinIO · ตั้ง Active Storage ให้ส่งไฟล์ไปเก็บใน MinIO โดยไม่เขียนรหัสผ่านลงไฟล์ (Rails credentials) · แล้วอัปรูปปกจริงพร้อมตามไปดูว่าไฟล์อยู่ไหน

🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้

🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 18 · aws-sdk-s3 1.238.0 · MinIO (image pgsty/silo ตัวเดียวกับ compose ของอาจารย์) · Chrome (สั่งด้วยสคริปต์) — สไลด์และ repo ของอาจารย์เขียน Ruby 4.0.5–4.0.6 · Rails 8.1.3 เรื่องในบทนี้ไม่ต่างกัน
📍 โค้ดเริ่มจาก: ตอนจบบท 137 ที่ commit แล้ว (git status --short ว่าง) · บทความมีช่อง body แบบ rich text · บทนี้แก้โค้ดจริงแล้ว commit ตอนจบ (ไม่คืนทิ้ง)
🖥 เปิดค้างไว้: docker compose up -d — ครั้งนี้ต้องมีทั้ง postgres และ minio (ดูด้วย docker compose ps ว่ามีชื่อ minio ในรายการ) · bin/dev หนึ่งหน้าต่าง (เว็บที่ localhost:3000) · อีกหน้าต่างไว้พิมพ์คำสั่ง · ผลรันทุกกล่องมาจากเครื่องจริง · เวลา ms · วันเวลา · ชื่อไฟล์สุ่มในโกดัง · ลายเซ็นยาว ๆ ใน URL · id ของคุณจะไม่ตรงกับในบท

ตอนนี้ไฟล์ที่อัปโหลดไปอยู่ไหน

ในบท 137 ถ้าคุณกดปุ่มคลิปของ Trix แล้วแนบรูป รูปนั้นไปอยู่ในโฟลเดอร์ storage/ ของโปรเจกต์ · เพราะ Rails ตั้งค่าเริ่มต้นไว้แบบนี้:

# config/environments/development.rb (ตอนเริ่มบท)
  # Store uploaded files on the local file system (see config/storage.yml for options).
  config.active_storage.service = :local

# config/storage.yml (ตอนเริ่มบท · ตัดมาเฉพาะส่วนนี้)
local:
  service: Disk
  root: <%= Rails.root.join("storage") %>

ตอนผมลองอัปรูปด้วยค่านี้ ไฟล์ไปโผล่ที่ storage/65/er/65eryelme20ify18xoi0sa9d1z7e — อยู่ในโฟลเดอร์ของแอป บนเครื่องที่รันแอป · บนโน้ตบุ๊กเราไม่มีปัญหา · แต่พอเอาแอปขึ้นให้คนใช้จริง (production) จะพังสี่แบบ (สไลด์หน้า 8):

เหตุการณ์เกิดอะไรกับไฟล์ใน storage/
restart · deploy ใหม่production มักรัน Rails ในกล่องแยก (container) แบบเดียวกับที่เรารัน Postgres · deploy (เอาโค้ดใหม่ขึ้น) = สร้างกล่องใหม่จากแม่แบบ แล้วทิ้งกล่องเก่า → โฟลเดอร์ในกล่องเริ่มจากศูนย์ รูปหายหมด
scale (เปิดแอปหลายเครื่องช่วยกันรับคน)ผู้ใช้อัปรูปผ่านเครื่อง A → ไฟล์อยู่ใน A เครื่องเดียว · คนที่เปิดหน้าเดียวกันแล้วไปตกเครื่อง B หรือ C เห็นรูปพัง
backupสำรองฐานข้อมูลแล้ว แต่ไฟล์อยู่อีกที่ ต้องจำไปสำรองแยก · กู้คืนทีหนึ่งต้องกู้สองที่ให้ตรงกัน
ไฟล์ใหญ่วิ่งผ่าน Railsตลอดเวลาที่ไฟล์กำลังส่งเข้ามา Rails ต้องมีคนคอยรับหนึ่งคน · server ของเราตอนเปิดพิมพ์ Min threads: 3 Max threads: 3 = รับงานพร้อมกันได้ 3 งาน · สไลด์ยกตัวอย่างว่า 10 คนอัปไฟล์ 500 MB พร้อมกัน = คนรับงานหมด คนที่แค่จะอ่านบทความก็เปิดเว็บไม่ได้

ใน Go บท 44 คุณเจอ 12-Factor ข้อ III (config) มาแล้ว · ข้อที่เกี่ยวกับบทนี้คือ ข้อ VI: แอปต้องไม่เก็บของที่ต้องอยู่ถาวรไว้กับตัว — ของแบบนั้นให้ไปอยู่ในบริการข้างนอก แบบที่ข้อมูลบทความไปอยู่ใน Postgres

🍳 เทียบให้เห็นภาพ — วางของฝากของลูกค้าไว้ในครัว

ลูกค้าฝากรูปปกไว้กับร้าน แล้วพนักงานเอาไปวางในครัว · วันหนึ่งครัวรีโนเวท (deploy) ช่างรื้อทุกอย่างแล้วสร้างครัวใหม่ตามแบบ → ของฝากหาย · พอร้านเปิดสามสาขา (scale) ของฝากอยู่ครัวสาขาเดียว ลูกค้าไปอีกสาขาก็ไม่เจอ · และระหว่างที่พนักงานต้อนรับแบกกล่องใหญ่ของลูกค้าเข้าครัว เขาก็รับออเดอร์โต๊ะอื่นไม่ได้

ที่ไม่เหมือนกัน: ร้านจริงรีโนเวทปีละครั้ง และช่างอาจเก็บของไว้ให้ · แอปจริง deploy ได้วันละหลายรอบ และกล่องใหม่ไม่มีอะไรจากกล่องเก่าติดมาเลย ทุกครั้ง ไม่มีใครเก็บของให้

เทียบกับที่รู้: แอป Go ใน บท 27 ก็ไม่เก็บบทความไว้ในตัวโปรแกรม แต่ส่งไป Postgres ด้วย GORM · ปิดเปิดโปรแกรม Go ใหม่ ข้อมูลยังอยู่ · บทนี้ทำแบบเดียวกันกับไฟล์

🗣 ศัพท์ที่เพิ่งเรียนไป
stateless process
โปรแกรมที่ไม่เก็บของถาวรไว้กับตัว ปิดทิ้งแล้วเปิดใหม่กี่ตัวก็ได้ไม่เสียอะไร (12-Factor ข้อ VI) · ถาม senior ได้ว่า "แอปเรา stateless หรือยัง มีอะไรเขียนลงดิสก์ของ container บ้าง"
backing service
บริการข้างนอกที่แอปเรียกใช้ผ่านเครือข่าย เช่น Postgres หรือที่เก็บไฟล์ (12-Factor ข้อ IV) · สลับตัวได้แค่แก้ config · พิมพ์บอก AI ได้ว่า "treat file storage as a backing service; never write uploads to the container's disk"
scale out · horizontal scaling
เพิ่มจำนวนเครื่องที่รันแอปตัวเดียวกันเพื่อรับคนได้มากขึ้น · ทำได้ก็ต่อเมื่อแอป stateless · ถาม senior ได้ว่า "ถ้าเรา scale out เป็น 3 เครื่องตอนนี้ อะไรจะพังก่อน"

object storage — โกดังเก็บไฟล์ที่พูดภาษา S3

object storage คือที่เก็บไฟล์ที่อยู่บนเครือข่าย · แอปส่งไฟล์ไปทั้งก้อน พร้อมบอกชื่อที่จะใช้เรียกคืน · อยากได้คืนก็ขอด้วยชื่อนั้น · มันไม่ได้อยู่ในเครื่องที่รันแอป → deploy กี่รอบ เปิดกี่เครื่อง ทุกเครื่องเห็นไฟล์ชุดเดียวกัน

MinIO อยู่ใน compose.yaml ของ repo ตั้งแต่บท 120 แล้ว · ดูส่วนของมัน (ไม่ต้องแก้):

# compose.yaml (ส่วนของ minio)
  minio:
    image: pgsty/silo
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    ports: ["9000:9000", "9001:9001"]
    volumes: ["miniodata:/data"]
🏬 เทียบให้เห็นภาพ — โกดังรับฝากของนอกร้าน

ร้านเลิกวางของฝากในครัว แล้วไปเช่าโกดังรับฝากของนอกร้าน · ครัวรีโนเวทกี่รอบ ของในโกดังก็อยู่ · ร้านเปิดสามสาขา ทุกสาขาไปเบิกจากโกดังเดียวกัน · ในโกดังแบ่งเป็นห้อง (bucket) · ของแต่ละชิ้นมีเลขที่ใบฝาก (key) · บริษัทโกดังหลายเจ้าใช้แบบฟอร์มฝาก-เบิกแบบเดียวกัน (ภาษา S3) ร้านจะย้ายเจ้าก็ไม่ต้องสอนพนักงานใหม่ · MinIO คือโกดังจำลองหลังบ้านเรา ที่ใช้แบบฟอร์มเดียวกันเป๊ะ ไว้ซ้อมก่อนไปเช่าของจริง

ที่ไม่เหมือนกัน: โกดังจริงอยู่ไกล ไปทีกินเวลา · object storage อยู่บนเครือข่าย ส่งไฟล์ไปกลับในเสี้ยววินาที · และโกดังจริงเปิดกล่องแก้ของข้างในได้ · object storage แก้ไฟล์ครึ่งชิ้นไม่ได้ ต้องส่งชิ้นใหม่ไปแทนทั้งชิ้น

เทียบกับที่รู้: MinIO เป็นกล่องหนึ่งใน compose.yaml แบบเดียวกับ postgres ของบท 120 · Postgres รับแถวข้อมูลทางประตู 5432 · MinIO รับไฟล์ทั้งก้อนทางประตู 9000 · ทั้งคู่อยู่นอกแอป Rails ปิดเปิดแอปกี่รอบของก็ยังอยู่

🗣 ศัพท์ที่เพิ่งเรียนไป
object storage · object · key
ที่เก็บไฟล์บนเครือข่าย · ไฟล์หนึ่งชิ้น · ชื่อที่ใช้เรียกไฟล์ · ถาม senior ได้ว่า "ไฟล์ที่ผู้ใช้อัปโหลดของระบบเราเก็บใน object storage ตัวไหน bucket อะไร"
bucket
กล่องใหญ่ที่ตั้งชื่อไว้เก็บ object · มักแยก bucket ตาม environment เช่น wongnok-dev กับของ production · พิมพ์บอก AI ได้ว่า "use a separate private bucket per environment"
S3-compatible · MinIO
บริการที่พูดภาษาเดียวกับ Amazon S3 · MinIO เป็นตัวที่รันเองได้ เหมาะกับเครื่อง dev · พิมพ์บอก AI ได้ว่า "we use MinIO locally as an S3-compatible store; configure Active Storage's S3 service with a custom endpoint"

เปิดโกดัง แล้วสร้างห้อง wongnok-dev

bucket ไม่มีใครสร้างให้ · เราสร้างผ่านหน้าเว็บ console ของ MinIO:

  1. เปิด http://localhost:9001 · เจอหน้า login ที่มีโลโก้ SILO ใต้โลโก้เขียน "OBJECT STORAGE CONSOLE" · ช่อง Username พิมพ์ minioadmin · ช่อง Password พิมพ์ minioadmin (ค่าจาก compose.yaml) → ปุ่ม Login · จะไปหน้า Overview
  2. เมนูซ้าย Buckets → ปุ่ม Create bucket (มุมขวาบน) → หน้า "Create Bucket"
  3. ช่อง Bucket Name* พิมพ์ wongnok-dev (ตรงตัวอักษร เดี๋ยวต้องใส่ชื่อนี้ใน config) · Versioning · Object Locking · Quota ปล่อย Off → ปุ่ม Create Bucket
  4. หน้าจอเด้งไปที่ห้องใหม่ ขึ้นว่า "This location is empty, please try uploading a new file" = ห้องว่าง พร้อมใช้

แท็บ Summary ของ bucket จะเขียน Access Policy: private = คนนอกเปิดไฟล์ในห้องนี้ตรง ๆ ไม่ได้ ต้องมีใบอนุญาตจากแอปเท่านั้น · ค่าเริ่มต้นนี้แหละที่เราต้องการ

⚠️ อย่าเปลี่ยน bucket เป็น public แม้จะทำให้รูปขึ้นง่ายกว่า — ใครเดาชื่อไฟล์ได้ก็เปิดได้หมด · บท 139 จะเห็นว่าแอปออก "ใบอนุญาตชั่วคราว" ให้เบราว์เซอร์แทน

Active Storage — ระบบบัตรรับฝากของร้าน

Active Storage คือส่วนของ Rails ที่ทำสามอย่างให้: ส่งไฟล์ไปเก็บ (ในเครื่องหรือโกดังไหนก็ได้ตาม config) · จดลงฐานข้อมูลว่าไฟล์อะไรเป็นของบทความไหน · และสร้างลิงก์ให้เบราว์เซอร์เปิดไฟล์ได้ · สไลด์หน้า 9 สั่งให้ติดตั้งด้วยคำสั่งนี้ — ลองพิมพ์ดู:

$ bin/rails active_storage:install
$ bin/rails db:migrate
$ git status --short
$

เงียบทั้งสามคำสั่ง (ผลจริง ไม่ใช่ตัดออก) · เพราะบท 137 ตอน action_text:install มันก็อป migration ของ Active Storage มาให้แล้ว (ไฟล์ db/migrate/…_create_active_storage_tables.active_storage.rb · เลขหน้าชื่อไฟล์ของคุณต่างจากของผม) และ migrate ไปแล้ว · ดูตารางที่มันสร้างใน db/schema.rb (ตัดมาเฉพาะสองตารางหลัก):

# db/schema.rb
  create_table "active_storage_attachments", force: :cascade do |t|
    t.string "name", null: false
    t.string "record_type", null: false
    t.bigint "record_id", null: false
    t.bigint "blob_id", null: false
    t.datetime "created_at", null: false
    t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
    t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
  end

  create_table "active_storage_blobs", force: :cascade do |t|
    t.string "key", null: false
    t.string "filename", null: false
    t.string "content_type"
    t.text "metadata"
    t.string "service_name", null: false
    t.bigint "byte_size", null: false
    t.string "checksum"
    t.datetime "created_at", null: false
    t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
  end

(บรรทัด t.index … unique: true = กฎ "ห้ามซ้ำ" ระดับฐานข้อมูลแบบ unique index ของบท 111 · ที่เหลือเป็นหน้าตาปกติของ schema.rb)

active_storage_blobs — หนึ่งแถว = ไฟล์หนึ่งชิ้น (blob = "ก้อนข้อมูล"):

active_storage_attachments — หนึ่งแถว = ป้ายบอกว่าไฟล์ไหนเป็นของใคร:

ตารางที่สาม active_storage_variant_records (ไม่ได้ตัดมาโชว์) ใช้จดรูปย่อ — รอบท 140 · สังเกตว่าไม่มีตารางไหนเก็บตัวไฟล์ · ฐานข้อมูลเก็บแค่ข้อมูลเกี่ยวกับไฟล์ ตัวไฟล์อยู่ในโกดัง

🎫 เทียบให้เห็นภาพ — ระบบบัตรรับฝากของร้าน

ทุกครั้งที่ของเข้าโกดัง ร้านออกใบรับฝาก (blob): เลขที่ฝาก ชื่อของ ขนาด ฝากไว้โกดังไหน แล้วเก็บใบนี้ในตู้แฟ้ม · แยกจากนั้นมีป้ายติดแฟ้ม (attachment): "รูปปกของแฟ้มบทความเลขที่ 1 → ดูใบรับฝากเลข 1" · เพราะใบรับฝากกับป้ายแยกกัน ใบเดียวจึงถูกป้ายหลายอันชี้มาได้

ที่ไม่เหมือนกัน: ร้านจริงให้ลูกค้าถือใบรับฝากกลับบ้าน · ที่นี่ทั้งใบและป้ายอยู่ในตู้แฟ้มของร้าน (ฐานข้อมูล) ลูกค้าไม่ได้ถืออะไร · และเลขที่ฝากของจริงมักเรียง 1 2 3 · ของ Active Storage เป็นตัวอักษรสุ่ม ใครก็เดาเลขของคนอื่นไม่ได้

เทียบกับที่รู้: คล้าย has_many :comments (บท 115) ที่เก็บคอมเมนต์แยกตาราง แล้วจดว่าเป็นของบทความไหน · ต่างกันตรงที่ Active Storage ใช้สองตารางนี้ร่วมกันทุก model ไม่ต้องสร้างตารางใหม่ต่อ model

🗣 ศัพท์ที่เพิ่งเรียนไป
Active Storage
ระบบแนบไฟล์ที่มากับ Rails · เก็บไฟล์ใน "service" ที่ตั้งไว้ และจดข้อมูลลงสองตาราง · พิมพ์บอก AI ได้ว่า "use Active Storage for uploads, not a custom file column"
blob
แถวใน active_storage_blobs = ข้อมูลของไฟล์หนึ่งชิ้น (key · ชื่อ · ชนิด · ขนาด · checksum) · ถาม senior ได้ว่า "blob ที่ไม่มี attachment ชี้มาเลย เรากวาดทิ้งตอนไหน" (เรื่องของบท 140)
attachment
แถวใน active_storage_attachments = ป้ายบอกว่า blob นี้แนบกับ record ไหน ช่องชื่ออะไร
service (ของ Active Storage)
ที่เก็บไฟล์หนึ่งแห่งที่ตั้งชื่อไว้ใน config/storage.yml เช่น local (ดิสก์) หรือ minio (S3) · ถาม senior ได้ว่า "production ใช้ Active Storage service ตัวไหน"

ติดตั้งตัวพูดภาษา S3 — gem aws-sdk-s3

Active Storage รู้ว่าต้องทำอะไร แต่พูดภาษา S3 เองไม่เป็น · ต้องมี gem ของ Amazon ที่รวมคำสั่งคุยกับ S3 ไว้ให้ · เปิด Gemfile แล้วพิมพ์ต่อท้ายไฟล์:

# Gemfile (ต่อท้ายไฟล์)

# Active Storage ต่อ MinIO/S3 · require: false เพราะ Active Storage require เองตอนใช้ service S3
gem "aws-sdk-s3", "~> 1.238", require: false
$ bundle install
Resolving dependencies...
Bundle complete! 29 Gemfile dependencies, 138 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.
1 installed gem you directly depend on is looking for funding.
  Run `bundle fund` for details

เครื่องผมมี gem ครบแล้ว (รันแบบ --local = ใช้ของในเครื่อง) · ของคุณจะมีบรรทัด Fetching … · Installing … ก่อน (Gemfile.lock ได้ gem เพิ่ม 8 ตัว: aws-sdk-s3 กับอีก 7 ตัวที่มันต้องใช้) · เลขรุ่นใน Gemfile.lock ของคุณอาจใหม่กว่าของผม (ผมได้ aws-sdk-s3 (1.238.0)) เพราะ bundler หยิบรุ่นล่าสุดที่เข้ากับ "~> 1.238" — ไม่เป็นไร · ลงท้ายด้วย Bundle complete! = ผ่าน

⚠️ พิมพ์บรรทัดใน Gemfile เอง อย่าใช้ bundle add aws-sdk-s3 — คำสั่งนั้นดึงรุ่นล่าสุด ณ วันที่รัน แล้วเขียนเลขรุ่นนั้นลง Gemfile (ตอนเราลองได้ 1.240.0) โค้ดจะไม่ตรงกับในบท · server ที่เปิดอยู่ยังไม่รู้จัก gem ใหม่ — ยังไม่ต้องปิด เราจะปิดเปิด bin/dev ทีเดียวตอนจบขั้นต่อรูปปก

กุญแจโกดังห้ามเขียนลงไฟล์ — Rails credentials

Rails ต้องมีชื่อผู้ใช้กับรหัสของ MinIO ถึงจะส่งไฟล์ได้ · ถ้าเขียนลง config/storage.yml ตรง ๆ ไฟล์นั้นขึ้น Git → ทุกคนที่เห็น repo ได้กุญแจโกดังไปด้วย (Go บท 44 สอนเกณฑ์ไว้แล้ว: "เปิด repo เป็น public ตอนนี้ได้ไหม")

ทางของ Rails เรียกว่า credentials: เก็บรหัสในไฟล์ที่เข้ารหัสแล้ว (.yml.enc · commit ได้ อ่านไม่ออก) คู่กับไฟล์กุญแจถอดรหัส (.key · ห้าม commit) · repo นี้มี config/credentials.yml.enc ของอาจารย์อยู่แล้ว แต่เราไม่มีกุญแจของมัน → เราสร้างชุดแยกสำหรับ development ของเราเอง

$ EDITOR=nano bin/rails credentials:edit --environment development

ผลจริงตอนเริ่ม (ผมตัดกลางกุญแจออกเป็น … · ของคุณเป็นเลขสุ่มชุดอื่น):

Adding config/credentials/development.key to store the encryption key: 3feb7854…1b33cf03

Save this in a password manager your team can access.

If you lose the key, no one, including you, can access anything encrypted with it.

      create  config/credentials/development.key
      append  .gitignore

Configured Git diff driver for credentials.
Editing config/credentials/development.yml.enc...

ใน nano จะเห็นตัวอย่างที่ Rails ใส่ไว้ (ทุกบรรทัดขึ้นต้นด้วย # = ไม่มีผล):

# smtp:
#   user_name: my-smtp-user
#   password: my-smtp-password
#
# aws:
#   access_key_id: 123
#   secret_access_key: 345

ลบทิ้งทั้งหมด แล้วพิมพ์ 3 บรรทัดนี้ (เว้นวรรคหน้าบรรทัด 2–3 ด้วยช่องว่าง 2 ตัว ห้ามใช้ Tab):

minio:
  access_key_id: minioadmin
  secret_access_key: minioadmin

บันทึกใน nano: Ctrl+O แล้ว Enter · ออก: Ctrl+X · เทอร์มินัลจะพิมพ์ต่อว่า File encrypted and saved. · (minioadmin คือผู้ดูแลสูงสุดของ MinIO จาก compose — ง่ายสำหรับเครื่อง dev · ระบบจริงควรสร้างกุญแจแยกที่มีสิทธิ์แค่ bucket เดียว)

ตรวจว่า Rails อ่านได้:

$ bin/rails runner 'p Rails.application.credentials.dig(:minio, :access_key_id)'
"minioadmin"

แล้วพิสูจน์ว่า Git ไม่เห็นกุญแจ:

$ git check-ignore -v config/credentials/development.key
.gitignore:43:/config/credentials/*.key	config/credentials/development.key

แปลว่า "ไฟล์นี้ถูกเมินเพราะ .gitignore บรรทัด 43 กฎ /config/credentials/*.key" · ส่วน development.yml.enc จะ commit ไปด้วย — เปิดดูก็เห็นแต่ตัวอักษรมั่ว ๆ เช่น KwCmT8+U3+TCUnal…

⚠️ ถ้าลืมใส่ EDITOR=… Rails จะสร้างกุญแจ ไฟล์ .yml.enc และเพิ่มบรรทัดใน .gitignore ไปก่อน (ข้อความ Adding … ขึ้นเหมือนข้างบน) แล้วจบด้วย No $VISUAL or $EDITOR to open file in. Assign one like this: โดยยังไม่ได้เปิดให้แก้ · ไม่เสียหาย — พิมพ์คำสั่งใหม่พร้อม EDITOR=nano ข้างหน้า รอบนี้จะขึ้นแค่ Editing config/credentials/development.yml.enc... (กุญแจมีแล้ว ไม่สร้างซ้ำ) แล้ว nano ก็เปิด

🔐 เทียบให้เห็นภาพ — ตู้เซฟใส่กุญแจโกดัง

ร้านเอากุญแจโกดังใส่ตู้เซฟ (.yml.enc) · ตู้เซฟนี้ยกไปสาขาไหนก็ได้ วางโชว์ก็ได้ เพราะไม่มีใครเปิดออก · รหัสเปิดตู้เซฟ (.key) จดแยก ส่งให้คนในทีมแบบตัวต่อตัว · ไม่เคยแปะไว้ข้างตู้ (ไม่ขึ้น Git)

ที่ไม่เหมือนกัน: ตู้เซฟจริงลืมรหัสยังเรียกช่างมางัดได้ · ไฟล์ .enc ไม่มีใครงัดได้ แม้แต่เจ้าของ — ทำกุญแจหายต้องสร้างชุดใหม่แล้วใส่รหัสทุกตัวใหม่หมด · และใครได้รหัสไป เปิดได้ทุกอย่างในเซฟ ไม่ใช่แค่กุญแจโกดัง

เทียบกับที่รู้: Go บท 46 เก็บรหัสในไฟล์ .env ตัวหนังสือธรรมดาแล้วซ่อนทั้งไฟล์จาก Git · Next.js ก็ใช้ .env.local แบบเดียวกัน · Rails กลับด้าน: commit ไฟล์รหัสได้เพราะมันเข้ารหัสแล้ว ซ่อนแค่กุญแจไฟล์เดียว · คนในทีมได้รหัสชุดใหม่ผ่าน Git แค่ต้องมีกุญแจ

🗣 ศัพท์ที่เพิ่งเรียนไป
credentials (encrypted credentials)
ไฟล์รหัสลับของ Rails ที่เข้ารหัสแล้ว commit ได้ · เปิดแก้ด้วย bin/rails credentials:edit · พิมพ์บอก AI ได้ว่า "put secrets in Rails credentials, never in config/*.yml or the code"
master key · environment key
ไฟล์กุญแจถอดรหัส · config/master.key สำหรับชุดหลัก · config/credentials/development.key สำหรับชุดของ development · ห้าม commit · ถาม senior ได้ว่า "ทีมส่ง credentials key ให้คนใหม่ทางไหน และ production อ่านกุญแจจากที่ไหน"
access key id · secret access key
คู่ "ชื่อผู้ใช้ + รหัสผ่าน" สำหรับโปรแกรมที่คุยภาษา S3 · ตัวแรกเปิดเผยได้บ้าง ตัวหลังเป็นความลับ · พิมพ์บอก AI ได้ว่า "create a dedicated access key scoped to one bucket instead of using the MinIO root user"

บอก Rails ว่าโกดังอยู่ไหน — storage.yml และ development.rb

เปิด config/storage.yml แล้วเพิ่มก้อนนี้ต่อจาก local: (ก่อนบรรทัด # Use bin/rails credentials:edit …):

# MinIO จาก compose.yaml (พูดภาษา S3 · console http://localhost:9001)
# key อยู่ใน credentials ของ development (bin/rails credentials:edit --environment development) ห้ามเขียนลงไฟล์นี้
# bucket เป็น private — เบราว์เซอร์ดูไฟล์ได้ผ่าน URL ที่เซ็นแล้วและหมดอายุเท่านั้น
minio:
  service: S3
  endpoint: http://localhost:9000
  force_path_style: true
  region: us-east-1
  bucket: wongnok-dev
  access_key_id: <%= Rails.application.credentials.dig(:minio, :access_key_id) %>
  secret_access_key: <%= Rails.application.credentials.dig(:minio, :secret_access_key) %>

แล้วเปิด config/environments/development.rb แก้สองบรรทัดนี้ (บรรทัดคอมเมนต์กับบรรทัดค่า):

  # Store uploaded files on the local file system (see config/storage.yml for options).
  config.active_storage.service = :local
  # เก็บไฟล์ที่อัปโหลดใน MinIO (service minio ใน config/storage.yml)
  config.active_storage.service = :minio

:minio คือชื่อก้อนที่เพิ่งเขียนใน storage.yml · ใช้เฉพาะ development · ตอนรัน test ยังใช้ test: (ดิสก์ใน tmp/storage) ไม่ต้องมี MinIO · production เราไม่แตะ

⚠️ ค่าใหม่ยังไม่มีผลจนกว่าจะปิด bin/dev แล้วเปิดใหม่ — ไฟล์ใน config/ ถูกอ่านครั้งเดียวตอนเปิด server · เราจะปิดเปิดทีเดียวตอนจบ 5 ขั้นข้างล่าง · ผมลองไม่ปิด: อัปรูปผ่าน แต่ไฟล์ไปลง storage/ เหมือนเดิม และ blob ถูกจดว่า service_name: "local"

ต่อรูปปกเข้ากับบทความ — 5 ขั้น

ภาพรวมก่อนลงมือ: ① model บอกว่าบทความมีรูปปก → ② controller ยอมรับช่องรูปปก → ③ ฟอร์มมีช่องเลือกไฟล์ → ④ เปิด JavaScript ที่ส่งไฟล์ขึ้นโกดัง → ⑤ หน้าบทความโชว์รูป · แต่ละขั้นแก้ไฟล์เดียว ยกเว้นขั้น ④ ที่แก้สองไฟล์

① model — app/models/post.rb เพิ่มสองบรรทัดต่อจาก has_rich_text :body:

  # เนื้อหาแบบ rich text เก็บในตาราง action_text_rich_texts (ไม่ได้อยู่ในตาราง posts)
  has_rich_text :body
  # รูปปก: ตัวไฟล์อยู่ใน MinIO · ข้อมูลไฟล์อยู่ในตาราง active_storage_blobs / active_storage_attachments
  has_one_attached :cover

has_one_attached :cover = "บทความหนึ่งชิ้นมีไฟล์แนบได้หนึ่งไฟล์ ในช่องชื่อ cover" · ไม่ต้องสร้าง migration และตาราง posts ไม่มีคอลัมน์ cover — ข้อมูลไปอยู่สองตารางเมื่อกี้ โดยแถว attachment จด name: "cover" · ได้ method มาใช้ เช่น post.cover.attached? (มีรูปหรือยัง)

② controller — app/controllers/posts_controller.rb ใน method post_params เพิ่ม :cover ในรายชื่อ (บท 117 · ไม่เพิ่ม = Rails ทิ้งช่องนี้):

      params.expect(post: [ :title, :body, :cover, :category_id ])

③ ฟอร์ม — app/views/posts/_form.html.erb เพิ่มก้อนนี้ก่อนก้อนช่อง "หมวด":

  <div class="my-5">
    <%= form.label :cover, "รูปปก", class: "block font-medium" %>
    <%# direct_upload: เบราว์เซอร์ส่งไฟล์ขึ้น MinIO (:9000) ตรง ๆ ไม่ผ่าน Rails · ฟอร์มส่งแค่รหัสของไฟล์ %>
    <%= form.file_field :cover, direct_upload: true, class: "file-input w-full mt-2" %>
  </div>

HTML ที่ Chrome ได้จริง:

<input class="file-input w-full mt-2" data-direct-upload-url="http://localhost:3000/rails/active_storage/direct_uploads" type="file" name="post[cover]" id="post_cover">

data-direct-upload-url = ที่อยู่ที่ JavaScript จะไปขอ "ใบอนุญาต" ก่อนส่งไฟล์ (ได้มาจาก direct_upload: true) · และ form_with ใส่ enctype="multipart/form-data" (แบบฟอร์มที่แนบไฟล์ได้) ให้<form>เองเพราะเห็นว่ามีช่องไฟล์

④ JavaScript ที่ทำให้ direct_upload ทำงาน — config/importmap.rb เพิ่มบรรทัดท้ายไฟล์:

pin "@rails/activestorage", to: "activestorage.esm.js"

และ app/javascript/application.js ต่อท้ายไฟล์:


// direct upload: ช่อง file_field ที่มี direct_upload: true ส่งไฟล์ขึ้น MinIO ตรง ๆ ก่อน submit ฟอร์ม
import * as ActiveStorage from "@rails/activestorage"
ActiveStorage.start()

⚠️ อย่ารัน bin/importmap pin @rails/activestorage — คำสั่งนั้นไปดาวน์โหลดไฟล์จากเว็บข้างนอกมาเก็บ · เราใช้ไฟล์ที่อยู่ใน Rails แล้ว พิมพ์บรรทัด pin เองพอ

⑤ แสดงรูป — app/views/posts/_post.html.erb ในก้อน <% if full %> เพิ่มก่อนบรรทัดคอมเมนต์ "แก้หัวข้อในหน้าเดิม":

    <% if full %>
      <%# รูปปก: src เป็น URL ของ Rails ที่ redirect (302) ไป URL ของ MinIO ที่เซ็นแล้ว (หมดอายุใน 5 นาที) %>
      <%= image_tag post.cover, alt: "", class: "w-full max-h-96 object-cover rounded-box" if post.cover.attached? %>

      <%# แก้หัวข้อในหน้าเดิม: id ต้องตรงกับ posts/edit.html+turbo_frame.erb %>

ตอนนี้ปิด bin/dev (Ctrl+C) แล้วเปิดใหม่ — ครั้งเดียวพอ ได้ทั้ง gem ใหม่และ config ใหม่

ลองอัปรูปปก แล้วตามไปดูว่าไฟล์อยู่ไหน

เปิด http://localhost:3000/posts/1/edit · ช่อง "รูปปก" กดเลือกรูปอะไรก็ได้ (ผมใช้ khaosoi.jpg ขนาด 1600×1000 · 49,262 ไบต์) → กด บันทึก · หน้าบทความขึ้น "บันทึกการแก้ไขแล้ว" และรูปปกโผล่เหนือหัวข้อ

ก่อนดูผล ทายสองข้อ:

① ตอนกดบันทึก ช่อง cover ที่ฟอร์มส่งให้ Rails มีอะไรอยู่ — ตัวรูปทั้งไฟล์ หรืออย่างอื่น? ② ใน bucket wongnok-dev ไฟล์นี้ชื่ออะไร — khaosoi.jpg หรือเปล่า?

① ไม่ใช่ตัวรูป · log ของ bin/dev ตอนกดบันทึก (ผลจริง ตัดมาเฉพาะ 3 บรรทัดที่เกี่ยว):

Started POST "/rails/active_storage/direct_uploads" for ::1 at 2026-11-10 10:24:47 +0700
  Parameters: {"blob" => {"filename" => "khaosoi.jpg", "content_type" => "image/jpeg", "byte_size" => 49262, "checksum" => "1Om72JYgL5Ud+uPFLdQiWA=="}, "direct_upload" => {"blob" => {"filename" => "khaosoi.jpg", "content_type" => "image/jpeg", "byte_size" => 49262, "checksum" => "1Om72JYgL5Ud+uPFLdQiWA=="}}}
  ActiveStorage::Blob Create (3.0ms)  INSERT INTO "active_storage_blobs" ("key", "filename", "content_type", "metadata", "service_name", "byte_size", "checksum", "created_at") VALUES ('l79czbrb5id3zw723b3nbxrllau6', 'khaosoi.jpg', 'image/jpeg', NULL, 'minio', 49262, '1Om72JYgL5Ud+uPFLdQiWA==', '2026-11-10 03:24:47.360505') RETURNING "id" /*action='create',application='WongnokBlogAllInOne',controller='direct_uploads'*/
  • for ::1 = คำขอมาจากเครื่องตัวเอง · Parameters พิมพ์ข้อมูลชุดเดียวกันซ้ำสองรอบ (ใต้ "blob" และ "direct_upload") เป็นนิสัยของ Rails ไม่ต้องสนใจ
  • JavaScript ขอ Rails ก่อน ส่งแค่ข้อมูลเกี่ยวกับไฟล์ (ชื่อ ชนิด ขนาด ลายนิ้วมือ) · Rails สร้าง blob สุ่ม key ให้ 'l79czbrb5id3zw723b3nbxrllau6' และจด 'minio'
  • จากนั้นเบราว์เซอร์ส่งตัวไฟล์ไป http://localhost:9000/wongnok-dev/l79czbrb5id3zw723b3nbxrllau6?… ตรง ๆ (Chrome เห็นคำขอนี้ · log ของ Rails ไม่มี เพราะไม่ได้ผ่าน Rails)

แล้วค่อยส่งฟอร์ม:

Started PATCH "/posts/1" for ::1 at 2026-11-10 10:24:47 +0700
  Parameters: {"authenticity_token" => "[FILTERED]", "post" => {"title" => "ต้มยำกุ้งน้ำข้น", "body" => "<div>ต้มยำกุ้งแบบน้ำข้นใส่นมข้นจืด รสเปรี้ยวเผ็ดกลมกล่อม กุ้งแม่น้ำตัวใหญ่ หอมตะไคร้ ข่า ใบมะกรูด กินกับข้าวสวยร้อน ๆ ได้ทั้งถ้วย ร้านนี้เปิดมาสามสิบปีแล้ว คิวยาวทุกวันเสาร์อาทิตย์</div>", "cover" => "eyJfcmFpbHMiOnsiZGF0YSI6MSwicHVyIjoiYmxvYl9pZCJ9fQ==--6dbdb6321336bba41059e6f6cc2dd6707974be18", "category_id" => "1"}, "commit" => "บันทึก", "id" => "1"}

(authenticity_token = ตั๋วกัน CSRF ของบท 124 · Rails ซ่อนค่าใน log เป็น [FILTERED]) · "cover" => "eyJf…--6dbd…" คือรหัสอ้างถึง blob ที่ Rails เซ็นกำกับไว้ (ส่วนหลัง -- คือลายเซ็น · ปลอมไม่ได้) ไม่ใช่รูป · Rails เอารหัสนี้ไปสร้างแถว attachment · ฟอร์มที่วิ่งผ่าน Rails จึงเล็กนิดเดียว ไม่ว่ารูปจะใหญ่แค่ไหน

② ไม่ใช่ khaosoi.jpg · เปิด console http://localhost:9001 → Buckets → wongnok-dev เห็นแถวนี้ (ผลจริง):

Name                            Last Modified    Size
l79czbrb5id3zw723b3nbxrllau6    Today, 10:24     48.1 KiB

ชื่อในโกดัง = key ที่สุ่ม ตรงกับใน INSERT ข้างบน · ชื่อ khaosoi.jpg อยู่ในคอลัมน์ filename ของฐานข้อมูล · 48.1 KiB = 49,262 ไบต์ ÷ 1024

ดูฝั่งฐานข้อมูลใน bin/rails console:

wongnok-blog-all-in-one(dev):001> Post.find(1).cover.blob
  Post Load (0.4ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = 1 LIMIT 1 /*application='WongnokBlogAllInOne'*/
  ActiveStorage::Attachment Load (0.4ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = 1 AND "active_storage_attachments"."record_type" = 'Post' AND "active_storage_attachments"."name" = 'cover' LIMIT 1 /*application='WongnokBlogAllInOne'*/
  ActiveStorage::Blob Load (0.4ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = 1 LIMIT 1 /*application='WongnokBlogAllInOne'*/
=>
#<ActiveStorage::Blob:0x0000000128be2a20
 id: 1,
 key: "l79czbrb5id3zw723b3nbxrllau6",
 filename: "khaosoi.jpg",
 content_type: "image/jpeg",
 metadata:
  {"identified" => true, "width" => 1600, "height" => 1000, "analyzed" => true},
 service_name: "minio",
 byte_size: 49262,
 checksum: "1Om72JYgL5Ud+uPFLdQiWA==",
 created_at: "2026-11-10 10:24:47.360505000 +0700">
wongnok-blog-all-in-one(dev):002> Post.find(1).cover.attachment
  Post Load (1.6ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = 1 LIMIT 1 /*application='WongnokBlogAllInOne'*/
  ActiveStorage::Attachment Load (0.8ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = 1 AND "active_storage_attachments"."record_type" = 'Post' AND "active_storage_attachments"."name" = 'cover' LIMIT 1 /*application='WongnokBlogAllInOne'*/
=>
#<ActiveStorage::Attachment:0x000000012fa70c80
 id: 1,
 name: "cover",
 record_type: "Post",
 record_id: 1,
 blob_id: 1,
 created_at: "2026-11-10 10:24:47.427634000 +0700">

สุดท้าย ดูว่าโฟลเดอร์ storage/ ในโปรเจกต์ไม่มีอะไรเพิ่ม:

$ ls -A storage
.keep

มีแค่ .keep (ไฟล์เปล่าที่ทำให้ Git เก็บโฟลเดอร์ไว้) = รูปไม่ได้อยู่ในแอปแล้ว · (ถ้าคุณเคยแนบรูปใน Trix ตอนบท 137 จะเห็นโฟลเดอร์ชื่อสองตัวอักษรของรูปพวกนั้นค้างอยู่ — เป็นของเก่าก่อนเปลี่ยนโกดัง)

commit ก่อนไปต่อ

$ git status --short
 M .gitignore
 M Gemfile
 M Gemfile.lock
 M app/controllers/posts_controller.rb
 M app/javascript/application.js
 M app/models/post.rb
 M app/views/posts/_form.html.erb
 M app/views/posts/_post.html.erb
 M config/environments/development.rb
 M config/importmap.rb
 M config/storage.yml
?? config/credentials/
$ git add -A
$ git commit -m "รูปปกบทความเก็บใน MinIO ด้วย Active Storage"
[my-day3 eda25ae] รูปปกบทความเก็บใน MinIO ด้วย Active Storage
 12 files changed, 70 insertions(+), 3 deletions(-)
 create mode 100644 config/credentials/development.yml.enc
$ git ls-files config/credentials
config/credentials/development.yml.enc

?? config/credentials/ = โฟลเดอร์ใหม่ · create mode … development.yml.enc กับบรรทัดสุดท้ายพิสูจน์ว่าที่เข้า Git มีแค่ .yml.enc · development.key ไม่เข้า · (ชื่อ branch กับเลข eda25ae ของคุณจะต่าง) · ไฟล์ .yml.enc ของคุณต่างจากของทุกคน เพราะกุญแจสุ่มไม่ซ้ำกัน — เป็นไฟล์เดียวในบทนี้ที่ไม่ควรเหมือนของคนอื่น

🎯 ภารกิจ — แนบรูปปกให้บทความที่ 2 แล้วพิสูจน์ว่ารูปไม่ได้อยู่ในแอป (~8 นาที)

  1. เปิด http://localhost:3000/posts/2/edit · เลือกรูปอะไรก็ได้ที่ไม่ใช่รูปเดิม → บันทึก · ต้องเห็นรูปในหน้าบทความ
  2. ก่อนพิมพ์ข้อ 3 เขียนลงกระดาษ: key จะหน้าตาแบบไหน · service_name จะเป็นอะไร · ชื่อไฟล์เดิมยังอยู่ไหม
  3. ใน bin/rails console:
    wongnok-blog-all-in-one(dev):001> Post.find(2).cover.blob.key
    wongnok-blog-all-in-one(dev):002> Post.find(2).cover.blob.service_name
    wongnok-blog-all-in-one(dev):003> Post.find(2).cover.filename.to_s
  4. ก็อป key ไปวางในช่อง "Start typing to filter objects in the bucket" ของหน้า bucket ใน console :9001 · ดูขนาดไฟล์
  5. ls -A storage อีกครั้ง

key หน้าตาแบบไหน · service_name คืออะไร · ใน console เจอไหม · storage/ มีอะไรเพิ่มหรือเปล่า?

ผลจริงของผม (อัป somtam.png 800×500):

wongnok-blog-all-in-one(dev):001> Post.find(2).cover.blob.key
=> "p0vw4mf4zvtnpbu01i83piqar3qi"
wongnok-blog-all-in-one(dev):002> Post.find(2).cover.blob.service_name
=> "minio"
wongnok-blog-all-in-one(dev):003> Post.find(2).cover.filename.to_s
=> "somtam.png"

(ตัดบรรทัด SQL ออก) · console :9001 เจอแถว p0vw4mf4zvtnpbu01i83piqar3qi · Today, 10:28 · 2.5 KiB · ls -A storage ยังได้ .keep ตัวเดียว

  • key = ตัวอักษรเล็กกับตัวเลขสุ่ม 28 ตัว · ของคุณจะต่าง แต่ยาวเท่ากันและไม่มีชื่อไฟล์ปน
  • service_name = "minio" · ชื่อไฟล์เดิมยังอยู่ แต่อยู่ในฐานข้อมูล ไม่ได้อยู่ในโกดัง (.to_s = แปลงเป็นข้อความธรรมดา ไม่งั้น console โชว์เป็นกล่อง #<ActiveStorage::Filename …>)

ถ้าเจออาการนี้ แปลว่า… (ทุกข้อผมทำพังให้ดูจริง)

  • เบราว์เซอร์เด้งกล่อง Error storing "ชื่อไฟล์". Status: 404 แล้วยังค้างอยู่หน้าแก้ไข — ยังไม่ได้สร้าง bucket หรือชื่อใน storage.yml ไม่ตรงกับใน console · Rails ออกใบอนุญาตให้แล้ว แต่โกดังตอบว่า "ไม่มีห้องนี้"
  • กล่อง Error creating Blob for "ชื่อไฟล์". Status: 500 และ log มี Aws::Sigv4::Errors::MissingCredentialsError (missing credentials, … — Rails ไม่มีรหัส MinIO · ไฟล์ config/credentials/development.key หาย หรือพิมพ์ใน credentials ผิดชื่อ (เช็คด้วยคำสั่ง runner ต้องได้ "minioadmin" ไม่ใช่ nil)
  • รูปขึ้น แต่ service_name เป็น "local" และมีโฟลเดอร์ใหม่ใน storage/ — ยังไม่ได้ปิดเปิด bin/dev หลังแก้ development.rb · ถ้าเปิดแท็บ Network ของ DevTools ใน Chrome จะเห็นไฟล์ถูกส่งไป localhost:3000/rails/active_storage/disk/… แทน :9000

ทดสอบความเข้าใจ

แอปเก็บรูปไว้ใน storage/ แล้วทีมเพิ่มเครื่องรันแอปเป็น 3 เครื่องช่วยกันรับคน · ผู้ใช้บ่นว่า "รูปปกขึ้นบ้างไม่ขึ้นบ้าง" · เพราะอะไร?

ตัวเลือกที่ 2 ถูก — ฐานข้อมูลมีเครื่องเดียวทุกเครื่องเห็นตรงกัน แต่ storage/ เป็นโฟลเดอร์ของแต่ละเครื่อง · คนที่เปิดหน้าแล้วไปตกเครื่องที่ไม่ใช่เครื่องที่รับอัปโหลดจะไม่เจอไฟล์

ตัวเลือกที่ 1 — Active Storage ไม่ได้บีบอัดรูปตอนเก็บ ไฟล์เป็นแบบที่อัปมา · ตัวเลือกที่ 3 — แถว blob ถูกจดถูกต้อง ปัญหาคือตัวไฟล์ไม่อยู่ในเครื่องที่ถูกถาม · ตัวเลือกที่ 4 — ถ้าเบราว์เซอร์จำรูปไว้ รูปจะขึ้น ไม่ใช่หาย

จุดที่ต้องจำ: ของถาวรต้องอยู่นอกตัวแอป (12-Factor ข้อ VI) · ไฟล์ไปโกดังกลาง ข้อมูลไปฐานข้อมูลกลาง

ใน bucket ไฟล์ชื่อ l79czbrb5id3zw723b3nbxrllau6 · แล้วชื่อ khaosoi.jpg ที่ผู้ใช้อัปมาถูกเก็บไว้ที่ไหน?

ตัวเลือกที่ 4 ถูก — blob คือใบรับฝาก จดทั้ง key (ชื่อในโกดัง) และ filename (ชื่อเดิม) ไว้แถวเดียวกัน · console เห็น filename: "khaosoi.jpg" คู่กับ key: "l79c…"

ตัวเลือกที่ 1 — ชื่อใน bucket คือ key ล้วน ไม่มีส่วนที่ซ่อน · ตัวเลือกที่ 2 — has_one_attached ไม่สร้างคอลัมน์ ไม่มี migration · ตาราง posts ไม่มีช่อง cover · ตัวเลือกที่ 3 — attachment จดแค่ "ของใคร ช่องไหน ชี้ blob ไหน" (name · record_type · record_id · blob_id) ไม่มีชื่อไฟล์

จุดที่ต้องจำ: โกดังเก็บตัวไฟล์ใต้ชื่อสุ่ม · ฐานข้อมูลเก็บ "ข้อมูลเกี่ยวกับไฟล์" (blob) กับ "ไฟล์นี้ของใคร" (attachment)

เพื่อน clone repo ไปแล้วอัปรูปปกไม่ได้ เด้ง Status: 500 และ log มี MissingCredentialsError · วิธีไหนแก้ถูกทางที่สุด?

ตัวเลือกที่ 1 ถูก — เพื่อนได้ .yml.enc มากับ Git แล้ว ขาดแค่กุญแจ · ส่งกุญแจแบบที่ Rails แนะนำ (ที่เก็บรหัสของทีม ไม่ใช่แชตกลุ่ม) แล้ว Rails อ่านรหัสได้ทันที

ตัวเลือกที่ 2 — กุญแจขึ้น Git = ใครเห็น repo ก็ถอดรหัสได้ทุกอย่าง เท่ากับไม่ได้เข้ารหัส · ตัวเลือกที่ 3 — รหัสโผล่ในไฟล์ที่ commit แบบตัวหนังสือธรรมดา คือสิ่งที่บทนี้หลีกเลี่ยง · ตัวเลือกที่ 4 — ผมลองแล้ว: Rails สร้างกุญแจใหม่ให้ แล้วฟ้อง Couldn't decrypt config/credentials/development.yml.enc. Perhaps you passed the wrong key? เพราะกุญแจใหม่ไม่ใช่ตัวที่ล็อกไฟล์นั้น

จุดที่ต้องจำ: .yml.enc ขึ้น Git ได้ · .key ห้ามขึ้น และต้องส่งให้ทีมแยกทางอย่างปลอดภัย

แก้ development.rb เป็น :minio แล้ว แต่อัปรูปเสร็จ ใน bucket ไม่มีไฟล์ใหม่ กลับมีโฟลเดอร์ใหม่ใน storage/ · เพราะอะไร?

ตัวเลือกที่ 3 ถูก — ไฟล์ใน config/environments/ ถูกอ่านครั้งเดียวตอนเปิด server · ผมลองแล้ว blob ถูกจด service_name: "local" จนกว่าจะเปิด server ใหม่

ตัวเลือกที่ 1 — private แปลว่าคนนอกเปิดไม่ได้ · console ล็อกอินด้วยผู้ดูแลเห็นทุกไฟล์ · ตัวเลือกที่ 2 — service: ใน model มีจริงแต่ไม่บังคับ ไม่ใส่ก็ใช้ค่าจาก config · ของเราไม่ใส่และไฟล์ไปโกดังได้ · ตัวเลือกที่ 4 — direct upload ส่งจากเบราว์เซอร์ไปที่เก็บตามที่ server ตั้งค่าอยู่ตอนนั้นทีเดียว ไม่มีขั้นย้ายทีหลัง

จุดที่ต้องจำ: แก้อะไรใน config/ หรือ Gemfile → ปิดเปิด server ใหม่เสมอ · เช็คด้วย blob.service_name

ถ้าเปิดสไลด์ดูประกอบ

หน้าสไลด์เขียนของจริง (Rails 8.1.4 · แอปของคอร์ส)ต้องสนใจไหม
9bin/rails active_storage:install && bin/rails db:migrateในแอปเราเงียบทั้งคู่ เพราะบท 137 สร้างตารางไปแล้ว · สไลด์สมมติว่ายังไม่มี Action Textไม่ต้อง
9"2 ตารางที่มันสร้างให้"มี 3 ตาราง · ตัวที่สาม active_storage_variant_records ใช้กับรูปย่อ (บท 140)ไม่ต้อง
9"แยกสองตารางเพราะไฟล์เดียวกันแนบได้หลายที่ โดยเก็บจริงครั้งเดียว (ดูจาก checksum)"ผมอัปรูปเดิมสองครั้ง ได้ blob สองแถว checksum เท่ากัน แต่ key ต่างกัน = ไฟล์สองชิ้นในโกดัง · Rails ไม่ได้รวมให้เองจาก checksum · blob เดียวแนบหลายที่ได้จริง เฉพาะเมื่อโค้ดสั่งแนบ blob ตัวเดิม (ลองแล้ว ได้ attachment สองแถว blob_id เดียวกัน)ไม่ต้อง
9config/storage.yml มีแค่บรรทัด access_key_idสไลด์ตัดจอ · ต้องมีบรรทัด secret_access_key ด้วย ไม่งั้นได้ MissingCredentialsError แบบในภารกิจสนใจ ถ้าพิมพ์ตาม
9has_many_attached :galleryแอปเราทำแค่รูปปกรูปเดียว (has_one_attached :cover) · has_many_attached = แนบได้หลายไฟล์ในช่องเดียวไม่ต้อง
8"ไฟล์ 500 MB … 10 คนอัปพร้อมกัน = worker หมด"เป็นตัวอย่างคิดเลข ไม่ได้วัด · "worker" ในหน้านี้ = คนรับงานของ server (คนละอย่างกับ worker ของ job ในบท 142) · server ของเราใน development รับพร้อมกันได้ 3 งาน (Min threads: 3 · Max threads: 3)ไม่ต้อง

อ้างอิง

ติดตรงไหน หรือผลของคุณต่างจากในบท — ก็อปผลจริงทั้งก้อน (log ของ bin/dev หรือข้อความที่เด้ง) ไปถาม AI หรือเพื่อนได้เลย โดยบอกว่ามาจากบท 138 ขั้นไหน