ShellCheck: เครื่องมือวิเคราะห์สำหรับค้นหาบั๊กในเชลล์สคริปต์
(shellcheck.net)- ShellCheck เป็นเครื่องมือที่ช่วยตรวจสอบข้อผิดพลาดที่พบบ่อยในเชลล์สคริปต์ได้ทันทีบนเว็บ โดยสามารถดูปัญหาของโค้ดที่วางลงไปได้จากผลลัพธ์ใต้ตัวแก้ไข
- ในเครื่องโลคัลสามารถติดตั้งได้ด้วย
cabal,apt,dnf,pkg,brew installเป็นต้น จึงใช้งานผ่าน แพ็กเกจเมเนเจอร์ ได้ - ตัวอย่างแสดงให้เห็นจุดที่มักพลาดในสคริปต์จริง เช่น คำเตือนด้าน portability ของ shebang
sh, ปัญหาเชิงความหมาย และข้อผิดพลาดเกี่ยวกับการจัดการเครื่องหมายคำพูด - เป็นซอฟต์แวร์เสรีภายใต้สัญญาอนุญาต GPLv3 และมีให้ใช้งานทั้งบน GitHub, Wiki และเป็นลินเตอร์แบบผสานรวมในเอดิเตอร์หลัก ๆ
- ใช้ร่วมกับ CodeClimate, Codacy และ CodeFactor เพื่อตรวจสอบ GitHub repository อัตโนมัติได้ และตัว ShellCheck เองเขียนด้วย Haskell
ความสามารถในการวิเคราะห์เชลล์สคริปต์
- ShellCheck เป็น เครื่องมือวิเคราะห์สำหรับค้นหาบั๊ก ในเชลล์สคริปต์
- หากวางสคริปต์ลงในเว็บไซต์ จะสามารถดู ผลการตรวจสอบ ได้ทันทีจากหน้าต่างผลลัพธ์ใต้ตัวแก้ไข
- ตัวอย่างที่มีมาให้แสดงว่าเครื่องมือนี้สามารถตรวจจับปัญหาได้หลายประเภท
- ปัญหาหลายอย่างที่ซ่อนอยู่ในสคริปต์ทั่วไป
- คำเตือนด้าน portability ที่เกิดขึ้นเมื่อ shebang เป็น
sh - ปัญหาเชิงความหมาย ในระดับที่สูงขึ้น
- ปัญหาการจัดการเครื่องหมายคำพูด หลายรูปแบบ
การติดตั้ง, สัญญาอนุญาต และการผสานรวม
- ติดตั้งในเครื่องได้ด้วย
cabal,apt,dnf,pkg,brew install - เป็นซอฟต์แวร์เสรีภายใต้สัญญาอนุญาต GPLv3
- ดูเอกสารได้ที่ ShellCheck Wiki
- โค้ดเปิดเผยบน GitHub และมี โค้ดของเว็บไซต์ เปิดเผยด้วย
- มีแพ็กเกจสำหรับดิสทริบิวชันหรือแพ็กเกจเมเนเจอร์พร้อมให้ใช้งานแล้ว
- ใช้เป็น ลินเตอร์แบบผสานรวม ในเอดิเตอร์หลัก ๆ ได้
- ใช้กับ CodeClimate, Codacy, CodeFactor เพื่อตรวจสอบ GitHub repository อัตโนมัติได้
- ShellCheck เขียนด้วย Haskell
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
เคล็ดลับที่ผมใช้เป็นแบบนี้: ใน shebang แทบทุกครั้งควรใส่
-u(nounset) เพื่อให้ ตัวแปรที่ไม่ได้ประกาศ กลายเป็นข้อผิดพลาด และข้อยกเว้นที่เจอบ่อยคือกรณีที่การขยายอาร์เรย์ว่างด้วยไวยากรณ์"${arr[@]}"ถูกมองว่าเป็น unbound-n(noexec) จะป้องกันไม่ให้รันคำสั่ง จึงใช้เหมือน dry-run แบบคนงบน้อยได้ ส่วน-e(errexit) ก็มีประโยชน์ แต่ต้องระวังว่าจริง ๆ แล้วมันทำให้จบการทำงานเฉพาะคำสั่งที่ล้มเหลว “ด้วยตัวมันเอง” เท่านั้น ดังนั้นโดยส่วนตัวผมจึงหลีกเลี่ยงและมักใส่|| fail "..."ต่อท้ายคำสั่งมากกว่า"${arr[@]}"มีเฉพาะใน bash 3 หรือต่ำกว่า เท่านั้น ตั้งแต่ bash 4 เป็นต้นมา แม้ตัวแปรจะไม่ได้ถูกนิยามจริง ๆ[@]ก็จะไม่โยน unbound variableถึงอย่างนั้น macOS ก็ยังติดตั้ง bash v3 เป็นค่าเริ่มต้นและไม่อัปเดตอัตโนมัติ จึงยังเป็นปัญหาอยู่ การที่รีลีสสุดท้ายของ bash 3 ออกมาเมื่อ 20 ปีก่อนเป็นเรื่องบ้ามาก ๆ ส่วน unbound ที่เกิดจากการขยายอาร์เรย์ว่างสามารถเลี่ยงได้ด้วยการขยาย
${var+alter}:echo "${arr+${arr[@]}}"bash my_script.shแทนที่จะเป็น./my_script.shออปชันจะไม่ถูกใช้มีคนจำนวนไม่น้อยที่ทำแบบนี้เพราะไม่อยากตั้งค่า execute bit และบางครั้งก็เกิดจากความไม่เข้าใจด้วย ดังนั้นจึงมักแนะนำให้ใช้
setเช่นset -euo pipefailในบรรทัดแรกถัดจาก shebang และยังช่วยในกรณีที่ shebang จัดการอาร์กิวเมนต์เพิ่มเติมได้ยาก เช่น#!/usr/bin/env bash-uไว้ใน shebang ทำไมต้อง shebang แทนที่จะเป็นset -u?ใน Bash ดูเหมือนว่า
"${arr[@]}"จะทำงานได้ดี ตามที่คอมเมนต์อื่นก็พูดถึง Bash รุ่นใหม่ดีขึ้นกว่านั้นอีก และดูเหมือนจะมีปัญหาเฉพาะใน<= 4.3: https://news.ycombinator.com/item?id=38397241ตัวอย่างเช่น
bash -uc 'unset x; echo "=> ${x[@]}"'และbash -uc 'x=(); echo "=> ${x[@]}"'ผ่านด้วยค่าว่าง แต่bash -uc 'x=(); echo "=> ${x[0]}"'จะเหลือข้อความbash: x[0]: unbound variableส่วน Zsh ไม่ชอบตัวอย่างแรก แต่ทั้งคู่ควรรองรับการขยายค่าเริ่มต้น เช่นbash -uc 'unset x; echo "=> ${x[@]:-null}"'สำหรับ-eพอเกี่ยวพันกับฟังก์ชันแล้วชวนสับสนมาก และยิ่งเวลาผ่านไปผมก็ยิ่งชอบมันน้อยลง-eจัดการได้ดีด้วย-o pipefailและสิ่งนี้ถูกรวมอยู่ใน POSIX ตั้งแต่ปีที่แล้วtrapควบคู่ไปด้วยtrapเป็นฟีเจอร์ scripting ที่ยอดเยี่ยม แต่จากความรู้สึกแล้วไม่ค่อยถูกพูดถึงมากพอเมื่อไม่นานมานี้ผมพบ ช่องโหว่ยกระดับสิทธิ์ ใน shell script เนื่องจาก arithmetic expansion เป็นประเภทคล้ายกับที่อยู่ใน https://research.nccgroup.com/2020/05/12/shell-arithmetic-ex...
เช่น
$((1 + ENV_VAR))หากควบคุม$ENV_VARได้ ก็สามารถทำ code injection ได้ น่าเสียดายที่ ShellCheck จับสิ่งนี้ไม่ได้ อย่างน้อยก็ในค่าเริ่มต้น แต่ถ้าจะทำอะไรที่สำคัญด้านความปลอดภัยแม้เพียงเล็กน้อย ก็ไม่ควรใช้ shell ตั้งแต่แรกShellCheck คือผู้กอบกู้จริง ๆ ก่อนหน้านี้ผมทำ wrapper ตัวเล็ก ๆ ไว้ที่ https://github.com/jamespwilliams/strictbash ซึ่งใช้เป็น shebang ของสคริปต์ได้
มันจะรัน ShellCheck ก่อนเริ่มสคริปต์ และถ้ามีข้อผิดพลาดก็ไม่ให้รันเลย รวมถึงตั้งค่าแฟล็ก “strict mode” ของ bash ทั้งหมดด้วย อ้างอิง: http://redsymbol.net/articles/unofficial-bash-strict-mode/
หัวข้อนี้เคยถูกพูดถึงหลายครั้งแล้ว: https://news.ycombinator.com/from?site=shellcheck.net
การถกเถียงใหญ่ครั้งล่าสุดอยู่ในปี 2021 ได้ 301 คะแนนและมี 54 ความคิดเห็น: https://news.ycombinator.com/item?id=27030504
เมื่อไม่นานมานี้ ผมได้เปลี่ยนสคริปต์ bash หลายตัวสำหรับบิลด์และดีพลอย รวมถึงสำหรับเซิร์ฟเวอร์โปรดักชันเดี่ยว ไปใช้ Turtle ของ Haskell
ดีตรงที่ลดความซ้ำซ้อนได้มาก และโค้ดที่ได้ก็สั้นลงมากด้วย https://hackage.haskell.org/package/turtle
เท่าที่ผมรู้ โปรแกรม Turtle มี current directory ได้เพียงที่เดียว จึงลำบากเวลารันงานพร้อมกันที่ต้องทำงานในไดเรกทอรีเฉพาะ แก้ได้บางส่วนด้วย lock, queue และ worker แต่พอ current directory ของ Turtle ถูกลบจนเริ่มล้มเหลว ก็รับมือยากเกินไป
ตรงกันข้าม typed-process จะสร้างโปรเซสแยก และรันใน working directory ได้โดยไม่ต้อง
cdจึงเหมาะกับ workflow ขนาดใหญ่และซับซ้อน การรองรับOverloadedStringsก็ดี ทำให้โดยปกติคัดลอกสิ่งที่จะพิมพ์ใน bash แล้ววางลงไป ก็ทำงานได้เลยผมยังใช้แพ็กเกจ
interpolateกับQuasiQuotesเพื่อให้ raw string ดูอ่านง่ายขึ้นในซอร์สโค้ด แต่เพราะเข้ากันไม่ได้กับhlintเลยคิดว่าจะลองหาแพ็กเกจอื่นสำหรับจัดการสตริงเป็นการโปรโมตตัวเองแบบตรง ๆ แต่ผมใส่ ShellCheck และ linter หลายตัวไว้ใน pre-commit config โดยยึดหลักว่า แก้ warning ทั้งหมดก่อน commit หรืออย่างน้อยก่อน merge
แต่ shell ส่วนใหญ่ในโปรเจกต์ของผมอยู่ในไฟล์
.gitlab-ci.ymlเลยตรวจสอบยาก ผมจึงทำ wrapper สำหรับจัดการให้อัตโนมัติ: https://pypi.org/project/glscpc/โดยใช้โปรเจกต์ ShellCheck กับเวทมนตร์เล็กน้อย มันจะแสดงคำเตือนจาก ShellCheck พร้อมเลขบรรทัดที่แทบจะตรง
ผมไม่ได้ใช้ GitLab CI แต่ใช้ไฟล์หลายรูปแบบที่โดยแก่นแล้วฝัง shell script แบบ inline อยู่พอสมควร เช่น Dockerfile, GitHub Actions, Justfile
โดยปกติ แม้เพื่อ ShellCheck อย่างเดียว ผมก็มักแยกอะไรที่ซับซ้อนกว่าคำสั่งไม่กี่คำสั่งออกเป็น shell script ต่างหาก แล้วให้ inline script ใน Dockerfile เรียกใช้ แพตเทิร์นนี้ยังช่วยไม่ให้ CI ผูกติดกับ GitHub Actions มากเกินไปด้วย
ตัวอย่างการใช้งานคือ pre-commit hook สำหรับ
.gitlab-ci.ymlและตัวอย่างการตั้งค่าอยู่ที่นี่: https://gitlab.com/engmark/root/-/blob/9f7d9b93c2297d0b170e5...scriptของ job นั้นตอน buildข้อดีคือทุกอย่างยัง self-contained อยู่ใน job ของ gitlab-ci แต่การจัดการสารพัดความประหลาดของ shell ในสภาพแวดล้อม GitLab CI runner นั้นเจ็บปวดเกินไป เลยหยุดไป และตอนนี้กำลังย้ายทุกงานไปเป็น Python script
ยังมี bash language server ด้วย: https://github.com/bash-lsp/bash-language-server/
ดีเลย สคริปต์โปรดักชัน
/bin/shตัวแรกที่ลองรันก็ทำให้ได้เรียนรู้อะไรหลายอย่างทันที ทั้งที่ผมแตะสคริปต์พวกนี้มาตั้งแต่ยุค 80 แล้วถ้ามันยาวเกินไปสำหรับการเขียนด้วย Bash จนจริง ๆ แล้วไม่ควรทำแบบนั้น ก็ขอแนะนำ https://github.com/bach-sh/bach ด้วย
ShellCheck ยอดเยี่ยม แต่การจัดการ source/import นั้นเจ็บปวดจริง ๆ ไม่ใช่ความผิดของ ShellCheck หรอก แต่เป็นเพราะ
shนี่แหละที่เป็นฝันร้าย# shellcheck source=./deployment/deployment-example.envแล้วตามด้วย. "${1}"แต่ถ้ามี shell script ย่อยหลายตัวและมีไฟล์ที่จะ source จำนวนมาก ก็จะเข้าใจว่าทำไมมันถึงเจ็บปวด