🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้
docker compose(บท 120) — เปิดโปรแกรมหลายตัวในกล่องแยก (container) ตามไฟล์compose.yaml· ตอนนั้นเห็นminio:ที่บอกว่า "วันนี้ยังไม่ใช้" — วันนี้ใช้แล้ว- Action Text (บท 137) — ตอนรัน
action_text:installมันแอบสร้างตารางของ Active Storage ไว้ให้ด้วย (ปุ่มคลิปแนบไฟล์ของ Trix ใช้มัน) · polymorphic = ตารางเดียวเก็บของได้หลายแผนก โดยจด "แผนกไหน + แฟ้มเลขที่เท่าไร" params.expect(บท 117) — รายชื่อช่องในฟอร์มที่รับได้ · ช่องใหม่ต้องเพิ่มชื่อเอง- 12-Factor · ไฟล์
.envห้าม commit (Go บท 44 · บท 46) — แนวคิดว่าแอปที่ดีควรหน้าตาแบบไหน · และรหัสผ่านห้ามขึ้น Git - schema.rb (บท 108) — ภาพรวมทุกตารางในฐานข้อมูล อ่านง่ายกว่าไล่ migration ทีละไฟล์
🧰 เวอร์ชันที่ใช้ตรวจ: 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") %>
service = :local— "ไฟล์ที่ผู้ใช้อัปมา ให้ใช้ที่เก็บชื่อlocal"local:→service: Disk— ที่เก็บชื่อ local คือดิสก์ของเครื่องนี้ ·root:= โฟลเดอร์storage/ในโปรเจกต์ (<%= %>ในไฟล์ yml คือ ERB แบบเดียวกับใน view · Rails คำนวณให้เป็นที่อยู่เต็มของโฟลเดอร์)
ตอนผมลองอัปรูปด้วยค่านี้ ไฟล์ไปโผล่ที่ 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 กี่รอบ เปิดกี่เครื่อง ทุกเครื่องเห็นไฟล์ชุดเดียวกัน
- bucket — กล่องใหญ่ที่ตั้งชื่อไว้ เก็บไฟล์ได้ไม่จำกัด · แอปเราจะใช้ bucket ชื่อ
wongnok-dev - object — ไฟล์หนึ่งชิ้นใน bucket · key = ชื่อที่ใช้เรียกมัน (เหมือนเลขที่ใบฝากของ)
- S3 — บริการ object storage ของ Amazon · ชุดคำสั่งที่ใช้คุยกับมันกลายเป็นภาษากลาง หลายเจ้าเลยพูดภาษาเดียวกัน (เรียกว่า S3-compatible)
- MinIO — โปรแกรม object storage ที่พูดภาษา S3 และรันในเครื่องเราได้ · ตอนพัฒนาใช้ MinIO · ตอนขึ้นจริงจะเปลี่ยนไปใช้ S3 ก็แค่แก้ config ไม่ต้องแก้โค้ด
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"]
image: pgsty/silo— ตัวโปรแกรม · หน้า login ของมันเขียนว่า "MinIO fork maintained by PGSTY" = MinIO ฉบับที่อีกทีมดูแลต่อ ใช้งานเหมือนกัน · ชื่อบนหน้าจอจะเป็น SILOcommand: server /data …— สั่งให้เก็บไฟล์ไว้ที่/dataในกล่อง ·--console-address ":9001"= เปิดหน้าเว็บจัดการที่ประตู 9001MINIO_ROOT_USER·MINIO_ROOT_PASSWORD— ชื่อและรหัสของผู้ดูแลสูงสุด ·minioadminเป็นค่าทดลองในเครื่อง ห้ามใช้กับระบบจริงports— 9000 = ประตูที่โปรแกรมคุยกัน (ภาษา S3 · Rails และเบราว์เซอร์จะส่งไฟล์มาที่นี่) · 9001 = หน้าเว็บ console ให้คนกดดูvolumes: miniodata:/data— เก็บ/dataไว้นอกกล่อง (แบบpgdataของ Postgres บท 120) · MinIO เองก็เป็นกล่อง แต่ปิดเปิดใหม่แล้วไฟล์ไม่หายเพราะบรรทัดนี้
ร้านเลิกวางของฝากในครัว แล้วไปเช่าโกดังรับฝากของนอกร้าน · ครัวรีโนเวทกี่รอบ ของในโกดังก็อยู่ · ร้านเปิดสามสาขา ทุกสาขาไปเบิกจากโกดังเดียวกัน · ในโกดังแบ่งเป็นห้อง (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:
- เปิด
http://localhost:9001· เจอหน้า login ที่มีโลโก้ SILO ใต้โลโก้เขียน "OBJECT STORAGE CONSOLE" · ช่อง Username พิมพ์minioadmin· ช่อง Password พิมพ์minioadmin(ค่าจากcompose.yaml) → ปุ่ม Login · จะไปหน้า Overview - เมนูซ้าย Buckets → ปุ่ม Create bucket (มุมขวาบน) → หน้า "Create Bucket"
- ช่อง Bucket Name* พิมพ์
wongnok-dev(ตรงตัวอักษร เดี๋ยวต้องใส่ชื่อนี้ใน config) · Versioning · Object Locking · Quota ปล่อย Off → ปุ่ม Create Bucket - หน้าจอเด้งไปที่ห้องใหม่ ขึ้นว่า "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 = "ก้อนข้อมูล"):
key— ชื่อของไฟล์ในโกดัง · Rails สุ่มให้ ·unique: true= ห้ามซ้ำfilename— ชื่อเดิมที่ผู้ใช้อัปมา เช่นkhaosoi.jpg·content_type— ชนิดไฟล์ เช่นimage/jpeg(บท 140 จะเห็นว่าเชื่อช่องนี้ไม่ได้)metadata— ข้อมูลเสริม เช่นกว้าง × ยาวของรูป ·byte_size— ขนาดเป็นไบต์service_name— ไฟล์นี้อยู่โกดังไหน (localหรือminio)checksum— ลายนิ้วมือของไฟล์ คำนวณจากเนื้อไฟล์ · ไว้ตรวจว่าไฟล์ไม่เพี้ยนระหว่างส่ง
active_storage_attachments — หนึ่งแถว = ป้ายบอกว่าไฟล์ไหนเป็นของใคร:
name— ชื่อช่อง เช่น"cover"(รูปปก)record_type+record_id— ของแฟ้มไหน เลขที่เท่าไร เช่น"Post"+1· นี่คือ polymorphic แบบเดียวกับaction_text_rich_textsของบท 137 · ตารางเดียวใช้ได้กับทุก modelblob_id— ชี้ไปแถวในactive_storage_blobs
ตารางที่สาม 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
"~> 1.238"— ใช้รุ่น 1.238 ขึ้นไป แต่ไม่ถึง 2.0require: false— ไม่ต้องโหลดตอนเปิดแอป · Active Storage จะโหลดเองตอนต้องคุยกับ S3
$ 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
EDITOR=nano— บอกว่าจะเปิดไฟล์ด้วยโปรแกรมแก้ข้อความในเทอร์มินัลชื่อ nano (บน macOS คำสั่งนี้เปิดโปรแกรม pico ที่หน้าตาและปุ่มเหมือนกัน หัวจอจะเขียนว่า PICO ไม่ต้องตกใจ) · ใช้ VS Code ได้ด้วยEDITOR="code --wait"(--waitสำคัญ ไม่งั้นมันปิดทันทีแล้วไม่ได้บันทึกอะไร)credentials:edit— ถอดรหัสไฟล์ ให้แก้ แล้วเข้ารหัสกลับตอนปิด ·--environment development= ทำชุดของ 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...
Adding config/credentials/development.key …— สร้างกุญแจให้ · ข้อความถัดมาเตือนว่าให้เก็บไว้ใน password manager ของทีม (โปรแกรมเก็บรหัสผ่านที่แชร์กันในทีมได้อย่างปลอดภัย) และถ้ากุญแจหาย ไม่มีใครเปิดไฟล์ได้อีก รวมถึงตัวคุณเองappend .gitignore— เพิ่มบรรทัดลง.gitignoreให้ Git ไม่สนไฟล์กุญแจ (ดูข้างล่าง)Configured Git diff driver …— ตั้งให้git diffถอดรหัสไฟล์.encโชว์เป็นข้อความได้ (บนเครื่องที่มีกุญแจ) · ไม่ต้องทำอะไรEditing …— แล้ว nano ก็เปิดขึ้นมา
ใน 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"
bin/rails runner '…'— รันโค้ด Ruby หนึ่งบรรทัดในแอป แล้วจบ (ไม่ต้องเปิด console)credentials.dig(:minio, :access_key_id)— เปิดหัวข้อminioแล้วหยิบaccess_key_id(dig= ขุดลงไปทีละชั้น ถ้าไม่มีก็ได้nilไม่ error) ·p= พิมพ์ออกมา
แล้วพิสูจน์ว่า 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) %>
minio:— ชื่อ service ที่เราตั้งเอง (ตั้งอะไรก็ได้ แต่ต้องตรงกับที่development.rbเรียก)service: S3— ใช้ตัวคุยภาษา S3 (gem ที่เพิ่งติดตั้ง)endpoint: http://localhost:9000— ที่อยู่ของโกดังที่ Rails จะไปคุยด้วย · ตรงนี้ไม่ใช่ Amazon แต่เป็น MinIO ในเครื่องเรา ประตู 9000 · ผมลองตัดบรรทัดนี้ออกแล้วให้ Rails สร้างที่อยู่ไฟล์ดู ได้https://s3.amazonaws.com/wongnok-dev/…= วิ่งไปหา Amazonforce_path_style: true— เขียนที่อยู่ไฟล์แบบhttp://localhost:9000/wongnok-dev/ชื่อไฟล์(ชื่อ bucket อยู่ในทางหลัง/) · ตัดออกแล้วได้http://wongnok-dev.localhost:9000/ชื่อไฟล์(ชื่อ bucket ไปอยู่หน้าชื่อเครื่อง แบบที่ Amazon ใช้) · กับ MinIO ในเครื่อง ใช้แบบแรกตรงไปตรงมาที่สุดregion: us-east-1— ภาษา S3 บังคับให้มีชื่อภูมิภาค · MinIO ไม่สน แต่ต้องใส่สักค่าbucket: wongnok-dev— ต้องตรงกับห้องที่สร้างใน console ทุกตัวอักษร<%= … %>— ใช่ ERB แบบเดียวกับใน view · Rails รัน ERB ในไฟล์ yml ก่อนอ่าน → ตรงนี้กลายเป็นค่าจาก credentials ตอนแอปเปิด
แล้วเปิด 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>
form.file_field :cover— ช่อง "เลือกไฟล์" (<input type="file">) ·file-input= หน้าตาของ daisyUI (บท 128)direct_upload: true— ให้เบราว์เซอร์ส่งไฟล์ตรงไปโกดังเอง ไม่ผ่าน Rails (แก้ปัญหาข้อ 4 ในตารางแรก · สไลด์หน้า 8 สรุปว่า "แอปควรเป็นคนออกใบอนุญาต ไม่ใช่คนขนของ") · บทนี้แค่เปิดไว้ วิธีทำงานละเอียดอยู่บท 139
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()
pin …— บอก importmap ว่าชื่อ@rails/activestorageคือไฟล์activestorage.esm.jsที่มากับ Rails อยู่แล้ว (บท 127)ActiveStorage.start()— เริ่มคอยดักฟอร์ม · พอกดบันทึก มันส่งไฟล์ในช่องdirect_uploadขึ้นโกดังก่อน แล้วค่อยส่งฟอร์ม
⚠️ อย่ารัน 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 %>
image_tag post.cover— สร้าง<img>ที่ชี้ไปรูปปก (Rails สร้างลิงก์ให้)if post.cover.attached?—ifท้ายบรรทัด (บท 94) = ทำบรรทัดนี้เฉพาะเมื่อเงื่อนไขจริง → โชว์เฉพาะบทความที่มีรูป · ไม่ใส่ = บทความที่ไม่มีรูปพังทั้งหน้าalt: ""— รูปปกเป็นภาพประกอบ ไม่ได้มีข้อมูลที่ต้องอ่าน ·object-cover= ตัดรูปให้เต็มกรอบโดยไม่บิดสัดส่วน- คอมเมนต์บรรทัดแรกพูดถึง "redirect" (ส่งเบราว์เซอร์ต่อไปอีกที่อยู่) กับ "URL ที่เซ็นแล้ว" (ลิงก์ที่มีลายเซ็นของแอปต่อท้าย ใครแก้ลิงก์ลายเซ็นก็ไม่ตรง และใช้ได้แค่ 5 นาที) — คือวิธีที่รูปเดินทางจากโกดังมาถึงเบราว์เซอร์ · พิมพ์ไว้ก่อน อธิบายเต็ม ๆ ในบท 139
ตอนนี้ปิด 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">
- SQL บรรทัด
Attachment Load— หาป้ายที่record_type = 'Post'·record_id = 1·name = 'cover'= "รูปปกของบทความ 1" (polymorphic ทำงานแบบนี้) - SQL บรรทัด
Blob Load— ได้blob_idจากป้าย แล้วไปหยิบใบรับฝากเลข 1 · หนึ่งรูปปก = SQL สองบรรทัด (ถ้าวนโชว์หลายบทความจะเป็น N+1 — บท 140 แก้) metadata: {… "width" => 1600, "height" => 1000 …}— Rails เปิดรูปวัดขนาดให้เองเป็นงานเบื้องหลังหลังบันทึก (log มีบรรทัดEnqueued ActiveStorage::AnalyzeJob· เรื่องงานเบื้องหลังรอบท 142)service_name: "minio"— ไฟล์นี้อยู่โกดัง minio ·blob_id: 1ในป้าย = ชี้ใบรับฝากนี้
สุดท้าย ดูว่าโฟลเดอร์ 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 นาที)
- เปิด
http://localhost:3000/posts/2/edit· เลือกรูปอะไรก็ได้ที่ไม่ใช่รูปเดิม → บันทึก · ต้องเห็นรูปในหน้าบทความ - ก่อนพิมพ์ข้อ 3 เขียนลงกระดาษ: key จะหน้าตาแบบไหน · service_name จะเป็นอะไร · ชื่อไฟล์เดิมยังอยู่ไหม
- ใน
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 - ก็อป key ไปวางในช่อง "Start typing to filter objects in the bucket" ของหน้า bucket ใน console :9001 · ดูขนาดไฟล์
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 · แอปของคอร์ส) | ต้องสนใจไหม |
|---|---|---|---|
| 9 | bin/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 เดียวกัน) | ไม่ต้อง |
| 9 | config/storage.yml มีแค่บรรทัด access_key_id | สไลด์ตัดจอ · ต้องมีบรรทัด secret_access_key ด้วย ไม่งั้นได้ MissingCredentialsError แบบในภารกิจ | สนใจ ถ้าพิมพ์ตาม |
| 9 | has_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) | ไม่ต้อง |
อ้างอิง
- Rails Guides — Active Storage Overview § Setup —
อ่านหัวข้อ Setup กับ S3 Service (ถัดลงไป) · ตาราง 3 ตาราง · ตัวอย่าง
storage.yml· บรรทัดgem "aws-sdk-s3", require: false· และตัวอย่างendpoint:สำหรับเจ้าที่พูดภาษา S3 แบบเดียวกับ MinIO - Rails Guides — Active Storage Overview § has_one_attached — ตัวอย่าง model + ฟอร์ม + controller ครบชุดแบบเดียวกับบทนี้ · อ่านแค่หัวข้อนี้หัวข้อเดียว
- Rails Guides — Securing Rails Applications § Custom Credentials —
ทำไมไฟล์
.encขึ้น Git ได้ แต่กุญแจต้องเก็บดี ๆ · อ่านสามย่อหน้าแรก · เรื่อง credentials แยกต่อ environment (แบบที่บทนี้ใช้) พิมพ์bin/rails credentials:helpแล้วดูหัวข้อ "Environment Specific Credentials" - The Twelve-Factor App — VI. Processes — หน้าสั้น · อ่านสองย่อหน้าที่ขึ้นต้นว่า "Twelve-factor processes are stateless…" ตรงกับปัญหา "เก็บไฟล์ในเครื่องที่รันแอป" ของบทนี้ · ถ้าว่างอ่าน IV. Backing services ต่อ
- MinIO — Console UI —
อธิบาย
--console-address ":9001"ใน compose ของเรา และหน้า login · อ่านแค่สองหัวข้อแรก - Rails API — ActiveStorage::Attached::Model#has_one_attached —
รายการ option ทั้งหมด (รวม
service:ที่พูดถึงใน quiz ข้อ 4) · ใช้เปิดดูตอนต้องการ ไม่ต้องอ่านทั้งหน้า
ติดตรงไหน หรือผลของคุณต่างจากในบท — ก็อปผลจริงทั้งก้อน (log ของ bin/dev หรือข้อความที่เด้ง) ไปถาม AI หรือเพื่อนได้เลย โดยบอกว่ามาจากบท 138 ขั้นไหน