Kubernetes Deployment: GitHub Actions ile CI/CD Pipeline Kurulumu
Kubernetes üzerinde uygulama yönetmek başlı başına bir iş. Bir de üstüne her değişiklikte manuel deploy yapmaya çalışırsanız, inanın geceleri hiç uyuyamazsınız. GitHub Actions ile CI/CD pipeline kurduğunuzda ise “kodu push’ladım, gerisi otomatik” rahatlığına kavuşuyorsunuz. Bu yazıda sıfırdan çalışan bir Kubernetes deployment pipeline’ı kuracağız, gerçek dünya senaryolarıyla.
Neden GitHub Actions ve Kubernetes Birlikte?
Pek çok ekip Jenkins kurar, bakımını yapar, plugin güncellemelerinde saçını başını yolar. GitHub Actions bu dertten büyük ölçüde kurtarıyor. Kodunuz zaten GitHub’da, CI/CD pipeline’ınız da aynı repoda YAML olarak duruyor. Versiyon kontrolü, code review, her şey tek yerde.
Kubernetes tarafında ise deployment stratejileri (rolling update, blue-green, canary) çok daha kontrollü yapılabiliyor. Bu ikisini birleştirdiğinizde şöyle bir akış elde ediyorsunuz:
- Geliştirici kodu push’lar veya PR açar
- GitHub Actions tetiklenir, testler çalışır
- Docker image build edilir ve registry’e push’lanır
- Kubernetes manifest’leri güncellenir
- Cluster’a deploy edilir
- Health check yapılır
Basit görünüyor, ama şeytanın detaylarda gizlendiğini hep birlikte göreceğiz.
Proje Yapısı
Örnek olarak bir Node.js API servisi kullanacağız. Gerçek dünyada ne olduğunu göstermek için sadece “merhaba dünya” değil, database bağlantısı olan, birden fazla environment’ı olan bir senaryo işleyeceğiz.
Repository yapımız şöyle görünecek:
my-api/
├── .github/
│ └── workflows/
│ ├── ci.yml
│ └── deploy.yml
├── k8s/
│ ├── base/
│ │ ├── deployment.yaml
│ │ ├── service.yaml
│ │ └── configmap.yaml
│ ├── overlays/
│ │ ├── staging/
│ │ │ └── kustomization.yaml
│ │ └── production/
│ │ └── kustomization.yaml
├── src/
│ └── app.js
├── Dockerfile
└── package.json
Kustomize kullanmak büyük kolaylık sağlıyor. Staging ve production için ayrı manifest setleri tutmak yerine, base üzerinde overlay uyguluyorsunuz. Daha az tekrar, daha az hata.
Dockerfile Hazırlığı
Pipeline’a geçmeden önce sağlam bir Dockerfile’a ihtiyacımız var. Multi-stage build kullanmak hem image boyutunu küçültüyor hem de güvenlik açısından daha iyi bir yaklaşım.
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Production stage
FROM node:20-alpine AS production
RUN addgroup -g 1001 -S nodejs &&
adduser -S nodeuser -u 1001
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --chown=nodeuser:nodejs src/ ./src/
COPY --chown=nodeuser:nodejs package.json ./
USER nodeuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3
CMD node -e "require('http').get('http://localhost:3000/health', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))"
CMD ["node", "src/app.js"]
Burada dikkat edilmesi gereken noktalar: root olmayan kullanıcı kullanıyoruz, HEALTHCHECK tanımladık (Kubernetes bunu değil kendi probe’larını kullanır ama yine de iyi pratik), ve production dependencies’ı ayrı stage’de yükledik.
Kubernetes Manifest’leri
Base deployment manifest’imiz şöyle görünmeli:
# k8s/base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-api
labels:
app: my-api
spec:
replicas: 2
selector:
matchLabels:
app: my-api
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: my-api
spec:
containers:
- name: my-api
image: ghcr.io/myorg/my-api:latest
ports:
- containerPort: 3000
envFrom:
- configMapRef:
name: my-api-config
- secretRef:
name: my-api-secrets
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
terminationGracePeriodSeconds: 30
maxUnavailable: 0 ayarı önemli. Zero-downtime deployment istiyorsanız, rolling update sırasında mevcut pod’ların hiçbirinin kaldırılmamasını sağlar. maxSurge: 1 ise geçici olarak bir ekstra pod ayağa kaldırılmasına izin verir.
GitHub Actions: CI Workflow
İki ayrı workflow dosyası kullanacağız. Birincisi CI, yani test ve build. İkincisi deploy. Bu ayrımı yapmak daha fazla kontrol sağlıyor.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Run linting
run: npm run lint
build-and-push:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push'
permissions:
contents: read
packages: write
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
image-digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix={{branch}}-
type=semver,pattern={{version}}
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Burada birkaç önemli nokta var. cache-from ve cache-to ile GitHub Actions cache kullanıyoruz. Bu, özellikle büyük projelerde build süresini dramatik biçimde düşürüyor. İlk build 5 dakika sürerken, sonrakiler sadece değişen layer’ları build ediyor.
outputs bölümünde image tag ve digest’i sonraki job’lara aktarıyoruz. Digest kullanmak önemli, çünkü tag’ler mutable ama digest’ler immutable. Güvenlik açısından production’a hangi tam image’ın deploy edildiğini bilmek kritik.
GitHub Actions: Deploy Workflow
Deploy workflow’u biraz daha karmaşık. Staging ve production için farklı triggerlar kullanıyoruz.
# .github/workflows/deploy.yml
name: Deploy
on:
workflow_run:
workflows: ["CI"]
types:
- completed
branches: [ main, develop ]
jobs:
deploy-staging:
runs-on: ubuntu-latest
if: >
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'develop'
environment: staging
steps:
- uses: actions/checkout@v4
- name: Set up kubectl
uses: azure/setup-kubectl@v3
with:
version: 'v1.28.0'
- name: Configure kubectl
run: |
mkdir -p $HOME/.kube
echo "${{ secrets.KUBE_CONFIG_STAGING }}" | base64 -d > $HOME/.kube/config
chmod 600 $HOME/.kube/config
- name: Set up Kustomize
run: |
curl -sfLo kustomize.tar.gz https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.3.0/kustomize_v5.3.0_linux_amd64.tar.gz
tar -xzf kustomize.tar.gz
chmod +x kustomize
sudo mv kustomize /usr/local/bin/
- name: Update image tag
run: |
IMAGE_TAG="${{ github.event.workflow_run.head_sha }}"
SHORT_SHA="${IMAGE_TAG:0:7}"
cd k8s/overlays/staging
kustomize edit set image ghcr.io/myorg/my-api=ghcr.io/myorg/my-api:develop-${SHORT_SHA}
- name: Deploy to staging
run: |
kustomize build k8s/overlays/staging | kubectl apply -f -
- name: Wait for rollout
run: |
kubectl rollout status deployment/my-api -n staging --timeout=300s
- name: Run smoke tests
run: |
STAGING_URL="${{ vars.STAGING_URL }}"
for i in {1..5}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" $STAGING_URL/health)
if [ "$STATUS" = "200" ]; then
echo "Smoke test passed"
exit 0
fi
echo "Attempt $i failed, retrying..."
sleep 10
done
echo "Smoke tests failed"
exit 1
deploy-production:
runs-on: ubuntu-latest
if: >
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main'
environment: production
needs: []
steps:
- uses: actions/checkout@v4
- name: Set up kubectl
uses: azure/setup-kubectl@v3
with:
version: 'v1.28.0'
- name: Configure kubectl
run: |
mkdir -p $HOME/.kube
echo "${{ secrets.KUBE_CONFIG_PROD }}" | base64 -d > $HOME/.kube/config
chmod 600 $HOME/.kube/config
- name: Deploy to production
run: |
IMAGE_TAG="${{ github.event.workflow_run.head_sha }}"
SHORT_SHA="${IMAGE_TAG:0:7}"
cd k8s/overlays/production
kustomize edit set image ghcr.io/myorg/my-api=ghcr.io/myorg/my-api:main-${SHORT_SHA}
kustomize build . | kubectl apply -f -
- name: Wait for rollout
run: |
kubectl rollout status deployment/my-api -n production --timeout=300s
- name: Notify Slack on success
if: success()
uses: slackapi/[email protected]
with:
payload: |
{
"text": "Production deploy basarili: my-api ${{ github.event.workflow_run.head_sha }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Rollback on failure
if: failure()
run: |
echo "Deploy basarisiz, rollback yapiliyor..."
kubectl rollout undo deployment/my-api -n production
kubectl rollout status deployment/my-api -n production --timeout=180s
Production environment için GitHub’ın Environment Protection Rules özelliğini kullanın. GitHub repo ayarlarından “Environments” bölümüne girip production environment için required reviewers ekleyebilirsiniz. Bu sayede production’a her deploy için manuel onay gerekiyor.
Secrets Yönetimi
Kubernetes kubeconfig’ini GitHub Secrets’a eklemek için:
# Kubeconfig'i base64'e çevir ve kopyala
cat ~/.kube/config | base64 -w 0
# Ya da specific context için:
kubectl config view --raw --minify --flatten | base64 -w 0
Bu çıktıyı GitHub repo’nuzda Settings > Secrets and variables > Actions bölümüne KUBE_CONFIG_STAGING ve KUBE_CONFIG_PROD olarak ekleyin.
Kubernetes’te uygulama secret’ları için ise External Secrets Operator veya Sealed Secrets kullanmanızı öneririm. Düz secret YAML’ı repoya commit etmeyin, bu en yaygın yapılan hatalardan biri.
# Sealed Secrets kullanımı
# Controller'ı kur
kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.24.0/controller.yaml
# Secret'ı seal et
kubectl create secret generic my-api-secrets
--from-literal=DB_PASSWORD=supersecret
--dry-run=client -o yaml |
kubeseal --format yaml > k8s/base/sealed-secret.yaml
# Bu dosyayı artık repoya güvenle commit edebilirsin
git add k8s/base/sealed-secret.yaml
git commit -m "Add sealed secret for my-api"
Kustomize Overlay Yapısı
Staging ve production arasındaki farkları Kustomize overlay’leriyle yönetiyoruz:
# k8s/overlays/staging/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: staging
resources:
- ../../base
patches:
- patch: |-
- op: replace
path: /spec/replicas
value: 1
target:
kind: Deployment
name: my-api
- patch: |-
- op: replace
path: /spec/template/spec/containers/0/resources/limits/memory
value: "128Mi"
target:
kind: Deployment
name: my-api
configMapGenerator:
- name: my-api-config
behavior: merge
literals:
- NODE_ENV=staging
- LOG_LEVEL=debug
- API_URL=https://staging-api.mycompany.com
Production overlay biraz farklı:
# k8s/overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: production
resources:
- ../../base
patches:
- patch: |-
- op: replace
path: /spec/replicas
value: 3
target:
kind: Deployment
name: my-api
configMapGenerator:
- name: my-api-config
behavior: merge
literals:
- NODE_ENV=production
- LOG_LEVEL=warn
- API_URL=https://api.mycompany.com
Yaygın Sorunlar ve Çözümleri
“ImagePullBackOff” hatası aldığınızda genellikle iki sebep var: image gerçekten yok, ya da registry’e erişim izni yok. GitHub Container Registry için:
# Kubernetes'e registry credentials ekle
kubectl create secret docker-registry ghcr-secret
--docker-server=ghcr.io
--docker-username=YOUR_GITHUB_USERNAME
--docker-password=YOUR_PAT_TOKEN
--namespace=production
# Deployment'a ekle
kubectl patch serviceaccount default
-p '{"imagePullSecrets": [{"name": "ghcr-secret"}]}'
--namespace=production
Rollout takılıp kalıyorsa, çoğunlukla readiness probe başarısız oluyor demektir. Debug için:
# Pod durumunu kontrol et
kubectl describe pod -l app=my-api -n production
# Log'lara bak
kubectl logs -l app=my-api -n production --previous
# Rollout geçmişi
kubectl rollout history deployment/my-api -n production
# Belirli bir versiyona rollback
kubectl rollout undo deployment/my-api --to-revision=3 -n production
Pipeline Optimizasyonları
Büyük ekiplerde build süresi kritik hale geliyor. Birkaç pratik optimizasyon:
Paralel job’lar kullanın. Test suite’inizi unit, integration ve e2e olarak ayırın ve hepsini aynı anda çalıştırın. Job seviyesinde needs dependency’sini sadece gerçekten gerekli olduğunda kullanın.
Docker layer cache’ini akıllıca kullanın. Package.json ve package-lock.json’u önce kopyalayın, npm install’ı çalıştırın, sonra kaynak kodu kopyalayın. Bu klasik bir optimizasyon ama pek çok Dockerfile’da görmezden geliniyor.
Conditional deployment kullanın. Sadece ilgili servis değiştiyse deploy tetiklensin:
- name: Check if API changed
id: changes
uses: dorny/paths-filter@v3
with:
filters: |
api:
- 'src/**'
- 'Dockerfile'
- 'package*.json'
- name: Deploy API
if: steps.changes.outputs.api == 'true'
run: |
echo "API degisti, deploy basliyor..."
Monorepo kullanıyorsanız bu yaklaşım özellikle değerli. Her servis için ayrı paths filter tanımlayın ve sadece değişen servisleri deploy edin. Hem zaman kazanırsınız hem de değişmemiş servisleri gereksiz yere restart etmemiş olursunuz.
Güvenlik Notları
Pipeline güvenliği sıklıkla ihmal ediliyor. Birkaç temel önlem:
Minimum yetki prensibi. GitHub Actions workflow’unda kullanılan service account’ın yalnızca ihtiyaç duyduğu namespace’e ve kaynak türlerine erişimi olsun:
# Dedicated service account for CI/CD
apiVersion: v1
kind: ServiceAccount
metadata:
name: github-actions-deployer
namespace: production
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: deployer-role
namespace: production
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "update", "patch"]
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "apply"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: deployer-rolebinding
namespace: production
subjects:
- kind: ServiceAccount
name: github-actions-deployer
namespace: production
roleRef:
kind: Role
name: deployer-role
apiGroup: rbac.authorization.k8s.io
Image scanning ekleyin. Build sonrası Trivy ile vulnerability scan çalıştırmak iyi bir alışkanlık:
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: 'ghcr.io/myorg/my-api:${{ steps.meta.outputs.version }}'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1'
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: 'trivy-results.sarif'
Sonuç
GitHub Actions ile Kubernetes CI/CD pipeline’ı kurmak göründüğü kadar karmaşık değil, ama “çalışıyor gibi görünüyor” ile “production’da güvenle çalışıyor” arasındaki fark büyük. Bu yazıda anlattığımız yapıyla şunları elde ediyorsunuz:
- Her PR’da otomatik test ve lint kontrolü
- Main ve develop branch’lerine push’ta otomatik build ve registry push
- Staging’e otomatik, production’a onaylı deployment
- Başarısız deploy’larda otomatik rollback
- Image scanning ile güvenlik kontrolü
- Sealed Secrets ile git’te güvenli secret yönetimi
Buradan sonra ekleyebileceğiniz şeyler: Helm chart migration, ArgoCD veya Flux ile GitOps yaklaşımı, canary deployment stratejisi, otomatik performance testing. Ama önce bu temeli sağlam kurun. Temel sağlamsa üstüne her şeyi inşa edebilirsiniz.
Bir şeyi özellikle vurgulamak isterim: bu pipeline’ı kurup bırakmayın. Her birkaç ayda bir gözden geçirin, action versiyonlarını güncelleyin, Kubernetes ve kubectl versiyonlarını cluster’ınızla senkron tutun. CI/CD pipeline’ı da bir servis, bakım istiyor.
