Category: Tutorial

  • Belajar Kubernetes #5: Pod, Unit Terkecil Kubernetes

    Belajar Kubernetes #5: Pod, Unit Terkecil Kubernetes

    Pod Kubernetes adalah unit terkecil yang bisa kamu deploy di sebuah cluster. Bukan container. Ini poin yang sering bikin pemula bingung, karena di Docker kita terbiasa berpikir dalam satuan container. Di Kubernetes, container tidak pernah berdiri sendiri. Container selalu dibungkus dalam pod, dan pod itulah yang dijadwalkan ke node, diberi alamat IP, dan dikelola oleh cluster.

    Di artikel ini kita akan membuat pod pertama, membedahnya dengan kubectl describe, memahami lifecycle-nya, membuktikan bahwa container dalam satu pod berbagi network, lalu sengaja membuat error ImagePullBackOff dan CrashLoopBackOff supaya kamu tahu cara mendiagnosanya. Artikel ini bagian kelima dari seri Belajar Kubernetes dari Nol, jadi kalau kamu baru masuk di tengah, mampir dulu ke halaman hub itu untuk lihat urutan lengkapnya.

    Prasyarat Sebelum Praktik

    Kamu butuh Minikube yang sudah jalan dan kubectl yang sudah terhubung ke cluster. Di seri ini saya memakai Minikube v1.36 dengan Kubernetes v1.33. Pastikan juga kamu sudah nyaman dengan perintah dasar seperti kubectl get dan kubectl describe. Kalau belum, baca dulu Belajar Kubernetes #4: Perintah Dasar kubectl karena semua perintah di artikel ini dibangun dari sana.

    Cek dulu cluster kamu hidup:

    kubectl get nodes

    Output yang diharapkan:

    NAME       STATUS   ROLES           AGE   VERSION
    minikube   Ready    control-plane   10d   v1.33.1

    Kenapa Pod, Bukan Container, yang Jadi Unit Terkecil

    Kubernetes tidak menjadwalkan container satu per satu. Kubernetes menjadwalkan pod. Satu pod adalah satu “kapsul” berisi satu atau lebih container yang selalu hidup bersama di node yang sama, berbagi alamat IP yang sama, dan bisa berbagi volume yang sama.

    Kenapa perlu lapisan ekstra ini? Karena ada kasus di mana dua proses harus benar-benar nempel: dijadwalkan bareng, mati bareng, dan saling akses lewat localhost. Kalau unit terkecilnya container, Kubernetes tidak punya cara menjamin dua container selalu ada di node yang sama. Dengan pod, jaminan itu otomatis.

    Kapan Satu Pod Berisi Lebih dari Satu Container

    Praktiknya, mayoritas pod berisi satu container. Satu aplikasi, satu container, satu pod. Pola lebih dari satu container dipakai untuk kasus khusus yang biasa disebut sidecar, misalnya:

    • Container log shipper yang membaca file log dari container utama lalu mengirimnya ke sistem logging terpusat.
    • Proxy seperti Envoy yang duduk di samping aplikasi untuk mengatur trafik (ini fondasi service mesh seperti Istio).
    • Container helper yang men-sync file konfigurasi atau konten secara berkala untuk dipakai container utama.

    Aturan praktisnya sederhana. Kalau dua proses bisa di-scale terpisah, pisahkan ke pod berbeda. Kalau dua proses tidak ada gunanya hidup tanpa satu sama lain, baru pertimbangkan satu pod.

    Praktik: Membuat Pod Nginx dengan kubectl run

    Cara paling cepat membuat pod adalah perintah imperatif kubectl run:

    kubectl run web-nginx --image=nginx:1.27

    Output:

    pod/web-nginx created

    Cek statusnya:

    kubectl get pods
    NAME        READY   STATUS    RESTARTS   AGE
    web-nginx   1/1     Running   0          15s

    Kolom READY 1/1 artinya 1 dari 1 container di pod ini siap. Sekarang bedah pod-nya:

    kubectl describe pod web-nginx

    Outputnya panjang, tapi ada empat bagian yang paling sering saya baca saat debugging di proyek klien:

    • Node: di node mana pod ini dijadwalkan. Di Minikube selalu minikube, di cluster produksi ini penting saat ada node bermasalah.
    • IP: alamat IP pod. Setiap pod dapat IP sendiri di jaringan internal cluster.
    • Containers: image apa yang dipakai, port apa yang dibuka, dan state container saat ini.
    • Events: riwayat kejadian dari scheduler sampai container jalan. Ini tempat pertama mencari petunjuk saat pod bermasalah.

    Bagian Events untuk pod yang sehat terlihat seperti ini:

    Events:
      Type    Reason     Age   From               Message
      ----    ------     ----  ----               -------
      Normal  Scheduled  40s   default-scheduler  Successfully assigned default/web-nginx to minikube
      Normal  Pulling    40s   kubelet            Pulling image "nginx:1.27"
      Normal  Pulled     31s   kubelet            Successfully pulled image "nginx:1.27"
      Normal  Created    31s   kubelet            Created container: web-nginx
      Normal  Started    31s   kubelet            Started container web-nginx

    Urutan ini menceritakan alur hidup pod: dijadwalkan ke node, image ditarik dari registry, container dibuat, lalu dijalankan.

    Lifecycle Pod: Pending, Running, Succeeded, Failed

    Setiap pod punya fase (phase) yang bisa kamu lihat di kolom STATUS. Ini fase resminya:

    Fase Artinya
    Pending Pod sudah diterima cluster tapi container belum jalan. Biasanya sedang menunggu jadwal atau menunggu image ditarik.
    Running Pod sudah menempel di node dan minimal satu container sedang berjalan.
    Succeeded Semua container selesai dengan sukses dan tidak akan di-restart. Umum untuk pod tipe job atau task sekali jalan.
    Failed Semua container berhenti dan minimal satu berakhir dengan error.

    Kamu mungkin juga melihat status seperti ContainerCreating, ImagePullBackOff, atau CrashLoopBackOff di kolom STATUS. Itu bukan fase resmi, melainkan alasan detail yang ditampilkan kubectl supaya lebih informatif. ContainerCreating misalnya, muncul saat pod masih di fase Pending dan kubelet sedang menyiapkan container.

    Pod Bersifat Sekali Pakai

    Ini konsep yang wajib tertanam sejak awal: pod itu ephemeral, alias sekali pakai. Kalau pod mati atau dihapus, Kubernetes tidak menghidupkan pod yang sama. Tidak ada “restart pod” dalam arti pod lama bangkit lagi. Yang ada hanyalah pod baru dengan identitas baru dan IP baru. Buktikan:

    kubectl delete pod web-nginx
    pod "web-nginx" deleted

    Jalankan kubectl get pods lagi. Kosong. Tidak ada yang menghidupkan ulang pod itu, karena kita membuatnya langsung tanpa controller. Fakta inilah yang nanti menjelaskan kenapa Deployment itu penting. Simpan dulu, kita bahas di akhir artikel.

    Container dalam Satu Pod Berbagi Network dan Storage

    Sekarang kita buktikan klaim tadi: semua container dalam satu pod berbagi network namespace yang sama. Artinya mereka saling akses lewat localhost tanpa perlu tahu IP siapa pun.

    Buat file pod-duo.yaml berisi pod dengan dua container, nginx sebagai container utama dan sebuah sidecar berisi curl:

    apiVersion: v1
    kind: Pod
    metadata:
      name: pod-duo
    spec:
      containers:
        - name: web
          image: nginx:1.27
        - name: sidecar
          image: curlimages/curl:8.8.0
          command: ["sleep", "infinity"]

    Jangan pusing dulu dengan struktur YAML-nya, kita bedah lengkap di bagian berikutnya dari seri ini. Terapkan:

    kubectl apply -f pod-duo.yaml
    kubectl get pods
    NAME      READY   STATUS    RESTARTS   AGE
    pod-duo   2/2     Running   0          20s

    Perhatikan READY 2/2. Dua container, satu pod. Sekarang eksekusi curl dari dalam container sidecar ke localhost:

    kubectl exec pod-duo -c sidecar -- curl -s localhost

    Output:

    <!DOCTYPE html>
    <html>
    <head>
    <title>Welcome to nginx!</title>
    ...

    Container sidecar berhasil mengakses nginx lewat localhost padahal nginx berjalan di container lain. Ini bukti dua container itu berbagi satu network namespace. Flag -c sidecar menentukan container mana yang menjalankan perintah, karena pod ini punya lebih dari satu container.

    Selain network, container dalam satu pod juga bisa berbagi storage lewat volume yang di-mount ke masing-masing container. Topik volume kita dalami di bagian tersendiri, yang penting sekarang kamu tahu kemampuannya ada. Bersihkan dulu:

    kubectl delete pod pod-duo

    Troubleshooting: Error Pod yang Pasti Ditemui Pemula

    Dua error ini hampir pasti kamu temui di minggu pertama belajar Kubernetes. Kita sengaja reproduksi supaya kamu kenal wajahnya.

    ImagePullBackOff: Image Tidak Bisa Ditarik

    Buat pod dengan nama image yang typo:

    kubectl run salah-ketik --image=ngincx:1.27

    Tunggu beberapa detik lalu cek:

    kubectl get pods
    NAME          READY   STATUS             RESTARTS   AGE
    salah-ketik   0/1     ImagePullBackOff   0          30s

    Penyebab paling umum: nama image salah ketik, tag tidak ada, atau image berada di registry privat tanpa kredensial. Cara diagnosa, baca Events:

    kubectl describe pod salah-ketik

    Di bagian Events akan ada baris seperti ini:

    Failed to pull image "ngincx:1.27": ... repository does not exist or may require 'docker login'

    Pesannya jujur: repository tidak ditemukan. Solusinya perbaiki nama image. Karena field image bisa diedit langsung, tidak perlu hapus pod:

    kubectl set image pod/salah-ketik salah-ketik=nginx:1.27

    Beberapa saat kemudian pod akan Running. Kata BackOff sendiri artinya Kubernetes menunggu makin lama di tiap percobaan ulang, mulai beberapa detik sampai maksimal lima menit, supaya registry tidak dihujani request.

    CrashLoopBackOff: Aplikasi Mati Terus

    Sekarang buat container yang langsung exit dengan kode error:

    kubectl run crash-demo --image=busybox:1.36 -- sh -c "echo boom; exit 1"
    kubectl get pods
    NAME         READY   STATUS             RESTARTS      AGE
    crash-demo   0/1     CrashLoopBackOff   3 (25s ago)   1m

    Polanya: container jalan, mati, di-restart, mati lagi, dan jeda restart makin lama. Bedanya dengan ImagePullBackOff, di sini image berhasil ditarik tapi prosesnya sendiri yang mati. Penyebab umum di dunia nyata: aplikasi gagal konek ke database, environment variable wajib belum di-set, atau memang ada bug yang bikin proses exit.

    Langkah diagnosa selalu sama. Pertama describe untuk lihat exit code, kedua baca log aplikasi:

    kubectl logs crash-demo
    boom

    Kalau container sudah terlanjur di-restart dan kamu butuh log dari proses yang mati sebelumnya, pakai flag --previous:

    kubectl logs crash-demo --previous

    Di pengalaman tim kami menangani backend Go dan Laravel milik klien, hampir semua CrashLoopBackOff ujungnya ketahuan dari kubectl logs. Jadi biasakan urutannya: get pods untuk lihat gejala, describe untuk konteks, logs untuk akar masalah. Bersihkan kedua pod percobaan:

    kubectl delete pod salah-ketik crash-demo

    Pod Nyangkut di Pending

    Satu lagi yang sering muncul: pod diam di status Pending lama sekali. Artinya scheduler belum menemukan node yang muat. Di Minikube ini biasanya terjadi kalau kamu meminta resource lebih besar dari kapasitas VM. Diagnosa tetap sama, kubectl describe pod lalu baca Events. Biasanya ada pesan Insufficient cpu atau Insufficient memory. Solusinya turunkan permintaan resource pod, atau besarkan Minikube dengan minikube start --cpus=4 --memory=4096.

    Kenapa di Dunia Nyata Kita Jarang Membuat Pod Langsung

    Setelah semua praktik di atas, ini pelajaran penutupnya: pod telanjang (bare pod) hampir tidak pernah dipakai di produksi. Alasannya balik ke sifat sekali pakai tadi. Kalau node tempat pod berjalan mati, pod ikut hilang dan tidak ada yang menggantikannya. Tidak ada self-healing, tidak ada scaling, tidak ada rolling update.

    Di dunia nyata, pod selalu dikelola oleh controller seperti Deployment. Kamu mendeklarasikan “saya mau 3 replika aplikasi ini”, lalu Deployment yang memastikan selalu ada 3 pod hidup. Pod mati satu, dibuatkan penggantinya secara otomatis. Ini pola yang tim Arrazy pakai saat membangun sistem aplikasi untuk klien: tidak ada satu pun bare pod, semuanya lewat Deployment atau controller lain.

    Lalu kenapa kita belajar pod duluan? Karena Deployment pada dasarnya hanyalah mesin pengelola pod. Semua skill hari ini, membaca describe, membaca Events, membaca logs, akan kamu pakai persis sama saat pod-nya dikelola Deployment.

    Rangkuman dan Lanjut ke Mana

    Hari ini kamu sudah pegang konsep paling fundamental di Kubernetes. Pod adalah unit terkecil yang bisa di-deploy, container di dalamnya berbagi network dan storage, lifecycle-nya berjalan dari Pending sampai Running atau Failed, dan sifatnya sekali pakai sehingga butuh controller untuk produksi. Kamu juga sudah kenal dua error klasik, ImagePullBackOff dan CrashLoopBackOff, lengkap dengan cara diagnosanya.

    Sejauh ini kita banyak memakai perintah imperatif seperti kubectl run. Cara itu cepat untuk belajar, tapi tidak cocok untuk kerja tim karena tidak ada catatan konfigurasi yang bisa di-review dan di-versioning. Solusinya menulis manifest YAML, dan itu tepat bahasan berikutnya: “Belajar Kubernetes #6: Cara Membuat File YAML Kubernetes”. Bagian itu terbit menyusul, pantau daftarnya di halaman hub Belajar Kubernetes dari Nol.

    Referensi

  • Cara Kerja Slice Go: Append, Kapasitas, dan Memori

    Cara Kerja Slice Go: Append, Kapasitas, dan Memori

    Cara kerja slice Golang sebenarnya sederhana: slice bukan array, melainkan struct kecil berisi tiga hal, yaitu pointer ke array di belakangnya, length (jumlah elemen terpakai), dan capacity (ukuran array yang dialokasikan). Saat kamu memanggil append, Go menulis elemen baru ke array itu selama kapasitas masih cukup. Begitu penuh, Go mengalokasikan array baru yang lebih besar, menyalin semua elemen lama, lalu mengembalikan slice yang menunjuk ke array baru tersebut.

    Di artikel ini kita bongkar mekanisme itu satu per satu dengan kode yang bisa kamu jalankan sendiri: melihat isi slice header, mengamati kapan alokasi baru terjadi, membuktikan lewat benchmark kenapa pre-alokasi penting, sampai membuat dynamic array versi sendiri. Artikel ini bagian kelima dari seri Belajar Struktur Data dari Nol. Kalau kamu belum paham beda array dan slice di level pemakaian, baca dulu bagian sebelumnya: Array dan Slice di Go: Struktur Data Paling Dasar.

    Semua kode di artikel ini dites dengan Go 1.23.1 di Linux dan berperilaku sama di Go 1.24. Jalankan go version untuk memastikan versimu minimal Go 1.20, karena kita memakai fungsi unsafe.SliceData yang baru ada sejak versi itu.

    Anatomi Slice Header: Pointer, Length, dan Capacity

    Di dalam runtime Go, slice direpresentasikan seperti ini:

    type sliceHeader struct {
        Data uintptr // alamat array di belakang slice
        Len  int     // jumlah elemen yang sedang dipakai
        Cap  int     // total slot yang tersedia di array
    }

    Struct ini ringan, hanya 24 byte di mesin 64-bit. Itu sebabnya slice murah untuk dioper antar fungsi: yang disalin cuma header-nya, bukan seluruh data. Kita bisa mengintip ketiga komponen ini dengan len(), cap(), dan unsafe.SliceData yang mengembalikan pointer ke elemen pertama array:

    package main
    
    import (
    	"fmt"
    	"unsafe"
    )
    
    func main() {
    	s := make([]int, 3, 8)
    	fmt.Println("len:", len(s), "cap:", cap(s))
    	fmt.Printf("alamat array s: %p\n", unsafe.SliceData(s))
    
    	t := s[1:3]
    	fmt.Println("len t:", len(t), "cap t:", cap(t))
    	fmt.Printf("alamat array t: %p\n", unsafe.SliceData(t))
    }

    Output di mesin saya (alamat pasti berbeda di mesinmu):

    len: 3 cap: 8
    alamat array s: 0xc00001e1c0
    len t: 2 cap t: 7
    alamat array t: 0xc00001e1c8

    Perhatikan dua hal. Pertama, alamat t hanya bergeser 8 byte dari s. Artinya t tidak punya data sendiri, ia menunjuk ke array yang sama, mulai dari elemen index 1. Ukuran int di mesin 64-bit memang 8 byte. Kedua, cap(t) jadi 7, bukan 8, karena capacity dihitung dari posisi awal slice sampai ujung array. Fakta bahwa dua slice bisa berbagi satu array inilah sumber dari hampir semua bug slice yang akan kita bahas nanti.

    Kapan append Memicu Alokasi Baru di Go 1.24

    Sekarang kita amati langsung kapan Go mengalokasikan array baru. Caranya gampang: append terus menerus, dan cetak setiap kali nilai cap berubah. Perubahan cap menandakan array lama sudah diganti array baru yang lebih besar.

    package main
    
    import "fmt"
    
    func main() {
    	var s []int
    	lastCap := -1
    	for i := 0; i < 600; i++ {
    		s = append(s, i)
    		if cap(s) != lastCap {
    			fmt.Printf("len=%-4d cap=%d\n", len(s), cap(s))
    			lastCap = cap(s)
    		}
    	}
    }

    Outputnya:

    len=1    cap=1
    len=2    cap=2
    len=3    cap=4
    len=5    cap=8
    len=9    cap=16
    len=17   cap=32
    len=33   cap=64
    len=65   cap=128
    len=129  cap=256
    len=257  cap=512
    len=513  cap=848

    Polanya terbaca jelas. Selama kapasitas masih di bawah 256, Go menggandakan kapasitas setiap kali penuh: 1, 2, 4, 8, dan seterusnya. Setelah melewati 256 elemen, pertumbuhan melambat ke kisaran 1,25 sampai 1,6 kali, makanya dari 512 lompat ke 848 dan bukan 1024. Angka 848 juga bukan hasil perkalian bulat karena runtime membulatkan ukuran alokasi ke kelas ukuran (size class) milik allocator Go supaya memori tidak terbuang. Detail angka ini bisa sedikit bergeser antar versi Go, tapi pola besarnya sama di Go 1.23 dan 1.24: gandakan saat kecil, tumbuh lebih hemat saat besar.

    Yang penting dipahami: di baris len=513 cap=848, Go baru saja menyalin 512 elemen ke array baru. Kalau slice-mu berisi jutaan elemen, satu kali append yang kebetulan memicu pertumbuhan berarti menyalin jutaan elemen juga.

    Dynamic Array dan Amortized O(1): Kenapa append Rata-Rata Murah

    Slice Go adalah implementasi dari struktur data klasik bernama dynamic array: array yang bisa tumbuh dengan cara realokasi dan salin. Kalau sesekali append harus menyalin semua elemen, kenapa append tetap disebut O(1)?

    Jawabannya ada di analisis amortized. Misalkan kita append 8 elemen mulai dari slice kosong dengan strategi gandakan kapasitas:

    Append ke- Kapasitas sebelum Perlu salin? Elemen disalin
    1 0 ya 0
    2 1 ya 1
    3 2 ya 2
    4 4 tidak 0
    5 4 ya 4
    6 sampai 8 8 tidak 0

    Total operasi salin untuk 8 append adalah 7. Kalau diteruskan ke n append, total salinnya selalu di bawah 2n. Dibagi rata ke semua operasi, tiap append menanggung biaya konstan, kurang dari 2 salinan per operasi. Itulah maksud amortized O(1): sesekali ada operasi mahal O(n), tapi rata-ratanya tetap konstan karena operasi mahal itu makin jarang terjadi seiring kapasitas membesar. Konsep menghitung biaya seperti ini sudah kita kenal dari bagian ketiga seri tentang notasi Big O.

    Benchmark: append Tanpa make vs Dengan make

    Amortized O(1) bukan berarti gratis. Realokasi tetap memakan waktu dan menyampah ke garbage collector. Kalau kamu sudah tahu jumlah elemen dari awal, pre-alokasi dengan make([]int, 0, n) menghapus semua realokasi itu. Kita buktikan dengan benchmark resmi Go. Simpan sebagai append_test.go di folder yang sudah punya go.mod:

    package main
    
    import "testing"
    
    const n = 10000
    
    func BenchmarkAppendTanpaMake(b *testing.B) {
    	for i := 0; i < b.N; i++ {
    		var s []int
    		for j := 0; j < n; j++ {
    			s = append(s, j)
    		}
    	}
    }
    
    func BenchmarkAppendDenganMake(b *testing.B) {
    	for i := 0; i < b.N; i++ {
    		s := make([]int, 0, n)
    		for j := 0; j < n; j++ {
    			s = append(s, j)
    		}
    	}
    }

    Jalankan dengan:

    go test -bench=. -benchmem

    Hasil di laptop saya (Intel i5-10310U, Go 1.23.1):

    BenchmarkAppendTanpaMake-8     10000    102632 ns/op    357625 B/op    19 allocs/op
    BenchmarkAppendDenganMake-8    81643     13500 ns/op     81920 B/op     1 allocs/op

    Bacaannya begini. Tanpa make, mengisi 10 ribu elemen butuh 19 kali alokasi dan total 357 ribu byte, karena setiap kali tumbuh Go membuat array baru dan array lama jadi sampah. Dengan pre-alokasi, cuma ada 1 alokasi sebesar 81920 byte (10 ribu kali 8 byte, dibulatkan ke size class) dan waktunya sekitar 7 kali lebih cepat. Angkamu pasti beda, tapi rasionya akan mirip.

    Di backend Go untuk sistem aplikasi klien yang tim Arrazy kerjakan, pola ini paling sering muncul saat mengubah hasil query database menjadi slice DTO. Jumlah baris sudah diketahui, jadi pre-alokasi jadi kebiasaan standar, murah ditulis dan efeknya nyata di endpoint yang dipanggil ribuan kali.

    Dua Kesalahan Umum Seputar Slice yang Sering Jadi Bug

    Memory leak karena slicing potongan kecil dari slice besar

    Ingat, slice hasil re-slicing berbagi array dengan slice asalnya. Kode seperti ini terlihat tidak berdosa:

    func ambilHeader(fileBesar []byte) []byte {
    	return fileBesar[:100] // BUG: menahan seluruh file di memori
    }

    Slice yang dikembalikan memang cuma 100 byte panjangnya, tapi pointer-nya masih menunjuk ke array asli yang mungkin puluhan megabyte. Selama slice kecil itu hidup, garbage collector tidak bisa membebaskan seluruh array. Kalau fungsi ini dipanggil untuk banyak file, memori server naik terus tanpa kelihatan sebabnya di kode. Solusinya salin datanya supaya array besar bisa dilepas:

    func ambilHeader(fileBesar []byte) []byte {
    	header := make([]byte, 100)
    	copy(header, fileBesar[:100])
    	return header
    }

    Sejak Go 1.21 kamu juga bisa memakai slices.Clone(fileBesar[:100]) dari package slices, hasilnya sama.

    append di dalam fungsi tidak mengubah slice pemanggil

    Karena yang dioper ke fungsi adalah salinan slice header, append di dalam fungsi hanya mengubah salinan itu:

    package main
    
    import "fmt"
    
    func tambahSalah(s []int) {
    	s = append(s, 99)
    }
    
    func tambahBenar(s []int) []int {
    	return append(s, 99)
    }
    
    func main() {
    	data := make([]int, 0, 4)
    	data = append(data, 1, 2)
    
    	tambahSalah(data)
    	fmt.Println("setelah tambahSalah:", data)
    
    	data = tambahBenar(data)
    	fmt.Println("setelah tambahBenar:", data)
    }

    Output:

    setelah tambahSalah: [1 2]
    setelah tambahBenar: [1 2 99]

    Di tambahSalah, elemen 99 sebenarnya sempat tertulis ke array (kapasitasnya masih cukup), tapi length milik pemanggil tetap 2, jadi elemen itu tidak pernah terlihat. Aturannya sederhana: fungsi yang melakukan append harus mengembalikan slice hasilnya, persis seperti append bawaan Go sendiri. Alternatifnya terima parameter *[]int, tapi gaya return lebih umum dan lebih enak dibaca.

    Latihan: Membuat Dynamic Array Sendiri di Go

    Cara terbaik memahami apa yang Go lakukan di balik layar adalah menirunya. Kita buat dynamic array sederhana: struct dengan array mentah, length manual, dan fungsi grow yang menggandakan kapasitas persis seperti perilaku slice kecil.

    package main
    
    import "fmt"
    
    type DynamicArray struct {
    	data []int // dipakai sebagai array mentah, tidak pernah di-append
    	len  int
    }
    
    func NewDynamicArray() *DynamicArray {
    	return &DynamicArray{data: make([]int, 1)}
    }
    
    func (d *DynamicArray) grow() {
    	newData := make([]int, len(d.data)*2)
    	copy(newData, d.data)
    	d.data = newData
    }
    
    func (d *DynamicArray) Push(v int) {
    	if d.len == len(d.data) {
    		d.grow()
    	}
    	d.data[d.len] = v
    	d.len++
    }
    
    func (d *DynamicArray) Get(i int) int {
    	if i < 0 || i >= d.len {
    		panic("index di luar batas")
    	}
    	return d.data[i]
    }
    
    func (d *DynamicArray) Len() int { return d.len }
    func (d *DynamicArray) Cap() int { return len(d.data) }
    
    func main() {
    	arr := NewDynamicArray()
    	for i := 1; i <= 10; i++ {
    		arr.Push(i * 10)
    		fmt.Printf("push ke-%d, len=%d cap=%d\n", i, arr.Len(), arr.Cap())
    	}
    	fmt.Println("elemen index 7:", arr.Get(7))
    }

    Output:

    push ke-1, len=1 cap=1
    push ke-2, len=2 cap=2
    push ke-3, len=3 cap=4
    push ke-4, len=4 cap=4
    push ke-5, len=5 cap=8
    push ke-6, len=6 cap=8
    push ke-7, len=7 cap=8
    push ke-8, len=8 cap=8
    push ke-9, len=9 cap=16
    push ke-10, len=10 cap=16
    elemen index 7: 80

    Bandingkan dengan output eksperimen append di atas: pola pertumbuhannya identik untuk ukuran kecil. Tiga komponen yang kamu kelola manual di sini, yaitu data, len, dan kapasitas, persis tiga field slice header. Sebagai tantangan tambahan, coba tambahkan method Pop(), Set(i, v), dan aturan pertumbuhan 1,25 kali setelah kapasitas 256 supaya makin mirip runtime Go.

    Troubleshooting: Error yang Sering Dialami Pemula

    panic: runtime error: slice bounds out of range

    Muncul saat re-slicing melebihi capacity, misalnya s[:10] padahal cap(s) hanya 8. Ingat batas re-slicing adalah capacity, bukan length, jadi s[:5] pada slice dengan len 3 dan cap 8 itu sah. Cek dulu dengan cap(s) sebelum memperlebar slice, atau tangani panjang data secara eksplisit.

    undefined: unsafe.SliceData

    Fungsi ini baru ada di Go 1.20. Kalau muncul error ini, versi Go-mu terlalu lama, cek dengan go version lalu upgrade lewat go.dev/doc/install. Pastikan juga baris go di go.mod tidak memaksa versi bahasa lama.

    go test -bench tidak menjalankan apa pun atau "no test files"

    Dua penyebab paling umum: nama file tidak diakhiri _test.go, atau nama fungsi tidak diawali Benchmark dengan huruf besar setelahnya. Pastikan juga kamu menjalankan perintah di folder yang berisi file test dan sudah ada go.mod (kalau belum, jalankan go mod init namamodul sekali).

    Dua variabel slice saling menimpa data

    Gejalanya: kamu append ke slice B, tiba-tiba isi slice A ikut berubah. Ini terjadi karena B hasil re-slicing dari A dan keduanya masih berbagi array, lalu append B menulis ke slot yang masih dalam jangkauan A. Solusinya pisahkan kepemilikan data dengan slices.Clone, atau pakai full slice expression a[low:high:max] untuk membatasi capacity sehingga append berikutnya dipaksa mengalokasikan array baru.

    Rangkuman dan Lanjutan Seri

    Sekarang kamu tahu isi perut slice: header kecil berisi pointer, length, dan capacity yang menunjuk ke satu array bersama. Append murah secara amortized, tapi pre-alokasi dengan make tetap layak dibiasakan saat ukuran data diketahui, dan dua jebakan klasiknya, yaitu array bersama dan salinan header, sekarang bisa kamu kenali sebelum jadi bug di production.

    Dynamic array punya satu kelemahan bawaan: sisip atau hapus elemen di tengah tetap O(n) karena semua elemen setelahnya harus digeser. Struktur data berikutnya menjawab masalah itu dengan pendekatan yang sama sekali berbeda. Bagian keenam, Linked List: Konsep dan Implementasi Singly Linked List di Go, terbit menyusul dan bisa kamu pantau di halaman hub Belajar Struktur Data dari Nol.

    Referensi

  • MAC Address dan ARP: Cara Perangkat Saling Mengenal

    MAC Address dan ARP: Cara Perangkat Saling Mengenal

    MAC address adalah alamat fisik yang tertanam di kartu jaringan setiap perangkat, dipakai untuk saling mengenal di jaringan lokal. Sementara ARP adalah protokol yang menjembatani dua dunia: dunia IP address yang dipakai aplikasi, dan dunia MAC address yang dipakai kabel serta WiFi. Tanpa ARP, komputer Anda tahu alamat IP tujuan tapi tidak tahu harus mengirim data ke perangkat fisik yang mana.

    Artikel ini bagian kelima dari seri Belajar Jaringan Komputer dari Nol. Kita akan bedah format MAC address, cara mengeceknya dengan ip link, cara kerja ARP, lalu praktik langsung mengamati ARP cache di Linux. Di akhir ada daftar error yang paling sering bikin pemula bingung, termasuk kasus host yang terlihat mati padahal nyala.

    Prasyarat Sebelum Mulai

    Anda cukup punya satu komputer Linux yang terhubung ke jaringan, entah lewat kabel atau WiFi. Semua contoh di sini dijalankan di Ubuntu 24.04 LTS dengan iproute2 versi 6.1 bawaan sistem. Distro lain juga bisa selama perintah ip tersedia, dan itu standar di hampir semua Linux modern.

    Kalau Anda belum paham konsep layer, baca dulu bagian sebelumnya: Model OSI dan TCP/IP Dipahami Tanpa Hafalan. MAC address hidup di layer 2 (data link), IP address hidup di layer 3 (network). Pemisahan dua layer inilah yang membuat ARP diperlukan.

    MAC Address Adalah Alamat Layer 2: Format dan Cara Membacanya

    MAC (Media Access Control) address adalah deretan 48 bit yang ditulis sebagai 6 pasang angka heksadesimal, misalnya 3c:7c:3f:1a:2b:9d. Alamat ini ditanam oleh pabrik ke dalam kartu jaringan (NIC), jadi sering disebut alamat fisik atau hardware address.

    Formatnya punya struktur:

    • 3 byte pertama disebut OUI (Organizationally Unique Identifier). Ini kode vendor. Misalnya 3c:7c:3f milik ASUSTek. Anda bisa menebak merek perangkat orang lain di jaringan hanya dari 3 byte ini.
    • 3 byte terakhir adalah nomor seri yang ditentukan vendor, unik per kartu.

    Ada satu alamat spesial yang wajib diingat: ff:ff:ff:ff:ff:ff, yaitu alamat broadcast. Frame yang dikirim ke alamat ini diterima oleh semua perangkat di jaringan lokal. ARP sangat bergantung pada alamat ini, kita bahas sebentar lagi.

    Cek MAC Address Sendiri dengan ip link

    Jalankan perintah ini di terminal:

    ip link show

    Output yang diharapkan kurang lebih seperti ini:

    1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
        link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    2: enp3s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
        link/ether 3c:7c:3f:1a:2b:9d brd ff:ff:ff:ff:ff:ff

    MAC address ada di baris link/ether. Pada contoh di atas, interface kabel enp3s0 punya MAC 3c:7c:3f:1a:2b:9d. Perhatikan juga brd ff:ff:ff:ff:ff:ff, itu alamat broadcast yang tadi disebut. Interface lo (loopback) tidak punya MAC sungguhan karena tidak pernah menyentuh jaringan fisik.

    Nama interface Anda bisa berbeda: eth0, wlp2s0, ens18, dan sebagainya. Catat nama interface aktif Anda karena akan dipakai di bagian praktik.

    Ethernet Frame: Kenapa MAC Tidak Ikut Menyeberang Router

    Setiap data yang lewat kabel atau WiFi dibungkus dalam unit bernama Ethernet frame. Strukturnya sederhana:

    Field Ukuran Isi
    Destination MAC 6 byte Alamat tujuan di jaringan lokal
    Source MAC 6 byte Alamat pengirim
    EtherType 2 byte Jenis isi, misal 0x0800 untuk IPv4, 0x0806 untuk ARP
    Payload 46 sampai 1500 byte Paket IP atau pesan ARP
    FCS 4 byte Checksum untuk deteksi frame rusak

    Poin penting yang sering bikin pemula salah paham: MAC address hanya berlaku dalam satu jaringan lokal. Saat paket Anda menuju server di internet, IP address tujuan tetap sama dari awal sampai akhir, tapi MAC address di frame berganti di setiap hop.

    Begini alurnya. Laptop Anda mau kirim data ke server Google. Laptop tahu IP tujuan ada di luar jaringan lokal, jadi frame dikirim dengan destination MAC milik router Anda, bukan milik server Google. Router menerima frame itu, membuang bungkus Ethernet-nya, membaca paket IP di dalamnya, lalu membungkus ulang dengan frame baru bermac-address hop berikutnya. Begitu terus sampai tujuan.

    Analoginya seperti paket kiriman antar kota. Alamat rumah tujuan (IP address) tertulis tetap di paketnya. Tapi label sopir yang membawanya (MAC address) berganti setiap kali paket pindah tangan: kurir pertama, truk antar kota, kurir terakhir. Karena itulah MAC address perangkat Anda tidak pernah terlihat oleh server di internet.

    Cara Kerja ARP: Memanggil Nama di Satu Ruangan

    Sekarang masalahnya: laptop Anda tahu router beralamat IP 192.168.1.1, tapi untuk mengirim frame ke router, laptop butuh MAC address-nya. Dari mana dapatnya. Di sinilah ARP (Address Resolution Protocol) bekerja.

    Bayangkan satu ruangan berisi banyak orang yang belum saling kenal wajah, tapi masing-masing tahu namanya sendiri. Anda perlu menyerahkan dokumen ke orang bernama Budi. Cara paling masuk akal: berteriak ke seluruh ruangan, “Budi yang mana ya, angkat tangan.” Semua orang mendengar, tapi hanya Budi yang merespons, “Saya di sini, yang pakai kemeja biru.” Sejak itu Anda hafal wajahnya dan tidak perlu berteriak lagi.

    ARP persis seperti itu:

    1. ARP request: laptop mengirim frame broadcast ke ff:ff:ff:ff:ff:ff berisi pertanyaan “siapa yang punya 192.168.1.1, tolong jawab ke 192.168.1.50”. Semua perangkat di jaringan lokal menerimanya.
    2. ARP reply: hanya perangkat yang merasa memiliki IP itu yang menjawab, langsung ke laptop Anda (unicast), “192.168.1.1 ada di MAC a4:91:b1:xx:yy:zz”.
    3. Caching: laptop menyimpan pasangan IP dan MAC itu di ARP cache (di Linux disebut neighbor table) supaya tidak perlu bertanya ulang setiap kali kirim data.

    Cache ini punya umur. Entri yang lama tidak dipakai akan ditandai basi lalu diverifikasi ulang. Mekanisme lengkapnya didefinisikan di RFC 826, salah satu RFC tertua yang masih dipakai sampai sekarang.

    Praktik: Mengamati ARP Cache dengan ip neigh show

    Sekarang kita buktikan semuanya di terminal. Pertama, lihat isi neighbor table saat ini:

    ip neigh show

    Kalau komputer baru dinyalakan dan belum berkomunikasi dengan siapa pun, outputnya bisa kosong atau hanya berisi router:

    192.168.1.1 dev enp3s0 lladdr a4:91:b1:5c:8e:02 STALE

    Sekarang ping perangkat lain di jaringan lokal Anda. Bisa HP yang tersambung ke WiFi yang sama, printer, atau komputer lain. Cek dulu IP-nya, lalu:

    ping -c 3 192.168.1.20

    Output yang diharapkan:

    PING 192.168.1.20 (192.168.1.20) 56(84) bytes of data.
    64 bytes from 192.168.1.20: icmp_seq=1 ttl=64 time=2.31 ms
    64 bytes from 192.168.1.20: icmp_seq=2 ttl=64 time=1.87 ms
    64 bytes from 192.168.1.20: icmp_seq=3 ttl=64 time=1.92 ms

    Di balik layar, sebelum ICMP pertama terkirim, laptop Anda melakukan ARP request dan mendapat reply. Buktikan dengan melihat neighbor table lagi:

    ip neigh show
    192.168.1.20 dev enp3s0 lladdr 8e:12:4f:aa:03:c7 REACHABLE
    192.168.1.1 dev enp3s0 lladdr a4:91:b1:5c:8e:02 REACHABLE

    Entri baru muncul untuk 192.168.1.20 lengkap dengan MAC address-nya. Arti status di kolom terakhir:

    • REACHABLE: entri masih segar, baru saja terkonfirmasi.
    • STALE: entri lama, masih boleh dipakai tapi akan diverifikasi ulang saat digunakan. Ini normal, bukan error.
    • FAILED: ARP request tidak dijawab. Perangkat tujuan mati, salah IP, atau beda jaringan.
    • PERMANENT: entri statis yang ditambahkan manual, tidak pernah kedaluwarsa.

    Satu eksperimen lagi yang menarik: ping IP yang tidak dipakai siapa pun, misalnya ping -c 2 192.168.1.230. Ping akan gagal dengan pesan Destination Host Unreachable, dan di ip neigh show muncul entri berstatus FAILED. Itu tanda ARP request Anda berteriak ke seluruh ruangan tapi tidak ada yang mengaku.

    Error Umum Seputar MAC Address dan ARP

    Empat masalah ini paling sering muncul saat praktik. Kami cukup sering menemui pola yang sama saat menelusuri gangguan jaringan di server klien, jadi pola diagnosisnya layak Anda hafal.

    1. Duplicate IP: Dua Perangkat Memakai IP yang Sama

    Gejala: koneksi ke satu host putus nyambung secara acak. Kadang ping jalan, kadang timeout, dan MAC address di ip neigh show untuk IP itu berubah-ubah antara dua nilai.

    Penyebab: ada dua perangkat mengklaim IP yang sama, biasanya karena satu perangkat diberi IP statis yang ternyata masih masuk rentang DHCP. Kedua perangkat sama-sama menjawab ARP request, dan jawaban yang datang terakhir yang menang.

    Solusi: deteksi dulu dengan arping dari paket iputils-arping (sudo apt install iputils-arping):

    sudo arping -D -I enp3s0 -c 3 192.168.1.50

    Opsi -D adalah mode duplicate address detection. Kalau ada perangkat lain yang menjawab, alamat itu sedang dipakai. Setelah ketahuan, ganti IP statis salah satu perangkat atau keluarkan alamat itu dari rentang DHCP di router.

    2. ARP Cache Basi: Host Terlihat Mati Padahal Nyala

    Gejala: perangkat baru saja ganti kartu jaringan, ganti IP, atau di lab virtual Anda baru clone sebuah VM. Perangkat jelas nyala dan bisa akses internet, tapi komputer Anda tetap tidak bisa mengaksesnya, ping timeout terus.

    Penyebab: ARP cache komputer Anda masih menyimpan MAC address lama untuk IP itu. Frame terus dikirim ke alamat fisik yang sudah tidak ada.

    Solusi: hapus entri yang basi, lalu biarkan ARP bertanya ulang:

    sudo ip neigh flush dev enp3s0

    Setelah flush, ping lagi. Entri baru dengan MAC yang benar akan terbentuk otomatis. Masalah yang sama juga bisa terjadi di arah sebaliknya, cache router yang basi, dan biasanya sembuh sendiri dalam hitungan menit atau setelah router di-restart.

    3. Destination Host Unreachable Saat Ping Tetangga Sendiri

    Gejala: ping ke perangkat di jaringan yang sama langsung dibalas Destination Host Unreachable oleh komputer sendiri.

    Penyebab: ini pesan kegagalan ARP. Kemungkinannya: perangkat tujuan benar-benar mati, salah ketik IP, subnet mask Anda salah sehingga komputer mengira tujuan ada di jaringan lain, atau ada isolasi client (AP isolation) di access point yang melarang antar client saling melihat.

    Solusi: cek IP dan subnet Anda dengan ip addr show, pastikan kedua perangkat satu subnet. Kalau lewat WiFi, cek pengaturan AP isolation di router. Fitur ini sering aktif diam-diam di WiFi tamu.

    4. Panik Melihat Status STALE di ip neigh

    Gejala: pemula melihat hampir semua entri berstatus STALE dan mengira jaringannya bermasalah.

    Penyebab: tidak ada masalah sama sekali. STALE hanya berarti entri belum dipakai beberapa saat. Kernel sengaja menandainya agar bisa diverifikasi ulang saat dipakai lagi, ini perilaku normal yang menghemat traffic ARP.

    Solusi: tidak perlu tindakan. Kirim traffic ke host itu dan statusnya akan kembali REACHABLE dengan sendirinya.

    Rangkuman dan Lanjut ke Bagian Berikutnya

    Tiga hal yang layak menempel di kepala dari bagian ini. Pertama, MAC address adalah identitas layer 2 yang hanya berlaku di jaringan lokal dan berganti di setiap hop router. Kedua, ARP menjembatani IP ke MAC lewat mekanisme broadcast tanya, unicast jawab, lalu disimpan di cache. Ketiga, ip link dan ip neigh show adalah dua perintah andalan untuk melihat semuanya secara langsung.

    Selama ini kita menyebut IP address berkali-kali tanpa membedahnya. Itu jatah bagian berikutnya: “Mengenal IP Address: IPv4, IP Private vs Public”. Artikelnya terbit menyusul, pantau daftar lengkapnya di halaman hub seri Belajar Jaringan Komputer.

    Referensi

  • Belajar Database dari Nol #5: INSERT, Menambah Data ke Tabel

    Belajar Database dari Nol #5: INSERT, Menambah Data ke Tabel

    Cara paling dasar untuk insert data MySQL adalah perintah INSERT INTO nama_tabel (kolom1, kolom2) VALUES (nilai1, nilai2);. Satu perintah bisa memasukkan satu baris, bisa juga puluhan baris sekaligus dengan memisahkan tiap baris pakai koma. Di MySQL 8.4, perintah ini juga yang paling sering dipakai di dunia kerja, baik lewat terminal langsung maupun lewat kode aplikasi.

    Artikel ini bagian kelima dari seri Belajar Database dari Nol. Di bagian ini kita tidak cuma belajar sintaks. Kita sekalian menyiapkan dataset studi kasus toko online berisi tabel produk, pelanggan, dan pesanan. Dataset ini akan dipakai terus sampai akhir seri, jadi kerjakan bagian ini sampai selesai supaya bagian berikutnya tinggal jalan.

    Prasyarat: MySQL 8.4 dan Database Kosong

    Kamu butuh MySQL Server 8.4 yang sudah jalan dan bisa login lewat terminal. Kalau belum, mundur dulu ke bagian pertama seri untuk instalasi. Kamu juga sebaiknya sudah paham kenapa kolom harga pakai DECIMAL dan bukan FLOAT. Itu dibahas tuntas di Belajar Database dari Nol #4: Tipe Data MySQL yang Tepat.

    Supaya semua pembaca mulai dari titik yang sama, kita buat database baru khusus studi kasus. Login dulu ke MySQL, lalu jalankan ini:

    CREATE DATABASE IF NOT EXISTS toko_online;
    USE toko_online;

    Sekarang buat tiga tabel utamanya. Relasi antar tabel dengan foreign key baru kita pasang di bagian 11, jadi untuk sekarang kolom pelanggan_id dan produk_id di tabel pesanan masih berupa angka biasa:

    CREATE TABLE produk (
      id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      nama VARCHAR(100) NOT NULL,
      kategori VARCHAR(50) NOT NULL,
      harga DECIMAL(12,2) NOT NULL,
      stok INT UNSIGNED NOT NULL DEFAULT 0
    );
    
    CREATE TABLE pelanggan (
      id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      nama VARCHAR(100) NOT NULL,
      email VARCHAR(100) NOT NULL UNIQUE,
      kota VARCHAR(50),
      terdaftar_pada DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
    );
    
    CREATE TABLE pesanan (
      id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      pelanggan_id INT UNSIGNED NOT NULL,
      produk_id INT UNSIGNED NOT NULL,
      jumlah INT UNSIGNED NOT NULL DEFAULT 1,
      total DECIMAL(12,2) NOT NULL,
      status VARCHAR(20) NOT NULL DEFAULT 'pending',
      tanggal_pesan DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
    );

    Kalau ketiga perintah sukses, tiap CREATE TABLE mengembalikan Query OK, 0 rows affected. Cek dengan SHOW TABLES; dan pastikan muncul tiga tabel: pelanggan, pesanan, produk.

    INSERT INTO Satu Baris: Bentuk Paling Dasar

    Kita mulai dari satu baris. Masukkan satu produk ke tabel produk:

    INSERT INTO produk (nama, kategori, harga, stok)
    VALUES ('Kaos Polos Hitam', 'Pakaian', 55000, 120);

    Output yang diharapkan:

    Query OK, 1 row affected (0.01 sec)

    Perhatikan strukturnya. Setelah nama tabel ada daftar kolom dalam kurung, lalu VALUES berisi nilai dengan urutan yang sama persis. Nilai teks diapit tanda kutip satu, angka ditulis polos tanpa kutip.

    Satu jebakan kecil soal tanda kutip. Kalau nilai teksnya sendiri mengandung tanda kutip satu, misalnya nama produk Kaos Sablon 'Limited', kamu harus menuliskannya dua kali berturut-turut: 'Kaos Sablon ''Limited'''. Alternatifnya pakai garis miring terbalik, 'Kaos Sablon \'Limited\''. Kalau lupa, MySQL menganggap string sudah selesai di kutip pertama dan sisanya dianggap sintaks rusak.

    Sebenarnya MySQL mengizinkan kamu melewatkan daftar kolom, seperti INSERT INTO produk VALUES (...);. Jangan dibiasakan. Tanpa daftar kolom, kamu wajib mengisi semua kolom sesuai urutan di tabel, termasuk kolom id. Begitu struktur tabel berubah, misalnya ada kolom baru, query lama langsung error. Menyebut kolom secara eksplisit membuat query tetap jalan dan lebih mudah dibaca orang lain.

    INSERT Multi Baris Sekaligus, Lebih Cepat dan Lebih Rapi

    Kalau mau memasukkan lima produk, kamu tidak perlu menulis lima perintah. Cukup satu INSERT dengan beberapa kelompok VALUES yang dipisah koma:

    INSERT INTO produk (nama, kategori, harga, stok) VALUES
    ('Kemeja Flanel Kotak', 'Pakaian', 145000, 45),
    ('Sepatu Lari Ringan', 'Sepatu', 320000, 30),
    ('Tas Ransel Laptop 15 inci', 'Tas', 210000, 25),
    ('Topi Baseball Navy', 'Aksesoris', 48000, 80),
    ('Jaket Hoodie Abu', 'Pakaian', 175000, 60);

    Output yang diharapkan:

    Query OK, 5 rows affected (0.01 sec)
    Records: 5  Duplicates: 0  Warnings: 0

    Selain lebih ringkas, cara ini jauh lebih cepat karena server hanya memproses satu perintah untuk banyak baris. Di proyek klien, tim Arrazy memakai pola multi-baris seperti ini untuk seeder, yaitu skrip pengisi data awal, baik lewat migration Laravel maupun program seeding di backend Go. Prinsipnya sama persis dengan yang kamu tulis manual di sini.

    Perilaku AUTO_INCREMENT, NULL, dan DEFAULT Saat Kolom Tidak Diisi

    Kamu mungkin sadar dari tadi kita tidak pernah mengisi kolom id. Itu bukan kelalaian. Ada tiga mekanisme yang bekerja diam-diam saat sebuah kolom tidak disebut di perintah INSERT:

    Mekanisme Kapan aktif Nilai yang diisi
    AUTO_INCREMENT Kolom bertanda AUTO_INCREMENT tidak diisi Angka berikutnya, mulai dari 1 lalu naik terus
    DEFAULT Kolom punya klausa DEFAULT Nilai default yang ditulis saat membuat tabel
    NULL Kolom boleh kosong dan tanpa default NULL, alias tidak ada nilai

    Coba buktikan dengan tabel pelanggan. Masukkan satu pelanggan tanpa menyebut id, kota, dan terdaftar_pada:

    INSERT INTO pelanggan (nama, email)
    VALUES ('Budi Santoso', 'budi@example.com');
    
    SELECT * FROM pelanggan;

    Hasilnya kira-kira begini:

    +----+--------------+------------------+------+---------------------+
    | id | nama         | email            | kota | terdaftar_pada      |
    +----+--------------+------------------+------+---------------------+
    |  1 | Budi Santoso | budi@example.com | NULL | 2026-07-27 09:15:42 |
    +----+--------------+------------------+------+---------------------+

    Tiga hal terjadi sekaligus. Kolom id terisi 1 dari AUTO_INCREMENT. Kolom kota jadi NULL karena boleh kosong dan tidak punya default. Kolom terdaftar_pada terisi waktu saat ini karena defaultnya CURRENT_TIMESTAMP.

    Kalau kamu butuh id yang baru saja dibuat, misalnya untuk dipakai di tabel lain, panggil SELECT LAST_INSERT_ID(); tepat setelah insert. Untuk memaksa kolom memakai nilai defaultnya secara eksplisit, tulis kata kunci DEFAULT di posisi nilainya, contohnya VALUES ('Sandal Jepit', 'Sepatu', 25000, DEFAULT) yang membuat stok terisi 0.

    Satu catatan penting soal NULL. NULL bukan nol dan bukan string kosong. NULL artinya nilainya memang tidak diketahui. Kolom yang ditandai NOT NULL menolak nilai ini, dan itu bagus untuk data yang wajib ada seperti nama produk atau harga.

    Mengisi Dataset Studi Kasus Toko Online

    Sekarang kita lengkapi dataset yang akan dipakai sepanjang seri. Tabel produk sudah berisi 6 baris. Tambahkan pelanggan dan pesanan berikut apa adanya, jangan diubah dulu, supaya hasil query kamu di bagian-bagian berikutnya sama dengan contoh di artikel:

    INSERT INTO pelanggan (nama, email, kota) VALUES
    ('Siti Rahayu', 'siti@example.com', 'Semarang'),
    ('Agus Wijaya', 'agus@example.com', 'Purwokerto'),
    ('Dewi Lestari', 'dewi@example.com', 'Banjarnegara'),
    ('Rudi Hartono', 'rudi@example.com', NULL);
    
    INSERT INTO pesanan (pelanggan_id, produk_id, jumlah, total, status) VALUES
    (1, 1, 2, 110000, 'selesai'),
    (1, 4, 1, 48000, 'selesai'),
    (2, 3, 1, 320000, 'dikirim'),
    (3, 6, 1, 175000, 'pending'),
    (4, 2, 2, 290000, 'selesai'),
    (5, 5, 3, 630000, 'dibatalkan');

    Perhatikan baris Rudi Hartono. Kita sengaja menulis NULL secara eksplisit di kolom kota. Ini sah karena kolom kota memang boleh kosong, dan nanti berguna saat belajar filter IS NULL. Di tabel pesanan, kolom tanggal_pesan kita biarkan terisi otomatis oleh default CURRENT_TIMESTAMP.

    Total dataset kamu sekarang: 6 produk, 5 pelanggan, dan 6 pesanan. Struktur toko online sederhana seperti ini juga jadi fondasi sistem yang lebih besar. Pola tabel produk, pelanggan, dan pesanan yang sama kami pakai saat membangun sistem aplikasi untuk klien, tentu dengan kolom dan relasi yang jauh lebih banyak.

    Cek Hasil dengan SELECT * Sebelum Lanjut

    Biasakan langsung memeriksa hasil setiap selesai memasukkan data. Cara tercepatnya adalah SELECT *, yang artinya tampilkan semua kolom:

    SELECT * FROM produk;
    SELECT * FROM pelanggan;
    SELECT * FROM pesanan;

    Untuk tabel produk, hasilnya harus 6 baris dengan id berurutan 1 sampai 6:

    +----+---------------------------+-----------+-----------+------+
    | id | nama                      | kategori  | harga     | stok |
    +----+---------------------------+-----------+-----------+------+
    |  1 | Kaos Polos Hitam          | Pakaian   |  55000.00 |  120 |
    |  2 | Kemeja Flanel Kotak       | Pakaian   | 145000.00 |   45 |
    |  3 | Sepatu Lari Ringan        | Sepatu    | 320000.00 |   30 |
    |  4 | Tas Ransel Laptop 15 inci | Tas       | 210000.00 |   25 |
    |  5 | Topi Baseball Navy        | Aksesoris |  48000.00 |   80 |
    |  6 | Jaket Hoodie Abu          | Pakaian   | 175000.00 |   60 |
    +----+---------------------------+-----------+-----------+------+
    6 rows in set (0.00 sec)

    Kalau jumlah barisnya tidak cocok, hapus isi tabel dengan TRUNCATE TABLE nama_tabel; lalu ulangi insert dari awal. TRUNCATE mengosongkan tabel sekaligus mereset hitungan AUTO_INCREMENT kembali ke 1, jadi id kamu kembali rapi. Untuk sekarang cukup SELECT * saja dulu. Menyaring baris tertentu dengan kondisi adalah materi bagian berikutnya.

    Kebiasaan cek cepat seperti ini terlihat sepele, tapi menyelamatkan banyak waktu. Salah ketik satu angka di kolom total jauh lebih murah ditemukan sekarang, saat datanya masih 6 baris, daripada nanti saat kamu bingung kenapa hasil agregasi di bagian 10 tidak masuk akal. Data yang salah sejak insert akan salah terus di semua query di atasnya.

    Troubleshooting: Error INSERT yang Paling Sering Muncul

    ERROR 1062: Duplicate entry for key PRIMARY

    ERROR 1062 (23000): Duplicate entry '1' for key 'produk.PRIMARY'

    Penyebab: kamu memasukkan nilai id yang sudah dipakai baris lain, biasanya karena mengisi kolom AUTO_INCREMENT secara manual. Primary key harus unik, jadi MySQL menolak. Error yang sama muncul di kolom UNIQUE, misalnya memasukkan email pelanggan yang sudah terdaftar. Solusi: jangan isi kolom id sama sekali, biarkan AUTO_INCREMENT bekerja. Untuk email duplikat, cek dulu datanya atau pakai email lain.

    ERROR 1406: Data too long for column

    ERROR 1406 (22001): Data too long for column 'status' at row 1

    Penyebab: nilai yang dimasukkan melebihi kapasitas kolom. Kolom status kita bertipe VARCHAR(20), jadi teks 21 karakter atau lebih ditolak. Di MySQL 8.4 dengan pengaturan bawaan (strict mode aktif), data tidak dipotong diam-diam tapi langsung ditolak, dan itu perilaku yang benar. Solusi: perpendek nilainya, atau kalau kebutuhan datanya memang lebih panjang, ubah kolom dengan ALTER TABLE pesanan MODIFY status VARCHAR(50) NOT NULL DEFAULT 'pending';.

    ERROR 1364: Field doesn’t have a default value

    ERROR 1364 (HY000): Field 'harga' doesn't have a default value

    Penyebab: ada kolom NOT NULL tanpa default yang tidak kamu sebut di daftar kolom. MySQL tidak tahu harus mengisi apa, jadi menolak. Solusi: sertakan kolom itu di perintah insert beserta nilainya. Ini alasan lain kenapa daftar kolom eksplisit itu penting, kamu langsung tahu kolom mana yang wajib diisi.

    ERROR 1136: Column count doesn’t match value count

    ERROR 1136 (21S01): Column count doesn't match value count at row 1

    Penyebab: jumlah nilai di VALUES tidak sama dengan jumlah kolom yang disebut. Biasanya gara-gara lupa satu nilai atau kelebihan koma saat menulis insert multi-baris yang panjang. Solusi: hitung ulang pasangan kolom dan nilai. Pesan errornya menyebut nomor baris yang bermasalah, mulai cek dari situ.

    Lanjut ke Bagian Berikutnya

    Dataset toko online kamu sekarang sudah terisi dan siap dipakai. Kamu sudah bisa insert data MySQL satu baris maupun banyak baris, paham apa yang terjadi pada kolom yang tidak diisi, dan tahu cara membaca error yang paling sering muncul.

    Data yang cuma bisa dimasukkan tapi tidak bisa dicari tentu belum berguna. Di bagian berikutnya, Belajar Database dari Nol #6: SELECT & WHERE, Query Dasar, kita mulai menggali data ini dengan query yang sesungguhnya, dari memilih kolom tertentu sampai menyaring baris dengan berbagai kondisi. Artikelnya terbit menyusul, pantau daftar lengkapnya di halaman hub Belajar Database.

    Referensi

  • Cara Masuk ke Container: docker exec, logs, dan Debugging

    Cara Masuk ke Container: docker exec, logs, dan Debugging

    Cara masuk ke container yang sedang jalan adalah dengan perintah docker exec -it nama_container bash. Kalau image-nya berbasis Alpine dan tidak punya bash, ganti dengan docker exec -it nama_container sh. Setelah itu kamu berada di dalam shell container, bisa lihat file, cek proses, dan edit konfigurasi seperti di server biasa.

    Artikel ini bagian kelima dari seri Belajar Docker dari Nol. Di sini kita bahas docker exec masuk container secara praktik: masuk ke container nginx, cari dan edit file konfigurasinya, lalu reload tanpa restart. Kita juga bahas cara memantau aplikasi lewat docker logs dan docker stats, mengambil file dengan docker cp, plus error klasik bash not found yang hampir pasti kamu temui.

    Di proyek klien, tim Arrazy hampir setiap hari memakai kombinasi exec dan logs ini untuk debug backend Go dan Laravel yang jalan di container. Ini skill dasar yang kepakai terus sampai level production.

    Prasyarat Sebelum Mulai

    Tutorial ini memakai Docker Engine 27.x di Ubuntu 24.04. Perintahnya sama persis di Windows dan macOS yang memakai Docker Desktop. Cek versimu dulu:

    docker --version

    Output yang diharapkan kurang lebih seperti ini:

    Docker version 27.5.1, build 9f9e405

    Kamu juga perlu paham konsep menjalankan container di background dan port mapping. Kalau belum, baca dulu bagian sebelumnya: Docker Port Mapping, Mode Detach, dan Environment Variable.

    docker exec -it: Masuk ke Shell Container yang Sedang Jalan

    Perintah docker exec menjalankan perintah tambahan di dalam container yang sudah berjalan. Formatnya:

    docker exec [opsi] nama_container perintah

    Dua opsi yang hampir selalu dipakai bersamaan:

    • -i (interactive): menjaga input tetap terbuka, jadi apa yang kamu ketik diteruskan ke container
    • -t (tty): memberi terminal virtual, jadi tampilannya seperti terminal normal lengkap dengan prompt

    Digabung jadi -it. Tanpa keduanya, shell memang jalan tapi kamu tidak bisa mengetik apa-apa. Contoh paling umum:

    docker exec -it web bash

    Perintah ini membuka shell bash di dalam container bernama web. Untuk keluar, ketik exit atau tekan Ctrl+D. Container tetap jalan setelah kamu keluar, karena yang berhenti hanya proses bash yang tadi kamu buka, bukan proses utama container.

    Bedanya docker exec dan docker run

    Pemula sering menukar dua perintah ini. Padahal fungsinya beda jauh:

    Aspek docker run docker exec
    Fungsi Membuat container baru dari image Menjalankan perintah di container yang sudah jalan
    Butuh apa Image Container berstatus running
    Efek Muncul container baru di docker ps Tidak ada container baru
    Contoh kasus Start nginx pertama kali Cek isi file config nginx yang sedang jalan

    Jadi kalau kamu menjalankan docker run -it nginx bash dengan niat “masuk ke nginx yang tadi”, yang terjadi justru Docker membuat container nginx kedua yang terpisah total. File yang kamu ubah di situ tidak akan berpengaruh ke container pertama. Ini sumber kebingungan yang sangat sering terjadi.

    Praktik: Masuk ke Container Nginx dan Edit Konfigurasinya

    Biar nempel, kita praktik langsung. Jalankan nginx versi 1.27 di background dengan port mapping:

    docker run -d --name web -p 8080:80 nginx:1.27

    Pastikan jalan:

    docker ps

    Kolom STATUS harus menunjukkan Up. Sekarang masuk ke dalamnya:

    docker exec -it web bash

    Prompt kamu berubah jadi seperti ini, tanda kamu sudah di dalam container:

    root@3f2a1b9c7d5e:/#

    Mencari file konfigurasi nginx

    Konfigurasi utama nginx ada di /etc/nginx/nginx.conf, dan konfigurasi per situs ada di /etc/nginx/conf.d/. Cek isinya:

    cat /etc/nginx/conf.d/default.conf

    Kamu akan lihat blok server yang listen di port 80 dan menyajikan file dari /usr/share/nginx/html.

    Edit konfigurasi tanpa editor

    Image nginx resmi itu ramping, tidak ada nano atau vim di dalamnya. Tapi ada sed, jadi kita bisa edit file langsung dari command line. Kita tambahkan header custom supaya perubahannya gampang dibuktikan:

    sed -i 's|listen       80;|listen       80;\n    add_header X-Diedit-Dari "dalam-container";|' /etc/nginx/conf.d/default.conf

    Validasi dulu sebelum reload. Ini kebiasaan wajib biar nginx tidak mati gara-gara typo:

    nginx -t

    Output yang diharapkan:

    nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
    nginx: configuration file /etc/nginx/nginx.conf test is successful

    Kalau sudah ok, reload nginx tanpa restart container:

    nginx -s reload

    Keluar dari container dengan exit, lalu tes dari mesin host:

    curl -I http://localhost:8080

    Di antara header respons, kamu akan melihat baris ini:

    X-Diedit-Dari: dalam-container

    Berhasil. Kamu baru saja masuk ke container yang sedang jalan, mengubah konfigurasinya, dan me-reload service tanpa downtime.

    Tidak harus buka shell dulu

    Untuk perintah sekali jalan, kamu tidak perlu masuk shell. Langsung saja:

    docker exec web nginx -t
    docker exec web cat /etc/nginx/conf.d/default.conf

    Pola ini enak dipakai di script atau saat cuma butuh satu informasi cepat.

    Memantau Aplikasi: docker logs, docker stats, dan docker cp

    Masuk ke container itu satu hal. Tapi sebagian besar sesi debugging justru dimulai dari membaca log dari luar.

    docker logs -f –tail: membaca log real time

    Container yang baik menulis log ke stdout dan stderr, dan Docker menangkap semuanya. Lihat log container nginx tadi:

    docker logs web

    Dua opsi yang paling sering dipakai:

    docker logs -f --tail 50 web
    • --tail 50: hanya tampilkan 50 baris terakhir, bukan seluruh riwayat log yang bisa ribuan baris
    • -f (follow): terus menampilkan log baru secara real time, seperti tail -f di Linux

    Coba buka http://localhost:8080 di browser sambil docker logs -f jalan. Setiap request langsung muncul sebagai baris access log baru. Tekan Ctrl+C untuk berhenti mengikuti log. Ini tidak mematikan container, hanya berhenti menonton.

    docker stats: cek pemakaian CPU dan memori

    Aplikasi lambat atau server penuh? Cek konsumsi resource semua container secara live:

    docker stats

    Outputnya kurang lebih:

    CONTAINER ID   NAME   CPU %   MEM USAGE / LIMIT     MEM %   NET I/O       BLOCK I/O   PIDS
    3f2a1b9c7d5e   web    0.00%   4.2MiB / 7.6GiB       0.05%   1.2kB/850B    0B/0B       5

    Dari sini kelihatan container mana yang rakus memori atau CPU. Tekan Ctrl+C untuk keluar. Kalau cuma mau snapshot sekali tanpa tampilan live, pakai docker stats --no-stream.

    docker cp: ambil file dari dalam container

    Kadang kamu butuh menyalin file dari container ke laptop, misalnya file konfigurasi atau log aplikasi yang ditulis ke file. Formatnya docker cp sumber tujuan:

    docker cp web:/etc/nginx/conf.d/default.conf ./default.conf

    File config tadi sekarang ada di folder kerjamu di host. Arah sebaliknya juga bisa, dari host ke container:

    docker cp ./default.conf web:/etc/nginx/conf.d/default.conf

    Setelah menyalin config baru ke dalam, jangan lupa docker exec web nginx -s reload supaya perubahan terbaca.

    Kapan docker exec Cocok untuk Debug, dan Apa Batasnya

    Gunakan docker exec untuk hal-hal yang sifatnya memeriksa dan bereksperimen: cek apakah file environment terbaca, tes koneksi ke database dari dalam container, lihat isi folder, atau coba ubah satu nilai config untuk membuktikan hipotesis. Cepat, tanpa rebuild, tanpa restart.

    Tapi ada satu hal penting yang wajib kamu pahami: semua perubahan lewat exec itu sementara. Perubahan tersimpan di writable layer milik container itu saja, bukan di image. Begitu container dihapus dan dibuat ulang dari image yang sama, misalnya lewat docker rm lalu docker run lagi, semua editanmu hilang dan kondisi kembali seperti bawaan image.

    Jadi alurnya yang sehat seperti ini:

    1. Pakai docker exec untuk menemukan akar masalah dan menguji perbaikan
    2. Setelah terbukti, pindahkan perbaikan itu ke tempat yang permanen: Dockerfile, file yang di-mount lewat volume, atau environment variable
    3. Rebuild atau recreate container, lalu verifikasi ulang

    Di tim Arrazy, aturan mainnya sama saat membangun sistem aplikasi untuk klien: exec hanya untuk investigasi, perbaikan permanen selalu masuk ke Dockerfile atau konfigurasi yang diversion-control. Kalau perbaikan cuma hidup di dalam container, satu kali server restart bisa mengembalikan bug yang sama dan tidak ada yang tahu kenapa.

    Troubleshooting: Error yang Sering Dialami Pemula

    OCI runtime exec failed: bash not found

    Kamu menjalankan docker exec -it nama_container bash lalu muncul:

    OCI runtime exec failed: exec failed: unable to start container process:
    exec: "bash": executable file not found in $PATH: unknown

    Penyebab: image-nya berbasis Alpine Linux, misalnya nginx:1.27-alpine atau redis:7-alpine. Alpine tidak menyertakan bash, adanya sh. Solusinya ganti perintahnya:

    docker exec -it nama_container sh

    Fungsinya sama saja untuk keperluan debug. Kalau ragu image apa yang dipakai container, cek dengan docker ps di kolom IMAGE.

    Container is not running

    Error response from daemon: container abc123 is not running

    Penyebab: docker exec hanya bisa dipakai ke container yang statusnya running. Kalau container-nya sudah exit, tidak ada proses yang bisa ditumpangi. Cek statusnya dengan docker ps -a. Kalau statusnya Exited, lihat dulu kenapa dia mati lewat docker logs nama_container, perbaiki penyebabnya, lalu start ulang dengan docker start nama_container sebelum exec.

    The input device is not a TTY

    the input device is not a TTY

    Penyebab paling umum: menjalankan docker exec -it dari Git Bash atau MinTTY di Windows, atau dari dalam script otomatis yang tidak punya terminal interaktif. Solusi di Git Bash: awali perintah dengan winpty, jadi winpty docker exec -it web bash. Kalau di dalam script atau cron, buang opsi -t dan jalankan perintah non-interaktif saja, misalnya docker exec web nginx -t.

    docker logs kosong padahal aplikasi jalan

    Kamu menjalankan docker logs tapi tidak ada output sama sekali. Penyebab yang paling sering: aplikasinya menulis log ke file di dalam container, bukan ke stdout/stderr. Docker hanya menangkap stdout dan stderr dari proses utama. Solusinya ada dua: konfigurasi aplikasi supaya log ke stdout (praktik standar untuk container), atau ambil file lognya dengan docker exec -it nama_container sh lalu cat, atau salin keluar pakai docker cp.

    Rangkuman dan Lanjut ke Bagian Berikutnya

    Sekarang kamu punya empat senjata utama untuk berurusan dengan container yang sedang jalan: docker exec -it untuk masuk ke shell, docker logs -f --tail untuk membaca log real time, docker stats untuk memantau resource, dan docker cp untuk memindahkan file. Kamu juga sudah paham kenapa perubahan lewat exec hilang setelah container dibuat ulang, dan kapan harus memindahkan perbaikan ke Dockerfile.

    Bagian berikutnya dari seri ini membahas “Mengelola Docker Image: Pull, Tag, dan Docker Hub”. Di sana kita bedah cara kerja tag image, kenapa latest itu jebakan, dan cara menyimpan image buatanmu sendiri. Artikelnya terbit menyusul, pantau daftarnya di halaman hub Belajar Docker.

    Referensi

  • Belajar Golang dari Nol #27: Validasi Input, Jangan Percaya Data Luar

    Belajar Golang dari Nol #27: Validasi Input, Jangan Percaya Data Luar

    Di Belajar Golang dari Nol #26 kita sudah mengatur siapa boleh melakukan apa lewat role dan otorisasi. Server kita sekarang tahu membedakan admin dan kasir. Tapi ada satu lubang yang belum kita tutup rapat. Bagaimana kalau data yang dikirim user itu sendiri yang bermasalah? Email tanpa tanda @. Password satu huruf. Field harga yang tidak pernah dikirim tapi diam-diam terbaca nol. Di bagian ini kita bereskan semuanya secara sistematis. Kalau kamu baru bergabung, mulai dari daftar lengkap seri dulu supaya tidak ada yang terlewat.

    Semua Data dari Luar Itu Tersangka

    Ini prinsip pertama dan paling penting. Server kamu tidak boleh percaya pada apa pun yang datang dari luar. Body JSON bisa diisi field aneh. Query param bisa berisi angka negatif. Path param bisa berisi string padahal kamu berharap angka. Header bisa dipalsukan. File upload bisa menyamar, seperti yang sudah kita bahas waktu memvalidasi file di bagian 20.

    Kenapa harus separanoid itu? Karena request ke API kamu tidak selalu datang dari frontend yang kamu buat. Siapa pun bisa membuka terminal, mengetik perintah curl, dan mengirim apa saja ke endpoint kamu. Validasi di sisi frontend itu bagus untuk pengalaman user, tapi dari sudut pandang server, validasi frontend itu tidak ada. Anggap saja tidak pernah terjadi.

    Prinsip kedua: validasi dilakukan di tepi, bukan berserakan. Maksudnya, begitu data masuk lewat handler, langsung periksa di situ. Jangan biarkan data mentah menyebar ke service, repository, lalu baru ketahuan bermasalah saat sudah setengah jalan diproses. Selama seri ini kita sudah beberapa kali menulis validasi kecil di sana-sini. Sekarang kita rapikan jadi satu pola yang konsisten.

    Jebakan Decode JSON yang Diam-Diam

    Decoder JSON bawaan Go itu sopan. Terlalu sopan, malah. Dia tidak protes kalau ada field yang salah ketik. Coba lihat struct ini.

    type ProdukRequest struct {
    	Nama  string `json:"nama"`
    	Harga int    `json:"harga"`
    }

    Sekarang bayangkan frontend mengirim body seperti ini, dengan typo di field harga.

    {"nama": "Kopi Susu", "hrga": 15000}

    Decode berhasil tanpa error. Field hrga diabaikan begitu saja, dan Harga terisi nol karena itu zero value untuk int. Masih ingat konsep zero value dari bagian 5? Di sinilah dia menggigit balik. Produk seharga lima belas ribu tersimpan sebagai produk gratis, dan tidak ada satu pun error yang muncul.

    Masalah kedua lebih halus lagi. Kalau Harga bernilai nol, kamu tidak bisa membedakan dua kemungkinan: apakah user memang mengirim nol karena produknya gratis, atau field itu tidak dikirim sama sekali? Keduanya menghasilkan nilai yang sama persis.

    Ada tiga senjata untuk menutup jebakan ini. Pertama, DisallowUnknownFields supaya field yang tidak dikenal langsung ditolak. Kedua, pointer untuk membedakan kosong dan nol. Ketiga, batas ukuran body supaya orang tidak bisa mengirim payload raksasa, sama semangatnya dengan batas ukuran file upload di bagian 20.

    type ProdukRequest struct {
    	Nama  string `json:"nama"`
    	Harga *int   `json:"harga"`
    }
    
    func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
    	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // maksimal 1 MB
    	dec := json.NewDecoder(r.Body)
    	dec.DisallowUnknownFields()
    	return dec.Decode(dst)
    }

    Dengan Harga bertipe *int, ceritanya jadi jelas. Kalau field tidak dikirim, nilainya nil. Kalau dikirim nol, nilainya pointer ke angka nol. Dua kondisi yang tadinya menyamar jadi satu, sekarang bisa dibedakan. Dan karena decodeJSON ini kita jadikan helper, semua handler memakai aturan yang sama tanpa harus mengulang kode.

    Validasi Manual yang Rapi

    Setelah decode berhasil, giliran memeriksa isinya. Pola yang enak dipakai: setiap struct request punya method Validate yang mengembalikan daftar error per field. Bukan satu error gabungan, tapi map yang memetakan nama field ke pesan kesalahannya.

    import (
    	"net/mail"
    	"strings"
    )
    
    type RegisterRequest struct {
    	Nama     string `json:"nama"`
    	Email    string `json:"email"`
    	Password string `json:"password"`
    }
    
    func (req RegisterRequest) Validate() map[string]string {
    	errs := map[string]string{}
    
    	if strings.TrimSpace(req.Nama) == "" {
    		errs["nama"] = "nama wajib diisi"
    	}
    	if _, err := mail.ParseAddress(req.Email); err != nil {
    		errs["email"] = "format email tidak valid"
    	}
    	if len(req.Password) < 8 {
    		errs["password"] = "password minimal 8 karakter"
    	}
    
    	if len(errs) > 0 {
    		return errs
    	}
    	return nil
    }

    Kenapa map, bukan error biasa? Karena frontend butuh tahu field mana yang salah supaya bisa menampilkan pesan tepat di bawah kolom isian yang bermasalah. Balasannya kita bungkus dalam JSON dengan status 400.

    func kirimErrorValidasi(w http.ResponseWriter, errs map[string]string) {
    	w.Header().Set("Content-Type", "application/json")
    	w.WriteHeader(http.StatusBadRequest)
    	json.NewEncoder(w).Encode(map[string]any{"errors": errs})
    }

    Hasilnya seperti ini. Satu request, semua kesalahan dilaporkan sekaligus, bukan satu per satu.

    {
      "errors": {
        "email": "format email tidak valid",
        "password": "password minimal 8 karakter"
      }
    }

    Bandingkan dengan API yang hanya membalas “input tidak valid” tanpa penjelasan. User mengisi ulang form sambil menebak-nebak salahnya di mana. Format per field ini kecil usahanya, besar dampaknya.

    go-playground/validator, Jalan Singkat yang Populer

    Menulis validasi manual untuk tiga field itu ringan. Tapi kalau aplikasimu punya dua puluh struct request dengan aturan mirip-mirip, kode Validate mulai terasa berulang. Di sinilah library go-playground/validator membantu. Aturan ditulis sebagai tag di struct.

    import "github.com/go-playground/validator/v10"
    
    type ProdukRequest struct {
    	Nama  string `json:"nama" validate:"required,min=3,max=100"`
    	Email string `json:"email" validate:"required,email"`
    	Stok  int    `json:"stok" validate:"gte=0"`
    }
    
    var validate = validator.New()

    Tag required menolak nilai kosong, email memeriksa format, min dan max membatasi panjang, gte=0 memastikan angka tidak negatif. Satu baris tag menggantikan beberapa baris if.

    Masalahnya, pesan error bawaannya berbahasa mesin. Sesuatu seperti “Field validation for ‘Email’ failed on the ’email’ tag” jelas tidak pantas ditampilkan ke user. Kita terjemahkan sendiri jadi bahasa manusia, sekaligus dipetakan per field seperti pola sebelumnya.

    func pesanValidasi(err error) map[string]string {
    	errs := map[string]string{}
    	for _, e := range err.(validator.ValidationErrors) {
    		field := strings.ToLower(e.Field())
    		switch e.Tag() {
    		case "required":
    			errs[field] = field + " wajib diisi"
    		case "email":
    			errs[field] = "format email tidak valid"
    		case "min":
    			errs[field] = field + " minimal " + e.Param() + " karakter"
    		case "gte":
    			errs[field] = field + " tidak boleh kurang dari " + e.Param()
    		default:
    			errs[field] = field + " tidak valid"
    		}
    	}
    	return errs
    }

    Lalu kapan cukup manual dan kapan pakai library? Jujur saja: untuk aplikasi kecil dengan sedikit endpoint, validasi manual lebih mudah dibaca dan tidak menambah dependensi. Library mulai terasa manfaatnya saat jumlah struct request banyak dan aturannya standar. Yang penting bukan pilihan alatnya, tapi konsistensinya. Pilih satu pola, pakai di semua endpoint.

    Bentuk Data vs Aturan Bisnis

    Ada dua jenis validasi yang sering dicampur padahal tempatnya beda. Yang pertama validasi bentuk: apakah email formatnya benar, apakah password cukup panjang, apakah stok berupa angka. Ini urusan handler, karena tidak butuh melihat database atau kondisi sistem.

    Yang kedua validasi aturan bisnis: apakah stok masih cukup kalau dikurangi jumlah pesanan, apakah email ini sudah dipakai akun lain, apakah user ini boleh menghapus data itu. Aturan seperti ini butuh konteks, biasanya butuh query ke database, dan tempatnya di layer service, sesuai pembagian tanggung jawab yang kita susun di bagian 16 dan kita praktikkan lagi di bagian 23.

    Contoh konkret. “Stok tidak boleh minus” terdengar seperti validasi input, tapi sebenarnya bukan. Angka 5 itu bentuknya valid. Baru setelah service mengecek stok tersisa 3, ketahuan bahwa mengurangi 5 akan membuat stok minus. Handler tidak mungkin tahu itu. Jadi jangan paksakan semua pemeriksaan masuk ke Validate. Bentuk di handler, aturan bisnis di service. Pembagian ini membuat masing-masing lapisan tetap sederhana.

    Sanitasi: Bereskan Saat Keluar, Bukan Saat Masuk

    Sekarang topik yang sering disalahpahami. Banyak pemula berpikir input berbahaya harus dibersihkan sebelum disimpan, misalnya menghapus semua tag HTML dari nama user. Pendekatan yang lebih sehat justru sebaliknya: simpan apa adanya, lalu escape saat menampilkan.

    Sebentar, apa itu XSS? Bayangkan ada user jahil yang mengisi nama dengan potongan kode JavaScript. Kalau aplikasimu menampilkan nama itu di halaman web mentah-mentah, browser pengunjung lain akan menjalankan kode tersebut seolah bagian sah dari situsmu. Kode itu bisa mencuri sesi, membajak akun, atau mengarahkan korban ke situs palsu. Itulah XSS, singkatan dari cross-site scripting: skrip orang lain yang menumpang lewat data.

    Kabar baiknya, html/template yang kita pakai sejak bagian 21 sudah melakukan escaping otomatis. Setiap nilai yang dirender lewat template itu diubah jadi teks aman, sehingga kode jahat tampil sebagai tulisan biasa, bukan dieksekusi. Ini alasan yang sama kenapa kita pakai placeholder untuk SQL di bagian 12: biarkan alat yang tepat menangani escaping, jangan tempel string sendiri.

    Lalu kenapa API yang hanya membalas JSON tetap perlu peduli? Karena browser kadang sok tahu. Kalau response tidak diberi Content-Type yang jelas, sebagian browser mencoba menebak jenis kontennya, dan tebakan itu bisa berujung pada JSON yang diperlakukan sebagai HTML. Pastikan setiap response JSON menyertakan header Content-Type: application/json, seperti yang sudah kita lakukan di helper tadi. Murah, satu baris, dan menutup satu pintu serangan.

    Normalisasi, Validasi Kecil yang Mencegah Bug Besar

    Ada satu langkah lagi sebelum data disimpan: normalisasi. Ini bukan soal menolak data, tapi merapikan bentuknya supaya konsisten. Tiga contoh yang paling sering menyelamatkan saya.

    Pertama, strings.TrimSpace untuk semua input teks. Spasi tak sengaja di akhir nama atau email itu sangat umum, apalagi dari keyboard HP yang suka menambah spasi setelah autocomplete.

    Kedua, email di-lowercase sebelum dicek unik. Waktu membangun register di bagian 25, kita menyimpan email sebagai identitas login. Kalau Budi@contoh.com dan budi@contoh.com dianggap dua akun berbeda, user akan bingung kenapa tidak bisa login padahal merasa sudah daftar. Normalkan dulu, baru bandingkan.

    Ketiga, nomor HP dinormalkan ke awalan 62. User Indonesia menulis nomor dengan segala gaya: 0812, +62812, 62812, kadang pakai spasi atau strip. Kalau disimpan apa adanya, fitur kirim WhatsApp atau pencarian nomor akan kacau.

    func normalisasiHP(hp string) string {
    	hp = strings.TrimSpace(hp)
    	hp = strings.ReplaceAll(hp, " ", "")
    	hp = strings.ReplaceAll(hp, "-", "")
    
    	switch {
    	case strings.HasPrefix(hp, "+62"):
    		return hp[1:]
    	case strings.HasPrefix(hp, "0"):
    		return "62" + hp[1:]
    	}
    	return hp
    }

    Letakkan normalisasi tepat sebelum validasi. Urutannya: decode, normalisasi, validasi bentuk, baru serahkan ke service. Dengan begitu validasi bekerja pada data yang sudah rapi.

    Latihan: Memperkuat Endpoint Register

    Sekarang kita gabungkan semuanya ke endpoint register yang kita buat di bagian 25. Dulu validasinya masih seadanya. Versi barunya membatasi body, menolak field asing, menormalkan input, lalu melaporkan error per field.

    func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
    	var req RegisterRequest
    	if err := decodeJSON(w, r, &req); err != nil {
    		kirimErrorValidasi(w, map[string]string{
    			"body": "format JSON tidak valid atau ada field yang tidak dikenal",
    		})
    		return
    	}
    
    	// normalisasi dulu, baru validasi
    	req.Nama = strings.TrimSpace(req.Nama)
    	req.Email = strings.ToLower(strings.TrimSpace(req.Email))
    
    	if errs := req.Validate(); errs != nil {
    		kirimErrorValidasi(w, errs)
    		return
    	}
    
    	user, err := h.service.Register(r.Context(), req)
    	if err != nil {
    		// aturan bisnis, misalnya email sudah terdaftar,
    		// ditangani service seperti di bagian 25
    		kirimErrorValidasi(w, map[string]string{"email": err.Error()})
    		return
    	}
    
    	w.Header().Set("Content-Type", "application/json")
    	w.WriteHeader(http.StatusCreated)
    	json.NewEncoder(w).Encode(user)
    }

    Uji dengan curl. Pertama, request yang benar.

    curl -X POST http://localhost:8080/register \
      -H "Content-Type: application/json" \
      -d '{"nama":"Budi Santoso","email":"Budi@contoh.com","password":"rahasia123"}'

    Server membalas 201, dan email tersimpan sebagai budi@contoh.com berkat normalisasi. Sekarang tiga variasi gagal. Password terlalu pendek dan nama kosong sekaligus.

    curl -X POST http://localhost:8080/register \
      -H "Content-Type: application/json" \
      -d '{"nama":"  ","email":"budi@contoh.com","password":"123"}'

    Balasan 400 dengan dua error sekaligus: nama wajib diisi, password minimal 8 karakter. Perhatikan nama berisi spasi saja tetap tertangkap karena TrimSpace berjalan sebelum validasi. Berikutnya, email rusak.

    curl -X POST http://localhost:8080/register \
      -H "Content-Type: application/json" \
      -d '{"nama":"Budi","email":"bukan-email","password":"rahasia123"}'

    Balasan 400 dengan pesan format email tidak valid. Terakhir, field yang salah ketik.

    curl -X POST http://localhost:8080/register \
      -H "Content-Type: application/json" \
      -d '{"nama":"Budi","emial":"budi@contoh.com","password":"rahasia123"}'

    Tanpa DisallowUnknownFields, typo emial akan lolos diam-diam dan email tersimpan kosong. Sekarang decoder langsung menolak, dan frontend tahu ada yang salah sejak detik pertama, bukan setelah data aneh masuk database.

    Penutup

    Hari ini kita menutup satu lubang besar dengan pola yang konsisten: semua data dari luar itu tersangka sampai terbukti valid. Decode dengan ketat, normalisasi, validasi bentuk di handler, aturan bisnis di service, escape saat menampilkan. Endpoint register kita sekarang jauh lebih tahan banting menghadapi input liar.

    Backend kita makin lengkap, tapi ada satu masalah klasik yang belum tersentuh: frontend sering harus menebak bentuk request dan response API kita. Di bagian 28 kita bahas “Dokumentasi API dengan OpenAPI: Biar Frontend Tidak Menebak”.

    Kalau kamu sedang membangun aplikasi untuk bisnismu dan ingin backend yang aman menangani input user sejak hari pertama, tim kami bisa membantu lewat jasa pengembangan sistem aplikasi. Sampai jumpa di bagian berikutnya.

  • Belajar Linux dari Nol #5: Cara Copy dan Hapus File di Linux

    Belajar Linux dari Nol #5: Cara Copy dan Hapus File di Linux

    Cara copy file di Linux lewat terminal cukup satu perintah: cp namafile tujuan. Untuk memindahkan atau mengganti nama file pakai mv, dan untuk menghapus pakai rm. Tiga perintah itu, ditambah mkdir untuk membuat folder dan touch untuk membuat file kosong, sudah cukup untuk mengelola file sehari-hari tanpa menyentuh file manager sama sekali.

    Artikel ini bagian kelima dari seri Belajar Linux dari Nol. Di bagian ini kita praktik semua perintah pengelolaan file dan folder, termasuk hal yang sering bikin pemula celaka: kenapa rm -rf berbahaya dan kenapa file yang dihapus di terminal tidak masuk recycle bin.

    Prasyaratnya ringan. Kamu sudah bisa berpindah folder dengan cd dan melihat isi folder dengan ls. Kalau masih ragu soal posisi folder seperti /home atau /etc, baca dulu Belajar Linux dari Nol #4: Struktur Direktori Linux Lengkap. Semua contoh di sini dites di Ubuntu 24.04 dengan GNU coreutils 9.4, tapi perintahnya sama di hampir semua distro Linux.

    Persiapan: Membuat Folder dan File dengan mkdir dan touch

    Kita mulai dari area latihan yang aman di home directory. Buat satu folder khusus supaya kalau ada salah hapus, tidak ada file penting yang ikut hilang.

    cd ~
    mkdir latihan-file
    cd latihan-file

    mkdir singkatan dari make directory. Kalau mau membuat beberapa folder bertingkat sekaligus, tambahkan opsi -p:

    mkdir -p proyek/catatan/harian

    Tanpa -p, perintah di atas gagal karena folder proyek dan catatan belum ada. Dengan -p, semua folder di jalurnya dibuat sekaligus.

    Sekarang buat beberapa file kosong dengan touch:

    touch catatan.txt tugas.txt data.csv

    Cek hasilnya dengan ls:

    ls

    Output yang diharapkan:

    catatan.txt  data.csv  proyek  tugas.txt

    Fungsi asli touch sebenarnya memperbarui timestamp file. Tapi karena dia otomatis membuat file baru kalau filenya belum ada, perintah ini jadi cara tercepat membuat file kosong untuk latihan atau placeholder.

    Cara Copy File di Linux dengan Perintah cp

    Format dasarnya: cp sumber tujuan. Tujuan bisa berupa nama file baru atau nama folder.

    cp catatan.txt catatan-backup.txt
    cp tugas.txt proyek/

    Baris pertama menyalin catatan.txt menjadi file baru bernama catatan-backup.txt di folder yang sama. Baris kedua menyalin tugas.txt ke dalam folder proyek dengan nama yang sama. Perhatikan garis miring setelah proyek. Tidak wajib, tapi kebiasaan bagus karena menegaskan bahwa tujuannya folder, bukan nama file.

    Cek dengan ls proyek, harusnya muncul:

    catatan  tugas.txt

    Copy Folder Harus Pakai cp -r

    Kalau kamu coba menyalin folder dengan cp biasa, Linux menolak:

    cp proyek proyek-cadangan

    Output di Ubuntu 24.04:

    cp: -r not specified; omitting directory 'proyek'

    Di distro atau versi coreutils yang lebih lama, pesannya berbunyi cp: omitting directory 'proyek'. Maksudnya sama: folder dilewati, tidak disalin. Solusinya tambahkan opsi -r yang artinya recursive, alias salin folder beserta seluruh isinya sampai ke tingkat terdalam:

    cp -r proyek proyek-cadangan

    Sekarang ls akan menampilkan proyek-cadangan lengkap dengan semua isi di dalamnya.

    Hindari Menimpa File Tanpa Sadar: cp -i

    Ini jebakan yang jarang disadari pemula. Kalau file tujuan sudah ada, cp langsung menimpanya tanpa bertanya. Tidak ada peringatan, tidak ada konfirmasi, isi file lama hilang begitu saja. Supaya aman, biasakan pakai opsi -i (interactive):

    cp -i catatan.txt catatan-backup.txt

    Karena catatan-backup.txt sudah ada dari langkah sebelumnya, terminal akan bertanya dulu:

    cp: overwrite 'catatan-backup.txt'?

    Ketik y lalu Enter untuk menimpa, atau n untuk membatalkan. Opsi -i juga berlaku untuk mv dan rm.

    Memindahkan dan Rename File dengan mv

    Di Linux, memindahkan dan mengganti nama file itu satu perintah yang sama: mv. Logikanya, rename hanyalah memindahkan file ke jalur baru dengan nama berbeda.

    mv data.csv proyek/
    mv catatan.txt catatan-utama.txt

    Baris pertama memindahkan data.csv ke folder proyek. Baris kedua mengganti nama catatan.txt menjadi catatan-utama.txt. Kamu bahkan bisa melakukan keduanya sekaligus, pindah folder plus ganti nama dalam satu perintah:

    mv catatan-utama.txt proyek/catatan-final.txt

    File berpindah ke folder proyek dan namanya berubah menjadi catatan-final.txt. Berbeda dengan cp, perintah mv tidak butuh opsi -r untuk folder. mv proyek arsip langsung jalan karena yang dipindah hanya “alamat” foldernya, bukan isinya satu per satu.

    Menghapus File dan Folder: rm dan rmdir

    Menghapus file cukup dengan rm:

    rm catatan-backup.txt

    Tidak ada output kalau berhasil. Di Linux, diam berarti sukses. Cek saja dengan ls untuk memastikan filenya hilang.

    Untuk folder kosong, ada perintah khusus rmdir:

    mkdir folder-kosong
    rmdir folder-kosong

    rmdir hanya mau menghapus folder yang benar-benar kosong. Kalau masih ada isinya, dia menolak. Ini fitur pengaman, bukan bug.

    Menghapus Folder Beserta Isinya dengan rm -r

    Folder yang ada isinya dihapus dengan rm -r:

    rm -r proyek-cadangan

    Sama seperti di cp, opsi -r artinya recursive: hapus folder dan seluruh isinya sampai ke dasar. Kalau mau lebih hati-hati, gabungkan dengan -i supaya setiap file dikonfirmasi satu per satu sebelum dihapus.

    Kenapa rm -rf Berbahaya dan Tidak Ada Recycle Bin

    Kamu mungkin sering lihat perintah rm -rf di tutorial internet. Opsi -f artinya force: jangan tanya apa pun, jangan tampilkan error, hapus saja semuanya. Digabung -r, perintah ini menghapus folder apa pun beserta seluruh isinya tanpa satu pun konfirmasi.

    Masalahnya, terminal Linux tidak punya recycle bin. File yang dihapus lewat rm tidak pindah ke trash seperti di file manager Ubuntu atau Windows. Dia langsung lepas dari sistem file. Memulihkannya butuh tool forensik khusus, itu pun tidak dijamin berhasil, apalagi di SSD modern.

    Kesalahan klasiknya sepele: salah ketik path. rm -rf ~/latihan /file-lama dengan spasi nyasar sebelum garis miring akan dibaca sebagai dua target terpisah, dan salah satunya adalah folder di root sistem. Tim kami di Arrazy rutin bekerja di server klien untuk berbagai sistem aplikasi, dan aturan internalnya sederhana: sebelum menekan Enter pada perintah rm -rf, baca ulang path-nya minimal dua kali, dan kalau ragu jalankan ls dulu pada path yang sama untuk memastikan apa yang akan terhapus. Kebiasaan kecil ini jauh lebih murah daripada memulihkan data dari backup.

    Wildcard: Mengelola Banyak File Sekaligus dengan *.txt

    Wildcard * mewakili karakter apa pun dengan panjang berapa pun. Pola *.txt artinya semua file yang namanya berakhiran .txt. Ini yang membuat terminal jauh lebih cepat daripada file manager untuk operasi massal.

    touch laporan1.txt laporan2.txt laporan3.txt gambar.png
    ls *.txt

    Output yang diharapkan:

    laporan1.txt  laporan2.txt  laporan3.txt  tugas.txt

    File gambar.png tidak ikut karena tidak cocok dengan polanya. Wildcard bisa dipakai di hampir semua perintah file:

    mkdir arsip
    cp *.txt arsip/
    rm laporan*.txt

    Baris kedua menyalin semua file .txt ke folder arsip sekali jalan. Baris ketiga menghapus semua file yang namanya diawali laporan dan diakhiri .txt. Hati-hati: rm * tanpa pola apa pun menghapus semua file di folder aktif. Selalu cek dulu dengan ls memakai pola yang sama sebelum menjalankan rm dengan wildcard.

    Latihan Mini: Membangun dan Merapikan Struktur Folder Proyek

    Sekarang gabungkan semuanya. Skenarionya: kamu punya proyek tulisan blog yang filenya berantakan, lalu merapikannya seperti struktur proyek sungguhan.

    1. Buat struktur foldernya sekali jalan:
      cd ~/latihan-file
      mkdir -p blog/{draf,publish,aset}

      Kurung kurawal di situ adalah brace expansion, cara cepat membuat tiga subfolder sekaligus: draf, publish, dan aset.

    2. Buat file draf dan aset dummy:
      cd blog
      touch draf-linux.txt draf-golang.txt draf-laravel.txt logo.png banner.png
    3. Pindahkan semua draf ke foldernya dengan wildcard:
      mv draf-*.txt draf/
      mv *.png aset/
    4. Anggap draf Linux sudah selesai. Pindahkan ke publish sambil ganti nama:
      mv draf/draf-linux.txt publish/artikel-linux.txt
    5. Buat backup seluruh folder blog:
      cd ~/latihan-file
      cp -r blog blog-backup
    6. Verifikasi hasil akhirnya:
      ls blog/draf blog/publish blog/aset

      Output yang diharapkan:

      blog/aset:
      banner.png  logo.png
      
      blog/draf:
      draf-golang.txt  draf-laravel.txt
      
      blog/publish:
      artikel-linux.txt
    7. Terakhir, bersihkan backup yang sudah tidak dipakai:
      rm -r blog-backup

    Kalau semua langkah jalan mulus, kamu sudah menguasai siklus lengkap pengelolaan file: buat, salin, pindah, rename, dan hapus.

    Error yang Sering Dialami Pemula

    cp: -r not specified; omitting directory

    Muncul saat menyalin folder tanpa opsi -r. Di coreutils lama pesannya cp: omitting directory. Penyebabnya sama: cp tidak mau menyalin folder kecuali diminta eksplisit. Solusi: tambahkan -r, misalnya cp -r proyek backup-proyek.

    rm: cannot remove ‘folder’: Is a directory

    Muncul saat menghapus folder dengan rm polos. rm tanpa opsi hanya bekerja pada file. Solusi: pakai rm -r namafolder untuk folder beserta isinya, atau rmdir namafolder kalau foldernya kosong.

    rmdir: failed to remove ‘folder’: Directory not empty

    Muncul saat rmdir dipakai pada folder yang masih ada isinya, termasuk file tersembunyi yang tidak terlihat di ls biasa. Cek dulu dengan ls -a namafolder. Kalau isinya memang mau dibuang semua, pakai rm -r namafolder.

    File Tertimpa Tanpa Peringatan

    Ini bukan pesan error, justru itu bahayanya: tidak ada pesan apa pun. cp dan mv menimpa file tujuan yang sudah ada secara diam-diam. Kalau kamu baru sadar setelah kejadian, isi file lama sudah tidak bisa dikembalikan. Pencegahannya: biasakan cp -i dan mv -i supaya selalu ada konfirmasi sebelum menimpa. Beberapa orang bahkan membuat alias permanen untuk ini, yang akan kita bahas saat masuk materi shell script.

    Lanjut ke Bagian Berikutnya

    Kamu sekarang bisa membuat, menyalin, memindahkan, dan menghapus file dengan aman, termasuk operasi massal pakai wildcard. Satu keterampilan yang belum kita sentuh: mengubah isi file langsung dari terminal. Itu materi bagian berikutnya, “Belajar Linux dari Nol #6: Cara Edit File di Terminal (Nano)”, yang terbit menyusul. Pantau daftar lengkapnya di halaman hub seri Belajar Linux.

    Referensi

  • Belajar Golang dari Nol #26: Role dan Otorisasi, Siapa Boleh Apa

    Belajar Golang dari Nol #26: Role dan Otorisasi, Siapa Boleh Apa

    Di Belajar Golang dari Nol #25 kita sudah membangun register, login, dan JWT. Server sekarang tahu siapa yang sedang mengakses API. UserID sudah tersimpan rapi di context. Tapi tahu siapa dia itu baru setengah cerita. Pertanyaan berikutnya: dia boleh melakukan apa? Itulah otorisasi, dan itu yang kita bahas di bagian ini. Kalau kamu baru bergabung, cek dulu daftar lengkap seri supaya tidak ada yang terlewat.

    Dua Cerita yang Sering Kejadian

    Cerita pertama. Sebuah aplikasi kasir sudah punya login. Semua pegawai punya akun. Suatu hari pemilik toko sadar ada transaksi yang hilang dari laporan. Setelah ditelusuri, ternyata salah satu kasir menghapus transaksi lewat API. Bukan karena dia hacker. Endpoint hapus transaksi memang bisa diakses siapa saja yang sudah login. Sistem cuma bertanya “kamu siapa”, tidak pernah bertanya “kamu boleh tidak”.

    Cerita kedua lebih halus. Sebuah API punya endpoint GET /orders/17. User dengan pesanan nomor 17 bisa melihat pesanannya. Lalu dia iseng mengganti angka di URL jadi 18. Muncul pesanan orang lain, lengkap dengan nama dan alamat. Dia coba PUT /users/9 dengan ID user lain, dan berhasil mengubah data orang itu. Ini celah klasik yang punya nama: IDOR, Insecure Direct Object Reference. Artinya sederhana: server memberi akses ke objek hanya berdasarkan ID yang diketik klien, tanpa mengecek apakah objek itu memang milik dia.

    Dua cerita ini masalah bisnis, bukan cuma masalah teknis. Versi non-teknisnya pernah kami tulis di artikel hak akses di sistem bisnis kalau kamu mau menjelaskan ini ke atasan atau klien.

    Dua Level Otorisasi yang Wajib Dibedakan

    Sebelum menulis kode, luruskan dulu konsepnya. Otorisasi punya dua level yang berbeda, dan keduanya harus dicek.

    Pertama, otorisasi berbasis peran. Admin boleh menghapus produk, kasir tidak. Ini menjawab pertanyaan “jabatan kamu mengizinkan aksi ini atau tidak”. Cek peran cocok ditaruh di middleware karena aturannya melekat pada route, bukan pada data.

    Kedua, otorisasi berbasis kepemilikan. User boleh mengedit profil, tapi hanya profilnya sendiri. User boleh membatalkan pesanan, tapi hanya pesanan miliknya. Ini menjawab pertanyaan “objek ini punya kamu atau bukan”.

    Banyak bug keamanan lahir karena developer hanya mengerjakan level pertama. Route sudah dilindungi middleware role, terasa aman, lalu cek kepemilikan dilupakan. Hasilnya persis cerita kedua tadi: semua user sah bisa saling mengutak-atik data lewat menebak ID. Ingat prinsip ini: cek peran dan cek kepemilikan itu dua pagar yang berbeda. Satu pagar tidak menggantikan pagar yang lain.

    Menambah Kolom Role di Tabel Users

    Kita mulai dari database. Tabel users dari bagian sebelumnya belum punya kolom role. Tambahkan lewat migrasi baru, dengan cara yang sama seperti yang kita pelajari di bagian 23. Buat dua file migrasi:

    -- 000006_add_role_to_users.up.sql
    ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'user';
    
    -- 000006_add_role_to_users.down.sql
    ALTER TABLE users DROP COLUMN role;

    Default 'user' penting. Semua akun lama otomatis jadi user biasa. Tidak ada akun yang tiba-tiba jadi admin. Untuk membuat admin pertama, ubah manual lewat SQL:

    UPDATE users SET role = 'admin' WHERE email = 'owner@toko.com';

    Jangan buat endpoint register yang menerima field role dari klien. Kalau klien bisa mendaftar sambil memilih rolenya sendiri, semua pagar yang kita bangun setelah ini percuma.

    Membawa Role di Dalam JWT

    Sekarang update proses login dari bagian 25. Saat token dibuat, ikutkan role sebagai claim:

    claims := jwt.MapClaims{
        "sub":  user.ID,
        "role": user.Role,
        "exp":  time.Now().Add(15 * time.Minute).Unix(),
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    signed, err := token.SignedString([]byte(secretKey))

    Lalu di middleware autentikasi yang sudah kita punya, tambahkan satu baris untuk menaruh role di context, bersebelahan dengan userID:

    c.Set("userID", int(claims["sub"].(float64)))
    c.Set("role", claims["role"].(string))

    Dengan begitu setiap handler dan middleware setelahnya bisa membaca role tanpa query ke database lagi.

    Middleware RequireRole

    Ini pagar pertama kita. Middleware yang menerima daftar role yang diizinkan, lalu menolak sisanya:

    func RequireRole(roles ...string) gin.HandlerFunc {
        return func(c *gin.Context) {
            roleVal, ok := c.Get("role")
            if !ok {
                c.AbortWithStatusJSON(http.StatusUnauthorized,
                    gin.H{"error": "silakan login dulu"})
                return
            }
    
            userRole := roleVal.(string)
            for _, allowed := range roles {
                if userRole == allowed {
                    c.Next()
                    return
                }
            }
    
            c.AbortWithStatusJSON(http.StatusForbidden,
                gin.H{"error": "kamu tidak punya akses untuk aksi ini"})
        }
    }

    Perhatikan dua status code yang berbeda di situ. Ini sering tertukar, jadi kita bedah pelan-pelan. 401 Unauthorized artinya “saya tidak tahu kamu siapa”. Token tidak ada, atau tidak valid. Solusinya login. 403 Forbidden artinya “saya tahu kamu siapa, dan kamu tetap tidak boleh”. Login ulang tidak akan menolong, karena masalahnya bukan identitas, tapi izin. Klien yang menerima 401 harus diarahkan ke halaman login. Klien yang menerima 403 cukup diberi pesan bahwa aksinya tidak diizinkan.

    Pemasangannya per route, ditumpuk setelah middleware autentikasi:

    products := r.Group("/products")
    products.GET("", h.ListProducts)
    products.GET("/:id", h.GetProduct)
    
    adminProducts := r.Group("/products")
    adminProducts.Use(AuthMiddleware(), RequireRole("admin"))
    adminProducts.POST("", h.CreateProduct)
    adminProducts.PUT("/:id", h.UpdateProduct)
    adminProducts.DELETE("/:id", h.DeleteProduct)

    Urutan middleware penting. AuthMiddleware harus jalan dulu supaya role sudah ada di context saat RequireRole membacanya. Route baca tetap terbuka untuk semua orang, route tulis hanya untuk admin.

    Cek Kepemilikan di Service Layer

    Sekarang pagar kedua. Kenapa tidak di middleware juga? Karena middleware tidak tahu apa-apa soal data. Untuk memutuskan “pesanan 17 ini milik user 4 atau bukan”, kita harus mengambil pesanan itu dari database dulu. Itu urusan service layer. Middleware hanya kenal request, service yang kenal data.

    Contoh pertama, update profil. User hanya boleh mengubah profilnya sendiri:

    var ErrForbidden = errors.New("tidak berhak mengakses data ini")
    
    func (s *UserService) UpdateProfile(ctx context.Context,
        targetID, requesterID int, input UpdateProfileInput) error {
    
        if targetID != requesterID {
            return ErrForbidden
        }
        return s.repo.Update(ctx, targetID, input)
    }

    Contoh kedua, hapus pesanan. Di sini datanya harus diambil dulu:

    func (s *OrderService) Delete(ctx context.Context,
        orderID, requesterID int, role string) error {
    
        order, err := s.repo.FindByID(ctx, orderID)
        if err != nil {
            return err
        }
    
        isOwner := order.UserID == requesterID
        isAdmin := role == "admin"
    
        if !isOwner && !isAdmin {
            return ErrForbidden
        }
    
        if isAdmin && !isOwner {
            s.audit.Log(ctx, requesterID, "delete_order", orderID)
        }
    
        return s.repo.Delete(ctx, orderID)
    }

    Di handler, petakan ErrForbidden ke status 403 dengan errors.Is, pola yang sudah kita pakai sejak membahas error handling. Ambil requesterID dan role dari context, jangan pernah dari body atau query. ID di URL adalah objek yang mau diakses. ID di context adalah siapa yang mengakses. Jangan sampai tertukar, karena menukar keduanya itulah yang menciptakan IDOR.

    Admin Override yang Tercatat

    Lihat lagi kode di atas. Admin boleh menghapus pesanan siapa pun. Kadang memang perlu, misalnya membersihkan pesanan bermasalah. Tapi kekuasaan tanpa catatan itu berbahaya, bahkan untuk admin yang jujur sekalipun. Ketika ada data hilang, pertanyaan pertama pemilik bisnis selalu sama: siapa yang menghapus, kapan, dan kenapa.

    Karena itu setiap kali admin menyentuh data milik orang lain, tulis ke log audit: siapa pelakunya, aksi apa, objek mana, jam berapa. Konsepnya sama dengan jejak audit yang kita bahas di bagian 18, sekarang tinggal dipakai di titik yang tepat. Satu baris s.audit.Log hari ini bisa menyelamatkan investigasi berjam-jam di kemudian hari.

    Jangan Percaya Klien, Tapi Kenali Batas JWT

    Mungkin kamu bertanya: role kan disimpan di JWT, dan JWT ada di tangan klien. Bagaimana kalau user mengedit tokennya sendiri dan mengganti role jadi admin?

    Tenang. JWT ditandatangani dengan secret yang hanya diketahui server. Begitu payload diubah satu karakter saja, signature tidak cocok lagi dan token ditolak saat verifikasi. Selama secret tidak bocor, isi klaim aman dari manipulasi.

    Tapi ada batas lain yang jujur harus diakui: klaim itu potret saat login, bukan data langsung dari database. Bayangkan seorang admin dicabut haknya siang ini. Token lama di tangannya masih berbunyi role: admin dan tetap valid sampai kadaluarsa. Selama sisa umur token itu, dia masih bisa lewat pagar RequireRole("admin").

    Ada dua mitigasi yang umum, dan keduanya punya harga. Pertama, expiry pendek, misalnya 15 menit seperti contoh kita, dipasangkan dengan refresh token. Perubahan role paling lambat berlaku 15 menit kemudian. Harganya: alur refresh lebih rumit. Kedua, untuk aksi yang sangat sensitif seperti menghapus user atau mengubah role orang lain, abaikan klaim dan cek role langsung ke database saat itu juga. Harganya: satu query ekstra per request. Pilihan yang masuk akal untuk kebanyakan aplikasi: expiry pendek untuk umum, cek database khusus untuk endpoint paling berbahaya. Tidak ada jawaban gratis di sini, yang penting kamu memilih dengan sadar.

    Latihan: API Produk dan Pesanan

    Waktunya merangkai semua jadi satu. Susun route seperti ini:

    r := gin.Default()
    
    // publik: siapa pun boleh baca produk
    r.GET("/products", h.ListProducts)
    r.GET("/products/:id", h.GetProduct)
    
    // admin: CRUD penuh produk
    admin := r.Group("/products")
    admin.Use(AuthMiddleware(), RequireRole("admin"))
    admin.POST("", h.CreateProduct)
    admin.PUT("/:id", h.UpdateProduct)
    admin.DELETE("/:id", h.DeleteProduct)
    
    // user login: pesanan dengan cek kepemilikan di service
    orders := r.Group("/orders")
    orders.Use(AuthMiddleware())
    orders.POST("", h.CreateOrder)
    orders.GET("/:id", h.GetOrder)
    orders.DELETE("/:id", h.DeleteOrder)

    Siapkan tiga kondisi untuk menguji: token admin, token user biasa, dan tanpa token. Contoh dua di antaranya:

    curl -i -X DELETE http://localhost:8080/products/3 \
      -H "Authorization: Bearer $TOKEN_ADMIN"
    
    curl -i -X DELETE http://localhost:8080/products/3 \
      -H "Authorization: Bearer $TOKEN_USER"

    Lalu cocokkan hasilnya dengan matriks ini. Kalau ada satu sel yang meleset, berarti ada pagar yang bocor:

    Endpoint                  Admin   User    Anon
    GET    /products          200     200     200
    POST   /products          201     403     401
    PUT    /products/:id      200     403     401
    DELETE /products/:id      204     403     401
    GET    /orders/:id        200     200*    401
    DELETE /orders/:id        204**   204*    401
    
    *  hanya untuk pesanan miliknya sendiri,
       pesanan orang lain harus 403
    ** aksi admin atas pesanan orang lain
       harus tercatat di log audit

    Uji juga skenario IDOR secara sengaja. Login sebagai user A, buat pesanan, catat ID-nya. Login sebagai user B, lalu coba GET dan DELETE pesanan milik A. Kalau jawabannya bukan 403, kembali ke service layer dan periksa cek kepemilikannya. Matriks yang lolos penuh artinya dua pagar kita sudah berdiri: peran di middleware, kepemilikan di service.

    Penutup

    API kita sekarang tidak cuma tahu siapa yang datang, tapi juga tegas soal siapa boleh apa. Peran dicek di middleware, kepemilikan dicek di service, dan aksi admin atas data orang lain selalu meninggalkan jejak. Tapi masih ada satu pintu yang belum kita jaga: data yang dikirim klien itu sendiri. Di bagian 27 kita bahas “Validasi Input: Jangan Percaya Data dari Luar”.

    Pola role dan hak akses seperti ini juga yang kami pasang di setiap proyek aplikasi bisnis, dari sistem kasir sampai manajemen gudang. Kalau bisnismu butuh sistem aplikasi dengan pengaturan hak akses yang rapi, tim Arrazy siap bantu merancangnya.

  • Belajar Kubernetes #4: Perintah Dasar kubectl

    Belajar Kubernetes #4: Perintah Dasar kubectl

    Perintah dasar kubectl selalu mengikuti satu pola: kubectl [verb] [resource] [nama] [flag]. Verb adalah aksinya (get, describe, delete), resource adalah objek yang dikenai aksi (pod, deployment, service), nama menunjuk objek spesifik, dan flag mengatur detail perilakunya. Begitu pola ini nempel di kepala, kamu tidak perlu lagi menghafal puluhan perintah satu per satu.

    Artikel ini bagian keempat dari seri Belajar Kubernetes dari Nol. Kita akan praktik langsung: menjalankan perintah harian kubectl ke deployment nginx, membaca outputnya, lalu menutup dengan trik produktivitas yang dipakai tim Arrazy sehari-hari saat mengelola cluster untuk backend Go dan Laravel milik klien.

    Prasyarat Sebelum Mulai

    Pastikan Minikube dari bagian 1 masih jalan. Tutorial ini diuji dengan Minikube v1.33, kubectl v1.30, dan Kubernetes v1.30. Kalau kamu belum paham siapa yang sebenarnya menerima perintah kubectl di sisi cluster, baca dulu Belajar Kubernetes #3: Arsitektur Kubernetes Cluster. Singkatnya, kubectl hanyalah klien yang mengirim request HTTP ke API server.

    Cek dulu cluster hidup:

    minikube status
    kubectl version

    Lalu siapkan deployment nginx sebagai bahan praktik. Kalau deployment dari bagian 1 sudah terhapus, buat lagi:

    kubectl create deployment nginx --image=nginx:1.27

    Anatomi Perintah kubectl

    Bedah satu contoh nyata:

    kubectl get pods nginx-7584b6f84c-x2m9q -o wide
    • get adalah verb, artinya ambil dan tampilkan data
    • pods adalah resource yang dituju
    • nginx-7584b6f84c-x2m9q adalah nama objek spesifik, boleh dikosongkan untuk menampilkan semua
    • -o wide adalah flag, di sini mengatur format output

    Nama resource punya bentuk panjang dan singkatan. pods bisa ditulis pod atau po, deployments jadi deploy, services jadi svc. Daftar lengkapnya bisa dilihat dengan kubectl api-resources.

    Context dan Kubeconfig, Penentu Cluster Mana yang Kamu Sentuh

    kubectl tahu harus mengirim perintah ke cluster mana dari file kubeconfig, defaultnya di ~/.kube/config. File ini menyimpan daftar cluster, user, dan context. Context adalah pasangan cluster plus user plus namespace default. Saat kamu install Minikube, context bernama minikube otomatis dibuat dan diaktifkan.

    kubectl config current-context

    Output yang diharapkan:

    minikube

    Kalau nanti kamu pegang lebih dari satu cluster, misalnya Minikube di laptop dan k3s di VPS, gunakan kubectl config get-contexts untuk melihat semuanya dan kubectl config use-context minikube untuk pindah. Kebiasaan mengecek context sebelum menjalankan perintah destruktif itu penting. Salah context berarti perintah delete kamu mendarat di cluster yang salah.

    Tujuh Perintah Harian kubectl

    Tujuh verb ini menutupi mayoritas pekerjaan harian: get, describe, logs, exec, apply, delete, dan edit. Kita coba satu per satu ke deployment nginx.

    kubectl get, Melihat Daftar Resource

    kubectl get deployments
    kubectl get pods

    Output yang diharapkan kurang lebih:

    NAME    READY   UP-TO-DATE   AVAILABLE   AGE
    nginx   1/1     1            1           3m
    
    NAME                     READY   STATUS    RESTARTS   AGE
    nginx-7584b6f84c-x2m9q   1/1     Running   0          3m

    Bagian acak di belakang nama pod akan berbeda di mesinmu, itu normal karena pod dibuat otomatis oleh ReplicaSet. Mau lihat beberapa jenis resource sekaligus juga bisa:

    kubectl get deploy,pods,svc

    kubectl describe, Melihat Detail dan Event

    get memberi ringkasan, describe memberi cerita lengkap. Ganti nama pod sesuai output di mesinmu:

    kubectl describe pod nginx-7584b6f84c-x2m9q

    Scroll ke bagian paling bawah, ada tabel Events. Di sinilah Kubernetes mencatat riwayat pod: kapan dijadwalkan ke node, kapan image ditarik, kapan container dinyalakan. Saat pod bermasalah, Events hampir selalu jadi tempat pertama untuk mencari petunjuk.

    kubectl logs, Membaca Output Aplikasi

    kubectl logs deployment/nginx

    Perintah ini menampilkan log nginx, termasuk access log tiap request yang masuk. Dua flag yang sering dipakai: -f untuk mengikuti log secara live seperti tail -f, dan --previous untuk membaca log container yang sudah mati karena restart. Flag kedua ini penyelamat saat aplikasi crash berulang dan log-nya hilang tiap restart.

    kubectl exec, Masuk ke Dalam Container

    Kadang kamu perlu mengecek langsung dari dalam container: isi file konfigurasi, environment variable, atau koneksi ke service lain.

    kubectl exec -it deployment/nginx -- /bin/bash

    Flag -it membuat sesi interaktif dengan terminal, dan -- memisahkan argumen kubectl dari perintah yang dijalankan di dalam container. Setelah masuk, coba:

    cat /etc/nginx/conf.d/default.conf
    exit

    Untuk perintah sekali jalan tanpa masuk shell, langsung saja:

    kubectl exec deployment/nginx -- nginx -v

    Output yang diharapkan:

    nginx version: nginx/1.27.5

    kubectl apply, Menerapkan File YAML

    apply membaca file manifest lalu menyamakan kondisi cluster dengan isi file. Buat file percobaan bernama nginx-scale.yaml:

    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: nginx
    spec:
      replicas: 2
      selector:
        matchLabels:
          app: nginx
      template:
        metadata:
          labels:
            app: nginx
        spec:
          containers:
          - name: nginx
            image: nginx:1.27

    Terapkan lalu cek hasilnya:

    kubectl apply -f nginx-scale.yaml
    kubectl get pods

    Sekarang ada dua pod nginx. Jangan pusing dulu dengan struktur YAML-nya, itu jatah bagian 6. Yang penting dipahami sekarang: apply bersifat deklaratif. Kamu menyatakan kondisi akhir yang diinginkan, Kubernetes yang mengurus caranya.

    kubectl delete, Menghapus Resource

    Coba hapus salah satu pod, ganti dengan nama pod di mesinmu:

    kubectl delete pod nginx-7584b6f84c-x2m9q
    kubectl get pods

    Hasilnya menarik: pod yang dihapus hilang, tapi pod baru langsung muncul menggantikannya. Itu kerjaan ReplicaSet yang menjaga jumlah replicas tetap dua. Kalau mau benar-benar bersih, hapus deployment-nya, tapi jangan lakukan sekarang karena masih kita pakai.

    kubectl edit, Mengubah Resource Langsung

    kubectl edit deployment nginx

    Perintah ini membuka manifest deployment di editor terminal, defaultnya vi. Ubah replicas: 2 menjadi replicas: 3, simpan, keluar, lalu cek kubectl get pods. Pod ketiga muncul. Praktis untuk eksperimen, tapi di pekerjaan nyata tim Arrazy lebih memilih mengubah file YAML lalu apply ulang, supaya semua perubahan tercatat di Git dan bisa di-review.

    Cara Membaca Output kubectl get pods

    Empat kolom output kubectl get pods sering bikin pemula salah paham, jadi kita bedah:

    Kolom Arti
    READY Jumlah container siap dibanding total container di pod. 1/1 artinya satu dari satu container siap. 0/1 artinya container ada tapi belum siap menerima traffic
    STATUS Fase pod saat ini. Running berarti jalan normal. Pending berarti belum dijadwalkan atau image masih ditarik. CrashLoopBackOff berarti container terus mati dan Kubernetes menunda restart berikutnya. ImagePullBackOff berarti image gagal ditarik, biasanya salah nama atau tag
    RESTARTS Berapa kali container di-restart. Angka yang terus naik adalah alarm, cek logs --previous dan describe
    AGE Umur pod sejak dibuat. Berguna untuk memastikan pod baru benar-benar tercipta setelah update

    Kombinasi ketiganya yang perlu dibaca sebagai satu kesatuan. Pod dengan STATUS Running tapi READY 0/1 berarti proses jalan tapi belum lolos pemeriksaan kesiapan, topik yang kita dalami di bagian 13 tentang probe.

    Trik Produktivitas kubectl

    Alias k dan Autocompletion

    Mengetik kubectl ratusan kali sehari itu melelahkan. Hampir semua praktisi memakai alias satu huruf. Untuk pengguna bash:

    echo 'alias k=kubectl' >> ~/.bashrc
    echo 'source <(kubectl completion bash)' >> ~/.bashrc
    echo 'complete -o default -F __start_kubectl k' >> ~/.bashrc
    source ~/.bashrc

    Baris kedua mengaktifkan autocompletion, jadi menekan Tab akan melengkapi nama perintah, resource, bahkan nama pod. Baris ketiga membuat autocompletion ikut bekerja pada alias k. Pengguna zsh tinggal ganti bash menjadi zsh dan ~/.bashrc menjadi ~/.zshrc, tanpa baris complete.

    kubectl explain, Dokumentasi di Terminal

    Lupa field apa saja yang valid di sebuah resource? Tidak perlu buka browser:

    kubectl explain pod.spec.containers

    Output-nya menjelaskan setiap field beserta tipenya, langsung dari API server sesuai versi cluster yang kamu pakai. Perintah ini akan sangat sering kamu pakai saat mulai menulis YAML sendiri.

    Flag -o wide dan -o yaml

    kubectl get pods -o wide

    Flag -o wide menambah kolom IP pod dan node tempat pod berjalan. Sementara -o yaml menampilkan manifest lengkap sebuah resource persis seperti yang tersimpan di cluster:

    kubectl get deployment nginx -o yaml

    Ini cara cepat mempelajari struktur resource dari objek yang sudah jalan, termasuk field default yang diisi otomatis oleh Kubernetes.

    kubectl port-forward, Akses Cepat Tanpa Service

    Mau membuka nginx di browser tanpa bikin Service dulu? Forward saja port-nya:

    kubectl port-forward deployment/nginx 8080:80

    Output yang diharapkan:

    Forwarding from 127.0.0.1:8080 -> 80
    Forwarding from [::1]:8080 -> 80

    Buka http://localhost:8080 di browser dan halaman selamat datang nginx muncul. Tekan Ctrl+C untuk berhenti. Teknik ini juga aman dipakai di cluster production untuk mengintip dashboard internal atau database tanpa mengekspos apa pun ke publik. Tim kami memakainya hampir tiap hari saat membangun sistem aplikasi untuk klien, misalnya mengecek service backend yang belum punya Ingress.

    Troubleshooting Error Umum kubectl

    error: You must be logged in to the server (Unauthorized)

    Artinya request sampai ke API server tapi kredensialmu ditolak. Penyebab tersering di Minikube adalah sertifikat client yang kedaluwarsa atau kubeconfig yang menunjuk kredensial lama setelah cluster dibuat ulang. Solusi:

    minikube update-context

    Kalau masih gagal, jalan terakhir yang hampir selalu beres adalah minikube delete lalu minikube start. Cek juga apakah variabel KUBECONFIG sedang menunjuk file lain dengan echo $KUBECONFIG.

    The connection to the server localhost:8080 was refused

    Error klasik yang artinya kubectl tidak menemukan kubeconfig sama sekali, lalu jatuh ke alamat default localhost:8080. Biasanya karena Minikube belum dinyalakan atau kamu menjalankan kubectl sebagai user lain, misalnya lewat sudo, yang home directory-nya tidak punya ~/.kube/config. Jalankan minikube start dan jangan pakai sudo untuk kubectl.

    Perintah Mendarat di Cluster yang Salah

    Kamu merasa sudah membuat deployment tapi kubectl get pods hasilnya kosong, atau sebaliknya muncul resource asing yang tidak pernah kamu buat. Hampir pasti context-mu sedang menunjuk cluster lain. Cek dan pindahkan:

    kubectl config get-contexts
    kubectl config use-context minikube

    Tanda bintang di output get-contexts menunjukkan context aktif. Biasakan melirik context setiap kali membuka terminal baru, apalagi kalau kamu pegang cluster production.

    exec Gagal: /bin/bash no such file or directory

    Tidak semua image punya bash. Image berbasis Alpine misalnya hanya menyediakan sh. Kalau kubectl exec -it ... -- /bin/bash gagal dengan error ini, ganti dengan:

    kubectl exec -it deployment/nginx -- /bin/sh

    Rangkuman dan Lanjut ke Bagian 5

    Kamu sekarang pegang fondasi terpenting untuk bekerja dengan Kubernetes: pola verb resource nama flag, tujuh perintah harian, cara membaca output get, plus alias dan autocompletion supaya kerja lebih cepat. Semua materi setelah ini tinggal soal mengenal resource baru, karena cara mengoperasikannya tetap lewat perintah yang sama.

    Di bagian berikutnya, Belajar Kubernetes #5: Pod, Unit Terkecil Kubernetes, kita bedah pod lebih dalam: lifecycle-nya, kenapa satu pod bisa berisi lebih dari satu container, dan kapan itu berguna. Artikelnya terbit menyusul, pantau daftarnya di halaman hub Belajar Kubernetes.

    Referensi

  • Belajar Golang dari Nol #25: Autentikasi Register, Login, dan JWT

    Belajar Golang dari Nol #25: Autentikasi Register, Login, dan JWT

    Di Belajar Golang dari Nol #24 kita mempercepat API dengan caching Redis. API kita sekarang cepat, punya database rapi, dan punya middleware. Tapi ada satu lubang besar. Semua orang yang tahu API key statis dari bagian 13 bisa mengakses semua data. Server tidak tahu siapa yang sedang memakai API. Di bagian ini kita tutup lubang itu. Kita bangun autentikasi user sungguhan dengan register, login, dan JWT. Kalau kamu baru bergabung, cek dulu daftar lengkap seri supaya tidak ada bagian yang terlewat.

    Autentikasi vs Otorisasi, Jangan Tertukar

    Dua istilah ini sering dicampur, padahal beda. Autentikasi menjawab pertanyaan “kamu siapa”. Otorisasi menjawab pertanyaan “kamu boleh apa”. Urutannya selalu autentikasi dulu, baru otorisasi. Server harus tahu identitasmu sebelum bisa memutuskan hakmu.

    API key statis di bagian 13 sebenarnya autentikasi juga, tapi levelnya aplikasi, bukan user. Semua pemegang key dianggap orang yang sama. Sekarang kita naik level. Setiap user punya akun sendiri, dan server tahu persis siapa yang mengirim tiap request. Bagian ini fokus ke autentikasi saja. Otorisasi kita bahas di bagian berikutnya.

    Tabel Users Lewat Migrasi

    Kita mulai dari database. Sesuai kebiasaan sejak bagian 23, perubahan skema selalu lewat file migrasi, bukan SQL manual di terminal. Buat sepasang file migrasi baru dengan nomor urut berikutnya di proyekmu.

    Isi file up:

    -- 000004_create_users.up.sql
    CREATE TABLE users (
        id BIGSERIAL PRIMARY KEY,
        email TEXT NOT NULL UNIQUE,
        password_hash TEXT NOT NULL,
        created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    );
    

    Dan file down untuk jalan mundurnya:

    -- 000004_create_users.down.sql
    DROP TABLE users;
    

    Perhatikan nama kolomnya: password_hash, bukan password. Ini disengaja. Password tidak pernah disimpan mentah di database. Tidak boleh, dalam kondisi apa pun. Kalau database bocor dan password tersimpan mentah, semua akun user langsung jatuh, termasuk akun mereka di layanan lain yang memakai password sama. Yang kita simpan hanya hasil hash, yaitu sidik jari matematis yang tidak bisa dibalik jadi password asli.

    Jalankan migrasinya seperti biasa dengan migrate -path ./migrations -database "$DATABASE_URL" up.

    Hash Password dengan Bcrypt

    Untuk hashing kita pakai bcrypt dari paket resmi Go. Pasang dulu:

    go get golang.org/x/crypto/bcrypt
    

    Kenapa bukan MD5 atau SHA-256? Karena keduanya dirancang untuk cepat. Cepat itu bagus untuk checksum file, tapi bencana untuk password. Penyerang yang mencuri database bisa menebak jutaan kombinasi per detik dengan hash yang cepat. Bcrypt sengaja dibuat lambat, sekitar puluhan milidetik per hash. Untuk satu kali login, lambatnya tidak terasa. Untuk penyerang yang harus mencoba miliaran tebakan, lambat itu jadi tembok. Jadi lambat di sini bukan kelemahan, justru fitur utamanya. Bonusnya, bcrypt otomatis menambahkan salt, jadi dua user dengan password sama tetap punya hash berbeda.

    Kita cuma butuh dua fungsi:

    package main
    
    import "golang.org/x/crypto/bcrypt"
    
    func hashPassword(password string) (string, error) {
    	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    	if err != nil {
    		return "", err
    	}
    	return string(hash), nil
    }
    
    func cocokkanPassword(hash, password string) bool {
    	err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
    	return err == nil
    }
    

    GenerateFromPassword dipakai saat register. CompareHashAndPassword dipakai saat login. Kita tidak pernah membandingkan string password secara langsung. Biarkan bcrypt yang menghitung apakah password yang dikirim cocok dengan hash di database.

    Endpoint Register

    Sekarang endpoint pertama. Alurnya: terima email dan password, validasi, hash, simpan.

    type authRequest struct {
    	Email    string `json:"email"`
    	Password string `json:"password"`
    }
    
    func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
    	var req authRequest
    	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    		http.Error(w, "body tidak valid", http.StatusBadRequest)
    		return
    	}
    
    	req.Email = strings.ToLower(strings.TrimSpace(req.Email))
    	if _, err := mail.ParseAddress(req.Email); err != nil {
    		http.Error(w, "format email tidak valid", http.StatusBadRequest)
    		return
    	}
    	if len(req.Password) < 8 {
    		http.Error(w, "password minimal 8 karakter", http.StatusBadRequest)
    		return
    	}
    
    	hash, err := hashPassword(req.Password)
    	if err != nil {
    		http.Error(w, "terjadi kesalahan server", http.StatusInternalServerError)
    		return
    	}
    
    	_, err = s.db.ExecContext(r.Context(),
    		"INSERT INTO users (email, password_hash) VALUES ($1, $2)",
    		req.Email, hash)
    	if err != nil {
    		http.Error(w, "registrasi tidak dapat diproses", http.StatusBadRequest)
    		return
    	}
    
    	w.WriteHeader(http.StatusCreated)
    	json.NewEncoder(w).Encode(map[string]string{"pesan": "registrasi berhasil"})
    }
    

    Ada satu keputusan keamanan yang halus di sini. Saat email sudah terdaftar, constraint UNIQUE membuat insert gagal, dan kita membalas dengan pesan generik “registrasi tidak dapat diproses”. Kenapa tidak jujur bilang “email sudah terdaftar”? Karena pesan sedetail itu bisa dipakai penyerang untuk memetakan siapa saja yang punya akun di sistemmu. Teknik ini disebut user enumeration. Penyerang cukup mencoba ribuan email dan mencatat mana yang ditolak. Pesan generik menutup celah itu. Untuk aplikasi internal kamu boleh lebih longgar, tapi biasakan pola aman sejak awal.

    Kenalan dengan JWT

    Register beres. Sekarang bagian menariknya: bagaimana server mengenali user di request berikutnya?

    Cara klasik adalah session. Server menyimpan catatan “user 42 sedang login” di memori atau database, lalu memberi browser sebuah cookie berisi ID session. Cara ini bekerja, tapi server harus menyimpan dan mencari catatan itu di setiap request.

    JWT, singkatan dari JSON Web Token, memakai pendekatan lain. Bayangkan tiket konser yang ditandatangani panitia. Di tiket tertulis namamu dan tanggal berlakunya. Petugas di pintu tidak perlu menelepon kantor pusat untuk mengecek daftar pembeli. Dia cukup memeriksa tanda tangannya asli atau tidak. Kalau asli, isi tiket dipercaya. JWT persis seperti itu. Token berisi klaim tentang user, ditandatangani dengan kunci rahasia server, dan server bisa memverifikasinya tanpa menyimpan session sama sekali.

    Bentuk JWT adalah tiga bagian yang dipisah titik: header.payload.signature. Header berisi jenis algoritma. Payload berisi klaim, misalnya ID user dan waktu kadaluarsa. Signature adalah tanda tangan kriptografis atas dua bagian pertama. Kalau ada satu karakter payload yang diubah, tanda tangan tidak akan cocok lagi dan server menolak token itu.

    Supaya adil, kita bahas juga minusnya. Pertama, JWT tidak bisa dicabut sebelum kadaluarsa. Server tidak menyimpan daftar token aktif, jadi token yang sudah terbit akan tetap valid sampai exp lewat, meskipun usernya menekan tombol logout. Kedua, payload hanya di-encode dengan base64, bukan dienkripsi. Siapa pun yang memegang token bisa membaca isinya. Jadi jangan pernah menaruh data sensitif seperti password atau nomor identitas di payload. Cukup ID user dan waktu kadaluarsa.

    Login dan Pembuatan Token

    Kita pakai library yang paling umum di ekosistem Go:

    go get github.com/golang-jwt/jwt/v5
    

    Kunci rahasia diambil dari environment variable, sama seperti konfigurasi database kita selama ini. Jangan tulis langsung di kode.

    var jwtSecret = []byte(os.Getenv("JWT_SECRET"))
    
    func buatToken(userID int64) (string, error) {
    	claims := jwt.MapClaims{
    		"sub": strconv.FormatInt(userID, 10),
    		"iat": time.Now().Unix(),
    		"exp": time.Now().Add(24 * time.Hour).Unix(),
    	}
    	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    	return token.SignedString(jwtSecret)
    }
    

    Klaim sub alias subject berisi ID user. Klaim exp membuat token hangus otomatis setelah 24 jam. Handler loginnya:

    func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
    	var req authRequest
    	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    		http.Error(w, "body tidak valid", http.StatusBadRequest)
    		return
    	}
    	req.Email = strings.ToLower(strings.TrimSpace(req.Email))
    
    	var id int64
    	var hash string
    	err := s.db.QueryRowContext(r.Context(),
    		"SELECT id, password_hash FROM users WHERE email = $1",
    		req.Email).Scan(&id, &hash)
    	if err != nil || !cocokkanPassword(hash, req.Password) {
    		http.Error(w, "email atau password salah", http.StatusUnauthorized)
    		return
    	}
    
    	tokenString, err := buatToken(id)
    	if err != nil {
    		http.Error(w, "terjadi kesalahan server", http.StatusInternalServerError)
    		return
    	}
    
    	json.NewEncoder(w).Encode(map[string]string{"token": tokenString})
    }
    

    Perhatikan lagi pesan errornya. Email tidak ketemu dan password salah dibalas dengan kalimat yang sama persis. Alasannya sama dengan register tadi: jangan beri petunjuk gratis ke penyerang.

    Middleware JWT Menggantikan API Key

    Di bagian 13 kita membuat middleware yang mengecek API key statis dari header. Sekarang middleware itu kita pensiunkan dan ganti dengan pemeriksa JWT. Polanya masih sama dengan middleware request ID di bagian 18: periksa request, taruh data di context, teruskan ke handler berikutnya.

    type contextKey string
    
    const kunciUserID contextKey = "userID"
    
    func middlewareJWT(next http.Handler) http.Handler {
    	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    		header := r.Header.Get("Authorization")
    		tokenString, ok := strings.CutPrefix(header, "Bearer ")
    		if !ok || tokenString == "" {
    			http.Error(w, "token tidak ditemukan", http.StatusUnauthorized)
    			return
    		}
    
    		token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
    			if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
    				return nil, errors.New("metode tanda tangan tidak dikenal")
    			}
    			return jwtSecret, nil
    		})
    		if err != nil || !token.Valid {
    			http.Error(w, "token tidak valid atau kadaluarsa", http.StatusUnauthorized)
    			return
    		}
    
    		sub, err := token.Claims.GetSubject()
    		if err != nil || sub == "" {
    			http.Error(w, "token tidak valid", http.StatusUnauthorized)
    			return
    		}
    
    		ctx := context.WithValue(r.Context(), kunciUserID, sub)
    		next.ServeHTTP(w, r.WithContext(ctx))
    	})
    }
    

    Tiga hal yang dicek middleware ini. Pertama, header Authorization harus berformat Bearer token. Kedua, tanda tangan harus valid dan algoritmanya harus HMAC, bukan algoritma lain yang diselundupkan penyerang. Ketiga, jwt.Parse otomatis menolak token yang exp nya sudah lewat. Kalau semua lolos, ID user masuk ke context dan handler di belakangnya tinggal membaca.

    Handler profil yang membaca userID dari context:

    func (s *Server) handleProfil(w http.ResponseWriter, r *http.Request) {
    	userID, _ := r.Context().Value(kunciUserID).(string)
    
    	var email string
    	var createdAt time.Time
    	err := s.db.QueryRowContext(r.Context(),
    		"SELECT email, created_at FROM users WHERE id = $1",
    		userID).Scan(&email, &createdAt)
    	if err != nil {
    		http.Error(w, "user tidak ditemukan", http.StatusNotFound)
    		return
    	}
    
    	json.NewEncoder(w).Encode(map[string]any{
    		"id":        userID,
    		"email":     email,
    		"terdaftar": createdAt,
    	})
    }
    

    Terakhir, daftarkan semua route. Register dan login terbuka, profil dilindungi middleware:

    mux := http.NewServeMux()
    mux.HandleFunc("POST /register", s.handleRegister)
    mux.HandleFunc("POST /login", s.handleLogin)
    mux.Handle("GET /profil", middlewareJWT(http.HandlerFunc(s.handleProfil)))
    

    Praktik Aman yang Wajib Kamu Pegang

    Sebelum lanjut ke latihan, catat empat aturan main ini.

    Pertama, secret harus panjang dan acak. Minimal 32 byte. Bangkitkan sekali dengan openssl rand -base64 32 lalu simpan di environment variable. Secret pendek seperti “rahasia123” bisa ditebak dengan brute force, dan siapa pun yang tahu secret bisa memalsukan token atas nama user mana saja.

    Kedua, HTTPS wajib di production. Token dikirim di header pada setiap request. Tanpa TLS yang sudah kita siapkan di bagian 15, token bisa dibaca siapa saja yang mengintip jaringan, dan mereka langsung bisa menyamar jadi usermu.

    Ketiga, buat expiry pendek. Contoh kita memakai 24 jam supaya enak dicoba, tapi di production banyak tim memakai 15 sampai 60 menit. Supaya user tidak perlu login ulang terus, biasanya ada token kedua bernama refresh token, yang berumur lebih panjang dan disimpan server, khusus untuk menukar access token baru. Konsepnya cukup kamu kenal dulu, implementasi penuhnya di luar cakupan bagian ini.

    Keempat, jangan pernah menulis token ke log. Token di log sama bahayanya dengan password di log. Kalau perlu debugging, log kan user ID nya saja.

    Latihan: Uji Alur Lengkapnya

    Jalankan server, lalu uji tiga endpoint tadi dengan curl. Pertama register:

    curl -X POST http://localhost:8080/register \
      -H "Content-Type: application/json" \
      -d '{"email":"budi@contoh.com","password":"rahasiabanget"}'
    
    {"pesan":"registrasi berhasil"}
    

    Lalu login untuk mendapatkan token:

    curl -X POST http://localhost:8080/login \
      -H "Content-Type: application/json" \
      -d '{"email":"budi@contoh.com","password":"rahasiabanget"}'
    
    {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3d..."}
    

    Simpan token itu, lalu akses profil dengan menyertakannya di header:

    TOKEN="tempel-token-kamu-di-sini"
    
    curl http://localhost:8080/profil \
      -H "Authorization: Bearer $TOKEN"
    
    {"id":"1","email":"budi@contoh.com","terdaftar":"2026-07-27T09:15:02Z"}
    

    Terakhir, buktikan penjagaannya bekerja. Akses tanpa token:

    curl -i http://localhost:8080/profil
    
    HTTP/1.1 401 Unauthorized
    
    token tidak ditemukan
    

    Dapat 200 saat bawa token dan 401 saat tidak, berarti autentikasimu sudah jalan. Coba juga skenario lain: register dengan email yang sama dua kali, login dengan password salah, dan akses profil dengan token yang kamu ubah satu hurufnya. Perhatikan responsnya, lalu telusuri di kode kenapa hasilnya begitu.

    Penutup

    API kita akhirnya kenal usernya. Password tersimpan aman sebagai hash bcrypt, login menghasilkan JWT bertanda tangan, dan middleware memastikan hanya pemegang token valid yang bisa masuk. Fondasi ini yang dipakai hampir semua API modern, dari aplikasi kasir sampai sistem informasi sekolah.

    Tapi ingat, server baru tahu kamu siapa. Dia belum bisa membedakan admin dari user biasa. Itu ranah otorisasi, dan kita bahas di bagian 26: Role dan Otorisasi: Membatasi Siapa Boleh Apa.

    Kalau kamu sedang membangun aplikasi dengan kebutuhan login, role, dan keamanan data yang serius, tim Arrazy bisa bantu lewat jasa pembuatan sistem aplikasi. Sampai jumpa di bagian berikutnya.