前言
随着 Docker 和 K8S 的普及,云原生时代已经到来,开发工程师对应用环境的掌控力进一步加强,运维成本进一步降低。DevOps 采用容器技术更是如虎添翼,持续集成更快更灵活,部署更简单。大多数会选择使用 Jenkins 完成持续集成能力。
场景
在搭建一个云原生 DevOps 平台的时候,不管是 SaaS 化还是私有化的都需要部署前后端项目和基础设施(mysql、rabbitmq...)等。如果该 DevOps 平台要是使用了 Jenkins,则需要部署一套 Jenkins。如果使用一个个的手工部署,部署效率低下,并且出错概率大。如果使用一键部署方式,编写好一键部署脚本,可能只需要做少量的配置,一行命令,一个脚本多处运行。但是如果使用一键部署,Jenkins 初始化时是一个无任何配置的,比如账号密码、 API Token、DevOps 执行 pipeline 需要用到的插件等。难道我们每次部署 Jenkins 都需要人工去操作一遍吗?有没有什么更好的方式能做到 Jenkins 开箱即用?
分析问题
根据以上场景进行分析,搭建云原生 DevOps 平台遇到的主要问题:
• 默认无账号密码和 API Token,无法调用 Jenkins API
• 私有化部署时,如果无外网,Jenkins 插件如何安装
• 使用 kubernetes-plugin 插件,默认无 Cloud 相关配置,无法调用 K8S 创建 slave pod
解决问题
通过定制化 Jenkins 镜像可以做:
• 创建好的账号密码和 API Token,通过一键部署 DevOps 服务和 Jenkins,即可通过账号密码或 API Token 调用 Jenkins API
• 安装好需要的插件,就算在无外网环境下,也无需下载插件了
• 配置好 K8S 相关配置,可以快速使用 Jenkins kubernetes-plugin 插件
如何制作镜像
Jenkins dockerfile 参考
请参考官方提供 ‣‣‣ Jenkins dockerfile
项目目录如下:
可以根据具体情况选择不同的 dockerfile,如我选择的是 Dockerfile-alpine
,基于 Dockerfile-alpine
做定制配置。
官方 Dockerfile-alpine
文件内容如下:
FROM openjdk:8-jdk-alpine
RUN apk add --no-cache \
bash \
coreutils \
curl \
git \
git-lfs \
openssh-client \
tini \
ttf-dejavu \
tzdata \
unzip
ARG user=jenkins
ARG group=jenkins
ARG uid=1000
ARG gid=1000
ARG http_port=8080
ARG agent_port=50000
ARG JENKINS_HOME=/var/jenkins_home
ARG REF=/usr/share/jenkins/ref
ENV JENKINS_HOME $JENKINS_HOME
ENV JENKINS_SLAVE_AGENT_PORT ${agent_port}
ENV REF $REF
# Jenkins is run with user `jenkins`, uid = 1000
# If you bind mount a volume from the host or a data container,
# ensure you use the same uid
RUN mkdir -p $JENKINS_HOME \
&& chown ${uid}:${gid} $JENKINS_HOME \
&& addgroup -g ${gid} ${group} \
&& adduser -h "$JENKINS_HOME" -u ${uid} -G ${group} -s /bin/bash -D ${user}
# Jenkins home directory is a volume, so configuration and build history
# can be persisted and survive image upgrades
VOLUME $JENKINS_HOME
# $REF (defaults to `/usr/share/jenkins/ref/`) contains all reference configuration we want
# to set on a fresh new installation. Use it to bundle additional plugins
# or config file with your custom jenkins Docker image.
RUN mkdir -p ${REF}/init.groovy.d
# jenkins version being bundled in this docker image
ARG JENKINS_VERSION
ENV JENKINS_VERSION ${JENKINS_VERSION:-2.60.3}
# jenkins.war checksum, download will be validated using it
ARG JENKINS_SHA=2d71b8f87c8417f9303a73d52901a59678ee6c0eefcf7325efed6035ff39372a
# Can be used to customize where jenkins.war get downloaded from
ARG JENKINS_URL=https://repo.jenkins-ci.org/public/org/jenkins-ci/main/jenkins-war/${JENKINS_VERSION}/jenkins-war-${JENKINS_VERSION}.war
# could use ADD but this one does not check Last-Modified header neither does it allow to control checksum
# see https://github.com/docker/docker/issues/8331
RUN curl -fsSL ${JENKINS_URL} -o /usr/share/jenkins/jenkins.war \
&& echo "${JENKINS_SHA} /usr/share/jenkins/jenkins.war" | sha256sum -c -
ENV JENKINS_UC https://updates.jenkins.io
ENV JENKINS_UC_EXPERIMENTAL=https://updates.jenkins.io/experimental
ENV JENKINS_INCREMENTALS_REPO_MIRROR=https://repo.jenkins-ci.org/incrementals
RUN chown -R ${user} "$JENKINS_HOME" "$REF"
# for main web interface:
EXPOSE ${http_port}
# will be used by attached slave agents:
EXPOSE ${agent_port}
ENV COPY_REFERENCE_FILE_LOG $JENKINS_HOME/copy_reference_file.log
USER ${user}
COPY jenkins-support /usr/local/bin/jenkins-support
COPY jenkins.sh /usr/local/bin/jenkins.sh
COPY tini-shim.sh /bin/tini
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/jenkins.sh"]
# from a derived Dockerfile, can use `RUN plugins.sh active.txt` to setup $REF/plugins from a support bundle
COPY plugins.sh /usr/local/bin/plugins.sh
COPY install-plugins.sh /usr/local/bin/install-plugins.sh
从文件内容中可分析出
-
ENV JENKINS_VERSION ${JENKINS_VERSION:-2.60.3}
指定了 Jenkins 版本 -
ARG JENKINS_SHA=2d71b8f87c8417f9303a73d52901a59678ee6c0eefcf7325efed6035ff39372a
配置了 jenkins.war 的 sha256 值,用于校验下载的 Jenkins.war sha256 和配置用于校验的 sha256 是否一致
定制步骤
- 运行
docker run -p 8080:8080 -d -v /root/jenkins_home:/var/jenkins_home jenkins/jenkins:${JENKINS_VERSION}
,将 Jenkins 通过 docker 运行起来,并且把 jenkins_home 目录挂载出来,便于我们获取到 Jenkins 数据,后面的步骤中还需使用这些数据 - 通过 Jenkins Web 端进行操作,配置好账号密码,安装所需插件,配置所需信息等
- 选择需要的 Jenkins 版本,通过修改
ENV JENKINS_VERSION ${JENKINS_VERSION:-2.60.3}
配置中的版本号 - 通过 wget
https://repo.jenkins-ci.org/public/org/jenkins-ci/main/jenkins-war/${JENKINS_VERSION}/jenkins-war-${JENKINS_VERSION}.war
获取到 jenkins.war,执行sha256sum jenkins-war-${JENKINS_VERSION}.war
获取 jenkins.war sha256 值,并且填写到ARG JENKINS_SHA
配置中 - 在
COPY jenkins-support /usr/local/bin/jenkins-support
前面添加COPY ${PATH}/jenkins_home /var/jenkins_home
,将定制数据打入镜像中
如果不想每次都去远程下载 jenkins.war,可以先手动下载,再 COPY 到镜像中
如需指定时区,配置以下内容即可
RUN ln -sf /usr/share/zoneinfo/Asia/ShangHai /etc/localtime
RUN echo "Asia/Shanghai" > /etc/timezone
这样我们就完成了第一步,将需要的数据已经初始化到镜像中,只要通过这个 Jenkins 镜像部署的,都可以达到开箱即用的目的,那么我们继续说一下如何部署?不同的部署方式需要注意什么事项?
如何部署
Docker
使用 docker 或者 docker-compose 部署 Jenkins,如果需要数据持久化,只需要通过 volume 将 /var/jenkins_home 目录挂载到主机即可。
eg:
docker run -d -p 8080:8080 自定义镜像地址
K8S
通过 K8S 部署 Jenkins,可持久化也可不持久化,按实际情况决定。
非持久化
eg:
apiVersion: apps/v1
kind: Deployment
metadata:
name: jenkins
annotations:
app: jenkins
labels:
app: jenkins
spec:
replicas: 1
selector:
matchLabels:
app: jenkins
template:
metadata:
labels:
app: jenkins
spec:
serviceAccountName: ${serviceAccount}
containers:
- name: jenkins
image: ${自定义镜像地址}
imagePullPolicy: Always
securityContext:
runAsUser: 0
privileged: true
ports:
- name: web
containerPort: 8080
- name: agent
containerPort: 50000
env:
- name: "JAVA_OPTS"
value: "
-Dhudson.model.LoadStatistics.clock=2000
-Dhudson.slaves.NodeProvisioner.recurrencePeriod=5000
-Dhudson.slaves.NodeProvisioner.initialDelay=0
-Dhudson.model.LoadStatistics.decay=0.5
-Dhudson.slaves.NodeProvisioner.MARGIN=50
-Dhudson.slaves.NodeProvisioner.MARGIN0=0.85
-Duser.timezone=Asia/Shanghai
"
持久化
eg:
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: jenkins
annotations:
app: jenkins
labels:
app: jenkins
spec:
replicas: 1
strategy:
type: RollingUpdate
selector:
matchLabels:
app: jenkins
template:
metadata:
labels:
app: jenkins
spec:
securityContext:
runAsUser: 0
serviceAccountName: ${serviceAccount}
initContainers:
- name: backup-data
image: {自定义镜像地址}
imagePullPolicy: Always
command: [ "cp", "-r", "/var/jenkins_home", "/var/jenkins_home_bak"]
volumeMounts:
- mountPath: /var/jenkins_home_bak
name: jenkins-home-bak
- name: revert-data
image: {自定义镜像地址}
imagePullPolicy: Always
command: [ "cp", "-r", "/var/jenkins_home_bak/jenkins_home", "/var"]
volumeMounts:
- mountPath: /var/jenkins_home_bak
name: jenkins-home-bak
- mountPath: /var/jenkins_home
name: jenkins-home
- name: copy-default-config
image: {自定义镜像地址}
imagePullPolicy: Always
command: [ "sh", "/var/jenkins_config/apply_config.sh" ]
volumeMounts:
- mountPath: /var/jenkins_home
name: jenkins-home
- mountPath: /var/jenkins_config
name: jenkins-config
- mountPath: /usr/share/jenkins/ref/plugins/
name: plugin-dir
- mountPath: /usr/share/jenkins/ref/secrets/
name: secrets-dir
containers:
- name: jenkins
image: {自定义镜像地址}
imagePullPolicy: Always
env:
- name: JAVA_OPTS
value: "-Dhudson.model.LoadStatistics.clock=2000 -Dhudson.slaves.NodeProvisioner.recurrencePeriod=5000 -Dhudson.slaves.NodeProvisioner.initialDelay=0 -Dhudson.model.LoadStatistics.decay=0.5 -Dhudson.slaves.NodeProvisioner.MARGIN=50 -Dhudson.slaves.NodeProvisioner.MARGIN0=0.85 -Duser.timezone=Asia/Shanghai
"
ports:
- containerPort: 8080
name: http
- containerPort: 50000
name: slavelistener
livenessProbe:
httpGet:
path: /login
port: http
initialDelaySeconds: 5
readinessProbe:
httpGet:
path: /login
port: http
initialDelaySeconds: 5
volumeMounts:
- mountPath: /var/jenkins_home
name: jenkins-home
readOnly: false
- mountPath: /var/jenkins_config
name: jenkins-config
readOnly: true
- mountPath: /usr/share/jenkins/ref/plugins/
name: plugin-dir
readOnly: false
- mountPath: /usr/share/jenkins/ref/secrets/
name: secrets-dir
readOnly: false
volumes:
- name: jenkins-config
configMap:
name: jenkins-config
- name: plugin-dir
emptyDir: {}
- name: secrets-dir
emptyDir: {}
- name: jenkins-home-bak
emptyDir: {}
- name: jenkins-home
persistentVolumeClaim:
claimName: jenkins // 使用 PVC 进行持久化
---
# config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: jenkins-config
data:
config.xml: |-
<?xml version='1.0' encoding='UTF-8'?>
<hudson>
<disabledAdministrativeMonitors/>
<version>2.222.3-alpine</version>
<installStateName>RUNNING</installStateName>
<numExecutors>10</numExecutors>
<mode>NORMAL</mode>
<useSecurity>true</useSecurity>
<authorizationStrategy class="hudson.security.FullControlOnceLoggedInAuthorizationStrategy">
<denyAnonymousReadAccess>true</denyAnonymousReadAccess>
</authorizationStrategy>
<securityRealm class="hudson.security.HudsonPrivateSecurityRealm">
<disableSignup>true</disableSignup>
<enableCaptcha>false</enableCaptcha>
</securityRealm>
<disableRememberMe>false</disableRememberMe>
<projectNamingStrategy class="jenkins.model.ProjectNamingStrategy$DefaultProjectNamingStrategy"/>
<workspaceDir>${JENKINS_HOME}/workspace/${ITEM_FULLNAME}</workspaceDir>
<buildsDir>${ITEM_ROOTDIR}/builds</buildsDir>
<markupFormatter class="hudson.markup.EscapedMarkupFormatter"/>
<jdks/>
<viewsTabBar class="hudson.views.DefaultViewsTabBar"/>
<myViewsTabBar class="hudson.views.DefaultMyViewsTabBar"/>
<clouds>
<org.csanchez.jenkins.plugins.kubernetes.KubernetesCloud plugin="kubernetes@">
<name>kubernetes</name>
<templates>
<org.csanchez.jenkins.plugins.kubernetes.PodTemplate>
<inheritFrom></inheritFrom>
<name>default</name>
<instanceCap>2147483647</instanceCap>
<idleMinutes>0</idleMinutes>
<label>jenkins-jenkins-slave</label>
<nodeSelector></nodeSelector>
<nodeUsageMode>NORMAL</nodeUsageMode>
<volumes>
</volumes>
<containers>
<org.csanchez.jenkins.plugins.kubernetes.ContainerTemplate>
<name>jnlp</name>
<image>jenkins/jnlp-slave:4.0.1-1</image>
<privileged>false</privileged>
<alwaysPullImage>false</alwaysPullImage>
<workingDir>/home/jenkins</workingDir>
<command></command>
<args>${computer.jnlpmac} ${computer.name}</args>
<ttyEnabled>false</ttyEnabled>
<resourceRequestCpu>200m</resourceRequestCpu>
<resourceRequestMemory>256Mi</resourceRequestMemory>
<resourceLimitCpu>200m</resourceLimitCpu>
<resourceLimitMemory>256Mi</resourceLimitMemory>
<envVars>
<org.csanchez.jenkins.plugins.kubernetes.ContainerEnvVar>
<key>JENKINS_URL</key>
<value>http://jenkins:8080</value>
</org.csanchez.jenkins.plugins.kubernetes.ContainerEnvVar>
</envVars>
</org.csanchez.jenkins.plugins.kubernetes.ContainerTemplate>
</containers>
<envVars/>
<annotations/>
<imagePullSecrets/>
<nodeProperties/>
</org.csanchez.jenkins.plugins.kubernetes.PodTemplate></templates>
<serverUrl>https://kubernetes.default</serverUrl>
<skipTlsVerify>false</skipTlsVerify>
<namespace>default</namespace>
<jenkinsUrl>http://jenkins:8080</jenkinsUrl>
<jenkinsTunnel>jenkins-agent:50000</jenkinsTunnel>
<containerCap>10</containerCap>
<retentionTimeout>5</retentionTimeout>
<connectTimeout>0</connectTimeout>
<readTimeout>0</readTimeout>
</org.csanchez.jenkins.plugins.kubernetes.KubernetesCloud>
</clouds>
<quietPeriod>5</quietPeriod>
<scmCheckoutRetryCount>0</scmCheckoutRetryCount>
<views>
<hudson.model.AllView>
<owner class="hudson" reference="../../.."/>
<name>All</name>
<filterExecutors>false</filterExecutors>
<filterQueue>false</filterQueue>
<properties class="hudson.model.View$PropertyList"/>
</hudson.model.AllView>
</views>
<primaryView>All</primaryView>
<slaveAgentPort>50000</slaveAgentPort>
<label></label>
<nodeProperties/>
<globalNodeProperties/>
<noUsageStatistics>true</noUsageStatistics>
</hudson>
apply_config.sh: |-
mkdir -p /usr/share/jenkins/ref/secrets/;
echo "false" > /usr/share/jenkins/ref/secrets/slave-to-master-security-kill-switch;
cp -f /var/jenkins_config/config.xml /var/jenkins_home;
plugins.txt: |-
可通过 PVC 进行持久化,也可通过 nodeSelector 选择一台主机通过 volume 进行挂载
Helm
请参考官方提供 ‣‣‣ jenkins-helm-charts
由于官方提供的 helm charts 不会保留自定义镜像中的数据,需要我们通过改造官方脚本进行自定义,在templates/jenkins-master-deployment.yaml
的 initContainers
中,将自定义的 config.xml
覆盖容器中的,并且执行 config.yaml
中定义的脚本。这样只能做到,自定义 config.xml 文件和在线安装插件等操作,由于挂载了 /var/jenkins_home
目录,且挂载的是一个空目录,这样会导致原镜像中的数据也置空,基于这个目的,我们进行 templates/jenkins-master-deployment.yaml
的改造。
templates/jenkins-master-deployment.yaml
文件需要新增内容如下:
...
initContainers:
- name: "backup-data"
image: "{{ .Values.Master.Image }}:{{ .Values.Master.ImageTag }}"
imagePullPolicy: "{{ .Values.Master.ImagePullPolicy }}"
command: [ "cp", "-r", "/var/jenkins_home", "/var/jenkins_home_bak"]
volumeMounts:
- mountPath: /var/jenkins_home_bak
name: jenkins-home-bak
- name: "revert-data"
image: "{{ .Values.Master.Image }}:{{ .Values.Master.ImageTag }}"
imagePullPolicy: "{{ .Values.Master.ImagePullPolicy }}"
command: [ "cp", "-r", "/var/jenkins_home_bak/jenkins_home", "/var"]
volumeMounts:
- mountPath: /var/jenkins_home_bak
name: jenkins-home-bak
- mountPath: /var/jenkins_home
name: jenkins-home
- name: "copy-default-config"
image: "{{ .Values.Master.Image }}:{{ .Values.Master.ImageTag }}"
imagePullPolicy: "{{ .Values.Master.ImagePullPolicy }}"
command: [ "sh", "/var/jenkins_config/apply_config.sh" ]
volumeMounts:
- mountPath: /var/jenkins_home
name: jenkins-home
- mountPath: /var/jenkins_config
name: jenkins-config
{{- if .Values.Master.CredentialsXmlSecret }}
- mountPath: /var/jenkins_credentials
name: jenkins-credentials
readOnly: true
{{- end }}
{{- if .Values.Master.SecretsFilesSecret }}
- mountPath: /var/jenkins_secrets
name: jenkins-secrets
readOnly: true
{{- end }}
{{- if .Values.Master.Jobs }}
- mountPath: /var/jenkins_jobs
name: jenkins-jobs
readOnly: true
{{- end }}
- mountPath: /usr/share/jenkins/ref/plugins/
name: plugin-dir
- mountPath: /usr/share/jenkins/ref/secrets/
name: secrets-dir
...
新增两个 initContainer,backup-data 用于将原镜像需要备份出来,revert-data 再将数据还原,再通过 copy-default-config 自定义 config.xml 和执行脚本
注意事项
Jenkins config.xml 中使用的 securityRealm
是 hudson.security.LegacySecurityRealm
会导致使用 Jenkins API 请求报错,如果需要通过 Jenkins API 操作,需修改为如下:
<securityRealm class="hudson.security.HudsonPrivateSecurityRealm">
<disableSignup>true</disableSignup>
<enableCaptcha>false</enableCaptcha>
</securityRealm>
但是修改成上面这种方式,会导致无法通过 values.yaml 自定义 Jenkins 账号密码,只能使用原镜像初始化的账号密码。
疑问
为什么不适用 docker commit 构建自定义镜像
对于 docker commit 命令,几乎所有的操作都围绕着可读可写层(Read-Write Layer),一次 commit 将可读可写层打包为一个全新的镜像,同时也保证镜像之间的独立性。当然,由于一个镜像同时包含镜像层文件系统内容和镜像 json 文件,因此对于一个 commit 操作,Docker Daemon 还会为镜像产生一个全新的 json 文件。
对于 Docker 容器而言,文件系统视角包含的内容有 Docker 镜像构成的内容(一个可读可写层加上多个只读层)、数据卷 VOLUME 挂载的目录内容,还有类似于 hosts、hostsname 和 resolv.conf 等挂载文件,当然还有一些如 /proc 和 /sys 等虚拟文件系统的内容。commit 操作只专注于可读可写层(Read-Write Layer),因此其他的内容都将不会出现在打包后的镜像中。举例说明,类似于 Jenkins 的数据容器,由于其自身的数据一般都持久化到数据卷 VOLUME 中,因此 Jenkins 在运行过程中产生的数据将不会在 commit 操作后被打包进入镜像。
总结
我们通过定制化 Jenkins 镜像,将业务平台需要的数据初始化进镜像中,能帮我们达到开箱即用的目的,无需人工介入,即降低人工成本,也减少人工出错的概率。