들어가며
안녕하세요,
최근 회사에서 사내 개발망을 외부망으로 전환하는 작업을 담당하면서,
인프라를 직접 구축할 기회가 생겼습니다.
이왕 하는 거, "서버에 CI/CD 파이프라인을 완벽하게 자동화해보자!" 라는 원대한 목표를 세우게 되었죠...
사용하기로 한 스택은 다음과 같습니다.
- IaC: Ansible (서버 프로비저닝 자동화)
- 선언적 IaC인 Terrafrom은 사내 보안상 패키지 설치 등 os를 건드릴 염려가 있어 제외했습니다!
- 절차적 IaC인 Ansible로 진행하면 playbook상 이해도 쉽지 않을까 생각했습니다.
- CI: GitLab (SCM + Container Registry), Jenkins
- GitLab을 기본 VCS로 사용하고, Webhook으로 Jenkins에 연결하여 사용합니다.
- CD: ArgoCD, K3s
- 서버 리소스상 k8s가 경량화된 k3s를 사용했습니다. ArgoCD는 QA 환경을 모방합니다.
결과적으로 구축에는 성공했고, 현재 잘 돌아가고 있습니다.
그 과정에서 겪었던 시행착오들을 몇 가지 정리해 보려 합니다 😂
특히 GitLab 포트 충돌과 K3s의 런타임 이슈에서 조금 삽질을 했는데요.
저처럼 처음 구축하는 분들을 위해 트러블슈팅 로그를 남겨봅니다.
이런건 AI도 잘 모르더라구요❓
0. 전체 인프라 구상하기

- Code Push : Project A에 코드 push가 발생합니다.
- Image Build & Push : Jenkins가 빌드를 수행하고, GitLab Container Registry에 이미지를 업로드합니다.
- Manifest Update : 이미지 업로드가 성공하면, Jenkins가 Project B의 deployment.yaml 태그를 수정하여 커밋합니다.
- CD Sync: ArgoCD가 Project B의 Git 변경 사항을 감지하고, K3s 클러스터에 배포합니다.
이 모든 환경을 Ansible 하나로 구축하는 것이 이번 포스팅의 목표입니다!
1. Ansible로 인프라 자동화하기
우선 모든 설치 과정은 Ansible로 코드화했습니다.
나중에 서버가 바뀌더라도 playbook 하나면 똑같은 환경을 찍어낼 수 있다는 점이 너무 매력적이지 않나요?
메인 서버에서 바로 돌릴 것이기 때문에 ansible_connection=local 옵션을 사용했습니다.
마음같아선 최소 2개의 인스턴스 환경에서 작업을 하고 싶었지만... 어쩔 수 없죠!
ansible_connection=local 이란?
> Ansible이 SSH를 통하지 않고, 로컬호스트에 직접 명령을 내릴 때 사용하는 옵션입니다.
# main-playbook.yml
- name: 1. Docker 설치 및 환경 구성
hosts: localhost
become: yes
roles:
- 1-docker
- name: 2. GitLab, Jenkins 컨테이너 실행 (Docker Compose)
hosts: localhost
roles:
- 2-cicd-tools
- name: 3. K3s 및 ArgoCD 환경 구축 (K8s)
hosts: localhost
become: yes
roles:
- 3-k3s
- 4-argocd
위처럼 mainPlayBook 설정을 해 놓고,
각 roles 내에 스크립트들을 작성 해 놨습니다.
결국 이 스크립트 하나만 실행하면
- Docker가 설치되고,
- GitLab과 Jenkins가 Docker Compose로 뜨고,
- K3s가 설치된 뒤,
- ArgoCD까지 자동으로 배포되도록 구성됩니다.
인프라 설치 끝!
이라며 좋아했던 것도 잠시...
2. GitLab Container Registry와 Nginx의 충돌
Jenkins에서 열심히 빌드한 Docker 이미지를 사내 GitLab Registry에 push 하려는데,
계속 404 Not Found 에러가 뜨는 겁니다.
로그인은 되는데 푸시만 안 되는... 정말 기이한 상황이었죠.
원인
범인은 바로 포트 설정이었습니다.
GitLab 웹 UI 포트와 Registry 포트를 둘 다 8081로 설정해버린 게 화근이었어요.
GitLab 내부의 Nginx가 docker push 요청을 받고는,
"어? 이거 웹 페이지 요청인가?" 하고 엉뚱한 곳으로 보내버리고 있었던 겁니다.
해결: 포트 분리
결국 docker-compose.yml을 수정해서 두 서비스의 포트를 명확히 찢어놓았습니다.
- GitLab Web: 8081
- Registry: 5005
services:
gitlab:
ports:
- "8081:8081" # 웹 접속용
- "5005:5005" # 레지스트리용
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'http://xxx.xxx.xxx.xxx:8081'
registry_external_url 'http://xxx.xxx.xxx.xxx:5005'
gitlab_registry_enable = true
그리고, 별도 도메인을 사용하지 않아 SSL 인증서 발급이 어려워 HTTP를 사용했는데요.
Docker는 기본적으로 HTTPS만 신뢰하기 때문에, 호스트의 /etc/docker/daemon.json에 아래 설정을 추가해줘야 합니다.
"insecure-registries": ["xxx.xxx.xxx.xxx:5005"]
insecure-registries 란?
HTTP로 통신하는 레지스트리에 접속을 허용하는 옵션입니다.
외부 인터넷 연결은 차단된 폐쇄망 환경이라, 보안보다는 편의성을 택했습니다.
3. 인증.. Token이 왜 이렇게 많아?
포트 문제를 해결하고 나니, 이번엔 denied 에러가 저를 반겨줍니다.
알고 보니 GitLab에는 토큰의 종류가 여러 가지였고, 저는 그걸 혼동해서 쓰고 있었더라고요.
Deploy Token vs Access Token
| 토큰 종류 | 용도 | 필요한 권한 |
|---|---|---|
| Deploy Token | 이미지 Push (Docker Login) | write_registry |
| Project Access Token | Git Push (Manifest Update) | write_repository |
- 이미지 올릴 때: Jenkins가 Docker 이미지를 레지스트리에 올릴 땐 Deploy Token을 써야 합니다.
- Git 수정할 때: Jenkins가 ArgoCD가 바라보는
deployment.yaml파일(버전 태그)을 수정해서 Git에 올릴 땐 Project Access Token이 필요합니다.
Protected Branch 함정
"토큰 권한 다 줬는데 왜 안 되지?" 의 원인이었는데,
GitLab의 main이나 develop 브랜치는 기본적으로 Protected 상태입니다.
Settings > Repository > Protected branches 메뉴에서
해당 토큰이 Protected 브랜치에 푸시할 수 있도록 Roles 권한을 확인 해 줘야 합니다.
저는 Maintainers만 가능하도록 설정 해 놓고 왜.. 계속 Denied가 나오지 하면서 찾아다녔네요.. 😅
4. 쿠버네티스는 Docker를 쓰지 않는다
ArgoCD까지 연동을 마쳤습니다.
이제 K3s 클러스터에 파드(Pod)가 예쁘게 떠야 하는데...
ImagePullBackOff 상태에서 무한 로딩이 걸립니다.
분명 위에서 daemon.json에 insecure-registries 설정을 했잖아요?
호스트에서는 docker pull이 잘 되는데, 왜 K3s만 못 가져올까요?
'containerd'
범인은 K3s의 런타임이었습니다.
K3s는 기본 컨테이너 런타임으로 Docker가 아닌 containerd를 사용합니다.
그러니 /etc/docker/daemon.json을 수정해봐야 K3s는 쳐다도 안 보는 것이었죠...
도커 지원중단 관련 쿠버네티스 블로그 주소
https://kubernetes.io/blog/2022/02/17/dockershim-faq
해결: registries.yaml
K3s를 위한 레지스트리 설정은 /etc/rancher/k3s/registries.yaml이라는 별도 파일에 해줘야 합니다.
mirrors:
"xxx.xxx.xxx.xxx:5005":
endpoint:
- "http://xxx.xxx.xxx.xxx:5005"
이 설정을 해주고 K3s를 재시작하니, 그제야 이미지를 정상적으로 당겨오기 시작했습니다.
추가로 K8s 내부에 키값을 가진 imagePullSecrets를 생성하여 deployment에 넣어주어야 Registry에 접근할 수 있습니다.
5. 데이터 날아가면 안 되니까 (백업 자동화)
마지막으로, 새벽에 동작하는 백업 스크립트를 Ansible에 추가했습니다.
GitLab과 Jenkins 데이터가 날아가면 안 되니까요.
그런데 Jenkins 백업 시 주의할 점이,
Jenkins는 유휴 상태일 때도 SCM 폴링이나 실시간 로그 기록, 내부 DB 갱신 등 백그라운드 작업을 계속 수행하며 프로세스를 점유하고 있습니다.
이때 tar로 파일을 묶으려 하면 "File changed as we read it" 오류가 발생하거나, 데이터 정합성이 깨져서 막상 복구하려 할 때 실패할 수 있습니다.
그래서, 백업 스크립트 내 컨테이너를 잠시 멈추는 코드를 넣는 방식을 택했습니다.
#!/bin/bash
# Jenkins 잠시 중지
docker-compose stop jenkins
tar -czf $BACKUP_DIR/jenkins_backup_$TIMESTAMP.tar.gz -C $SOURCE_DIR .
# 백업 후 재시작
docker-compose start jenkins
"서비스가 중단되는 거 아닌가요?" 라고 하실 수 있지만,
새벽 2시에 빌드 돌릴 사람은 없다고 판단해서 과감하게 멈췄습니다.
반면 GitLab은 gitlab-backup create라는 명령어가 있어서 중단 없이 편하게 가능하더라구요. 👍
마무리
[Code Push] -> [Jenkins Build] -> [Docker Push] -> [Manifest Update] -> [ArgoCD Sync] -> [K3s Deploy]

이 한 줄의 파이프라인을 완성하기 위해 겪었던 수많은 에러...
당시엔 머리를 쥐어뜯었지만, 덕분에 Docker와 K3s, GitLab의 권한 체계 등을 부딪히며 이해하게 된 것 같습니다.
혹시 저처럼 온프레미스 환경에서 맨땅에 CI/CD를 구축하려는 분들이 계신다면,
이 삽질 기록이 조금이나마 도움이 되길 바랍니다. 😊
틀린 부분이나 더 좋은 방법이 있다면 댓글로 알려주세요!
감사합니다.
