You are going to run two computers on your one computer. One of them does nothing but remember things — that is PostgreSQL. The other one shows a calendar to people and writes what they type into the first one. They talk over a private network that nothing else can reach. 你将在自己的一台电脑上运行两台计算机。其中一台只负责记住东西,那就是 PostgreSQL。另一台把日历展示给用户,并把用户输入的内容写进第一台里。 它们通过一个别人碰不到的私有网络通信。 คุณกำลังจะรันคอมพิวเตอร์สองเครื่องบนเครื่องเดียวของคุณ เครื่องหนึ่งทำหน้าที่จำอย่างเดียว นั่นคือ PostgreSQL ส่วนอีกเครื่องแสดงปฏิทินให้ผู้ใช้ดู และเขียนสิ่งที่ผู้ใช้พิมพ์ลงไปในเครื่องแรก ทั้งสองคุยกันผ่านเครือข่ายส่วนตัวที่ไม่มีใครเข้าถึงได้
Everything on this page was built and run before it was written. The commands are the commands that worked, and the gotchas are the ones that actually bit. 本页所有内容都是先真正跑通、再写下来的。这里的命令就是当时能用的命令, 踩坑部分也都是真的踩过的坑。 ทุกอย่างในหน้านี้ถูกสร้างและรันจริงก่อนจะเขียนขึ้นมา คำสั่งที่เห็นคือคำสั่งที่ใช้ได้จริง และข้อผิดพลาดที่เตือนไว้ก็คือสิ่งที่เจอมาจริง ๆ
Twenty-two words. Learn these first and every command below explains itself. 二十二个词。先学会这些,下面每一条命令都会自己解释自己。 ยี่สิบสองคำ เรียนคำเหล่านี้ก่อน แล้วทุกคำสั่งข้างล่างจะอธิบายตัวมันเอง
Two containers, one private network, one port open to you.
ddtraveller/calendar-labpostgres:17-alpinepgdataYour browser reaches localhost:8080, which Docker forwards to port
3000 inside web. Nothing reaches db except web, because
db publishes no ports at all. That is not laziness — it is the single most valuable
security habit in this whole lab.
localhost:8080,Docker 把它转发到 web 容器内部的
3000 端口。除了 web,没有任何东西能碰到 db,因为 db
根本没有对外开放任何端口。这不是偷懒——这是整个实验里最有价值的一个安全习惯。localhost:8080 แล้ว Docker ส่งต่อไปยังพอร์ต
3000 ภายในคอนเทนเนอร์ web ไม่มีอะไรเข้าถึง db ได้นอกจาก web
เพราะ db ไม่ได้เปิดพอร์ตออกมาเลย นี่ไม่ใช่ความขี้เกียจ
แต่เป็นนิสัยด้านความปลอดภัยที่มีค่าที่สุดในแล็บนี้One table. A calendar event is not a complicated thing.
db/init.sql
SERIAL PRIMARY KEY means Postgres invents the id for you and guarantees it is
unique. The index on event_date matters because every screen of this app asks
the same question — what is happening in this month? — and an index is what stops
that question from reading the whole table.
SERIAL PRIMARY KEY 表示 id 由 Postgres 自动生成并保证唯一。
给 event_date 建索引很重要,因为这个应用的每一屏都在问同一个问题——
这个月有什么事?——而索引正是让这个问题不必扫描整张表的东西。SERIAL PRIMARY KEY หมายความว่า Postgres สร้าง id ให้เองและรับประกันว่าไม่ซ้ำ
ส่วนดัชนีบน event_date สำคัญ เพราะทุกหน้าจอของแอปนี้ถามคำถามเดียวกันคือ
เดือนนี้มีอะไรบ้าง และดัชนีคือสิ่งที่ทำให้ไม่ต้องอ่านทั้งตาราง/docker-entrypoint-initdb.d/ run only once, the first time the data
volume is created. Edit init.sql afterwards and absolutely nothing happens —
no error, no warning, no new column. To make it run again you must destroy the volume with
docker compose down -v, which also destroys your data.
会浪费你一下午的坑。/docker-entrypoint-initdb.d/
里的文件只在数据卷第一次创建时运行一次。之后你再改 init.sql,
什么都不会发生——没有报错、没有警告、也不会多出新字段。想让它重新运行,
必须用 docker compose down -v 销毁数据卷,而那会连数据一起销毁。
จุดที่ทำให้เสียเวลาทั้งบ่าย ไฟล์ใน
/docker-entrypoint-initdb.d/ จะรันครั้งเดียวเท่านั้น
คือตอนที่สร้าง volume ครั้งแรก ถ้าคุณแก้ init.sql ทีหลัง จะไม่มีอะไรเกิดขึ้นเลย
ไม่มี error ไม่มีคำเตือน ไม่มีคอลัมน์ใหม่ ถ้าอยากให้รันอีกครั้งต้องลบ volume ด้วย
docker compose down -v ซึ่งจะลบข้อมูลของคุณไปด้วยBecause of that, the backend also creates the table itself on every boot, using the
same IF NOT EXISTS statements. Belt and braces: the image then works against a
database that has never seen init.sql — which is exactly what happens when
somebody pulls it from Docker Hub.
IF NOT EXISTS
语句自己建表。双保险:这样镜像就能在一个从没见过 init.sql 的数据库上正常工作——
而这正是别人从 Docker Hub 拉取它时会发生的情况。IF NOT EXISTS ชุดเดียวกัน เป็นการกันไว้สองชั้น
อิมเมจจึงทำงานได้กับฐานข้อมูลที่ไม่เคยเห็น init.sql มาก่อน
ซึ่งก็คือสิ่งที่เกิดขึ้นเวลามีคนดึงมันมาจาก Docker HubJavaScript on the server. It is the only thing allowed to touch the database.
[db] not ready (attempt 1/30): EAI_AGAIN and then
[db] connected on attempt 2. EAI_AGAIN is a DNS failure — at that
moment the name db did not resolve to anything, because the container did not
exist yet.
这是验证过的,不是想当然。先在完全没有数据库的情况下启动应用,
十四秒后才启动 Postgres。日志显示 [db] not ready (attempt 1/30): EAI_AGAIN,
接着是 [db] connected on attempt 2。EAI_AGAIN 是 DNS 解析失败——
在那一刻 db 这个名字什么都解析不到,因为那个容器还不存在。
ตรวจสอบจริง ไม่ได้เดา เริ่มรันแอปโดยยังไม่มีฐานข้อมูลเลย
แล้วค่อยเริ่ม Postgres หลังจากนั้นสิบสี่วินาที ล็อกขึ้นว่า
[db] not ready (attempt 1/30): EAI_AGAIN แล้วตามด้วย
[db] connected on attempt 2 โดย EAI_AGAIN คือ DNS ล้มเหลว
เพราะตอนนั้นชื่อ db ยังไม่ชี้ไปที่อะไรเลย เนื่องจากคอนเทนเนอร์ยังไม่มีอยู่$1 mattersThe values are handed to Postgres separately from the SQL text. They are never glued into the query string, so they can never become commands.
x'); DROP TABLE events;--. It was saved as an ordinary event with that literal
title, appeared in the calendar as text, and the table was still there afterwards. Build the
same query with string concatenation and you would have lost the table.
真的测过。提交了一个标题为 x'); DROP TABLE events;--
的事件。它被当作一条普通事件保存下来,标题就是那串字面文本,在日历里显示为文字,
之后表还好好地在那里。如果你用字符串拼接来构造同样的查询,这张表就没了。
ทดสอบจริง ลองส่งกิจกรรมที่ชื่อว่า
x'); DROP TABLE events;-- เข้าไป มันถูกบันทึกเป็นกิจกรรมธรรมดาที่มีชื่อตามนั้นทุกตัวอักษร
แสดงในปฏิทินเป็นข้อความ และตารางก็ยังอยู่ครบ
ถ้าเขียนคำสั่งเดียวกันด้วยการต่อสตริง ตารางนั้นคงหายไปแล้ว| Method & path | Does | Returns |
|---|---|---|
GET /api/health | Checks the database is reachable | {ok:true, events:N} |
GET /api/events?month=YYYY-MM | Lists one month | array of events |
POST /api/events | Creates an event | 201 + the event |
PUT /api/events/:id | Updates an event | the event, or 404 |
DELETE /api/events/:id | Deletes an event | 204, or 404 |
Plain browser JavaScript. No framework, no build step, no node_modules.
The file you edit is the file the browser runs. For a lab that is a feature, not a limitation: there is no compiler between you and the mistake.
toISOString() shifts a Bangkok evening into tomorrow, and once in the backend,
where the Postgres driver hands back a DATE as a JavaScript Date
in the server's timezone. Both ends of this app format dates from explicit parts instead.
时区会坑你两次。一次在浏览器里,toISOString()
会把曼谷的傍晚变成第二天;一次在后端,Postgres 驱动会把 DATE
按服务器时区还原成 JavaScript 的 Date。所以这个应用的两端都改成
用明确的年月日分量来拼日期。
เขตเวลาจะเล่นงานคุณสองรอบ รอบแรกในเบราว์เซอร์ ที่
toISOString() ทำให้ตอนเย็นในกรุงเทพกลายเป็นวันถัดไป และอีกรอบที่แบ็กเอนด์
ซึ่งไดรเวอร์ Postgres คืนค่า DATE มาเป็น Date
ตามเขตเวลาของเซิร์ฟเวอร์ ทั้งสองฝั่งของแอปนี้จึงประกอบวันที่จากส่วนประกอบที่ระบุชัดเจนแทนEvery line here is doing a job. Read the comments, not just the commands.
package.json on its own?Docker builds in layers and reuses any layer whose inputs did not change. If you copy the
whole project and then run npm install, changing one character in
server.js invalidates the copy layer and every layer after it — so npm
reinstalls everything, every time. Copying the manifests first puts the slow step behind a
file that rarely changes.
npm install,那么改动 server.js
里的一个字符就会让复制层和它之后的所有层全部失效——于是 npm 每次都要重装一遍。
先复制清单文件,就是把这个慢步骤挡在一个很少变动的文件后面。npm install การแก้แค่ตัวอักษรเดียวใน server.js
จะทำให้ชั้นคัดลอกและทุกชั้นถัดจากนั้นใช้ไม่ได้ npm จึงต้องติดตั้งใหม่ทุกครั้ง
การคัดลอกไฟล์ manifest ก่อน คือการเอาขั้นตอนที่ช้าไปไว้หลังไฟล์ที่แทบไม่เปลี่ยนOne file that says how the two containers fit together.
depends_on does not mean what you think. On its own it
waits for the database container to start — not for PostgreSQL inside it to accept
connections. Postgres takes a few seconds more, so the app connects to nothing, throws
ECONNREFUSED, and exits. Adding condition: service_healthy makes
compose wait for the healthcheck instead, which is what you actually meant.
depends_on 的意思和你想的不一样。它本身只等数据库容器
启动,而不等里面的 PostgreSQL 能接受连接。Postgres 还要再花几秒,
于是应用连了个寂寞,抛出 ECONNREFUSED 然后退出。
加上 condition: service_healthy,compose 才会去等健康检查——
那才是你真正想要的。
depends_on ไม่ได้แปลว่าอย่างที่คุณคิด ลำพังมันรอแค่ให้คอนเทนเนอร์ฐานข้อมูล
เริ่มทำงาน ไม่ได้รอให้ PostgreSQL ข้างในพร้อมรับการเชื่อมต่อ
Postgres ใช้เวลาอีกไม่กี่วินาที แอปจึงเชื่อมต่อไม่ติด โยน ECONNREFUSED แล้วปิดตัวลง
การใส่ condition: service_healthy ทำให้ compose รอ healthcheck แทน
ซึ่งคือสิ่งที่คุณตั้งใจจริง ๆdocker run, where no compose file exists
to help them.
刻意做了两道防线。健康检查解决 compose 里的问题;
后端的重试循环解决其他所有场景——包括别人从 Docker Hub 拉下镜像、
直接用 docker run 跑的时候,那里可没有 compose 文件帮他们。
ป้องกันสองชั้นโดยตั้งใจ healthcheck แก้ปัญหาภายใน compose
ส่วนลูป retry ในแบ็กเอนด์แก้ในทุกกรณีที่เหลือ รวมถึงตอนที่มีคนดึงอิมเมจจาก Docker Hub
แล้วรันด้วย docker run เปล่า ๆ ซึ่งไม่มีไฟล์ compose มาช่วยFour commands. The first one takes a minute; the rest are instant.
Do not trust the screen. Ask the database directly.
DELETE returns 204.
这段输出是真的。第 4 行是在浏览器里点某一天的 +、
在弹窗里输入而产生的——浏览器点击 → fetch → Express → PostgreSQL,
再用 psql 从数据库容器内部读回来。第 3 行是那次注入尝试,之后被删掉,
用来验证 DELETE 返回 204。
ผลลัพธ์นี้ของจริง แถวที่ 4 เกิดจากการคลิก +
บนวันหนึ่งในเบราว์เซอร์แล้วพิมพ์ลงในกล่องโต้ตอบ คือคลิกในเบราว์เซอร์ ไปที่ fetch ไปที่ Express
ไปถึง PostgreSQL แล้วอ่านกลับด้วย psql จากในคอนเทนเนอร์ฐานข้อมูล ส่วนแถวที่ 3
คือความพยายามทำ injection ซึ่งถูกลบทีหลังเพื่อพิสูจน์ว่า DELETE
คืนค่า 204-v is the destructive one. It removes the named
volume, and every event goes with it. There is no confirmation prompt.
-v 是那个有破坏性的选项。它会删除命名卷,
所有事件都会跟着消失。而且不会有任何二次确认。
-v คือตัวที่ทำลายข้อมูล มันลบ named volume
และกิจกรรมทั้งหมดจะหายไปด้วย โดยไม่มีข้อความยืนยันใด ๆOne image, two processor architectures, one command.
This image is published and public. You can run it right now without building anything:
Check what you actually published — a multi-arch image is a list of images:
tail or head hides its exit code — the last lines can look
perfectly happy while the build actually failed. Capture the status explicitly. Second, if
buildx inspect does not list linux/arm64, the emulator is not
registered for that builder; a builder created before you installed binfmt will not
pick it up, so create a fresh one.
发布时的两个坑。第一,把 buildx 构建的输出管道给
tail 或 head 会掩盖它的退出码——最后几行看着一切正常,
实际上构建已经失败了。要显式地捕获状态码。第二,如果 buildx inspect
没列出 linux/arm64,说明该 builder 没注册模拟器;
在你安装 binfmt 之前创建的 builder 不会自动获得它,所以要新建一个。
กับดักสองข้อตอนเผยแพร่ ข้อแรก การไพป์ผลลัพธ์ของ buildx ไปยัง
tail หรือ head จะบดบัง exit code
บรรทัดท้าย ๆ อาจดูเรียบร้อยทั้งที่บิลด์ล้มเหลวไปแล้ว ให้เก็บค่าสถานะอย่างชัดเจน
ข้อสอง ถ้า buildx inspect ไม่แสดง linux/arm64
แปลว่า builder นั้นยังไม่มีตัวจำลอง builder ที่สร้างก่อนติดตั้ง binfmt
จะไม่ได้รับมันมา ต้องสร้างใหม่linux/amd64 even after binfmt installed
qemu-aarch64 successfully. A brand new builder immediately reported
linux/arm64, and the push produced both architectures.
做这一页的时候这两个坑都踩到了。即使 binfmt 已经成功安装了
qemu-aarch64,机器上原有的 builder 仍然只报告 linux/amd64。
新建一个 builder 后立刻就报告了 linux/arm64,推送也顺利产出了两种架构。
เจอกับดักทั้งสองข้อตอนทำหน้านี้ แม้ binfmt จะติดตั้ง
qemu-aarch64 สำเร็จแล้ว แต่ builder เดิมบนเครื่องก็ยังรายงานแค่
linux/amd64 พอสร้าง builder ใหม่ มันรายงาน linux/arm64 ทันที
และการ push ก็ได้ครบทั้งสองสถาปัตยกรรมSymptom, cause, fix. Every row here happened to somebody.
| Symptom | Cause | Fix |
|---|---|---|
ECONNREFUSED then the web container exits |
App started before Postgres was accepting connections | condition: service_healthy plus the retry loop |
EAI_AGAIN in the log |
DNS could not resolve db — that container does not exist yet |
Same fix; it resolves once the container is up |
Edited init.sql, nothing changed |
Init scripts run only on a first-ever empty volume | docker compose down -v, or migrate from the app |
| Events show a day early or late | toISOString() converted a local day to UTC |
Build YYYY-MM-DD from local parts |
| All events vanished | Someone ran docker compose down -v |
Nothing. The volume is gone. Back up first |
port is already allocated |
Something else on the host owns 8080 | Change the left half: "8081:3000" |
| Code change not visible after rebuild | Browser cached the old app.js |
Hard reload, or add ?v=2 to the URL |
docker stop takes ten seconds |
PID 1 ignored SIGTERM | tini as ENTRYPOINT and a SIGTERM handler |
| Push succeeded but the image is amd64 only | Builder had no arm64 emulation registered | Install binfmt, then create a new builder |
| Build "succeeded" but nothing was pushed | Piping into tail masked a non-zero exit code |
Capture the exit status explicitly |
depends_on waits for a container to start, not for the service to be ready.PGHOST=db is all the addressing you need.$1, never by gluing them into SQL.package.json before the source, or you reinstall npm on every edit.toISOString().docker manifest inspect — believe the manifest, not the build log.depends_on 只等启动不等就绪;初始化脚本只在空卷上跑一次;
服务名就是主机名;不开放端口的数据库外网碰不到;用 $1 传值而不是拼接 SQL;
先复制 package.json;日历日期绝不用 toISOString();
用 docker manifest inspect 核实你到底发布了什么。depends_on รอแค่ให้เริ่ม ไม่ได้รอให้พร้อม สคริปต์ init รันครั้งเดียวบน volume ว่าง
ชื่อเซอร์วิสคือชื่อโฮสต์ ฐานข้อมูลที่ไม่เปิดพอร์ตเข้าถึงจากอินเทอร์เน็ตไม่ได้
ส่งค่าด้วย $1 อย่าต่อสตริงเข้าไปใน SQL คัดลอก package.json ก่อน
อย่าใช้ toISOString() กับวันในปฏิทิน และตรวจสิ่งที่เผยแพร่ด้วย
docker manifest inspect