저번 포스트까지 Go의 Chromedp 라이브러리를 활용하여 스크래핑 코드를 작성하는 방법을 알아보았다. 이번 포스트에서는 작성한 코드를 docker 이미지를 굽고, 이 이미지를 자동으로 Lambda에 배포하는 방법을 알아보고자 한다.
Chromedp 코드 Dockerize 하기
Chromedp 코드를 dockerize 하는 이유는 AWS Lambda 환경에서 스크래퍼 코드가 안정적으로 잘 돌아가게 만들기 위함이다. Lambda 위에서 가상 환경을 만들고 해당 환경 안에서 스크래핑을 진행할 수 있게 하는 것이다. 스크래핑을 위해선 Chrome 프로세스를 띄우고 이 프로세스를 조작하면서 정보를 긁어와야 하는데, Lambda와 같은 FaaS 서비스의 경우 임의의 프로세스를 띄우는 것을 막고 있다. 실제로 그냥 쌩으로 돌리면 아래와 같은 에러를 만나게 된다.
fork/exec /usr/bin/google-chrome: operation not permitted
(ChatGPT에 물어보니 임의의 프로세스를 실행하는 것은 악의적인 코드가 시스템 리소스에 접근하거나 다른 사용자의 데이터를 침해할 수 있어 보안을 위협하고 / 리소스 사용량을 예측하거나 제한하기 어렵게 만든다는 점이 주요 이유인 것 같았다. 아무래도 FaaS가 멀티 테넌트 환경이다 보니 특히 더 그런듯)
하지만 코드를 하루에 한 번 30분 동안만 돌리면 되는(심지어 볼륨이 그렇게 크지도 않은) 작업을 위해 EC2를 띄우는 것은 낭비라고 생각하였다. 물론 EC2 t2.micro로도 스크래퍼를 돌릴 수 있을 것 같긴 하였지만 스펙상(vCPU1, 메모리 1GiB, 네트워크 1Gbps) 조금 더 느리게 돌아갈 것이라고 생각했고, 무엇보다 여기에서 포기하면 기껏 성능을 높이기 위해 Python에서 Go로 코드를 리팩토링 한 의미가 거의 없어졌기 때문에 Lambda로 돌릴 수 있는 방법을 찾고 또 찾았다.
방법은 크게 두 가지가 있는 것 같았다. 1) Docker Container를 띄우는 방법 2) Lambda Layer에 패키지나 의존성을 넣는 방법. Layer를 사용하는 방법은 주로 JS 진영에서 Puppeteer를 사용할 때 많이 사용하는 것 같았다. Node.js를 활용하는 예시는 많았지만, Go(특히 chromedp와 조합하여) 사용하는 예제 등은 나와 있지 않아 사용하기가 꺼려졌다.
https://www.techmagic.co/blog/running-headless-chrome-with-aws-lambda-layers/
https://github.com/shelfio/chrome-aws-lambda-layer#available-regions
반면 Container를 활용한 방법의 경우 완전 성공하지는 않았지만, chromedp를 사용하여 이미지를 구운 사례도 있었고, Docker를 연습하고자 하는 개인적인 욕심도 있었기 때문에 1번 방법을 택하였다.
최종적으로 작성한 Dockerfile과 스크래핑 코드(필요한 부분만 일부 가져옴)는 아래와 같다
Dockerfile
FROM golang:1.20.4-alpine3.17 AS builder
ENV GO111MODULE=on \
CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64
WORKDIR /app
RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/*
COPY go.mod go.sum main.go ./
RUN go mod download
COPY . .
RUN go build -o main
FROM chromedp/headless-shell:113.0.5672.93
WORKDIR /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /app/main .
ENTRYPOINT [ "./main" ]
Script
func getPage(URL string, lineNum string, stationNm string) {
// settings for crawling
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.NoSandbox,
chromedp.Flag("disable-setuid-sandbox", true),
chromedp.Flag("disable-dev-shm-usage", true),
chromedp.Flag("single-process", true),
chromedp.Flag("no-zygote", true),
)
alloCtx, _ := chromedp.NewExecAllocator(context.Background(), opts...)
ctx, cancel := chromedp.NewContext(alloCtx, chromedp.WithLogf(log.Printf))
defer cancel()
var htmlContent string
ch := chromedp.WaitNewTarget(ctx, func(i *target.Info) bool {
return strings.Contains(i.URL, "/timetable/web/")
})
err := chromedp.Run(ctx,
chromedp.Navigate(URL),
chromedp.WaitVisible(".end_footer_area"),
chromedp.Click("button"),
)
checkErr(err)
newContext, cancel := chromedp.NewContext(ctx, chromedp.WithTargetID(<-ch))
defer cancel()
if err := chromedp.Run(newContext,
chromedp.WaitReady(".table_schedule", chromedp.ByQuery),
chromedp.OuterHTML(".schedule_wrap", &htmlContent, chromedp.ByQuery),
); err != nil {
panic(err)
}
crawler(htmlContent, lineNum, stationNm)
}
Dockerfile
Multistage Build를 활용한 이미지 경량화하기
도커 이미지 안에 여러 의존성들을 넣다보면 이미지의 크기가 매우 커지는 경우가 많다. Multistage Build는 빌드에만 필요한 의존성들은 모두 날리고 최종적으로는 실행에 필요한 것들만 남겨 이미지를 경량화하는 방법이다. (보통 Golang 코드에 대한 이미지를 구울 때 많이 사용하는 것 같았다. 코드 컴파일을 위해선 go를 다운받아야 하는데 이 파일의 용량이 꽤 크기 때문인 것 같음)
FROM golang:1.20.4-alpine3.17 AS builder
ENV GO111MODULE=on \
CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64
WORKDIR /app
RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/*
COPY go.mod go.sum main.go ./
RUN go mod download
COPY . .
RUN go build -o main
위 파트에서는 Go에 대한 환경변수를 설정하고, go.mod go.sum main.go를 copy 하여 컴파일한다. 또한 Chrome에서 스크래핑을 진행해야 하기 때문에 RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/* 를 추가하여 HTTPS 연결 허용을 위한 CA 인증서를 설치한다. (rm -rf /var/cache/apk/* 는 임시 캐시 디렉토리를 삭제함으로써 이미지 크기를 줄이는 목적으로 쓰였다. 멀티스테이지 빌드를 통해 이미지를 경량화 할 예정이지만, 필요하지 않은 파일을 미리 제거함으로써 빌드 과정 중 시간을 절약하고 이후 도커 이미지 빌드를 다시 할 때, 즉 첫번째 스테이지 결과물을 캐시할 때 깔끔한 캐시 결과물을 남길 수 있다는 장점이 있다)
FROM chromedp/headless-shell:113.0.5672.93
WORKDIR /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /app/main .
ENTRYPOINT [ "./main" ]
첫번째 스테이지에서 만들어진 것들 중 최종 이미지에서 필요한 것들만 COPY --from=builder를 통해서 가져온다. 이렇게 하면 빌드할 때만 필요한 것들은 제거하고 온전히 실행에 필요한 것들만 남겨 이미지를 경량화할 수 있다.
Script
사실 코드에서는 무엇보다 Flag 설정이 중요했는데, 이는 다른 사람들의 자료를 통해 감으로 때려맞혀야 하는 수밖에 없는 것 같았다. chromedp + chrome 개발자 도구 역할이 뒤죽박죽 섞여있는 느낌이었고, 무엇보다 성공한 조합 공유가 거의 없었다;; 나는 아래 자료들과 수많은 실험끝에 flag 조합을 찾아내어 사용하였다...
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.NoSandbox,
chromedp.Flag("disable-setuid-sandbox", true),
chromedp.Flag("disable-dev-shm-usage", true),
chromedp.Flag("single-process", true),
chromedp.Flag("no-zygote", true),
)
- 참고자료 1: Problem with aws lambda
- 참고자료 2: chromedp-aws-lambda-example
GitHub Actions와 ECR을 활용한 배포 자동화
배포 자동화를 한 이유는 간단하다. 귀찮고 번거로우니까....
파이썬 코드의 경우 AWS 웹페이지에서 직접 적을 수 있었지만, Go와 같은 컴파일 언어의 경우 컴파일한 결과물을 zip으로 만들어서(!) 업로드 해야 했다. Docker 이미지를 올려야 하는 경우 ECR(AWS의 Docker Hub)에 이미지를 업로드 하고, Lambda에서 클릭 클릭하여 이미지를 교체해야 했다. 즉, 로컬에서 이미지를 굽고 -> ECR에 들어가서 업로드 하고 -> Lambda에 들어가서 이미지를 재선택 해야 했다. 말이 쉽지 생각보다 꽤 오래걸리고 귀찮은 작업이다. 특히 Lambda 위에서 잘 돌아가는지 체크하고 코드를 수정하는 과정이 반복되었는데 이 작업들이 큰 걸림돌이 되었다.
이 작업을 자동화 하기 위해 GitHub Actions를 사용하기로 하였다. GitHub의 Main 브랜치에 push를 하면 자동으로 도커 이미지가 빌드되어 ECR에 업로드, Lambda에 배포까지 자동으로 진행되는 방식으로 자동화하였다.
대략적인 흐름과 YAML은 아래와 같다
name: AWS Lambda Deployment
on:
push:
branches: [ "main" ]
env:
AWS_REGION: ${{ secrets.AWS_REGION }}
ECR_REPOSITORY: ${{ secrets.AWS_ECR_REPOSITORY }}
LAMBDA_FUNCTION_NAME: ${{ secrets.AWS_LAMBDA_FUNCTION_NAME }}
permissions:
contents: read
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v1
- name: Build, tag, and push image to Amazon ECR and deploy to AWS Lambda
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
aws lambda update-function-code --function-name ${{ env.LAMBDA_FUNCTION_NAME }} --image-uri ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
AWS Credential 정보의 경우 Actions Secret과 echo를 활용하여 관리하였다. .env 파일에 존재하는 환경 변수를 Actions Secret에 넣고, Action이 돌아갈 때 echo를 활용하여 환경 변수 파일이 그 안에서만 생성, 그걸 바탕으로 build하는 방법인데, 자세한 방법은 아래 포스트에!
https://kyumcoding.tistory.com/48