ถ้าต้องการคลัสเตอร์ Kubernetes เล็ก ๆ สำหรับ POC หรือเครื่องมือภายในทีม การตั้ง k3s บน VM เครื่องเดียวแล้วให้ Argo CD ดึงงานจาก Git เป็นทางที่เร็วและดูแลง่าย แต่สิ่งที่มักถูกมองข้ามคือ ช่องทางจัดการ ทั้ง kube API, kubelet และหน้า Argo CD ไม่ควรเปิดให้ทั้ง LAN หรืออินเทอร์เน็ตเห็น บทความนี้สรุปขั้นตอนที่ทีมเราใช้จริงในการตั้งระบบลักษณะนี้ โดยให้ทุกอย่างที่เป็นงานแอดมินเข้าได้เฉพาะทาง Tailscale (tailnet) เท่านั้น

ตัวอย่างในบทความใช้ค่าสมมติทั้งหมด: Tailscale IP ของ VM คือ 100.64.0.10, IP ฝั่ง LAN คือ 192.0.2.10, interface ฝั่ง LAN ชื่อ ens18 และดิสก์ข้อมูลคือ /dev/sdb ให้เปลี่ยนเป็นค่าของระบบคุณเอง

แผนภาพ VM เดียวที่รัน k3s, Argo CD และ SealedSecrets ผู้ดูแลเข้าผ่าน Tailscale IP ส่วน LAN และอินเทอร์เน็ตถูกปิดพอร์ตจัดการ Argo CD ดึง manifest จาก Git และคีย์ถูกสำรองออกนอกเครื่อง
ภาพรวม: ผู้ดูแลอยู่ใน tailnet, Argo CD ดึงงานจาก Git, ข้อมูลคลัสเตอร์อยู่บนดิสก์แยก และคีย์ SealedSecrets ถูกสำรองออกนอก VM (คลิกที่ภาพเพื่อดูขนาดเต็ม)

สิ่งที่ต้องเตรียม

  • VM Linux หนึ่งเครื่อง (Debian หรือ Ubuntu รุ่นที่ยังได้รับแพตช์) อย่างน้อย 4 vCPU และ RAM 8 GB สำหรับ k3s, Argo CD และแอปขนาดเล็ก
  • ดิสก์ข้อมูลแยกอีกหนึ่งลูก เช่น 50–100 GB
  • เครื่องนี้ join tailnet แล้ว และเรามีสิทธิ์ root
  • Git repository ที่เก็บ manifest ของแอป (Helm, Kustomize หรือ YAML ธรรมดา)

ก่อนลงมือให้กำหนดเวอร์ชันของทุกตัวไว้ล่วงหน้า แล้วจดไว้ใน runbook การ pin เวอร์ชันทำให้ติดตั้งซ้ำได้ผลเหมือนเดิม และรู้ชัดว่าต้องตามข่าวช่องโหว่ของรุ่นไหน

export K3S_VERSION="v1.xx.y+k3s1"    # รุ่นใน stable channel ที่ทดสอบแล้ว
export ARGOCD_VERSION="v3.x.y"
export SEALED_SECRETS_VERSION="0.xx.y"

ขั้นที่ 1: แยกดิสก์ข้อมูลไว้ที่ /var/lib/rancher

k3s เก็บทุกอย่างไว้ใต้ /var/lib/rancher ทั้ง image ของ containerd, datastore ของคลัสเตอร์ และ volume ของ local-path ถ้าปล่อยไว้บนดิสก์ระบบ วันที่ image หรือ log โตจนดิสก์เต็ม ทั้ง OS และคลัสเตอร์จะล้มไปพร้อมกัน การแยกดิสก์ตั้งแต่แรกจึงคุ้มมาก และยังขยายหรือย้ายดิสก์ได้ง่ายกว่า (ดูเพิ่มที่ ขยาย partition บน Linux โดยไม่ต้องรีบูต)

สิ่งสำคัญที่สุดคือตรวจให้แน่ใจก่อนว่าดิสก์ว่างจริง อย่า format จากชื่อ device อย่างเดียว

lsblk -f /dev/sdb          # ต้องไม่มี partition และไม่มี filesystem
wipefs -n /dev/sdb         # -n = ดูอย่างเดียว ต้องไม่พบ signature
blkid /dev/sdb || echo "ไม่พบ signature"

เมื่อมั่นใจแล้วจึงสร้าง partition, format และ mount ด้วย UUID

parted -s /dev/sdb mklabel gpt mkpart rancher ext4 0% 100%
mkfs.ext4 -L rancher /dev/sdb1
mkdir -p /var/lib/rancher
cp /etc/fstab /etc/fstab.bak-$(date +%Y%m%d)
echo "UUID=$(blkid -s UUID -o value /dev/sdb1) /var/lib/rancher ext4 defaults,nofail 0 2" >> /etc/fstab
mount /var/lib/rancher && findmnt /var/lib/rancher

ตัวเลือก nofail ทำให้เครื่องยังบูตขึ้นมาให้เราเข้าไปแก้ได้ ถ้าวันหนึ่งดิสก์ลูกนี้หายไป

ขั้นที่ 2: ติดตั้ง k3s แบบ pin เวอร์ชัน

เขียน config ไว้ก่อนติดตั้ง k3s จะอ่านไฟล์นี้ตั้งแต่ครั้งแรก จุดที่ต้องใส่มีสามจุด คือใส่ Tailscale IP ไว้ใน tls-san เพื่อให้ certificate ของ API ใช้กับ IP นั้นได้, เปิดการเข้ารหัส Secret ใน datastore และจำกัดสิทธิ์ไฟล์ kubeconfig

mkdir -p /etc/rancher/k3s
cat > /etc/rancher/k3s/config.yaml <<'EOF'
write-kubeconfig-mode: "0600"
tls-san:
  - 100.64.0.10
secrets-encryption: true
# ถ้ายังไม่ใช้ Ingress หรือ LoadBalancer ปิดไว้ก่อนได้
# disable: [traefik, servicelb]
EOF

curl -sfL https://get.k3s.io -o /root/k3s-install.sh   # อ่านสคริปต์ก่อนรัน
INSTALL_K3S_VERSION="$K3S_VERSION" sh /root/k3s-install.sh
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl get nodes

kubeconfig ที่ได้มีสิทธิ์ระดับ cluster-admin ให้เก็บไว้บน VM เท่านั้น ห้าม commit ลง Git และห้ามส่งต่อในแชต

ขั้นที่ 3: ติดตั้ง Argo CD และปล่อยให้เป็น ClusterIP

kubectl create namespace argocd
kubectl apply -n argocd --server-side --force-conflicts \
  -f "https://raw.githubusercontent.com/argoproj/argo-cd/${ARGOCD_VERSION}/manifests/install.yaml"
kubectl -n argocd rollout status deploy/argocd-server

หลังติดตั้ง Service argocd-server จะเป็น ClusterIP ซึ่งเป็นค่าที่เราต้องการ อย่าเปลี่ยนเป็น NodePort หรือ LoadBalancer เพราะ k3s จะเปิดพอร์ตนั้นบนทุก interface ทันที

ขั้นที่ 4: เปิดหน้า Argo CD เฉพาะใน tailnet ด้วย systemd

วิธีที่หลายคนใช้คือ SSH tunnel ทุกครั้งที่จะเข้า ซึ่งใช้ได้ แต่ไม่สะดวกกับทั้งทีม เราเลือกให้ kubectl port-forward ฟังเฉพาะบน Tailscale IP แล้วให้ systemd ดูแลแทน ข้อดีคือ

  • พอร์ตเปิดเฉพาะ 100.64.0.10:8443 ไม่ฟังบน LAN, localhost หรือ 0.0.0.0
  • TLS วิ่งตรงถึง argocd-server โดยไม่มีตัวกลางมาถอดรหัส
  • ไม่ต้องแก้ config ของ Tailscale และไม่ต้องเขียนกฎ firewall ยกเว้นให้ NodePort

สร้างสคริปต์รอให้ Tailscale IP และ Argo CD พร้อมก่อน ถ้าไม่รอ หลังรีบูต port-forward จะ bind ไม่ได้และล้มซ้ำไปเรื่อย ๆ

cat > /usr/local/sbin/argocd-ui-tailnet-wait.sh <<'EOF'
#!/bin/sh
TS_IP=100.64.0.10
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
for i in $(seq 1 90); do
  ip -4 addr show dev tailscale0 2>/dev/null | grep -q "inet ${TS_IP}/" && break
  sleep 1
done
kubectl -n argocd wait --for=condition=available deploy/argocd-server --timeout=90s
EOF
chmod 755 /usr/local/sbin/argocd-ui-tailnet-wait.sh

จากนั้นสร้าง unit

# /etc/systemd/system/argocd-ui-tailnet.service
[Unit]
Description=Argo CD UI on tailnet only (100.64.0.10:8443)
After=network-online.target tailscaled.service k3s.service
Wants=network-online.target tailscaled.service k3s.service

[Service]
Type=simple
Environment=KUBECONFIG=/etc/rancher/k3s/k3s.yaml
ExecStartPre=/usr/local/sbin/argocd-ui-tailnet-wait.sh
ExecStart=/usr/local/bin/kubectl -n argocd port-forward \
  --address 100.64.0.10 svc/argocd-server 8443:443
Restart=always
RestartSec=5
TimeoutStartSec=200

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now argocd-ui-tailnet.service
ss -ltnp | grep 8443        # ต้องเห็นแค่ 100.64.0.10:8443

Restart=always จำเป็นมาก เพราะ port-forward จะหลุดทุกครั้งที่ pod ของ argocd-server ถูกสร้างใหม่ เช่นตอนอัปเกรด จากเครื่องที่อยู่ใน tailnet ให้ลองเปิด https://100.64.0.10:8443 หรือใช้ CLI

argocd login 100.64.0.10:8443 --grpc-web --insecure

--insecure ใช้เพราะ Argo CD ใช้ certificate แบบ self-signed เป็นค่าเริ่มต้น ถ้าจะใช้ยาว ควรเปลี่ยนเป็น certificate ที่เชื่อถือได้ และใช้ ACL ของ Tailscale จำกัดว่าใครใน tailnet เข้าพอร์ตนี้ได้บ้าง ถ้ายังไม่คุ้นกับ VPN แบบ mesh อ่านพื้นฐานได้ที่ ติดตั้ง WireGuard บน Debian ซึ่ง Tailscale ก็ใช้ WireGuard อยู่เบื้องหลัง

วิธีนี้เหมาะกับหน้าแอดมินที่มีคนใช้ไม่กี่คน ไม่เหมาะกับทราฟฟิกของผู้ใช้จริง ถ้าแอปต้องรับโหลด ให้ใช้ Ingress พร้อม certificate แทน

ขั้นที่ 5: ติดตั้ง SealedSecrets และสำรองคีย์ทันที

SealedSecrets ทำให้เรา commit Secret ที่เข้ารหัสแล้วลง Git ได้ โดยมีแค่ controller ในคลัสเตอร์ที่ถอดรหัสได้

kubectl apply -f "https://github.com/bitnami-labs/sealed-secrets/releases/download/v${SEALED_SECRETS_VERSION}/controller.yaml"
kubectl -n kube-system rollout status deploy/sealed-secrets-controller
kubeseal --fetch-cert > sealed-secrets-cert.pem    # cert สาธารณะ แชร์ให้ทีมได้

บทเรียนที่แพงที่สุดคือถ้าคีย์ของ controller หาย SealedSecret ทุกตัวใน Git จะกลายเป็นข้อมูลที่ถอดไม่ได้ ต้องขอค่า secret จริงจากเจ้าของมา seal ใหม่ทั้งหมด จึงต้องสำรองคีย์ทันทีหลังติดตั้ง

install -d -m 700 /root/secrets
kubectl -n kube-system get secret \
  -l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml \
  > /root/secrets/sealed-secrets-key-$(date +%Y%m%d-%H%M).yaml
chmod 600 /root/secrets/sealed-secrets-key-*.yaml

ค่าเริ่มต้นของ controller จะสร้างคีย์ใหม่ทุก 30 วัน และเก็บคีย์เก่าไว้ด้วย จึงควรให้ cron สำรองซ้ำเป็นประจำ แล้วส่งสำเนาที่เข้ารหัสแล้วออกไปเก็บนอก VM

# /etc/cron.d/sealed-secrets-backup
SHELL=/bin/sh
KUBECONFIG=/etc/rancher/k3s/k3s.yaml
15 2 * * * root umask 077; kubectl -n kube-system get secret -l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml > /root/secrets/sealed-secrets-key-$(date +\%Y\%m\%d).yaml

ถ้าต้องสร้างคลัสเตอร์ใหม่ ให้ kubectl apply -f ไฟล์สำรอง แล้วลบ pod ของ controller (kubectl -n kube-system delete pod -l name=sealed-secrets-controller) เพื่อให้มันโหลดคีย์เดิมกลับมา ส่วน SealedSecret ที่เคย seal ไว้กับคลัสเตอร์อื่นซึ่งไม่มีคีย์สำรอง ต้อง seal ใหม่ด้วย cert ของคลัสเตอร์นี้

ขั้นที่ 6: เปิดแอป POC ด้วย NodePort

สำหรับ POC ที่ยังไม่มีโดเมนและ certificate การใช้ NodePort แล้วเข้าด้วย IP:พอร์ต เร็วที่สุด ให้ Argo CD Application ชี้ไปที่ repo แล้ว sync ตามปกติ ส่วน Service จะหน้าตาประมาณนี้

apiVersion: v1
kind: Service
metadata:
  name: demo-web
spec:
  type: NodePort
  selector:
    app: demo-web
  ports:
    - port: 80
      targetPort: 8080
      nodePort: 30080

ข้อควรรู้คือ NodePort ฟังบนทุก interface ของ node โดยค่าเริ่มต้น และทราฟฟิกจะถูก DNAT ไปที่ pod ก่อนถึง chain input ของ firewall ฝั่ง host กฎ input ธรรมดาจึงกันไม่ได้ วิธีที่ตรงกว่าคือบอก kube-proxy ให้รับ NodePort เฉพาะบาง IP

# เพิ่มใน /etc/rancher/k3s/config.yaml แล้ว systemctl restart k3s
kube-proxy-arg:
  - "nodeport-addresses=100.64.0.10/32"

เมื่อ POC ผ่านแล้ว ควรย้ายไปใช้ Ingress พร้อมโดเมนและ certificate แทนการเปิดพอร์ตตรง

เช็กลิสต์ความปลอดภัยหลังติดตั้ง

k3s ตั้งค่าเริ่มต้นมาให้ใช้งานง่าย ไม่ได้ตั้งมาให้ปิดทุกอย่าง ก่อนส่งมอบเครื่อง ให้ไล่ดู ss -tulpn และเช็กทีละข้อ

  • kube API (6443) และ kubelet (10250) ฟังบนทุก interface โดยค่าเริ่มต้น ควรปิดจาก LAN ให้เหลือแค่ localhost กับ tailnet อย่าปิดด้วยการ drop ทุก interface ที่ไม่ใช่ tailscale0 เพราะ pod ในคลัสเตอร์ต้องคุยกับ API ผ่าน interface ของ CNI ด้วย ให้กันเฉพาะ interface ฝั่ง LAN แทน
# /etc/nftables.d/k3s-mgmt.nft  (ตารางแยก ไม่ยุ่งกับกฎของ k3s)
table inet k3s_mgmt {
  chain input {
    type filter hook input priority filter - 10; policy accept;
    iifname "ens18" tcp dport { 6443, 10250 } drop
  }
}
nft -c -f /etc/nftables.d/k3s-mgmt.nft && nft -f /etc/nftables.d/k3s-mgmt.nft

ทดสอบจากเครื่องอื่นใน LAN ด้วย nc -vz 192.0.2.10 6443 ต้องเข้าไม่ได้ และจากเครื่องใน tailnet ต้องเข้าได้ ถ้าจะเพิ่ม node ในอนาคต ต้องเปิดพอร์ตเหล่านี้ให้ IP ของ node อื่นด้วย ถ้าอยากทบทวนพื้นฐานการเขียนกฎกรองทราฟฟิก อ่านต่อได้ที่ บล็อก IP ต่างประเทศด้วย iptables

  • Traefik และ NodePort ของ k3s เปิดพอร์ต 80/443 ผ่าน hostPort ของ ServiceLB และ NodePort ของ Traefik บนทุก interface ถ้ายังไม่ใช้ ให้ปิดด้วย disable: [traefik, servicelb] ถ้าใช้ ให้ตัดสินใจให้ชัดว่าจะให้เข้าจาก LAN, tailnet หรือทั้งคู่
  • เปลี่ยนรหัสผ่าน admin ของ Argo CD ทันทีที่ login ครั้งแรก แล้วลบ Secret รหัสเริ่มต้นทิ้ง
argocd account update-password
kubectl -n argocd delete secret argocd-initial-admin-secret
  • สำรองคีย์ SealedSecrets ตามรอบ ให้ตรวจว่า cron ทำงานจริงและมีสำเนานอก VM ไม่ใช่แค่ตั้งไว้แล้วลืม
  • Flannel VXLAN (8472/udp) ถ้าเป็นคลัสเตอร์ node เดียว ไม่จำเป็นต้องให้เครื่องอื่นเข้าถึง
  • จด runbook ว่าติดตั้งรุ่นไหน ไฟล์สำคัญอยู่ที่ใด และถอนการติดตั้งอย่างไร โดยไม่ใส่รหัสผ่านหรือคีย์ลงไป

ตรวจสอบหลังติดตั้ง

export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl get nodes; kubectl get pods -A
findmnt /var/lib/rancher
systemctl is-active argocd-ui-tailnet
ss -ltnp | grep -E '8443|6443|10250'
curl -kI https://100.64.0.10:8443/      # รันจากเครื่องใน tailnet

สรุป

k3s บน VM เดียวตั้งเสร็จได้ในไม่ถึงชั่วโมง แต่สิ่งที่ทำให้ระบบอยู่ได้นานคือรายละเอียดรอบ ๆ ได้แก่ การแยกดิสก์ข้อมูล, การให้ช่องทางจัดการอยู่ใน tailnet เท่านั้น, การสำรองคีย์ SealedSecrets ตั้งแต่วันแรก และการไล่ปิดพอร์ตที่ k3s เปิดไว้ให้โดยค่าเริ่มต้น ถ้าทำครบตามเช็กลิสต์นี้ ทีมจะได้คลัสเตอร์ POC ที่ใช้ GitOps ได้เต็มรูปแบบ โดยไม่ต้องเปิดอะไรออกสู่ภายนอก